Структура приложения: клиент, сервер и слои
Это входной урок раздела. Прежде чем спорить про DDD, гексагоны и микросервисы, нужно договориться о базе: кто такой клиент, кто такой сервер, где живёт состояние и почему обработчик запроса не должен делать всё сразу. Дальше весь раздел будет наращивать эти четыре идеи, и каждая из них вернётся: stateless всплывёт в кешировании и масштабировании, разделение обработчика на слои дорастёт до гексагона, а вопрос «по какому признаку делить код» станет философией границ.
Клиент-серверная модель
Клиент инициирует запросы, сервер обрабатывает и возвращает ответ. Звучит банально, но в этой фразе спрятаны два жёстких правила, из которых вырастает вся остальная архитектура:
- Инициатива всегда у клиента. Сервер не может «позвонить» браузеру сам по себе: пока клиент не открыл соединение, сервера для него не существует. Всё, что выглядит как push (уведомления, живые ленты), на самом деле построено поверх соединения, которое открыл клиент. Подробно это разобрано в уроке про realtime.
- Сервер обслуживает многих, клиент один. Код на клиенте работает для одного пользователя, код на сервере для всех сразу. Поэтому ошибка на клиенте портит вечер одному человеку, а ошибка на сервере всем. И поэтому всё, что пришло от клиента, сервер обязан проверять заново: клиент по определению вне зоны доверия.
клиент сервер
┌────────┐ GET /me/tickets ┌────────────┐
│ браузер │ ─────────────────────────> │ обработчик │ ──> БД
│ (SPA) │ <───────────────────────── │ │ <──
└────────┘ 200 OK + JSON └────────────┘
инициатива здесь state здесь (или нет)
Дальше начинаются варианты: сколько работы взять на себя клиенту.
- Тонкий клиент. Сервер рендерит готовый HTML, браузер показывает. Классический серверный сайт, как этот: страница урока собирается на сервере, клиенту достаётся немного скриптов для интерактива.
- Толстый клиент. SPA держит состояние и логику у себя, сервер превращается в API поверх данных. Рендеринг, роутинг и часть валидации переезжают в браузер.
Удобно держать в голове таблицу, кто за что отвечает:
| Вопрос | Тонкий клиент | Толстый клиент (SPA) |
|---|---|---|
| Кто рендерит HTML | сервер | браузер |
| Кто хранит состояние интерфейса | сервер (и чуть-чуть браузер) | браузер |
| Кто валидирует ввод | сервер | оба: клиент для UX, сервер для безопасности |
| Что гоняем по сети | готовые страницы | JSON и статику |
| Где живёт роутинг | сервер | браузер (history API) |
Строка про валидацию самая важная. Валидация на клиенте делает интерфейс отзывчивым: подсказать «email невалиден» можно до запроса. Но решает всегда сервер: запрос можно отправить и curl-ом, минуя любые проверки в браузере. Клиентская валидация это вежливость, серверная это закон.
Третий вопрос модели: где живёт состояние приложения. Вариантов три, и обычно они смешаны:
- на клиенте: стор SPA, localStorage, IndexedDB;
- на сервере: сессия, БД;
- распределено: клиент держит копию, сервер источник истины, между ними синхронизация (и все радости рассинхрона).
Stateful против stateless
Теперь вопрос, который решает судьбу масштабирования: помнит ли сервер что-то между двумя запросами одного клиента. Точнее, помнит ли это конкретный процесс, обслуживший запрос.
Stateless-сервис устроен так: каждый запрос несёт всё необходимое (токен с данными пользователя, ключи), либо процесс достаёт нужное из внешнего хранилища. Перезапусти процесс, и никто не заметит. Поставь рядом второй такой же, и балансировщик может кидать запросы в любой.
Stateful-сервис помнит: сессии в памяти, открытые WebSocket-соединения, локальный кеш. Рестарт убивает все сессии, а второй инстанс за балансировщиком получает пользователей, которых «не знает».
Классический провал выглядит так. Сессии сложили в объект в памяти:
// антипаттерн: состояние в памяти процесса
const sessions = new Map<string, { userId: string }>();
app.post('/login', (req, reply) => {
const sid = crypto.randomUUID();
sessions.set(sid, { userId: req.body.userId });
reply.setCookie('sid', sid);
});
app.get('/me', (req, reply) => {
const session = sessions.get(req.cookies.sid); // живёт только в ЭТОМ процессе
if (!session) return reply.code(401).send();
// ...
});
Работает, пока процесс один. Дальше два сценария поломки:
- Рестарт. Деплой перезапустил процесс,
Mapпуст, все пользователи разлогинены. На каждый деплой. - Второй инстанс. Балансировщик отправил
/loginв процесс А, а следующий/meв процесс Б. В процессе Б этой сессии нет, 401.
Костыль для второго сценария называется sticky sessions: балансировщик прибивает клиента к «его» инстансу. Это лечит симптом и создаёт новые болезни: нагрузку нельзя ровно размазать, упавший инстанс уносит своих пользователей, выкатка по одному инстансу рвёт сессии. Sticky оправдан почти только для WebSocket, где соединение и так живёт на конкретной ноде (подробнее в уроке про коммуникацию).
Правильных выхода два, и оба означают «вынести состояние из процесса»:
- Сессия во внешнем хранилище. Redis или Postgres хранит сессии, любая нода достаёт их оттуда. Процесс снова stateless, состояние никуда не делось, оно просто переехало туда, где живёт по праву. Именно так устроен auth этого сайта: в куке только токен, сама сессия лежит в Postgres, и любой инстанс может проверить её одним запросом.
- Всё в токене. JWT несёт данные пользователя в себе, сервер проверяет подпись и не ходит в хранилище вообще. Максимально stateless, но появляется обратная проблема: токен нельзя отозвать до истечения срока. Уволенный сотрудник с валидным JWT остаётся «залогинен», пока токен не протухнет.
На практике живёт микс: короткоживущий JWT (минуты) плюс список отозванных токенов в Redis, который проверяется по необходимости. Выбор зависит от трёх вещей: размера сессии, требований к мгновенному revoke и стоимости похода в shared store на каждый запрос.
Заповедь из 12-factor формулируется просто: процесс должен быть одноразовым. Всё, что жалко потерять при рестарте, не имеет права жить в памяти процесса. Память это кеш, а не хранилище.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.