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;
  }
}

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

Подытожим

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

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

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

Комментарии (4)


  1. Format-X22
    25.12.2022 01:02
    +3

    А ещё можно эти столбики декораторов сжимать, если там повторяющиеся общие паттерны действий у методов.

    Например такой код:

        @GetArrayWithCache('symbol/:id')
        @InfluxTimeLog()
        async getSymbol(@Headers() headers: object, @Param('id') id: string): TProxyItem {
            return this.symbolsService.getById(id);
        }

    может иметь такой вот составной декоратор

    export function GetArrayWithCache(path: string) {
        return applyDecorators(
            Get(path),
            ApiOkResponse(ARRAY_RESPONSE_DOC),
            ApiOperation(ARRAY_CACHE_DOC),
            CheckNotFound(),
        );
    }

    где applyDecorators это функция-склеиватель

    export function applyDecorators(
      ...decorators: Array<ClassDecorator | MethodDecorator | PropertyDecorator>
    ) {
        return <TFunction extends Function, Y>(
          target: TFunction | object,
          propertyKey?: string | symbol,
          descriptor?: TypedPropertyDescriptor<Y>,
        ) => {
            for (const decorator of decorators) {
                if (target instanceof Function && !descriptor) {
                    (decorator as ClassDecorator)(target);
                    continue;
                }
                (decorator as MethodDecorator | PropertyDecorator)(
                    target,
                    propertyKey,
                    descriptor,
                );
            }
        };
    }

    И, как видите, в составном декораторе можно передать параметры... в том числе и другие декораторы, если это нужно.

    А ещё декораторами можно описывать апи и в итоге вместо писанины ручной валидации или конфигов или каких-либо других хитросплетений - мы можем просто сделать так

    export class AccountListArgs {
        @Property({
            enum: EFieldsSortingAccounts,
            description: 'orderBy',
        })
        @IsOptional()
        @IsEnum(EFieldsSortingAccounts)
        orderBy?: EFieldsSortingAccounts = EFieldsSortingAccounts.ID;
    }

    и, натравив туда пакеты class-transformer и class-validator с ходу отвалидировать на тип, енум, не обязательность наличия и ещё сверху сгенерировать swagger-схему и на лету её показать на отдельном урле с документацией.

    В общем, если вы ещё не используете декораторы - время пришло.


    1. pharrell
      25.12.2022 01:55
      +8

      В общем, если вы ещё не используете декораторы - время пришло.

      На самом деле если вы ещё не используете TS декораторы – то сейчас самый неподходящий момент для начала их использования. Во-первых, core team разработчики TS называют декораторы одним из самых неудачных экспериментов (не зря они experimentalDecorators). Во-вторых, как причина первого пункта, в ECMAScript на подходе нативные декораторы, которые имеют такой же синтаксис, но работают совершенно иначе. И как этот зоопарк дружить друг с другом пока что под большим вопросом.

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


      1. Format-X22
        25.12.2022 02:39
        +1

        Ну, неудачный это эксперимент или нет, но это удобно, это работает, на этом множество фреймворков построено и кода написано. На столько что где-то это корпоративный легаси уже. И фатальных недостатков так то нет. Кроме того что обогнало оригинальный JS на несколько лет и теперь придется чуть поиграть с транспилерами.

        А императивный или нет - всё же экономия времени и ресурсов слишком большая чтобы отказывать себе в удовольствии пользоваться декораторами, это как классы или лямбды, или тот же асинк-авейт - можно без них и в разных парадигмах даже что-то из этого там или сям отсутствует, но всё же это добавляет слишком много выразительности и удобства, чтобы пройти мимо. К тому же декораторы были обточены на многих других языках и пришли как устоявшийся в целом механизм, хоть и в итоге есть нюансы в различиях работы ванилы и тайпскрипта. Так то в ваниле и приватные методы с хешами совсем иначе работают, но в итоге они просто дополнили друг друга - классические ООП приватные методы от тайпскрипта и суровые на символе-указателе с хешом в имени и невидимые извне у ванилы. Посмотрим как тут слияние выйдет, но декораторы уже слишком плотно в обороте чтобы с ними что-то произошло.


  1. klimkinMD
    26.12.2022 09:02

    Жду "карамелизаторов" и "лакираторов"