CodeGym /Kurse /Java Server /Auslagerung des CatalogClient

Auslagerung des CatalogClient

Java Server
Level 15, Lektion 4
Verfügbar

1. Transportcode und main()

main() ist wie der Flur einer Wohnung: man kann dort Schlüssel und Jacke lassen, aber wenn man dort Borschtsch kocht, Hemden bügelt und Fahrräder repariert, wird das Wohnen unbequem. Transportcode mit URI, Headern, Timeouts, send() und Fehlerbehandlung verwandelt main() sehr schnell in eine „Allzweckmaschine“. Und das Unangenehmste ist nicht nur, dass es hässlich ist: Dadurch wird es schwieriger, Code zu ändern, neue Anfragen hinzuzufügen und zu verstehen, wo die Anwendungslogik endet und die Netzwerklogik beginnt.

Stellen Sie sich eine typische frühe Version vor (sie funktioniert, ist aber traurig):

import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;

// HttpClient wird hier als irgendwo erstellt angenommen (z.B. über HttpClient.newBuilder()).
String query = "clean code";
String baseUrl = "https://catalog.example";

// Wir kodieren nur den Wert des query-Parameters, nicht die gesamte URL.
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
URI uri = URI.create(baseUrl + "/search?q=" + encoded);

HttpResponse<String> response = client.send(
        // Transport-Details (HttpRequest, send, BodyHandlers) „leben“ bereits in main().
        HttpRequest.newBuilder(uri).GET().build(),
        HttpResponse.BodyHandlers.ofString()
);
System.out.println(response.body()); // { ...JSON... }

Das Problem ist hier nicht, dass der Code „schlecht“ ist. Er ist einfach nicht an seinem Platz. Sobald ein zweiter Endpunkt, ein dritter hinzukommt, Statusbehandlung, Timeouts und sich wiederholende Header – und main() verwandelt sich in eine Schriftrolle, die auszurollen Angst macht.

Aber es geht nicht nur um „reines main()“. An dieser Stelle haben sich ganz konkrete Transportregeln angesammelt: akkurate URI, expliziter HttpRequest, Timeouts und die harte Grenze 2xx/non-2xx. CatalogClient wird benötigt, damit all diese Entscheidungen an einem Ort leben und sich nicht über die Anwendung verteilen.

2. Transportschicht im Projekt

Die Transportschicht ist der Teil des Codes, der „HTTP sprechen kann“: die Anfrageadresse zusammenbauen, Methode und Header festlegen, die Anfrage senden, die Antwort empfangen und Erfolg von Fehler anhand des Status unterscheiden. Sie muss nicht wissen, was das Feld title in JSON bedeutet, und nicht entscheiden, was dem Benutzer gezeigt wird. Ihre Aufgabe ist es, Daten über das Netzwerk zu liefern und sie in einer vorhersagbaren Form zurückzugeben (oder mitzuteilen, dass dies nicht gelungen ist).

Um den Überblick zu behalten, ist es hilfreich, eine einfache Verantwortungsgrenze im Kopf zu haben. Unten eine kleine Tabelle (ja, eine Tabelle rettet manchmal das Gehirn besser als tausend Zeilen Code):

Frage Wo lösen wir? Beispiel
Welche URL aufrufen und wie query kodieren? CatalogClient /search?q=... + URLEncoder
Welche Header setzen? CatalogClient Accept: application/json
Welche Timeouts einstellen? HttpClient und HttpRequest connectTimeout, request.timeout(...)
Was tun bei Status 404 oder 500? CatalogClient (auf Ebene der Transportregeln) „Das ist kein Erfolg, wir geben Body nicht als Ergebnis“
Wie JSON in DTO/Objekte umwandeln? Mapping-Schicht über dem Transport-Client Transport-Code gibt raw string zurück
Wie Ergebnis dem Benutzer zeigen? ReadLaterApplication / App-seitige Logik Ausgabe, Formatierung

Die Hauptidee: Die Transportschicht ist der „Kurier“. Er soll ein Paket liefern oder mitteilen, warum es nicht geklappt hat. Er soll nicht entscheiden, was Sie aus den Produkten im Paket kochen werden.

3. Gerüst von CatalogClient: Abhängigkeiten und Methoden

Wenn Sie eine Klasse CatalogClient erstellen, sollte sie nicht wie ein „Set von HTTP-Utilities“ aussehen, sondern wie eine kleine API Ihrer Anwendung. Das heißt, sie sollte Methoden haben, die so klingen, wie Sie über Szenarien denken: „Suche“ und „Details“, nicht „sendRequestAndParseHeadersAndMaybeCry()“. Und, wichtig, diese Klasse sollte explizite Abhängigkeiten haben: HttpClient und baseUrl. Dann ist sie vorhersagbar und wiederverwendbar.

Beginnen wir mit einem minimalen Gerüst. Hier bleibt roher JSON-String als Ausgabe: CatalogClient ist für das Netzwerk und die Grenzen der Transportschicht verantwortlich, nicht für das Mapping.

import java.net.http.HttpClient;

public class CatalogClient {
    // HttpClient von außen übergeben: wiederverwendbar und einmal konfiguriert.
    private final HttpClient client;

    // baseUrl ist die „Adresse des Dienstes“, nicht eines bestimmten Endpunkts.
    private final String baseUrl;

    public CatalogClient(HttpClient client, String baseUrl) {
        this.client = client;
        // Normalisieren baseUrl einmal, um später doppelte Schrägstriche zu vermeiden.
        this.baseUrl = normalizeBaseUrl(baseUrl);
    }
}

Jetzt fügen wir öffentliche Methoden hinzu. Sie sollen vor dem Rest der Anwendung verbergen, dass es intern /search und /books/{id} gibt.

public String searchRaw(String query) {
    // Grenze der Transportschicht: Eingang ist ein „semantischer“ Parameter, Ausgang ist die Antwort des Dienstes als String.
    return "...";
}

public String detailsRaw(String externalId) {
    // externalId ist die externe ID, die der Katalog versteht.
    return "...";
}

Und ja, das Suffix Raw ist ein ehrliches Schild „am Ausgang liegt rohes JSON vor“. Es hilft, nicht zu vergessen, dass die Transportgrenze hier mit der Antwortzeichenkette endet, nicht mit bereits geparsten Daten.

4. Zusammenbau von URI innerhalb des Clients

Der tückischste Bug in HTTP-Code sieht oft so aus: „Nun … ich habe einfach eine Zeichenkette zusammengebaut, was kann schon schiefgehen?“ Und dann stellt sich heraus, dass alles schiefgegangen ist: Leerzeichen, &, ?, überflüssige Schrägstriche, unerwartete Zeichen. Deshalb ist der Zusammenbau von URI eine hervorragende Sache, um ihn innerhalb von CatalogClient zu zentralisieren. Dann werden die Regeln „wie wir die Adresse bauen“ nicht über das Projekt verteilt.

Fügen wir private Methoden buildSearchUri und buildDetailsUri hinzu. Beachten Sie: Wir kodieren den Wert des query-Parameters, nicht die gesamte URL.

import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;

private URI buildSearchUri(String query) {
    // Kodieren nur den Wert des query-Parameters.
    String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8);
    return URI.create(baseUrl + "/search?q=" + encodedQuery);
}

private URI buildDetailsUri(String externalId) {
    // Hier wird externalId in das Pfadsegment eingesetzt.
    // Wenn externalId Sonderzeichen enthalten kann, besser separate Kodierung des Pfads bedenken.
    return URI.create(baseUrl + "/books/" + externalId);
}

Hier steckt ein kleiner, aber wichtiger praktischer Sinn: ReadLaterApplication wird nie mehr wissen, dass die Suche "/search?q=" ist. Für den Einstiegspunkt der Anwendung heißt es einfach „Suche aufrufen“, und fertig.

Ein weiterer Mikro-Nuance: Wenn Ihr baseUrl mit einem Schrägstrich am Ende kommen kann (https://catalog.example/), dann ergibt baseUrl + "/search" einen doppelten Schrägstrich. In CatalogClient fängt man das besser einmal im Konstruktor ab und arbeitet dann mit der normalisierten Adresse.

private static String normalizeBaseUrl(String baseUrl) {
    // Entfernen abschließenden '/', um später kein '//' beim Zusammenfügen zu erhalten.
    return baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
}

5. Zusammenbau von HttpRequest im Client

Die Anfrage sollte sichtbare Grenzen haben: Methode, Header und Body sind keine nebensächlichen Details, sondern Teil des Vertrags. Die gute Nachricht: All dies kann in CatalogClient versteckt werden, sodass außen Einfachheit bleibt und innen Disziplin herrscht. Das wichtigste Werkzeug ist HttpRequest.Builder, das von oben nach unten wie eine Liste von Entscheidungen gelesen wird.

Beginnen wir mit einer kleinen Hilfsmethode: Wir wollen der entfernten API immer mitteilen, dass wir JSON erwarten. Dazu setzen wir in jeder Anfrage Accept: application/json.

Der allgemeine HttpClient ist für connectTimeout zuständig, der konkrete HttpRequest für seinen eigenen timeout. Daher lebt das request-level-Timeout direkt hier, zusammen mit Methode und Headern.

import java.net.URI;
import java.net.http.HttpRequest;
import java.time.Duration;

private HttpRequest buildGetJson(URI uri) {
    return HttpRequest.newBuilder(uri)
            // Explizit festlegen, „was wir erhalten wollen“ – das ist Teil des Vertrags.
            .header("Accept", "application/json")
            .timeout(Duration.ofSeconds(3)) // Begrenzen die Wartezeit auf Antwort auf Ebene der spezifischen Anfrage
            .GET()
            .build();
}

Jetzt werden searchRaw und detailsRaw fast „literarisch“ – man liest sie auch nach einer Woche gerne:

public String searchRaw(String query) {
    URI uri = buildSearchUri(query);
    HttpRequest request = buildGetJson(uri);
    return sendForString(request);
}

public String detailsRaw(String externalId) {
    URI uri = buildDetailsUri(externalId);
    HttpRequest request = buildGetJson(uri);
    return sendForString(request);
}

Und wenn der Client einen POST mit JSON-Body bekommt, bleibt das Prinzip gleich: Transport-Details leben innerhalb von CatalogClient, nicht verteilt über main().

import java.net.URI;
import java.net.http.HttpRequest;
import java.time.Duration;

private HttpRequest buildPostJson(URI uri, String jsonBody) {
    return HttpRequest.newBuilder(uri)
            // Accept – was wir erhalten wollen, Content-Type – was wir senden.
            .header("Accept", "application/json")
            .header("Content-Type", "application/json")
            .timeout(Duration.ofSeconds(3)) // Für POST gilt die gleiche Warteregel: Die Anfrage darf nicht ewig hängen
            // Den Anfragetext bilden wir hier, damit der restliche Code nichts über BodyPublishers weiß.
            .POST(HttpRequest.BodyPublishers.ofString(jsonBody))
            .build();
}

Beachten Sie, wie schön: ReadLaterApplication muss überhaupt nicht wissen, was BodyPublishers sind. Es muss nur wissen: „Ich möchte ein Szenario ausführen.“

6. sendForString(...): Erfolg und Fehlerbehandlung

Wenn der Transportcode sich ausbreitet, dann meistens genau in diesem Teil: An einer Stelle prüft man nur == 200, an anderer Stelle vergisst man, InterruptedException zu behandeln, an dritter Stelle fängt man „für alle Fälle“ Exception und tut dann so, als sei alles gut. Deshalb brauchen wir hier eine Methode, die weiß: wie man die Anfrage sendet, wie man entscheidet „Erfolg oder nicht“ und was in den wichtigsten Fehlerzweigen zu tun ist.

Zuerst ein kleiner Helfer, um nicht überall magische Bedingungen zu schreiben:

private boolean is2xx(int statusCode) {
    return statusCode >= 200 && statusCode < 300;
}

Wir teilen das in kleine Stücke, damit die Transportregel direkt aus dem Code lesbar ist. Der Sinn: Wenn wir eine HttpResponse erhalten haben, ist der Transportschritt abgeschlossen, aber der Erfolg wird durch den Status bestimmt. Und wenn wir eine Exception gefangen haben, könnte es überhaupt keine Antwort gegeben haben.

Und hier ist es wichtig, keinen zweiten Vertrag zu erfinden. Wenn HttpResponse bereits angekommen ist, der Status aber nicht 2xx ist, sollte der Client den Status in CatalogApiException speichern; wenn es gar keine Antwort gab, ist das ein anderer Zweig.

import java.io.IOException;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;

private String sendForString(HttpRequest request) {
    try {
        // Einheitlicher Punkt zum Senden der Anfrage: So werden die Transportregeln nicht im Projekt verteilt.
        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
        return extractBodyOrThrow(response);
    } catch (HttpTimeoutException e) {
        // Timeout separat unterscheiden: Antwort kam nicht in angemessener Zeit.
        throw new IllegalStateException("Katalog antwortet zu lange (Timeout)", e);
    } catch (IOException e) {
        // Netzwerkprobleme: DNS, Verbindungsabbruch, Host nicht erreichbar usw.
        throw new IllegalStateException("Netzwerkfehler beim Aufruf des Katalogs", e);
    } catch (InterruptedException e) {
        // Wichtig: Interrupt-Flag wiederherstellen, sonst „verlieren“ wir den Interrupt.
        Thread.currentThread().interrupt();
        throw new IllegalStateException("Anfrage wurde unterbrochen", e);
    }
}

Und hier ist extractBodyOrThrow. Wir brauchen eine Exception, die den status nicht verliert und später in eine verständliche Nachricht für den Benutzer umgewandelt werden kann.

private String extractBodyOrThrow(HttpResponse<String> response) {
    int status = response.statusCode();
    if (is2xx(status)) {
        return response.body();
    }

    throw new CatalogApiException(status, response.body());
}

Ja, das sieht streng aus: Jeder non-2xx wird zu CatalogApiException. Aber auf der Ebene der Transportschicht ist das ehrlich. CatalogApiException bedeutet „Antwort kam an, aber der Status ist schlecht“, während IllegalStateException aus sendForString(...) bedeutet „Wir sind überhaupt nicht zu einer Antwort gelangt, mit der man arbeiten kann“. Das heißt nicht, dass die Anwendung abstürzen muss. Es heißt, dass ein erfolgreiches Ergebnis nicht so tut, als wäre es erfolgreich, wenn es nicht erfolgreich ist. Wie genau dieser Fehler dem Benutzer gezeigt wird, kann an der Grenze der Anwendung entschieden werden.

Beachten Sie die Reihenfolge von catch: HttpTimeoutException kommt vor IOException, sonst würde der Timeout im allgemeinen Netzwerkzweig untergehen. Und gesondert betont sei InterruptedException: „Einfach schlucken“ ist eine schlechte Idee. Wir stellen das Interrupt-Flag korrekt über Thread.currentThread().interrupt() wieder her und übersetzen den Fehler in eine verständliche Form.

7. ReadLaterApplication nach Auslagerung des Clients

Nachdem Sie den Transportcode ausgelagert haben, kehrt der Einstiegspunkt der Anwendung zum normalen Leben zurück. Er soll drei Dinge tun: verstehen, welches Szenario gestartet wurde, Abhängigkeiten zusammenstellen und das Ergebnis dem Benutzer zeigen. Alles andere ist nicht seine Aufgabe.

Im Happy-Path verbleiben am Einstiegspunkt nur noch das Zusammenstellen der Abhängigkeiten und das Starten des Szenarios. main() kennt weder /search noch URLEncoder noch die Behandlung von statusCode().

Der allgemeine HttpClient hält connectTimeout, und CatalogClient fügt intern den request-Timeout, den Zusammenbau der Anfragen und die Grenze 2xx/non-2xx hinzu. Wenn der Client CatalogApiException oder einen Transportfehler wirft, entscheidet der Einstiegspunkt, wie genau dies dem Benutzer gezeigt wird.

import java.net.http.HttpClient;
import java.time.Duration;

public class ReadLaterApplication {
    public static void main(String[] args) {
        HttpClient httpClient = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(2))
                .build();

        CatalogClient catalog = new CatalogClient(httpClient, "https://catalog.example");
        String json = catalog.searchRaw("clean code");

        System.out.println(json); // { ...JSON... }
    }
}

Natürlich werden Sie in einem realen Szenario einen Befehl catalog search ... und Argumente haben. Aber darum geht es nicht. Es geht darum, dass jeder Befehl den gleichen Stil haben wird: „Client erstellt → entsprechende Methode aufgerufen → Ergebnis erhalten“. Wenn Sie das so machen, beginnt das Projekt zu wachsen, ohne dass Sie das Gefühl haben, Spaghetti zu schreiben.

Um das Bild zu festigen, ist es nützlich, dieses kleine Architekturstück als einfaches Schema darzustellen:

flowchart LR
    A["ReadLaterApplication
main()"] --> B["CatalogClient
Transportschichtgrenze"] B --> C["HttpClient
JDK"] C --> D["External Catalog API
HTTP"] D --> C C --> B B --> A

Dieses Schema ist einfach, aber es diszipliniert bereits: Der externe Vertrag lebt innerhalb von CatalogClient, und die Anwendung kommuniziert mit ihm über zwei verständliche Methoden.

8. Typische Fehler bei der Auslagerung von CatalogClient

Fehler Nr. 1: Die Hälfte der Transportlogik in main() zu lassen und die Hälfte in CatalogClient zu verschieben.
Manchmal lagert ein Student send() in den Client aus, baut aber die URI in main() zusammen, „weil es schneller geht“. Das Ergebnis: Die Grenze verschwimmt – der Einstiegspunkt kennt immer noch Details der externen API. Besser einmal die Entscheidung treffen: Alles, was HTTP-Adressen, Header, Timeouts und Status betrifft, gehört in den Client.

Fehler Nr. 2: Für jede Anfrage einen neuen HttpClient zu erstellen.
HttpClient ist als wiederverwendbares Objekt konzipiert. Wenn Sie innerhalb von searchRaw() HttpClient.newHttpClient() schreiben – machen Sie den Code schwerer und das Verhalten weniger vorhersagbar. Richtig ist es, HttpClient einmal zusammenzustellen (z.B. in main()), den connectTimeout zu konfigurieren und ihn an CatalogClient zu übergeben.

Fehler Nr. 3: Eine non-2xx-Antwort als „fast erfolgreich“ zu betrachten und den Body als normales Ergebnis zurückzugeben.
Dies ist eine der gefährlichsten Formen der Selbsttäuschung: „Na, JSON ist doch gekommen, also ist alles in Ordnung.“ In der Praxis führt das dazu, dass Ihr Code anschließend den error payload so parst, als wären es Buchdaten. Die Regel sollte hart sein: erst Status, dann Body. Nicht umgekehrt.

Fehler Nr. 4: Exception abzufangen und so zu tun, als sei alles unter Kontrolle.
catch (Exception e) in der Transportschicht ist wie die Kontrollleuchte „Check engine“ mit Klebeband abzukleben. Das Auto fährt, aber Sie wissen nicht mehr, was genau kaputt ist. Uns sind zumindest grundlegende Unterschiede wichtig: Timeout, IOException und Unterbrechung. Und es ist besonders wichtig, das Interrupt-Flag bei InterruptedException wiederherzustellen.

Fehler Nr. 5: null oder einen leeren String bei Fehlern zurückzugeben.
Das sieht „bequem“ aus, bis es unbequem wird. Dann bekommen Sie eine NullPointerException an einer ganz anderen Stelle und beginnen ein Ratespiel „Warum null?“. In der Transportschicht ist es besser, eine Exception mit einer verständlichen Nachricht und Ursache zu werfen, als so zu tun, als sei alles erfolgreich verlaufen.

1
Aufgabe
Java Server, Level 15, Lektion 4
Gesperrt
Suche in einen separaten CatalogClient auslagern
Suche in einen separaten CatalogClient auslagern
1
Aufgabe
Java Server, Level 15, Lektion 4
Gesperrt
Zwei Szenarien in CatalogClient und kurzer CLI-Einstieg
Zwei Szenarien in CatalogClient und kurzer CLI-Einstieg
1
Umfrage/Quiz
HTTP-Client, Level 15, Lektion 4
Nicht verfügbar
HTTP-Client
Anfragen und Timeouts
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION