Сохранение контекста между асинхронными вызовами в Node.js
Вы находитесь на третьем уровне вложенности асинхронных вызовов при обработке HTTP-запроса. Вам нужен ID запроса для логгера, ID пользователя для запроса к базе данных и ID арендатора для ключа кэша. Будете ли вы пробрасывать их через сигнатуры каждой функции? Это быстро превращается в хаос.
В Node.js есть элегантное встроенное решение: AsyncLocalStorage.
Ключевые выводы
AsyncLocalStorageизnode:async_hooksраспространяет контекст через асинхронные границы, не загрязняя сигнатуры функций.- API стабилен начиная с Node.js 16.4.0 и предпочтительнее, чем
cls-hookedили прямое использование низкоуровневого APIasync_hooks. - Контекст устанавливается один раз в точке входа запроса через
run(), а затем читается в любом месте с помощьюgetStore(). - Идеален для ID запросов, данных трассировки, метаданных арендатора и контекста аутентификации — но не для состояния бизнес-логики.
- Остерегайтесь потери контекста при использовании нестандартных промисов или устаревших callback-based API; обычно это решается через
util.promisify().
Проблема распространения асинхронного контекста
В синхронном коде можно использовать простой глобальный стек для отслеживания контекста. Но асинхронные функции ломают эту модель. Когда срабатывает setTimeout или резолвится Promise, исходный стек вызовов уже отсутствует. Обычная глобальная переменная будет общей для всех параллельных запросов — это серьёзная ошибка, поджидающая любой реальный API-сервер.
До того как AsyncLocalStorage стал стабильным, разработчики прибегали к библиотекам вроде cls-hooked или к самописным решениям на основе низкоуровневого модуля async_hooks. Оба подхода ненадёжны. Сырой API async_hooks намеренно низкоуровневый и при неправильном использовании создаёт ощутимые накладные расходы на производительность. Не стоит строить на нём прикладной код напрямую.
AsyncLocalStorage, входящий в состав node:async_hooks, является рекомендуемым высокоуровневым API. Он стабилен начиная с Node.js 16.4.0 и используется внутри фреймворков вроде AdonisJS для управления HTTP-контекстом.
Как работает AsyncLocalStorage
AsyncLocalStorage работает подобно thread-local storage из других языков — за исключением того, что Node.js однопоточный, поэтому «поток» заменён на асинхронный контекст выполнения. Любая асинхронная операция, запущенная внутри вызова run(), автоматически наследует этот контекст, включая setTimeout, цепочки Promise и вызовы await.
import { AsyncLocalStorage } from 'node:async_hooks';
const requestContext = new AsyncLocalStorage();
Вы создаёте один экземпляр (обычно как синглтон на уровне модуля), а затем используете run() для установки контекста в точке входа каждого запроса.
Реалистичный пример логирования в рамках запроса
Вот минимальный Express-middleware, который прикрепляет ID запроса к каждой строке лога — без передачи чего-либо через аргументы функций:
import express from 'express';
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
const requestContext = new AsyncLocalStorage();
// Middleware: establish context for each request
function contextMiddleware(req, res, next) {
const store = { requestId: randomUUID(), userId: req.headers['x-user-id'] };
requestContext.run(store, next);
}
// Logger: reads context without any arguments
function log(message) {
const ctx = requestContext.getStore();
const prefix = ctx ? `[${ctx.requestId}]` : '[no-context]';
console.log(`${prefix} ${message}`);
}
// Simulated async database query
async function someDbQuery() {
return new Promise((resolve) => setTimeout(resolve, 50));
}
// Route handler: calls async functions freely
async function fetchUserData() {
log('Fetching user data'); // ✅ has request ID
await someDbQuery();
log('Fetched user data'); // ✅ still has request ID
}
const app = express();
app.use(contextMiddleware);
app.get('/user', async (req, res) => {
log('Request received');
await fetchUserData();
res.json({ ok: true });
});
app.listen(3000);
Ключевая идея: fetchUserData никогда не получает ID запроса в качестве параметра. Контекст автоматически распространяется через асинхронную границу, поскольку был установлен через run().
Discover how at OpenReplay.com.
Что хранить в контексте
AsyncLocalStorage хорошо подходит для сквозных задач, привязанных к области запроса, но не относящихся к бизнес-логике:
- ID запросов для распределённой трассировки и корреляции логов
- Метаданные аутентифицированного пользователя или арендатора для мультиарендных приложений
- Контекст трассировки для инструментов вроде OpenTelemetry
- Feature flags, разрешаемые во время запроса
Избегайте хранения больших объектов или часто изменяющихся данных. Держите хранилище небольшим и рассматривайте его как преимущественно read-only после инициализации.
Один подводный камень: потеря контекста
Контекст может теряться при использовании сторонних реализаций промисов или некоторых устаревших callback-based API. Если getStore() возвращает undefined там, где вы этого не ожидаете, проверьте, была ли асинхронная операция запущена внутри вызова run(). Часто помогает оборачивание callback-based кода через util.promisify(), хотя для некоторых пользовательских асинхронных ресурсов может потребоваться AsyncResource.
Заключение
AsyncLocalStorage элегантно решает реальную проблему. Вместо того чтобы пробрасывать метаданные запроса через каждый вызов функции, вы устанавливаете контекст один раз на границе запроса и читаете его там, где это необходимо. Это правильный инструмент для request-scoped логирования, трассировки и контекста аутентификации в любом Node.js API или SSR-приложении.
Часто задаваемые вопросы
Небольшие накладные расходы существуют, поскольку Node.js должен отслеживать асинхронные ресурсы для распространения хранилища, но для типичных веб-нагрузок эта стоимость пренебрежимо мала. Производительность значительно улучшилась в последних версиях Node.js, и компромисс обычно оправдан по сравнению с ручным пробрасыванием контекста через каждый вызов функции.
Да. Вы можете создавать отдельные экземпляры для разных задач, таких как контекст логирования, трассировка и данные арендатора. Каждый экземпляр поддерживает собственное независимое хранилище, поэтому они не будут влиять друг на друга. Просто держите каждый экземпляр как синглтон на уровне модуля, чтобы по всему коду использовалась одна и та же ссылка.
Каждый worker-поток имеет собственное изолированное состояние AsyncLocalStorage, поэтому контекст не пересекает границы потоков. Если вам нужно поделиться контекстом запроса с worker-ом, передайте необходимые данные явно через канал сообщений worker-а и заново установите хранилище внутри worker-а с помощью ещё одного вызова run().
Явная передача более предсказуема и легче тестируется, но она загромождает сигнатуры функций и засоряет промежуточные слои, которым эти данные на самом деле не нужны. AsyncLocalStorage лучше всего подходит для сквозных задач вроде логирования и трассировки, тогда как критически важные для бизнеса данные всё же стоит передавать через аргументы, чтобы код оставался понятным и тестируемым.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.