Потужність декораторів TypeScript на живих прикладах. Декорування методів класу.

Декоратори darkside

Декоратори – інструмент декларативного програмування. З їх допомогою можна легко та елегантно додати до класів та членів класу метадані. На основі цих метаданих можна розширювати або змінювати поведінки класів та членів класу, не змінюючи при цьому кодову базу, до якої застосовано декоратор. Саму технологію можна віднести до мета-програмування чи декларативного програмування.

У рамках цієї статті розбирається кілька прикладів із реальних проектів, де застосування декораторів спростило код для розуміння і виключило його дублювання.

Яку проблему вирішують декоратори?

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

Наскрізна функціональність – функціональність, яка розподілена по всій кодовій базі. Як правило, ця функціональність не залежить від предметної галузі вашого проекту. До неї можна віднести такі приклади:

  • Логування
  • Кешування
  • Валідація
  • Форматування
  • і т.д.

Для роботи з наскрізною функціональністю існує ціла парадигма -  Аспектно-орієнтоване програмування (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): TypedPropertyDescriptor | 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 = (self: T, originalMethod: Function, ...args: any[]) => Promise | void;

export function createDecorator(action: CreateDecoratorAction) {
  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 .

AppStore

В результаті рендеру сторінки викликаються два запити на сервер для завантаження списків. 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 {
  loading: T;
  setLoading(key: keyof T, value: boolean): void;
}

export abstract class Loadable implements ILoadable {
  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 = (keyLoading: keyof T) =>
  createDecorator<ILoadable>(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 {
  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 {
  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 {
  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 {
  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 {
  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 {
  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 {
  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;
  }
}

Підсумуємо

Області застосування декораторів різноманітні та потенціал їх великий. З їхньою допомогою можна легко вирішити цілий спектр прикладних завдань, зробити код елегантним та простим для читання. Завдяки декораторам, ми можемо інкапсулювати повторюваний наскрізний функціонал у модулі, і перевикористовувати в інших частинах проекту або інших проектах. Однак у невмілих руках такий потужний інструмент може й нашкодити, наприклад, змінити пряме призначення методу. Але, на мій погляд, це не є підставою не вивчати та застосовувати цю технологію у своїх проектах. Декоратори стануть чудовим доповненням у ваших проектах як у ГО так і у функціональній парадигмі.

Переклад статті “The power of TypeScript decorators: real cases. Decorating class methods.