1. URI и заголовки после маршрутизации
После того как вы научились выбирать ветку обработки по method + path, появляется ощущение: “ну всё, сервер почти готов”. И тут реальность мягко кладёт руку на плечо и говорит: “А теперь достань из запроса id, фильтры, и ещё проверь Content-Type”. Это отдельная задача, и она должна происходить после выбора маршрута, иначе вы начнёте парсить id даже там, где id не существует (например, в /health).
В HTTP‑запросе данные живут в разных местах, и это не прихоть стандарта, а способ сделать контракт предсказуемым. Путь обычно отвечает за “какой ресурс” (например, конкретный элемент списка чтения), query params — за “какими условиями его читать” (фильтр/поиск), headers — за “служебную информацию” (формат тела, ожидаемый формат ответа и т.д.). Если вы смешаете всё в одну “строку запроса” и начнёте выдёргивать значения через contains(...) и магические substring(...), вы получите код, который ломается от лишнего слэша или пробела — то есть от обычной жизни.
Чтобы зафиксировать картину, полезно держать в голове вот такую мини‑таблицу (она поможет не путать “куда что класть” и “откуда что читать”):
| Где лежит значение | Пример | Обычно для чего | Как читаем в HttpExchange |
|---|---|---|---|
| Path | /api/v1/reading-list/42 | идентичность ресурса (id) | exchange.getRequestURI().getPath() |
| Query string | ?status=PLANNED&title=clean | фильтры, параметры чтения | exchange.getRequestURI().getQuery() / getRawQuery() |
| Headers | Content-Type: application/json | метаданные запроса/ответа | exchange.getRequestHeaders().getFirst(...) |
И ещё один важный момент: в этом курсе мы сознательно не пишем “универсальный роутер” и не строим мини‑Spring. Мы делаем маленькие, понятные утилиты, которые убирают дублирование, но не скрывают механику. То есть ровно столько удобства, чтобы не страдать — и ровно столько прозрачности, чтобы понимать, что происходит.
2. URI в HttpExchange: path и query
Когда вы впервые видите exchange.getRequestURI(), хочется взять toString() и дальше жить с этим как с обычной строкой. Это примерно как взять паспорт, отсканировать в JPEG и потом пытаться “прочитать возраст” через поиск по пикселям. Можно, но зачем? URI уже умеет отдавать части адреса отдельно — и этим стоит пользоваться, чтобы не перепутать путь и query‑часть.
Минимальный “скелет” чтения URI выглядит так: берём URI из HttpExchange, отдельно читаем path, отдельно читаем query. И сразу закладываемся на то, что query может быть null (если ? в запросе не было). Это не ошибка клиента, это нормальная ситуация: фильтр не задан — значит, фильтровать не нужно.
Небольшой пример “разделили и не перепутали”:
import com.sun.net.httpserver.HttpExchange;
import java.net.URI;
// URI содержит структуру адреса: path, query и т.д.
URI uri = exchange.getRequestURI();
// path — то, по чему мы маршрутизируем (без query)
String path = uri.getPath();
// query — параметры после '?', может быть null
String query = uri.getQuery();
Чтобы почувствовать разницу, представьте два запроса:
1) GET /api/v1/reading-list/42
2) GET /api/v1/reading-list/42?debug=true
У них одинаковый path, но второй несёт дополнительный параметр в query string. Если вы будете работать с “полным URI как строкой”, вы легко случайно начнёте сравнивать маршрут с "/api/v1/reading-list/42?debug=true" и получите “маршрут не найден” там, где всё корректно.
Иногда полезно использовать getRawQuery() вместо getQuery(). В реальной жизни query‑часть может содержать URL‑кодирование (%20 и т.п.), и “raw” вариант честнее показывает то, что реально пришло по HTTP. Для простого учебного сервера это не критично, но полезно знать, что такой метод есть.
import java.net.URI;
// rawQuery отдаёт query как пришло по сети (с %20 и т.п.), может быть null
String rawQuery = uri.getRawQuery();
С этого места начинается дисциплина: маршрут выбираем по path (без query), а query разбираем отдельно, только когда нам реально нужны фильтры.
3. Сегменты пути и извлечение id
Путь в URI — это строка вида /api/v1/reading-list/42. Логически она состоит из сегментов (кусочков между /). Это очень удобно, потому что многие маршруты строятся по простому правилу: “последний сегмент — идентификатор”. Но тут есть коварная мелочь: из‑за ведущего слэша первый элемент после split("/") часто оказывается пустым. А ещё бывает хвостовой слэш, и тогда последний элемент может быть пустым. Такие “пустые сегменты” — классическая ловушка для новичка.
Самый приятный способ — сделать маленькую функцию, которая превращает path в список сегментов, фильтруя пустые строки. Тогда и ведущий /, и случайный хвостовой / перестают быть проблемой.
import java.util.Arrays;
import java.util.List;
private List<String> pathSegments(String path) {
// Делим путь по '/', а потом выбрасываем пустые элементы
// (они появляются из-за ведущего '/' или двойных/хвостовых слэшей)
return Arrays.stream(path.split("/"))
.filter(s -> !s.isBlank())
.toList();
}
Теперь можно легко получить последний сегмент, не думая, что там “в начале пусто, в конце пусто, а где вообще мои данные”.
import java.util.List;
private String lastSegment(String path) {
List<String> segments = pathSegments(path);
// Пустой список сегментов — это, по сути, некорректный путь для нашего кейса
if (segments.isEmpty()) {
throw new IllegalArgumentException("Пустой путь");
}
// Последний сегмент часто и есть id (например, /reading-list/42)
return segments.get(segments.size() - 1);
}
И уже поверх этого — извлечь id как long. Обратите внимание: Long.parseLong(...) бросит NumberFormatException, и это нормально. На уровне HTTP‑границы мы часто хотим превратить такие ошибки в “понятное” сообщение вроде “id должен быть числом”. Технически это всё ещё “ошибка запроса клиента”, а не “сломался сервер”.
private long parseIdFromPath(String path) {
try {
// Берём последний сегмент и ожидаем, что это число
return Long.parseLong(lastSegment(path));
} catch (NumberFormatException e) {
// Это не ошибка сервера: клиент прислал нечисловой id
throw new IllegalArgumentException("id должен быть числом");
}
}
Ключевой дисциплинарный момент: parseIdFromPath(...) вызывается только в той ветке, где маршрут действительно ожидает id. То есть сначала маршрутизация сказала “это похоже на /api/v1/reading-list/{id}”, и только потом мы полезли извлекать id. Если вы начнёте парсить id “на входе вообще для всех запросов”, то запрос GET /health внезапно начнёт падать с ошибкой “id должен быть числом”. И это будет не клиент виноват, а вы, потому что решили, что любой путь обязан заканчиваться числом. (Да, это звучит глупо — но на практике именно так и появляются странные баги.)
Чтобы связать это с нашим проектом, представим, что в лекции 1 у нас уже есть ветка маршрутизации:
// Сначала — выбор ветки по method + path (без query)
if (method.equals("GET") && path.startsWith("/api/v1/reading-list/")) {
// Только тут мы ожидаем, что в конце пути есть id
handleGetByIdRoute(exchange);
return;
}
Внутри handleGetByIdRoute(...) можно сделать аккуратный разбор:
// В этой ветке маршрут уже выбран, поэтому безопасно доставать id из path
String path = exchange.getRequestURI().getPath();
long id = parseIdFromPath(path);
// дальше — пока просто печатаем/логируем, бизнес-логики ещё нет
Мы сознательно не лезем дальше в прикладную логику и не делаем “получить объект из репозитория” — это будет позже. Сейчас цель — научиться без истерики доставать из path то, что там лежит.
4. Query string и параметры в Map
Query string — это кусок URI после вопросительного знака. Там обычно живут параметры чтения: фильтры, поиск, лимиты. В нашем ReadLater API это пригодится очень скоро, потому что список можно читать не только целиком, но и, например, с фильтрами. Но сегодня наша задача не “как фильтровать”, а “как прочитать параметры в Java, чтобы дальше можно было с ними работать”.
Первое, что нужно принять: uri.getQuery() может вернуть null. Это не “ошибка запроса”, просто параметров не было. Второе: query string — это формат вида key=value&key2=value2, но в значениях могут быть спецсимволы и пробелы, поэтому часто используется URL‑кодирование (%20 вместо пробела и т.п.). Мы не будем уходить в сложные кейсы, но базовую декодировку сделать полезно — иначе title=clean%20code останется “как есть” и будет выглядеть странно.
Сначала сделаем маленький decode(...) с URLDecoder. Он не идеален для всех сценариев мира, но для учебного API более чем окей.
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
private String decode(String value) {
// Декодируем %XX-последовательности (например, %20 -> пробел)
return URLDecoder.decode(value, StandardCharsets.UTF_8);
}
Теперь парсер query string в Map<String, String>. Мы специально делим pair.split("=", 2), потому что значение может содержать =. Если делить без ограничения, вы получите “кусочки токена” вместо значения — и будете грустить, не понимая, почему у вас “пропали символы”.
import java.util.HashMap;
import java.util.Map;
private Map<String, String> parseQuery(String query) {
Map<String, String> result = new HashMap<>();
// Если query отсутствует — это нормально, просто вернём пустую map
if (query == null || query.isBlank()) return result;
// Формат: key=value&key2=value2
for (String pair : query.split("&")) {
// Делим на ключ и значение только один раз
String[] parts = pair.split("=", 2);
// Ключ всегда есть (хотя может быть пустым — это уже странный запрос)
String key = decode(parts[0]);
// Значение может отсутствовать: ?title= -> parts.length == 2, value == ""
// или даже ?title -> parts.length == 1, тогда считаем значение пустой строкой
String value = parts.length == 2 ? decode(parts[1]) : "";
// Для учебного кейса достаточно Map<String, String> (последнее значение победит)
result.put(key, value);
}
return result;
}
И как это выглядит “на месте” в handler‑коде:
URI uri = exchange.getRequestURI();
// Читаем rawQuery, чтобы видеть реальную строку (с %XX), а не "уже декодированную"
Map<String, String> queryParams = parseQuery(uri.getRawQuery());
// Параметры могут отсутствовать — это нормальная ситуация
String status = queryParams.get("status"); // может быть null
String title = queryParams.get("title"); // может быть null
Обратите внимание на философию: мы считаем нормой, что ключ может отсутствовать. status == null означает “фильтр по статусу не задан”, а не “ой, всё пропало, срочно 500”. Реальная жизнь HTTP‑API почти всегда начинается именно с таких “может быть null”.
Иногда удобно иметь маленький безопасный геттер: “верни параметр, но только если он реально непустой”. Это помогает не путать ?title= и отсутствие параметра.
import java.util.Map;
import java.util.Optional;
private Optional<String> queryParam(Map<String, String> params, String name) {
// ofNullable — чтобы отсутствие ключа не приводило к NPE
return Optional.ofNullable(params.get(name))
// filter — чтобы отсечь пустую строку (например, ?title=)
.filter(v -> !v.isBlank());
}
Тогда использование становится понятнее, даже если вы пока не фанат Optional:
// Если параметр не задан или пустой — получим Optional.empty()
Optional<String> title = queryParam(queryParams, "title");
И ещё один честный нюанс: в реальных API может быть несколько одинаковых query параметров (tag=java&tag=backend). Наш простой Map такой сценарий “схлопнет” (оставит последнее значение). Для курса это нормально: мы не строим поисковый движок. Главное — научиться базово разбирать query, не утонув в строковых операциях.
5. Headers: Content-Type, Accept и null
После URI у нас остаётся ещё один источник входных данных: заголовки. Для новичка headers часто выглядят как “какая-то служебная пыль”, но в HTTP они реально меняют поведение. Самый полезный для нас сейчас — Content-Type: он говорит, что лежит в request body. От этого зависит, можно ли вообще пытаться читать тело как JSON и маппить его в request DTO.
В HttpExchange заголовки читаются через exchange.getRequestHeaders(). Это объект типа Headers, который ведёт себя как map “имя заголовка → список значений”. Но для простого учебного API чаще всего достаточно getFirst(...), чтобы взять первое значение.
import com.sun.net.httpserver.HttpExchange;
// Заголовки запроса; getFirst(...) вернёт первое значение или null, если заголовка нет
String contentType = exchange.getRequestHeaders().getFirst("Content-Type");
String accept = exchange.getRequestHeaders().getFirst("Accept");
И вот здесь важно психологически принять: contentType может быть null. Например, в GET /health обычно нет тела запроса, и клиент не обязан отправлять Content-Type. Если ваш код будет думать “заголовок обязателен всегда”, вы начнёте “ломать” вполне корректные запросы.
Когда мы дойдём до JSON‑тела, нам понадобится мягкая проверка: “если endpoint ожидает JSON, то Content-Type должен быть application/json”. Для учебного уровня достаточно проверки через startsWith(...), потому что реальные заголовки часто выглядят так: application/json; charset=UTF-8.
private boolean isJsonContentType(String contentType) {
// null — нормальный вариант для запросов без body
return contentType != null && contentType.startsWith("application/json");
}
Если вам хочется сразу делать “строже”, можно выбрасывать IllegalArgumentException (а потом переводить её в 400 Bad Request рядом с handler‑кодом). Мы пока не строим идеальную систему ошибок, но уже учимся отделять “плохой запрос” от “плохого сервера”.
private void requireJsonContentType(String contentType) {
// Проверка нужна только там, где реально ожидается JSON-тело
if (!isJsonContentType(contentType)) {
throw new IllegalArgumentException("Content-Type должен быть application/json");
}
}
И пример использования в конкретной ветке маршрута (не на входе вообще, а там, где есть body):
// Content-Type имеет смысл проверять только в эндпоинтах с body (например, POST/PUT)
String contentType = exchange.getRequestHeaders().getFirst("Content-Type");
requireJsonContentType(contentType);
// дальше уже будем читать body
В headers можно читать и любые “пользовательские” заголовки, если вы захотите. Но важное правило для новичка простое: пока вы не уверены, что заголовок обязателен по контракту, относитесь к его отсутствию как к нормальному варианту, а не как к катастрофе.
6. Утилиты в readinglist.http
Когда вы в одном методе handle(...) и маршрутизируете, и режете путь на сегменты, и разбираете query string, и читаете заголовки — метод начинает распухать. Потом вы добавляете второй endpoint и копируете те же parseQuery(...) и lastSegment(...) в другой класс. Потом третий. И внезапно вы сами себе построили “фреймворк на копипасте”. У фреймворков хотя бы документация бывает — у копипасты нет даже этого.
На масштабе одного handler-а такие вещи спокойно живут как private helper-методы рядом с route(...). Это нормальный baseline дня. Отдельный utility‑класс нужен только тогда, когда path/query/header‑шум начал реально повторяться в нескольких местах. То есть это не вторая обязательная архитектура, а обычная extraction, когда код сам попросил об этом.
Если захотите вынести самые механические операции, это может выглядеть так:
import com.sun.net.httpserver.HttpExchange;
import java.net.URI;
final class HttpRequestUtils {
private HttpRequestUtils() {
// Утилитный класс: экземпляры не нужны
}
static URI uri(HttpExchange exchange) {
// Централизуем доступ к URI — дальше можно расширять (rawQuery, host и т.д.)
return exchange.getRequestURI();
}
static String path(HttpExchange exchange) {
// path используем для маршрутизации (без query)
return uri(exchange).getPath();
}
}
Такие helper-ы чаще всего подключаются в body-less GET-ветках: роутер выбрал маршрут, дальше вы достали id из path, фильтры из query, нужный header — и пошли дальше. В ветках с body к этому добавляется ещё один шаг: проверить Content-Type, потом читать тело.
Вот как это может упростить код в обработчике маршрута:
// handler-код читает "сценарием", а не россыпью технических деталей
String path = HttpRequestUtils.path(exchange);
// Query и заголовки — отдельные источники данных (и их читаем отдельно)
Map<String, String> queryParams = parseQuery(exchange.getRequestURI().getRawQuery());
String contentType = exchange.getRequestHeaders().getFirst("Content-Type");
// дальше — уже логика конкретного маршрута
Смысл этих утилит не в “красоте ради красоты”, а в том, чтобы ваш ReadingListHandler читался как сценарий: “принял запрос → разобрал нужные части → передал дальше”. А технические детали были рядом, но не мешали понимать поток.
И да, это тот самый момент, где у вас начинает появляться уважение к Spring MVC: он делает эти шаги за вас, но не отменяет их существование. Он просто автоматизирует рутину. А мы сейчас честно видим эту рутину вживую — без лишней магии.
7. Типичные ошибки при разборе запроса
Ошибка №1: сравнивать маршрут с полным URI, включая query string.
Если вы сравниваете строку вида "/api/v1/reading-list?status=PLANNED" с "/api/v1/reading-list", вы случайно превращаете фильтрацию в “другой маршрут”. Правильный подход — маршрутизировать по uri.getPath(), а query разбирать отдельно. Тогда GET /api/v1/reading-list и GET /api/v1/reading-list?status=... попадут в один и тот же handler, как и должно быть.
Ошибка №2: split("/") и неожиданная пустая строка в начале.
Путь начинается с /, поэтому "/health".split("/") даст ["", "health"]. Если не фильтровать пустые сегменты, вы начинаете писать странные индексы и условия. Гораздо проще сделать pathSegments(...), который выбрасывает пустые строки — и дальше работать с нормальными сегментами.
Ошибка №3: пытаться парсить id до выбора маршрута.
Это классика: вы решили, что “мне нужен id”, и начинаете сразу на входе вызывать Long.parseLong(lastSegment(path)). В результате запрос /health превращается в “id должен быть числом”. Поэтому порядок такой: сначала маршрут, потом парсинг именно тех данных, которые нужны этому маршруту.
Ошибка №4: разбор query string без проверки на null.
uri.getQuery() (и getRawQuery()) возвращает null, если ? отсутствует. Это не повод для 500. Это повод вернуть пустую карту параметров и спокойно жить дальше. На уровне API отсутствие фильтра — нормальная ситуация, а не “ошибка клиента”.
Ошибка №5: split("=") без ограничения, из‑за чего ломаются значения.
Значение query‑параметра может содержать =. Даже если в нашем учебном проекте это редко, привычку лучше поставить правильную: split("=", 2). Тогда вы гарантированно отделяете ключ от значения один раз, а не “режете” значение на куски.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ