- PVSM.RU - https://www.pvsm.ru -

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

image [1]

Введение

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

Декораторы или аннотации — наследники аспектов [3], которые позволяют декларативно описывать логику, модифицировать поведение классов, их свойств, аргументов и методов.

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

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

Возьмем простейший http-контроллер. Допустим, нам требуется, чтобы только определенные пользователи могли воспользоваться его методами. Для этого кейса в Nest есть встроенная функциональность гардов [4].

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 в этом случае предлагает использовать [5] декоратор @SetMetadata. Как понятно из названия, он позволяет ассоциировать метаданные с декорируемыми объектами — классами или методами.

Для доступа к этим данным используется экземпляр класса Reflector, но можно и напрямую через reflect-metadata [6].

@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 [7].

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 [8] не содержит этот признак в явном виде, поэтому его приходится выводить из сигнатуры вызова.

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 [9], qiwi/decorator-utils [10].
Это несколько улучшает читаемость.

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 [11] делегирует это вышестоящему декоратору посредством метаданных.

И далее именно декоратор метода / класса будет резолвить аргументы вызова (через очень длинную цепочку от ParamsTokenFactory [12] до RouterExecutionContext [13]).

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

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

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

Без знания логики компилятора возникает неопределенность. Правильнее, наверное, было бы бросить ошибку.

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

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

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

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

Автор: Максим Письменский

Источник [18]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/node-js/354993

Ссылки в тексте:

[1] Image: https://habr.com/ru/company/qiwi/blog/510864/

[2] набирающий популярность: https://www.npmtrends.com/@nestjs/common

[3] аспектов: https://en.wikipedia.org/wiki/Aspect-oriented_programming

[4] гардов: https://docs.nestjs.com/guards

[5] предлагает использовать: https://docs.nestjs.com/guards#setting-roles-per-handler

[6] reflect-metadata: https://github.com/rbuckton/reflect-metadata

[7] applyDecorators: https://docs.nestjs.com/custom-decorators#decorator-composition

[8] декораторов в typescript: https://www.typescriptlang.org/docs/handbook/decorators.html

[9] lukehorvat/decorator-utils: https://github.com/lukehorvat/decorator-utils

[10] qiwi/decorator-utils: https://github.com/qiwi/decorator-utils

[11] createParamDecorator: https://docs.nestjs.com/custom-decorators#param-decorators

[12] ParamsTokenFactory: https://github.com/nestjs/nest/blob/ae1988be3c681fcc6d985d795107b18313fe1358/packages/core/pipes/params-token-factory.ts

[13] RouterExecutionContext: https://github.com/nestjs/nest/blob/bf10f001c855997d2887129c467b313101d7e219/packages/core/router/router-execution-context.ts#L383

[14] соответствующий декоратор: https://github.com/qiwi/nestjs-enterprise/tree/master/packages/common#requestsize

[15] отдельными портами: https://github.com/qiwi/nestjs-enterprise/tree/master/packages/common#port

[16] обсахарить: https://github.com/qiwi/json-rpc/tree/master/packages/nestjs

[17] JSON-RPC: https://en.wikipedia.org/wiki/JSON-RPC

[18] Источник: https://habr.com/ru/post/510864/?utm_source=habrahabr&utm_medium=rss&utm_campaign=510864