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