1. HttpRequest как «паспорт» запроса
Когда новичок пишет HTTP-клиент, он часто думает так: «Я же уже знаю URL, значит запрос почти готов». Но в реальности URL — это только адрес. Чтобы запрос стал запросом, нам нужно зафиксировать ещё метод, заголовки и, если это не GET, то и body. В Java объект HttpRequest как раз и выступает «паспортом» вызова: в нём явно записано, что мы просим у сервера и в каком виде.
Если смотреть на это «с высоты птичьего полёта», то HttpRequest — это то, что вы сначала аккуратно собираете, а потом отдаёте в client.send(...). Поэтому чем читабельнее выглядит сборка запроса, тем меньше у вас загадок уровня «почему сервер меня не понимает».
Небольшая схема (без попыток стать учебником RFC):
flowchart TD
%% Важно: сеть появляется только на этапе send, до этого мы просто "собираем паспорт" запроса
A["URI (куда идём)"] --> B["HttpRequest.Builder (как именно идём)"]
B --> C["HttpRequest (зафиксированная конфигурация)"]
C --> D["HttpClient.send(...) (попытка сходить в сеть)"]
D --> E["HttpResponse (status + headers + body)"]
Обратите внимание: сеть появляется только на этапе send. Всё, что до этого — наши решения, и их удобно видеть прямо в коде.
Минимальный «скелет» запроса выглядит так:
import java.net.URI;
import java.net.http.HttpRequest;
// Куда идём: это только адрес, не "готовый запрос"
URI uri = URI.create("https://catalog.example/search?q=java");
// Собираем "паспорт" запроса: метод + (потом при необходимости) заголовки и body
HttpRequest request = HttpRequest.newBuilder(uri)
.GET() // Явно фиксируем метод
.build(); // После build() запрос становится неизменяемым
Да, это уже запрос. Но пока он слишком «немой»: в нём нет договорённости о формате ответа, и в реальном проекте такие запросы быстро превращаются в «работает… пока работает».
2. HttpRequest.Builder: сборка запроса
В HttpClient-API очень удачно сделано то, что новичку обычно нравится: запрос собирается через builder, а значит его можно читать сверху вниз как небольшой сценарий. В каждой строке вы как будто отвечаете на вопрос: куда идём, что ждём в ответ, чем (если надо) нагружаем запрос, какой метод выбираем. Это особенно полезно в учебном проекте, где мы специально не прячем механику за фреймворками.
Важный момент: HttpRequest после build() становится неизменяемым. Это означает, что запрос «заморожен» и случайно его не испортить. А вот HttpRequest.Builder — штука временная: собрали, построили, забыли. Не пытайтесь «переиспользовать builder на все случаи жизни», иначе довольно быстро поймаете неочевидную кашу: у вас в одном месте добавили заголовок, а он внезапно «прилепился» к другому запросу.
Типичная читабельная сборка GET-запроса в нашем стиле:
import java.net.URI;
import java.net.http.HttpRequest;
URI uri = URI.create("https://catalog.example/search?q=clean+code");
HttpRequest request = HttpRequest.newBuilder(uri)
.header("Accept", "application/json") // Договариваемся о формате ответа
.GET() // Фиксируем метод
.build(); // "Замораживаем" конфигурацию запроса
Здесь уже видно три вещи: куда идём, что хотим получить, каким методом идём. По сути, это и есть наш маленький «контракт» клиента с сервером, только со стороны клиента.
3. GET: header Accept и «вежливое» ожидание JSON-ответа
До этого момента мы часто работали с ответом как с сырой строкой, и это нормально для старта. Но даже когда мы читаем body как строку, нам всё равно важно договориться с сервером, какой формат ответа мы ожидаем. В HTTP для этого есть заголовок Accept. Он означает: «Сервер, пожалуйста, если ты умеешь, ответь мне в таком формате».
В реальности некоторые API всегда отвечают JSON и игнорируют Accept. Но в учебном проекте нам важна привычка: Accept — часть нормального HTTP-контракта. Плюс бывают API, которые умеют отвечать и JSON, и чем-нибудь ещё (например, HTML или XML), и там Accept реально решает, что вы получите.
Давайте продолжим наш сценарий поиска книг в каталоге. Предположим, мы уже собрали URI (из лекции 1), и теперь делаем запрос:
import java.net.URI;
import java.net.http.HttpRequest;
// В URI уже зашиты query-параметры (это часть адреса)
URI uri = URI.create("https://catalog.example/search?q=clean+code&limit=5");
HttpRequest request = HttpRequest.newBuilder(uri)
.header("Accept", "application/json") // Просим JSON (если сервер умеет)
.GET() // GET без body — нормальный сценарий
.build();
Обратите внимание: мы не ставим Content-Type для GET. Почему? Потому что Content-Type описывает тело запроса, а в GET тела обычно нет. Да, бывают экзотические случаи «GET с body», но если вы не хотите страдать — не надо. Мы строим предсказуемый клиент, а не ребус.
Иногда полезно добавить ещё и User-Agent. Некоторые внешние сервисы относятся к нему капризно, а иногда по нему проще диагностировать, что запрос пришёл именно от нашего приложения. Но не превращайте заголовки в коллекцию марок: добавляем только то, что понимаем.
Например, вот так:
import java.net.URI;
import java.net.http.HttpRequest;
URI uri = URI.create("https://catalog.example/search?q=java");
HttpRequest request = HttpRequest.newBuilder(uri)
.header("Accept", "application/json") // Что хотим получить
.header("User-Agent", "readlater-starter/1.0") // Кто мы такие (для диагностики и капризных API)
.GET()
.build();
4. Accept и Content-Type: различия
Путаница Accept и Content-Type — это почти обязательный «ритуал посвящения» в HTTP. Ничего страшного: у этих заголовков действительно похожий вид, но смысл у них разный. Чтобы перестать путать, удобно держать в голове простую идею: один заголовок говорит про то, что вы хотите получить, а другой — про то, что вы отправляете.
Можно даже представить диалог:
- Accept: «Я умею воспринимать JSON, пожалуйста, ответь JSON-ом».
- Content-Type: «Я отправляю тебе JSON. Не думай, что это plain text или что-то ещё».
И это работает и для запросов, и для ответов. В ответе сервера Content-Type означает: «Вот в каком формате лежит body ответа».
Сведём различия в короткую табличку (она реально экономит время на старте):
| Заголовок | Где встречается | О чём он | Пример |
|---|---|---|---|
| Accept | в запросе клиента | какой формат ответа клиент предпочитает/умеет | Accept: application/json |
| Content-Type | в запросе клиента | в каком формате client отправляет body | Content-Type: application/json |
| Content-Type | в ответе сервера | в каком формате server возвращает body | Content-Type: application/json; charset=utf-8 |
Если хочется совсем бытовую аналогию, то Accept — это как фраза «мне, пожалуйста, кофе без молока», а Content-Type — это наклейка на контейнере «внутри суп». Вроде оба про еду, но один про ожидание, другой про фактическое содержимое.
5. POST с JSON-body: BodyPublishers.ofString
POST мы сегодня используем в учебных целях, потому что на реальных каталогах книг поиск часто делается GET-ом с query-параметрами. Но отправка JSON-body — это настолько частая история в backend-интеграциях, что пройти мимо неё нельзя. Даже если сегодня вы не будете реально слать POST в наш внешний каталог, механика должна стать знакомой: метод + headers + body.
В HttpRequest body задаётся через BodyPublisher. Самый простой вариант — отправить строку, то есть BodyPublishers.ofString(...). И вот здесь появляется два практичных нюанса: JSON-строку нужно собрать без ошибок, и нужно выставить Content-Type: application/json.
В Java 25 нам очень помогают text blocks. Они экономят нервы, потому что не нужно экранировать каждую кавычку в JSON. Вместо вот такого «кавычечного ада» мы пишем почти нормальный JSON.
Пример «учебного запроса», который отправляет JSON на условный endpoint /preview:
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.BodyPublishers;
import java.nio.charset.StandardCharsets;
String baseUrl = "https://catalog.example";
// Тело запроса: JSON удобно держать как text block, чтобы не экранировать кавычки
String json = """
{
"query": "clean code",
"limit": 5
}
""";
HttpRequest request = HttpRequest.newBuilder(URI.create(baseUrl + "/preview"))
.header("Accept", "application/json") // Хотим получить JSON-ответ
.header("Content-Type", "application/json") // Сообщаем, что мы отправляем JSON
.POST(BodyPublishers.ofString(json, StandardCharsets.UTF_8)) // Фиксируем method + body + кодировку
.build();
Здесь важно увидеть «костяк» POST-запроса глазами:
1) POST(...) делает запрос именно POST-ом и одновременно задаёт body.
2) Content-Type говорит серверу, что внутри body JSON.
3) Accept говорит серверу, что мы хотим получить JSON в ответ.
Если вы забудете Content-Type, некоторые серверы будут пытаться интерпретировать body как «не пойми что», и вы получите ошибку, которая выглядит как «сервер сломан». На самом деле он просто не понял, что вы ему отправили.
Ещё один маленький момент: многие API ожидают Content-Type: application/json; charset=utf-8. В современном мире JSON почти всегда в UTF‑8, и многие сервера понимают это по умолчанию. Но если вы видите странные проблемы с кириллицей или спецсимволами, имеет смысл указывать charset явно. В рамках наших примеров мы оставим application/json ради простоты, а кодировку обеспечим через ofString(...).
6. Повторяющиеся заголовки: маленький helper
Когда вы делаете два-три запроса, всё кажется простым. Но как только появляется «поиск» и «детали», а потом ещё парочка вариантов, вы внезапно обнаруживаете, что в каждом месте копируете одно и то же: Accept, иногда User-Agent, иногда Content-Type. Копипаст в transport-коде опасен не философски, а очень практично: одна строка где-то уехала — и у вас «почему-то один запрос работает, а другой нет».
При этом мы не хотим строить «универсальный фреймворк запросов». Нам нужен маленький, честный helper, чтобы сборка запроса стала короче, но не потеряла понятность.
Простейший вариант — метод, который возвращает настроенный HttpRequest.Builder под JSON-ответы:
import java.net.URI;
import java.net.http.HttpRequest;
private static HttpRequest.Builder jsonRequest(URI uri) {
return HttpRequest.newBuilder(uri)
.header("Accept", "application/json") // Базовая договорённость: хотим JSON
.header("User-Agent", "readlater-starter/1.0");// Базовая идентификация клиента
}
Тогда GET становится совсем читабельным:
import java.net.URI;
import java.net.http.HttpRequest;
URI uri = URI.create("https://catalog.example/search?q=java");
HttpRequest request = jsonRequest(uri) // Берём базовые заголовки (Accept + User-Agent)
.GET() // Добавляем метод для конкретного запроса
.build();
А POST — тоже:
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.BodyPublishers;
import java.nio.charset.StandardCharsets;
// Короткий JSON тоже можно держать как text block, чтобы не возиться с экранированием
String json = """
{
"query": "java",
"limit": 3
}
""";
HttpRequest request = jsonRequest(URI.create("https://catalog.example/preview"))
.header("Content-Type", "application/json") // Для POST с body это важно: что именно мы отправляем
.POST(BodyPublishers.ofString(json, StandardCharsets.UTF_8)) // Тело + кодировка
.build();
Заметьте, мы не пытаемся прятать «POST с body» куда-то в глубины. Мы просто убрали повторяющиеся куски, а смысл запроса по-прежнему читается в 5–6 строк.
7. Типичные ошибки при сборке HttpRequest
Ошибка №1: путать Accept и Content-Type.
Самая частая история выглядит так: вы делаете POST, выставляете Accept: application/json, думаете «ну я же про JSON сказал», и удивляетесь, почему сервер ругается на тело. Серверу всё равно, что вы хотите получить в ответ; ему нужно понять, что вы отправили. Для этого и существует Content-Type.
Ошибка №2: отправить POST без body (или с пустым body) случайно.
Иногда хочется написать просто .POST(...), но «что туда передать» непонятно, и рука тянется к чему-нибудь вроде BodyPublishers.noBody(). Формально это возможно, но в JSON-сценариях обычно бессмысленно: сервер ждал JSON, а вы отправили «ничего». Если сценарий действительно про JSON, то body должен быть явным.
Ошибка №3: собрать JSON строкой с кавычками и сломать её на ровном месте.
Строка вида "{\"query\":\"clean code\"}" выглядит как наказание за грехи из прошлой жизни. В ней легко забыть обратный слэш, закрыть кавычку не там и получить «невалидный JSON». В учебном проекте лучше либо использовать Java text block (""" ... """), либо держать JSON коротким и проверяемым.
Ошибка №4: выставить Content-Type на GET «на всякий случай».
Content-Type описывает тело запроса. В GET тела нет, и заголовок становится странным шумом. Иногда серверы терпеливы, но иногда вы попадаете на API, которое делает строгую проверку и начинает вести себя непредсказуемо. «На всякий случай» в HTTP — это частый путь к неожиданностям.
Ошибка №5: пытаться переиспользовать один HttpRequest.Builder для разных запросов.
Builder удобно выглядит как «объект-конфигуратор», и возникает мысль: «создам один, а потом буду менять URI». На практике вы быстро забываете, какие заголовки вы уже добавили, и получаете случайный набор headers в разных запросах. Создавайте builder заново на каждый запрос, а общий минимум выносите в маленький helper (как мы сделали выше).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ