CodeGym /Kursy /Java Server /Routing po metodzie i ścieżce w HttpServer

Routing po metodzie i ścieżce w HttpServer

Java Server
Poziom 23, Lekcja 0
Dostępny

1. Od /health do API: potrzebny routing

Gdy /health zaczyna działać, pojawia się bardzo niebezpieczne uczucie: „No to już, serwer postawiony — teraz tylko dodawać logikę biznesową i będzie szczęście”. To mniej więcej tak, jakby po pierwszej udanej jajecznicy uznać, że biznes restauracyjny masz już w kieszeni. W rzeczywistości następny ból jest techniczny: serwer zaczyna dostawać różne żądania i trzeba zdecydować, który fragment kodu ma je obsłużyć.

Dopóki mieliśmy jeden endpoint, można było żyć według zasady „każde żądanie — tam”. Ale gdy tylko chcemy dodać /api/v1/reading-list, /api/v1/reading-list/{id}, a potem jeszcze metody modyfikacji — uderzamy w zadanie dispatchingu. Serwer przyjmuje jedno wejście: HttpExchange. A dalej trzeba rozłożyć to wejście do „koszyczków”: gdzie health, gdzie lista, gdzie konkretny element.

W dużych frameworkach (cześć, Spring MVC) robi to router/dispatcher: adnotacje, tablice tras, automatyczne dopasowywanie. W naszym pomostowym kursie robimy to ręcznie — i właśnie dlatego potem Spring przestaje wydawać się magią. On nie „czaruje”, on po prostu automatyzuje to, co teraz zobaczymy wprost.

2. Trasa = method + path, a nie URL

W web developmencie nowicjusz często myśli tak: „Jest URL — czyli jest handler”. I to logiczne… jeśli całe życie widziałeś tylko przeglądarkę i linki. Ale dla API ścieżka to tylko połowa historii. Druga połowa to metoda HTTP. Ten sam path może oznaczać różne operacje i serwer musi je rozróżniać, inaczej dostaniesz „uniwersalny kombajn”, który robi wszystko naraz, ale w sposób nieprzewidywalny.

Trasa w naszym kontekście — to właśnie powiązanie:

method (GET, POST, PUT, PATCH, DELETE)
path (/health, /api/v1/reading-list, /api/v1/reading-list/42)

Jeśli spojrzeć na to jak na kontrakt, to path mówi „o jaki zasób chodzi”, a method mówi „co chcą z nim zrobić”. Dlatego trasa — to nie „string URL”, a kombinacja znaczeń.

Dla utrwalenia warto zobaczyć prostą tabelkę (i tak, to właśnie ta rzecz, którą frameworki trzymają u siebie w środku):

Method Path Znaczenie (po ludzku) Gdzie przekazać sterowanie
GET /health „Serwerze, żyjesz?” handleHealth(...)
GET /api/v1/reading-list „Daj listę” handleList(...) (na razie zaślepka)
GET /api/v1/reading-list/42 „Daj element 42” handleGetByIdRoute(...) (na razie tylko wybór trasy)

Zwróć uwagę na ważny moment: nawet jeśli dwie trasy zaczynają się tak samo (/api/v1/reading-list), są różne. I musimy nauczyć się rozróżniać „dokładne dopasowanie” i „ścieżkę ze zmiennym segmentem”.

3. Konteksty HttpServer i nasz router

W tym miejscu zwykle pojawia się małe rozczarowanie: HttpServer już potrafi coś podobnego do routingu — przez createContext(path, handler). Wydaje się, że można po prostu utworzyć kontekst dla każdego endpointa i żyć szczęśliwie. Ale rzeczywistość jest trochę smutniejsza (jak prawie zawsze w życiu): kontekst w JDK HttpServer — to routing tylko po ścieżce, i to w formacie „prefiks/kontekst”, a nie „tablica tras REST”.

Czyli HttpServer pomaga nam na zgrubnym poziomie: „wszystkie żądania na /health — tam”, „wszystkie żądania na /api/v1/reading-list — tu”. Ale wewnątrz jednego kontekstu i tak trzeba będzie rozdzielać:

różne metody HTTP dla tej samej ścieżki
sub-ścieżki w stylu /api/v1/reading-list/42

I tutaj pojawia się „nasz router” — zwykła logika Javy w HttpHandler, która czyta method i path oraz wybiera gałąź obsługi.

Dla kontekstu (bez wchodzenia głębiej) może to wyglądać tak:

import com.sun.net.httpserver.HttpServer;

// Rejestrujemy konteksty (routing po prefiksie ścieżki)
// Ważne: wewnątrz readingListHandler i tak trzeba będzie rozróżniać /... i /.../{id}
server.createContext("/health", healthHandler);
server.createContext("/api/v1/reading-list", readingListHandler);

Dalej readingListHandler będzie dostawał i żądanie na /api/v1/reading-list, i żądanie na /api/v1/reading-list/42. A więc to właśnie wewnątrz handlera potrzebujemy zrozumiałego routingu.

4. Wyciągamy method i path z HttpExchange

Teraz będzie mały, ale ważny moment z serii „tak, to są tylko stringi — i właśnie dlatego łatwo coś zepsuć”. Żeby zrouterować żądanie, musimy wyciągnąć z HttpExchange dwie rzeczy: metodę i ścieżkę. Robi się to dosłownie w dwóch linijkach, ale te dwie linijki potem będą w każdym handlerze, więc lepiej od razu traktować je jak podstawową „operację odczytu żądania”.

Oto minimalny fragment:

import com.sun.net.httpserver.HttpExchange;

// Metoda HTTP: "GET", "POST", ...
String method = exchange.getRequestMethod();

// Tylko path bez query-string (?a=b&c=d)
String path = exchange.getRequestURI().getPath();

Kilka komentarzy, żeby nie było niespodzianek. getRequestMethod() zwraca string w stylu "GET", "POST" — zwykle wielkimi literami. getRequestURI().getPath() zwraca tylko ścieżkę, bez query-stringa. Czyli dla /api/v1/reading-list?status=PLANNED dostaniesz path /api/v1/reading-list. To świetnie, bo dla routingu potrzebujemy właśnie path: query-parametry odpowiadają już nie za wybór gałęzi, a za warunki odczytu wewnątrz wybranego handlera.

Trochę bardziej „obronnym” nawykiem przy porównywaniu stringów jest zapisywanie stałej po lewej, żeby przypadkowy null nie urządził ci imprezy w stylu NullPointerException:

// Stała po lewej: jeśli method nagle okaże się null, porównanie nie wybuchnie
if ("GET".equals(method)) {
    // ...
}

Tak, metoda prawie nigdy nie jest null, ale nawyk jest przydatny: oszczędza komórki nerwowe, a tych, jak wiadomo, Java nie odbudowuje (żart, ale lepiej nie sprawdzaj na sobie).

5. Trasy stałe: dokładne dopasowanie

Gdy trasa jest stała, na przykład /health, najpewniejszym sposobem jest dokładne porównanie ścieżki. To banalne, ale właśnie banalność jest tu cenna: im mniej „sprytu”, tym mniejsza szansa, że przypadkowo dopasujesz nie to. W edukacyjnym API jest to szczególnie ważne, bo potrzebujemy przewidywalności, a nie „magii, która czasem zgaduje”.

Minimalny przykład trasy stałej:

// Odczytujemy "dane wejściowe" dla routingu
String method = exchange.getRequestMethod();
String path = exchange.getRequestURI().getPath();

// Dokładne dopasowanie po powiązaniu method + path
if ("GET".equals(method) && "/health".equals(path)) {
    // Delegujemy do konkretnej gałęzi handlera
    handleHealth(exchange);
    return; // Ważne: znaleźliśmy trasę — wychodzimy, żeby nie przelecieć niżej
}

Zwróć uwagę na return. To styl „guard clauses” (wczesne wyjścia): znaleźliśmy odpowiednią trasę — od razu przekazaliśmy sterowanie i wyszliśmy. Taki styl sprawia, że router czyta się z góry na dół jak scenariusz.

Dlaczego nie sprawdzamy tylko path? Bo jutro (albo nawet dziś wieczorem, gdy trochę się rozluźnisz) zechcesz zrobić POST /health „dla testu”. Jeśli sprawdzasz tylko ścieżkę, dostaniesz dziwne zachowanie: serwer spróbuje obsłużyć to jako health. A jeśli sprawdzasz method + path, to serwer uczciwie powie: „nie znam takiej trasy” (na razie) — i nie będziesz zgadywał, dlaczego API zachowuje się jak kot: niby domowy, ale robi co chce.

6. Zmienny segment: staranny startsWith

Gdy tylko pojawia się endpoint typu /api/v1/reading-list/{id}, dokładne porównanie przestaje działać: id jest za każdym razem inne. W Springu rozwiązuje to adnotacja i szablon ścieżki. W plain Javie możemy zacząć od najprostszego i najuczciwszego wariantu: sprawdzania prefiksu przez startsWith(...).

Kluczowy szczegół: prefiks musi kończyć się ukośnikiem, inaczej ryzykujesz dopasowanie czegoś niepotrzebnego. Na przykład jeśli prefiksem będzie "/api/v1/reading-list", to ścieżka "/api/v1/reading-listing" też się „zacznie” od tego stringa. Ale jeśli prefiks to "/api/v1/reading-list/", to taki numer już nie przejdzie.

Przykład gałęzi trasy ze zmiennym segmentem:

// Gałąź "pobierz element po id": /api/v1/reading-list/{id}
if ("GET".equals(method) && path.startsWith("/api/v1/reading-list/")) {
    // Ważne: tu tylko wybieramy gałąź, a id rozbierzemy później
    handleGetByIdRoute(exchange);
    return;
}

Tutaj ważne jest zaznaczenie: handleGetByIdRoute(...) na razie — to tylko osobna gałąź dla trasy „jeden konkretny element”. Nas tu nie interesuje ani repozytorium, ani logika biznesowa. Parsowanie segmentów ścieżki i wyciąganie id — to już obowiązek samej gałęzi po tym, jak router zrobił swoją robotę.

I jeszcze jeden moment, który łatwo przeoczyć: sprawdzanie startsWith("/api/v1/reading-list/") trzeba postawić po sprawdzeniu dokładnego "/api/v1/reading-list". Inaczej żądanie na listę też „pasuje” pod prefiks (w zależności od tego, jak napisałeś prefiks), a serwer pomyli „kolekcję” i „element”. To jak pomylenie drzwi klatki schodowej i drzwi mieszkania: adres podobny, a konsekwencje różne.

7. Kolejność sprawdzeń i gałąź domyślna

Gdy zaczynasz pisać if/else, bardzo szybko powstaje „drabinka”, w której trudno zrozumieć, co jest ważniejsze. Dlatego warto z góry umówić się co do stylu: najpierw sprawdzamy najbardziej konkretne i precyzyjne trasy, potem bardziej ogólne, a na końcu obowiązkowo robimy zrozumiałą gałąź „nic nie pasuje”. Bez tej gałęzi serwer albo będzie po cichu zamykał połączenie, albo rzuci wyjątek, albo odpowie czymkolwiek — i będziesz debugował nie kod, a własną wiarę w ludzkość.

Na razie to lokalny fragment routera. Interesuje nas tutaj tylko wybór gałęzi po method + path; query, body i wspólne JSON-helpery dodadzą się dookoła tego samego kawałka, ale nie zmienią jego głównej pracy.

Myślowo wygodnie wyobrazić sobie routing jako małe drzewo decyzyjne:

flowchart TD
    A["Żądanie przyszło: method + path"] --> B{"GET /health ?"}
    B -->|tak| H[handleHealth]
    B -->|nie| C{"GET /api/v1/reading-list ?"}
    C -->|tak| L[handleList]
    C -->|nie| D{"GET /api/v1/reading-list/* ?"}
    D -->|tak| I[handleGetByIdRoute]
    D -->|nie| N[sendNotFound]

A oto jak to może wyglądać w kodzie (uproszczone i bez szczegółów odpowiedzi — omówimy je w miarę składania response-side helperów):

import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;

private void route(HttpExchange exchange) throws IOException {
    // 1) Wyciągamy dane, po których będziemy routerować
    String method = exchange.getRequestMethod();
    String path = exchange.getRequestURI().getPath();

    // 2) Najpierw najbardziej precyzyjne dopasowania
    if ("GET".equals(method) && "/health".equals(path)) {
        handleHealth(exchange);
        return;
    }

    if ("GET".equals(method) && "/api/v1/reading-list".equals(path)) {
        handleList(exchange);
        return;
    }

    // 3) Potem bardziej ogólna trasa ze zmiennym segmentem
    if ("GET".equals(method) && path.startsWith("/api/v1/reading-list/")) {
        handleGetByIdRoute(exchange);
        return;
    }

    // 4) Gałąź domyślna: nic nie pasuje
    sendNotFound(exchange);
}

Tak, to wygląda „wprost”. I to jest normalne. Świadomie nie budujemy mini-frameworka. Naszym celem jest, żeby student mógł wzrokowo odczytać logikę i zrozumieć: „Aha, serwer wybiera gałąź właśnie tak”. Gdy pojawi się Spring MVC, po prostu rozpoznasz w nim ten sam routing — tylko wyniesiony do infrastruktury.

Na razie sendNotFound(exchange) można tu czytać jako nazwę gałęzi fallback. Gdy w odpowiedzi pojawią się wspólne JSON-helpery, ta gałąź stanie się zwykłym 404 w tym samym error format, a nie jakimś osobnym przypadkiem tekstowym.

I jeszcze ważne zastrzeżenie: dla bieżącego routera tyle wystarczy. Najpierw ważne jest oddzielenie samych gałęzi po method + path, bez wmieszania tu całej macierzy błędów HTTP i obsługi każdego konkretnego statusu.

8. Routing oddzielnie od handlerów

Na tym etapie łatwo popełnić błąd „z przyzwyczajenia z konsolowej Javy”: wepchnąć wszystko do jednej metody handle(...) i cieszyć się, że działa. Ale kod backendowy jest bardzo mściwy: może działać nawet w złej strukturze, tylko potem cierpisz przy każdej zmianie. Dlatego od razu wyrabiamy właściwy nawyk: handle(...) — to wejście wysokopoziomowe, a routing — to osobna metoda, która czyta się jak scenariusz.

Mini-szkielet handlera (bez szczegółów JSON i bez logiki biznesowej) może wyglądać tak:

import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;

final class ReadingListHandler implements HttpHandler {
    @Override
    public void handle(HttpExchange exchange) throws IOException {
        // Wejście wysokopoziomowe: przyszło żądanie HTTP
        // Dalej — tylko delegowanie do routera
        route(exchange);
    }
}

Dlaczego to jest dobre? Bo handle(...) pozostaje małe i zrozumiałe: „przyszło żądanie — wysyłamy do routera”. A dalej cały wybór tras koncentruje się w route(...).

Ten szkielet na razie pokazuje tylko górne role. Logowanie, JSON-helpery i error-gałęzie jeszcze nie biorą udziału, ale sama zasada jest już ustalona: handle(...) nie powinien zamieniać się w megametodę, która i routeruje, i czyta body, i składa odpowiedź.

I drugi ważny moment: metody typu handleHealth, handleList, handleGetByIdRoute — to nie „logika biznesowa”. To „gałęzie warstwy transportowej”: dostają HttpExchange i dalej będą wyciągały dane żądania, czytały body, wołały serwisy, składały odpowiedź. Ale same reguły „dokąd skierować żądanie” muszą być w jednym miejscu — inaczej dostaniesz API, które „rozlazło się” po projekcie, i żadna architektura już cię nie uratuje (nawet jeśli potem dodasz Spring).

Dobra wewnętrzna umowa na dziś jest taka: routing rozwiązuje tylko jedno — która metoda ma obsłużyć żądanie. On nie czyta JSON-a, nie parsuje query, nie wchodzi do domeny. Ma jedną pracę: dispatcher.

9. Typowe błędy ręcznego routingu

Gdy piszesz routing ręcznie, błędy zwykle nie są „skomplikowane”, tylko „ludzkie”. Czyli takie, które popełniasz, bo jesteś zmęczony, rozkojarzony albo uznałeś „a tu i tak jest jasne”. Poniżej — najczęstsze grabie, żebyś rozpoznawał je po dźwięku jeszcze zanim na nie nadepniesz.

Błąd nr 1: porównywać tylko ścieżkę i ignorować metodę.
Na początku wydaje się, że metoda — to „drobiazg”, bo „przecież mamy na razie tylko GET”. Ale gdy tylko dodasz choć jeden POST, zachowanie zacznie się plątać. Sprawdzenie musi być po powiązaniu method + path, nawet jeśli teraz wygląda to „zbędnie”. To dyscyplina kontraktu.

Błąd nr 2: szukać trasy przez contains(...) albo „mniej więcej podobne”.
if (path.contains("reading-list")) — to tak, jakby powiedzieć „jeśli adres zawiera słowo «ulica», to jest mój dom”. Przy takim podejściu złapiesz cudze trasy, przypadkowe dopasowania i długo będziesz się dziwił, dlaczego handler odpala „czasem”. Dla tras stałych używaj equals, dla zmiennych segmentów — wyraźnego startsWith z poprawnym prefiksem.

Błąd nr 3: pomylić kolejność sprawdzeń (ogólna trasa stoi wyżej niż konkretna).
Jeśli najpierw sprawdzasz startsWith("/api/v1/reading-list"), a potem dokładne equals("/api/v1/reading-list"), to „ogólna” gałąź może przechwytywać żądania, które powinny były trafić do „konkretnej”. To czyni API nieprzewidywalnym. Lekarstwo jest proste: najpierw najbardziej precyzyjne reguły, potem bardziej ogólne.

Błąd nr 4: mieszać routing i obsługę danych żądania.
Bardzo kusi, żeby od razu w route(...) wyciągnąć id, sparsować query, przeczytać body… i już masz 80 linii w jednej metodzie. A potem dodajesz drugi endpoint i metoda zamienia się w kaszę. Poprawna struktura: route(...) wybiera gałąź, a w gałęzi już rozkładasz potrzebne dane (i to — starannie, małymi metodami).

Błąd nr 5: nie mieć wyraźnej gałęzi „trasa nie znaleziona”.
Jeśli na końcu nie ma sendNotFound(exchange), nieuchronnie dostaniesz „dziwne” odpowiedzi: gdzieś połączenie się zamknęło, gdzieś wysłano nagłówki, ale nie body, gdzieś wyleciał wyjątek. Dla klienta to wygląda jak „serwer ma czkawkę”, a dla ciebie — jak „czemu to czasem działa”. Serwer powinien zawsze potrafić uczciwie powiedzieć: „nie znam takiej trasy”.

1
Zadanie
Java Server, poziom 23, lekcja 0
Niedostępne
Dwa dokładne trasy
Dwa dokładne trasy
1
Zadanie
Java Server, poziom 23, lekcja 0
Niedostępne
Dok\u0142adna trasa i trasa z prefiksem
Dok\u0142adna trasa i trasa z prefiksem
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION