Русский
Русский
English
Статистика
Реклама

Nestjs

Пишем full stack монолит с помощью Angular Universal NestJS PostgreSQL

10.08.2020 02:14:34 | Автор: admin
Привет, Хабр!

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


Эта статья будет полезна, если вы:


  • Начинающий fullstack-разработчик;
  • Стартапер, который пишет MVP чтобы проверить гипотезу.

Почему выбрал такой стек:


  • Angular: имею много опыта в нем, люблю строгую архитектуру и Typescript из коробки, выходец из .NET
  • NestJS: тот-же язык, та-же архитектура, быстрое написание REST API, возможность в дальнейшем пересесть на Serverless (дешевле виртуалки)
  • PostgreSQL: Собираюсь хоститься в Яндекс.Облаке, на минималках дешевле на 30% чем MongoDB

Прайс яндекса


Прежде чем написать статью, поискал на хабре статьи про подобный кейс, нашел следующее:



Из этого ничего не описывает "скопировал и вставил" или дает ссылки на то что еще нужно дорабатывать.


Оглавление:


1. Создаем Angular приложение и добавляем библиотеку компонентов ng-zorro
2. Устанавливаем NestJS и решаем проблемы с SSR
3. Делаем API на NestJS и подключаем к фронту
4. Подключаем базу данных PostgreSQL



1. Создаем Angular приложение


Установим Angular-CLI чтобы создавать SPA-сайты на Ангуляре:


npm install -g @angular/cli

Создадим Angular приложение с помощью следующей команды:


ng new angular-habr-nestjs

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


cd angular-habr-nestjsng serve --open

Статическое SPA-приложение на Angular


Приложение создалось. Подключаем библиотеку NG-Zorro:


ng add ng-zorro-antd

Далее выбираем следующие конфигурации библиотеки:


? Enable icon dynamic loading [ Detail: https://ng.ant.design/components/icon/en ] Yes? Set up custom theme file [ Detail: https://ng.ant.design/docs/customize-theme/en ] No? Choose your locale code: ru_RU? Choose template to create project: sidemenu

Эта конфигурация заменит содержимое app.component на дизайн с менюшкой слева, футером и хедером и подключит локализацию на русском языке:


Подключили NG-Zorro


В данной статье мы отобразим список данных для наглядности, поэтому добавим простенькую табличку в компоненте src/app/pages/welcome, который сгенерил NG-Zorro:
Пример взят отсюда:
https://ng.ant.design/components/table/en


// welcome.component.html<nz-table #basicTable [nzData]="items$ | async"> <thead> <tr>  <th>Name</th>  <th>Age</th>  <th>Address</th> </tr> </thead> <tbody> <tr *ngFor="let data of basicTable.data">  <td>{{ data.name }}</td>  <td>{{ data.age }}</td>  <td>{{ data.address }}</td> </tr> </tbody></nz-table>

// welcome.module.tsimport { NgModule } from '@angular/core';import { WelcomeRoutingModule } from './welcome-routing.module';import { WelcomeComponent } from './welcome.component';import { NzTableModule } from 'ng-zorro-antd';import { CommonModule } from '@angular/common';@NgModule({ imports: [  WelcomeRoutingModule,  NzTableModule, // Добавили для таблицы  CommonModule // Добавили для пайпа async ], declarations: [WelcomeComponent], exports: [WelcomeComponent]})export class WelcomeModule {}

// welcome.component.tsimport { Component, OnInit } from '@angular/core';import { Observable, of } from 'rxjs';import { HttpClient } from '@angular/common/http';import { share } from 'rxjs/operators';@Component({ selector: 'app-welcome', templateUrl: './welcome.component.html', styleUrls: ['./welcome.component.scss']})export class WelcomeComponent implements OnInit { items$: Observable<Item[]> = of([  {name: 'Вася', age: 24, address: 'Москва'},  {name: 'Петя', age: 23, address: 'Лондон'},  {name: 'Миша', age: 21, address: 'Париж'},  {name: 'Вова', age: 23, address: 'Сидней'} ]); constructor(private http: HttpClient) { } ngOnInit() { } // Сразу напишем метод к бэку, понадобится позже getItems(): Observable<Item[]> {  return this.http.get<Item[]>('/api/items').pipe(share()); }}interface Item { name: string; age: number; address: string;}

Получилось следующее:


Табличка NG-Zorro



2. Устанавливаем NestJS


Далее установим NestJS таким образом, чтобы он предоставил Angular Universal (Server Side Rendering) из коробки и напишем пару ендпоинтов.


ng add @nestjs/ng-universal

После установки, запускаем наш SSR с помощью команды:


npm run serve

И вот уже первый косяк :) У нас появляется следующая ошибка:


TypeError: Cannot read property 'indexOf' of undefined  at D:\Projects\angular-habr-nestjs\node_modules\@nestjs\ng-universal\dist\utils\setup-universal.utils.js:35:43  at D:\Projects\angular-habr-nestjs\dist\server\main.js:107572:13  at View.engine (D:\Projects\angular-habr-nestjs\node_modules\@nestjs\ng-universal\dist\utils\setup-universal.utils.js:30:11)  at View.render (D:\Projects\angular-habr-nestjs\node_modules\express\lib\view.js:135:8)  at tryRender (D:\Projects\angular-habr-nestjs\node_modules\express\lib\application.js:640:10)  at Function.render (D:\Projects\angular-habr-nestjs\node_modules\express\lib\application.js:592:3)  at ServerResponse.render (D:\Projects\angular-habr-nestjs\node_modules\express\lib\response.js:1012:7)  at D:\Projects\angular-habr-nestjs\node_modules\@nestjs\ng-universal\dist\angular-universal.module.js:60:66  at Layer.handle [as handle_request] (D:\Projects\angular-habr-nestjs\node_modules\express\lib\router\layer.js:95:5)  at next (D:\Projects\angular-habr-nestjs\node_modules\express\lib\router\route.js:137:13)

Чтобы решить косяк, зайдем в файл server/app.module.ts и поменяем значение liveReload на false:


import { Module } from '@nestjs/common';import { AngularUniversalModule } from '@nestjs/ng-universal';import { join } from 'path';@Module({ imports: [  AngularUniversalModule.forRoot({   viewsPath: join(process.cwd(), 'dist/browser'),   bundle: require('../server/main'),   liveReload: false  }) ]})export class ApplicationModule {}

Также подтюним конфиг тайпскрипта, так-как эта конфигурация не взлетает с использованием Ivy рендера:


// tsconfig.server.json{ "extends": "./tsconfig.app.json", "compilerOptions": {  "outDir": "./out-tsc/server",  "target": "es2016",  "types": [   "node"  ] }, "files": [  "src/main.server.ts" ], "angularCompilerOptions": {  "enableIvy": false, // Добавили флажок  "entryModule": "./src/app/app.server.module#AppServerModule" }}

После пересоберем приложение командой ng run serve чтобы SSR заработал.


Angular SSR + NestJS


Ура! SSR подрубился, но как видимо в devtools он приходит с кривыми стилями.


Добавим extractCss: true, который позволит выносить стили не в styles.js, а в styles.css:


// angular.json..."architect": {    "build": {     "builder": "@angular-devkit/build-angular:browser",     "options": {      "outputPath": "dist/browser",      "index": "src/index.html",      "main": "src/main.ts",      "polyfills": "src/polyfills.ts",      "tsConfig": "tsconfig.app.json",      "aot": true,      "assets": [       "src/favicon.ico",       "src/assets",       {        "glob": "**/*",        "input": "./node_modules/@ant-design/icons-angular/src/inline-svg/",        "output": "/assets/"       }      ],      "extractCss": true, // Добавили флажок      "styles": [       "./node_modules/ng-zorro-antd/ng-zorro-antd.min.css",       "src/styles.scss"      ],      "scripts": []     },...

Также подключим стили библиотеки в app.component.scss:


// app.component.scss@import "~ng-zorro-antd/ng-zorro-antd.min.css"; // Подключили стили:host { display: flex; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;}.app-layout { height: 100vh;}...

Теперь стили подключены, SSR отдает страничку со стилями, но мы видим что сначала у нас грузится SSR, потом страница моргает и отрисовывается CSR (Client Side Rendering). Это решается следующим способом:


import { NgModule } from '@angular/core';import { Routes, RouterModule } from '@angular/router';const routes: Routes = [ { path: '', pathMatch: 'full', redirectTo: '/welcome' }, { path: 'welcome', loadChildren: () => import('./pages/welcome/welcome.module').then(m => m.WelcomeModule) }];@NgModule({ imports: [RouterModule.forRoot(routes, {initialNavigation: 'enabled', scrollPositionRestoration: 'enabled'})], // Добавили initialNavigation, scrollPositionRestoration exports: [RouterModule]})export class AppRoutingModule { }

  • initialNavigation: 'enabled' дает инструкцию роутингу не отрисовывать страницу, если уже загружена через SSR
  • scrollPositionRestoration: 'enabled' скролит страницу наверх при каждом роутинге.


3. Сделаем пару ендпоинтов на NestJS


Перейдем в папку server и создадим первый контроллер items:


cd servernest g module itemsnest g controller items --no-spec

// items.module.tsimport { Module } from '@nestjs/common';import { ItemsController } from './items.controller';@Module({ controllers: [ItemsController]})export class ItemsModule {}

// items.controller.tsimport { Controller } from '@nestjs/common';@Controller('items')export class ItemsController {}

Контроллер и модуль создались. Создадим метод на получение списка items и на добавление объекта в список:


// server/src/items/items.controller.tsimport { Body, Controller, Get, Post } from '@nestjs/common';class Item { name: string; age: number; address: string;}@Controller('items')export class ItemsController { // для простоты данные взял из Angular private items: Item[] = [  {name: 'Вася', age: 24, address: 'Москва'},  {name: 'Петя', age: 23, address: 'Лондон'},  {name: 'Миша', age: 21, address: 'Париж'},  {name: 'Вова', age: 23, address: 'Сидней'} ]; @Get() getAll(): Item[] {  return this.items; } @Post() create(@Body() newItem: Item): void {  this.items.push(newItem); }}

Попробуем вызвать GET в Postman:


GET запросы апишки NestJS


Отлично, работает! Обратите внимание, вызываем метод GET items с префиксом api, который ставится автоматически в файле server/main.ts при установке NestJS:


// server/main.tsimport { NestFactory } from '@nestjs/core';import { ApplicationModule } from './app.module';async function bootstrap() { const app = await NestFactory.create(ApplicationModule); app.setGlobalPrefix('api'); // Это префикс await app.listen(4200);}bootstrap();

Теперь прикрутим бэк к фронту. Возвращаемся к файлу welcome.component.ts и делаем запрос списка к бэку:


// welcome.component.tsimport { Component, OnInit } from '@angular/core';import { Observable, of } from 'rxjs';import { HttpClient } from '@angular/common/http';import { share } from 'rxjs/operators';@Component({ selector: 'app-welcome', templateUrl: './welcome.component.html', styleUrls: ['./welcome.component.scss']})export class WelcomeComponent implements OnInit { items$: Observable<Item[]> = this.getItems(); // прикрутили вызов бэка constructor(private http: HttpClient) { } ngOnInit() { } getItems(): Observable<Item[]> {  return this.http.get<Item[]>('/api/items').pipe(share()); }}interface Item { name: string; age: number; address: string;}

Можно увидеть что апиха на фронте дергается, но также дергается и в SSR, причем с ошибкой:


Дергание апихи в SSR


Ошибка при запросе в SSR решается следующим способом:


// welcome.component.tsimport { Component, OnInit } from '@angular/core';import { Observable, of } from 'rxjs';import { HttpClient } from '@angular/common/http';import { share } from 'rxjs/operators';@Component({ selector: 'app-welcome', templateUrl: './welcome.component.html', styleUrls: ['./welcome.component.scss']})export class WelcomeComponent implements OnInit { items$: Observable<Item[]> = this.getItems(); // прикрутили вызов бэка constructor(private http: HttpClient) { } ngOnInit() { } getItems(): Observable<Item[]> {  return this.http.get<Item[]>('http://localhost:4200/api/items').pipe(share()); // Прописали полный путь к апихе чтобы SSR не ругался }}interface Item { name: string; age: number; address: string;}

Чтобы исключить двойной запрос к апихе (один на SSR, другой на фронте), нужно проделать следующее:


  • Установим библиотеку @nguniversal/common:

npm i @nguniversal/common

  • В файле app/app.module.ts добавим модуль для запросов из SSR:

// app.module.tsimport { BrowserModule } from '@angular/platform-browser';import { NgModule } from '@angular/core';import { AppRoutingModule } from './app-routing.module';import { AppComponent } from './app.component';import { IconsProviderModule } from './icons-provider.module';import { NzLayoutModule } from 'ng-zorro-antd/layout';import { NzMenuModule } from 'ng-zorro-antd/menu';import { FormsModule } from '@angular/forms';import { HttpClientModule } from '@angular/common/http';import { BrowserAnimationsModule } from '@angular/platform-browser/animations';import { NZ_I18N } from 'ng-zorro-antd/i18n';import { ru_RU } from 'ng-zorro-antd/i18n';import { registerLocaleData } from '@angular/common';import ru from '@angular/common/locales/ru';import {TransferHttpCacheModule} from '@nguniversal/common';registerLocaleData(ru);@NgModule({ declarations: [  AppComponent ], imports: [  BrowserModule.withServerTransition({ appId: 'serverApp' }),  TransferHttpCacheModule, // Добавили  AppRoutingModule,  IconsProviderModule,  NzLayoutModule,  NzMenuModule,  FormsModule,  HttpClientModule,  BrowserAnimationsModule ], providers: [{ provide: NZ_I18N, useValue: ru_RU }], bootstrap: [AppComponent]})export class AppModule { }

Схожую операцию проделаем с app.server.module.ts:


// app.server.module.tsimport { NgModule } from '@angular/core';import { ServerModule, ServerTransferStateModule } from '@angular/platform-server';import { AppModule } from './app.module';import { AppComponent } from './app.component';@NgModule({ imports: [  AppModule,  ServerModule,  ServerTransferStateModule, // Добавили ], bootstrap: [AppComponent],})export class AppServerModule {}

Хорошо. Теперь получаем данные из апи в SSR, отрисовываем на форме, отдаем на фронт и тот не делает повторных запросов.


Запроса нет, данные есть!



4. Подключим базу PostgreSQL


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


npm i pg typeorm @nestjs/typeorm

Внимание: у вас уже должна быть установлена PostgreSQL с базой внутри.


Описываем конфиг подключения к базе в server/app.module.ts:


// server/app.module.tsimport { Module } from '@nestjs/common';import { AngularUniversalModule } from '@nestjs/ng-universal';import { join } from 'path';import { ItemsController } from './src/items/items.controller';import { TypeOrmModule } from '@nestjs/typeorm';@Module({ imports: [  AngularUniversalModule.forRoot({   viewsPath: join(process.cwd(), 'dist/browser'),   bundle: require('../server/main'),   liveReload: false  }),  TypeOrmModule.forRoot({ // Конфиг подключения к базе   type: 'postgres',   host: 'localhost',   port: 5432,   username: 'postgres',   password: 'admin',   database: 'postgres',   entities: ['dist/**/*.entity{.ts,.js}'],   synchronize: true  }) ], controllers: [ItemsController]})export class ApplicationModule {}

Немного про поля конфига:


  • type: указываем название типа базы данных, к которой подключаемся
  • host и port: место где база хостится
  • username и password: аккаунт для этой базы
  • database: название базы
  • entities: путь, откуда будем брать сущности для схемы нашей базы

По последнему пункту, нужно создать сущность Item для мапинга полей в базу:


// server/src/items/item.entity.tsimport { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm/index';@Entity()export class ItemEntity { @PrimaryGeneratedColumn() id: number; @CreateDateColumn() createDate: string; @Column() name: string; @Column() age: number; @Column() address: string;}

Далее свяжем эту сущность с нашей базой.


// items.module.tsimport { Module } from '@nestjs/common';import { TypeOrmModule } from '@nestjs/typeorm';import { ItemEntity } from './item.entity';import { ItemsController } from './items.controller';@Module({ imports: [  TypeOrmModule.forFeature([ItemEntity]) // Подключаем фича-модуль и указываем сущности базы ], controllers: [ItemsController]})export class ItemsModule {}

Теперь укажем в контроллере, что хотим работать с базой, а не кешем:


// items.controller.tsimport { Body, Controller, Get, Post } from '@nestjs/common';import { ItemEntity } from './item.entity';import { InjectRepository } from '@nestjs/typeorm';import { Repository } from 'typeorm/index';interface Item { name: string; age: number; address: string;}@Controller('items')export class ItemsController { constructor(@InjectRepository(ItemEntity)       private readonly itemsRepository: Repository<ItemEntity>) { // Подключили репозиторий } @Get() getAll(): Promise<Item[]> {  return this.itemsRepository.find(); } @Post() create(@Body() newItem: Item): Promise<Item> {  const item = this.itemsRepository.create(newItem);  return this.itemsRepository.save(item); }}

Проверим работу апихи в Postman:


POST к апихе с базой


Работает. Потыкали несколько раз постман, посмотрим что записалось в базе с помощью DBeaver:


Записи в базе


Отлично! В базе есть, посмотрим как выглядит на фронте:


Рабочее fullstack приложение


Готово! Мы сделали fullstack приложение, с которым можно работать дальше.


P.S. Сразу поясню следующее:


  • Вместо Ng-Zorro вы можете использовать любую другую библиотеку, например Angular Material. Мне она лично не зашла из-за сложности разработки;
  • Я знаю, что нужно на бэке использовать сервисы, а не напрямую дергать базу в контроллерах. Эта статья о том, как решив проблемы "влоб" получить MVP с которым можно работать, а не про архитектуру и паттерны;
  • Вместо вписывания на фронте http://localhost:4200/api возможно лучше написать интерсептор и проверять откуда мы стучимся

Полезные ссылки:


Подробнее..

NEST.JS. Работа с ошибками. Мысли и рецепты

14.03.2021 10:05:42 | Автор: admin

Холивар...

Одни умные люди говорят: "эксепшены - это зло, используйте монады!", другие кричат: "коды ошибок ещё никто не отменял!", а третьи включают механизм исключений в языки программирования.. Однако, у большинства статей, который попадались мне на глаза, есть (при всём уважении к авторам!) два проблемных момента:

  • Некоторая... академичность. Разобрано много и интересно, но заканчивается всё стандартным: "ваш выбор зависит от вашей ситуации".

  • Абсолютно отсутствуют упоминания о бюджете. Никто же не будет спорить, что теоретически мерседес лучше, чем восьмёрка по всем показателям кроме.. цены.

Задача этого поста - поделиться выработанным практическим рецептом. В конкретном фреймворке и с конкретными границами применимости. Без претензий на уникальность, универсальность и, тем более, академическую "правильность".


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

Стартовые условия.

Выделим основные: язык, фреймворк, тип приложения. Раскроем кратко каждый пункт:

ЯЗК

Платформа и ЯП, очень сильно влияют на выбор подходов по работе с ошибками.

К примеру, в go не стоит вопрос, использовать ли исключения - там их нет. В функциональных языках, в частности в F#, было бы очень странно не использовать монады или discriminated union'ы (возврат одного из нескольких возможных типов значений), т. к. это там это реализовано очень удобным и естественным образом. В C#, монады тоже можно сделать, но получается намного больше букв. А это не всем нравится, мне например - не очень. Правда, последнее время всё чаще упоминается библиотека https://www.nuget.org/packages/OneOf/, которая фактически добавляет в язык discriminated union'ы.

А к чему нас подталкивает javascript/typescript?... К анархии! Можно много за что ругать JS и вполне по делу, но точно не за отсутствие гибкости.

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

ФРЕЙМВОРК

С nestjs уже интереснее. Выброс исключений из прикладного кода предлагается нам в документации как основной механизм возврата неуспешных ответов. То есть, если взять обычное http приложение, то чтобы клиенту вернулся статус 404 нам надо бросить NotFoundException..

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

@Controller()class SomeController {  @Post()  do (): Either<SomeResult, SomeError> {    ...  }}

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

Важно также, что Фреймворк делает практически всё для того, чтобы нам не приходилось заботиться об устойчивости процесса приложения . Nest сам выстраивает для нас "конвейер" обработки запроса и оборачивает всё это в удобный глобальный "try/catch", который ловит всё.

Правда иногда случаются казусы

Например в одной из старых версий nest'а мы столкнулись с тем, что ошибка, вылетевшая из функции переданной в декоратор @Transform() (из пакета class-transformer) почему-то клала приложение насмерть. В версии 7.5.5 это не воспроизводится, но от подобных вещей, конечно никто не застрахован.

ТИП ПРИЛОЖЕНИЯ

Самое важное. Мы не пишем софт для спутников. Там вряд ли можно было бы себе позволить что-то в стиле "сервис временно недоступен, попробуйте позже". Для нас же - это вполне ожидаемая ситуация. Нежелательная, конечно, но и не фатальная.

Мы пишем веб-сервисы. Есть http-сервисы, есть rpc (на redis и RabbitMQ, смотрим в сторону gRPC), гибридные тоже есть. В любом случае, мы стараемся внутреннюю логику приложения абстрагировать от транспорта, чтобы в любой момент можно было добавить новый.

Мы фокусируемся на том, что у нас есть запрос, есть его обработчик и есть ответ (который иногда void). И мы допускаем, что обработка запроса может по каким-то причинам оказаться неудачной. В этом случае, либо запрос будет повторён (успешно), либо будет зафиксирован и затем исправлен баг.

При таком подходе, важно, чтобы ошибки не могли привести данные в неконсистентное состояние. Помогают нам в этом две вещи:

  • Транзакционность. То есть, либо получилось всё, либо не получилось ничего.

  • Идемпотентность. Повторное выполнение одной и той же команды не ломает и не меняет состояние системы.

Транзакции (особенно распределённые) и идемпотентность выходят за рамки данной статьи. Но во многом, эти вещи являются основой надёжности.

Ближе к делу.

Наши принципы обработки ошибок базируются на следующих соглашениях:

КОНФИГУРАЦИЯ ПРИЛОЖЕНИЯ.

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

@Injectable()export class SomeModuleConfig {  public readonly someUrl: URL;public readonly someFile: string;public readonly someArrayOfNumbers: number[];  constructor (source: ConfigurationSource) {    // Бросит ConfigurationException если не удастся распарсить Url. Можно    // также проверять его доступность, например, при помощи пакета is-reachable    this.someUrl = source.getUrl('env.SOME_URL');// Бросит ConfigurationException если файл не существует или на него нет прав.this.someFile = source.getFile('env.SOME_FILE_PATH');// Бросит ConfigurationException если там не перечисленные через запятую числаthis.someArrayOfNumbers = source.getNumbers('env.NUMBERS')  }}

Значения из конфигурации считываются только в конструкторах подобных классов и валидируются максимально строго.

Подход к валидации

Мы написали свои валидаторы. Их преимущетсво в том, что мы не только валидируем данные, но в ряде случаев, можем сделать дополнительные проверки (доступность файла или удалённого ресурса, как в примере выше, например).

Однако, вполне можно использовать joi или json-схемы (может ещё есть варианты) - кому что больше нравится.

Неизменным должно быть одно - всё валидируется на старте.

УРОВНИ АБСТРАКЦИИ.

Мы максимально чётко разделяем бизнес-код и инфраструктурный код. И всё инфраструктурное выносим в библиотеки. Более менее очевидно, но всё же приведу пример:

// Задача: скачать файл по ссылке.const response = await axios.get(url, { responseType: 'stream' });const { contentType, filename } = this.parseHeaders(response);const file = createWriteStream(path);response.data.pipe(file);file.on('error', reject);file.on('finish', () => resolve({ contentType, filename, path }));

Такому коду не место не только в бизнес-логике, но вообще в приложении. В нём нет ничего уникального, привязывающего его к какому-то контексту. Ему место в библиотеке, скажем в классе NetworkFile. Вызывающий же код может выглядеть примерно так:

const file: NetworkFile = await NetworkFile.download('https://download.me/please', {  saveAs: 'path/to/directory'});

Фактически, мы заворачиваем в подобные переиспользуемые "смысловые" абстракции почти все нативные нодовские вызовы и вызовы сторонних библиотек. Стратегия обработки ошибок в этих обёртках: "поймать -> завернуть -> бросить". Пример простейшей реализации такого класса:

export class NetworkFile {private constructor (  public readonly filename: string,    public readonly path: string,    public readonly contentType: string,    public readonly url: string  ) {}    // В примере выше у нас метод download принимает вторым аргументов объект опций  // Таким образом мы можем кастомизировать наш класс: он может записывать файл на диск  // или не записывать, например.  // Но тут для примера - самая простая реализация.  public static async download (url: string, path: string): Promise<NetworkFile> {    return new Promise<NetworkFile>(async (resolve, reject) => {      try {      const response = await axios.get(url, { responseType: 'stream' });        const { contentType, filename } = this.parseHeaders(response);        const file = createWriteStream(path);        response.data.pipe(file);// Здесь мы отловим и завернём все ошибки связанную с записью данных в файл.        file.on('error', reject(new DownloadException(url, error));        file.on('finish', () => {        resolve(new NetworkFile(filename, path, contentType, url));        })    } catch (error) {        // А здесь, отловим и завернём ошибки связанные с открытием потока или скачиванием        // файла по сети.        reject(new DownloadException(url, error))      }    });  }private static parseHeaders (    response: AxiosResponse  ): { contentType: string, filename: string } {    const contentType = response.headers['content-type'];    const contentDisposition = response.headers['content-disposition'];    const filename = contentDisposition// parse - сторонний пакет content-disposition      ? parse(contentDisposition)?.parameters?.filename as string      : null;    if (typeof filename !== 'string') {      // Создавать здесь специальный тип ошибки нет смысла, т. к. на уровень выше      // она завернётся в DownloadException.      throw new Error(`Couldn't parse filename from header: ${contentDisposition}`);    }    return { contentType, filename };  }}
Promise constructor anti-pattern

Считается не круто использовать new Promise() вообще, и async-коллбэк внутри в частности. Вот и вот - релевантные посты на stackoverflow по этому поводу.

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

Уследить за потоком управления в таком маленьком классе (на самом деле, его боевая версия лишь немногим больше) - не проблема. А в итоге, вызывающий код работает только с одним типом исключений: DownloadException, внутрь которого завёрнута причина, по которой файл скачать не удалось. И причина носит исключительно информативный характер и не влияет на дальнейшую работу приложения, т. к.:

В БИЗНЕС-КОДЕ НИГДЕ НЕ НАДО ПИСАТЬ TRY / CATCH.

Серьёзно, о таких вещах, как закрытие дескрипторов и коннектов не должна заботиться бизнес-логика! Если вам прям очень надо написать try / catch в коде приложения, подумайте.. либо вы пишете то, что должно быть вынесено в библиотеку. Либо.. вам придётся объяснить товарищам по команде, почему именно здесь необходимо нарушить правило (хоть и редко, но такое всё же бывает).

Так почему не надо в сервисе ничего ловить? Для начала:

ЧТО М СЧИТАЕМ ИСКЛЮЧИТЕЛЬНОЙ СИТУАЦИЕЙ?

Откровенно говоря, в этом месте мы сломали немало копий. В конце концов, копья кончились, и мы пришли к концепции холивар-agnostic. Зачем нам отвечать на этот провокационный вопрос? В нём очень легко утонуть, причём мы будем не первыми утопленниками )

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

Не смогли считать файл - до свиданья. Не смогли распарсить ответ от стороннего API - до свидания. В базе duplicate key - до свидания. Не можем найти указанную сущность - до свидания. Максимально просто. И механизм throw, даёт нам удобную возможность осуществить этот быстрый выход без написания дополнительного кода.

В основном исключения ругают за две вещи:

  • Плохой перформанс. Нас это не очень волнует, т. к. мы не highload. Если он нас всё же в какой-то момент настигнет, мы, пересмотрим подходы там, где это будет реально критично. Сделаем бенчмарки... Хотя, готов поспорить, оверхед на исключения будет не главной нашей проблемой.

  • Запутывание потока управления программы. Это как оператор goto который уже давно не применяется в высокоуровневых программах. Вот только в нашем случае, goto бывает только в одно место - к выходу. А ранний return из функции - отнють не считается анти-паттерном. Напротив - это очень широко используемый способ уменьшить вложенность кода.

ВИД ОШИБОК

Говорят, что надо обрабатывать исключения там, где мы знаем, что с ними делать. В нашем слое бизнес-логики ответ будет всегда один и тот же: откатить транзакцию, если она есть (автоматически), залогировать всё что можно залогировать и вернуть клиенту ошибку. Вопрос в том, какую?

Мы используем 5 типов рантайм-исключений (про конфигурационные уже говорил выше):

abstract class AuthenticationException extends Exception {  public readonly type = 'authentication';}abstract class NotAllowedException extends Exception {public readonly type = 'authorization';}abstract class NotFoundException extends Exception {  public readonly type = 'not_found';}abstract class ClientException extends Exception {  public readonly type = 'client';}abstract class ServerException extends Exception {  public readonly type = 'server';}

Эти классы семантически соответствуют HTTP-кодам 401, 403, 404, 400 и 500. Конечно, это не вся палитра из спецификации, но нам хватает. Благодаря соглашению, что всё, что вылетает из любого места приложения должно быть унаследовано от указанных типов, их легко автоматически замапить на HTTP ответы.

А если не HTTP? Тут надо смотреть конкретный транспорт. К примеру один из используемых у нас вариантов подразумевает получения сообщения из очереди RabbitMQ и отправку ответного сообщения в конце. Для сериализации ответа мы используем.. что-то типа either:

interface Result<T> {data?: T;  error?: Exception}

Таким образом, есть всё необходимое, чтобы клиентский код мог интерпретировать ошибку принятым у нас способом.

Базовый класс Exception выглядит примерно так:

export abstract class Exception {  abstract type: string;  constructor (    public readonly code: number,    public readonly message: string,    public readonly inner?: any  ) {}toString (): string {    // Здесь логика сериализации, работа со стек-трейсами, вложенными ошибками и проч...  }}
Коды ошибок

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

  • Бывает такое, что клиентское приложение должно предпринять различные действия в зависимости от пришедшей от сервера ошибки. С кодами мы можем решить это не добавляя новых http статусов и без, прости Господи, парсинга сообщений.

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

Насколько это всё нужно и полезно - жизнь покажет

Поле inner - это внутренняя ошибка, которая может быть "завёрнута" в исключение (см. пример с NetworkFile).

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

ПРИМЕР ИСПОЛЬЗОВАНИЯ

Опустим AuthenticationException - он используется у нас только в модуле контроля доступа. Разберём более типовые примеры и начнём ошибок валидации:

import { ValidatorError } from 'class-validator';// ....export interface RequestValidationError {  // Массив - потому что ошибка может относиться к нескольким полям.  properties: string[];  errors: { [key: string]: string };nested?: RequestValidationError[]}// Небольшая трансформация стандартной ошибки class-validator'а в более удобный// "наш" формат.const mapError = (error: ValidationError): RequestValidationError => ({  properties: [error.property],  errors: error.constraints,  nested: error.children.map(mapError)});// Сами цифры не имеют значения.export const VALIDATION_ERROR_CODE = 4001;export class ValidationException extends ClientException {  constructor (errors: ValidationError[]) {    const projections: ValErrorProjection[] = ;    super(      VALIDATION_ERROR_CODE,      'Validation failed!',      errors.map(mapError)    );  }}

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

app.useGlobalPipes(new ValidationPipe({  exceptionFactory: errors => new ValidationException(errors);   });)

Соответственно, на выходе наш ValidationException замапится на BadRequestException с кодом 400 - потому что он ClientException.

Другой пример, с NotFoundException:

export const EMPLOYEE_NOT_FOUND_ERROR_CODE = 50712;export class EmployeeNotFoundException extends NotFoundException {  constructor (employeeId: number) {  super(      EMPLOYEE_NOT_FOUND_ERROR_CODE,      `Employee id = ${employeeId} not found!`    );  }}

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

// Вместо, что не даст нам ни кодов, ни типа - ничего:throw new Error('...тут мы должны сформировать внятное сообщение...')// Простоthrow new EmployeeNotFoundException(id);

Сценарий использования NotAllowedException похож на предыдущий. Пользовать может иметь доступ к роуту getEmployeeById, но не иметь права запрашивать определённые категории работников. Соответственно, мы в сервисе можем проверить его доступ и выкинуть ошибку такого вида:

export const EMPLOYEE_NOT_ALLOWED_ERROR_CODE = 40565;export class EmployeeNotAllowedException extends NotAllowedException {  constructor (userId: number, employeeId: number) {  super(      EMPLOYEE_NOT_ALLOWED_ERROR_CODE,      `User id = ${userId} is not allowed to query employee id = ${employeeId}!`    );  }}

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

МАППИНГ

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

export interface IExceptionsFormatter {  // Verbose - флаг, который мы держим в конфигурации. Он используется для того,  // чтобы в девелоперской среде всегда на клиент отдавалась полная инфа о  // ошибке, а на проде - нет.  format (exception: unknown, verbose: boolean): unknown;    // При помощи этого метода можно понять, подходит ли данных форматтер  // для этого типа приложения или нет.  match (host: ArgumentsHost): boolean;}@Module({})export class ExceptionsModule {  public static forRoot (options: ExceptionsModuleOptions): DynamicModule {    return {      module: ExceptionsModule,      providers: [        ExceptionsModuleConfig,        {          provide: APP_FILTER,          useClass: GlobalExceptionsFilter        },        {          provide: 'FORMATTERS',          useValue: options.formatters        }      ]    };  }}const typesMap = new Map<string, number>().set('authentication', 401).set('authorization', 403).set('not_found', 404).set('client', 400).set('server', 500);@Catch()export class GlobalExceptionsFilter implements ExceptionFilter {  constructor (    @InjectLogger(GlobalExceptionsFilter) private readonly logger: ILogger,    @Inject('FORMATTERS') private readonly formatters: IExceptionsFormatter[],    private readonly config: ExceptionsModuleConfig  ) { }  catch (exception: Exception, argumentsHost: ArgumentsHost): Observable<any> {    this.logger.error(exception);    const formatter = this.formatters.find(x => x.match(argumentsHost));    const payload = formatter?.format(exception, this.config.verbose) || 'NO FORMATTER';    // В случае http мы ставим нужный статус-код и возвращаем ответ.if (argumentsHost.getType() === 'http') {      const request = argumentsHost.switchToHttp().getResponse();      const status = typesMap.get(exception.type) || 500;      request.status(status).send(payload);      return EMPTY;    }// В случае же RPC - бросаем дальше, транспорт разберётся.    return throwError(payload);  }}

Бывает конечно, что мы где-то напортачили и из сервиса вылетело что-то не унаследованное от Exception. На этот случай у нас есть ещё интерцептор, который все ошибки, не являющиеся экземплярами наследников Exception, заворачивает в new UnexpectedException(error) и прокидывает дальше. UnexpectedException естественно наследуется от ServerException. Для нас возникновение такой ошибки - иногда некритичный, но всё же баг, который фиксируется и исправляется.


В принципе, это всё. Для 95% наших задач этого вполне хватает. Способ может и не "канонический", но удобный и вполне рабочий - то, к чему мы и стремились.

И всё же бывают ситуации

КОГДА ВСЁ НЕ ТАК ЯСНО.

Приведу два примера:

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

В таких случаях всё-таки приходится проявлять "фантазию", например, обернуть в try/catch обработку каждой строки csv-файла. И в блоке catch писать ошибку в "отчёт". Тоже не бином Ньютона )

Второй. Я сознательно не написал выше реализацию DownloadException.

export class DOWNLOAD_ERROR_CODE = 5506;export class DownloadException extends ServerException {  constructor (url: string, inner: any) {    super(      DOWNLOAD_ERROR_CODE,      `Failed to download file from ${url}`,      inner    );  }}

Почему ServerException? Потому что, в общем случае, клиенту всё равно почему сервер не смог куда-то там достучаться. Для него это просто какая-то ошибка, в которой он не виноват.

Однако, теоретически может быть такая ситуация, что мы пытаемся скачать файл по ссылке, предоставленной клиентом. И тогда, в случае неудачи, клиент должен получить 400 или может быть 404, но не 500.

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

ЗАКЛЮЧЕНИЕ

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

P. S. К сожалению, опенсорс в нашей компании в процессе согласования, поэтому привести реальный используемый код я не могу. Когда будет такая возможность, мы выложим на github библиотеку, при помощи которой работаем с исключениями. А за одно и некоторые другие пакеты, которые могут оказаться кому-то полезными.

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

Подробнее..

Кастомные декораторы для NestJS от простого к сложному

14.07.2020 12:05:49 | Автор: admin

image


Введение


NestJS стремительно набирающий популярность фрeймворк, построенный на идеях IoC/DI, модульного дизайна и декораторов. Благодаря последним, Nest имеет лаконичный и выразительный синтаксис, что повышает удобство разработки.


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


Базовые декораторы


Возьмем простейший http-контроллер. Допустим, нам требуется, чтобы только определенные пользователи могли воспользоваться его методами. Для этого кейса в Nest есть встроенная функциональность гардов.
Guard это комбинация класса, реализующего интерфейс CanActivate и декоратора @UseGuard.


@Injectable()export class RoleGuard implements CanActivate {  canActivate(    context: ExecutionContext,  ): boolean | Promise<boolean> | Observable<boolean> {    const request = context.switchToHttp().getRequest();    return getRole(request) === 'superuser'  }}@Controller()export class MyController {  @Post('secure-path')  @UseGuards(RoleGuard)  async method() {    return  }}

Захардкоженный superuser не самое лучшее решение, куда чаще нужны более универсальные декораторы.
Nest в этом случае предлагает использовать
декоратор @SetMetadata. Как понятно из названия, он позволяет ассоциировать метаданные с декорируемыми объектами классами или методами.
Для доступа к этим данным используется экземпляр класса Reflector, но можно и напрямую через reflect-metadata.


@Injectable()export class RoleGuard implements CanActivate {  constructor(private reflector: Reflector) {}  canActivate(    context: ExecutionContext,  ): boolean | Promise<boolean> | Observable<boolean> {    const role = this.reflector.get<string>('role', context.getHandler());    const request = context.switchToHttp().getRequest();    return getRole(request) === role  }}@Controller()export class MyController {  @Post('secure-path')  @SetMetadata('role', 'superuser')  @UseGuards(RoleGuard)  async test() {    return  }}

Композитные декораторы


Декораторы зачастую применяются в связках.
Обычно это обусловлено тесной связностью эффектов в каком-то бизнес-сценарии. В этом случае имеет смысл объединить несколько декораторов в один.
Для композиции можно воспользоваться утилитной функцией applyDecorators.


const Role = (role) => applyDecorators(UseGuards(RoleGuard), SetMetadata('role', role))

или написать агрегатор самим:


const Role = role => (proto, propName, descriptor) => {  UseGuards(RoleGuard)(proto, propName, descriptor)  SetMetadata('role', role)(proto, propName, descriptor)}@Controller()export class MyController {  @Post('secure-path')  @Role('superuser')  async test() {    return  }}

Полиморфные декораторы


Легко столкнуться с ситуацией, когда оказывается нужным задекорировать все методы класса.


@Controller()@UseGuards(RoleGuard)export class MyController {  @Post('secure-path')  @Role('superuser')  async test1() {    return  }  @Post('almost-securest-path')  @Role('superuser')  async test2() {    return  }  @Post('securest-path')  @Role('superuser')  async test3() {    return  }}

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


type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;type MethodDecorator = <T>(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void;type ParameterDecorator = (target: Object, propertyKey: string | symbol, parameterIndex: number) => void;const Role = (role: string): MethodDecorator | ClassDecorator => (...args) => {  if (typeof args[0] === 'function') {    // Получение конструктора    const ctor = args[0]    // Получение прототипа    const proto = ctor.prototype    // Получение методов    const methods = Object      .getOwnPropertyNames(proto)      .filter(prop => prop !== 'constructor')    // Обход и декорирование методов    methods.forEach((propName) => {      RoleMethodDecorator(        proto,        propName,        Object.getOwnPropertyDescriptor(proto, propName),        role,      )    })  } else {    const [proto, propName, descriptor] = args    RoleMethodDecorator(proto, propName, descriptor, role)  }}

Есть вспомогательные библиотеки, которые берут на себя часть этой рутины: lukehorvat/decorator-utils, qiwi/decorator-utils.
Это несколько улучшает читаемость.


import { constructDecorator, CLASS, METHOD } from '@qiwi/decorator-utils'const Role = constructDecorator(  ({ targetType, descriptor, proto, propName, args: [role] }) => {    if (targetType === METHOD) {      RoleMethodDecorator(proto, propName, descriptor, role)    }    if (targetType === CLASS) {      const methods = Object.getOwnPropertyNames(proto)      methods.forEach((propName) => {        RoleMethodDecorator(          proto,          propName,          Object.getOwnPropertyDescriptor(proto, propName),          role,        )      })    }  },)

Совмещение в одном декораторе логики для разных сценариев дает очень весомый плюс для разработки:
вместо @DecForClass, @DecForMethood, @DecForParam получается всего один многофункциональный @Dec.


Так, например, если роль пользователя вдруг потребуется в бизнес-слое контроллера, можно просто расширить логику @Role.
Добавляем в ранее написанную функцию обработку сигнатуры декоратора параметра.
Так как подменить значение параметров вызова напрямую нельзя, createParamDecorator делегирует это вышестоящему декоратору посредством метаданных.
И далее именно декоратор метода / класса будет резолвить аргументы вызова (через очень длинную цепочку от ParamsTokenFactory до RouterExecutionContext).


// Сигнатура параметра  if (typeof args[2] === 'number') {    const [proto, propName, paramIndex] = args    createParamDecorator((_data: unknown, ctx: ExecutionContext) => {      return getRole(ctx.switchToHttp().getRequest())    })()(proto, propName, paramIndex)  }

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


class SomeController {   @RequestSize(1000)   @RequestSize(5000)   @Post('foo')   method(@Body() body) {   }}

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


class SomeController {   @Port(9092)   @Port(8080)   @Post('foo')   method(@Body() body) {   }}

Схожая ситуация возникает с ролевой моделью.


class SomeController {  @Post('securest-path')  @Role('superuser')  @Role('usert')  @Role('otheruser')  method(@Role() role) {  }}

Обобщая рассуждения, реализация декоратора для последнего примера с использованием reflect-metadata и полиморфного контракта
может иметь вид:


import { ExecutionContext, createParamDecorator } from '@nestjs/common'import { constructDecorator, METHOD, PARAM } from '@qiwi/decorator-utils'@Injectable()export class RoleGuard implements CanActivate {  canActivate(context: ExecutionContext): boolean | Promise<boolean> {    const roleMetadata = Reflect.getMetadata(      'roleMetadata',      context.getClass().prototype,    )    const request = context.switchToHttp().getRequest()    const role = getRole(request)    return roleMetadata.find(({ value }) => value === role)  }}const RoleMethodDecorator = (proto, propName, decsriptor, role) => {  UseGuards(RoleGuard)(proto, propName, decsriptor)  const meta = Reflect.getMetadata('roleMetadata', proto) || []  Reflect.defineMetadata(    'roleMetadata',    [      ...meta, {        repeatable: true,        value: role,      },    ],    proto,  )}export const Role = constructDecorator(  ({ targetType, descriptor, proto, propName, paramIndex, args: [role] }) => {    if (targetType === METHOD) {      RoleMethodDecorator(proto, propName, descriptor, role)    }    if (targetType === PARAM) {      createParamDecorator((_data: unknown, ctx: ExecutionContext) =>        getRole(ctx.switchToHttp().getRequest()),      )()(proto, propName, paramIndex)    }  },)

Макродекораторы


Nest спроектирован таким образом, что его собственные декораторы удобно расширять и переиспользовать. На первый взгляд довольно сложные кейсы, к примеру, связанные с добавлением поддержки новых протоколов, реализуются парой десятков строк обвязочного кода. Так, стандартный @Controller можно обсахарить
для работы с JSON-RPC.
Не будем останавливаться на этом подробно, это слишком бы далеко вышло за формат этой статьи, но покажу основную идею: на что способны декораторы, в сочетании с Nest.


import {  ControllerOptions,  Controller,  Post,  Req,  Res,  HttpCode,  HttpStatus,} from '@nestjs/common'import { Request, Response } from 'express'import { Extender } from '@qiwi/json-rpc-common'import { JsonRpcMiddleware } from 'expressjs-json-rpc'export const JsonRpcController = (  prefixOrOptions?: string | ControllerOptions,): ClassDecorator => {  return <TFunction extends Function>(target: TFunction) => {    const extend: Extender = (base) => {      @Controller(prefixOrOptions as any)      @JsonRpcMiddleware()      class Extended extends base {        @Post('/')        @HttpCode(HttpStatus.OK)        rpc(@Req() req: Request, @Res() res: Response): any {          return this.middleware(req, res)        }      }      return Extended    }    return extend(target as any)  }}

Далее необходимо извлечь @Req() из rpc-method в мидлваре, найти совпадение с метой, которую добавил декоратор @JsonRpcMethod.
Готово, можно использовать:


import {  JsonRpcController,  JsonRpcMethod,  IJsonRpcId,  IJsonRpcParams,} from 'nestjs-json-rpc'@JsonRpcController('/jsonrpc/endpoint')export class SomeJsonRpcController {  @JsonRpcMethod('some-method')  doSomething(    @JsonRpcId() id: IJsonRpcId,    @JsonRpcParams() params: IJsonRpcParams,  ) {    const { foo } = params    if (foo === 'bar') {      return new JsonRpcError(-100, '"foo" param should not be equal "bar"')    }    return 'ok'  }  @JsonRpcMethod('other-method')  doElse(@JsonRpcId() id: IJsonRpcId) {    return 'ok'  }}

Вывод


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

Подробнее..

NestJS. Загрузка файлов в S3 хранилище (minio)

13.08.2020 02:13:49 | Автор: admin
NestJS фреймворк для создания эффективных, масштабируемых серверных приложений на платформе Node.js. Вы можете встретить утверждение, что NestJS является платформо-независимым фреймворком. Имеется в виду, что он может работать на базе одного из двух фрейморков по Вашему выбору: NestJS+Express или NestJS+Fastify. Это действительно так, или почти так. Эта платформо-независимость заканчивается, на обработке запросов Content-Type: multipart/form-data. То есть практически на второй день разработки. И это не является большой проблемой, если Вы используете платформу NestJS+Express в документации есть пример работы для Content-Type: multipart/form-data. Для NestJS+Fastify такого примера нет, и примеров в сети не так уж и много. И некоторые из этих примеров идут по весьма усложненному пути.

Выбирая между платформой NestJS+Fastify и NestJS+Express я сделал выбор в сторону NestJS+Fastify. Зная склонность разработчиков в любой непонятной ситуации вешать на объект req в Express дополнительные свойства и так общаться между разными частями приложения, я твердо решил что Express в следующем проекте не будет.

Только нужно было решить технический вопрос с Content-Type: multipart/form-data. Также полученные через запросы Content-Type: multipart/form-data файлы я планировал сохранять в хранилище S3. В этом плане реализация запросов Content-Type: multipart/form-data на платформе NestJS+Express меня смущала тем, что не работала с потоками.

Запуск локального хранилища S3



S3 это хранилище данных (можно сказать, хотя не совсем строго, хранилище файлов), доступное по протоколу http. Изначально S3 предоставлялся AWS. В настоящее время API S3 поддерживается и другими облачными сервисами. Но не только. Появились реализации серверов S3, которые Вы можете поднять локально, чтобы использовать их во время разработки, и, возможно, поднять свои серверы S3 для работы на проде.

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

Более весомым мотивом является 1) расширяемость Вам не нужно думать о том, что место на диске может закончиться, и 2) надежность сервера работают в кластере и Вам не нужно думать о резервном копировании, так как необходимое количество копий есть всегда.

Для поднятия реализации серверов S3 minio локально нужен только установленный на компьютере docker и docker-compose. Соответсвующий файл docker-compose.yml:

version: '3'services:  minio1:    image: minio/minio:RELEASE.2020-08-08T04-50-06Z    volumes:      - ./s3/data1-1:/data1      - ./s3/data1-2:/data2    ports:      - '9001:9000'    environment:      MINIO_ACCESS_KEY: minio      MINIO_SECRET_KEY: minio123    command: server http://minio{1...4}/data{1...2}    healthcheck:      test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']      interval: 30s      timeout: 20s      retries: 3  minio2:    image: minio/minio:RELEASE.2020-08-08T04-50-06Z    volumes:      - ./s3/data2-1:/data1      - ./s3/data2-2:/data2    ports:      - '9002:9000'    environment:      MINIO_ACCESS_KEY: minio      MINIO_SECRET_KEY: minio123    command: server http://minio{1...4}/data{1...2}    healthcheck:      test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']      interval: 30s      timeout: 20s      retries: 3  minio3:    image: minio/minio:RELEASE.2020-08-08T04-50-06Z    volumes:      - ./s3/data3-1:/data1      - ./s3/data3-2:/data2    ports:      - '9003:9000'    environment:      MINIO_ACCESS_KEY: minio      MINIO_SECRET_KEY: minio123    command: server http://minio{1...4}/data{1...2}    healthcheck:      test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']      interval: 30s      timeout: 20s      retries: 3  minio4:    image: minio/minio:RELEASE.2020-08-08T04-50-06Z    volumes:      - ./s3/data4-1:/data1      - ./s3/data4-2:/data2    ports:      - '9004:9000'    environment:      MINIO_ACCESS_KEY: minio      MINIO_SECRET_KEY: minio123    command: server http://minio{1...4}/data{1...2}    healthcheck:      test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']      interval: 30s      timeout: 20s      retries: 3


Запускаем и без проблем получаем кластер из 4 серверов S3.

NestJS + Fastify + S3



Работу с сервером NestJS опишу с самых первых шагов, хотя часть этого материала отлично описана в документации. Устанавливается CLI NestJS:

npm install -g @nestjs/cli


Создается новый проект NestJS:

nest new s3-nestjs-tut


Инсталлируются необходимые пакеты (включая те что нужны для работы с S3):

npm install --save @nestjs/platform-fastify fastify-multipart aws-sdk sharpnpm install --save-dev @types/fastify-multipart  @types/aws-sdk @types/sharp


По умолчанию в проекте устанавливается платформа NestJS+Express. Как установить Fastify описано в документации docs.nestjs.com/techniques/performance. Дополнительно нам нужно установить плагин для обработки Content-Type: multipart/form-data fastify-multipart

import { NestFactory } from '@nestjs/core';import {  FastifyAdapter,  NestFastifyApplication,} from '@nestjs/platform-fastify';import fastifyMultipart from 'fastify-multipart';import { AppModule } from './app.module';async function bootstrap() {  const fastifyAdapter = new FastifyAdapter();  fastifyAdapter.register(fastifyMultipart, {    limits: {      fieldNameSize: 1024, // Max field name size in bytes      fieldSize: 128 * 1024 * 1024 * 1024, // Max field value size in bytes      fields: 10, // Max number of non-file fields      fileSize: 128 * 1024 * 1024 * 1024, // For multipart forms, the max file size      files: 2, // Max number of file fields      headerPairs: 2000, // Max number of header key=>value pairs    },  });  const app = await NestFactory.create<NestFastifyApplication>(    AppModule,    fastifyAdapter,  );  await app.listen(3000, '127.0.0.1');}bootstrap();


Теперь опишем сервис, загружающий файлы в хранилище S3, сократив код по обработке некоторых видов ошибок (полный текст есть в репозитарии статьи):

import { Injectable, HttpException, BadRequestException } from '@nestjs/common';import { S3 } from 'aws-sdk';import fastify = require('fastify');import { AppResponseDto } from './dto/app.response.dto';import * as sharp from 'sharp';@Injectable()export class AppService {  async uploadFile(req: fastify.FastifyRequest): Promise<any> {    const promises = [];    return new Promise((resolve, reject) => {      const mp = req.multipart(handler, onEnd);      function onEnd(err) {        if (err) {          reject(new HttpException(err, 500));        } else {          Promise.all(promises).then(            data => {              resolve({ result: 'OK' });            },            err => {              reject(new HttpException(err, 500));            },          );        }      }      function handler(field, file, filename, encoding, mimetype: string) {        if (mimetype && mimetype.match(/^image\/(.*)/)) {          const imageType = mimetype.match(/^image\/(.*)/)[1];          const s3Stream = new S3({            accessKeyId: 'minio',            secretAccessKey: 'minio123',            endpoint: 'http://127.0.0.1:9001',            s3ForcePathStyle: true, // needed with minio?            signatureVersion: 'v4',          });          const promise = s3Stream            .upload(              {                Bucket: 'test',                Key: `200x200_${filename}`,                Body: file.pipe(                  sharp()                    .resize(200, 200)                    [imageType](),                ),              }            )            .promise();          promises.push(promise);        }        const s3Stream = new S3({          accessKeyId: 'minio',          secretAccessKey: 'minio123',          endpoint: 'http://127.0.0.1:9001',          s3ForcePathStyle: true, // needed with minio?          signatureVersion: 'v4',        });        const promise = s3Stream          .upload({ Bucket: 'test', Key: filename, Body: file })          .promise();        promises.push(promise);      }    });  }}


Из особенностей следует отметить, что мы пишем входной поток в два выходных потока, если загружается картинка. Один из потоков сжимает картинку до размеров 200х200. Во всех случаях используется стиль работы с потоками (stream). Но для того, чтобы отловить возможные ошибки и вернуть их в контроллер, мы вызываем метод promise(), который определен в библиотеке aws-sdk. Полученные промисы накапливаем в массиве promises:

        const promise = s3Stream          .upload({ Bucket: 'test', Key: filename, Body: file })          .promise();        promises.push(promise);


И, далее, ожидаем их разрешение в методе Promise.all(promises).

Код контроллера, в котором таки пришлось пробросить FastifyRequest в сервис:

import { Controller, Post, Req } from '@nestjs/common';import { AppService } from './app.service';import { FastifyRequest } from 'fastify';@Controller()export class AppController {  constructor(private readonly appService: AppService) {}  @Post('/upload')  async uploadFile(@Req() req: FastifyRequest): Promise<any> {    const result = await this.appService.uploadFile(req);    return result;  }}


Запускается проект:

npm run start:dev


Репозитарий статьи github.com/apapacy/s3-nestjs-tut

apapacy@gmail.com
13 августа 2020 года
Подробнее..
Категории: Node.js , S3 , Nodejs , Docker , Docker-compose , Nestjs , Express , Expressjs , Fastify , Aws-s3 , Minio

Lerna CI ? Или как не запутаться в трёх соснах

02.01.2021 22:16:45 | Автор: admin

Вместо предисловия

Доброго времени суток! Меня зовут Сергей, и я тимлид в компании Медпоинт24-Лаб. Я занимаюсь разработкой на nodejs чуть больше полутора лет - до этого был C#, ну а ещё до того, всякое разное и не очень серьёзно. Ну то есть, опыта у меня не так чтобы вагон, и иногда приходится серьёзно поломать голову при решении возникающих проблем. Решив такую, всегда хочется поделиться находками с товарищами по команде.

И вот несколько дней назад, они посоветовали мне завести блог... а я подумал, может тогда просто написать на Хабр?

Возможно, примеры практических ситуаций, из которых со скрипом вылезает толковый, но не очень опытный разработчик, будут интересны таким же толковым и неопытным )) А может и ещё кому пригодится.

Рассказывать постараюсь без погружения в теорию, но со ссылками на оную.

О чём пойдёт речь?

Пилот будет посвящён интересной проблеме с которой мы столкнулись при попытке организовать CI/CD для монорепозитория с lerna. Сразу скажу, что этот пост:

  • не про монорепозитории. Плюсы и минусы монорепы, как концепции, уже давно описаны в множестве постов, в том числе на хабре (этот довольно холиварный, кстати)

  • не про инструменты для управления монорепозиториями. Монорепу можно реализовать при помощи Nx, rush, даже просто yarn workspaces. Но так получилось, что мы выбрали lerna и поживём с ней какое то время.

  • не про пакетные менеджеры. Могу порекомендовать хороший видос со сравнением npm, yarn и pnpm и офигенную серию постов в которой работа c npm объясняется с самых азов и очень тщательно. А у нас npm (пока)...

  • не про nestjs. Но он классный!

Обо всём этом будет рассказано только в том объёме, который нужен для понимания проблематики.

Тогда о чём?

Дано:

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

packages+-- @contract|+-- src|+-- package.json|   ...|+-- application|   +-- src|   +-- package.json|   ...|+-- package.json+-- lerna.json...
Зачем пакет?

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

То есть, вызывающий код делает не просто axios.post(....) передавая туда никак не проверяемые статически параметры (any), а вызывает метод с типизированным входом и выходом.

import { Client } from '@contract/some-service';const client = new Client(options);const filters: StronglyTypedObject = ...const data = await client.getSomeData(filters)/** И результат у нас тоже типизированный.* А ещё из getSomeData() вылетают типизированные ошибки известного формата,* чем в нас обычно кидается, axios.*/

Наша практика показала, что это очень облегчает и ускоряет разработку для всех, кто использует наш сервис, включая фронтенд. Так что нам подход понравился.

Кроме того, недавно мы пошли чуть дальше и сделали так:

const query = new SomeQuery({ ... });const data = await client.call(query);/** Теперь клиент у нас общий на все сервисы, а вот запросы и команды -* волшебные и сами знают, что делать. Это мы используем с rabbitMQ.*/

Такой подход мы используем не только для http-сервисов, но и для сервисов, которые обрабатывают сообщения из RabbitMQ, а также при помощи одного из самописных средств транспорта на основе redis. Но усложнять статью этими деталями не будем.

Так вот, у нас есть монорепа, что она нам даёт? В первую очередь, удобство разработки. Базовая фича для всех релевантных инструментов - это то, что в lerna называется bootstrap.

lerna bootstrap --hoist

Флаг --hoist - самая приятная часть. Он говорит лерне, что все зависимости, если можно, надо ставить в node_modules в корне проекта. Мы на этом экономим место + получаем ещё бонус, который нам пригодится дальше.

Помимо установки пакетов lerna bootstrap создаёт симлинки на пакеты имеющиеся в репозитории. То есть, хотя в application/package.json указано

"dependencies": {"@contract/core": "^1.0.0"}

в действительности, этот пакет не будет установлен из npm-реестра, а просто прилинкуется в node_modules из папки packages. Таким образом, мы его меняем, собираем и сразу используем новую версию в нашем приложении.

Задача

Мы строим систему CI/CD. И нам нужно научиться красиво вписать наш монорепозиторий в конвеер. Казалось бы, задача должна была уже 1000 раз быть решена - настолько она очевидна.

И действительно, есть куча issues на github, посты на Stackoverflow и др. ресурсах. Но нет рецептов.. костыли есть, и то не все рабочие, а нормального "штатного" решения я не нашёл (искал, чесслово).

Так вот, когда наш сервис готов к релизу:

  1. Мы хотим смержить PR и, таким образом, запустить пайплайн.

  2. Мы хотим собрать проект, прогнать линтер, unit-тесты.

  3. Мы хотим поднять версию приложения и пакета (а в чём не было изменений - там не поднимать).

  4. Мы хотим опубликовать пакет @contract в npm registry (в нашем случае, приватном).

  5. Мы хотим собрать из нашего приложения артефакт, который потом отдать тестировщикам, ну а в конце скопировать на рабочую виртуалку и запустить. (да, да, я знаю - docker, но не все сразу. В любом случае, проблемы там будут те же самые)

  6. Ну и мы хотим, чтобы наш артефакт был разумного размера и содержал только то, что ему реально нужно для работы. node_modules по ГБ - не совсем то, что нам нужно.

Поехали!

Первый пункт берут на себя CI/CD системы.

Со вторым проблем быть не должно:

Для третьего lerna нам предлагает две прекрасные команды: lerna version и lerna publish (последняя включает в себя первую и мы будем использовать её). Примерно так:

lerna publish --conventional-commits --yes# На заметку: команда publish принимает все флаги команды version.# В доке это есть, но я с первого раза пропустил.
Чуть подробнее про conventional commits.

Если делать команду lerna publish без указанных ключей, то поднятие версии будет интерактивным,что для CIонвейра не годится. На помощь приходит спецификация Conventional Commits. Соблюдение этого простого и понятного соглашения по структуре commit-сообщений, позволяет lerna автоматически определить, какую как правильно по semver поднять (минор, мажор или патч). Самое милое, что мы можем вынудить наших разработчиков писать правильные коммиты (что само по себе хорошо)! Вот достаточно подробная инструкция.

С пунктом 4 у нас тоже нет проблем.lerna publish нам это уже сделала, а если нас это почему-то не устраивает (ну, к примеру, мы не хотим ставить теги или ещё что), то используем lerna version в сочетании с npm publish из директории пакета. Забавно, что npm publish не имеет ключа --registry, чтобы указать, куда пушить пакет. В случае lerna publish, нас выручит lerna.json (стр. 7):

{  "version": "1.2.2",  "npmClient": "npm",  "command": {    "publish": {      "message": "chore(release): publish",      "registry": ....    }  },  "packages": [    "packages/@contract",    "packages/application"  ]}

Иначе нам понадобится файл .npmrc (файл с настройками npm) в директории пакета.

Первые сложности

Итак, наша CI-машина должна делать примерно следующее (без привязки к конкретной системе CI/CD):

# Pull и checkoutlerna bootsrap --hoist lerna run build # Запустит команду npm run build в каждом пакете.lerna publish --conventional-commits --yescp packages/application/build /tmp/place/for/artifact...

Но для работы нашему приложению нужны ещё node_modules.

Попытка 1. Можно конечно взять да и скопировать папку node_modules из корня проекта в наш /tmp/place/for/artifact. Но тогда:

  • Мы точно получим лишние зависимости (всякие jest, typescript и кучу ещё всего ненужного в рантайме). А если у нас в репозитории не 2 пакета, а 22, то размер node_modules может быть неприличным.

  • Мы, возможно, недополучим нужные зависимости, т. к. lerna поднимает пакеты в корень только если может. Так бывает не всегда - могут быть где-то разные версии, например.

Попытка 2. Не вопрос. У нас же есть package.json внутри packages/application. Там ведь перечисленно всё, что надо! Копируем package.json в папку с артефактом, запускаем npm i - профит! Но:

Дело в том, что для обеспечения повторяемости билда, в CI среде вместо команды npm install принято использовать npm ci. Основное отличие от npm install в том, что пакеты ставятся не из package.json, а из package-lock.json или shrinkwrap.json (смысл тот же). Подробнее о lock-файлах можно почитать в хорошем переводе от Андрея Мелихова.

Для моего рассказа важно понимать следующее:

  • Обойтись без lock-файла никак нельзя. Даже если указать в dependencies точные версии зависимостей без всяких "~" и "^" - это не поможет, т. к. транзитивные зависимости (то есть зависимости ваших зависимостей) вы не контролируете.

  • lock-файл должен быть синхронизирован с package.json. Это значит, что если у нас в package.json появилась новая зависимость (или наоборот), а в package-lock.json её нет, то npm ci будет ругаться:

Приятно, что текст ошибки содержит прямое указание на то, что нам нужно сделать - npm install.

Давайте вспомним, с чего мы начинали нашу сборку: lerna bootstrap --hoist Эта команда на самом деле уже создала нам package-lock.json в корне проекта. Однако, это нам мало помогает.

Можете убедиться в этом сами, скопировав в артефакт package.json из packages/application и lock-файл из корня - получите ошибку. Конечно, ведь там никакого намёка не будет на синхронизацию! А в application lock-файла у нас нет. Поэтому:

Попытка 3. Давайте попробуем обойтись без "всплытия". Да, не супер удобно, зато lock-файл будет там где надо. Делаем просто:

lerna bootstrap

Это действительно даст нам по lock-файлу на каждый проект. Но и тут всё не слава Богу! Потому что при попытке с этим файлом сделать npm ci, нам опять скажут нехорошие слова про синхронизацию. Как так?

Изучаем файл package-lock.json и видим.. что там не хватает пакета @contract/core!Ну и правильно, мы его не устанавливали, а делали симлинк...

Попытка 4. Ок, делаем просто npm install внутри каждого пакета. Тут нам поможет:

lerna exec -- npm i

Ура, теперь lock-файл и манифест внутри пакета синхронизированы! npm ci работает! Победа!

Запускаем наше приложение и при первом же запросе...

Оно падает

Говорит, что с модулем @contractчто-то не то. Конечно не то! Ведь npm i поставил этот модуль из npm registry. Ну тогда понятно - это только формально та же версия. А по факту, мы локально могли внести изменения, в том числе и ломающие, но версию ещё не поднимали и пакет не пушили (напоминаю, что build у нас до publish). Если же никаких изменений в пакете не было, то всё сработает.. и это скорее плохо, чем хорошо. Лучше пусть всегда не работает, чем то так, то сяк.

Думали гадали насчёт того, как бы делать publish сначала. Но это не логично - код надо собрать, потом протестировать, потом только паблишить - а он, зараза, не собирается.

Попытка 4. Ну ок, нам нужен симлинк, давайте его сделаем...

Выполняем:

lerna exec -- npm i      # Создаётся lock-файлы в пакетах.lerna link               # Создаются симлинки.lerna run buildlerna publish --conventional-commits ...cp packages/application/build /path/to/artifact# Можно ещё вместо копирования сделать production сборку# - без sourceMaps и деклараций.cp packages/application/package*.json /path/to/artifact(cd /path/to/artifact && npm ci --production)

И оно даже работает! Только мы кое-что забыли.. Добавим вызов jest где-нибудь между 3-й и 4-й строкой...

И внезапно падают уже тесты

Могут конечно и не упасть... особенно, если их нет. Но приложение может работать некорректно. А точнее, не так, как на машине разработчика, где он делает lerna bootstrap --hoist а потом билд.

До этого момента все проблемы были в общем-то тривиальны. Времени много ушло потому, что не было некоторых базовых знаний платформы и инструментов. Сейчас - после нескольких часов проведённых в гугле (хабре, медиуме, гитхабе...) - уже кажется, что всё просто. А вот новая проблема прямо мистическая. И возможно, вы с ней не столкнётесь. Но в чём тут суть, ИМХО понимать полезно.

Итак, lerna bootstrap --hoist и lerna exec -- npm i && lerna link - в чём может быть разница? Ведь второе - это по сути lerna bootstrap, но без --hoist. Пробуем на машине разработчика убрать флаг hoist... тесты падают. Добавляем - проходят.

На самом деле, когда первый приступ отупения проходит, можно осознать следующее:

packages+-- @contract|   +-- node_modules|       +-- class-transformer|+-- src|+-- package.json|   ...|+-- application|   +-- node_modules|       +-- class-transformer|       +-- @contract -> символическая ссылка|   +-- src|   +-- package.json|   ...|+-- package.json+-- lerna.json...

На схеме подсказка. И application и contract зависят от пакета class-transformer. Вообще-то, там есть и другие общие зависимости, но, к счастью, не все зависимости ломаются, когда в структуре node_modules они присутствуют в двойном экземпляре.

class-transformer - из тех, что ломается.

Подробнее о том, почему

class-transformer - удобная библиотека для преобразования объектов основанная на декораторах. В nestjs она встроена в дефолтную систему валидации (ValidationPipe). Самый простой пример её использования может быть такой:

import { Type } from 'class-transformer';import { IsInt, IsPositive } from 'class-validator';export class Query {  @IsInt()  @IsPositive()  @Type(() => Number)  id: number;}

При этом мы это посылаем в GET запросе (?id=100500) и получается, что nest на вход получит строчку, а не число. И валидатор IsInt() на это ругнётся (может и нет, но IsPositive() ругнётся 100%).

Поэтому мы говорим несту: преобразуй пожалуйста в число. Декоратор @Type() - самый простой способ. Если я не ошибаюсь, то он сделает просто return Number(id) Для более сложных случаев можно использовать декоратор @Transform() в который можно передать функцию преобразования.

Всё это вы можете найти в доке по class-validator и class-transformer.

Но вот только будте осторожны - функция трансформации НИ В КОЕМ СЛУЧАЕ не должна бросить ошибку. Это положит поток (на горьком опыте - потерянные 3 часа жизни)

Так вот:

Что произойдёт в нашем случае. Когда отработает декоратор @Type(), он он запишет в специальный объект в недрах class-transformer метаданные: "у вот этого класса надо преобразовывать входящее значение в число". Потом, когда объект придёт в ваше приложение nest вызовет функцию plainToClass из той же самой библиотеки, передав туда данные и конструктор Query. Она из того же объекта считает метаданные и проведёт преобразование.

В этом "том же" вся проблема. Если копий библиотеки у нас две, то это может быть два разных объекта и когда plainToClass будет работать, метаданных установленных в декораторе @Type() там не окажется!

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


Если кто-то уже достиг дзен - напишите пожалуйста в комментах, кому-то точно пригодится.

А мы как раз поместили наш класс Query в один пакет, а приложение в другой и если внимательно посмотреть на структуру проекта выше, но становится понятно, что у @contractнет никаких шансов найти нужную копию class-transformer.

Забавно, что у class-validator такой проблемы нет. Возможно, они хранят метаданные иначе (в global?). Как именно ещё не успел посмотреть.

Вот собственно ответ и найден. Получается, из-за того, как работает резолв зависимостей в ноде (ищем в node_modules, потом поднимаемся выше, ищем в node_modules... и так до рута) в случае симлинков нам очень выгоден --hoist. В случае установки из registry, пакетный менеджер сделает примерно (меня пугает это примерно...) то же - поднимет всё, что сможет поднять.

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

Что в итоге?

Чуток устаканив в голове новое понимание (и вопросы), я родил примерно следующее:

  • Когда программист начинает работу над репозиторием (только что слонировав его), он действует по плану:

lerna bootstrap --hoist # Не npm i в корне! Это сломает ваш lock-file!lerna run buildjest# ну и работаем...
  • В CI делаем то же самое, потом lerna publish, а затем:

# Makefile# Получаем версию приложения.BUILD:=build.$(shell jq .version packages/application/package.json | sed 's/"//g')artifact:  # Скрипт build/prod убирает sourceMap'ы и декларации, которые не нужны в продакшене(cd packages/application && npm run build:prod -- --outDir ../../deploy/$(BUILD))cp -r packages/application/package*.json deploy/$(BUILD)  # Ставим только рантайм-зависимости из package-lock.json(cd deploy/$(BUILD) && npm ci --production)    # Я вырезал кое-какие несущественные подробности, типа удаления package*.json и  # создания tar.gz архива.rm deploy/$(BUILD)/package*.jsosdf

Мы используем make, но это не суть. В итоге, это всё планируется перенести в Dockerfile, когда наша инфраструктура будет к этому готова.

  • Как создать корректные lock-файлы, если их нет?

lerna exec -- npm ilerna clean --yes# Именно так. Ставим модули, а потом удаляем. Но это единственный способ получить# lock-файлыlerna bootstrap -- hoist
  • Ну и последнее и, пожалуй, самое неприятное. Как установить новый пакет в наш application (или @contract)таким образом, чтобы сохранить консистентность lock-файлов:

# Makefileadd:# (ОЧЕНЬ ВАЖНО) Здесь обновится только package.jsonlerna add --scope=$(scope) $(package) --no-bootstrap# Обновится package-lock.json внутри пакетаlerna exec --scope=$(scope) -- npm i# node_modules внутри units/application нам не нужны!lerna clean --yes# вернёт нам все зависимости в корень и обновит рутовый package-lock.jsonlerna bootstrap --hoist  # Запускаем так (в scope надо передавать имя пакета из package.json):$ make add scope=app_name package left-pad

Почему так сложно? Потому что команда lerna add не обновляет package-lock.json, а только сам манифест. Не понятно почему. Может быть я не нашёл чего-то. Подскажите...

Выводы:

  • Зависимости в ноде - это сложно.

  • Управление зависимостями в монорепозиториях в условиях CI/CD - это ещё сложнее.

  • Но самое главное, свет в конце всегда туннеля есть! И пока решаешь такого рода заморочки, частенько удаётся поднять хороший пласт новых знаний.

Уверен, что это не последняя итерация. Меня не покидает ощущение, что всё можно сделать проще, чище - буду рад мнениям и идеям в комментариях.

Надо ещё поиграться с командой npm shrinkwrap, например...

Большое спасибо тем, кто дочитал до конца... Если здесь ещё кто-нибудь есть?

Если такой формат "история из практики" интересен, напишите пожалуйста, что "так", что "не так". Потому что историй... их есть у меня.

Спасибо за внимание!

Подробнее..
Категории: Node.js , Ci/cd , Nodejs , Nestjs , Monorepo , Lerna

Категории

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

  • Имя: Макс
    24.08.2022 | 11:28
    Я разраб в IT компании, работаю на арбитражную команду. Мы работаем с приламы и сайтами, при работе замечаются постоянные баны и лаги. Пацаны посоветовали сервис по анализу исходного кода,https://app Подробнее..
  • Имя: 9055410337
    20.08.2022 | 17:41
    поможем пишите в телеграм Подробнее..
  • Имя: sabbat
    17.08.2022 | 20:42
    Охренеть.. это просто шикарная статья, феноменально круто. Большое спасибо за разбор! Надеюсь как-нибудь с тобой связаться для обсуждений чего-либо) Подробнее..
  • Имя: Мария
    09.08.2022 | 14:44
    Добрый день. Если обладаете такой информацией, то подскажите, пожалуйста, где можно найти много-много материала по Yggdrasil и его уязвимостях для написания диплома? Благодарю. Подробнее..
© 2006-2024, personeltest.ru