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

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

Источник: habr.com
К списку статей
Опубликовано: 14.07.2020 12:05:49
0

Сейчас читают

Комментариев (0)
Имя
Электронная почта

Блог компании qiwi

Node.js

Typescript

Decorator

Metadata

Nestjs

Категории

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

© 2006-2020, personeltest.ru