1. Stateless: сервер — не чатик
Если вы привыкли к мессенджерам, кажется естественным, что «система помнит, что было пять минут назад». Вы написали «привет», потом «а теперь покажи второе», и все понимают, о каком «втором» речь. Но HTTP — это не чат с историей, а скорее серия отдельных писем, где каждое должно быть написано так, чтобы получатель понял его без телепатии. Иначе сервер превращается в гадалку на кофейной гуще, а гадалки плохо масштабируются и ещё хуже отлаживаются.
В обычном backend-мире один и тот же сервер (или группа серверов) обслуживает много клиентов: браузеры, мобильные приложения, другие сервисы. Запросы приходят параллельно, в разном порядке, иногда повторяются, иногда теряются по дороге. Если сервер начнёт «помнить контекст разговора» между запросами так, как это делает чат, он очень быстро запутается: какой контекст чей, какой из них актуальный, а кто вообще «первым писал».
Здесь и появляется ключевая идея дня: в stateless-модели каждый запрос должен содержать все данные, которые нужны, чтобы выполнить именно этот шаг. Сервер не должен полагаться на то, что «вы до этого уже вызывали шаг 1». Он должен уметь обработать запрос, глядя на него «в вакууме» — плюс, конечно, используя данные ресурсов (о них поговорим чуть позже).
2. Stateless по-человечески: без истории
Слово stateless часто пугает новичков, потому что звучит как «сервер вообще ничего не хранит». А как тогда жить? Где лежат книги, пользователи, список чтения и всё прекрасное? Спокойно: stateless — не про то, что сервер не может иметь память вообще. Это про то, что сервер не должен быть обязан помнить «контекст диалога» между запросами, чтобы понять следующий запрос.
Давайте аккуратно разделим два типа «памяти»:
- Состояние ресурсов (resource state) — это данные, которые сервер хранит как предметную область: список книг, элементы reading list, статусы, комментарии. Это нормальная, обязательная часть backend-жизни. Даже если хранение in-memory (как будет в нашем учебном проекте позже), это всё равно «данные ресурса».
- Состояние разговора (conversation state) — это скрытая зависимость следующего запроса от предыдущего: «мы уже выбрали пользователя», «мы уже знаем, какую книгу вы имели в виду», «мы помним ваш последний фильтр». Если следующий запрос нельзя понять без того, что было раньше, то сервер становится “stateful” именно в плохом смысле: он начинает требовать историю.
Чтобы это было более осязаемо, вот небольшая табличка. Она не про «можно/нельзя вообще», а про то, что относится к stateless-привычке и что ломает самодостаточность запроса:
| Что это | Пример | Нормально в stateless мире? | Почему |
|---|---|---|---|
| Данные ресурса | «В reading list есть элемент с id=10» | Да | Это предметная область. Запросы читают/меняют ресурсы. |
| Скрытый контекст диалога | «Мы помним, что вы до этого выбрали id=10» | Плохо | Новый запрос становится непонятен без истории. |
| Явные параметры в запросе | GET /reading-list/10 | Да | id передан явно, запрос самодостаточен. |
| Поле в памяти «последний пользователь» | lastUserId | Плохо | Другой клиент перезапишет, и всё поедет. |
Важно уловить формулировку: stateless — это требование к обработке каждого запроса. Сервер, конечно, может иметь данные и менять их. Но он не должен «держать вас за руку» и помнить, что вы делали пять запросов назад, чтобы понять текущий.
3. Самодостаточный запрос
Когда говорят «запрос должен быть самодостаточным», у новичка возникает вопрос: «Окей, а что именно я должен туда положить?» Хорошая новость: мы уже знаем строительные блоки запроса из предыдущих дней. Самодостаточность — это не новая магическая сущность, а правильное использование уже знакомых частей: method, path, query, headers, body.
Представьте, что сервер — это сотрудник пункта выдачи заказов. Если вы подходите и говорите: «Дайте мне то, что я хотел вчера», сотрудник вежливо улыбается и внутренне плачет. Если вы говорите: «Мой номер заказа 48371», всё становится просто. В HTTP «номер заказа» — это обычно path (например, /reading-list/10) или параметры в query/body, в зависимости от операции.
Вот практическая шпаргалка, где обычно живут разные виды данных, чтобы запрос был понятен без «тайной истории»:
| Что нужно серверу для этого шага | Где обычно передаём | Мини-пример |
|---|---|---|
| Какую операцию делаем | HTTP method | GET |
| Над каким ресурсом | path | /api/v1/reading-list/10 |
| Фильтры/необязательные критерии | query | ?status=PLANNED&title=clean |
| Метаданные запроса | headers | Accept: application/json |
| Данные для создания/обновления | body | { "title": "...", "author": "..." } |
И теперь главный момент: если для выполнения шага нужны данные, а их нет — сервер не должен угадывать. Он должен честно сказать: «Запрос неполный/непонятный» (в HTTP-терминах это обычно 400 Bad Request, но статусы мы сегодня не углубляем — важно именно мышление).
Чуть грубоватая, но честная формула:
«Самодостаточный запрос = «всё нужное для этого шага передано явно» + «сервер не делает вид, что понял, если не понял».»
4. Антипример: обработчик, который «помнит шаг»
Пока вы пишете консольные программы, очень легко привыкнуть к идее «сначала ввели одно, потом ввели второе, программа помнит первое». И это нормально: в консольной программе один пользователь, один поток действий, и жизнь относительно линейная. Но HTTP-сервер живёт в мире, где «сначала» и «потом» — понятия подозрительные. Поэтому самый частый баг на старте — попытка перенести консольное мышление в обработчики запросов.
Вот минимальный пример «плохой памяти», который вроде бы работает, пока вы тестируете один сценарий одним клиентом:
class BadHandler {
// Плохо: поле хранит "контекст диалога" между вызовами, то есть между запросами.
// В реальном сервере это общее состояние для разных клиентов и разных потоков.
private String lastUserId;
String step1(String userId) {
// Плохо: запоминаем "последнего пользователя", вместо того чтобы передавать userId явно дальше.
lastUserId = userId;
return "OK";
}
String step2(String itemId) {
// Плохо: результат зависит от того, что было вызвано раньше (и кем именно).
return "user=" + lastUserId + ", item=" + itemId;
}
}
Снаружи это выглядит как «двухшаговый процесс»: сначала мы как бы «указываем пользователя», потом — «действуем с item». Но проблема в том, что lastUserId — это скрытая зависимость. Если step2() вызовут без step1(), получится мусор. Если step1() вызовет другой клиент, первый клиент получит результат «про чужого пользователя». Если запросы придут параллельно, всё станет ещё веселее (в плохом смысле).
Чтобы почувствовать боль без сервера и сети, достаточно обычного вызова методов:
BadHandler h = new BadHandler();
System.out.println(h.step1("alice")); // Шаг 1: "выбрали" пользователя
System.out.println(h.step2("42")); // Шаг 2: используем сохранённый lastUserId
System.out.println(h.step1("bob")); // Другой "клиент" перетирает lastUserId
System.out.println(h.step2("42")); // Теперь шаг 2 работает уже в контексте bob
В консоли всё выглядит логично, но представьте, что alice и bob — это два разных клиента, которые обращаются к одному серверу. И теперь вопрос: а кто гарантирует, что между запросами Алисы никто не пришёл со своим step1()? Ответ: никто. Сеть не подписывала контракт «не мешайте Алисе».
И вот тут возникает важная привычка backend-мышления: если вы видите в обработчике поле “последнее что-то” (lastXxx) — это почти всегда запах проблемы. Иногда такое поле бывает оправдано (например, кеш), но для «контекста диалога» — это прямой путь к хрупкости.
5. Нормальный подход: только явные данные
После антипримера обычно хочется спросить: «Окей, а как правильно, если мне нужно и userId, и itemId?» Правильно — передать всё, что нужно, в рамках одного запроса/вызова. Да, звучит банально. В backend-разработке половина инженерного прогресса человечества вообще построена на банальном: “давайте перестанем надеяться на магию”.
Вот версия, где обработчик не опирается на скрытое состояние:
class GoodHandler {
String handle(String userId, String itemId) {
// Важно: проверяем обязательные данные здесь и сейчас,
// а не "надеемся", что они где-то лежат со вчера.
if (userId == null || itemId == null) return "400 Bad Request";
// Важно: результат зависит только от входных данных текущего вызова.
return "user=" + userId + ", item=" + itemId;
}
}
Обратите внимание на психологический эффект: такой код немного «обиднее» для автора. Он не делает вид, что всё понял. Если данных нет — он говорит об этом сразу. Это и есть дисциплина stateless: сервер не должен быть «слишком понимающим».
Чтобы сделать идею ещё ближе к HTTP, можно представить, что обработчик принимает не два параметра, а один «объект запроса» (мы не строим сейчас фреймворк, просто показываем принцип):
import java.util.Map;
// Упрощённая "модель HTTP-запроса": есть method, path и query-параметры.
record RequestData(String method, String path, Map<String, String> query) { }
class StatelessRouter {
String route(RequestData r) {
// Важно: маршрутизация определяется только текущим запросом.
if ("/reading-list".equals(r.path()) && "GET".equals(r.method())) return "200 OK";
// Важно: никакого "в прошлый раз вы делали шаг 1" — только то, что пришло сейчас.
return "404 Not Found";
}
}
Здесь нет «вчерашних значений». Есть вход, который полностью описывает текущий шаг: method + path + параметры. Да, это пока игрушечный пример. Но именно так вы позже будете мыслить, когда начнёте работать с реальными HTTP-запросами через инструменты и код.
6. Где живёт состояние
На этом месте обычно появляется честное недоумение: «Если сервер stateless, как он вообще хранит список чтения? Ведь список чтения — это состояние». И это хороший вопрос, потому что он заставляет правильно разделить виды состояния. Давайте закрепим это на простой схеме.
flowchart LR
C[Клиент] -->|"HTTP request: method+path+data"| S[Сервер]
S -->|"читает/меняет"| R[(Состояние ресурсов: данные)]
S -->|"HTTP response: status+data"| C
Сервер при обработке запроса может читать и менять данные ресурсов. В будущем в нашем учебном проекте ReadLater Starter это будут элементы reading list (пока без базы данных, in-memory). Это абсолютно нормально. Stateless не запрещает данные.
Что stateless запрещает (или, точнее, делает плохой идеей) — хранить «контекст разговора» как обязательную часть понимания следующего запроса. Потому что тогда сервер должен помнить, что именно вы делали до этого, и связывать запросы в цепочки. Это уже другой класс систем, где появляется масса дополнительных вопросов: как хранить этот контекст, как истекать, как делить между клиентами, как масштабировать на несколько серверов. Всё это бывает нужно, но это точно не то, с чего мы начинаем в базовом HTTP-мышлении.
Чтобы увидеть разницу на микро-уровне, представьте две переменные:
long readingListItemId = 10; // состояние ресурса: норм (это "данные", которые существуют как факт)
long lastSelectedId = 10; // "последний выбранный": подозрительно (это уже "контекст диалога")
Первое — просто существующий факт предметной области (есть элемент с id 10). Второе — попытка сказать «мы помним, что пользователь выбирал 10». Если следующий запрос будет «поставь статус FINISHED», и сервер будет менять статус именно у lastSelectedId, то у нас появляется скрытая зависимость от прошлого. И мы снова в мире хрупкости.
7. Stateless: контракт без угадываний
Когда вы принимаете stateless как правило игры, контракт между клиентом и сервером становится намного яснее. Клиент больше не может «намекнуть» и ожидать, что сервер догадается. Сервер больше не делает вид, что «понимает общий контекст», если это не так. Получается довольно взрослый результат: стороны начинают явно договариваться, какие данные нужны для операции.
Выглядит это так: если клиент хочет изменить конкретный ресурс, он обязан указать идентификатор ресурса в запросе. Если клиент хочет создать новый ресурс, он обязан передать данные создания в запросе (обычно в body). Если клиент хочет отфильтровать список, он передаёт фильтры в query. И если чего-то не хватает — сервер честно возвращает ошибку запроса, а не «создаёт что-то по умолчанию, потому что так проще».
Есть и приятный побочный эффект: одинаковые входные данные легче отлаживать. Когда запрос самодостаточен, вы можете взять один конкретный HTTP-запрос (method + URL + headers + body) и воспроизвести проблему. Вы не обязаны повторять «предыдущие три шага, чтобы оно сломалось так же». Для новичка это огромная экономия нервов.
И ещё один важный нюанс. Иногда кажется, что самодостаточность = «одинаковый запрос всегда даёт одинаковый ответ». Это не совсем так, потому что состояние ресурсов может меняться. Но верная мысль такая: ответ должен зависеть от текущего запроса и текущего состояния ресурсов, а не от скрытых переменных “что было раньше”. Это и есть предсказуемость, на которую опирается backend-инженерия.
8. ReadLater: «каждый запрос сам по себе»
Сейчас в курсе мы ещё не пишем код HTTP-клиента и не поднимаем сервер — это будет позже. Но домен ReadLater уже рядом, и на нём очень удобно почувствовать смысл stateless без лишней теории. Представьте будущие операции (чисто как мысленные картинки): получить список, получить один элемент, создать элемент, обновить статус.
Если бы мы пытались построить это “как диалог”, могло бы появиться что-то вроде: «сначала выбери книгу, потом отправь команду “добавь”, потом скажи “поставь статус”». И сервер бы держал в памяти «последнюю выбранную книгу». В одиночном тесте это работало бы. А затем вы открыли бы Postman, сделали два запроса параллельно, и внезапно статус поменялся не у той книги. Поздравляю: вы изобрели баг, который в реальной работе стоит недели нервов, а в учебном проекте — ломает доверие к собственной голове.
В stateless-модели всё скучнее, зато надёжнее: если вы хотите поменять статус — вы явно указываете, у какого элемента. Если вы хотите получить элемент — вы явно указываете id. Если вы хотите список с фильтром — вы явно указываете фильтр. Сервер не держит «молчаливую переменную контекста». Он просто работает с ресурсами.
И это напрямую влияет на архитектуру проекта (даже если мы пока не кодим): вы уже сейчас начинаете мыслить так, чтобы контракт можно было записать и проверить. Именно поэтому в этом модуле мы так упорно говорим про method/path/headers/status, а не только про «какой JSON прилетел». Без stateless-мышления всё это превращается в “ну вы же поняли, что я хотел”.
9. Типичные ошибки при работе с stateless
Ошибка №1: думать, что stateless означает “сервер вообще ничего не хранит”.
Такое понимание быстро приводит к абсурду: «если сервер ничего не хранит, значит, он не может иметь ни список чтения, ни каталог, ни вообще смысл существования». В реальности stateless относится к обработке запроса: сервер не должен требовать скрытую историю диалога, но состояние ресурсов (данные) хранить можно и нужно.
Ошибка №2: прятать обязательные данные для обработки в полях класса (lastXxx, currentUser, selectedId).
Это классическая попытка перенести консольный «пошаговый сценарий» в серверный мир. Пока вы тестируете один запрос за другим, кажется, что всё хорошо. Как только появляются два клиента или два параллельных запроса, скрытое поле начинает перетираться, и сервер «помнит не то». В stateless-подходе нужные данные должны приходить в запросе явно.
Ошибка №3: ожидать, что сервер “догадается”, если данных не хватает.
Например, клиент не передал идентификатор ресурса, но хочет «обновить статус». Если сервер начинает угадывать “последний элемент”, “первый элемент”, “любимый элемент” — вы получаете непредсказуемое API. Правильная привычка — считать отсутствие обязательных данных ошибкой запроса и отвечать явно, а не делать вид, что всё нормально.
Ошибка №4: путать “состояние ресурсов” и “состояние разговора” в голове, а потом — в коде.
Данные reading list — это предметная область. “Мы помним, что пользователь только что смотрел item 10” — это контекст разговора. Если их смешать, то легко начать проектировать API как цепочку шагов, где второй шаг бессмыслен без первого. Потом это сложно расширять, сложно тестировать и почти невозможно объяснить самому себе через месяц.
Ошибка №5: проверять проблему, начиная с “что в body”, а не с “понятен ли запрос вообще”.
Когда что-то ломается, новичок часто сразу смотрит в тело ответа или в JSON. Но в stateless-мире первым вопросом становится: а запрос вообще самодостаточен? Указан ли нужный id? Передан ли обязательный header? Совпадает ли method? Если запрос неполный, разбор body часто превращается в чтение чайных листьев.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ