1. URI в transport-слое
Когда мы только начинаем писать HTTP-клиент, очень хочется воспринимать адрес как декоративную строку: «ну я же вижу URL, сейчас соберу его через + и поедем». Но в backend-мире адрес — это часть контракта, почти как сигнатура метода. Если вы ошиблись в URI, вы даже не дошли до этапа “HTTP как протокол”: запрос будет неправильным ещё до того, как сеть успеет вас разочаровать.
В ReadLater Starter адрес — это буквально вход в внешний каталог. Неверно собранная строка может означать всё что угодно: от “404 Not Found” до “400 Bad Request” или просто странных данных. И самое неприятное: такие ошибки часто выглядят как «API сломан» или «HttpClient кривой», хотя на самом деле вы просто отправили запрос по неверному адресу. То есть это чистая transport-проблема: не бизнес-логика и не «провайдер плохой», а вы накосячили в адресе (мы все через это проходили, да).
Давайте зафиксируем простую мысль: корректный URI — это первый слой надежности вашего HTTP-кода. Если он собран аккуратно, у вас сразу меньше “магических” ошибок, меньше загадочных багов и больше уверенности, что если что-то не работает — проблема уже дальше по цепочке, а не в банальном пробеле в строке.
В transport-коде всё идёт цепочкой: сначала надо не сломать сам адрес, потом превратить его в HttpRequest, потом пережить таймауты и неуспешные статусы. Если промахнуться уже на URI, дальше вы будете диагностировать не ту проблему. Поэтому начнём с самого первого слоя — как вообще собирать адрес без лотереи.
Опасности строковой конкатенации
Строки в Java — штука удобная, но коварная. Пока у вас адрес вида baseUrl + "/search?q=java", всё выглядит нормально. Но стоит появиться пробелам, спецсимволам или нескольким параметрам — и вы начинаете жить в мире ?, &, = и непечатных выражений лица, когда “вроде всё правильно, но сервер почему-то не понимает”.
Представьте, что пользователь ищет книгу по запросу "clean code". Если вы соберёте адрес так:
// Базовый адрес сервиса (как правило, берётся из конфигурации)
String baseUrl = "https://catalog.example";
// Пользовательский ввод: потенциально содержит пробелы и спецсимволы
String query = "clean code";
// Склеиваем "как есть" — это и есть источник проблем
String rawUrl = baseUrl + "/search?q=" + query;
System.out.println(rawUrl); // https://catalog.example/search?q=clean code
На глаз — ок. Но для URI пробел внутри query-строки — это не “просто пробел”. Это символ, который должен быть закодирован. В лучшем случае библиотека/сервер попытается вас “спасти”. В худшем — вы получите ошибку, которую будете лечить шаманским бубном и фразой: «Ну оно же работало вчера…».
Ещё более больной случай — когда в запросе есть &. Например "java & spring". Если вы не закодируете значение параметра, сервер увидит & как разделитель параметров и решит, что вы прислали два параметра, а не один. И всё: запрос изменил смысл.
И, наконец, мелочь, но с характером: слеши. Один лишний / может превратить https://catalog.example//search в странный адрес. Многие сервера это переживут, но “многие” — это не то же самое, что “всегда”. А мы хотим предсказуемости, а не лотереи.
2. URI в Java — это тип
В Java есть два популярных типа, которые новички часто путают: URL и URI. Для нашей задачи (сборка адреса запроса и передача его в HttpRequest) нам нужен именно URI. Это объектное представление адреса, которое помогает держать в голове, что у адреса есть структура, а не просто набор символов.
В JDK HttpRequest.newBuilder(...) работает с URI, и это не случайность. URI — более нейтральный и безопасный тип. Он не пытается «пойти в сеть» сам по себе, он просто описывает идентификатор ресурса. Это как “паспорт адреса”: он должен быть валидным, иначе вы ловите ошибку на ранней стадии и не делаете вид, что всё нормально.
В нашей учебной реальности нам чаще всего достаточно URI.create(...). Это самый прямой путь: вы собрали корректную строку и превратили её в URI.
import java.net.URI;
// Здесь мы явно говорим: "ожидаю корректный URI, иначе падай сразу"
URI uri = URI.create("https://catalog.example/search?q=java");
// Печатаем, чтобы увидеть, что получилось (пока без логгера)
System.out.println(uri); // https://catalog.example/search?q=java
URI.create(...) хорош тем, что он бросит IllegalArgumentException, если строка вообще не похожа на URI. Это полезно: вы ловите проблему раньше, чем она уйдёт в “почему send() не работает”.
Но важно помнить: URI не “починит” вам неправильно собранную query-строку. Если вы положили туда пробелы и странные символы — вы сами отвечаете за то, чтобы они были корректно закодированы. И вот тут нам нужен следующий герой.
3. URLEncoder: кодируем query-значения
Когда слышишь слово “кодирование”, хочется закодировать всё и сразу, чтобы наверняка. Это естественное желание, но оно ведёт к очень смешным (и очень грустным) результатам. Правило простое: кодировать нужно значения query-параметров, а не весь URL.
В Java для этого есть URLEncoder.encode(..., StandardCharsets.UTF_8). Он превращает “опасные” символы в безопасную форму. Для пробела, например, получится + (да, это историческая форма из мира HTML-форм; многие API нормально её понимают).
Вот правильный минимальный пример:
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
// База — как адрес сервиса, без пользовательских данных
String baseUrl = "https://catalog.example";
// Пользовательский запрос, который нужно безопасно вложить в query
String query = "clean code";
// Кодируем ТОЛЬКО значение параметра q
String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8);
// Собираем итоговый URI
URI uri = URI.create(baseUrl + "/search?q=" + encodedQuery);
// Проверяем глазами, что пробел превратился в +
System.out.println(uri); // https://catalog.example/search?q=clean+code
А теперь — пример “как делать не надо”, потому что это типичная ошибка:
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
// Это УЖЕ целый URL, со схемой, хостом, путём и query
String rawUrl = "https://catalog.example/search?q=clean code";
// Ошибка: кодируем весь URL целиком, ломая его структуру
String wrong = URLEncoder.encode(rawUrl, StandardCharsets.UTF_8);
System.out.println(wrong);
// https%3A%2F%2Fcatalog.example%2Fsearch%3Fq%3Dclean+code
На первый взгляд, “ну закодировалось же”. Но это больше не URL в человеческом понимании. Вы закодировали : и /, то есть сломали структуру адреса. Сервер такой запрос, скорее всего, не поймёт. Мы хотели закодировать только значение q, а не превратить весь адрес в “шифрограмму”.
Ещё один важный нюанс: кодировать лучше именно значения параметров. Ключи (q, limit) обычно безопасны и у вас фиксированы в коде. Если у вас ключи динамические (что уже звучит подозрительно для простого курса) — тогда нужно думать глубже, но в нашем проекте ключи статичны.
4. baseUrl, path, query: сборка
Хорошая сборка адреса начинается с уважения к структуре URL. Даже если вы в итоге используете URI.create(baseUrl + "..."), полезно мысленно держать адрес как три части: baseUrl (где живёт сервис), path (какой ресурс/endpoint вы хотите), и query (какие критерии/фильтры/параметры вы передаёте).
Для ReadLater Starter это очень наглядно. У нас есть как минимум два сценария внешнего каталога: поиск и детали книги. Поиск обычно выглядит как GET /search с query-параметрами (например, q и limit). Детали книги обычно выглядят как GET /books/{externalId}, то есть идентификатор ресурса живёт в path, а не в query.
Это различие полезно фиксировать прямо в коде, иначе вы легко начнёте мешать всё в одну кучу. А потом появится третий сценарий, четвёртый, и ваш main() превратится в роман на 300 страниц, где главная интрига — “где же я забыл амперсанд”.
Чтобы упростить жизнь, мы уже сегодня начнём делать то, что отличает backend-подход от “поиграюсь с HttpClient”: будем собирать URI в одном месте и одинаковыми правилами. Даже если пока это будут просто приватные методы в ReadLaterApplication.
Небольшая памятка в виде таблицы помогает удерживать дисциплину:
| Что это | Где живёт в URL | Пример | Когда использовать |
|---|---|---|---|
| Идентификатор ресурса | path | /books/OL12345M | когда обращаемся к конкретной сущности |
| Критерии поиска/фильтрации | query | ?q=clean+code&limit=5 | когда уточняем, что хотим получить |
| Базовый адрес сервиса | scheme + host (+port) | https://catalog.example | это “где живёт API” |
Эта таблица не про “правильно/неправильно навсегда”, а про дисциплину для простого и понятного API-клиента. На bridge-уровне она делает ваш код заметно спокойнее.
5. Helper-методы сборки URI
Сейчас мы сделаем маленький, но очень “окупаемый” шаг: вынесем сборку URI в методы. Они будут небольшими и прямолинейными. Их задача — не “построить универсальный URI builder для всего интернета”, а просто зафиксировать правила именно нашего клиентского сценария: поиск и детали.
Для начала можно разместить методы прямо в ReadLaterApplication. Да, это не финальная архитектура, но методически это нормально: мы учимся выделять ответственность постепенно. Главное — чтобы сборка адреса не копировалась по всему коду.
Пример для поиска, где query нужно обязательно кодировать, а limit — число:
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
private static URI buildSearchUri(String baseUrl, String query, int limit) {
// Кодируем пользовательский ввод, чтобы пробелы/&,= и т.д. не ломали query-строку
String encodedQuery = URLEncoder.encode(query, StandardCharsets.UTF_8);
// limit — число, оно безопасно (и обычно идёт как есть)
return URI.create(baseUrl + "/search?q=" + encodedQuery + "&limit=" + limit);
}
И пример для деталей. Здесь мы кладём externalId в path. В нашем учебном контракте externalId выглядит как OL12345M, то есть безопасен. Поэтому мы не усложняем тему отдельным “кодированием path segment”.
import java.net.URI;
private static URI buildDetailsUri(String baseUrl, String externalId) {
// externalId — часть пути (path), а не query
return URI.create(baseUrl + "/books/" + externalId);
}
Обратите внимание на важную вещь: у нас теперь “разная форма адреса” оформлена не в голове и не в комментариях, а в коде. Это сильно помогает не перепутать сценарии, особенно когда вы дописываете проект через неделю и уже не помните, почему вы тогда решили сделать именно так.
6. baseUrl: нормализация
Есть один мелкий баг, который живёт почти в каждом учебном проекте, пока кто-то не устанет и не починит. Это trailing slash. То есть baseUrl может быть "https://catalog.example" или "https://catalog.example/". Оба значения визуально похожи. Но если вы слепо делаете baseUrl + "/search", во втором случае получится //search.
Часть серверов и прокси на это реагируют спокойно, часть — не очень. И вот вы сидите и думаете: “Почему в одном окружении работает, а в другом 404?”. А причина — два слеша подряд, которые вы даже не заметили.
Давайте добавим простую нормализацию. Она не идеальна для всех случаев мира, но для нашего курса отлично подходит: если строка заканчивается /, уберём его.
private static String normalizeBaseUrl(String baseUrl) {
// Убираем trailing slash, чтобы дальше не получить //search или //books/...
if (baseUrl.endsWith("/")) {
return baseUrl.substring(0, baseUrl.length() - 1);
}
return baseUrl;
}
Теперь сборка URI будет выглядеть чуть аккуратнее:
// Даже если конфиг принёс baseUrl со слешем на конце — приводим к нормальной форме
String baseUrl = normalizeBaseUrl("https://catalog.example/");
// Дальше собираем URI одним правилом (через helper), а не "как получится"
URI uri = buildSearchUri(baseUrl, "clean code", 5);
System.out.println(uri); // https://catalog.example/search?q=clean+code&limit=5
Да, это всего пару строк. Но это тот случай, когда “пара строк” экономит пару часов тупого дебага. Такие мелочи потом очень отличают «код, который живёт» от «код, который был написан вчера ночью на вдохновении».
7. Самопроверка: печатаем URI
До того как мы начнём усложнять клиент (таймауты, обработка ошибок, выделение transport-слоя), очень полезно научиться делать маленькую самопроверку: “я вообще туда иду?”. Потому что если вы собрались не туда, то дальше вы будете чинить всё, кроме причины. Это как лечить насморк перепрошивкой роутера: технически можно, но выглядит странно.
Сделаем маленький фрагмент кода, который просто печатает итоговые URI. Да, потом мы заменим “печать” на логирование. Но сейчас наша цель — увидеть глазами, что сборка адреса работает как задумано.
// В реальном проекте baseUrl обычно приходит из конфигурации, поэтому нормализация — полезная привычка
String baseUrl = normalizeBaseUrl("https://catalog.example");
// Поиск: query кодируем, limit добавляем как число
URI searchUri = buildSearchUri(baseUrl, "java & spring", 3);
// Детали: идентификатор кладём в path
URI detailsUri = buildDetailsUri(baseUrl, "OL12345M");
// Печатаем итог: так проще всего поймать "куда именно ушёл запрос"
System.out.println(searchUri); // https://catalog.example/search?q=java+%26+spring&limit=3
System.out.println(detailsUri); // https://catalog.example/books/OL12345M
Здесь важно заметить, что & в запросе превратился в %26. Это ровно то, чего мы хотели: значение параметра осталось одним значением, а не сломало строку на “ещё один параметр”.
Если у вас где-то в коде всё ещё есть склейка URL в стиле baseUrl + "/search?q=" + query, это хороший момент заменить её на вызов helper-метода. Не ради “красоты”, а ради того, чтобы правило сборки адреса было единым и не расходилось по проекту.
9. Типичные ошибки сборки URI
Ошибка №1: “Я склею всё строками, что может пойти не так?”
Это обычно работает ровно до первого пробела или & в query. Ошибка здесь не в том, что строки — зло, а в том, что вы начинаете смешивать структуру URI (path/query) и пользовательские данные в один “бульон”. Правильная мысль: пользовательские данные почти всегда нужно кодировать.
Ошибка №2: кодировать весь URL вместо значения параметра.
Чаще всего это происходит из благих намерений: “мне сказали, что надо encode, вот я и encode”. В итоге вы закодировали https:// и слеши, и адрес перестал быть адресом. Кодировать нужно то, что потенциально содержит спецсимволы, то есть значения query-параметров.
Ошибка №3: забыть нормализовать baseUrl и получить двойной //.
Это не всегда ломает запрос, поэтому ошибка коварная. Она проявляется “иногда” и “не везде”. А такие баги — самые неприятные, потому что мозг сразу начинает подозревать сеть, провайдера, Java, луну в фазе ретроградного меркурия. Лучше просто привести baseUrl к единому виду.
Ошибка №4: пытаться пихать идентификатор ресурса в query-параметры без причины.
Например, делать /books?id=OL12345M, когда контракт подразумевает /books/OL12345M. Это не “смертельно”, но в большинстве API идентификатор — часть пути (path), а query — фильтры и критерии. Если вы держитесь этой дисциплины, код становится предсказуемее.
Ошибка №5: дублировать правила сборки URI в нескольких местах.
Сегодня вы добавили &limit=5 в одном месте, завтра — забыли в другом, послезавтра — поменяли path /search на /books/search и половина кода продолжила ходить по старому адресу. Это типичная причина “оно работает через раз”. Один helper-метод или один небольшой класс для сборки адресов решает эту проблему сразу.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ