Цитата из документации:
Nest предоставляет готовую архитектуру приложений, которая позволяет разработчикам и командам создавать высокопроверяемые, масштабируемые, слабо связанные и легко обслуживаемые приложения. Архитектура в значительной степени вдохновлена Angular.
Nest построен на основе шаблона проектирования - dependency injection. Мы увидим как он реализован в Nest и как это влияет на весь остальной код.
Для начала посмотрим на самый простой код для запуска приложения nestjs из документации:
main.ts
import { NestFactory } from "@nestjs/core"; import { AppModule } from "./app.module"; async function bootstrap() { const app = await NestFactory.create(AppModule); await app.listen(3000); } bootstrap();
Итак. NestFactory главный класс nest с чего все и начинается. Его метод create сканирует существующие модули, которые доступны в дереве зависимостей из корневого модуля AppModule, после чего сканирует зависимости полученных модулей в виде контроллеров, сервисов и других модулей, и добавляет все это в контейнер. После сканирования возвращает экземпляр приложения. При запуске метода listen происходит инициализация сервера и регистрация роутеров, созданных в контроллере, с необходимыми для каждого обратными вызовами, которые уже хранятся в контейнере.
К созданию подобной функциональности мы придем чуть позже, а сначала посмотрим к��кие у нас есть контроллер, провайдер и модуль.
app.controller.ts
import { Controller, Get, Post, Body, Param } from 'nestjs/common'; import { AppService } from './app.service'; @Controller() export class AppController { constructor(private readonly appService: AppService) {} @Get() getHello(): string { return this.appService.getHello(); } @Post('body/:id') recieveBody(@Body() data: { recieveData: string }, @Param('id') id: string) { return 'body: ' + data.recieveData + ' has been recieved and id: ' + id; } }
Что интересно в этом коде, так это то, что создавая методы запроса, мы просто навешиваем декораторы на функции, которые и становятся callbacks для роутеров. Мы не знаем, когда они регистрируется и как именно, нам не нужно думать о реализации, все, что мы делаем, это следуем заданной архитектуре. Мы говорим, что мы хотим сделать, а не как, то есть, в качестве пользователей Nest, используем декларативный подход. Соответственно мы реализуем данную функциональность в этой статье.
Также здесь есть зависимость AppService, которую Nest внедряет самостоятельно. Для нас же, это рабочий код. Зависимости в Nest разрешаются по типу, и мы рассмотрим как именно.
app.service.ts
import { Injectable } from 'nestjs/common'; @Injectable() export class AppService { getHello(): string { return 'Hello World!'; } }
Здесь мы видим декоратор Injectable, который, по описанию в документации, определяет AppService как класс, которым может управлять контейнер Nest, что является не совсем правдой. На самом деле все, что он делает, это добавляет метаданные о времени жизни класса. По умолчанию оно совпадает с временем жизни приложения, и сам Nest не рекомендует изменять это поведение. Поэтому если вы не хотите это изменить, то Injectable можно опустить. А управлять им контейнер Nest мо��ет только в том случае, если он будет присутствовать в providers модуля, в котором он используется.
app.module.ts
import { Module } from 'nestjs/common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @Module({ imports: [], controllers: [AppController], providers: [AppService], }) export class AppModule {}
Итак. Возвращаясь к main.ts, реализуем NestFactory класс.
Стоит оговориться, некоторые вспомогательные функции, вроде проверки на null и т.д., а также интерфейсы будут опущены в статье, но будут находиться в исходном коде.
./core/nest-factory.ts
import { NestApplication } from "./nest-application"; import { NestContainer } from "./injector/container"; import { InstanceLoader } from "./injector/instance-loader"; import { DependenciesScanner } from "./scanner"; import { ExpressAdapter } from '../platform-express/express.adapter'; export class NestFactoryStatic { public async create(module: any) { const container = new NestContainer(); // Сканирует зависимости, создает экземпляры // и внедряет их await this.initialize(module, container); // Инициализирует http сервер и регистрирует роутеры // при запуске instance.listen(3000) const httpServer = new ExpressAdapter() container.setHttpAdapter(httpServer); const instance = new NestApplication( container, httpServer, applicationConfig, ); return instance; } private async initialize( module: any, container: NestContainer, ) { const instanceLoader = new InstanceLoader(container) const dependenciesScanner = new DependenciesScanner(container); await dependenciesScanner.scan(module); await instanceLoader.createInstancesOfDependencies(); } } /** * Используйте NestFactory для создания экземпляра приложения. * * ### Указание входного модуля * * Передайте требуемый *root module* (корневой модуль) для приложения * через параметр модуля. По соглашению он обычно называется * `AppModule`. Начиная с этого модуля Nest соби��ает граф * зависимостей и создает экземпляры классов, необходимых для запуска * вашего приложения. * * @publicApi */ export const NestFactory = new NestFactoryStatic();
Итак. Из кода мы видим, что сперва сканируются все существующие модули, а также их зависимости, а после создается http сервер. Поэтому сейчас мы реализуем класс DependenciesScanner. Он получится чуть больше, чем предыдущий, но не будем пугаться, ведь ничего сложного, на самом деле, там нет.
./core/scanner.ts
import { MODULE_METADATA } from "../common/constants"; import { NestContainer } from "./injector/container"; import 'reflect-metadata'; import { Module } from "./injector/module"; export class DependenciesScanner { constructor(private readonly container: NestContainer) {} public async scan(module: any) { // Сначала сканирует все модули, которые есть в приложении, и добавляет их в контейнер await this.scanForModules(module); // После у каждого модуля сканирует зависимости, такие как Controllers и Providers await this.scanModulesForDependencies(); } public async scanForModules(module: any) { // Добавляет модуль в контейнер и возвращает при этом его экземпляр const moduleInstance = await this.insertModule(module); // Получает модули, которые были импортированы в этот модуль в массив imports. // Так как AppModule - корневой модуль, то от него идет дерево модулей. const innerModules = [...this.reflectMetadata(moduleInstance, MODULE_METADATA.IMPORTS)]; // Перебирает внутренние модули этого модуля, чтобы сделать с ними тоже самое. // То есть, происходит рекурсия. for (const [index, innerModule] of innerModules.entries()) { await this.scanForModules(innerModule) } return moduleInstance } /** * Добавляет модуль в контейнер */ public async insertModule(module: any) { return this.container.addModule(module); } /** * Получает из контейнера все модули, и сканирует у них * зависимости, которые хранятся в reflect объекте. */ public async scanModulesForDependencies() { const modules: Map<string, Module> = this.container.getModules(); for (const [token, { metatype }] of modules) { await this.reflectAndAddImports(metatype, token); this.reflectAndAddProviders(metatype, token); this.reflectAndAddControllers(metatype, token); } } public async reflectAndAddImports( module: any, token: string, ) { // Получает по модулю imports зависимости и добавляет их в контейнер const modules = this.reflectMetadata(module, MODULE_METADATA.IMPORTS); for (const related of modules) { await this.container.addImport(related, token); } } public reflectAndAddProviders( module: any, token: string, ) { // Получает по модулю providers зависимости и добавляет их в контейнер const providers = this.reflectMetadata(module, MODULE_METADATA.PROVIDERS); providers.forEach((provider: any) => this.container.addProvider(provider, token), ); } public reflectAndAddControllers(module: any, token: string) { // Получает по модулю controllers зависимости и добавляет их в контейнер const controllers = this.reflectMetadata(module, MODULE_METADATA.CONTROLLERS); controllers.forEach((controller: any) => this.container.addController(controller, token), ); } /** * Метод, который получает нужные зависимости по модулю и ключу зависимостей. */ public reflectMetadata(metatype: any, metadataKey: string) { return Reflect.getMetadata(metadataKey, metatype) || []; } }
Смотря на код, мы видим, что модули и их зависимости добавляются в контейнер, и это возможно благодаря двум классам используемым здесь - NestContainer и Module. На самом деле в контейнере хранятся модули как экземпляры класса Module, и их зависимости, такие как другие модули, контроллеры и провайдеры, хранятся в Module, в таких структурах данных как Map и Set. А сами зависимости, контроллеры и провайдеры, являются экземплярами класса InstanceWrapper.
Классы Module и InstanceWrapper в нашей реализации являются довольно простыми, особенно второй, поэтому сначала реализуем наш контейнер.
./core/injector/container.ts
import { Module } from "./module"; import { ModuleTokenFactory } from "./module-token-factory"; import { AbstractHttpAdapter } from "../adapters"; export class NestContainer { private readonly modules = new Map<string, Module>(); private readonly moduleTokenFactory = new ModuleTokenFactory(); private httpAdapter: AbstractHttpAdapter | undefined; /** * Создает экземпляр класса Module и сохраняет его в контейнер */ public async addModule(module: any) { // Создает токен модуля, который будет являться его ключом Map, // который и будет использоваться для проверки и получения этого модуля. const token = this.moduleTokenFactory.create(module); if (this.modules.has(module.name)) { return; } const moduleRef = new Module(module); moduleRef.token = token; this.modules.set(token, moduleRef); return moduleRef; } /** * Возвращает все модули, для сканирования зависимостей, * создания экземпляров этих зависимостей, и для использования в качестве callbacks * при создании роутеров его контроллеров, с разрешенными зависимостями. */ public getModules(): Map<string, Module> { return this.modules; } /** * Контейнер также устанавливает и хранит единственный экземпляр http сервера, * в нашем случае express. Этот метод вызывается в классе NestFactory. */ public setHttpAdapter(httpAdapter: any) { this.httpAdapter = httpAdapter; } /** * Будет вызван при создании роутеров в классе RouterExplorer. */ public getHttpAdapterRef() { return this.httpAdapter; } /** * При сканировании зависимостей для полученных модулей в DependenciesScanner, * у них также берется токен, по которому здесь находится модуль, * и с помощью своего метода добавляет �� себе импортированный модуль. */ public async addImport( relatedModule: any, token: string, ) { if (!this.modules.has(token)) { return; } const moduleRef = this.modules.get(token); if (!moduleRef) { throw Error('MODULE NOT EXIST') } const related = this.modules.get(relatedModule.name); if (!related) { throw Error('RELATED MODULE NOT EXIST') } moduleRef.addRelatedModule(related); } /** * Также как и для импортированных модулей, подобная функциональность * работает и для провайдеров. */ public addProvider(provider: any, token: string) { if (!this.modules.has(token)) { throw new Error('Module not found.'); } const moduleRef = this.modules.get(token); if (!moduleRef) { throw Error('MODULE NOT EXIST') } moduleRef.addProvider(provider) } /** * Также как и для импортированных модулей, подобная функциональность * работает и для контроллеров. */ public addController(controller: any, token: string) { if (!this.modules.has(token)) { throw new Error('Module not found.'); } const moduleRef = this.modules.get(token); if (!moduleRef) { throw Error('MODULE NOT EXIST') } moduleRef.addController(controller); } }
Мы увидели здесь также класс ModuleTokenFactory, который создаёт токен, по которому хранится модуль. На самом деле, здесь можно обойтись и обычным созданием уникального id, например с помощью пакета uuid. Поэтому вы можете сильно не обращать на это внимание, но, кому интересно, вот максимально приближенная реализация этого класса к реализации Nest, только несколько упрощенная.
./core/injector/module-token-factory.ts
import hash from 'object-hash'; import { v4 as uuid } from 'uuid'; import { Type } from '../../common/interfaces/type.interface'; export class ModuleTokenFactory { // Здесь хранятся данные о том, какие модули уже были отсканированы. // На случай того, если один модуль является зависимостью у нескольких, // чтобы не было дубликатов. private readonly moduleIdsCashe = new WeakMap<Type<unknown>, string>() public create(metatype: Type<unknown>): string { const moduleId = this.getModuleId(metatype); const opaqueToken = { id: moduleId, module: this.getModuleName(metatype), }; return hash(opaqueToken, { ignoreUnknown: true }); } public getModuleId(metatype: Type<unknown>): string { let moduleId = this.moduleIdsCashe.get(metatype); if (moduleId) { return moduleId; } moduleId = uuid(); this.moduleIdsCashe.set(metatype, moduleId); return moduleId; } public getModuleName(metatype: Type<any>): string { return metatype.name; } }
Теперь рассмотрим класс Module.
./core/injector/module.ts
import { InstanceWrapper } from "./instance-wrapper"; import { randomStringGenerator } from "../../common/utils/random-string-generator.util"; export class Module { private readonly _imports = new Set<Module>(); private readonly _providers = new Map<any, InstanceWrapper>(); private readonly _controllers = new Map<string, InstanceWrapper>(); private _token: string | undefined; constructor( private readonly module: any, ) {} get providers(): Map<string, any> { return this._providers; } get controllers(): Map<string, any> { return this._controllers; } get metatype() { return this.module; } get token() { return this._token!; } set token(token: string) { this._token = token; } public addProvider(provider: any) { this._providers.set( provider.name, new InstanceWrapper({ name: provider.name, metatype: provider, instance: null, }), ) } public addController(controller: any) { this._controllers.set( controller.name, new InstanceWrapper({ name: controller.name, metatype: controller, instance: null, }), ); this.assignControllerUniqueId(controller); } public assignControllerUniqueId(controller: any) { Object.defineProperty(controller, 'CONTROLLER_ID', { enumerable: false, writable: false, configurable: true, value: randomStringGenerator(), }); } public addRelatedModule(module: Module) { this._imports.add(module); } }
Комментарии здесь излишни. Все, что он делает, это хранит зависимости определенного модуля, сам модуль и его токен.
Теперь рассмотрим еще более простой класс InstanceWrapper.
./core/injector/instance-wrapper.ts
import { Type } from '../../common/interfaces/type.interface'; export class InstanceWrapper<T = any> { public readonly name: string; public metatype: Type<T> | Function; public instance: any; public isResolved = false constructor(metadata: any) { Object.assign(this, metadata); this.instance = metadata.instance; this.metatype = metadata.metatype; this.name = metadata.name } }
При его создании к instance присваивается null. В дальнейшем, например, если контроллер имеет зависимость в виде провайдера в его конструкторе, то при внедрении зависимостей, экземпляр этого провайдера будет уже создан, и при создании экземпляра контроллера, будет добавлен в его конструктор. Собств��нно так и разрешаются зависимости. Собственно этим мы дальше и займемся.
Сейчас у нас есть функциональность сканирования модулей и их зависимостей. Модули добавляются в контейнер, хранятся по созданным токеном в виде класса Module, в котором они все и представлены и хранят свои зависимости, которые находятся в объекте reflect, в структурах данных Map и Set.
А теперь снова вернемся к классу NestContainer и взглянем на его метод initialize
private async initialize( module: Module, container: NestContainer, ) { const instanceLoader = new InstanceLoader(container) const dependenciesScanner = new DependenciesScanner(container); await dependenciesScanner.scan(module); await instanceLoader.createInstancesOfDependencies();
на его метод initialize
Сейчас, когда мы просканировали модули, нам нужно создать экземпляры их зависимостей. Поэтому сейчас реализуем класс InstanceLoader.
./core/injector/instance-loader.ts
import { NestContainer } from "./container"; import { Injector } from "./injector"; import { Module } from "./module"; export class InstanceLoader { private readonly injector = new Injector(); constructor(private readonly container: NestContainer) {} public async createInstancesOfDependencies() { const modules = this.container.getModules(); await this.createInstances(modules); } /** * Сначала создаются экземпляры провайдеров, * потому что если они являются зависимостями контроллеров, * при создании экземпляров для контроллеров, они уже должны * существовать. */ private async createInstances(modules: Map<string, Module>) { await Promise.all( [...modules.values()].map(async module => { await this.createInstancesOfProviders(module); await this.createInstancesOfControllers(module); }) ) } private async createInstancesOfProviders(module: Module) { const { providers } = module; const wrappers = [...providers.values()]; await Promise.all( wrappers.map(item => this.injector.loadProvider(item, module)), ) } private async createInstancesOfControllers(module: Module) { const { controllers } = module; const wrappers = [...controllers.values()]; await Promise.all( wrappers.map(item => this.injector.loadControllers(item, module)), ) } }
Тоже не сложный класс. Все, что она делает вызывает методы класса Injector. Что здесь стоит отметить, что уже написано в комментарии к методу createInstances, это то, что созданные экземпляры провайдеров будут добавляться в конструкторы соответствующих контроллеров при создании их экземпляров.
Сейчас рассмотрим класс Injector, который несколько интереснее остальных, и который и производит внедрение зависимостей.
./core/injector/injector.ts
import { Module } from "./module"; import { InstanceWrapper } from './instance-wrapper'; import { Type } from '../../common/interfaces/type.interface'; export class Injector { public async loadInstance<T>( wrapper: InstanceWrapper<T>, collection: Map<string, InstanceWrapper>, moduleRef: Module, ) { const { name } = wrapper; const targetWrapper = collection.get(name); if (!targetWrapper) { throw Error('TARGET WRAPPER NOT FOUNDED') } const callback = async (instances: unknown[]) => { await this.instantiateClass( instances, wrapper, targetWrapper, ); } await this.resolveConstructorParams<T>( wrapper, moduleRef, callback, ); } public async loadProvider( wrapper: any, moduleRef: Module, ) { const providers = moduleRef.providers; await this.loadInstance<any>( wrapper, providers, moduleRef, ); } public async loadControllers( wrapper: any, moduleRef: Module, ) { const controllers = moduleRef.controllers; await this.loadInstance<any>( wrapper, controllers, moduleRef, ); } /** * design:paramtypes создается автоматически объектом reflect * для зависимостей, указанных в конструкторе класса. * Как видно, если провайдеру нужно разрешить зависимости, * то они также должны быть провайдерами. * callback, как видно из метода loadInstance, вызывает метод * instantiateClass для найденных зависимостей в виде провайдеров. */ public async resolveConstructorParams<T>( wrapper: InstanceWrapper<T>, moduleRef: Module, callback: (args: unknown[]) => void | Promise<void>, ) { const dependencies = Reflect.getMetadata('design:paramtypes', wrapper.metatype) const resolveParam = async (param: Function, index: number) => { try { let providers = moduleRef.providers const paramWrapper = providers.get(param.name); return paramWrapper?.instance } catch (err) { throw err; } }; const instances = dependencies ? await Promise.all(dependencies.map(resolveParam)) : []; await callback(instances); } /** * Создает экземпляр зависимости, которая хранится в InstanceLoader, * как metatype, с ее зависимостями, которые являются провайдерами, * и добавляет этот экземпляр в instance поле класса InstanceLoader, * для дальнейшего извлечения при создании роутеров. */ public async instantiateClass<T = any>( instances: any[], wrapper: InstanceWrapper, targetMetatype: InstanceWrapper, ): Promise<T> { const { metatype } = wrapper; targetMetatype.instance = instances ? new (metatype as Type<any>)(...instances) : new (metatype as Type<any>)(); return targetMetatype.instance; } }
Отлично. Теперь у нас есть просканированные модули, и созданные экземпляры зависимостей. Идем дальше.
Сейчас еще раз вернемся к NestFactory, а именно к его методу create.
public async create(module: Module) { const applicationConfig = new ApplicationConfig(); const container = new NestContainer(); await this.initialize(module, container); const httpServer = new ExpressAdapter() container.setHttpAdapter(httpServer); const instance = new NestApplication( container, httpServer, applicationConfig, ); return instance;
У нас здесь есть класс ExpressAdapter, который наследуется от класса AbstractHttpAdapter. То есть здесь используется паттерн проектирования известный как адаптер. При желании можно создать и класс FastifyAdapter для использования fastify вместо express. Так и сделано в nest, но здесь мы возьмем express из-за его большей распространенности.
Сначала рассмотрим AbstractHttpAdapter.
./core/adapters/http-adapter.ts
import { HttpServer } from "../../common/interfaces/http-server.interface"; export abstract class AbstractHttpAdapter< TServer = any, TRequest = any, TResponse = any > implements HttpServer<TRequest, TResponse> { protected httpServer: TServer | undefined; constructor(protected readonly instance: any) {} public use(...args: any[]) { return this.instance.use(...args); } public get(...args: any[]) { return this.instance.get(...args); } public post(...args: any[]) { return this.instance.post(...args); } public listen(port: any) { return this.instance.listen(port); } public getHttpServer(): TServer { return this.httpServer as TServer; } public setHttpServer(httpServer: TServer) { this.httpServer = httpServer; } public getInstance<T = any>(): T { return this.instance as T; } abstract initHttpServer(): any; abstract reply(response: any, body: any, statusCode?: number): any; abstract registerBodyParser(prefix?: string): any; }
Видим, что он реализует несколько обычных методов http сервера. Для упрощения кода, у нашего nest будет только два http метода, а именно post и get.
А это интерфейс, который реализует адаптер
interface HttpServer<TRequest = any, TResponse = any> { reply(response: any, body: any, statusCode?: number): any; get(handler: RequestHandler<TRequest, TResponse>): any; get(path: string, handler: RequestHandler<TRequest, TResponse>): any; post(handler: RequestHandler<TRequest, TResponse>): any; post(path: string, handler: RequestHandler<TRequest, TResponse>): any; listen(port: number | string): any; getInstance(): any; getHttpServer(): any; initHttpServer(): void; registerBodyParser(): void }
Теперь посмотрим на класс ExpressAdapter
./platform-express/express.adapter.ts
import { AbstractHttpAdapter } from '../core/adapters'; import { isNil, isObject } from '../common/utils/shared.utils' import express from 'express'; import * as http from 'http'; import { json as bodyParserJson, urlencoded as bodyParserUrlencoded, } from 'body-parser'; export class ExpressAdapter extends AbstractHttpAdapter { constructor() { super(express()); } /** * Является response методом. С помощью него отправляются все данные. */ public reply(response: any, body: any) { if (isNil(body)) { return response.send(); } return isObject(body) ? response.json(body) : response.send(String(body)); } /** * Запускает сервер на выборном порте */ public listen(port: any) { return this.httpServer.listen(port); } public registerBodyParser() { const parserMiddleware = { jsonParser: bodyParserJson(), urlencodedParser: bodyParserUrlencoded({ extended: true }), }; Object.keys(parserMiddleware) .forEach((parserKey: any) => this.use((parserMiddleware as any)[parserKey])); } public initHttpServer() { this.httpServer = http.createServer(this.getInstance()); } }
Собственно здесь реализуется запуск и настройка express. В конструкторе, в методе super, экземпляр express передается в AbstractHttpAdapter, из которого и будут вызываться методы post, get и use.
Теперь, снова возвращаясь к NestFactory,
public async create(module: Module) { const container = new NestContainer(); await this.initialize(module, container); const httpServer = new ExpressAdapter() container.setHttpAdapter(httpServer); const instance = new NestApplication( container, httpServer, ); return instance; }
нам нужно реализовать класс NestApplication, который является экземпляром всего приложения Nest. Именно из него вызывается метод listen,
async function bootstrap() { const app = await NestFactory.create(AppModule); await app.listen(3000); }
который запускает приложение.
./core/nest-application.ts
import { HttpServer } from '../common/interfaces/http-server.interface'; import { Resolver } from './router/interfaces/resolver.interface'; import { addLeadingSlash } from '../common/utils/shared.utils'; import { NestContainer } from './injector/container'; import { RoutesResolver } from './router/routes-resolver'; export class NestApplication { private readonly routesResolver: Resolver; public httpServer: any; constructor( private readonly container: NestContainer, private readonly httpAdapter: HttpServer, ) { this.registerHttpServer(); this.routesResolver = new RoutesResolver( this.container, ); } public registerHttpServer() { this.httpServer = this.createServer(); } /** * Начинает процесс инициализации выбранного http сервера */ public createServer<T = any>(): T { this.httpAdapter.initHttpServer(); return this.httpAdapter.getHttpServer() as T; } public async init(): Promise<this> { this.httpAdapter.registerBodyParser(); await this.registerRouter(); return this; } /** * Метод, с помощью которого запускается приложение Nest. * Он запускает процесс инициализации http сервера, регистрации * созданных роутеров, и запуска сервера на выбранном порте. */ public async listen(port: number | string) { await this.init(); this.httpAdapter.listen(port); return this.httpServer; } /** * Метод, который запускает регистрацию роутеров, * которые были созданы с помощью декораторов http методов, * таких как post и get. */ public async registerRouter() { const prefix = '' const basePath = addLeadingSlash(prefix); this.routesResolver.resolve(this.httpAdapter, basePath); } }
И это подводит нас к роутерам, а именно к классу RoutesResolver.
./core/router/routes-resolver.ts
import { NestContainer } from '../injector/container'; import { Resolver } from '../router/interfaces/resolver.interface'; import { MODULE_PATH } from '../../common/constants'; import { HttpServer } from '../../common/interfaces/http-server.interface'; import { InstanceWrapper } from '../injector/instance-wrapper'; import { RouterExplorer } from './router-explorer'; export class RoutesResolver implements Resolver { private readonly routerExplorer: RouterExplorer; constructor( private readonly container: NestContainer, ) { this.routerExplorer = new RouterExplorer( this.container, ); } /** * Для каждого модуля сначала находит базовый путь, который * указывается в декораторе Module, * и передает его и контроллеры в метод registerRouters */ public resolve(applicationRef: any, basePath: string): void { const modules = this.container.getModules(); modules.forEach(({ controllers, metatype }) => { let path = metatype ? this.getModulePathMetadata(metatype) : undefined; path = path ? basePath + path : basePath; this.registerRouters(controllers, metatype.name, path, applicationRef); }); } /** * Для каждого контроллера в модуле, запускает метод explore * класса routerExplorer, который отвечает за всю логику * регистрации роутеров */ public registerRouters( routes: Map<string, InstanceWrapper<any>>, moduleName: string, basePath: string, applicationRef: HttpServer, ) { routes.forEach(instanceWrapper => { const { metatype } = instanceWrapper; // Находит путь для декоратора контроллера, например @Controller('cats') const paths = this.routerExplorer.extractRouterPath( metatype as any, basePath, ); // Если путь был передан как @Controllers('cats'), то будет вызвано один раз. // Дело в том, что reflect возвращает массив paths.forEach(path => { this.routerExplorer.explore( instanceWrapper, moduleName, applicationRef, path, ); }); }); } private getModulePathMetadata(metatype: object): string | undefined { return Reflect.getMetadata(MODULE_PATH, metatype); } }
Код выше делает так, чтобы для каждого контроллера был вызван метод explore класса RouterExplorer. Класс RouterExplorer реализует основную логику регистрации роутеров. Он создает http методы, добавляет контроллеры в качестве их callbacks, привязывает эти контроллеры к пространству модуля, в котором он находится, и реализует функциональн��сть ответов и их обработки для запросов.
./core/router/routes-explorer.ts
import { NestContainer } from '../injector/container'; import { RouterProxyCallback } from './router-proxy'; import { addLeadingSlash } from '../../common/utils/shared.utils'; import { Type } from '../../common/interfaces/type.interface'; import { Controller } from '../../common/interfaces/controller.interface'; import { PATH_METADATA, METHOD_METADATA, ROUTE_ARGS_METADATA, PARAMTYPES_METADATA } from '../../common/constants'; import { RequestMethod } from '../../common/enums/request-method.enum'; import { HttpServer } from '../../common/interfaces/http-server.interface'; import { InstanceWrapper } from '../injector/instance-wrapper'; import { RouterMethodFactory } from '../helpers/router-method-factory'; import { isConstructor, isFunction, isString, } from '../../common/utils/shared.utils'; import { RouteParamtypes } from '../../common/enums/route-paramtypes.enum'; export interface RoutePathProperties { path: string[]; requestMethod: RequestMethod; targetCallback: RouterProxyCallback; methodName: string; } export class RouterExplorer { private readonly routerMethodFactory = new RouterMethodFactory(); constructor ( private readonly container: NestContainer, ) { } public explore<T extends HttpServer = any>( instanceWrapper: InstanceWrapper, module: string, router: T, basePath: string, ) { const { instance } = instanceWrapper; const routePaths: RoutePathProperties[] = this.scanForPaths(instance); // Для каждого метода контроллера запускает регистрацию роутеров (routePaths || []).forEach((pathProperties: any) => { this.applyCallbackToRouter( router, pathProperties, instanceWrapper, module, basePath, ); }) } /** * Метод, который сканирует контроллер, и находит у него методы * запроса с определенными путями, например метод, на который * навешен декоратор @post('add_to_database'). * В таком случае эта функция возвращает массив методов контроллера * с путями, телами этих методов, методом request и именами, которые * получаются в методе exploreMethodMetadata */ public scanForPaths( instance: Controller, ): RoutePathProperties[] { const instancePrototype = Object.getPrototypeOf(instance); let methodNames = Object.getOwnPropertyNames(instancePrototype); const isMethod = (prop: string) => { const descriptor = Object.getOwnPropertyDescriptor(instancePrototype, prop); if (descriptor?.set || descriptor?.get) { return false; } return !isConstructor(prop) && isFunction(instancePrototype[prop]); }; return methodNames.filter(isMethod).map(method => this.exploreMethodMetadata(instance, instancePrototype, method)) } /** * Для определенного метода контроллера возвращает его свойства, * для метода scanForPaths */ public exploreMethodMetadata( instance: Controller, prototype: object, methodName: string, ): RoutePathProperties { const instanceCallback = (instance as any)[methodName]; const prototypeCallback = (prototype as any)[methodName]; const routePath = Reflect.getMetadata(PATH_METADATA, prototypeCallback); const requestMethod: RequestMethod = Reflect.getMetadata( METHOD_METADATA, prototypeCallback, ); const path = isString(routePath) ? [addLeadingSlash(routePath)] : routePath.map((p: string) => addLeadingSlash(p)); return { path, requestMethod, targetCallback: instanceCallback, methodName, }; } private applyCallbackToRouter<T extends HttpServer>( router: T, pathProperties: RoutePathProperties, instanceWrapper: InstanceWrapper, moduleKey: string, basePath: string, ) { const { path: paths, requestMethod, targetCallback, methodName, } = pathProperties; const { instance } = instanceWrapper; // Получает определенный http метод const routerMethod = this.routerMethodFactory .get(router, requestMethod) .bind(router); // Создает callback для определенного метода const handler = this.createCallbackProxy( instance, targetCallback, methodName, ); // Если декоратор используется как @Post('add_to_database'), // то будет вызвано один раз для этого пути. paths.forEach(path => { const fullPath = this.stripEndSlash(basePath) + path; // Региструет http метод. Сопоставляет путь метода, и его callback, // полученный из контроллера. Ответ же производится reply методом, // реализованным в классе ExpressAdapter routerMethod(this.stripEndSlash(fullPath) || '/', handler); }); } public stripEndSlash(str: string) { return str[str.length - 1] === '/' ? str.slice(0, str.length - 1) : str; } public createCallbackProxy( instance: Controller, callback: (...args: any[]) => unknown, methodName: string, ) { // Достает ключи данных запроса указанных ранее в декораторах @Body() и @Param() const metadata = Reflect.getMetadata(ROUTE_ARGS_METADATA, instance.constructor, methodName) || {}; const keys = Object.keys(metadata); const argsLength = Math.max(...keys.map(key => metadata[key].index)) + 1 // Извлеченные данные из request, такие как тело и параметры запроса. const paramsOptions = this.exchangeKeysForValues(keys, metadata); const fnApplyParams = this.resolveParamsOptions(paramsOptions) const handler = <TRequest, TResponse>( args: any[], req: TRequest, res: TResponse, next: Function, ) => async () => { // так как args это объект, а не примитивная переменная, // то он передается по ссылке, а не по значению, // поэтому он изменяется, и после вызова fnApplyParams, // в args хранятся аргументы, полученные из request fnApplyParams && (await fnApplyParams(args, req, res, next)); // Здесь мы привязываем один из методов контроллера, // например, добавление данных в базу данных, и аргументы из request, // и теперь он может ими управлять, как и задумано return callback.apply(instance, args); }; const targetCallback = async <TRequest, TResponse>( req: TRequest, res: TResponse, next: Function, ) => { // Заполняется undefined для дальнейшего изменения реальными данными // из request const args = Array.apply(null, { argsLength } as any).fill(undefined); // result это экземпляр контроллера с пространством данных аргументов // из request const result = await handler(args, req, res, next)() const applicationRef = this.container.getHttpAdapterRef() if(!applicationRef) { throw new Error(`Http server not created`) } return await applicationRef.reply(res, result); } return async <TRequest, TResponse>( req: TRequest, res: TResponse, next: () => void, ) => { try { await targetCallback(req, res, next); } catch (e) { throw e } }; } /** * extractValue здесь это метод exchangeKeyForValue. * И ему передается request, для извлечения данных запроса */ public resolveParamsOptions(paramsOptions: any) { const resolveFn = async (args: any, req: any, res: any, next: any) => { const resolveParamValue = async (param: any) => { const { index, extractValue } = param; const value = extractValue(req, res, next); args[index] = value } await Promise.all(paramsOptions.map(resolveParamValue)); } return paramsOptions && paramsOptions.length ? resolveFn : null; } /** * Перебирает ключи данных запроса для вызова для каждого * метода exchangeKeyForValue, который достанет соответствующие данные, * которые были определены ранее в декораторах @Body() и @Param(), * из request. */ public exchangeKeysForValues( keys: string[], metadata: Record<number, any>, ): any[] { return keys.map((key: any) => { const { index, data } = metadata[key]; const numericType = Number(key.split(':')[0]); const extractValue = <TRequest, TResponse>( req: TRequest, res: TResponse, next: Function, ) => this.exchangeKeyForValue(numericType, data, { req, res, next, }); return { index, extractValue, type: numericType, data } }) } /** * Проверяет чему соответствует ключ данных, телу или параметрам запроса. * Это определяется в соответствующих декораторах @Body() и @Param(). * И теперь, когда запрос на соответствующий api выполнен, мы пытаемся * достать их из request, если они были переданы. */ public exchangeKeyForValue< TRequest extends Record<string, any> = any, TResponse = any, TResult = any >( key: RouteParamtypes | string, data: string | object | any, { req, res, next }: { req: TRequest; res: TResponse; next: Function }, ): TResult | null { switch (key) { case RouteParamtypes.BODY: return data && req.body ? req.body[data] : req.body; case RouteParamtypes.PARAM: return data ? req.params[data] : req.params; default: return null; } } public extractRouterPath(metatype: Type<Controller>, prefix = ''): string[] { let path = Reflect.getMetadata(PATH_METADATA, metatype); if (Array.isArray(path)) { path = path.map(p => prefix + addLeadingSlash(p)); } else { path = [prefix + addLeadingSlash(path)]; } return path.map((p: string) => addLeadingSlash(p)); } }
Что к этому стоит добавить, так это то, что в методе applyCallbackToRouter для получения http метода используется класс RouterMethodFactory, который, на самом деле, имеет всего один метод
./core/helpers/router-method-factory.ts
import { HttpServer } from '../../common/interfaces/http-server.interface'; import { RequestMethod } from '../../common/enums/request-method.enum'; export class RouterMethodFactory { public get(target: HttpServer, requestMethod: RequestMethod): Function { switch (requestMethod) { case RequestMethod.POST: return target.post; default: { return target.get; } } } }
Что ж. Если вы еще здесь, поздравляю! Мы написали все ядро нашего мини Nest фреймворка. Теперь, все, что осталось, это написать декораторы, на которых мы и пишем Nest приложение в качестве пользователей.
Начнем с декоратора @Module(), и сперва посмотрим на пример его использования из документации
import { Module } from '@nestjs/common'; import { CatsController } from './cats.controller'; import { CatsService } from './cats.service'; @Module({ controllers: [CatsController], providers: [CatsService], }) export class CatsModule {}
Мы видим, что метаданные указываются как параметры декоратора, теперь реализуем его.
./common/decorators/module.decorator.ts
import { MODULE_METADATA as metadataConstants } from '../constants'; const metadataKeys = [ metadataConstants.IMPORTS, metadataConstants.EXPORTS, metadataConstants.CONTROLLERS, metadataConstants.PROVIDERS, ]; /** * Проверяет, чтобы были указаны только правильные массивы, * соответствующие metadataKeys */ export function validateModuleKeys(keys: string[]) { const validateKey = (key: string) => { if (metadataKeys.includes(key)) { return; } throw new Error(`NOT INVALID KEY: ${key}`); }; keys.forEach(validateKey); } /** * Сохраняет зависимости в объект Reflect. * Где property название одной из зависимости, * например controllers. Именно благодаря этому, * у нас есть возможность извлекать данные после. */ export function Module(metadata: any): ClassDecorator { const propsKeys = Object.keys(metadata); validateModuleKeys(propsKeys); return (target: Function) => { for (const property in metadata) { if (metadata.hasOwnProperty(property)) { Reflect.defineMetadata(property, (metadata as any)[property], target); } } }; }
Довольно не сложно, не так ли? Действительно, декораторы одна из довольно простых частей Nest.
Теперь рассмотрим декоратор @Controller(), который, все, что делает, это сохраняет базовый путь контроллера, ведь сам контроллер уже сохранен в Reflect по модулю, в котором он используется.
./common/decorators/controller.decorator.ts
import { PATH_METADATA } from "../constants"; import { isUndefined } from "../utils/shared.utils"; export function Controller( prefix?: string, ): ClassDecorator { const defaultPath = '/'; const path = isUndefined(prefix) ? defaultPath : prefix return (target: object) => { Reflect.defineMetadata(PATH_METADATA, path, target); }; }
Помните про декоратор @Injectable(), который якобы помечает класс как провайдер? Как уже написано выше, он лишь устанавливает время жизни провайдера. Класс помечается как провайдер, только если он передается в массив providers соответствующего модуля. И хоть мы не реализовали возможность изменения времени жизни для провайдера, но для полноты, все равно рассмотрим этот декоратор.
./common/decorators/injectable.decorator.ts
import { SCOPE_OPTIONS_METADATA } from '../constants'; export enum Scope { DEFAULT, TRANSIENT, REQUEST, } export interface ScopeOptions { scope?: Scope; } export type InjectableOptions = ScopeOptions; export function Injectable(options?: InjectableOptions): ClassDecorator { return (target: object) => { Reflect.defineMetadata(SCOPE_OPTIONS_METADATA, options, target); }; }
Теперь у нас осталось всего четыре декоратора для реализации, для данных запроса, а именно @Body() и @Param(), и для http методов, @Post() и @Get().
Сперва рассмотрим первые два.
./common/decorators/route-params.decorator.ts
import { ROUTE_ARGS_METADATA } from "../constants"; import { RouteParamtypes } from "../enums/route-paramtypes.enum"; import { isNil, isString } from "../utils/shared.utils"; /** * Здесь используется неизменяемость данных, для того, чтобы * использовать один метод для нескольких типов запроса. */ const createPipesRouteParamDecorator = (paramtype: RouteParamtypes) => ( data?: any, ): ParameterDecorator => (target, key, index) => { const hasParamData = isNil(data) || isString(data); const paramData = hasParamData ? data : undefined; const args = Reflect.getMetadata(ROUTE_ARGS_METADATA, target.constructor, key) || {}; // Где paramtype это body или param, а index его // положение в параметрах функции, где находится декоратор, // для правильного присвоения после получения из request Reflect.defineMetadata( ROUTE_ARGS_METADATA, { ...args, [`${paramtype}:${index}`]: { index, data: paramData, }, }, target.constructor, key, ); }; export function Body( property?: string, ): ParameterDecorator { return createPipesRouteParamDecorator(RouteParamtypes.BODY)( property, ); } export function Param( property?: string, ): ParameterDecorator { return createPipesRouteParamDecorator(RouteParamtypes.PARAM)( property, ); }
И последнее, декораторы post и get, которые сохраняют в объект Reflect для определенных методов контроллеров их пути и методы запроса.
./common/decorators/request-mapping.decorator.ts
import { METHOD_METADATA, PATH_METADATA } from '../constants'; import { RequestMethod } from '../enums/request-method.enum'; export interface RequestMappingMetadata { path?: string | string[]; method?: RequestMethod; } const defaultMetadata = { [PATH_METADATA]: '/', [METHOD_METADATA]: RequestMethod.GET, }; export const RequestMapping = ( metadata: RequestMappingMetadata = defaultMetadata, ): MethodDecorator => { const pathMetadata = metadata[PATH_METADATA]; const path = pathMetadata && pathMetadata.length ? pathMetadata : '/'; const requestMethod = metadata[METHOD_METADATA] || RequestMethod.GET; return ( target: object, key: string | symbol, descriptor: TypedPropertyDescriptor<any>, ) => { Reflect.defineMetadata(PATH_METADATA, path, descriptor.value); Reflect.defineMetadata(METHOD_METADATA, requestMethod, descriptor.value); return descriptor; }; }; const createMappingDecorator = (method: RequestMethod) => ( path?: string | string[], ): MethodDecorator => { return RequestMapping({ [PATH_METADATA]: path, [METHOD_METADATA]: method, }); }; /** * Обработчик маршрута (метод) Decorator. Направляет запросы HTTP POST по указанному пути. * * @publicApi */ export const Post = createMappingDecorator(RequestMethod.POST); /** * Обработчик маршрута (метод) Decorator. Направляет запросы HTTP GET по указанному пути. * * @publicApi */ export const Get = createMappingDecorator(RequestMethod.GET);
Хорошая работа, наш мини Nest готов!
Теперь мы можем создать директорию project-view на уровне других директорий nest, и написать простое приложение
./project-view/main.ts
import { NestFactory } from '../core'; import { AppModule } from './app.module'; async function bootstrap() { const app = await NestFactory.create(AppModule); await app.listen(3000); } bootstrap();
./project-view/app.controller.ts
import { Controller, Get, Post, Body, Param } from '../common'; import { AppService } from './app.service'; @Controller() export class AppController { constructor(private readonly appService: AppService) {} @Get() getHello(): string { return this.appService.getHello(); } @Post('body/:id') recieveBody(@Body() data: any, @Param('id') id: string) { return 'body: ' + data.data + ' has been received and id: ${id}'; } }
./project-view/app.service.ts
import { Injectable } from '../common'; @Injectable() export class AppService { getHello(): string { return 'Hello World!'; } }
./project-view/app.module.ts
import { Module } from '../common'; import { AppController } from './app.controller'; import { AppService } from './app.service'; @Module({ imports: [], controllers: [AppController], providers: [AppService], }) export class AppModule {}
После чего инициализировать typescript проект, создав tsconfig.json файл с помощью команды
tsc --init
и настроить его как-то вот так
{ "compilerOptions": { "target": "es2017", "experimentalDecorators": true, "emitDecoratorMetadata": true, "module": "commonjs", "outDir": "./", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, "skipLibCheck": true, }, "include": ["packages/**/*", "integration/**/*", "./core/", "./common/", "./project-view/", "./platform-express/"], "exclude": ["node_modules", "**/*.spec.ts"] }
Теперь мы можем скомпилировать typescript в js с помощью следующей команды
tsc --build
перейти в директорию нашего пользовательского приложения
cd project-view
и запустить скомпилированный входной файл
node main.js
Вы можете проверить результат, например, через postman, и поиграться с body и params для post запроса.
Теперь вы знаете Nest чуть лучше :)
