Раздел 8 · Frameworks и Libs

Nest: основы и IoC-контейнер

middle~45 мин

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

Nest: основы и IoC-контейнер

Fastify-трек мы прошли снизу вверх: голый node:http, потом система плагинов, хуки, схемы, и в финале собрали боевой модульный API руками. Каждый кусок инфраструктуры мы добавляли сами и видели, из чего он сделан.

Nest подходит к той же задаче с противоположного конца. Это фреймворк с жёсткими конвенциями: он заранее решил, как у тебя будут устроены модули, как сервисы находят друг друга, где живёт валидация и обработка ошибок. Ты не собираешь каркас, ты заполняешь готовый. Цена и выгода этого выбора, это и есть тема блока B.

Сердце Nest, это IoC-контейнер на декораторах. Поймёшь контейнер, поймёшь Nest. Поэтому начинаем не с роутов, а с того, как объекты находят друг друга.

1. Зачем вообще контейнер

Возьмём знакомую тройку слоёв из Fastify-урока 05: контроллер зовёт сервис, сервис зовёт репозиторий. Без контейнера сборка выглядит так:

const repository = new UsersRepository(dataSource);
const service = new UsersService(repository);
const controller = new UsersController(service);

Пока объектов три, это терпимо. Но репозиторию нужен коннект к базе, сервису, кроме репозитория, ещё логгер и почтовый клиент, почтовому клиенту, конфиг. Граф растёт, и руками собирать его становится больно: порядок создания, переиспользование одного коннекта на всех, замена реализации в тестах.

Dependency injection снимает эту боль. Каждый класс лишь объявляет, что ему нужно, а контейнер сам строит граф, создаёт объекты в правильном порядке и переиспользует синглтоны:

@Injectable()
export class UsersService {
  // объявили зависимости в конструкторе, не создаём их сами
  constructor(
    private readonly repository: UsersRepository,
    private readonly logger: Logger,
  ) {}
}

Контейнер видит по типам аргументов конструктора, что UsersService зависит от UsersRepository и Logger, находит их, создаёт при необходимости и передаёт. Это и называется инверсией: не сервис тянет зависимости, а контейнер их вкладывает.

Откуда контейнер знает типы аргументов в рантайме? TypeScript при включённом emitDecoratorMetadata записывает типы параметров конструктора в метаданные через reflect-metadata. Это та самая рефлексия, которую мы вскроем в уроке 09. Пока достаточно знать, что декоратор @Injectable() помечает класс как участника графа.

2. Три кирпича: модуль, провайдер, контроллер

Nest-приложение собрано из трёх сущностей.

Провайдер (provider), это всё, что контейнер умеет создавать и внедрять: сервисы, репозитории, фабрики, значения конфига. Помечается @Injectable().

Контроллер (controller), это провайдер с привязкой к HTTP: его методы становятся обработчиками роутов через декораторы @Get, @Post и так далее.

Модуль (module), это граница. Он перечисляет, какие провайдеры и контроллеры в него входят, что он импортирует из других модулей и что отдаёт наружу. Модуль, это аналог области инкапсуляции из Fastify-урока 02: внутри модуля провайдеры видят друг друга, наружу видно только то, что в exports.

@Module({
  imports: [],          // другие модули, чьи exports нам нужны
  controllers: [UsersController],
  providers: [UsersService, UsersRepository],
  exports: [UsersService], // что увидят те, кто импортирует UsersModule
})
export class UsersModule {}

Запомни параллель с Fastify, она сквозная для всего блока:

FastifyNestЧто это
плагин (область)модульграница инкапсуляции
декоратор на инстансепровайдерпереиспользуемая зависимость
fastify-plugin (слом изоляции)exportsсделать видимым наружу
хукguard / interceptor / pipeточка во входящем конвейере

Разница в том, что в Fastify ты сам решаешь, что положить на инстанс и где сломать инкапсуляцию через fp. В Nest контейнер решает за тебя по графу типов, а ты лишь декларируешь связи.

3. Минимальный модуль целиком

Соберём users: контроллер, сервис, простой in-memory репозиторий. Это тот же домен, что мы делали на Fastify, держим рядом для сравнения.

Репозиторий, обычный провайдер. Пока без базы, чтобы не отвлекаться:

// users.repository.ts
import { Injectable } from '@nestjs/common';

export interface User {
  id: number;
  email: string;
  name: string;
}

@Injectable()
export class UsersRepository {
  private readonly users = new Map<number, User>();
  private sequence = 0;

  create(data: Omit<User, 'id'>): User {
    const id = ++this.sequence;
    const user = { id, ...data };
    this.users.set(id, user);
    return user;
  }

  findById(id: number): User | undefined {
    return this.users.get(id);
  }
}

Сервис зависит от репозитория. Бизнес-логика и решение, что делать при отсутствии пользователя, живут здесь:

// users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';

import { CreateUserDto } from './dto/create-user.dto';
import { UsersRepository } from './users.repository';

@Injectable()
export class UsersService {
  constructor(private readonly repository: UsersRepository) {}

  create(dto: CreateUserDto) {
    return this.repository.create(dto);
  }

  findById(id: number) {
    const user = this.repository.findById(id);
    if (!user) {
      throw new NotFoundException(`Пользователь ${id} не найден`);
    }
    return user;
  }
}

Контроллер, тонкий слой HTTP. Он принимает запрос, зовёт сервис, отдаёт результат. Никакой логики, кроме привязки к транспорту:

// users.controller.ts
import { Body, Controller, Get, Param, ParseIntPipe, Post } from '@nestjs/common';

import { CreateUserDto } from './dto/create-user.dto';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  constructor(private readonly service: UsersService) {}

  @Post()
  create(@Body() dto: CreateUserDto) {
    return this.service.create(dto);
  }

  @Get(':id')
  findById(@Param('id', ParseIntPipe) id: number) {
    return this.service.findById(id);
  }
}

Обрати внимание на три вещи, которые контейнер делает за тебя:

  • @Controller('users') задаёт префикс роутов, @Post() и @Get(':id'), методы и пути. Это декларативный аналог app.post('/users', ...) из Fastify.
  • @Body(), @Param(), это parameter-декораторы: Nest сам достанет тело и параметр пути из запроса.
  • ParseIntPipe, встроенный pipe: превратит строковый :id в число или кинет 400. Pipes, тема урока 07, здесь просто увидь, что они есть.

Возврат значения из метода, это идиома Nest: что вернул, то и уйдёт в тело ответа как JSON. Бросок NotFoundException превратится в ответ 404. Руками трогать объект ответа почти никогда не нужно, как и в Fastify, где мы предпочитали return вместо reply.send.

Связываем всё модулем (как в § 2) и собираем приложение:

// main.ts
import { NestFactory } from '@nestjs/core';

import { UsersModule } from './users/users.module';

const app = await NestFactory.create(UsersModule);
await app.listen(3000);

Меньше явной сборки, чем в Fastify: мы нигде не написали new UsersService(...). Контейнер прочитал граф из декораторов и собрал его сам.

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

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

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