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

от автора

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 адаптируются к широкому спектру прикладных задач. В них легко переносится утилитная и бизнесовая логика. Их несложно расширять, композировать, совмещая несколько сценариев. И в этом, без сомнения, одна из сильных сторон фреймворка.

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

ссылка на оригинал статьи https://habr.com/ru/company/qiwi/blog/510864/


Комментарии

Добавить комментарий

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