1. Stateless: сервер — не чат
Якщо ви звикли до месенджерів, вам може здаватися природним, що система «памʼятає, що було пʼять хвилин тому». Ви написали «привіт», потім — «а тепер покажи друге», і всім зрозуміло, про яке саме «друге» йдеться. Але HTTP — це не чат з історією, а радше серія окремих листів, де кожен має бути написаний так, щоб адресат зрозумів його без телепатії. Інакше сервер перетворюється на ворожку на кавовій гущі, а ворожки погано масштабуються і ще гірше налагоджуються.
У звичайному світі бекенду один і той самий сервер або група серверів обслуговує багато клієнтів: браузери, мобільні застосунки, інші сервіси. Запити приходять паралельно, у різному порядку, іноді повторюються, іноді губляться дорогою. Якщо сервер почне «памʼятати контекст розмови» між запитами так, як це робить чат, він дуже швидко заплутається: який контекст кому належить, який із них актуальний і хто взагалі написав першим.
Із цього й випливає ключова ідея: у stateless-моделі кожен запит має містити всі дані, потрібні для виконання саме цього кроку. Сервер не має покладатися на те, що «ви вже викликали крок 1». Він має вміти обробити запит, виходячи лише з того, що в ньому є, — звісно, разом із даними ресурсів, про які поговоримо трохи пізніше.
2. Stateless по-людськи: без історії
Слово stateless часто лякає новачків, бо звучить так, ніби сервер узагалі нічого не зберігає. А як тоді все працює? Де лежать книги, користувачі, список читання і все прекрасне? Спокійно: stateless — не про те, що сервер не може мати памʼять узагалі. Це про те, що сервер не має бути зобовʼязаним памʼятати контекст діалогу між запитами, щоб зрозуміти наступний запит.
Давайте акуратно розділимо два типи памʼяті:
- Стан ресурсів (resource state) — це дані, які сервер зберігає як частину предметної області: список книг, елементи списку читання, статуси, коментарі. Це нормальна, обовʼязкова частина роботи бекенду. Навіть якщо зберігання in-memory (як буде в нашому навчальному проєкті пізніше), це все одно дані ресурсу.
- Стан розмови (conversation state) — це прихована залежність наступного запиту від попереднього: «ми вже обрали користувача», «ми вже знаємо, яку книгу ви мали на увазі», «ми памʼятаємо ваш останній фільтр». Якщо наступний запит не можна зрозуміти без того, що було раніше, сервер стає stateful саме в поганому сенсі: він починає вимагати історію.
Щоб це було відчутніше, ось невелика табличка. Вона не про «можна/не можна взагалі», а про те, що належить до stateless-звички і що ламає самодостатність запиту:
| Що це | Приклад | Нормально у світі stateless? | Чому |
|---|---|---|---|
| Дані ресурсу | «У списку читання є елемент з 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()? Відповідь: ніхто. Мережа не підписувала контракт «не заважайте Алісі».
І тут виникає важлива звичка бекенд-мислення: якщо ви бачите в обробнику поле «останнє щось» (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-запит: метод+шлях+дані"| S[Сервер]
S -->|"читає/змінює"| R[(Стан ресурсів: дані)]
S -->|"HTTP-відповідь: статус+дані"| C
Сервер під час обробки запиту може читати і змінювати дані ресурсів. У майбутньому в нашому навчальному проєкті ReadLater Starter це будуть елементи списку читання (поки що без бази даних, 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) і відтворити проблему. Ви не зобовʼязані повторювати попередні три кроки, щоб воно зламалося так само. Для новачка це величезна економія нервів.
І ще один важливий нюанс. Іноді здається, що самодостатність = «однаковий запит завжди дає однакову відповідь». Це не зовсім так, бо стан ресурсів може змінюватися. Але правильна думка така: відповідь має залежати від поточного запиту і поточного стану ресурсів, а не від прихованих змінних на кшталт “що було раніше”. Це і є передбачуваність, на яку спирається бекенд-інженерія.
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: плутати «стан ресурсів» і «стан розмови» в голові, а потім — у коді.
Дані списку читання — це предметна область. «Ми памʼятаємо, що користувач щойно дивився item 10» — це контекст розмови. Якщо їх змішати, легко почати проєктувати API як ланцюжок кроків, де другий крок безглуздий без першого. Потім це складно розширювати, складно тестувати і майже неможливо пояснити самому собі через місяць.
Помилка № 5: перевіряти проблему, починаючи з «що в body», а не з «чи зрозумілий запит узагалі».
Коли щось ламається, новачок часто одразу дивиться в тіло відповіді або в JSON. Але у stateless-світі першим запитанням стає: а запит узагалі самодостатній? Чи вказано потрібний id? Чи передано обовʼязковий header? Чи збігається method? Якщо запит неповний, розбір body часто перетворюється на читання кавової гущі.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ