1. Карта об’єктів JDK HttpServer
Коли ви вперше бачите код серверної частини, мозок новачка часто очікує магії: «мабуть, є один startServer(), і далі все відбувається само». У HttpServer магії майже немає — і це якраз добре. Тут є кілька базових класів, які й складають усю картину: адреса, сервер, контекст (шлях), обробник, а також об’єкт обміну запитом і відповіддю.
Серверний режим уже зрозумілий за самою роллю застосунку. Тепер важливо розкласти механіку по деталях: хто тримає порт, хто зіставляє шлях, а хто працює вже з конкретним запитом і відповіддю.
Давайте зберемо коротку «карту» в одну таблицю — так простіше тримати в голові, хто за що відповідає, і не плутати «сервер» із «запитом».
| Сутність | Що це | За що відповідає в нашій серверній частині |
|---|---|---|
| InetSocketAddress | адреса (host + port) | де саме сервер «слухає» |
| HttpServer | об’єкт сервера | тримає сокет, приймає запити, знає про контексти |
| HttpContext | контекст шляху | зв’язує рядок шляху (наприклад, /health) і обробник |
| HttpHandler | обробник | ваш код, який буде викликано під час запиту |
| HttpExchange | обмін (request+response) | об’єкт на один запит: з нього читаємо вхідні дані й записуємо вихідні |
Щоб це відчути не як довідник, а як «потік», корисно побачити схему:
flowchart TD Client["HTTP-клієнт: Postman / curl / браузер"] -->|"GET /health"| Server["HttpServer"] Server --> Context["HttpContext: /health"] Context --> Handler["HttpHandler: handle(exchange)"] Handler --> Exchange["HttpExchange: запит + відповідь"] Handler -->|"статус + заголовки + тіло"| Client
Тепер важливо не змішати два кроки. Спочатку потрібен робочий каркас сервера: адреса, HttpServer, запуск процесу. Сам зовнішній контракт /health має сенс навішувати вже поверх цього каркаса.
На практиці тут важливо впевнено відповідати на два запитання. Перше — «який об’єкт живе довго і є самим сервером?» (це HttpServer). Друге — «який об’єкт створюється на кожен запит і через нього ми спілкуємося з клієнтом?» (це HttpExchange). Усе інше — зв’язувальні деталі, які не дають нам потонути в хаосі.
2. HttpServer: сервер і диспетчер
Інтуїтивно хочеться думати, що «сервер» — це той код, який відповідає на запит. Але в JDK HttpServer сервер — це радше «диспетчер»: він слухає порт, приймає з’єднання і, побачивши шлях запиту, вирішує, якому обробнику (handler) передати роботу. А от обробник — це вже ваш код, де ви читаєте запит і формуєте відповідь.
Важливий момент: HttpServer — це об’єкт, який створюється один раз під час запуску серверної частини і живе весь час, поки працює ваш процес. Він не створюється на кожен запит. Якщо колись побачите код «на кожен запит створюємо новий HttpServer», знайте: десь поруч плаче один мережевий порт.
Мінімально сервер створюють через фабричний метод HttpServer.create(...). Зверніть увагу: тут ми поки що лише створюємо об’єкт. Запуск (start()) — це наступний крок і наступна лекція, де ми акуратно вмонтуємо це в серверний режим і конфігурацію.
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
// Адреса, на якій сервер слухатиме вхідні зʼєднання
InetSocketAddress address = new InetSocketAddress("localhost", 8080);
// Створюємо сервер, але поки що НЕ запускаємо його (start() буде пізніше)
HttpServer server = HttpServer.create(address, 0);
У другому аргументі create(address, 0) слово backlog виглядає як заклинання з давнього гримуара. На практиці це розмір черги вхідних з’єднань на рівні ОС. Для навчального локального сервера ми чесно ставимо 0, щоб використати значення за замовчуванням і не влаштовувати собі курс «Мережевий адміністратор за 3 дні».
Далі в HttpServer з’являється ключовий обов’язок: зареєструвати контексти, тобто зв’язати шматочки шляху з обробниками. І ось тут ми плавно переходимо до слова «context».
3. InetSocketAddress: host + port без рядкової магії
Якщо ви колись будували URL через конкатенацію рядків, а потім ловили баг із серії «зайвий слеш зламав усе», то ви вже емоційно готові полюбити InetSocketAddress. Він розв’язує просту задачу: зафіксувати адресу прослуховування як структуру даних, а не як «рядок у стилі ‘http://…’».
Для серверної частини нас цікавлять два параметри: host і port. Із host краще не мудрувати. localhost зазвичай достатньо, коли ви тестуєте сервер на своїй машині й не хочете, щоб він був доступний із локальної мережі. Якщо поставити щось на кшталт 0.0.0.0, сервер почне слухати на всіх інтерфейсах — іноді це зручно, але для навчального проєкту частіше означає «я випадково відчинив двері в під’їзд і тепер дивуюся, чому там люди».
Ось невеликий приклад, який показує, що InetSocketAddress — це саме структура, з якої можна прочитати параметри:
import java.net.InetSocketAddress;
// Створюємо адресу як структуру (не як "склеєний рядок")
InetSocketAddress address = new InetSocketAddress("localhost", 8080);
// Читаємо параметри назад — зручно для логів і діагностики
String host = address.getHostString();
int port = address.getPort();
І ще одна важлива деталь: порт 0. Якщо вказати 0, операційна система сама вибере вільний порт. Це іноді використовують у тестах, але для нас зараз це радше джерело плутанини: ви запустили сервер, а він слухає «невідомо де». Тому в навчальному серверному режимі ми майже завжди хочемо явний порт (і в наступній лекції братимемо його з конфігурації).
4. HttpContext: прив’язка шляху до обробника
У HttpServer немає анотацій @GetMapping, немає «контролерів», немає «роутера з коробки» — і саме тому контекст виглядає так прозоро. Контекст — це зв’язка: «ось цей шлях» → «ось цей обробник». Цю зв’язку задають методом createContext(...).
Важлива практична деталь: контекст у HttpServer — це не повноцінний роутинг, а радше прив’язка за префіксом шляху. Тобто, грубо кажучи, "/health" — це «гілка» URL-дерева. І сервер уміє вибрати найвідповіднішу гілку.
Ось як виглядає реєстрація контексту в найпростішому вигляді:
import com.sun.net.httpserver.HttpServer;
// Реєструємо обробник на шлях /health
server.createContext("/health", exchange -> {
// Тут буде код, який обробить КОЖЕН запит на /health
// (і GET, і POST — метод потрібно перевіряти окремо)
});
Тут варто запам’ятати три речі.
По-перше, контекст прив’язується за шляхом, а не за «повним URL». Ніяких http://localhost:8080/health тут бути не повинно — це завдання клієнта, а не сервера.
По-друге, HttpServer не вибирає обробник за HTTP-методом. Якщо ви прив’язали /health, то в цей обробник прилетить і GET, і POST, і що завгодно. Відрізняти методи — це окрема тема наступної лекції, і ми не будемо її ігнорувати в цій лекції.
По-третє, контексти можуть бути більш загальними і більш конкретними, і сервер намагається вибрати більш конкретний. Наприклад, якщо колись у вас будуть одночасно контексти "/api" і "/api/v1/reading-list", то запит на "/api/v1/reading-list" має потрапити в більш специфічний обробник. Це не магія, а просто здоровий глузд, вбудований у сервер: спочатку шукаємо найточніший збіг.
5. HttpHandler: лямбда або клас
Коли ви тільки починаєте, лямбда виглядає дуже привабливо: мінімум коду, максимум результату. Але щойно обробник перестає бути «дві стрічки», лямбда перетворюється на монстра, якого ніхто не хоче читати. Гарна новина: HttpHandler — звичайний інтерфейс, і ви можете винести обробник в окремий клас, а не тримати все всередині createContext(...).
Сам HttpHandler концептуально виглядає так: є метод handle(exchange), і exchange — це той самий об’єкт «обміну», який представляє один вхідний HTTP-запит і майбутню відповідь.
Давайте подивимося на клас-обробник у мінімальній формі. Він поки що нічого не робить (відповідь ми почнемо писати в лекції про /health), але він уже корисний як структура: є місце, де житиме логіка конкретної кінцевої точки.
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
// Окремий клас зручний, коли зʼявляться залежності й логіка стане більшою за "дві стрічки"
public class HealthHandler implements HttpHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
// exchange — це об'єкт на один конкретний запит (у ньому і request, і response)
// TODO: у наступній лекції сформуємо відповідь /health
}
}
Лямбд у LocalApiServer для одного технічного /health на цьому етапі вже цілком досить. Окремий клас особливо виправдовує себе там, де в шляху з’являються залежності й логіка більша за дві стрічки.
Тепер порівняймо з лямбдою. Лямбда хороша, коли ви хочете швидко побачити механіку: «шлях → обробник → обмін». Для цього — ідеально.
// Швидка форма: лямбда як HttpHandler
server.createContext("/health", exchange -> {
// Зчитуємо метод (GET/POST/...)
String method = exchange.getRequestMethod();
// Зчитуємо шлях (/health)
String path = exchange.getRequestURI().getPath();
});
Чому я зазвичай пропоную переходити до окремих класів досить рано? Тому що в реальному проєкті обробник майже завжди має залежності: логер, конфігурацію, ObjectMapper для JSON, можливо, сервіси. У лямбду ви почнете «захоплювати» зовнішні змінні, потім передавати п’ять аргументів, потім переносити код в окремий метод, а потім усе одно опинитеся в окремому класі — тільки вже з легким запахом рефакторингу по всьому коду.
6. HttpExchange: запит і відповідь
HttpExchange — це серце всієї механіки. Це об’єкт, який створюється сервером на кожен вхідний запит і передається у ваш HttpHandler. Через нього ви можете прочитати метод, URI, заголовки, тіло запиту, а також сформувати статус відповіді, заголовки відповіді та записати тіло відповіді.
І тут важливо психологічно перебудуватися: HttpExchange — це не «запит». Це «обмін». Тобто в ньому живе і вхід, і вихід. Іноді це здається дивним («чому не два об’єкти?»), але на навчальному рівні це навіть зручно: ви не втрачаєте зв’язок «що прийшло» → «що ми відправили».
Почнемо з найпростішого: метод і шлях. Саме ці дві речі ви майже завжди хочете бачити в логах (і це нам дуже стане в пригоді, коли будемо перевіряти серверний режим).
import com.sun.net.httpserver.HttpExchange;
// Метод запиту: GET / POST / PUT / ...
String method = exchange.getRequestMethod();
// Шлях запиту: наприклад, /health
String path = exchange.getRequestURI().getPath();
Далі часто потрібно прочитати заголовки запиту. У HttpExchange вони доступні як exchange.getRequestHeaders(). Це не «гарний DTO», а доволі прямолінійна структура. Але для старту достатньо одного прийому: getFirst(...), щоб забрати одне значення.
import com.sun.net.httpserver.Headers;
// Заголовки запиту (request headers)
Headers headers = exchange.getRequestHeaders();
// Наприклад, читаємо Accept (може бути null — це нормально)
String accept = headers.getFirst("Accept");
accept тут може виявитися null, і це нормально: клієнт не зобов’язаний надсилати Accept. Наша мета зараз не валідація і не ідеальний контракт, а розуміння механіки.
У HttpExchange є і вхідний потік, який містить body запиту:
import java.io.InputStream;
// Тіло запиту — це потік байтів (потім ми будемо декодувати/парсити, наприклад JSON)
InputStream bodyStream = exchange.getRequestBody();
Одразу важливе застереження: те, що body — це InputStream, означає «це байти, а не JSON». До перетворення в DTO через Jackson ще купа кроків. Ми будемо робити їх пізніше, наступної лекції, і це буде окрема історія про те, як не з’їхати з глузду без Spring MVC binding.
І нарешті, у HttpExchange є й вихідна частина. Навіть якщо ви ще не пишете body, ви вже повинні розуміти, що відповідь живе тут: response headers і response body stream.
import java.io.OutputStream;
// Заголовки відповіді (response headers) задаємо ДО відправлення тіла
exchange.getResponseHeaders().set("Content-Type", "text/plain; charset=utf-8");
// Потік, у який пишемо тіло відповіді
OutputStream out = exchange.getResponseBody();
Поки що не сприймайте це як готовий шаблон. Зараз нам важливо лише зрозуміти: один і той самий exchange — це місце, де ви робите все. А в наступній лекції ми зберемо мінімально коректну відповідь для /health (зі статусом і JSON).
7. Структура в ReadLater Starter
Коли додаєте сервер у проєкт, є спокуса зробити все в одному місці: у ReadLaterApplication створити сервер, там само зареєструвати шляхи, там само написати відповіді. Так справді швидше… рівно до моменту, коли ви захочете другу кінцеву точку. Потім з’являється третя, і раптом ReadLaterApplication перетворюється на «головний файл Всесвіту», який ніхто не відкриває без легкої тривожності.
Нам допомагає структура, яку ми вже заклали раніше: package-by-feature та явний composition root. Сам запуск сервера і технічний /health поки що доцільніше тримати в app.server, тому що це не доменна фіча reading list, а інфраструктурний вхід у локальний API. ServerConfig залишається в config, тому що це налаштування. А обробники для шляхів reading list уже логічно тримати поруч із feature, наприклад у readinglist.http.
Невелика ілюстрація «куди цілитися» (без спроби будувати мініфреймворк) може виглядати так:
com.example.readlater
|-- app
| `-- server
| `-- LocalApiServer.java
|-- config
| `-- ServerConfig.java
`-- readinglist
`-- http
Поки шлях один, createContext("/health", ...) навіть корисно тримати прямо в LocalApiServer: так видно сам механізм без зайвих шарів. Коли шляхів стане багато, реєстрацію контекстів уже можна винести в окремий клас на кшталт ApiRoutes, але зараз це було б радше ускладненням, ніж виграшем.
8. Типові помилки під час знайомства з HttpServer і HttpExchange
Помилка № 1: плутати «сервер» і «обробник».
Дуже легко почати думати, що HttpServer — це щось на кшталт «контролера», і намагатися писати логіку відповіді прямо поруч із create(...). Але HttpServer — це довгоживучий об’єкт, який приймає запити й роздає їх обробникам. Логіка відповіді має жити в HttpHandler, бо саме він отримує HttpExchange конкретного запиту.
Помилка № 2: сприймати createContext(...) як повноцінний роутинг, як у Spring.
createContext("/api/v1/reading-list/{id}", ...) не спрацює так, як ви очікуєте після досвіду зі Spring MVC. У JDK HttpServer контекст — це прив’язка за шляхом (радше за префіксом), без шаблонів і без автоматичного витягування змінних. Якщо тримати це в голові одразу, ви менше злитеся на технологію і краще розумієте, що саме Spring потім бере на себе.
Помилка № 3: будувати URL рядками і тягнути «повний URL» у код серверної частини.
Серверу не потрібно знати http://localhost:8080/health як рядок. Йому потрібні host і port для InetSocketAddress, а шляхи реєструються як "/health". Щойно ви починаєте змішувати «адресу прослуховування» і «шляхи», у голові утворюється каша, а в коді — конкатенації, які потім важко розплутати.
Помилка № 4: ігнорувати те, що HttpExchange — це request і response в одному об’єкті.
Іноді новачки читають метод і шлях, а потім шукають, де відповідь, в іншому місці — і не знаходять. У HttpServer усе робиться через exchange: ви прочитали з нього вхід, і через нього ж виставляєте статус, заголовки та пишете body. Коли це стане очевидним, подальші теми (JSON, статуси, коректні заголовки) лягають значно спокійніше.
Помилка № 5: намагатися «зробити красиво», побудувавши власний міні-Spring.
Щойно з’являється createContext, у багатьох прокидається внутрішній архітектор: хочеться зробити роутер, анотації, рефлексію і «автоматичне зв’язування». У межах курсу це прямо шкідливо: ви втратите головну цінність — прозорість. Тут наша мета не «зробити назавжди», а побачити механіку руками і підготувати мозок до Spring MVC без відчуття магії.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ