JavaRush /Курси /Java Server /Виділення CatalogClient

Виділення CatalogClient

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

1. Транспортний код і main()

main() — це як передпокій квартири: у ньому можна залишити ключі й куртку, але якщо ви почнете там готувати борщ, прасувати сорочки та лагодити велосипед, жити стане незручно. Транспортний код із URI, заголовками, таймаутами, send() і обробкою помилок дуже швидко перетворює main() на «комбайн». І найгірше — це не просто негарно: потім складніше змінювати код, додавати нові запити й розуміти, де закінчується логіка застосунку та починається логіка мережі.

Уявіть типовий ранній варіант — він працює, але сумує:

import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;

// HttpClient тут вважається створеним десь поруч (наприклад, через HttpClient.newBuilder()).
String query = "clean code";
String baseUrl = "https://catalog.example";

// Кодуємо саме значення параметра query, а не весь URL повністю.
String encoded = URLEncoder.encode(query, StandardCharsets.UTF_8);
URI uri = URI.create(baseUrl + "/search?q=" + encoded);

HttpResponse<String> response = client.send(
        // Транспортні деталі (HttpRequest, send, BodyHandlers) уже «живуть» у main().
        HttpRequest.newBuilder(uri).GET().build(),
        HttpResponse.BodyHandlers.ofString()
);
System.out.println(response.body()); // { ...JSON... }

Проблема тут не в тому, що код «поганий». Він просто опинився не на своєму місці. Щойно з’являються другий ендпоінт, третій, обробка статусів, таймаути та повторювані заголовки — і main() перетворюється на сувій, який страшно розгортати.

Але справа вже не лише в «чистому main()». До цього місця ми накопичили цілком конкретні транспортні правила: акуратний URI, явний HttpRequest, таймаути й жорстку межу 2xx/non-2xx. CatalogClient потрібен для того, щоб усі ці рішення жили в одному місці й не розповзалися по застосунку.

2. Транспортний шар у проєкті

Транспортний шар — це частина коду, яка вміє спілкуватися через HTTP: збирати адресу запиту, задавати метод і заголовки, надсилати запит, отримувати відповідь і відрізняти успіх від помилки за статусом. Він не зобов’язаний знати, що означає поле title в JSON, і не повинен вирішувати, що показувати користувачеві. Його завдання — доставити дані через мережу і повернути їх у передбачуваній формі або повідомити, що зробити це не вдалося.

Щоб не заплутатися, зручно тримати в голові просту межу відповідальності. Нижче — невелика таблиця (так, таблиця іноді рятує мозок краще, ніж тисяча рядків коду):

Питання Де вирішуємо? Приклад
Який URL викликати й як кодувати query? CatalogClient /search?q=... + URLEncoder
Які заголовки встановити? CatalogClient Accept: application/json
Які таймаути виставити? HttpClient і HttpRequest connectTimeout, request.timeout(...)
Що робити, коли статус 404 або 500? CatalogClient (на рівні транспортних правил) «Це не успіх, не віддаємо body як результат»
Як перетворити JSON на DTO/об’єкти? шар відображення над transport-клієнтом transport-код повертає сирий рядок
Як показати результат користувачеві? ReadLaterApplication / app-side логіка виведення, форматування

Головна ідея: транспортний шар — це «кур’єр». Він має привезти посилку або повідомити, чому не вийшло. Він не повинен вирішувати, що ви готуватимете з продуктів усередині посилки.

3. Каркас CatalogClient: залежності та методи

Коли ви створюєте клас CatalogClient, він має виглядати не як «набір утиліт для HTTP», а як маленьке API вашого застосунку. Тобто в нього мають бути методи, що звучать так, як ви мислите про сценарії: «пошук» і «деталі», а не «sendRequestAndParseHeadersAndMaybeCry()». І, що важливо, цей клас повинен мати явні залежності: HttpClient і baseUrl. Тоді він буде передбачуваним і придатним для повторного використання.

Почнімо з мінімального каркаса. На виході тут лишається сирий JSON-рядок: CatalogClient відповідає за мережу й межі транспортного шару, а не за mapping.

import java.net.http.HttpClient;

public class CatalogClient {
    // HttpClient передаємо ззовні: він придатний для повторного використання й налаштовується один раз.
    private final HttpClient client;

    // baseUrl — це «адреса сервісу», а не конкретної кінцевої точки.
    private final String baseUrl;

    public CatalogClient(HttpClient client, String baseUrl) {
        this.client = client;
        // Нормалізуємо baseUrl один раз, щоб далі не ловити подвійні слеші.
        this.baseUrl = normalizeBaseUrl(baseUrl);
    }
}

Тепер додамо публічні методи. Вони мають «ховати» від решти застосунку те, що десь усередині є /search і /books/{id}.

public String searchRaw(String query) {
    // Межа транспортного шару: на вході «смисловий» параметр, на виході — відповідь сервісу як рядок.
    return "...";
}

public String detailsRaw(String externalId) {
    // externalId — це зовнішній ідентифікатор, який розуміє каталог.
    return "...";
}

І так, суфікс Raw — це чесна табличка «на виході raw JSON». Він допомагає не забути, що транспортна межа тут закінчується рядком відповіді, а не вже розібраними даними.

4. Збирання URI усередині клієнта

Найпідступніша помилка у HTTP-коді часто виглядає так: «ну… я просто зібрав рядок, що може піти не так». А потім виявляється, що не так пішло все: пробіли, &, ?, зайві слеші, неочікувані символи. Тому збирання URI — чудова річ для централізації всередині CatalogClient. Тоді правила «як ми будуємо адресу» не розмазуються по проєкту.

Додамо приватні методи buildSearchUri і buildDetailsUri. Зверніть увагу: ми кодуємо значення параметра query, а не весь URL цілком.

import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;

private URI buildSearchUri(String query) {
    // Кодуємо лише значення параметра query.
    String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8);
    return URI.create(baseUrl + "/search?q=" + encodedQuery);
}

private URI buildDetailsUri(String externalId) {
    // Тут externalId підставляється в сегмент шляху.
    // Якщо у вас externalId може містити спецсимволи, краще окремо продумати кодування шляху.
    return URI.create(baseUrl + "/books/" + externalId);
}

Тут є маленький, але важливий практичний сенс: тепер ReadLaterApplication більше ніколи не знатиме, що пошук — це "/search?q=". Для точки входу застосунку це просто «виклич пошук», і все.

Ще один мікронюанс: коли ваш baseUrl може приходити зі слешем наприкінці (https://catalog.example/), то baseUrl + "/search" дасть подвійний слеш. У CatalogClient краще впіймати це один раз у конструкторі й далі працювати вже з нормалізованою адресою.

private static String normalizeBaseUrl(String baseUrl) {
    // Прибираємо завершальний '/', щоб потім не отримати '//' під час склеювання.
    return baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
}

5. Збирання HttpRequest у клієнті

У запиту мають бути видимі межі: метод, заголовки й тіло — це не побічні деталі, а частина контракту. Гарна новина: усе це можна сховати в CatalogClient так, щоб зовні залишилася простота, а всередині — дисципліна. Ключовий інструмент — HttpRequest.Builder, який читається зверху вниз як список рішень.

Почнімо з маленького helper-методу: ми хочемо завжди повідомляти віддаленому API, що очікуємо JSON. Для цього в кожному запиті ставимо Accept: application/json.

Загальний HttpClient відповідає за connectTimeout, а конкретний HttpRequest — за власний timeout. Тому request-level timeout живе просто тут, поруч із методом і заголовками.

import java.net.URI;
import java.net.http.HttpRequest;
import java.time.Duration;

private HttpRequest buildGetJson(URI uri) {
    return HttpRequest.newBuilder(uri)
            // Явно фіксуємо, що саме хочемо отримати, — це частина контракту.
            .header("Accept", "application/json")
            .timeout(Duration.ofSeconds(3)) // Обмежуємо очікування відповіді на рівні конкретного запиту
            .GET()
            .build();
}

Тепер searchRaw і detailsRaw стають майже «літературними» — їх приємно читати навіть через тиждень:

public String searchRaw(String query) {
    URI uri = buildSearchUri(query);
    HttpRequest request = buildGetJson(uri);
    return sendForString(request);
}

public String detailsRaw(String externalId) {
    URI uri = buildDetailsUri(externalId);
    HttpRequest request = buildGetJson(uri);
    return sendForString(request);
}

А коли у клієнта з’явиться POST із JSON-body, принцип залишиться тим самим: транспортні деталі живуть усередині CatalogClient, а не розмазуються по main().

import java.net.URI;
import java.net.http.HttpRequest;
import java.time.Duration;

private HttpRequest buildPostJson(URI uri, String jsonBody) {
    return HttpRequest.newBuilder(uri)
            // Accept — що хочемо отримати, Content-Type — що надсилаємо.
            .header("Accept", "application/json")
            .header("Content-Type", "application/json")
            .timeout(Duration.ofSeconds(3)) // Для POST правило очікування таке саме: запит не має висіти нескінченно
            // Тіло запиту формуємо тут, щоб решта коду не знала про BodyPublishers.
            .POST(HttpRequest.BodyPublishers.ofString(jsonBody))
            .build();
}

Зверніть увагу, як це зручно: ReadLaterApplication узагалі не зобов’язаний знати, що таке BodyPublishers. Йому достатньо знати лише «я хочу виконати сценарій».

6. sendForString(...): успіх і обробка помилок

Коли транспортний код розповзається, то найчастіше це стається саме тут: в одному місці перевіряють лише == 200, в іншому забувають обробити InterruptedException, у третьому «про всяк випадок» ловлять Exception, а потім вдають, що все гаразд. Тому тут нам потрібен один метод, який знає, як надсилати запит, як визначати, «успіх це чи ні», і що робити в основних гілках помилок.

Спочатку — маленький helper, щоб не писати магічні умови всюди:

private boolean is2xx(int statusCode) {
    return statusCode >= 200 && statusCode < 300;
}

Розділимо це на маленькі частини, щоб транспортне правило читалося прямо з коду. Сенс такий: коли отримали HttpResponse, транспортний крок завершився, але успішність визначається статусом. А коли спіймали виняток — відповіді може не бути взагалі.

І тут важливо не вигадувати другий контракт. Коли HttpResponse уже прийшов, але status не 2xx, клієнт має зберегти статус у CatalogApiException; коли відповіді не було зовсім, це інша гілка.

import java.io.IOException;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;

private String sendForString(HttpRequest request) {
    try {
        // Єдина точка надсилання запиту: так транспортні правила не розмазуються по проєкту.
        HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
        return extractBodyOrThrow(response);
    } catch (HttpTimeoutException e) {
        // Таймаут відрізняємо окремо: відповідь не встигла надійти за розумний час.
        throw new IllegalStateException("Каталог відповідає надто довго (тайм-аут)", e);
    } catch (IOException e) {
        // Мережеві проблеми: DNS, розрив з’єднання, недоступність хоста тощо.
        throw new IllegalStateException("Мережева помилка під час виклику каталогу", e);
    } catch (InterruptedException e) {
        // Важливо: відновлюємо прапорець переривання, інакше «загубимо» interrupt.
        Thread.currentThread().interrupt();
        throw new IllegalStateException("Запит було перервано", e);
    }
}

А ось extractBodyOrThrow. Нам потрібен exception, який не втрачає status і який потім можна перетворити на зрозуміле повідомлення користувачеві.

private String extractBodyOrThrow(HttpResponse<String> response) {
    int status = response.statusCode();
    if (is2xx(status)) {
        return response.body();
    }

    throw new CatalogApiException(status, response.body());
}

Так, це виглядає суворо: будь-який non-2xx перетворюється на CatalogApiException. Але на рівні транспортного шару це чесно. CatalogApiException означає «відповідь прийшла, але статус поганий», а IllegalStateException із sendForString(...) — «до відповіді, з якою можна працювати, узагалі не дісталися». Це не означає, що застосунок зобов’язаний падати. Це означає, що успішний результат не вдає з себе успішний, коли він неуспішний. А як саме показати цю помилку користувачеві, можна вирішити вже на межі застосунку.

Зверніть увагу на порядок catch: HttpTimeoutException іде раніше за IOException, інакше timeout розчиниться в загальній мережевій гілці. І окремо підкреслю: «просто проковтнути» InterruptedException — погана ідея. Ми коректно відновлюємо прапорець переривання через Thread.currentThread().interrupt() і переводимо помилку в зрозумілу форму.

7. ReadLaterApplication після виділення клієнта

Після того як ви винесли транспортний код, точка входу застосунку повертається до нормального життя. Вона має робити три речі: зрозуміти, який сценарій запуску вибрано, зібрати залежності та показати результат користувачеві. Усе інше — не її робота.

У happy-path у точці входу залишаються лише збирання залежностей і запуск сценарію. main() більше не знає ні про /search, ні про URLEncoder, ні про обробку statusCode().

Загальний HttpClient тримає connectTimeout, а CatalogClient уже всередині додає request timeout, збирання запитів і межу 2xx/non-2xx. Коли клієнт кине CatalogApiException або транспортну помилку, точка входу вирішує, як саме показати це користувачеві.

import java.net.http.HttpClient;
import java.time.Duration;

public class ReadLaterApplication {
    public static void main(String[] args) {
        HttpClient httpClient = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(2))
                .build();

        CatalogClient catalog = new CatalogClient(httpClient, "https://catalog.example");
        String json = catalog.searchRaw("clean code");

        System.out.println(json); // { ...JSON... }
    }
}

Звісно, у реальному сценарії у вас буде команда catalog search ... та аргументи. Але суть не в цьому. Суть у тому, що будь-яка команда матиме один і той самий стиль: «створив клієнта → викликав потрібний метод → отримав результат». Коли ви так робите, проєкт починає зростати без відчуття, що ви пишете лапшу.

Щоб закріпити картину, корисно уявити цей шматок архітектури як просту схему:

flowchart LR
    A["ReadLaterApplication
main()"] --> B["CatalogClient
транспортна межа"] B --> C["HttpClient
JDK"] C --> D["Зовнішній API каталогу
HTTP"] D --> C C --> B B --> A

Ця схема проста, але вона вже дисциплінує: зовнішній контракт живе всередині CatalogClient, а застосунок спілкується з ним через два зрозумілі методи.

8. Типові помилки під час виділення CatalogClient

Помилка №1: залишити половину транспортної логіки в main(), а половину винести в CatalogClient.
Іноді студент виносить send() у клієнта, але збирання URI робить у main() «бо так швидше». Підсумок — межа розмивається: точка входу все ще знає деталі зовнішнього API. Краще один раз ухвалити рішення: усе, що стосується HTTP-адрес, заголовків, таймаутів і статусів, — усередині клієнта.

Помилка №2: створювати новий HttpClient на кожен запит.
HttpClient задуманий як придатний для повторного використання об’єкт. Коли ви всередині searchRaw() пишете HttpClient.newHttpClient() — ви робите код важчим, а поведінку менш передбачуваною. Правильніше один раз зібрати HttpClient (наприклад, у main()), налаштувати connectTimeout і передати його в CatalogClient.

Помилка №3: вважати відповідь non-2xx «майже успіхом» і віддавати body як звичайний результат.
Це один із найнебезпечніших видів самообману: «ну JSON же прийшов, отже все нормально». На практиці це призводить до того, що далі ваш код почне розбирати error payload так, ніби це дані книги. Правило має бути жорстким: спочатку статус, потім body. Не навпаки.

Помилка №4: ловити Exception і робити вигляд, що все під контролем.
catch (Exception e) у транспортному шарі — це як заклеїти лампочку “Check engine” ізоляційною стрічкою. Машина їде, але ви вже не знаєте, що саме зламалося. Нам важливі принаймні базові відмінності: таймаут, IOException і переривання. І особливо важливо не забувати відновлювати interrupt-прапорець під час InterruptedException.

Помилка №5: повертати null або порожній рядок під час помилки.
Це виглядає «зручно», доки не стає незручно. Потім ви отримуєте NullPointerException у зовсім іншому місці й починаєте грати в «вгадай, чому null». У транспортному шарі краще викинути виняток із зрозумілим повідомленням і причиною, ніж вдавати, що все пройшло успішно.

1
Задача
Java Server, 15 рівень, 4 лекція
Недоступна
Виносимо пошук в окремий CatalogClient
Виносимо пошук в окремий CatalogClient
1
Задача
Java Server, 15 рівень, 4 лекція
Недоступна
Два сценарії в CatalogClient і короткий CLI-вхід
Два сценарії в CatalogClient і короткий CLI-вхід
1
Опитування
HTTP-клієнт, рівень 15, лекція 4
Недоступний
HTTP-клієнт
Запити й таймаути
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ