Пошук уроків, статей та іншого контенту
Налаштуєте request-scoped залежності та оціните їхній вплив на продуктивність і життєвий цикл запитів.
За замовчуванням провайдер у NestJS має область видимості DEFAULT. Такий провайдер створюється один раз під час запуску застосунку, а потім повторно використовується в усіх запитах.
Для залежності, яка повинна існувати лише протягом одного HTTP-запиту, використовується область REQUEST:
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
// ...
}NestJS створює новий екземпляр такого провайдера для кожного запиту та знищує його після завершення обробки запиту.
Це корисно, коли залежність зберігає контекст поточного запиту:
ідентифікатор запиту;
дані автентифікованого користувача;
локаль або часовий пояс;
значення кореляції для логування;
тимчасовий стан, який не можна розділяти між запитами.
Розглянемо сервіс, який формує контекст поточного HTTP-запиту.
import { Inject, Injectable, Scope } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Request } from 'express';
import { randomUUID } from 'node:crypto';
@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
private readonly id: string;
private readonly startedAt: number;
constructor(
@Inject(REQUEST)
private readonly request: Request,
) {
this.id =
request.header('x-request-id') ??
randomUUID();
this.startedAt = Date.now();
}
getId(): string {
return this.id;
}
getMethod(): string {
return this.request.method;
}
getPath(): string {
return this.request.path;
}
getElapsedMilliseconds(): number {
return Date.now() - this.startedAt;
}
}Токен REQUEST містить об’єкт поточного HTTP-запиту. Для адаптера Express його типом є Request.
Важливо, що RequestContextService не потрібно вручну створювати або передавати в методи контролера. NestJS сам створить його в контексті поточного запиту.
Провайдер можна інжектувати в контролер звичайним способом:
import { Controller, Get } from '@nestjs/common';
import { RequestContextService } from './request-context.service';
@Controller('diagnostics')
export class DiagnosticsController {
constructor(
private readonly requestContext: RequestContextService,
) {}
@Get()
getDiagnostics() {
return {
requestId: this.requestContext.getId(),
method: this.requestContext.getMethod(),
path: this.requestContext.getPath(),
elapsedMilliseconds: this.requestContext.getElapsedMilliseconds(),
};
}
}Тепер кожен HTTP-запит до GET /diagnostics отримає власний екземпляр RequestContextService.
Приклад відповіді:
{
"requestId": "f3e2f99c-1e5a-4e0b-a9b4-c7cc0a51e6ef",
"method": "GET",
"path": "/diagnostics",
"elapsedMilliseconds": 0
}Якщо передати власний ідентифікатор:
curl -H "x-request-id: order-123" http://localhost:3000/diagnosticsу відповіді буде використано значення order-123.
Провайдер потрібно зареєструвати в модулі, як і звичайний провайдер:
import { Module } from '@nestjs/common';
import { DiagnosticsController } from './diagnostics.controller';
import { RequestContextService } from './request-context.service';
@Module({
controllers: [DiagnosticsController],
providers: [RequestContextService],
})
export class AppModule {}Мінімальний main.ts залишається стандартним:
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
bootstrap();Після запуску застосунку можна перевірити поведінку:
curl http://localhost:3000/diagnostics
curl http://localhost:3000/diagnosticsДва запити матимуть різні ідентифікатори, якщо клієнт не передав один і той самий x-request-id.
Для кожного запиту NestJS виконує приблизно такі кроки:
Створює контекст запиту.
Створює екземпляри request-scoped провайдерів, потрібних цьому запиту.
Розв’язує їхні залежності.
Виконує обробник контролера.
Завершує обробку запиту.
Вивільняє об’єкти, пов’язані з цим контекстом.
Якщо два запити одночасно використовують RequestContextService, вони не отримують спільний екземпляр:
Запит A ──> RequestContextService #1
Запит B ──> RequestContextService #2Тому стан усередині сервісу можна безпечно змінювати в межах одного запиту, не боячись, що його побачить інший запит.
Проте це не означає, що request-scoped провайдер потрібно використовувати для будь-якого тимчасового значення. Залежність створюється під час кожного запиту, тому зайвий стан збільшує навантаження на застосунок.
Область видимості поширюється вгору графом залежностей.
Якщо контролер залежить від request-scoped провайдера:
@Controller('orders')
export class OrdersController {
constructor(
private readonly requestContext: RequestContextService,
) {}
}то сам контролер також стає request-scoped у практичному сенсі: NestJS повинен створювати його для кожного запиту.
Якщо request-scoped сервіс інжектується в інший сервіс, залежний сервіс також потрапляє до request-scoped гілки:
RequestContextService (REQUEST)
↑
OrdersService
↑
OrdersControllerЦе впливає на весь ланцюжок створення залежностей. Тому request-scoped провайдер, розташований близько до кореня графа, може змусити NestJS створювати багато об’єктів на кожен запит.
Не слід інжектувати request-scoped залежність у великий сервіс без потреби. Якщо лише один метод потребує заголовка запиту або ідентифікатора, краще:
обмежити request-scoped логіку невеликим провайдером;
не зберігати в ньому важкі об’єкти;
не робити request-scoped усю бізнес-логіку застосунку;
залишати незмінні сервіси singleton-провайдерами.
DEFAULT, REQUEST і TRANSIENTNestJS підтримує кілька стандартних областей видимості.
DEFAULT@Injectable()
export class ConfigService {}або еквівалентно:
@Injectable({ scope: Scope.DEFAULT })
export class ConfigService {}Екземпляр створюється один раз і спільно використовується всіма споживачами.
Це рекомендований варіант для:
конфігурації;
клієнтів бази даних;
клієнтів зовнішніх API;
сервісів без стану конкретного запиту;
більшості бізнес-сервісів.
REQUEST@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {}Новий екземпляр створюється для кожного запиту.
TRANSIENT@Injectable({ scope: Scope.TRANSIENT })
export class FormatterService {}Transient-провайдер не є спільним для споживачів. NestJS створює окремий екземпляр для кожного споживача, який його інжектує.
TRANSIENT не є заміною REQUEST. Він описує область споживача, а не область HTTP-запиту. Якщо потрібен стан, спільний для кількох сервісів у межах одного запиту, слід використовувати REQUEST.
Request-scoped залежності мають вищу вартість, ніж singleton-провайдери, оскільки для кожного запиту потрібно:
створити нові екземпляри;
розв’язати їхні залежності;
підтримувати контекст запиту;
виконати додаткову роботу контейнера залежностей.
Чим більше request-scoped залежностей у графі, тим більшою може бути затримка.
Під час оцінювання слід порівняти щонайменше два варіанти:
Контролер і сервіси працюють як singleton.
Та сама функціональність використовує request-scoped провайдер.
Для кожного варіанта варто перевірити:
середню затримку;
затримку для повільніших запитів;
кількість запитів за секунду;
використання CPU;
кількість тимчасових об’єктів і частоту роботи збирача сміття;
поведінку під одночасним навантаженням.
Вимірювати потрібно в режимі, близькому до production. Результати розробницького запуску з логуванням і режимом watch можуть суттєво відрізнятися від результатів зібраного застосунку.
Singleton-сервіс:
import { Injectable } from '@nestjs/common';
@Injectable()
export class PriceService {
calculate(price: number, tax: number): number {
return price + price * tax;
}
}Request-scoped версія тієї самої логіки:
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST })
export class PriceService {
calculate(price: number, tax: number): number {
return price + price * tax;
}
}Функціональний результат однаковий, але друга версія створюватиме екземпляр для кожного запиту. Якщо сервіс не використовує дані поточного запиту, його не потрібно робити request-scoped.
Request scope доречний, коли стан повинен бути ізольованим для кожного запиту:
контекст аудиту;
ідентифікатор кореляції;
локалізований форматер;
контекст авторизованого користувача;
накопичення метаданих для одного запиту;
обгортка над операціями, яка не повинна ділитися станом між запитами.
Наприклад, сервіс аудиту може отримувати контекст запиту:
import { Injectable } from '@nestjs/common';
import { RequestContextService } from './request-context.service';
@Injectable()
export class AuditService {
constructor(
private readonly requestContext: RequestContextService,
) {}
record(action: string): void {
console.log({
action,
requestId: this.requestContext.getId(),
path: this.requestContext.getPath(),
});
}
}У цьому прикладі AuditService входить до залежного графа request-scoped контексту. Якщо аудит використовується в багатьох місцях, потрібно врахувати ціну такого поширення.
Не слід застосовувати request scope лише для того, щоб:
уникнути явної передачі параметра методу;
зберегти кеш конфігурації;
створити клієнт бази даних для кожного запиту;
приховати залежність від поточного користувача;
замінити звичайні параметри методів;
зробити сервіс «безпечнішим» без реальної потреби в ізоляції.
Наприклад, якщо метод потребує ідентифікатор користувача, часто простіше й прозоріше передати його явно:
@Injectable()
export class OrdersService {
findForUser(userId: string) {
return {
userId,
orders: [],
};
}
}Так сервіс залишається singleton-провайдером, а його залежності не залежать від HTTP-контексту.
Request-scoped провайдер доступний протягом обробки запиту, зокрема в асинхронних методах, які очікуються через await:
import { Injectable, Scope } from '@nestjs/common';
@Injectable({ scope: Scope.REQUEST })
export class RequestTimerService {
private readonly startedAt = Date.now();
async measure(operation: () => Promise<void>) {
await operation();
return {
elapsedMilliseconds: Date.now() - this.startedAt,
};
}
}Водночас не слід зберігати request-scoped екземпляр у глобальній змінній, singleton-кеші або іншому довгоживучому об’єкті. Це може:
утримувати дані запиту довше, ніж потрібно;
створити витік пам’яті;
призвести до змішування контекстів;
зробити поведінку залежною від порядку запитів.
Токен REQUEST стосується HTTP-контексту. Його використання потрібно перевірити, якщо застосунок обробляє не лише HTTP-запити, а й інші типи повідомлень або транспортів.
Для кожного типу транспорту контекст запиту може мати іншу структуру. Не варто безпосередньо припускати, що в ньому доступні властивості Express Request, наприклад method, path або headers.
Також request-scoped логіка повинна враховувати, що не кожен виклик бізнес-методу обов’язково походить від HTTP-запиту. Фонові задачі, команди та інші внутрішні виклики можуть не мати такого контексту.
@Injectable({ scope: Scope.REQUEST })
export class AppConfigService {}Конфігурація зазвичай не залежить від конкретного запиту, тому request scope лише збільшує кількість створюваних об’єктів.
Не слід робити так:
@Injectable()
export class GlobalStore {
private context: RequestContextService;
setContext(context: RequestContextService): void {
this.context = context;
}
}Singleton живе протягом усього процесу, а request-scoped контекст — лише протягом одного запиту. Така схема може змішати дані паралельних запитів.
Кожен запит отримує новий екземпляр:
@Injectable({ scope: Scope.REQUEST })
export class CounterService {
private count = 0;
increment(): number {
this.count += 1;
return this.count;
}
}Для послідовних запитів результат може бути 1, 1, 1, а не 1, 2, 3. Для спільного лічильника потрібне інше сховище або singleton із коректною синхронізацією.
Зміна області видимості одного провайдера може зробити request-scoped контролери та сервіси, які від нього залежать. Перед зміною потрібно перевірити, скільки об’єктів буде створюватися для кожного запиту.
Залежність від REQUEST не повинна безумовно використовуватися в коді, який викликається фоновими процесами або іншими транспортами. У таких випадках контекст потрібно передавати явно або визначати окрему модель контексту.
Scope.REQUEST створює новий екземпляр провайдера для кожного запиту.
Request-scoped провайдери ізолюють стан паралельних запитів.
Токен REQUEST дає доступ до поточного HTTP-запиту.
Область видимості поширюється вгору графом залежностей.
Створення об’єктів на кожен запит має ціну для CPU, пам’яті та затримки.
Провайдери без стану запиту краще залишати singleton-провайдерами.
Продуктивність потрібно оцінювати вимірюваннями, а не припущеннями.
Request-scoped екземпляри не можна зберігати в довгоживучих singleton-об’єктах.