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”.
GO TO FULL VERSION