Раздел 6 · Node.js

Контекст, middleware-антипаттерны и контракты

middle-senior~45 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Контекст, middleware-антипаттерны и контракты

В классическом Node-стеке (Express, Koa, Fastify) запрос ходит через цепочку middleware, и каждый middleware что-то добавляет к общему объекту: req.user, req.tenant, req.txn. К контроллеру приходит толстый, плохо типизированный, в любом порядке прибитый объект. Этот урок про то, чем такой подход вредит, какие ловушки появляются на ровном месте, и как делать иначе.

Первая половина урока, про передачу контекста (req против ALS). Вторая, разбор middleware-антипаттернов по пяти слайдам Тимура Шемседдинова. Третья, про runtime-контракты как способ удержать тип на границе процесса.

1. Что такое “контекст запроса”

Контекст, всё то, что нужно знать о текущем запросе любому слою: request-id для логов, текущий пользователь, транзакция БД, локаль, trace-id для observability. Эти значения меняются от запроса к запросу, но в коде нужны почти везде.

Способов передавать контекст три:

  1. Аргументы. Каждая функция принимает ctx явным параметром. Хорошо для типов и предсказуемости, но “прокачивать” ctx через двадцать функций больно.
  2. Mutable-объект (req). Один объект на запрос, каждый слой мутирует. Эргономично писать, плохо типизируется, плохо тестируется, плохо распределяется по слоям.
  3. 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 не бывает”. Это правда в синхронном коде. В асинхронном, неправда.

Race condition в middleware: shared let userId
request A · userId alice
  1. 1 set userId = alice
  2. 2 await delay
  3. 3 read userId, respond

responded with --

shared module-scope
userId = --
одна переменная, последняя запись побеждает
request B · userId bob
  1. 1 set userId = bob
  2. 2 await delay
  3. 3 read userId, respond

responded with --

0 runs0 wrong responsesidle

В 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.