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». У транспортному шарі краще викинути виняток із зрозумілим повідомленням і причиною, ніж вдавати, що все пройшло успішно.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ