Декораторы — инструмент декларативного программирования. С их помощью можно легко и элегантно добавить к классам и членам класса метаданные. На основе этих метаданных можно расширять или изменять поведения классов и членов класса, не изменяя при этом кодовую базу, к которой применен декоратор. Саму технологию можно отнести к мета-программированию или декларативному программированию.
В рамках этой статьи разбирается несколько примеров из реальных проектов, где применение декораторов сильно упростило код для понимания и исключило его дублирование.
Какую проблему решают декораторы?
С помощью декораторов мы можем избежать “дублирования” кода, инкапсулировав сквозную функциональность в отдельный модуль. Убрать лишний “шум” в коде, что позволит сфокусироваться автору на бизнес логике приложения.
Сквозная функциональность — функциональность, которая распределена по всей кодовой базе. Как правило, эта функциональность не зависит от предметной области вашего проекта. К ней можно отнести следующие примеры:
-
Логирование
-
Кеширование
-
Валидация
-
Форматирование
-
и т.д.
Для работы со сквозной функциональностью существует целая парадигма — Аспектно-ориентированное программирование (AOP). Про ее реализацию в JavaScript советую прочитать в этой замечательной статье. Так же существуют замечательные библиотеки, реализующие AOP в JavaScript:
Если Вам интересна тема AOP, советую поставить эти пакеты и поиграться с их функциональностью.
В этой статье я попытался показать, как можно решить описанные выше проблемы встроенной в TypeScript функциональностью — декораторы.
Для прочтения этой статьи предполагается, что вы уже имеет опыт использования react, mobx и typescript, т.к. я не буду вдаваться в подробности этих технологий.
Немного о декораторах в общем
В TypeScript декоратором является функция.
Форма применения: @funcName. Где funcName— имя функции описывающее декоратор. После прикреплении декоратора к члену класса, а затем его вызове, сначала будут выполняться декораторы, а затем уже код класса. Однако декоратор может прервать поток выполнения кода на своем уровне, так что, основной код класса в конечном счете не будет выполнен. Если к члену класса прикреплены несколько декораторов, их выполнение происходит сверху вниз по очереди.
Декораторы все еще являются экспериментальной функцией TypeScript. По этому, для их использования, вам нужно добавить в ваш tsconfig.json следующую настройку:
{
"compilerOptions": {
"experimentalDecorators": true,
},
}
Функция-декоратор вызывается компилятором, и компилятор сам подставляет в нее нужные аргументы.
Сигнатура этой функции для методов класса следующая:
funcName<TCls, TMethod>(target: TCls, key: string, descriptor: TypedPropertyDescriptor<TMethod>): TypedPropertyDescriptor<TMethod> | void
Где:
-
target — объект, для которого будет применен декоратор
-
key — имя метода класса, который декорируется
-
descriptor — дескриптор метода класса.
С помощью дескриптора, мы можем получить доступ к исходному методу объекта.
С помощью шаблонной типизации вы можете исключить неверное использование декоратора. Например, вы можете ограничить применение декоратора методами класса, у которых первый аргумент всегда имеет строковый тип, определив тип дескриптора следующим образом:
type TestDescriptor = TypedPropertyDescriptor<(id: string, ...args: any[]) => any>;
В наших примерах мы будем использовать фабрики декораторов. Фабрика декораторов — это функция которая возвращает вызываюмую декоратором во время выполнения функцию.
function format(pattern: string) {
// это фабрика декораторов и она возвращает функцию-декоратора
return function (target) {
// это декоратор. Здесь будет код,
// который что то делает с target и pattern
};
}
Подготовительные работы
При рассмотрения примеров ниже мы будем использовать 2 модели данных:
export type Product = {
id: number;
title: string;
};
export type User = {
id: number;
firstName: string;
lastName: string;
maidenName: string;
}
Во всех функциях декораторах для дескриптора мы будем использовать тип PropertyDescriptor, который является эквивалентом TypedPropertyDescriptor<any>.
Добавим функцию-хелпер createDecorator, которая поможет нам сократить синтаксический сахар создания декораторов:
export type CreateDecoratorAction<T> = (self: T, originalMethod: Function, ...args: any[]) => Promise<void> | void;
export function createDecorator<T = any>(action: CreateDecoratorAction<T>) {
return (target: T, key: string, descriptor: PropertyDescriptor) => {
const originalMethod = descriptor.value; // ссылка на оригинальный метод класса
// переопределяем метод класса
descriptor.value = async function (...args: any[]) {
const _this = this as T;
await action(_this, originalMethod, ...args);
};
};
}
Проект построен на React + TypeScript. Для отображения состояние приложения на экран используется замечательная библиотека Mobx. Ниже в примерах я опущу связанные с Mobx части кода, чтобы сфокусировать ваше внимание на проблематики и ее решения.
Полную рабочую версию кода, можете найти в этом репозитории.
Отображение индикатора загрузки данных
Сперва создадим класс AppStore, который будет содержать в себе все состояние нашего маленького приложения. Приложение будет состоять из двух списков — список пользователей и список товаров. Данные будут использоваться из сервиса dummyjson.
В результате рендера страницы вызываются 2 запроса на сервер для загрузки списков. AppStore выглядит следующим образом:
class AppStore {
users: User[] = [];
products: Product[] = [];
usersLoading = false;
productsLoading = false;
async loadUsers() {
if (this.usersLoading) {
return;
}
try {
this.setUsersLoading(true);
const resp = await fetch("https://dummyjson.com/users");
const data = await resp.json();
const users = data.users as User[];
this.users = users;
} finally {
this.setUsersLoading(false);
}
}
async loadProducts() {
if (this.productsLoading) {
return;
}
try {
this.setProductsLoading(true);
const resp = await fetch("https://dummyjson.com/products");
const data = await resp.json();
const products = data.users as Product[];
this.products = products;
} finally {
this.setProductsLoading(false);
}
}
private setUsersLoading(value: boolean) {
this.usersLoading = value;
}
private setProductsLoading(value: boolean) {
this.usersLoading = value;
}
}
Изменение значения флагов usersLoading и productsLoading контролирует видимость индикаторов загрузки списка. Можно увидеть в приведенном кода выше, эта функциональность в метода повторяется. Попробуем воспользоваться декораторами, чтобы убрать это дублирование. Инкапсулируем все флаги загрузки в один объект, который будет лежать в свойстве loading нашего хранилища состояния. Для этого определим интерфейс и базовый класс (для повторного использования кода по управлению состоянием загрузки флагов):
type KeyBooleanValue = {
[key: string]: boolean;
};
export interface ILoadable<T> {
loading: T;
setLoading(key: keyof T, value: boolean): void;
}
export abstract class Loadable<T> implements ILoadable<T> {
loading: T;
constructor() {
this.loading = {} as T;
}
setLoading(key: keyof T, value: boolean) {
(this.loading as KeyBooleanValue)[key as string] = value;
}
}
В случае, если у вас нет возможности использовать наследование, можете использовать интерфейс ILoadable и реализовать собственный метод setLoading.
Теперь изолируем общую функциональность контроля состояния флагов в декоратор. Для этого создадим обобщенную фабрику создания декораторов loadable используя функцию хелпер createDecorator:
export const loadable = <T>(keyLoading: keyof T) =>
createDecorator<ILoadable<T>>(async (self, method, ...args) => {
try {
if (self.loading[keyLoading]) return;
self.setLoading(keyLoading, true);
return await method.call(self, ...args);
} finally {
self.setLoading(keyLoading, false);
}
});
Фабричная функция является обобщенной и принимает на вход ключи свойств объекта, который будет лежать в свойстве loading интерфейса ILoadable. Для корректного использования этого декоратора, нужно чтобы наш класс реализовал интерфейс ILoadable. Воспользуемся наследованием от класса Loadable, Который уже реализует этот интерфейс и перепишем наш код следующим образом:
const defaultLoading = {
users: false,
products: false,
};
class AppStore extends Loadable<typeof defaultLoading> {
users: User[] = [];
products: Product[] = [];
constructor() {
super();
this.loading = defaultLoading;
}
@loadable("users")
async loadUsers() {
const resp = await fetch("https://dummyjson.com/users");
const data = await resp.json();
const users = data.users as User[];
this.users = users;
}
@loadable("products")
async loadProducts() {
const resp = await fetch("https://dummyjson.com/products");
const data = await resp.json();
const products = data.users as Product[];
this.products = products;
}
}
В качестве типа для объекта в свойстве loading мы передаем динамически вычисляемый тип typeof defaultLoading от состояния по умолчанию этого объекта — defaultLoading. Так же, присваиваем это состояние свойству loading. За счет этого, строковые ключи, которые мы передаем в декоратор loadable контролируется типизацией typescript. Как вы видите, методы loadUsers и loadProducts лучше читаются, а функциональность показа спиннеров инкапсулирована в отдельный модуль. Фабрика декораторов loadable и интерфейс ILoadable абстрагированы от конкретной реализации стора и могут использоваться в неограниченном количестве сторов в приложении.
Обработка ошибок в методе
Если вдруг, по какой либо причине, в приведенном выше примере сервис dummyjson перестанет быть доступным, наше приложение упадет с ошибкой и пользователь об этом не узнает. Давайте исправим эту ситуацию
class AppStore extends Loadable<typeof defaultLoading> {
users: User[] = [];
products: Product[] = [];
constructor() {
super();
this.loading = defaultLoading;
}
@loadable("users")
async loadUsers() {
try {
const resp = await fetch("https://dummyjson.com/users");
const data = await resp.json();
const users = data.users as User[];
this.users = users;
} catch (error) {
notification.error({
message: "Error",
description: (error as Error).message,
placement: "bottomRight",
});
}
}
@loadable("products")
async loadProducts() {
try {
const resp = await fetch("https://dummyjson.com/products");
const data = await resp.json();
const products = data.users as Product[];
this.products = products;
} catch (error) {
notification.error({
message: "Error",
description: (error as Error).message,
placement: "bottomRight",
});
}
}
}
В каждом методе, появляется блок try … catch …, где обработка ошибок происходит в блоке catch. Всплывает уведомление в правом нижнем углу с текстом ошибки. Воспользуемся силой декораторов и инкапсулируем эту обработку в отдельный модуль, сделав ее абстрактной:
export const errorHandle = (title?: string, desc?: string) =>
createDecorator(async (self, method, ...args) => {
try {
return await method.call(self, ...args);
} catch (error) {
notification.error({
message: title || "Error",
description: desc || (error as Error).message,
placement: "bottomRight",
});
}
});
Фабричная функция принимает на вход необязательные параметры — кастомный заголовок и описание ошибки, которые будут выводиться в уведомлении. Если параметры не будут заполнены то будет использоваться заголовок по умолчанию и сообщение из поля message ошибки. Используем функцию errorHandle в нашем коде:
class AppStore extends Loadable<typeof defaultLoading> {
users: User[] = [];
products: Product[] = [];
constructor() {
super();
this.loading = defaultLoading;
}
@loadable("users")
@errorHandle()
async loadUsers() {
const resp = await fetch("https://dummyjson.com/users");
const data = await resp.json();
const users = data.users as User[];
this.users = users;
}
@loadable("products")
@errorHandle()
async loadProducts() {
const resp = await fetch("https://dummyjson.com/products");
const data = await resp.json();
const products = data.users as Product[];
this.products = products;
}
}
Так просто мы добавили функциональность обработки ошибок, убрали дублирования кода и сам код методов остался простым и читаемым.
Уведомления об успешной работе метода
Допустим, что нам нужно сообщить об успешной загрузки списков пользователей и продуктов. Без декораторов код выглядел бы следующим образом:
class AppStore extends Loadable<typeof defaultLoading> {
users: User[] = [];
products: Product[] = [];
constructor() {
super();
this.loading = defaultLoading;
}
@loadable("users")
@errorHandle()
async loadUsers() {
const resp = await fetch("https://dummyjson.com/users");
const data = await resp.json();
const users = data.users as User[];
this.users = users;
notification.success({
message: "Users uploaded successfully",
placement: "bottomRight",
});
}
@loadable("products")
@errorHandle()
async loadProducts() {
const resp = await fetch("https://dummyjson.com/products");
const data = await resp.json();
const products = data.users as Product[];
this.products = products;
notification.success({
message: "Products uploaded successfully",
placement: "bottomRight",
});
}
}
Так же, инкапсулируем эту функциональность в отдельный модуль и сделаем ее абстрактной:
export const successfullyNotify = (message: string, description?: string) =>
createDecorator(async (self, method, ...args) => {
const result = await method.call(self, ...args);
notification.success({
message,
description,
placement: "bottomRight",
});
return result;
});
Фабричная функция принимает на вход обязательный параметр сообщение в уведомлении и не обязательный парметр — описание сообщения. Перепишем код с использованием этой функции:
class AppStore extends Loadable<typeof defaultLoading> {
users: User[] = [];
products: Product[] = [];
constructor() {
super();
this.loading = defaultLoading;
}
@loadable("users")
@errorHandle()
@successfullyNotify("Users uploaded successfully")
async loadUsers() {
const resp = await fetch("https://dummyjson.com/users");
const data = await resp.json();
const users = data.users as User[];
this.users = users;
}
@loadable("products")
@errorHandle()
@successfullyNotify("Products uploaded successfully")
async loadProducts() {
const resp = await fetch("https://dummyjson.com/products");
const data = await resp.json();
const products = data.users as Product[];
this.products = products;
}
}
Логирование метода
Если вы собираете данные вашего приложения и потом проводите анализ, для его оптимизации и отслеживания ошибок, то вам необходимо добавлять логи в код. Рассмотрим пример логирования — вывод в консоль. Добавим логи в наш сервис без использования декоратора:
class AppStore extends Loadable<typeof defaultLoading> {
users: User[] = [];
products: Product[] = [];
constructor() {
super();
this.loading = defaultLoading;
}
@loadable("users")
@errorHandle()
@successfullyNotify("Users uploaded successfully")
async loadUsers() {
try {
console.log(`Before calling the method loadUsers`);
const resp = await fetch("https://dummyjson.com/users");
const data = await resp.json();
const users = data.users as User[];
this.users = users;
console.log(`The method loadUsers worked successfully.`);
} catch (error) {
console.log(`An exception occurred in the method loadUsers. Exception message: `, (error as Error).message);
throw error;
} finally {
console.log(`The method loadUsers completed`);
}
}
@loadable("products")
@errorHandle()
@successfullyNotify("Products uploaded successfully")
async loadProducts() {
try {
console.log(`Before calling the method loadProducts`);
const resp = await fetch("https://dummyjson.com/products");
const data = await resp.json();
const products = data.users as Product[];
this.products = products;
console.log(`The method loadProducts worked successfully.`);
} catch (error) {
console.log(`An exception occurred in the method loadProducts. Exception message: `, (error as Error).message);
throw error;
} finally {
console.log(`The method loadProducts completed`);
}
}
}
Как видите, код в методах усложнился для восприятия и понимания. И здесь еще нет кода, который включает или отключает логи в зависимости от стейджа сборки или по каким ни будь другим критериям, что сделает код еще более сложным. Создадим универсальный декоратор для логирования.
export type LogPoint = "before" | "after" | "error" | "success";
let defaultLogPoint: LogPoint[] = ["before", "after", "error", "success"];
export function setDefaultLogPoint(logPoints: LogPoint[]) {
defaultLogPoint = logPoints;
}
export const log = (points = defaultLogPoint) =>
createDecorator(async (self, method, ...args) => {
try {
if (points.includes("before")) {
console.log(`Before calling the method ${method.name} with args: `, args);
}
const result = await method.call(self, ...args);
if (points.includes("success")) {
console.log(`The method ${method.name} worked successfully. Return value: ${result}`);
}
return result;
} catch (error) {
if (points.includes("error")) {
console.log(
`An exception occurred in the method ${method.name}. Exception message: `,
(error as Error).message
);
}
throw error;
} finally {
if (points.includes("after")) {
console.log(`The method ${method.name} completed`);
}
}
});
В этом декораторе мы определили точки для логирования, которые можно настраивать передав их типизированный массив в первый параметр фабрики декоратора. По умолчанию логируется все. Так же, функции setDefaultLogPoint можно переопределить точки логирования по умолчанию. Применим эту фабрику в нашем коде:
class AppStore extends Loadable<typeof defaultLoading> {
users: User[] = [];
products: Product[] = [];
constructor() {
super();
this.loading = defaultLoading;
}
@loadable("users")
@errorHandle()
@successfullyNotify("Users uploaded successfully")
@log()
async loadUsers() {
const resp = await fetch("https://dummyjson.com/users");
const data = await resp.json();
const users = data.users as User[];
this.users = users;
}
@loadable("products")
@errorHandle()
@successfullyNotify("Products uploaded successfully")
@log()
async loadProducts() {
const resp = await fetch("https://dummyjson.com/products");
const data = await resp.json();
const products = data.users as Product[];
this.products = products;
}
}
Такая инкапсуляция поможет гибко настраивать включение и отключение логирования в приложении. Как видите, мы добавили много функционала не изменения при этом код с бизнес логикой приложения.
Подытожим
Области применения декораторов разнообразны и потенциал их велик. С их помощью можно легко решить целый спектр прикладных задач, сделать код элегантным и простым для чтения. Благодаря декораторам, мы можем инкапсулировать повторяющийся сквозной функционал в модули, и переиспользовать в других частях проекта или других проектах. Однако в неумелых руках такой мощный инструмент может и навредить, например, изменить прямое назначение метода. Но, на мой взгляд, это не является основанием не изучать и применять эту технологию в своих проектах. Декораторы станут отличным дополнением в ваших проектах как в ОО так и в функциональной парадигме.
Репозиторий с исходным кодом можно найти тут
Ссылка на статью на английском тут
Автор:
andry36