Каждый Ангуляр разработчик видел декораторы в тайпскрипт коде. Их используют, чтобы описать Модули, сконфигурировать Dependency Injection или настроить компонент. Другими словами, декораторы используются, чтобы описать дополнительную информацию, или метаданные, для фреймворка или компилятора (в случае Ангуляра). При чем, Ангуляр лишь один из примеров. Существуют многие другие библиотеки, использующие декораторы для простоты и наглядности кода, как декларативный подход. Как .NET разработчик в прошлом, я вижу много сходства между TS декораторами и .NET аттрибутами. Наконец, набирающий популярность NestJS фреймворк для бекенд приложений (абстракция над Node), также построен на интенсивном использовании декораторов и декларативном подходе. Как это все работает и каким образом использовать декораторы в своем коде, чтобы он был более удобным и читабельным? Мы все понимаем, что после компиляции TS кода мы получаем Javascript код. В котором нет понятия декоратор, как и многих других Typescript особенностей. Поэтому для меня наиболее интересным является вопрос, во что превращается декоратор после компиляции. Занимаясь этим вопросом, я сделал выступление на митапе в Минске и хочу поделиться статьей.
Содержание
- Примеры декораторов
- Общая информация о декораторах
- Декораторы для функций
- Декораторы для классов
- Декораторы для полей или свойств класса
- Декораторы для параметров — домашняя работа
- Существующие библиотеки
- Заключение
Примеры декораторов
Наиболее яркие на мой взгляд примеры работы с декораторами можно найти в Ангуляре. Давайте взглянем на пару из них, перед погружением в технические подробности.
- for Module declaration
@NgModule({
imports: [
CommonModule,
],
exports: [
],
})
export class NbThemeModule {}
- for component declaration
@Component({
selector: 'nb-card-header',
template: `<ng-content></ng-content>`,
})
export class NbCardHeaderComponent {}
Из примеров видно, что чаще всего декораторы позволяют добавить полезной информации, метаданных, классу.
Чтобы начать пользоваться декораторами, стоит проверить tsconfig.json
файл, в нем должны быть включены опции emitDecoratorMetadata
и experimentalDecorators
, так как это все еще экспериментальная функциональность.
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "es2017",
},
}
Общая информация о декораторах
Согласно документации, Декоратор — это специальный вид описания, который можно присоединить к декларации класса, метода, get свойства, свойства или параметра. Декораторы используют форму @expression
, то есть при использовании ставится символ @
перед именем декоратора. Хотя по сути expression
может быть любая функция. Эта функция будет вызвана в процессе выполнения программы, причем вызывающий код добавит аргументы с информацией о том объекте, который был задекорирован.
Другими словами, декоратор — это способ добавить дополнительное поведение классу, функции, свойству или параметру. Это можно отнестик парадигме мета-программирования или декларативного программирования.
Важно, что декоратор — это лишь функция. При использовании, среда исполнения сначала вызовет функцию-декоратор, и только потом будет выполнен основной сценарий объекта (если код декоратора содержит этот вызов). При наличии нескольких декораторов, они будет вызваны по очереди, сверху вниз.
Декораторы для функций
Начнем с наиболее очевидного случая — декоратора для функции. Определение в самом Typescript выглядит следующим образом:
declare type MethodDecorator =
<T>(
target: Object,
propertyKey: string | symbol,
descriptor: TypedPropertyDescriptor<T>)
=> TypedPropertyDescriptor<T> | void;
Это функция, принимающая несколько аргументов. А именно:
- объект, у которого данная функция была вызвана
- имя функции
- дескриптор функции
Дескриптор выглядит так:
interface TypedPropertyDescriptor<T> {
enumerable?: boolean;
configurable?: boolean;
writable?: boolean;
value?: T;
get?: () => T;
set?: (value: T) => void;
}
По сути, дескриптор нужен, чтобы получить доступ к исходной функции и иметь возможность ее вызвать из кода декоратора.
Стоит отметить, что функция-декоратор будет вызвана не вашим кодом, компилятор сам подставит в нее нужные аргументы. В примере чуть ниже мы посмотрим скомпилированный пример Javascript кода.
Чтобы рассмотреть пример, нам понадобится какой-нибудь понятный и полезный сценарий. Например — измерение производительности функции.
class TestServiceDeco {
@LogTime()
function testLogging() {
...
}
}
Декоратор для функции, свойства или параметра функции можно применить только внутри некоего класса. В настоящее время компилятор Typescript не позволит применить декоратор для функции, которая написана вне класса. Насколько я понимаю, это связано с необходимостью привязаться к какому-то хранилищу метаданных, необходимо наличие прототипа.
Для нашего сценария код декоратора может выглядеть таким образом:
function LogTime() {
return (target: Object, propertyName: string, descriptor: TypedPropertyDescriptor<Function>) => {
const method = descriptor.value;
descriptor.value = function(...args) {
console.time(propertyName || 'LogTime');
const result = method.apply(this, args);
console.timeEnd(propertyName || 'LogTime');
return result;
};
};
}
Как я сказал ранее, декоратор — это функция, которая возвращает функцию определенного типа. В примере видны аргументы этой функции — target, propertyName и дескриптор функции. Их компилятор подставит в вызывающий код.
Дескриптор функции здесь позволяет переопределить поведение — подменить искомую функцию на новую, которая уже следует заданной декоратором логике. Наша логика подразумевает засечь момент старта функции, и ее завершения, и вывести разницу в консоль. Конечно не стоит забывать вернуть значение искомой функции.
Скомпилированнй Javascript код будет выглядеть следующим образом
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
function LogTime() {
return (target, propertyName, descriptor) => {
const method = descriptor.value;
descriptor.value = function (...args) {
console.time(propertyName || 'LogTime');
const result = method.apply(this, args);
console.timeEnd(propertyName || 'LogTime');
return result;
};
};
}
exports.LogTime = LogTime;
Тут никаких сюрпризов, все примерно как и в Typescript коде. А вот код вызывающий уже интереснее:
Object.defineProperty(exports, "__esModule", { value: true });
const log_time_decorator_1 = require("../src/samples/log-time.decorator");
class TestServiceDeco {
testLogging() {
... }
}
__decorate([
log_time_decorator_1.LogTime(),
__metadata("design:type", Function),
__metadata("design:paramtypes", []),
__metadata("design:returntype", void 0)
], TestServiceDeco.prototype, "testLogging", null);
Тут уже видна системная функция __decorate
, в которую передается наш декоратор вместе с дополнительными аргументами.
Заметим, что в качестве
target
аргумент подставленprototype
класса, в котором определена функция.
Подставленный компилятором код, вызывающий функцию __decorate
, будет выполнен в процессе интерпретации кода, сразу после интерпретации класса. Но сам код нашего декоратора будет вызываться каждый раз, когда вызывается исходная функция. Это ключевое отличие от следующего вида декораторов.
Декораторы для классов
Этот вид декораторов как правило используется, чтобы добавить классу метаданных. Где и как они будут использованы — уже другой вопрос. В Ангуляре — это подсказки компилятору. Но есть и более понятные сценарии — например Dependency Injection. Давайте напишем свой простой и легкий контейнер зависимостей на основе декоратора класса. Например, мы бы могли его использовать следующим образом:
@CustomBehavior({
singleton: false,
})
class TestServiceDeco {
constructor() {
console.log('TestServiceDeco ctor');
}
}
Прежде чем приступить, посмотрим формальное определение декоратора класса в Typescript:
declare type ClassDecorator =
<TFunction extends Function>(target: TFunction)
=> TFunction | void;
Таким образом наш декоратор будет выглядить таким образом:
import 'reflect-metadata';
interface Metadata {
singleton?: boolean;
}
function CustomBehavior(metadata: Metadata) {
return function(ctor: Function) {
Reflect.defineMetadata('metadataKey', metadata, ctor);
}
}
Мы определили интерфейс для придания структуры нашим метаданным. Самая важная для нас информация — является ли данный класс singelton-ом или же его можно инстанциировать многократно. Дальше мы просто сохраняем данную информацию для дальнейшего использования.
Пара важных моментов:
- сейчас в качестве target аргумента мы ожидаем конструктор класса
- мы начали использовать
reflect-metadata
Reflect-metadata это хранилище метаданных в Typescript. Его смысл тот же, что и в других языках — хранить информацию о типах для работы с ней в процессе выполнения программы. В нашем случае, мы добавили свои метаданные класса, которыми будем пользоваться в своем контейнере зависимостей.
import 'reflect-metadata';
const instancesMap: Map<Object, Object> = new Map<Object, Object>();
function getInstance<T>(tType: new () => T): T {
let metadata = Reflect.getMetadata('metadataKey', tType) as Metadata;
if (metadata.singleton) {
if (!instancesMap.has(tType)) {
instancesMap.set(tType, new tType());
}
return instancesMap.get(tType) as T;
} else {
return new tType() as T;
}
}
- наш контейнер состоит из единственной функции
getInstance
, в которую будет передаваться тип, класс, экземпляр которого необходимо создать - с помощью
Reflect.getMetadata
мы получаем информацию, которую передали с помощью декоратора. Так как эта функция возвращаетany
, нам приходится добавлятьas Metadata
для приведения к своему типу - так как нам необходимо создавать экземпляры, нам нужен конструктор. Поэтому накладываем ограничение
tType: new () => T
- и конечно нужен какой-то способ хранения созданных экземпляров, в нашем простом случае это Map
Вуаля, в несколько сравнительно простых строк кода мы написали свой контейнер зависимостей и декоратор для определения, может ли класс иметь несколько экземпляров или только один.
Теперь можем где угодно в коде вызывать getInstance
, а как может быть создан класс уже прописано в его декораторе.
Я не стал приводить скомпилированный код данного декоратора и класса, он не сильно отличается от прошлого примера. Но ключевым моментом является тот факт, что код декоратора класса будет выполнен только один раз при интерпретации Javascript кода этого файла.
Декораторы для полей или свойств класса
Еще одна область применения декораторов относится к свойствам класса. Тут открывается целый спектр прикладных задач, но наиболее насущной, на мой взгляд, является валидация данных. Представьте, есть класс Person
с полем Age
, значения которого по логике приложения должно быть между 18 и 60. Давайте сделаем данную проверку с помощью декоратора:
class Person {
@Age(18, 60)
age: number;
}
Снова обратимся к формальному определению:
declare type PropertyDecorator =
(target: Object, propertyKey: string | symbol) => void;
Наш декоратор для валидации выглядит следующим образом:
import 'reflect-metadata';
function Age(from: number, to: number) {
return function (object: Object, propertyName: string) {
const metadata = {
propertyName: propertyName,
range: { from, to },
};
Reflect.defineMetadata(`validationMetadata_${propertyName}`, metadata, object.constructor);
};
}
И снова мы видим, что основной логики тут нет. Мы просто сохраняем нужную нам информацию в хранилище метаданных. Все потому, что это код, как и код декоратора класса, будет выполнен только один раз при прочтении кода. До того, как конструктор класса был вызван.
Скомпилированный код:
class Person {
...
}
__decorate([
age_decorator_1.Age(18, 60),
__metadata("design:type", Number)
], Person.prototype, "age", void 0);
Видим, что сразу после определения класса компилятор поместил свою функцию __decorate
, в которую передал наш декоратор с параметрами.
Это своеобразное подтверждение того, что основная задача декораторов — сделать код более удобным к прочтению, информативно богатым. В случае валидации — описать правила проверок в том же месте, где и сам класс, причем в удобной форме.
Возвращаясь к валидации, ее необходимо описать отдельно:
function validate<T>(object: T) {
const properties = Object.getOwnPropertyNames(object);
properties.forEach(propertyName => {
let metadata = Reflect.getMetadata(metaKey + propertyName, object.constructor);
if (metadata && metadata.range) {
const value = object[metadata.propertyName];
if (value < metadata.range.from || value > metadata.range.to) {
throw new Error('Validation failed');
}
}
});
}
В примере, конечно же, мы делаем одну единственную проверку. Реальный сценарий будет несколько сложнее.
Пример вызова:
const person = new Person();
person.age = 40;
validate(person);
// > validation passed
person.age = 16;
validate(person);
// > validation error
Декораторы для параметров функций
Если вы дочитали до этого места, я уверен, что вы получили достаточно информации и понимания, чтобы сделать маленькую домашнюю работу и разобраться в декораторах для параметров функций самостоятельно.
Формально же, декоратор для параметра функции выглядит таким образом:
declare type ParameterDecorator = (target: Object, propertyKey: string | symbol, parameterIndex: number) => void;
Существующие библиотеки
Class-Validator
Я бы хотел привести библиотеку Class-Validator, использование которой для меня лично очень удобно. Ее декораторы постоянно используются в коде моих проектов.
export class Post {
@Length(10, 20)
title: string;
@IsInt()
@Min(0)
@Max(10)
rating: number;
@IsEmail()
email: string;
}
...
validate(object).then(errors => { // array of validation errors
if (errors.length > 0) {
console.log("validation failed. errors: ", errors);
} else {
console.log("validation succeed");
}
});
Думаю, из примера все понятно. Прочие же детали можно найти в репозитории.
Интересный факт в том, что именно эта библиотека используется по умолчанию в фреймворке NestJS когда применяется @UsePipes(new ValidationPipe())
для валидации всех входящих http запросов.
Заключение
Потенциал Typescript по созданию удобного, простого для прочтения и надежного кода очень велик. Его можно использовать сразу в нескольких парадигмах, в том числе для мета-программирования. Декораторы, даже будучи экспериментальной функциональностью, дают возможности для решения целого спектра прикладных задач, помогают сделать код простым для прочтения и более удобным для работы, помогают в решении таких задач как логирование, измерение производительности, проверки, дополнительное поведение… Чем и пользуются такие фреймворки как Angular и NestJS. Понимание декораторов помогает писать код более красиво (пусть это и субьективная оценка).
Пишите код, улучшайте его, вычишайте его, делайте его красивым, тестируйте и наконец наслаждайтесь как проделанной работой, так и самим процессом создания кода!
p.s. пока писал (вернее переводил свою же статью), нашел еще одну тут же на хабре, которая хорошо раскрывает тему декораторов. Привожу ссылку
Специально для сайта ITWORLD.UZ. Новость взята с сайта Хабр