NestJS: полноценный фреймворк для Node.js?

Эта статья была опубликована в журнале Programmez №250 от 7 января 2022 года. Еще раз спасибо им и Sfeir за эту возможность!

В наши дни ИТ — это настолько быстро меняющаяся среда, что время выхода на рынок (время между зарождением идеи и моментом, когда готовый продукт попадает в руки пользователей) стало ключевым показателем. Для быстрого создания функций разработчики уже давно полагаются на фреймворки, которые призваны повысить производительность, взяв на себя часть сложности разработки.

NestJS (Nest) — это фреймворк с открытым исходным кодом, предназначенный для разработки приложений на платформе Node.js. Он написан на языке Typescript, который он поддерживает изначально, хотя он также позволяет вам разрабатывать приложения на Javascript. Реальное преимущество Nest заключается в том, что он ускоряет запуск проекта, предлагая архитектуру, вдохновленную Angular, которая позволяет командам разрабатывать приложения, которые легко тестируются, масштабируются и поддерживаются в течение долгого времени. По состоянию на апрель 2022 года на сайте npm еженедельно загружается 1,3 миллиона программ. Его работу можно сравнить с Spring для Java, с системой аннотаций и инъекций зависимостей.

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

В этой статье мы рассмотрим пример приложения, написанного с помощью Nest: управление личным списком сериалов с оценками и комментариями. Это приложение позволяет вывести список обзоров серий и создать новый обзор с помощью формы.

Код, послуживший примером, доступен на github: CeliaDoolaeghe/my-list-of-series.

Первые шаги и конфигурация

Будучи фреймворком, Nest сделал выбор в пользу upstream, чтобы разработчикам не приходилось самостоятельно настраивать проект, что зачастую является долгим и утомительным этапом настройки, но не приносит никакой пользы бизнесу. Поэтому Nest предоставляет CLI, который позволит быстро и легко создать базовое приложение, уже настроенное и готовое к использованию, со следующей древовидной структурой:

Сгенерированный проект работает немедленно, достаточно запустить его с помощью npm start, и у нас уже есть приложение, работающее на localhost:3000, даже если оно просто отображает «Hello World» в браузере.

Nest изначально предоставляет конфигурацию для Typescript, Eslint и Prettier, которые обрабатывают типизацию Javascript, проверку кодовых соглашений и форматирование соответственно. Эти конфигурации могут быть изменены при необходимости и даже удалены, как и любая другая зависимость. Эти инструменты широко используются сообществом разработчиков Javascript, поскольку они облегчают управление проектом и особенно его сопровождаемость с течением времени.

В пакете .json уже определено некоторое количество скриптов, в частности, скрипты, необходимые для запуска приложения (с горячей перезагрузкой на этапе разработки), для запуска eslint и prettier, или для запуска тестов. Nest устанавливает и настраивает по умолчанию тестовый фреймворк Jest, наиболее распространенный для Javascript-приложений. Если мы запустим скрипт npm test, у нас уже будет запущен 1 тест, который находится там для примера. В папке с тестами также присутствуют сквозные тесты. Конечно, мы можем дополнительно установить любые необходимые зависимости, как и в любом проекте Node.js.

Производительность

По умолчанию Nest построен на базе Express, самого популярного фреймворка Node.js с открытым исходным кодом. Но если производительность — ваша главная забота, Nest также совместим с Fastify, еще одним фреймворком с открытым исходным кодом, ориентированным на производительность.

Модули

Первая сложность в проекте — это архитектура: чтобы обеспечить поддерживаемость проекта с течением времени, вам нужна четкая и масштабируемая структура. Энтропия, естественная тенденция ИТ-проектов к усложнению со временем, должна быть максимально ограничена, что сказывается на производительности при разработке новых функций.

Компания Nest выбрала модульную архитектуру: каждая функциональность будет рассматриваться как модуль. Сначала модуль состоит из одного или нескольких контроллеров, которые открывают маршруты. Модуль содержит провайдеры, которые представляют собой классы с поведением (бизнес, база данных и т.д.). Модуль может экспортировать классы и импортироваться в другие модули. Каждый модуль содержит все необходимое для его работы.

Например, возьмем функцию, которая будет использоваться только для создания обзора серии. Мы создаем CreateReviewModule, который открывает маршрут для оценки серии путем оставления комментария:

@Module({
  controllers: [CreateReviewController],
  imports: [
    MongooseModule.forFeature([
      { name: SeriesReview.name, schema: SeriesReviewSchema },
    ]),
  ],
  providers: [CreateReviewRepository, CommentChecker],
})
export class CreateReviewModule {}
Войдите в полноэкранный режим Выход из полноэкранного режима

Здесь мы видим, что наш модуль раскрывает CreateReviewController, который содержит маршрут. Он импортирует модуль Mongoose, ORM, который управляет для нас связью между нашими сущностями и базой данных MongoDB, в которой мы будем хранить заметки и комментарии к сериям (ORM не является обязательным, это зависит от вас, для примера, как здесь, это проще). Наконец, в провайдерах мы видим два класса CreateReviewRepository, который отвечает за сохранение в базу данных, и CommentChecker, который будет отвечать за проверку того, что содержание комментария разрешено (например, чтобы избежать сохранения комментария с оскорбительными выражениями).

Любые классы, перечисленные в провайдерах, могут быть внедрены в контроллеры или другие провайдеры. Классы, экспортируемые модулями, которые мы импортируем, также могут быть внедрены в классы нашего модуля.

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

Эта архитектура также является масштабируемой, поскольку добавление новых модулей не влияет на существующие, каждая новая функциональность просто добавляется в корневой модуль, то есть тот, который затем будет импортировать все остальные модули. Локальная сложность в модулях остается связанной со сложностью бизнеса, а не с размером проекта.

Например, в нашем проекте мы можем представить два модуля: один для списка существующих уведомлений, а другой для создания нового уведомления. Оба модуля используют один и тот же модуль Mongoose для базы данных, но могут нуждаться и в других собственных модулях, например, для получения афиш серий в списке объявлений. Каждый модуль импортирует только то, что ему необходимо в интересах ограниченной ответственности.

Инъекция зависимостей

Прежде чем двигаться дальше, давайте сделаем небольшое отступление об инъекции зависимостей. По сути, это пятый из принципов объектно-ориентированного программирования SOLID (D — инверсия зависимостей). Идея заключается в том, что класс «высокого уровня» (управление бизнес-правилами) не связан напрямую с классом «низкого уровня» (управление инфраструктурой). Например, мы создаем интерфейс с функциями чтения базы данных и внедряем в бизнес-классы класс, реализующий этот интерфейс.

Здесь интересно то, что наш бизнес-класс не отвечает за инстанцирование класса чтения базы данных, он ожидает получить класс, который уважает правильный интерфейс и поэтому может вызывать его функции, не заботясь о реализации. Нашему бизнес-классу не нужно знать, что эта реализация находится в MongoDB или PostgreSQL, а также не нужен макет для модульного тестирования (мы вернемся к этому в параграфе о тестировании). Обязанности каждого класса четко разделены.

В любом случае, это принцип, на котором основан Nest: объявив класс в качестве провайдера в модуле, он становится инжектируемым в другие классы модуля. Теперь мы посмотрим, как построить код на основе этого принципа.

Контроллер и валидация

Теперь давайте создадим маршрут для предоставления отзывов о серии. Это маршрут POST, поскольку мы создаем новое уведомление. Рецензия содержит название серии, оценку от 0 до 5 и необязательный комментарий.

Первое, что нужно сделать (помимо тестирования, если вы используете TDD, но к этому мы вернемся позже), это создать маршрут для добавления комментария. Это роль контроллера, который будет отвечать при вызове маршрута. Nest предоставляет необходимые аннотации для создания маршрута Post, получения тела и автоматического возврата статуса «201 Created», если не возвращаются исключения.

Разработчику остается только реализовать реальный бизнес-код, т.е. проверить, что если комментарий присутствует, то он должен быть валидным (без оскорбительного содержания), а затем сохранить это уведомление в базе данных.

@Controller()
export class CreateReviewController {
  constructor(
    private commentChecker: CommentChecker,
    private createReviewRepository: CreateReviewRepository,
  ) {}

  @Post('/series/reviews')
  async grade(@Body() gradeRequest: ReviewRequest): Promise<void> {
    if (gradeRequest.comment) {
      const isValidComment = this.commentChecker.check(gradeRequest.comment);

      if (!isValidComment) {
        throw new BadRequestException({
          message: 'This comment is not acceptable',
        });
      }
    }

    await this.createReviewRepository.save(gradeRequest);
  }
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Как можно видеть здесь, классы CommentChecker и CreateReviewRepository являются зависимостями, вводимыми конструктором, который управляется Nest через модуль, объявленный нами ранее.

Аннотации @Post() достаточно, чтобы объявить маршрут для Nest. Аннотация @Body() используется для получения тела, отправленного в Post, которое мы можем непосредственно напечатать. Здесь мы возвращаем Promise<void>, поскольку Nest по умолчанию заботится о возврате статуса 201 для маршрутов Post, хотя мы можем переопределить это поведение при необходимости.

Наконец, в дополнение к аннотациям мы написали только бизнес-правила для управления уведомлениями, и это самое главное: тратить время на бизнес-ценность, предоставляемую нашим кодом, а не на форму, чтобы заставить его работать, которой занимается фреймворк. Осталось только реализовать функции в классах CommentChecker и CreateReviewRepository, и у нас есть рабочий маршрут.

Обратите внимание, что если комментарий недействителен, мы возвращаем исключение типа BadRequestException, которое содержит статус «400 Bad Request» и в котором мы просто передаем поясняющее сообщение.

Проверка подлинности тела

Когда мы отправляем запрос, мы должны сначала проверить, что тело запроса соответствует нашим спецификациям: все обязательные поля должны присутствовать, оценка должна быть числовой и т.д. Есть две зависимости class-validator и class-transformer, которые позволяют обеспечить эту валидацию через аннотации на класс body. Здесь мы применяем правила валидации к классу ReviewRequest:

export class ReviewRequest {
  @ApiProperty({ description: 'Title of the series' })
  @IsNotEmpty()
  title: string;

  @ApiProperty({ description: 'Grade between 0 and 5' })
  @IsNumber()
  @Min(0)
  @Max(5)
  grade: number;

  @ApiPropertyOptional({ description: 'A comment on the series' })
  @IsOptional()
  @IsNotEmpty()
  comment?: string;

  constructor(title: string, grade: number, comment?: string) {
    this.title = title;
    this.grade = grade;
    this.comment = comment;
  }
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Каждое поле имеет свои правила валидации. Заголовок не должен быть пустым. Оценка должна быть числовой, а ее значение должно находиться в диапазоне от 0 до 5. Комментарий необязателен, но если он присутствует, то не должен быть пустым. Аннотации здесь очень явные и позволяют реализовать простейшие правила валидации.

Если проверка тела не прошла, то Nest возвращает статус «400 Bad Request» с сообщением, указывающим, в каком поле произошла ошибка и почему.

Также можно выполнять валидацию массивов, проверяя, что он не пуст, что каждый элемент массива соответствует заданным правилам и т.д. Доступные аннотации очень богаты.

А если моя проверка более сложная?

Иногда нам нужно выразить правила, которые не являются частью аннотаций проверки, предлагаемых по умолчанию. В этом случае сначала можно создать собственные аннотации для выполнения конкретной проверки поля. Например, мы можем проверить, начинается ли строка символов с определенного слова, если это нам необходимо.

Но мы также можем представить себе валидацию, которая потребует считывания нескольких полей. Например, в нашем случае, если рейтинг, связанный с отзывом, низкий, мы можем потребовать, чтобы комментарий был обязательным для обоснования этого рейтинга, а в остальном оставить его необязательным. Как мы можем управлять этим делом?

Мы можем создать Validation Pipe. Это класс, поведение которого выполняется до того, как контроллер получит тело. Он имеет доступ ко всему объекту ввода и оставляет разработчику право написать правила валидации. Таким образом, мы можем реализовать любое правило валидации для объекта, чтобы убедиться, что он действителен, когда попадает в контроллер. В нашем примере, если оценка меньше 3 и нет комментария, то мы возвращаем BadRequestException, в противном случае объект действителен.

@Injectable()
export class MandatoryCommentOnBadGradePipe implements PipeTransform {
  transform(value: unknown): ReviewRequest {
    const reviewRequest = plainToClass(ReviewRequest, value);

    if (reviewRequest.grade < 3 && !reviewRequest.comment) {
      throw new BadRequestException(
        'Comment is mandatory when grade is lower than 3',
      );
    }

    return reviewRequest;
  }
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Swagger

Более внимательные заметили: для чего нужны аннотации @ApiProperty()?

После того как наш маршрут создан, мы хотим протестировать его. Конечно, мы можем использовать curl, Postman или любой другой инструмент, который позволяет нам делать вызовы API. Но экосистема вокруг Nest предоставляет зависимости для динамической генерации документации Swagger из аннотаций.

Реализация очень проста, всего несколько строк в файле main.ts для развертывания этой документации на маршруте нашего приложения.

Для нашего маршрута создания уведомления, рендеринг будет выглядеть так: :

Схема тела непосредственно генерируется аннотациями @ApiProperty() и @ApiPropertyOptional() и описанием, которое они содержат. Мы получаем стандартную документацию, которой легко поделиться, поскольку она размещена непосредственно на нашем приложении, и которую легко использовать благодаря опции «Попробовать» (к аутентификации мы вернемся позже).

Модульные тесты

Сейчас мы поговорим о модульных тестах. Для того чтобы приложение оставалось работоспособным в течение долгого времени, недостаточно, чтобы архитектура помогла нам понять функциональность, на которую влияют наши изменения, необходимо также наличие модульных и/или сквозных тестов, чтобы убедиться, что наши изменения не создают регрессий в существующих бизнес-правилах.

Благодаря инъекции зависимостей, упомянутой ранее, реализованные классы легко поддаются модульному тестированию, поскольку зависимости могут быть растиражированы, т.е. заменены поддельными экземплярами, в которых мы контролируем поведение и возвраты.

Для тестирования контроллера Nest предоставляет инструменты для создания тестовых модулей, в которые мы можем внедрить наши подражаемые зависимости:

let app: INestApplication;
let commentCheckerMock: CommentChecker;
let createReviewRepository: CreateReviewRepository;

beforeEach(async () => {
  commentCheckerMock = {} as CommentChecker;
  commentCheckerMock.check = jest.fn().mockReturnValue(true);

  createReviewRepository = {} as CreateReviewRepository;
  createReviewRepository.save = jest.fn();

  const moduleFixture: TestingModule = await Test.createTestingModule({
    controllers: [CreateReviewController],
    providers: [CommentChecker, CreateReviewRepository],
  })
    .overrideGuard(AuthGuard)
    .useValue({})
    .overrideProvider(CommentChecker)
    .useValue(commentCheckerMock)
    .overrideProvider(CreateReviewRepository)
    .useValue(createReviewRepository)
    .compile();

  app = moduleFixture.createNestApplication();
  app.useGlobalPipes(new ValidationPipe());
  await app.init();
});

it('201 valid review with no comment', () => {
  return request(app.getHttpServer())
    .post('/series/reviews')
    .send({
      title: 'Test',
      grade: 3,
    })
    .expect(201);
});
Войдите в полноэкранный режим Выход из полноэкранного режима

Здесь мы создаем поддельный экземпляр CommentChecker и CreateReviewRepository, используем Jest для поддельной реализации функций этих двух классов и предоставляем их в качестве провайдеров тестовому модулю. Затем все, что остается в тесте, — это вызвать маршрут и проверить возврат.

Затем мы можем создать тесты для всех случаев, обрабатываемых нашим кодом: возврат ошибки, если одно из обязательных полей отсутствует, если оценка не находится в диапазоне от 0 до 5, если комментарий является оскорбительным и т.д.

Конечно, тесты могут быть написаны до реализации, как это рекомендуется в TDD (Test Driven Development).

Безопасность и аутентификация

Большинство приложений не находятся в свободном доступе для широкой публики и поэтому должны быть защищены. Классические рекомендации, такие как, например, установка зависимости helmet для предварительной настройки HTTP-заголовков, по-прежнему применимы и не должны быть забыты. Это также является частью рекомендаций по безопасности компании Nest.

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

Охранники работают на той же модели, но имеют преимущество в том, что знают контекст, в котором они вызываются: они знают, какой маршрут вызывается, а также какой контроллер будет выполнен, если валидация пройдена. Защитник может быть внедрен с зависимостью, например, с сервисом, который управляет проверкой маркера.

Здесь мы имеем пример защиты, которая защищает маршруты с помощью Basic-аутентификации, т.е. HTTP-запросы имеют заголовок авторизации, который содержит имя пользователя и пароль, закодированные в base 64. Затем мы проверяем, что пользователь распознан приложением:

@Injectable()
export class AuthGuard implements CanActivate {
  constructor(private authService: AuthService) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest<Request>();

    if (!request.headers.authorization) {
      throw new UnauthorizedException();
    }

    const [basic, token] = request.headers.authorization.split(' ');

    const isValidToken = await this.authService.validateBasicToken(token);
    if (basic !== 'Basic' || !isValidToken) {
      throw new UnauthorizedException();
    }

    return true;
  }
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Базовая аутентификация — не самый безопасный метод, но эта модель совместима с другими методами аутентификации, такими как JWT.

Чтобы применить эту защиту, нам просто нужно добавить в наши контроллеры аннотацию @UseGuard(AuthGuard). Мы также могли бы определить эту защиту глобально в модуле AppModule. Теперь наши маршруты безопасны, а модуль SwaggerModule может принимать опцию, позволяющую вводить базовую аутентификацию непосредственно из swagger.

Интерфейс с Nest MVC

Сейчас у нас есть маршрут для отправки отзывов о серии, но swagger не очень подходит для большинства пользователей, не являющихся разработчиками… В идеале мы должны создать небольшую форму, которая отправляет отзывы в наш API.

Конечно, мы можем подключить внешний интерфейс к нашему apis. Nest совместим со всеми зависимостями npm, такими как, например, cors, который позволяет осуществлять кросс-оригинальные вызовы между фронтендом и бэкендом, не размещенными на одном домене.

В остальном Nest позволяет реализовать все грани MVC (Model-View-Controller): мы уже видели ранее части Model и Controller, но мы также можем реализовать часть View напрямую. Идея заключается в том, чтобы сделать простые представления с помощью языка шаблонов (типа handlebars или ejs) для выполнения SSR (Server-Side Rendering). Для сложных или высокодинамичных интерфейсов этого может быть недостаточно, но для нашей формы это будет идеально.

Во-первых, нам нужно написать файл handlebars, который будет содержать нашу форму. Это классическая html-страница с шаблонизацией mustache, в которую мы можем добавить css для дизайна и js для поведения, например, для проверки значений обязательных полей перед отправкой формы.

С точки зрения Nest, наш интерфейс — это такой же модуль, как и любой другой, поэтому нам нужно импортировать его в AppModule. Наш контроллер просто связывает файл create-review.hbs с маршрутом /interface в браузере:

@Controller()
export class CreateReviewFormController {
  @Get('/interface')
  @ApiExcludeEndpoint()
  @Render('create-review')
  createReviewForm(): void {
    // Rendering form
  }
}
Войдите в полноэкранный режим Выход из полноэкранного режима

Если нам нужно ввести значения на страницу с помощью шаблонов, контроллер просто возвращает объект, содержащий значения для отображения. Здесь он нам не нужен. Аннотация @ApiExcludeEndpoint предотвратит попадание этого специфического для интерфейса маршрута в swagger.

Когда мы вводим в браузере url http://localhost:3000/interface, мы видим нашу форму:

В этом примере дизайн очень прост, но главное — иметь интерфейс, который позволит пользователям, не являющимся swagger, использовать наше приложение. Конечно, мы можем сделать гораздо более красивые интерфейсы, чем этот!

Мы также можем расширить его, добавив еще одну страницу со списком серий, для которых была опубликована рецензия, отображая вставкой серии с лучшим средним баллом и т.д. Каждый дополнительный экран будет просто модулем для доступа пользователя к сайту. Каждый дополнительный экран будет просто еще одним модулем для добавления в приложение.

Сильные и слабые стороны NestJS

У Nest есть много преимуществ, когда речь идет о запуске нового приложения. Во-первых, CLI позволяет сразу же получить рабочий проект. Модульная архитектура обеспечивает масштабируемость и ремонтопригодность с течением времени, сохраняя при этом контроль над сложностью. Nest позволяет использовать любую внешнюю зависимость и не закрыт для новых применений. Сообщество очень реактивно, и многие примеры использования задокументированы.

С другой стороны, фреймворк очень богат и сложен, и легко заблудиться в документации, когда вы застряли на очень конкретном моменте. Более того, нередко приходится искать в Google, как сделать то или иное действие (например, внедрить сервис в гвардию), а не опираться на документацию. Более того, в этой документации иногда отсутствуют рекомендации по надлежащей практике, гарантирующей ремонтопригодность проекта.

Чтобы идти дальше

Nest по-прежнему предлагает множество расширений, которые позволяют обогатить ваш проект и которые я не представил здесь, но с которыми, возможно, будет интересно познакомиться. Например, есть рекомендации по настройке CQRS или проверок здоровья, или инструмента для создания документации Compodoc.

Заключение

Nest — это фреймворк, на котором я лично работаю ежедневно и который используется в производстве на известном сайте электронной коммерции. Он значительно облегчает мою работу как разработчика, поскольку предоставляет готовые к использованию ответы на вопросы, которые в какой-то момент возникают в каждом проекте: масштабируемость и поддерживаемость с течением времени, безопасность, аутентификация и т.д. Фреймворк очень богат, и то, что он не делает, может управляться внешними инструментами, поскольку он не закрыт для расширения за счет других зависимостей.

Он делает то, что мы в основном ожидаем от фреймворка, то есть освобождает нас от сложности архитектуры проекта и позволяет нам сосредоточиться на бизнес-сложности нашего приложения, той, которая приносит ценность нашим пользователям.

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *