JavaRush /Курси /Java Server /Короткий HttpHandler

Короткий HttpHandler: кроки й утиліти

Java Server
Рівень 23 , Лекція 4
Відкрита

1. Короткий HttpHandler і читання зверху вниз

На цьому етапі в нас уже є всі елементи: маршрутизація за method + path, читання path/query/headers/body і sendJson(...) для відповіді. Тепер лишилося зібрати їх так, щоб обробник не розповзся на безлад. Коли ви починаєте писати серверний код без фреймворка, дуже легко потрапити в пастку: «Ну я ж роблю лише один endpoint… зараз у handle() швиденько». А потім додається другий endpoint, третій, зʼявляються body, помилки, заголовки — і раптом handle() перетворюється на «роман на 300 рядків», де посередині читається JSON, а наприкінці несподівано виникає маршрутизація. Саме тому нам потрібен короткий, сценарний HttpHandler.

Уявіть HttpHandler як адміністратора в клініці. Він не лікує зуби — це завдання сервісу, тобто бізнес-логіки. Він не виготовляє імпланти — це вже репозиторій або сховище. І навіть «історію хвороби» як доменну модель він не пише. Його робота простіша: прийняти людину, зрозуміти, «до якого лікаря», зібрати мінімальні дані — ПІБ, скаргу — передати далі й видати зрозумілий результат. У коді це означає, що handle(...) має займатися транспортом: прийняти HttpExchange, вибрати маршрут, розпарсити вхідні дані, викликати потрібну гілку методу й надіслати відповідь.

Якщо HttpHandler тримати коротким, ви отримуєте дуже практичний бонус: серверна поведінка читається зверху вниз, як інструкція зі збирання меблів — тільки без сюрпризу «а ось цей гвинт зайвий». Якщо ж він довгий, то будь-яка помилка перетворюється на квест «у якій частині цього моноліту я забув поставити Content-Type».

2. Порядок кроків: від HttpExchange до відповіді

Зараз ми зберемо «конвеєр» обробки запиту, щоб у голові зʼявилася стійка послідовність дій. Це важливо саме у світі без Spring: тут ніхто не зробить за вас routing, binding, validation, error mapping і response writing. Якщо ви не тримаєте порядок кроків, ви починаєте читати body ще до того, як зрозуміли маршрут, або надсилаєте відповідь двічі — а сервер потім ображається й мовчки розриває зʼєднання.

Зручно дивитися на це так: handle(...) — це верхній сценарій, а деталі живуть у маленьких методах. Схематично:

flowchart TD
    A["handle(exchange)"] --> B["записуємо в лог method + path"]
    B --> C["route(exchange)"]
    C --> D{гілка маршруту}
    D -->|GET /health| E[sendJson 200]
    D -->|POST ...| F["readBody -> readJson DTO"]
    F --> G[збираємо response DTO]
    G --> H[sendJson status]
    C --> I["якщо маршрут не знайдено -> sendNotFound"]
    C --> J["якщо транспортна помилка -> sendBadRequest"]

У гілках без body після route(...) зазвичай вистачає path/query/headers helper-ів: дістати id з path, прочитати фільтри з query, перевірити потрібний header і рухатися далі. У гілок із body pipeline відрізняється лише одним шматком: перед readBody(...) стоїть явний gate за Content-Type, а потім уже йдуть readBody(...), перевірка на порожнє тіло і readJson(...). Це один і той самий handler-конвеєр, просто різні гілки читають різні частини вхідного запиту.

Зверніть увагу на одну річ: маршрутизація не має читати body «про всяк випадок». Тіло читається лише в тих гілках, де його очікують. Інакше ви робите зайву роботу та множите помилки: наприклад, GET без тіла раптом починає падати з «Некоректним JSON», бо хтось вирішив читати body завжди.

З практичного боку нам важливо, щоб обробник робив чотири речі передбачувано: логування входу, вибір маршруту, розбір вхідних даних і надсилання відповіді. Усе інше — по можливості поза handler-ом, але сьогодні ми свідомо лишаємося на транспортному рівні й робимо лише «preview» операцій, без реальної логіки reading list.

3. Скелет ReadingListHandler: залежності в конструкторі

Нам потрібно вибрати місце, де житиме обробник, і як він отримає залежності. У нашому проєкті це пакет com.example.readlater.readinglist.http, тому що це явно web- і transport-шар reading list-фічі. Усередині handler-а нам точно потрібен ObjectMapper, тому що ми читаємо JSON і пишемо JSON. Важливо не створювати ObjectMapper всередині кожного запиту: це і повільно, і порушує архітектурну дисципліну, а ще потім ви не зможете централізовано налаштувати JSON.

Давайте почнемо з мінімального каркаса класу. Він ще не знає маршрутів і helper-ів, але вже показує правильні інженерні звички: пакет, final клас, Logger, поле залежності та конструктор.

package com.example.readlater.readinglist.http;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

final class ReadingListHandler implements HttpHandler {
    // Логер — для діагностики вхідних запитів і проблем транспорту.
    private static final Logger log = LoggerFactory.getLogger(ReadingListHandler.class);

    // ObjectMapper створюється один раз у composition root і передається в handler.
    private final ObjectMapper objectMapper;

    ReadingListHandler(ObjectMapper objectMapper) {
        // Проста перевірка не завадить: інакше NPE зʼявиться десь потім.
        this.objectMapper = objectMapper;
    }
}

Каркас поки ще не знає маршрутів і helper-ів, але дві головні звички вже на місці: залежності приходять ззовні, а обробник не починає сам збирати пів застосунку навколо себе.

Ще один момент: я свідомо не роблю клас public. У маленькому проєкті корисно починати з package-private, щоб не перетворювати будь-який клас на «частину публічного API застосунку». Усередині пакета readinglist.http нам достатньо бачити обробник і його допоміжні методи.

4. Метод handle(...): сценарій і обробка помилок

Тепер додамо в цей самий клас верхньорівневий handle(...). Робоча версія відразу поєднує дві речі: логування входу і одну точку перехоплення транспортних помилок. Інакше вони дуже швидко розмазуються по гілках маршрутів.

@Override
public void handle(HttpExchange exchange) throws IOException {
    // Записуємо в лог мінімум: метод + шлях, без тіла й чутливих даних.
    log.info("{} {}", exchange.getRequestMethod(), exchange.getRequestURI().getPath());

    try {
        // Усередині route(...) вже живуть конкретні гілки маршрутів.
        route(exchange);
    } catch (IllegalArgumentException e) {
        // Клієнт надіслав неправильні дані — це не "помилка сервера".
        log.warn("Некоректний запит: {}", e.getMessage());

        // Повертаємо стабільний JSON-формат помилки й коректний HTTP-статус.
        sendBadRequest(exchange, e.getMessage());
    }
}

Тут важливі дві інженерні звички. По-перше, handle(...) лишається коротким і не знає деталей маршрутів. По-друге, обробка IllegalArgumentException живе в одному місці, а не розмазується по handleHealth(...), handleCreatePreview(...) та інших гілках.

Формат самого ErrorResponse і low-level JSON-helper-и у нас уже є. Обробник на цьому рівні просто користується ними й не перетворює catch-блок на окремий кустарний фреймворк.

5. route(...): уся маршрутизація в одному місці

route(...) — це ваш «міні-розклад руху поїздів». Він каже: якщо прийшов GET /health — іди туди; якщо прийшов POST ... — іди сюди; інакше — «маршрут не знайдено». Дуже хочеться розкидати це по різних методах («нехай кожен endpoint сам себе перевірить»), але тоді ви втрачаєте картину цілком. Коли баг у роутингу, ви хочете відкрити один метод і відразу побачити, що сервер узагалі розуміє.

На нашому поточному рівні достатньо простого if/else без «геніальних» абстракцій. Важливо дотримуватися порядку: більш конкретні правила — вище, більш загальні — нижче. І ще важливіше — робити ранній return після того, як ви надіслали відповідь, щоб не злетіти в спробу надіслати другу.

import com.sun.net.httpserver.HttpExchange;

import java.io.IOException;

private void route(HttpExchange exchange) throws IOException {
    // Дістаємо метод і шлях рівно один раз, щоб не повторюватися й не помилятися в умовах.
    String method = exchange.getRequestMethod();
    String path = exchange.getRequestURI().getPath();

    // Найпростіша гілка без body.
    if ("GET".equals(method) && "/health".equals(path)) {
        handleHealth(exchange);
        return; // Важливо: відповідь надіслано, далі не продовжуємо.
    }

    // Тимчасова transport-гілка: дає прогнати JSON-pipeline, не змішуючи його з бізнес-логікою.
    if ("POST".equals(method) && "/api/v1/reading-list/preview".equals(path)) {
        handleCreatePreview(exchange);
        return;
    }

    // Якщо нічого не підійшло — повертаємо нормальний JSON 404.
    sendNotFound(exchange);
}

Тут одразу видно дві різні гілки одного й того самого handler-а: одна живе без body, інша потребує body і JSON. Але сам route(...) як і раніше не читає тіло, не парсить JSON і не будує відповідь — він лише вибирає, кому передати керування.

6. Службові методи поруч із handler-ом

Коли ви пишете другий-третій endpoint, ви помічаєте, що повторюєте одні й ті самі шматки коду: прочитати body, розпарсити JSON, перевірити Content-Type, надіслати JSON, виставити Content-Type у відповіді, порахувати довжину байтів. Якщо залишити це «на місці», то за тиждень у вас буде пʼять варіантів sendJson(...), і вгадайте, в якому з них ви одного дня забудете charset=UTF-8.

У межах одного обробника такі методи нормально тримати private поруч із route(...). Окремі utility-класи знадобляться лише тоді, коли цей код почне повторюватися вже між кількома обробниками. Для сьогоднішнього дня достатньо короткого набору helper-ів.

Почнімо з читання body. Потік одноразовий, тому читаємо рівно один раз і одразу декодуємо в UTF-8:

import com.sun.net.httpserver.HttpExchange;

import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;

private String readBody(HttpExchange exchange) throws IOException {
    // Тіло запиту читаємо один раз: потік "одноразовий".
    try (InputStream is = exchange.getRequestBody()) {
        byte[] bytes = is.readAllBytes();
        return new String(bytes, StandardCharsets.UTF_8);
    }
}

Для гілок із body потрібен і явний gate за Content-Type:

import com.sun.net.httpserver.HttpExchange;

private void requireJsonContentType(HttpExchange exchange) {
    String contentType = exchange.getRequestHeaders().getFirst("Content-Type");

    if (contentType == null || !contentType.startsWith("application/json")) {
        throw new IllegalArgumentException("Очікується Content-Type: application/json");
    }
}

Наступний helper — читання JSON у DTO. Ми ловимо IOException від Jackson і перетворюємо його на IllegalArgumentException, щоб верхній handle() віддав 400 Bad Request, а не stack trace клієнту:

import java.io.IOException;

private <T> T readJson(String body, Class<T> type) {
    try {
        // Десеріалізація в потрібний DTO-тип.
        return objectMapper.readValue(body, type);
    } catch (IOException e) {
        // На рівні транспорту нам важливо віддати 400, а не "вибухнути" stack trace.
        throw new IllegalArgumentException("Некоректний JSON");
    }
}

І нарешті — надсилання JSON. Тут два вічні нюанси: Content-Type треба виставити до надсилання заголовків, а довжину слід рахувати за байтами.

import com.sun.net.httpserver.HttpExchange;

import java.io.IOException;
import java.io.OutputStream;

private void sendJson(HttpExchange exchange, int status, Object body) throws IOException {
    // Серіалізуємо одразу в байти: так коректно рахуємо Content-Length.
    byte[] bytes = objectMapper.writeValueAsBytes(body);

    // Заголовки виставляємо до sendResponseHeaders(...), інакше вони можуть не потрапити у відповідь.
    exchange.getResponseHeaders().set("Content-Type", "application/json; charset=UTF-8");
    exchange.sendResponseHeaders(status, bytes.length);

    // Важливо закривати OutputStream: try-with-resources гарантує закриття навіть у разі помилки запису.
    try (OutputStream os = exchange.getResponseBody()) {
        os.write(bytes);
    }
}

Поверх sendJson(...) лишаються тонкі обгортки для типових error-гілок:

import com.example.readlater.common.error.ErrorResponse;

import java.io.IOException;
import java.util.List;

private void sendBadRequest(HttpExchange exchange, String message) throws IOException {
    sendJson(exchange, 400, new ErrorResponse("BAD_REQUEST", message, List.of()));
}

private void sendNotFound(HttpExchange exchange) throws IOException {
    sendJson(exchange, 404, new ErrorResponse("NOT_FOUND", "Маршрут не знайдено", List.of()));
}

У результаті гілки обробника читаються як сценарії, а не як набір низькорівневих маніпуляцій зі stream-ами та байтами.

7. «Preview»-endpoint: повне коло без логіки

Зараз зробимо міні-гілку, яка покаже повне коло обробки запиту, але не полізе в логіку списку читання. Це важливо: сьогодні ми будуємо transport-шар, а не CRUD. Тому endpoint буде тимчасовим: він прийме JSON, дістане кілька полів і поверне їх назад як відповідь, щоб ви могли перевірити механіку через Postman.

Для цієї гілки не потрібен другий майже такий самий DTO. Достатньо взяти CreateReadingItemRequest, який уже описує форму вхідного JSON.

import com.example.readlater.readinglist.dto.CreateReadingItemRequest;

import java.io.IOException;
import java.util.Map;

private void handleCreatePreview(HttpExchange exchange) throws IOException {
    // 1) Гілка з body спочатку перевіряє media type.
    requireJsonContentType(exchange);

    // 2) Читаємо body як рядок (один раз).
    String body = readBody(exchange);

    // 3) Базова перевірка транспорту: порожнє тіло — це 400.
    if (body.isBlank()) {
        throw new IllegalArgumentException("Тіло запиту обовʼязкове");
    }

    // 4) Парсимо JSON у вже знайомий request DTO.
    CreateReadingItemRequest request = readJson(body, CreateReadingItemRequest.class);

    // 5) Повертаємо короткий echo-response: перевіряємо, що transport-pipeline зібрано.
    sendJson(exchange, 200, Map.of(
            "title", request.title(),
            "author", request.author()
    ));
}

Ця preview-гілка не вдає з себе реальний create-endpoint. У неї одна задача: прогнати через обробник повний body-side pipeline і не змішувати це одразу з репозиторієм, сервісом і доменною логікою.

Після цього весь каркас обробника читається вже цілком так:

final class ReadingListHandler implements HttpHandler {
    private static final Logger log = LoggerFactory.getLogger(ReadingListHandler.class);
    private final ObjectMapper objectMapper;

    ReadingListHandler(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public void handle(HttpExchange exchange) throws IOException {
        log.info("{} {}", exchange.getRequestMethod(), exchange.getRequestURI().getPath());

        try {
            route(exchange);
        } catch (IllegalArgumentException e) {
            log.warn("Некоректний запит: {}", e.getMessage());
            sendBadRequest(exchange, e.getMessage());
        }
    }

    private void route(HttpExchange exchange) throws IOException {
        String method = exchange.getRequestMethod();
        String path = exchange.getRequestURI().getPath();

        if ("GET".equals(method) && "/health".equals(path)) {
            handleHealth(exchange);
            return;
        }

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

        sendNotFound(exchange);
    }

    // Нижче лишаються private helper-и:
    // requireJsonContentType(...), readBody(...), readJson(...),
    // sendJson(...), sendBadRequest(...), sendNotFound(...).
}

Це й є базова версія наприкінці дня: один короткий обробник, у якому body-less і body-bearing гілки живуть поруч, а транспортна рутина не розповзається по проєкту.

8. Структура проєкту: межа readinglist.http

Зараз корисно зафіксувати, що ми будуємо саме web-layer, а не «пишемо все підряд, де зручно». Якщо обробник почне містити бізнес-правила, наприклад «externalId має бути унікальним», то ми непомітно зламаємо архітектуру курсу: transport почне керувати доменом, а далі все розповзеться. Тому обробник має жити в readinglist.http, DTO — у readinglist.dto, домен — у readinglist.domain, сервіс — у readinglist.service (але ми його сьогодні не чіпаємо).

Візуально це може виглядати так:

com.example.readlater
└── readinglist
    ├── dto
    │   └── CreateReadingItemRequest.java
    └── http
        └── ReadingListHandler.java

Сам ErrorResponse при цьому логічно тримати в загальному common.error, тому що єдиний формат помилки потрібен не лише одній фічі.

І ще один маленький, але важливий момент: ReadingListHandler має створюватися в composition root (у вашому server-режимі), а не сам створювати собі ObjectMapper, Logger і «половину застосунку». В ідеалі server-mode робить приблизно так: бере ObjectMapper із загального місця (common.json), створює обробник, реєструє context. Це не «краса заради краси», це спосіб тримати залежності передбачуваними.

Поки наш обробник містить службові методи всередині себе — це допустимо і навіть добре для навчання. Але якщо ви побачите, що цих методів стає забагато, краще винести їх у маленькі helper-класи, усе одно лишаючись у пакеті readinglist.http. Важливо не вийти за межі й не побудувати «framework», який буде складніший за задачу курсу.

9. Типові помилки під час збирання HttpHandler

Наприкінці дня дуже хочеться «добити» обробник до стану «ну, наче працює», але саме тут новачки найчастіше наступають на граблі, які потім важко налагоджувати. Помилки зазвичай не в теорії HTTP, а в дрібницях: порядок дій, подвійне надсилання відповіді, дублювання утиліт, неправильна довжина body. Давайте проговоримо кілька типових сценаріїв, щоб ви впізнавали їх за характерними симптомами.

Помилка №1: handle(...) перетворюється на мегаметод, який робить узагалі все.
Спочатку це здається зручним: «ну я ж бачу весь код в одному місці». На практиці через пару endpoint-ів ви отримуєте 150 рядків handle() із розгалуженнями, де частина коду повторюється, а частина залежить від змінних з іншої гілки. Рятує просте правило: handle() читається як сценарій верхнього рівня, а деталі йдуть у route(...), handleCreatePreview(...), sendJson(...) тощо.

Помилка №2: створення ObjectMapper всередині маршруту або всередині кожного запиту.
Технічно це може працювати, але ви платите зайвою ініціалізацією й втрачаєте єдине місце налаштування JSON. У результаті один endpoint серіалізує дату так, інший — інакше, а третій раптово падає на невідомому полі. Тримайте ObjectMapper як залежність обробника, передану через конструктор, і створюйте його один раз у composition root.

Помилка №3: читання request body «про всяк випадок» до маршрутизації.
Ви прочитали body на початку handle(), а потім виявилося, що це GET /health, якому body не потрібне. Начебто «не страшно», але ви створюєте собі проблеми: зайва робота, потенційні блокування на читанні, а головне — втрачаєте дисципліну «спочатку маршрут, потім парсинг». Читайте body лише в тих гілках, де його очікують.

Помилка №4: неправильна довжина відповіді у sendResponseHeaders(...).
Дуже часта пастка: надіслати exchange.sendResponseHeaders(status, json.length()), де json — рядок. Для ASCII це іноді «випадково працює», а для UTF-8, особливо з текстом українською або іншими не-ASCII символами, довжина в символах не дорівнює довжині в байтах, і клієнт може отримати обрізану відповідь. Рішення просте: серіалізуємо в byte[] і використовуємо bytes.length.

Помилка №5: забути виставити Content-Type або виставити його «після надсилання заголовків».
Заголовки — це не побажання, а частина контракту. Якщо Content-Type не виставлений, клієнт може спробувати інтерпретувати JSON як text/plain, або навпаки. А якщо ви виставите заголовок після sendResponseHeaders(...), він уже може не потрапити у відповідь. Тому Content-Type ставимо до надсилання заголовків — завжди.

1
Задача
Java Server, 23 рівень, 4 лекція
Недоступна
Короткий `GreetingHandler` з центральною обробкою помилок
Короткий `GreetingHandler` з центральною обробкою помилок
1
Задача
Java Server, 23 рівень, 4 лекція
Недоступна
Короткий `EchoHandler` із query і body в одному місці
Короткий `EchoHandler` із query і body в одному місці
1
Опитування
HTTP-роутинг, рівень 23, лекція 4
Недоступний
HTTP-роутинг
Маршрути та обробка запитів
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ