1. /health: Prüfen, ob der Server lebt
Wenn Sie den Server gerade hochfahren, möchte das Gehirn sehr gerne der Zeile im Log „Server started…“ glauben. Aber die Backend-Realität ist hart: Der Prozess kann leben, während der Endpunkt nicht antwortet, oder falsch antwortet, oder einfach hängt. /health ist unser erster ehrlicher „Ping“ über HTTP, der genau das prüft, wofür wir den HttpServer gestartet haben: die Fähigkeit, eine Anfrage anzunehmen und eine korrekte Antwort zurückzugeben.
Nach einem start() lebt der Server bereits als Prozess, aber von außen ist er noch stumm. Wir brauchen mindestens einen Pfad, der beweist: Der Kontext ist registriert, der Handler funktioniert und die Antwort geht über HTTP raus, nicht nur ins Log.
In realen Systemen wird /health oft für automatische Prüfungen verwendet (Monitoring, Load Balancer, Container und all das Erwachsene). Wir gehen heute nicht so weit, aber die Idee nehmen wir mit: Wir brauchen einen Endpunkt, der nicht von der Geschäftslogik abhängt, damit er ein stabiler Prüfpunkt ist. Für ein Übungsprojekt ist es auch psychologisch angenehm: Sie sehen, dass der Server JSON antwortet, also ist die nächste Stufe mit Routing und eingehenden Anfragen nicht mehr „ins Leere“.
Um Ordnung im Projekt zu halten, ist es sinnvoll, gleich zu entscheiden, wo dieser Endpunkt leben soll. Logischerweise hält man ihn neben dem Start der lokalen API und nicht innerhalb der readinglist-Funktion, denn /health geht nicht um die Leseliste, sondern um die Anwendung als Service.
Eine kleine Orientierung zu den Dateien (eine Variante, die zu unserer Struktur passt):
Datei: /src/main/java/com/example/readlater/
# Verzeichnisstruktur, in der der lokale API-Server lebt
# (ASCII-Verzeichnisbaum, um die Dateilagen besser zu erfassen)
src/main/java/com/example/readlater
├─ app/server
│ ├─ HealthResponse.java
│ └─ LocalApiServer.java
└─ config
└─ ServerConfig.java
2. Vertrag von /health: Status und JSON
Wenn man sagt „der Endpunkt antwortet“, denken Anfänger oft nur an den Body: „Na ja, der Text ist angekommen, also ist es in Ordnung.“ Aber ein HTTP-Vertrag besteht aus mehreren Teilen, und wir sind nicht mehr auf dem Niveau „Hauptsache, irgendwas wird im Browser angezeigt“. /health muss vorhersagbar antworten: mit dem richtigen Status, dem richtigen Content-Type und einem JSON mit fester Form, damit jeder Client (Postman, Browser, Ihr eigener HttpClient) versteht, was ihm geschickt wurde.
Wir legen einen sehr einfachen Vertrag fest. Wir geben 200 OK und ein JSON der folgenden Form zurück:
{
"status": "UP",
"appName": "readlater-starter"
}
Dabei ist status ein technisches Feld, und appName ein nützlicher Marker, dass die Konfiguration übernommen wurde und wir genau mit der Anwendung antworten, die erwartet wird. Wenn Sie jemals parallel zwei Dienste auf verschiedenen Ports betreiben, werden Sie solche Kleinigkeiten schnell zu schätzen wissen.
Es ist praktisch, den Vertrag einmal in einer Tabelle zu sehen, um nicht alles im Kopf behalten zu müssen:
| Antwortteil | Was zurückgegeben wird | Warum das wichtig ist |
|---|---|---|
| Statuscode | 200 | Der Client muss nicht raten: „lebendig“ = Erfolg |
| Header Content-Type | application/json; charset=utf-8 | Der Client versteht, dass der Body JSON ist und wie er dekodiert wird |
| Body | JSON mit status und appName | Daten in vorhersagbarer Form, nicht „zufälliger String“ |
Beachten Sie das charset. Ja, JSON ist standardmäßig meist UTF‑8, aber wir lernen hier, den Vertrag explizit zu schreiben, um nicht von den Annahmen des Clients oder den „Standardeinstellungen“ auf einem bestimmten Rechner abhängig zu sein.
3. Antwort-DTO: HealthResponse
Man hat großen Drang (besonders nach ein paar Tagen Java Core), es so zu machen: „Ich gebe einfach einen String zurück.“ Oder „Ich lege ein paar Felder in eine Map.“ Es wird tatsächlich funktionieren … bis zum ersten Moment, in dem Sie ein drittes Feld hinzufügen, die Antwort wiederverwenden oder das erwartete JSON mit dem tatsächlichen vergleichen möchten.
Deshalb machen wir genau das, was wir früher für Transportmodelle gemacht haben: Wir führen ein kleines Antwort-DTO ein. In Java 25 ist ein record das ideale Format für solche Objekte: kurz, klar und ohne überflüssige Getter/Setter. Und, was angenehm ist: ObjectMapper arbeitet gut mit record zusammen (und das sieht ordentlich aus).
Wir erstellen eine Trägerklasse für die Antwort:
package com.example.readlater.app.server;
// DTO der Antwort des Endpunkts /health.
// Hier gibt es keine Logik: es ist Teil des API-Vertrags (JSON-Struktur).
public record HealthResponse(String status, String appName) {
}
Beachten Sie, wie „leichtgewichtig“ es ist: zwei Felder und sonst nichts. Es gibt keine Geschäftslogik, keine Validierung, keine Methoden. Es ist ein reines Antwortmodell, also ein Teil des Vertrags.
Wenn man etwas mehr Disziplin möchte, kann man vereinbaren, dass status immer der String "UP" ist. In einer strengeren Welt würden wir einen enum oder eine Konstante verwenden, aber heute geht es uns darum, die Web-Mechanik zu üben, nicht über Strings zu streiten.
4. JSON mit ObjectMapper
JSON durch String-Konkatenation zusammenzustellen ist, als würde man einen Laptop mit einem Hammer reparieren: Manchmal hilft es wirklich, aber dann wundert man sich, warum die Tasten an der Wand kleben. Fehler sind dort fast garantiert: Anführungszeichen, Escaping, Kommas, null, Kodierungen. Außerdem ist es einfach unangenehm zu lesen.
Daher verwenden wir für /health sofort dasselbe Werkzeug, das bereits im Projekt vorhanden ist: ObjectMapper. Es kann unser HealthResponse in JSON serialisieren. Wichtig ist, dass man ObjectMapper normalerweise einmal erstellt und wiederverwendet: Er ist threadsicher für Lese-/Schreibzugriffe (wenn Sie die Einstellungen nicht im laufenden Betrieb ändern), und seine Erstellung ist keine kostenlose Operation.
Ich zeige zunächst die Idee an einem Mini-Beispiel (im luftleeren Raum), später bauen wir sie in den Handler ein.
import com.fasterxml.jackson.databind.ObjectMapper;
// Unser DTO, das wir an den Client übergeben möchten
HealthResponse dto = new HealthResponse("UP", "readlater-starter");
// ObjectMapper wird normalerweise einmal erstellt und wiederverwendet,
// aber für dieses „luftleere" Beispiel erstellen wir ihn hier direkt.
ObjectMapper mapper = new ObjectMapper();
// Serialisierung des DTO nach JSON (als String)
String json = mapper.writeValueAsString(dto);
// json wird ungefähr so aussehen:
// {"status":"UP","appName":"readlater-starter"}
Aber im echten Server-Code ist es praktischer, nicht mit einem String, sondern direkt mit Bytes zu arbeiten. Denn HttpExchange sendet letztendlich immer Bytes, und wir brauchen eine korrekte Länge der Antwort. ObjectMapper bietet die angenehme Methode writeValueAsBytes(...).
5. Antwort über HttpExchange
HttpExchange ist Ihr „Briefumschlag“ für eine Anfrage. Es enthält die Anfrage, und über es müssen Sie die Antwort senden. Das Wichtigste, was man sich hier merken muss: Die Antwort hat eine bestimmte Aufbaureihenfolge. Wenn man die Schritte vertauscht, stürzt der Server entweder ab, sendet eine seltsame Antwort oder der Client hängt in der Warteschleife.
Die Logik ist: Sie bereiten den Body (in Bytes) vor, setzen die Header, senden den Status und die Länge über sendResponseHeaders(...) und schreiben dann die Bytes in getResponseBody() und schließen den Stream unbedingt. Das Schließen des Streams ist im Wesentlichen der „Punkt“, nach dem der Client die Antwort als abgeschlossen betrachten kann. Wie im richtigen Leben: Solange der Brief nicht verschlossen ist, bekommt der Empfänger nur einen Durchzug.
Lassen Sie uns einen funktionierenden Handler für /health zusammenstellen. Ich zeige ihn als Methode handleHealth(...), die vom Kontext-Handler aufgerufen wird. Dies ist keine neue parallele Klasse, sondern die nächste Version desselben LocalApiServer: Jetzt bindet er sich nicht nur an eine Adresse, sondern kann auch die erste technische JSON-Antwort liefern.
Datei: /src/main/java/com/example/readlater/app/server/LocalApiServer.java
import com.example.readlater.config.ServerConfig;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
import java.io.OutputStream;
public final class LocalApiServer {
private final ObjectMapper objectMapper;
public LocalApiServer(ObjectMapper objectMapper) {
// ObjectMapper wird von außen übergeben, um ihn nicht bei jeder Anfrage neu zu erstellen
this.objectMapper = objectMapper;
}
private void handleHealth(HttpExchange exchange, ServerConfig config) throws IOException {
// 1) Antwort-DTO vorbereiten (Teil des Vertrags)
HealthResponse response = new HealthResponse("UP", config.appName());
// 2) In Bytes serialisieren, um die genaue Länge der Antwort zu kennen
byte[] body = objectMapper.writeValueAsBytes(response);
// 3) Header VOR sendResponseHeaders setzen
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
// 4) Status und Länge der Antwort bekannt geben
exchange.sendResponseHeaders(200, body.length);
// 5) Body schreiben und Stream unbedingt schließen (try-with-resources)
try (OutputStream os = exchange.getResponseBody()) {
os.write(body);
}
}
}
Dieses Code-Stück ist fast der ganze „Kern“ des heutigen Themas. Hier sieht man mit eigenen Händen, was später vom Framework versteckt wird: Status, Header, Serialisierung, Länge, Schreiben in den Stream.
Lassen Sie uns die wichtigen Stellen durchgehen.
objectMapper.writeValueAsBytes(response) liefert uns die JSON-Bytes (normalerweise UTF‑8). Wir kennen sofort die Länge body.length, und können daher korrekt sendResponseHeaders(200, body.length) aufrufen. Stimmt die Länge nicht mit dem überein, was Sie tatsächlich gesendet haben, kann der Client eine abgeschnittene Antwort erhalten oder hängen bleiben (er erwartet mehr Bytes, während Sie den Stream bereits geschlossen haben).
exchange.getResponseHeaders() setzt den Header. Wenn man ihn vergisst, zeigen Postman und Browser oft trotzdem den Body an, aber der Client-Code könnte die Antwort als text/plain interpretieren und sich anders verhalten als erwartet. Für den einfachen /health ist das nicht tödlich, aber wir bauen ja Gewohnheiten auf, nicht nur „es funktioniert bei mir“.
try (OutputStream os = exchange.getResponseBody()) – das ist ein Must-have für Anfänger. Sie schließen den Stream garantiert, selbst wenn in der Mitte etwas schiefgeht. Ohne Schließen funktioniert es manchmal „irgendwie“, aber manchmal wartet der Client, als hätten Sie ihm noch ein paar Kilobyte Lebenssinn versprochen.
Um die Reihenfolge zu festigen, hier ein sehr einfaches Schema:
flowchart TD
%% Minimales „Ritual“ zum Erstellen einer Antwort in HttpServer
%% Wichtig: Header und sendResponseHeaders kommen vor dem Schreiben des Body
A["HealthResponse-DTO erstellen"] --> B["In JSON-Bytes serialisieren"]
B --> C["Content-Type setzen"]
C --> D["sendResponseHeaders(status, length)"]
D --> E["Bytes in responseBody schreiben"]
E --> F["OutputStream schließen"]
Sie können dies als das minimale „Ritual“ jedes Endpunkts auf HttpServer betrachten: DTO und Status ändern sich, aber die Schritte sind fast immer die gleichen.
Ab diesem Zeitpunkt ist es sinnlos, mit dem Code zu diskutieren – er muss durch eine externe Anfrage überprüft werden. Der Client muss genau die Status, Header und Body sehen, die wir gerade von Hand zusammengestellt haben.
6. /health einbinden
Der Handler selbst ist die eine Hälfte der Arbeit. Die andere Hälfte ist, dafür zu sorgen, dass der Server überhaupt weiß, dass es den Pfad /health gibt, und welcher Code ihn bedienen soll. In HttpServer geschieht das über createContext(path, handler). Diese Idee haben wir bereits in der letzten Vorlesung gesehen, und jetzt führen wir sie zu einer vollwertigen JSON-Antwort.
Wichtig ist, nicht ins Chaos zu verfallen, in dem Handler sich ihren eigenen ObjectMapper erstellen und appName als String "my-app" hardcodiert wird (und Sie dann Gespenster in den Logs jagen). Deshalb übergeben wir Abhängigkeiten explizit: LocalApiServer bekommt den ObjectMapper im Konstruktor, und ServerConfig kommt in start() und wird dann an handleHealth() weitergereicht.
Ich zeige einen minimalen Ausschnitt von start(), in dem /health registriert wird:
Datei: /src/main/java/com/example/readlater/app/server/LocalApiServer.java
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
public HttpServer start(ServerConfig config) throws IOException {
// Adresse und Port aus der Konfiguration (nicht hartcodiert)
InetSocketAddress address = new InetSocketAddress(config.host(), config.port());
HttpServer server = HttpServer.create(address, 0);
// Endpoint /health registrieren und config an den Handler übergeben
server.createContext("/health", exchange -> handleHealth(exchange, config));
// Server starten
server.start();
return server;
}
Hier ist der Endpunkt zunächst nur an den Pfad gebunden. Jede Anfrage an /health landet in diesem Handler; eine separate Methodenprüfung brauchen wir jetzt nicht. Für diesen Teil ist wichtig: Der Pfad ist registriert, der Handler wird aufgerufen, und das korrekte JSON geht nach außen.
Beachten Sie: Hier ist /health direkt lesbar. Das ist am Anfang wichtig. Wir bauen noch keinen Router, führen keine „Routentabelle“ ein und machen keinen Mini-Spring im Alleingang. Unser Ziel ist, dass der Student die Datei öffnen und verstehen kann: Hier ist der Pfad, hier der Handler, hier was er tut.
Wenn man den Code noch etwas lesbarer machen möchte, kann man die Registrierung von /health in eine separate Methode registerHealthEndpoint(server, config) auslagern. Aber das ist Geschmackssache – Hauptsache, der Mechanismus ist transparent.
7. Typische Fehler bei der Arbeit mit /health
Fehler Nr. 1: JSON manuell mit Strings zusammenbauen.
Es beginnt fast immer mit einem harmlosen "{\"status\":\"UP\"}" und endet damit, dass Sie einen halben Tag nach einem „überflüssigen Komma“ oder einer plötzlich kaputten Escape-Sequenz suchen, wenn in appName ein Bindestrich, Leerzeichen oder ein deutscher Buchstabe auftaucht. Selbst für zwei Felder ist es besser, sofort ObjectMapper zu verwenden und /health nicht in einen Wettbewerb um die zerbrechlichste String-Beschwörung zu verwandeln.
Fehler Nr. 2: Vergessen, Content-Type zu setzen, oder „fast JSON“ setzen.
Wenn man JSON zurückgibt, aber Content-Type: text/plain stehen lässt, zeigen viele Clients den Inhalt trotzdem an, aber nicht alle behandeln ihn als JSON-Daten. Besonders wenn Sie später den Vertrag programmatisch prüfen. application/json; charset=utf-8 ist eine einfache Gewohnheit, die Zeit bei seltsamen „Warum meckert der Parser“ spart.
Fehler Nr. 3: Die Reihenfolge von sendResponseHeaders(...) und dem Schreiben des Body vertauschen.
HttpExchange erwartet klar: Zuerst deklarieren Sie Status und Länge, dann schreiben Sie den Body. Wenn Sie früher mit dem Schreiben des Body beginnen, können Sie eine Ausnahme oder eine „kaputte“ Antwort erhalten. Das ist einer dieser Fehler, die besonders nerven, weil sie so aussehen: „Ich habe doch nur in den Stream geschrieben“.
Fehler Nr. 4: Falsche Länge in sendResponseHeaders(...).
Wenn Sie eine Länge übergeben, die nicht den tatsächlich gesendeten Bytes entspricht, kann der Client ein abgeschnittenes JSON erhalten oder hängen bleiben. Deshalb entweder vorher byte[] besorgen und body.length verwenden oder (wenn Sie sich schon sicher fühlen) den Chunked-Response-Modus nutzen. Auf dem aktuellen Stand ist der zuverlässigste und klarste Weg body.length.
Fehler Nr. 5: exchange.getResponseBody() nicht schließen (oder try-with-resources vergessen).
Manchmal funktioniert der Code „irgendwie“, weil die JVM Ressourcen beim Beenden des Threads oder am Ende der Anfrage freigibt. Aber manchmal wartet der Client auf das Ende der Antwort, während Sie den „Punkt“ nicht gesetzt haben – den Stream nicht geschlossen. try-with-resources ist hier nicht nur „Schönheit“, sondern eine Garantie, dass die Antwort korrekt abgeschlossen wird.
GO TO FULL VERSION