Раздел 8 · Frameworks и Libs

Nest: жизненный цикл и идиомы

middle~50 мин

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

Nest: жизненный цикл и идиомы

В уроке 06 мы собрали модуль и поняли контейнер. Но контроллер был тонким, а вся сквозная логика (аутентификация, логирование, формат ответа, обработка ошибок) висела в воздухе. В Fastify такие задачи мы решали хуками: помнишь конвейер onRequest → preHandler → handler → onSend из урока 03? У Nest есть свой конвейер, только из четырёх типов сущностей. Этот урок про него и про идиомы, которые делают Nest-код таким, каким его пишут в проде.

Главный практический сюжет урока, в § 7: как из сервисов возвращать типизированные ошибки в стиле railway (через fp-ts TaskEither) и аккуратно распаковывать их одним interceptor, не засоряя контроллеры try/catch. Это паттерн из боевого Nest-проекта, и он отлично ложится на функциональный стиль, который мы держим во всём курсе.

1. Конвейер запроса

Каждый входящий запрос в Nest проходит фиксированную цепочку:

middleware → guards → interceptors (before) → pipes → handler
           → interceptors (after) → (при ошибке) exception filters

Разберём по ролям:

  • middleware, самый ранний слой, Express/Fastify-совместимый. Работает ещё до того, как Nest сопоставил запрос с обработчиком. Сюда кладут то, что не зависит от роута: CORS, helmet, разбор cookie, сырое логирование.
  • guards, отвечают на вопрос «пускать или нет». Метод canActivate возвращает true/false (или промис/Observable от них). false, это 403, дальше не идём. Здесь живут аутентификация и авторизация.
  • interceptors, оборачивают вызов обработчика. У них две половины: до handler (старт таймера, открытие транзакции) и после (трансформация результата, кэш, замер длительности). Это самая «эффектовая» точка, к ней вернёмся в § 7.
  • pipes, валидируют и преобразуют аргументы обработчика. ValidationPipe проверяет DTO, ParseIntPipe приводит типы. Невалидный вход, это 400, и handler не вызовется.
  • handler, метод контроллера. Зовёт сервис, возвращает значение.
  • exception filters, ловят любое исключение из любого слоя выше и превращают его в HTTP-ответ. Это единая воронка ошибок.

Параллель с Fastify прямая, но сущностей меньше и они типизированы ролями. Где в Fastify был один preHandler, в котором ты сам и аутентифицировал, и валидировал, в Nest это разнесено по guard и pipe с чёткой ответственностью.

Прокрути запрос по конвейеру и посмотри, как разные сбои сходятся в одной точке, в фильтре исключений:

Конвейер запроса в Nest
  1. middleware
  2. guards
  3. interceptors (before)
  4. pipes
  5. handler
  6. interceptors (after)
  7. exception filters (сбор ошибок)
нажми «шаг»

Запрос ещё не вошёл в конвейер.

Ключевое наблюдение из виджета: любая ошибка, в каком бы слое она ни возникла, приходит в exception filter. Это и есть фундамент идиомы из § 7: сервису достаточно сообщить об ошибке, а единый формат ответа соберёт фильтр.

2. Четыре сущности конвейера в коде

Минимальные примеры каждого слоя, чтобы было от чего отталкиваться.

Guard, реализует CanActivate:

@Injectable()
export class RolesGuard implements CanActivate {
  constructor(private readonly reflector: Reflector) {}

  canActivate(context: ExecutionContext): boolean {
    const required = this.reflector.get<string[]>('roles', context.getHandler());
    if (!required) return true;
    const { user } = context.switchToHttp().getRequest();
    return required.includes(user?.role);
  }
}

Reflector, штатный сервис, читающий метаданные, которые ты вешаешь своим декоратором (@Roles('admin')). Это уже знакомая по уроку 06 рефлексия: декоратор кладёт метку, guard её читает.

Interceptor, реализует NestInterceptor, работает через RxJS:

@Injectable()
export class LoggingInterceptor implements NestInterceptor {
  private readonly logger = new Logger(LoggingInterceptor.name);

  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const started = performance.now();
    const { method, url } = context.switchToHttp().getRequest();
    // до handler: код здесь выполнится перед вызовом
    return next.handle().pipe(
      // после handler: tap видит результат
      tap(() => this.logger.log(`${method} ${url} · ${Math.round(performance.now() - started)} ms`)),
    );
  }
}

next.handle(), это Observable результата обработчика. Всё до return, выполняется до handler; операторы в .pipe(...), после. Interceptor, единственный слой, который видит обе стороны вызова, поэтому в нём и трансформируют ответ.

Pipe, реализует PipeTransform:

@Injectable()
export class ParsePositiveIntPipe implements PipeTransform<string, number> {
  transform(value: string): number {
    const parsed = Number(value);
    if (!Number.isInteger(parsed) || parsed <= 0) {
      throw new BadRequestException('Ожидалось положительное целое');
    }
    return parsed;
  }
}

Exception filter, реализует ExceptionFilter, ловит указанный класс ошибок:

@Catch(DomainError)
export class DomainExceptionFilter implements ExceptionFilter {
  catch(exception: DomainError, host: ArgumentsHost): void {
    const response = host.switchToHttp().getResponse();
    response.status(exception.httpCode).send({
      success: false,
      error: { type: exception.type, details: exception.details },
    });
  }
}

3. Куда вешать: метод, контроллер, глобально

Каждую из четырёх сущностей можно навесить на трёх уровнях, и это важная часть идиоматики:

// на конкретный метод
@UseGuards(RolesGuard)
@Get(':id')
findById() {}

// на весь контроллер
@UseGuards(RolesGuard)
@Controller('users')
export class UsersController {}

// глобально, для всего приложения
app.useGlobalGuards(new RolesGuard());

Но глобальная регистрация через useGlobalGuards/useGlobalFilters отрезает сущность от контейнера: new RolesGuard() ты создаёшь руками, и зависимости в него не внедрятся. Идиоматичный способ повесить guard/interceptor/pipe/filter глобально с DI, через токены APP_GUARD, APP_INTERCEPTOR, APP_PIPE, APP_FILTER:

@Module({
  providers: [
    { provide: APP_GUARD, useClass: RolesGuard },
    { provide: APP_INTERCEPTOR, useClass: LoggingInterceptor },
    { provide: APP_FILTER, useClass: DomainExceptionFilter },
    {
      provide: APP_PIPE,
      useValue: new ValidationPipe({ whitelist: true, transform: true }),
    },
  ],
})
export class CoreModule {}

Так контейнер сам создаёт guard и внедряет в него Reflector. Этот CoreModule с глобальными провайдерами, типовая раскладка боевого проекта: один модуль собирает всю сквозную инфраструктуру, остальные импортируют его. Порядок interceptors при этом, порядок их регистрации в providers, это пригодится в § 7.

4. Scopes: сколько живёт провайдер

По умолчанию провайдер, синглтон: контейнер создаёт его один раз на всё приложение. Это scope DEFAULT, и в 95% случаев он правильный. Есть ещё два:

@Injectable({ scope: Scope.REQUEST })
export class RequestContextService {
  readonly id = randomUUID(); // свой на каждый запрос
}

@Injectable({ scope: Scope.TRANSIENT })
export class CounterService {} // новый инстанс при каждом внедрении
  • REQUEST, новый инстанс на каждый HTTP-запрос. Нужен, когда провайдер хранит состояние запроса (текущий пользователь, request id, открытая транзакция).
  • TRANSIENT, новый инстанс каждому, кто его внедряет. Нужен редко: например, логгер, который хочет знать имя класса-владельца.

Цена REQUEST-scope реальна: он заразен вверх по графу. Если сервис request-scoped, то и контроллер, который его внедряет, становится request-scoped, и контейнер пересобирает эту ветку графа на каждый запрос. Для горячего пути это заметные накладные расходы. Идиома: не тянуть состояние запроса в провайдеры, а возить его в ExecutionContext или через AsyncLocalStorage (мост на контекст в Node), оставляя сервисы синглтонами.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.