1. /health: перевіряємо, чи сервер живий
Коли ви тільки підіймаєте сервер, мозок дуже хоче повірити рядку в журналі «Server started…». Але бекенд-реальність сувора: процес може жити, а кінцева точка — не відповідати, відповідати не тим або взагалі зависати. /health — це наш перший чесний «пінг» через HTTP, який перевіряє саме те, заради чого ми підіймали HttpServer: здатність прийняти запит і повернути коректну відповідь.
Після одного start() сервер уже живий як процес, але зовні він усе ще мовчить. Потрібен хоча б один шлях, який доведе: контекст зареєстровано, обробник спрацьовує, а відповідь іде через HTTP, а не лише в журнал.
У реальних системах /health часто використовують для автоматичних перевірок: моніторингу, балансувальників, контейнерів і всього такого дорослого. Ми сьогодні туди не йдемо, але ідею беремо: нам потрібна кінцева точка, яка не залежить від бізнес-логіки, щоб вона була стабільною точкою перевірки. Для навчального проєкту це ще й психологічно приємно: ви бачите, що сервер відповідає JSON-ом, отже наступний рівень із маршрутизацією та вхідними запитами буде вже не «в порожнечу».
Щоб зберегти порядок у проєкті, корисно відразу вирішити, де житиме ця кінцева точка. Логічно тримати її поруч із запуском локального API, а не всередині readinglist-функціоналу, тому що /health — не про список читання, а про застосунок як сервіс.
Невелика орієнтація за файлами: варіант, сумісний із нашою структурою.
Файл: /src/main/java/com/example/readlater/
# Структура папки, де живе локальний API-сервер
# (ASCII-дерево, щоб було простіше зором «схопити» розташування файлів)
src/main/java/com/example/readlater
├─ app/server
│ ├─ HealthResponse.java
│ └─ LocalApiServer.java
└─ config
└─ ServerConfig.java
2. Контракт /health: статус і JSON
Коли кажуть «кінцева точка відповідає», новачки часто думають лише про body: «ну прийшов же текст, отже все нормально». Але HTTP-контракт складається з кількох частин, і ми з вами вже не на рівні «аби щось показати в браузері». /health має відповідати передбачувано: правильним статусом, правильним Content-Type і JSON-ом фіксованої форми, щоб будь-який клієнт (Postman, браузер, ваш же HttpClient) розумів, що йому прислали.
Зафіксуємо дуже простий контракт. Ми повертатимемо 200 OK і JSON такого вигляду:
{
"status": "UP",
"appName": "readlater-starter"
}
Тут status — технічне поле, а appName — корисний маркер, що конфігурація підхопилася і ми відповідаємо саме тим застосунком, який очікуємо. Якщо у вас колись паралельно працюють два сервіси на різних портах, ви швидко почнете цінувати такі дрібниці.
Зручно один раз побачити контракт у таблиці, щоб не тримати все в голові:
| Частина відповіді | Що повертаємо | Чому це важливо |
|---|---|---|
| Код статусу | 200 | Клієнту не потрібно гадати: «живий» = успіх |
| Заголовок Content-Type | application/json; charset=utf-8 | Клієнт розуміє, що body — JSON і як його декодувати |
| Body | JSON із status і appName | Дані в передбачуваній формі, а не випадковий рядок |
Зверніть увагу на charset. Так, JSON за замовчуванням зазвичай UTF‑8, але ми зараз вчимося писати контракт явно, щоб не залежати від здогадів клієнта та від налаштувань за замовчуванням на конкретній машині.
3. DTO відповіді: HealthResponse
Дуже хочеться, особливо після кількох днів Java Core, зробити так: «та я просто рядок поверну». Або: «та я в Map покладу пару полів». Воно справді спрацює… до першого моменту, коли ви додасте третє поле, захочете перевикористати відповідь або порівняти очікуваний JSON із фактичним.
Тому ми робимо рівно те, що вже робили для transport-моделей: вводимо маленький response DTO. У Java 25 record — ідеальний формат для таких об’єктів: коротко, зрозуміло і без зайвих гетерів та сетерів. А ще, що приємно, ObjectMapper із record дружить нормально, і виглядає це акуратно.
Зробимо клас-носій для відповіді:
package com.example.readlater.app.server;
// DTO відповіді кінцевої точки /health.
// Тут немає логіки: це частина контракту API (структура JSON).
public record HealthResponse(String status, String appName) {
}
Зверніть увагу, наскільки він легкий: два поля і все. Тут немає бізнес-логіки, валідації чи методів. Це чиста модель відповіді, тобто частина контракту.
Якщо хочеться трохи більше дисципліни, можна домовитися, що status завжди "UP" саме як рядок. У більш строгому світі ми б зробили enum або константу, але сьогодні нам важливіше відпрацювати web-механіку, а не сперечатися з рядками.
4. JSON через ObjectMapper
Зібрати JSON конкатенацією рядків — це як лагодити ноутбук молотком: інколи справді допомагає, але потім ви дивуєтеся, чому клавіші відбилися на стіні. Помилки там майже гарантовані: лапки, екранування, коми, null, кодування. А ще це просто неприємно читати.
Тому для /health ми відразу використовуємо той самий інструмент, який уже є в проєкті: ObjectMapper. Він уміє серіалізувати наш HealthResponse у JSON. Важливо, що ObjectMapper зазвичай створюють один раз і перевикористовують: він потокобезпечний для читання й запису, якщо ви не змінюєте налаштування на льоту, а його створення — не безкоштовна операція.
Покажу спочатку ідею на мініприкладі, а потім вбудуємо її в handler.
import com.fasterxml.jackson.databind.ObjectMapper;
// Наш DTO, який хочемо віддати клієнту
HealthResponse dto = new HealthResponse("UP", "readlater-starter");
// ObjectMapper зазвичай створюють один раз і перевикористовують,
// але для «вакуумного» прикладу створимо його прямо тут.
ObjectMapper mapper = new ObjectMapper();
// Серіалізація DTO в JSON (рядком)
String json = mapper.writeValueAsString(dto);
// json буде приблизно таким:
// {"status":"UP","appName":"readlater-starter"}
Але в реальному серверному коді нам зручніше працювати не з рядком, а відразу з байтами. Тому що HttpExchange у підсумку все одно надсилає байти, і нам потрібна коректна довжина відповіді. У ObjectMapper є зручний метод writeValueAsBytes(...).
5. Відповідь через HttpExchange
HttpExchange — це ваш поштовий конверт на один запит. У ньому лежить запит, і через нього ж ви маєте відправити відповідь. Головне, що тут потрібно запам’ятати: у відповіді є порядок збирання. Якщо переплутати кроки, сервер або впаде, або відправить дивну відповідь, або клієнт зависне в очікуванні.
Логіка така: ви готуєте body у байтах, виставляєте заголовки, надсилаєте статус і довжину через sendResponseHeaders(...), а потім пишете байти в getResponseBody() і обов’язково закриваєте потік. Закриття потоку — це, по суті, крапка, після якої клієнт може вважати відповідь завершеною. Як і в житті: доки лист не запечатано, адресат отримує лише протяг.
Давайте зберемо робочий обробник /health. Я покажу його як метод handleHealth(...), який викликається з context-handlerʼа. Це не новий паралельний клас, а наступна версія того самого LocalApiServer: тепер він не тільки bind'иться на адресу, а й уміє віддати першу технічну JSON-відповідь.
Файл: /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 ззовні, щоб не створювати його на кожен запит
this.objectMapper = objectMapper;
}
private void handleHealth(HttpExchange exchange, ServerConfig config) throws IOException {
// 1) Готуємо DTO відповіді (частина контракту)
HealthResponse response = new HealthResponse("UP", config.appName());
// 2) Серіалізуємо в байти, щоб точно знати довжину відповіді
byte[] body = objectMapper.writeValueAsBytes(response);
// 3) Виставляємо заголовки ДО sendResponseHeaders
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
// 4) Оголошуємо статус і довжину відповіді
exchange.sendResponseHeaders(200, body.length);
// 5) Пишемо тіло й обов’язково закриваємо потік (try-with-resources)
try (OutputStream os = exchange.getResponseBody()) {
os.write(body);
}
}
}
Цей шматок коду — майже вся серцевина сьогоднішньої теми. Тут прямо руками видно, що пізніше буде приховано фреймворком: статус, заголовки, серіалізація, довжина, запис у потік.
Давайте проговоримо важливі місця.
objectMapper.writeValueAsBytes(response) дає нам байти JSON (зазвичай це UTF‑8). Ми одразу знаємо довжину body.length, а отже можемо коректно викликати sendResponseHeaders(200, body.length). Якщо довжина не збігатиметься з тим, що ви реально відправили, клієнт може отримати обрізану відповідь або зависнути: він чекає більше байтів, а ви вже закрили потік.
exchange.getResponseHeaders() виставляє заголовок. Якщо його забути, Postman і браузер часто все одно покажуть body, але клієнтський код може почати трактувати відповідь як text/plain і поводитися не так, як ви очікуєте. Для навчальної /health це не критично, але ми якраз вирощуємо звички, а не лише «у мене працює».
try (OutputStream os = exchange.getResponseBody()) — це прямий must-have для новачка. Ви закриваєте потік гарантовано, навіть якщо посередині щось пішло не так. Без закриття інколи все ніби працює, а інколи клієнт чекає і чекає, ніби ви обіцяли йому ще кілька кілобайт сенсу життя.
Щоб закріпити порядок, ось дуже проста схема:
flowchart TD
%% Мінімальний «ритуал» формування відповіді в HttpServer
%% Важливо: заголовки та sendResponseHeaders ідуть до запису body
A["Створити DTO HealthResponse"] --> B["Серіалізувати в байти JSON"]
B --> C["Виставити Content-Type"]
C --> D["sendResponseHeaders(status, length)"]
D --> E["Записати байти в тіло відповіді"]
E --> F["Закрити OutputStream"]
Можна сприймати це як мінімальний ритуал будь-якої кінцевої точки на HttpServer: змінюються DTO і статус, але кроки майже завжди ті самі.
Від цього моменту сперечатися з кодом безглуздо — його вже потрібно перевіряти зовнішнім запитом. Клієнт має побачити саме ті status, headers і body, які ми щойно зібрали руками.
6. Підключаємо /health
Сам обробник — це половина справи. Друга половина — зробити так, щоб сервер узагалі знав, що шлях /health існує, і який код має його обслуговувати. У HttpServer це робиться через createContext(path, handler). Ми вже бачили цю ідею на минулій лекції, а тепер доводимо її до повноцінної JSON-відповіді.
Нам важливо не скотитися в хаос, де обробники створюють собі ObjectMapper просто всередині, а appName жорстко прописується рядком "my-app" і потім ви ловите привиди в журналах. Тому залежності ми передаємо явно: LocalApiServer отримує ObjectMapper у конструкторі, а ServerConfig приходить у start() і прокидається далі в handleHealth().
Покажу мінімальний фрагмент start(), де реєструється /health:
Файл: /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 {
// Адресу і порт беремо з конфігурації, а не хардкодимо
InetSocketAddress address = new InetSocketAddress(config.host(), config.port());
HttpServer server = HttpServer.create(address, 0);
// Реєструємо endpoint /health і прокидаємо config в обробник
server.createContext("/health", exchange -> handleHealth(exchange, config));
// Запускаємо сервер
server.start();
return server;
}
Тут кінцева точка поки що прив’язана лише за шляхом. Тобто будь-який запит на /health потрапить у цей handler; окрема перевірка методу нам зараз не потрібна. Для цього шматка важливо інше: шлях зареєстровано, обробник викликається, і коректний JSON іде назовні.
Зверніть увагу: тут /health читається просто з першого погляду. Це важливо на старті. Ми поки не будуємо роутер, не вводимо таблицю маршрутів і не робимо міні-Spring в одну каску. Наша мета — щоб студент міг відкрити файл і зрозуміти: ось шлях, ось обробник, ось що він робить.
Якщо хочеться зробити код ще трохи читабельнішим, можна винести реєстрацію /health в окремий метод registerHealthEndpoint(server, config). Але це вже смаківщина — головне, щоб механізм був прозорий.
7. Типові помилки під час роботи з /health
Помилка №1: збирати JSON вручну рядками.
Майже завжди це починається з невинного "{\"status\":\"UP\"}", а закінчується тим, що ви пів дня шукаєте зайву кому або раптом ламаєте екранування, коли в appName з’являється дефіс, пробіл чи українська літера. Навіть для двох полів краще відразу використовувати ObjectMapper і не перетворювати /health на конкурс на найкрихкіше рядкове закляття.
Помилка №2: забути виставити Content-Type або виставити майже JSON.
Якщо повернути JSON, але залишити Content-Type: text/plain, багато клієнтів усе одно покажуть вміст, але не всі сприйматимуть його як JSON-дані. Особливо якщо пізніше ви перевірятимете контракт програмно. application/json; charset=utf-8 — проста звичка, яка економить час на дивних «чому парсер свариться».
Помилка №3: переплутати порядок sendResponseHeaders(...) і запису body.
У HttpExchange є чітке очікування: спочатку ви оголошуєте статус і довжину, потім пишете тіло. Якщо почати писати тіло раніше, можна отримати виняток або зламану відповідь. Це одна з тих помилок, які особливо дратують, бо виглядають так, ніби ви просто записали в потік.
Помилка №4: неправильна довжина в sendResponseHeaders(...).
Якщо ви передали довжину, яка не відповідає фактично надісланим байтам, клієнт може отримати обрізаний JSON або зависнути. Тому або заздалегідь отримуйте byte[] і використовуйте body.length, або, коли ви вже впевнено почуваєтеся, використовуйте режим chunked-відповіді. На поточному етапі найнадійніший і найзрозуміліший шлях — body.length.
Помилка №5: не закрити exchange.getResponseBody() або забути try-with-resources.
Інколи код ніби працює, тому що JVM закриває ресурси під час завершення потоку або наприкінці запиту. Але інколи клієнт чекає завершення відповіді, а ви не поставили крапку — не закрили потік. try-with-resources тут не просто краса, а гарантія, що відповідь буде коректно завершена.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ