Контекст, middleware-антипаттерны и контракты
В классическом Node-стеке (Express, Koa, Fastify) запрос ходит через
цепочку middleware, и каждый middleware что-то добавляет к общему
объекту: req.user, req.tenant, req.txn. К контроллеру приходит
толстый, плохо типизированный, в любом порядке прибитый объект. Этот
урок про то, чем такой подход вредит, какие ловушки появляются на
ровном месте, и как делать иначе.
Первая половина урока, про передачу контекста (req против ALS). Вторая, разбор middleware-антипаттернов по пяти слайдам Тимура Шемседдинова. Третья, про runtime-контракты как способ удержать тип на границе процесса.
1. Что такое “контекст запроса”
Контекст, всё то, что нужно знать о текущем запросе любому слою: request-id для логов, текущий пользователь, транзакция БД, локаль, trace-id для observability. Эти значения меняются от запроса к запросу, но в коде нужны почти везде.
Способов передавать контекст три:
- Аргументы. Каждая функция принимает
ctxявным параметром. Хорошо для типов и предсказуемости, но “прокачивать”ctxчерез двадцать функций больно. - Mutable-объект (req). Один объект на запрос, каждый слой мутирует. Эргономично писать, плохо типизируется, плохо тестируется, плохо распределяется по слоям.
- Async context (ALS). Хранилище, привязанное к async-цепочке. Любая функция в цепочке может прочитать значение, никто не передавал её через аргументы.
В Node для (3) есть AsyncLocalStorage.
2. AsyncLocalStorage как замена req-объекту
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
type RequestContext = {
requestId: string;
userId: string | null;
startedAt: number;
};
const requestContext = new AsyncLocalStorage<RequestContext>();
// один раз на старте, при создании сервера:
function withRequestContext(handler: (req, res) => Promise<void>) {
return (req, res) => {
const ctx: RequestContext = {
requestId: randomUUID(),
userId: null,
startedAt: performance.now(),
};
requestContext.run(ctx, () => handler(req, res));
};
}
// в любом месте кода, без передачи аргументом:
function logger(message: string) {
const ctx = requestContext.getStore();
console.log(JSON.stringify({ message, requestId: ctx?.requestId }));
}
Что это даёт:
- Логи знают request-id, не передавая его через каждый вызов.
- Аутентификация записывает
userIdодин раз, репозиторий читает черезgetStore, не черезreq.user. - Database transactions:
withTransaction(async () => { ... })кладёт активныйtxnв ALS, репозитории берут его автоматически.
Это не silver bullet. Подводные:
setImmediateбез AsyncResource теряет контекст. В коде библиотек низкого уровня (старые драйверы, нативные модули) ALS может не пробросить.worker_threads, у каждого worker свой ALS-граф. Между потоками значение не передаётся.- Производительность: AsyncLocalStorage в Node 16+ построен на Promise-фрейме, оверхед минимальный. В старых версиях бывал заметный регресс.
- Тестируемость: писать тесты на код, который читает ALS, нужно
через
requestContext.run({ ... }, async () => { ... }). Терпимо.
Когда НЕ использовать ALS:
- В библиотеке, которая публикуется в npm. Это implicit-зависимость, consumer ожидает, что её код самостоятельный.
- В domain-слое (бизнес-логика). Domain-функции должны быть чистыми и принимать контекст параметром.
Когда использовать:
- Логи, метрики, tracing.
- Cross-cutting concerns: tenant, локаль, feature-flags.
- Database transactions внутри одного процесса.
3. AsyncResource: чинить потерянный контекст
Если интегрируешься с native-кодом или со старой callback-API,
AsyncLocalStorage может потерять привязку. Лекарство, оборачивать
callback в AsyncResource:
import { AsyncResource } from 'node:async_hooks';
const wrapped = AsyncResource.bind(callback);
// или в виде класса
class MyTask extends AsyncResource {
constructor() {
super('MyTask');
}
start(callback: () => void) {
this.runInAsyncScope(callback);
}
}
AsyncResource.bind(fn) возвращает обёртку, которая внутри
восстанавливает async-context. Используется внутри библиотек
типа OpenTelemetry, чтобы не терять span между ребоундом callback-ов.
В обычном коде это редко нужно. Но если ALS “теряется”, это первое, куда смотреть.
4. Антипаттерны middleware
Теперь к разбору проблем с middleware-моделью. Базовые контракты двух стилей:
// Connect / Express
type Middleware = (
req: Request,
res: Response,
next: (err?: unknown) => void,
) => void;
// Koa
type KoaMiddleware = (
ctx: Context,
next: () => Promise<void>,
) => Promise<void>;
Оба контракта приглашают писать функции, которые мутируют общий объект
(req или ctx) и передают эстафету дальше через next. Ниже,
конкретные провокации, которые этот контракт создаёт.
4.1. Provocation list
Слайд: Mixin / Reference pollution / Race condition / Abstraction leak / Fat controller / High coupling / Error ignoring.
Каждый из этих антипаттернов появляется, потому что middleware приглашает делать так, а не запрещает.
4.2. Reference pollution: req обрастает полями
// auth-middleware
app.use((req, res, next) => {
req.user = decodeToken(req.headers.authorization);
next();
});
// tenant-middleware
app.use((req, res, next) => {
req.tenant = req.user?.tenant;
next();
});
// log-middleware
app.use((req, res, next) => {
req.logger = createLogger({ user: req.user, tenant: req.tenant });
next();
});
// controller
app.get('/orders', (req, res) => {
// что здесь живёт в req?
// user, tenant, logger, body, params, query, signedCookies...
// плюс всё, что добавил express.
// Тип, в лучшем случае Request & Record<string, any>.
});
К контроллеру приходит “request”, в котором живёт всё, что когда-либо
кто-либо добавил по пути. Тип-чек этого хорошо не сделает: либо все
поля опциональные (бесполезно для контракта), либо any (ещё хуже).
Контроллер привязан к десяти неявным зависимостям, вместо одной явной.
4.3. Race condition: shared state в module-scope
// плохо: переменная живёт в module-scope, общая для всех запросов
let currentUserId: string | null = null;
app.use((req, res, next) => {
currentUserId = req.headers['x-user-id'];
next();
});
app.get('/profile', async (req, res) => {
// microtask может прийти ПОСЛЕ того, как другой запрос уже
// переписал currentUserId. На профиль приходит чужой userId.
await delay(50);
const profile = await db.profile.find(currentUserId);
res.json(profile);
});
В Node один поток JS, но event loop переключается между запросами на
каждом await. Между currentUserId = ... и db.profile.find(...)
может проскочить второй запрос с другим user-id, и переменная
перезапишется. Контроллер прочитает чужой ID.
Это не теоретическая страшилка. Я видел это в проде в трёх проектах, и каждый раз код выглядел невинно. Авторы рассуждали так: “у нас один поток, race не бывает”. Это правда в синхронном коде. В асинхронном, неправда.
let userIdalice- 1 set userId = alice
- 2 await delay
- 3 read userId, respond
responded with --
userId = bob- 1 set userId = bob
- 2 await delay
- 3 read userId, respond
responded with --
В broken-режиме обе ручки делят одну module-scope переменную.
Запрос B успевает переписать userId, пока запрос A в
await. Когда A читает, видит bob, отвечает не тому пользователю.
Переключи режим на fixed: AsyncLocalStorage и пусти запросы снова: каждый запрос
видит свой контекст.
Защита, не делать shared state в module-scope. Контекст хранить в
AsyncLocalStorage или в локальной переменной обработчика запроса.
4.4. Abstraction leak: ниже видно поля верхнего
Когда middleware добавляет req.user, он связывает контроллер с
authentication layer. Если authentication поменяется (например, добавит
поле user.permissions), все контроллеры, читающие req.user,
ломаются. Это leak в обе стороны: middleware зависит от того, как его
читают, контроллер зависит от того, как middleware пишет.
В нормальной слоистой архитектуре эта связь явная:
// явно: контроллер принимает Authenticator-зависимость, не req.user
function ordersController(deps: { auth: Authenticator; orders: OrdersRepo }) {
return async (request: OrdersRequest) => {
const user = await deps.auth.requireUser(request.token);
return deps.orders.list(user.tenant);
};
}
Связь видна по типам. Менять authentication можно только в одной
точке. Контроллеры, не привязаны к расположению полей в req.
4.5. Fat controller: логика размазана
Когда middleware-цепочка длинная, контроллер часто просто делает “возьми из req и отдай”. Бизнес-логика разбросана по middleware: в одной проверка прав, в другой подгрузка ресурса, в третьей валидация, в четвёртой логирование. Чтобы понять, что делает endpoint, надо собрать всю цепочку в голове.
Альтернатива, явный pipeline в контроллере:
// явно: вся логика endpoint-а в одном месте
async function handleListOrders(request: HttpRequest, deps: Deps): Promise<HttpResponse> {
const user = await deps.auth.requireUser(request.headers);
const params = ListOrdersInput.parse(request.query);
const result = await deps.orders.list({ tenantId: user.tenantId, ...params });
return { status: 200, body: result };
}
Это читается линейно, тестируется без HTTP-сервера, не зависит от middleware-порядка.
4.6. High coupling: middleware “знает” друг про друга
// auth-middleware кладёт req.user
app.use(authMiddleware);
// tenant-middleware читает req.user (зависит от auth)
app.use(tenantMiddleware);
// rate-limit читает req.user.id (зависит от auth)
app.use(rateLimitMiddleware);
// если порядок ломается, всё падает в рантайме
app.use(rateLimitMiddleware); // упс, req.user undefined
app.use(authMiddleware);
Порядок middleware, неявная зависимость. Эту зависимость не видно по типам, ей не помогает IDE. На review такие баги ловятся плохо.
4.7. Error ignoring: молчаливое проглатывание
// плохо: ошибки не пробрасываются, controller не знает, что что-то сломалось
app.use(async (req, res, next) => {
try {
req.user = await decodeToken(req.headers.authorization);
} catch {
// пропустим, может пользователь и так анонимный
}
next();
});
Это особенно опасно, когда ошибка маскирует проблему: “токен пришёл, но decode упал из-за expired”, а сервер делает вид, что пользователь анонимный. Дальше логика “если пользователь, то X, иначе Y” уезжает не туда. И никаких логов.
В Connect-стиле next(err) правильно пробросит ошибку в
error-middleware. Но писать его легко забыть, и линтер не помогает.
дальше после входа
Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.
Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.