Пошук уроків, статей та іншого контенту
Передаватимете контекст запиту між асинхронними операціями для кореляції логів, трасування й доступу до tenant-контексту.
AsyncLocalStorageAsyncLocalStorage — це API Node.js для зберігання даних у контексті виконання асинхронної операції.
На відміну від глобальної змінної, значення в AsyncLocalStorage ізольоване для кожного асинхронного ланцюжка. Тому паралельні HTTP-запити не перезаписують контекст один одного.
Це зручно для зберігання:
ідентифікатора запиту requestId;
ідентифікатора tenant-а;
кореляційних даних для логів;
ідентифікатора trace або span;
технічних метаданих поточного запиту.
Контекст автоматично передається через багато стандартних асинхронних операцій Node.js:
Promise;
async/await;
таймери;
мережеві операції;
більшість API, побудованих на асинхронних ресурсах Node.js.
Глобальна змінна не ізолює дані між запитами:
let currentRequestId: string | undefined;Якщо два запити обробляються паралельно, другий запит може перезаписати значення, поки перший ще виконується.
AsyncLocalStorage створює окремий контекст для кожного запиту:
Запит A → requestId: A
Запит B → requestId: BНавіть якщо обидва запити одночасно очікують завершення Promise, кожен отримає власний контекст.
У NestJS найзручніше створити:
singleton-сервіс-обгортку над AsyncLocalStorage;
middleware, яке створює контекст для кожного HTTP-запиту;
сервіси, які читають контекст без передавання requestId через кожен метод.
AsyncLocalStorage потрібно створити один раз і зареєструвати як singleton-провайдер.
import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';
export interface RequestContextStore {
readonly requestId: string;
readonly tenantId: string;
}
@Injectable()
export class RequestContext {
private readonly storage = new AsyncLocalStorage<RequestContextStore>();
run<T>(store: RequestContextStore, callback: () => T): T {
return this.storage.run(store, callback);
}
get(): RequestContextStore | undefined {
return this.storage.getStore();
}
require(): RequestContextStore {
const store = this.get();
if (!store) {
throw new Error('Контекст запиту недоступний');
}
return store;
}
}Метод run запускає callback із заданим контекстом. Усі асинхронні операції, створені всередині callback, успадковують цей контекст.
Метод getStore повертає undefined, якщо код виконується поза контекстом HTTP-запиту. Метод require зручний там, де контекст є обов’язковим.
Middleware виконується на початку обробки запиту. Саме тут можна створити requestId і визначити tenant.
import {
Injectable,
NestMiddleware,
} from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import { randomUUID } from 'node:crypto';
import { RequestContext } from './request-context';
@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
constructor(private readonly requestContext: RequestContext) {}
use(
request: Request,
response: Response,
next: NextFunction,
): void {
const requestId =
request.header('x-request-id') ?? randomUUID();
const tenantId =
request.header('x-tenant-id') ?? 'public';
this.requestContext.run(
{
requestId,
tenantId,
},
() => next(),
);
}
}Виклик next() має відбутися всередині run. Завдяки цьому NestJS оброблятиме подальший ланцюжок у створеному контексті.
Заголовок x-request-id часто надходить від reverse proxy або API gateway. Якщо його немає, застосунок генерує новий ідентифікатор.
У production-системі зовнішні значення потрібно додатково перевіряти:
обмежувати довжину;
дозволяти лише очікуваний формат;
не використовувати tenantId із заголовка як доказ автентифікації tenant-а.
Middleware потрібно застосувати до маршрутів модуля:
import {
MiddlewareConsumer,
Module,
NestModule,
} from '@nestjs/common';
import { RequestContext } from './request-context';
import { RequestContextMiddleware } from './request-context.middleware';
import { CorrelationLogger } from './correlation-logger';
import { OrdersService } from './orders.service';
import { OrdersController } from './orders.controller';
@Module({
controllers: [OrdersController],
providers: [
RequestContext,
RequestContextMiddleware,
CorrelationLogger,
OrdersService,
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer
.apply(RequestContextMiddleware)
.forRoutes('*');
}
}Оскільки RequestContext є singleton-провайдером, middleware і всі інші сервіси використовують той самий екземпляр AsyncLocalStorage.
Тепер логер може самостійно додавати requestId і tenantId, не отримуючи їх параметрами кожного методу.
import { Injectable } from '@nestjs/common';
import { RequestContext } from './request-context';
@Injectable()
export class CorrelationLogger {
constructor(private readonly requestContext: RequestContext) {}
log(message: string, fields: Record<string, unknown> = {}): void {
const context = this.requestContext.get();
process.stdout.write(
`${JSON.stringify({
timestamp: new Date().toISOString(),
requestId: context?.requestId,
tenantId: context?.tenantId,
message,
...fields,
})}\n`,
);
}
}Будь-який сервіс, який використовує CorrelationLogger, отримає правильний контекст поточного запиту.
Нижче наведено мінімальний приклад NestJS-застосунку з контекстом запиту, логуванням і асинхронною операцією.
request-context.tsimport { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';
export interface RequestContextStore {
readonly requestId: string;
readonly tenantId: string;
}
@Injectable()
export class RequestContext {
private readonly storage = new AsyncLocalStorage<RequestContextStore>();
run<T>(store: RequestContextStore, callback: () => T): T {
return this.storage.run(store, callback);
}
get(): RequestContextStore | undefined {
return this.storage.getStore();
}
require(): RequestContextStore {
const store = this.get();
if (!store) {
throw new Error('Контекст запиту недоступний');
}
return store;
}
}request-context.middleware.tsimport { Injectable, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import { randomUUID } from 'node:crypto';
import { RequestContext } from './request-context';
@Injectable()
export class RequestContextMiddleware implements NestMiddleware {
constructor(private readonly requestContext: RequestContext) {}
use(
request: Request,
response: Response,
next: NextFunction,
): void {
const requestId =
request.header('x-request-id') ?? randomUUID();
const tenantId =
request.header('x-tenant-id') ?? 'public';
this.requestContext.run(
{
requestId,
tenantId,
},
() => next(),
);
}
}correlation-logger.tsimport { Injectable } from '@nestjs/common';
import { RequestContext } from './request-context';
@Injectable()
export class CorrelationLogger {
constructor(private readonly requestContext: RequestContext) {}
log(message: string, fields: Record<string, unknown> = {}): void {
const context = this.requestContext.get();
process.stdout.write(
`${JSON.stringify({
timestamp: new Date().toISOString(),
requestId: context?.requestId,
tenantId: context?.tenantId,
message,
...fields,
})}\n`,
);
}
}orders.service.tsimport { Injectable } from '@nestjs/common';
import { CorrelationLogger } from './correlation-logger';
import { RequestContext } from './request-context';
@Injectable()
export class OrdersService {
constructor(
private readonly logger: CorrelationLogger,
private readonly requestContext: RequestContext,
) {}
async loadOrders(): Promise<{ tenantId: string; count: number }> {
const before = this.requestContext.require();
this.logger.log('Початок завантаження замовлень');
await new Promise<void>((resolve) => {
setTimeout(resolve, 50);
});
const after = this.requestContext.require();
this.logger.log('Замовлення завантажено', {
contextPreserved: before.requestId === after.requestId,
});
return {
tenantId: after.tenantId,
count: 3,
};
}
}orders.controller.tsimport { Controller, Get } from '@nestjs/common';
import { CorrelationLogger } from './correlation-logger';
import { OrdersService } from './orders.service';
import { RequestContext } from './request-context';
@Controller('orders')
export class OrdersController {
constructor(
private readonly ordersService: OrdersService,
private readonly requestContext: RequestContext,
private readonly logger: CorrelationLogger,
) {}
@Get()
async getOrders(): Promise<{
requestId: string;
tenantId: string;
orders: { tenantId: string; count: number };
}> {
const context = this.requestContext.require();
this.logger.log('Отримано HTTP-запит');
const orders = await this.ordersService.loadOrders();
return {
requestId: context.requestId,
tenantId: context.tenantId,
orders,
};
}
}app.module.tsimport {
MiddlewareConsumer,
Module,
NestModule,
} from '@nestjs/common';
import { CorrelationLogger } from './correlation-logger';
import { OrdersController } from './orders.controller';
import { OrdersService } from './orders.service';
import { RequestContextMiddleware } from './request-context.middleware';
import { RequestContext } from './request-context';
@Module({
controllers: [OrdersController],
providers: [
RequestContext,
RequestContextMiddleware,
CorrelationLogger,
OrdersService,
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
consumer
.apply(RequestContextMiddleware)
.forRoutes('*');
}
}main.tsimport { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
await app.listen(process.env.PORT ?? 3000);
}
void bootstrap();Після запуску запит:
curl \
-H "x-request-id: request-42" \
-H "x-tenant-id: acme" \
http://localhost:3000/ordersповерне результат приблизно такого вигляду:
{
"requestId": "request-42",
"tenantId": "acme",
"orders": {
"tenantId": "acme",
"count": 3
}
}Логи матимуть спільний requestId:
{"requestId":"request-42","tenantId":"acme","message":"Отримано HTTP-запит"}
{"requestId":"request-42","tenantId":"acme","message":"Початок завантаження замовлень"}
{"requestId":"request-42","tenantId":"acme","message":"Замовлення завантажено","contextPreserved":true}Tenant-контекст корисний у multi-tenant застосунках, коли кожна операція має виконуватися в межах певного tenant-а.
Наприклад, репозиторій може отримати tenant без явного параметра:
import { Injectable } from '@nestjs/common';
import { RequestContext } from './request-context';
@Injectable()
export class TenantOrdersRepository {
constructor(private readonly requestContext: RequestContext) {}
async findAll(): Promise<unknown[]> {
const { tenantId } = this.requestContext.require();
// У реальному застосунку tenantId використовується у фільтрі запиту до БД.
return [
{
id: 1,
tenantId,
},
];
}
}Однак AsyncLocalStorage не замінює авторизацію. Значення tenantId має походити з перевіреного токена, сесії або іншого довіреного механізму. HTTP-заголовок у прикладі використовується лише для демонстрації.
run проти enterWithДля створення контексту запиту слід переважно використовувати run:
this.requestContext.run(store, () => {
next();
});run обмежує контекст callback-ом і асинхронними операціями, створеними всередині нього.
enterWith змінює поточний контекст до кінця поточного синхронного виконання. У middleware та обробниках подій це може призвести до небажаного поширення контексту на наступні операції. Для HTTP-контексту run зазвичай є безпечнішим і зрозумілішим вибором.
Контекст HTTP-запиту існує лише в асинхронному ланцюжку, який почався під час цього запиту.
Не слід покладатися на нього у фоновій задачі, яка запускається незалежно:
setInterval(() => {
// Тут немає гарантованого контексту HTTP-запиту.
}, 10_000);Якщо фонова операція логічно належить конкретному запиту, необхідні дані краще передати їй явно або зберегти разом із повідомленням черги:
const { requestId, tenantId } = this.requestContext.require();
await queue.add('send-report', {
requestId,
tenantId,
});Після цього worker може створити власний контекст на час обробки повідомлення.
У store варто класти невеликі незмінні значення:
рядки;
ідентифікатори;
короткі числа;
прапорці.
Не потрібно зберігати там повний об’єкт запиту, відповіді, великі payload-и або об’єкти доменного рівня. Це збільшує зв’язаність і може утримувати зайві дані в пам’яті.
Краще створити store як набір незмінних значень:
const store = {
requestId,
tenantId,
} as const;Якщо контекст потрібно доповнювати, безпечніше створити новий об’єкт, а не змінювати спільний mutable-об’єкт.
Сервіс може викликатися:
з HTTP-контролера;
із cron-задачі;
із consumer-а черги;
під час unit-тесту.
Тому get() і require() мають різне призначення:
get() підходить для необов’язкового логування;
require() підходить для операцій, яким tenant або request ID обов’язково потрібен.
AsyncLocalStorage у кожному запитіНеправильно створювати новий екземпляр у middleware:
const storage = new AsyncLocalStorage();У такому разі різні сервіси не матимуть доступу до одного й того самого сховища. Екземпляр повинен бути singleton-провайдером NestJS.
next() поза runНеправильно:
this.requestContext.run(store, () => {
// Контекст створено, але ланцюжок NestJS запущено поза ним.
});
next();Правильно:
this.requestContext.run(store, () => {
next();
});Глобальні змінні не ізолюють паралельні запити та призводять до змішаних логів і помилкового tenant-контексту.
Контекст HTTP-запиту не є глобальним контекстом усього застосунку. Фонові задачі повинні отримувати необхідні ідентифікатори явно та створювати власний контекст за потреби.
Значення з AsyncLocalStorage зручно передавати до репозиторіїв, але воно не гарантує, що користувач має доступ до цього tenant-а. Автентифікація й авторизація повинні виконуватися окремо.
AsyncLocalStorage зберігає дані в межах асинхронного ланцюжка.
У NestJS його зручно інкапсулювати в singleton-сервіс.
Middleware має створити контекст через run до виклику next().
Сервіси можуть отримувати requestId і tenantId без явного передавання через усі методи.
Контекст добре підходить для кореляції логів і трасування.
Для фонових задач контекст HTTP-запиту не гарантований.
Дані tenant-а з контексту не замінюють механізми автентифікації та авторизації.