Мощь декораторов TypeScript на живых примерах. Декорирование методов класса

от автора

Photo by TOMMY VAN KESSEL on Unsplash
Photo by TOMMY VAN KESSEL on Unsplash

Декораторы — инструмент декларативного программирования. С их помощью можно легко и элегантно добавить к классам и членам класса метаданные. На основе этих метаданных можно расширять или изменять поведения классов и членов класса, не изменяя при этом кодовую базу, к которой применен декоратор. Саму технологию можно отнести к мета-программированию или декларативному программированию.

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

Какую проблему решают декораторы?

С помощью декораторов мы можем избежать “дублирования” кода, инкапсулировав сквозную функциональность в отдельный модуль. Убрать лишний “шум” в коде, что позволит сфокусироваться автору на бизнес логике приложения.

Сквозная функциональность  —  функциональность, которая распределена по всей кодовой базе. Как правило, эта функциональность не зависит от предметной области вашего проекта. К ней можно отнести следующие примеры:

  • Логирование

  • Кеширование

  • Валидация

  • Форматирование

  • и т.д.

Для работы со сквозной функциональностью существует целая парадигма — Аспектно-ориентированное программирование (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;   } }

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

Подытожим

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

Репозиторий с исходным кодом можно найти тут

Ссылка на статью на английском тут


ссылка на оригинал статьи https://habr.com/ru/post/707496/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *