javascript

TypeScript: паттерны проектирования. Часть 1

  • среда, 16 ноября 2022 г. в 01:16:20
https://habr.com/ru/company/timeweb/blog/699408/
  • Блог компании Timeweb Cloud
  • Разработка веб-сайтов
  • JavaScript
  • Проектирование и рефакторинг
  • TypeScript




Привет, друзья!


Представляю вашему вниманию перевод первой части серии статей, посвященных паттернам проектирования в TypeScript.


Спасибо Денису Улесову за помощь в переводе материала.


Паттерны (или шаблоны) проектирования (design patterns) описывают типичные способы решения часто встречающихся проблем при проектировании программ.


В отличие от готовых функций или библиотек, паттерн нельзя просто взять и скопировать в программу. Паттерн представляет собой не какой-то конкретный код, а общую концепцию решения той или иной проблемы, которую нужно будет еще подстроить под нужды вашей программы.


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


Если привести аналогии, то алгоритм — это кулинарный рецепт с четкими шагами, а паттерн — инженерный чертеж, на котором нарисовано решение, но не конкретные шаги его реализации (источник — https://refactoring.guru/ru/design-patterns, в настоящее время сайт работает только с VPN).


В данной статье рассматриваются следующие паттерны:


  • Стратегия / Strategy;
  • Цепочка обязанностей / Chain of Responsibility;
  • Наблюдатель / Observer;
  • Издатель-Подписчик / Publisher/Subscriber как частный случай использования паттерна "Наблюдатель".

❯ Стратегия / Strategy


Важным функционалом почти любого веб-приложения является регистрация пользователя (аутентификация) и выполнение пользователем входа в систему (авторизация). Наиболее распространенными способами регистрации являются следующие: учетная запись (логин) / пароль, электронная почта или номер мобильного телефона. После успешной регистрации пользователь может использовать соответствующий метод входа в систему.


function login(mode) {
  if (mode === "account") {
    loginWithPassword();
  } else if (mode === "email") {
    loginWithEmail();
  } else if (mode === "mobile") {
    loginWithMobile();
  }
}

Иногда приложение может поддерживать другие методы аутентификации и авторизации, например, в дополнение к электронной почте, страница авторизации Medium поддерживает сторонних провайдеров аутентификации, таких как Google, Facebook, Apple и Twitter.





Для поддержки новых методов нам потребуется снова и снова изменять и дополнять функцию login:


function login(mode) {
  if (mode === "account") {
    loginWithPassword();
  } else if (mode === "email") {
    loginWithEmail();
  } else if (mode === "mobile") {
    loginWithMobile();
  } else if (mode === "google") {
    loginWithGoogle();
  } else if (mode === "facebook") {
    loginWithFacebook();
  } else if (mode === "apple") {
    loginWithApple();
  } else if (mode === "twitter") {
    loginWithTwitter();
  }
}

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


Для того, чтобы лучше понять приведенный ниже код взгляните на следующую диаграмму:





Сначала мы определяем интерфейс Strategy. Затем на основе этого интерфейса реализуем две стратегии авторизации — через Twitter и логин/пароль.


Интерфейс Strategy


interface Strategy {
  authenticate(args: any[]): boolean;
}

Класс TwitterStrategy


class TwitterStrategy implements Strategy {
  authenticate(args: any[]) {
    const [token] = args;

    if (token !== "tw123") {
      console.error("Аутентификация с помощью аккаунта Twitter провалилась!");
      return false;
    }

    console.log("Аутентификация с помощью аккаунта Twitter выполнена успешно!");

    return true;
  }
}

Класс LocalStrategy


class LocalStrategy implements Strategy {
  authenticate(args: any[]) {
    const [username, password] = args;

    if (username !== "bytefer" && password !== "666") {
      console.log("Неправильное имя пользователя или пароль!");
      return false;
    }

    console.log("Аутентификация с помощью логина и пароля выполнена успешно!");
    return true;
  }
}

После описания различных стратегий авторизации, можно определить класс для переключения между стратегиями и выполнения соответствующих операций:


Класс Authenticator


class Authenticator {
  strategies: Record<string, Strategy> = {};

  use(name: string, strategy: Strategy) {
    this.strategies[name] = strategy;
  }

  authenticate(name: string, ...args: any) {
    if (!this.strategies[name]) {
      console.error("Политика аутентификации не установлена!");
      return false;
    }

    return this.strategies[name].authenticate.apply(null, args);
  }
}

Пример того, как это работает:


const auth = new Authenticator();

auth.use("twitter", new TwitterStrategy());

auth.use("local", new LocalStrategy());

function login(mode: string, ...args: any) {
  return auth.authenticate(mode, args);
}

login("twitter", "123");

login("local", "bytefer", "666");

Результат запуска приведенного кода выглядит следующим образом:





Кроме аутентификации и авторизации паттерн "Стратегия" можно использовать для валидации формы, а также для оптимизации большого количества ветвей if else.


Если вы используете Node.js для разработки сервиса аутентификации, обратите внимание на модуль passport.js:


В настоящее время данный модуль поддерживает 538 стратегий аутентификации:





Случаи использования паттерна "Стратегия":


  • когда необходим динамический выбор одного из нескольких доступных алгоритмов. Как правило, каждый алгоритм инкапсулируется в отдельной стратегии (классе);
  • когда имеется несколько классов, которые отличаются только поведением, и выбор конкретного поведения можно произвести динамически во время выполнения кода.

❯ Цепочка обязанностей / Chain of Responsibility


Паттерн "Цепочка обязанностей" (далее также — "Цепочка") позволяет избежать тесной связи и взаимного влияния между отправителем и получателем запроса, предоставляя нескольким объектам возможность последовательно обрабатывать запрос. В рассматриваемом паттерне многочисленные объекты ссылаются друг на друга, формируя цепочку объектов. Запрос передаются по цепочке до тех пор, пока один из объектов не осуществит его окончательную обработку.





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


При разработке программного обеспечения распространенным сценарием применения "Цепочки" является посредник (промежуточное ПО, middleware).


Для того, чтобы лучше понять приведенный ниже код, взгляните на следующую диаграмму:





Мы определяем интерфейс Handler. Данный интерфейс, в свою очередь, определяет следующие методы:


  • use(h: Handler): Handler — для регистрации обработчика (посредника);
  • get(url: string, callback: (data: any) => void): void — для вызова обработчика.

Интерфейс Handler


interface Handler {
  use(h: Handler): Handler;

  get(url: string, callback: (data: any) => void): void;
}

Далее определяется абстрактный класс AbstractHandler, который инкапсулирует логику обработки запроса. Этот класс соединяет обработчики в цепочку последовательных ссылок:


Абстрактный класс AbstractHandler


abstract class AbstractHandler implements Handler {
  next!: Handler;

  use(h: Handler) {
    this.next = h;
    return this.next;
  }

  get(url: string, callback: (data: any) => void) {
    if (this.next) {
      return this.next.get(url, callback);
    }
  }
}

На основе AbstractHandler определяются классы AuthMiddleware и LoggerMiddleware. Посредник AuthMiddleware используется для обработки аутентификации пользователей, а посредник LoggerMidddleware — для вывода информации о запросе:


Класс AuthMiddleware


class AuthMiddleware extends AbstractHandler {
  isAuthenticated: boolean;

  constructor(username: string, password: string) {
    super();

    this.isAuthenticated = false;

    if (username === "bytefer" && password === "666") {
      this.isAuthenticated = true;
    }
  }

  get(url: string, callback: (data: any) => void) {
    if (this.isAuthenticated) {
      return super.get(url, callback);
    } else {
      throw new Error("Не авторизован!");
    }
  }
}

Класс LoggerMiddleware


class LoggerMiddleware extends AbstractHandler {
  get(url: string, callback: (data: any) => void) {
    console.log(`Адрес запроса: ${url}.`);

    return super.get(url, callback);
  }
}

Определяем класс Route для регистрации созданных посредников:


Класс Route


class Route extends AbstractHandler {
  urlDataMap: { [key: string]: any };

  constructor() {
    super();
    this.urlDataMap = {
      "/api/todos": [
        { title: "Изучение паттернов проектирования" },
      ],
      "/api/random": () => Math.random(),
    };
  }

 get(url: string, callback: (data: any) => void) {
  super.get(url, callback);

  if (this.urlDataMap.hasOwnProperty(url)) {
      const value = this.urlDataMap[url];
      const result = typeof value === "function" ? value() : value;
      callback(result);
    }
  }
}

Пример регистрации посредников с помощью Route:


const route = new Route();

route.use(new AuthMiddleware("bytefer", "666"))
 .use(new LoggerMiddleware());

route.get("/api/todos", (data) => {
  console.log(JSON.stringify(data, null, 2));
});

route.get("/api/random", (data) => {
  console.log(data);
});




Результат выполнения приведенного кода выглядит следующим образом:





Случаи использования паттерна "Цепочка обязанностей":


  • когда необходимо отправить запрос одному из нескольких объектов без явного указания получателя;
  • когда существует несколько объектов для обработки запроса, и выбор конкретного объекта для окончательной обработки запроса можно произвести динамически во время выполнения кода.

❯ Наблюдатель / Observer


Паттерн "Наблюдатель" широко используется в веб-приложениях — MutationObserver, IntersectionObserver, PerformanceObserver, ResizeObserver, ReportingObserver. Все эти API можно рассматривать как примеры применения "Наблюдателя". Кроме того, данный паттерн также используется для перманентного мониторинга событий и реагирования на модификацию данных.


В "Наблюдателе" существует две основные роли: Субъект/Subject и Наблюдатель/Observer.


Паттерн "Наблюдатель" определяет отношение один ко многим (one-to-many), позволяя нескольким объектам-наблюдателям одновременно следить за наблюдаемым субъектом. При изменении состояния наблюдаемого субъекта об этом уведомляются все объекты-наблюдатели, чтобы они, в свою очередь, могли обновить собственное состояние.





На приведенной диаграмме в качестве Субъекта выступает моя статья (Article), а Наблюдателями являются Chris1993 и Bytefish. "Наблюдатель" поддерживает простую связь в режиме широковещательной передачи (broadcast), поэтому все наблюдатели автоматически уведомляются о публикации новой статьи.


Для того, чтобы лучше понять приведенный ниже код, взгляните на следующую диаграмму:





Мы определяем интерфейсы Observer и Subject, которые используются для описания соответствующих объектов:


Интерфейс Observer


interface Observer {
  notify(article: Article): void;
}

Интерфейс Subject


interface Subject {
  observers: Observer[];

  addObserver(observer: Observer): void;

  deleteObserver(observer: Observer): void;

  notifyObservers(article: Article): void;
}

Затем мы определяем классы ConcreteObserver и ConcreteSubject, которые реализуют соответствующие интерфейсы:


Класс ConcreteObserver


class ConcreteObserver implements Observer {
  constructor(private name: string) {}

  notify(article: Article) {
    console.log(`"Статья: ${article.title}" была отправлена  ${this.name}.`);
  }
}

Класс ConcreteSubject


class ConcreteSubject implements Subject{
  public observers: Observer[] = [];

  public addObserver(observer: Observer): void {
    this.observers.push(observer);
  }

  public deleteObserver(observer: Observer): void {
    const n: number = this.observers.indexOf(observer);

    n != -1 && this.observers.splice(n, 1);
  }

  public notifyObservers(article: Article): void {
    this.observers.forEach((observer) => observer.notify(article));
  }
}

Проверяем работоспособность методов наших классов:


const subject: Subject = new ConcreteSubject();

const chris1993 = new ConcreteObserver("Chris1993");

const bytefish = new ConcreteObserver("Bytefish");

subject.addObserver(chris1993);
subject.addObserver(bytefish);

subject.notifyObservers({
  author: "Bytefer",
  title: "Observer Pattern in TypeScript",
  url: "https://medium.com/***",
});

subject.deleteObserver(bytefish);

subject.notifyObservers({
  author: "Bytefer",
  title: "Adapter Pattern in TypeScript",
  url: "https://medium.com/***",
});

Результат выполнения приведенного кода выглядит следующим образом:


"Статья: Observer Pattern in TypeScript" была отправлена Chris1993.
"Статья: Observer Pattern in TypeScript" была отправлена Bytefish.
"Статья: Adapter Pattern in TypeScript" была отправлена Chris1993.

Представим, что в настоящее время я пишу на две основные тематики — JavaScript и TypeScript. Поэтому, если я захочу опубликовать новую статью, то об этом необходимо уведомить только читателей, интересующихся JavaScript, или только читателей, интересующихся TypeScript. При использовании паттерна "Наблюдатель", нам придется создать два отдельных Субъекта. Однако лучшим решением будет использование паттерна "Издатель-Подписчик".


Паттерн Издатель-Подписчик / Pub/Sub


"Издатель-Подписчик" — это парадигма обмена сообщениями, в которой отправители сообщений (называемые издателями) не отправляют сообщения конкретным получателям (называемым подписчиками) напрямую. Вместо этого, опубликованные сообщения группируются по категориям и отправляются разным подписчикам. Подписчики могут интересоваться одной или несколькими категориями сообщений и получать только такие сообщения, не зная о существовании издателей.


В "Издателе-Подписчике" существует три основные роли: Издатель/Publisher, Канал передачи сообщений/Channel и Подписчик/Subscriber.





На приведенной диаграмме Издатель — это Bytefer, тема A и тема B в Каналах соответствуют JavaScript и TypeScript, а ПодписчикиChris1993, Bytefish и др.


Реализуем класс EventEmitter с помощью рассматриваемого паттерна:


type EventHandler = (...args: any[]) => any;

class EventEmitter {
  private c = new Map<string, EventHandler[]>();

  subscribe(topic: string, ...handlers: EventHandler[]) {
    let topics = this.c.get(topic);

    if (!topics) {
      this.c.set(topic, (topics = []));
    }

    topics.push(...handlers);
  }

  unsubscribe(topic: string, handler?: EventHandler): boolean {
    if (!handler) {
      return this.c.delete(topic);
    }

    const topics = this.c.get(topic);

    if (!topics) {
      return false;
    }

    const index = topics.indexOf(handler);

    if (index < 0) {
      return false;
    }

    topics.splice(index, 1);

    if (topics.length === 0) {
      this.c.delete(topic);
    }

    return true;
  }

  publish(topic: string, ...args: any[]): any[] | null {
    const topics = this.c.get(topic);

    if (!topics) {
      return null;
    }

    return topics.map((handler) => {
      try {
        return handler(...args);
      } catch (e) {
        console.error(e);
        return null;
      }
    });
  }
}

Пример использования EventEmitter:


const eventEmitter = new EventEmitter();

eventEmitter.subscribe("ts",
  (msg) => console.log(`Получено:${msg}`));

eventEmitter.publish("ts", `Паттерн "Наблюдатель"`);

eventEmitter.unsubscribe("ts");

eventEmitter.publish("ts", `Паттерн "Издатель/Подписчик"`);

Результат выполнения приведенного кода: Получено: Паттерн "Наблюдатель".


В событийно-ориентированной архитектуре паттерн "Издатель-Подписчик" играет важную роль. Конкретная реализация данного паттерна может использоваться в качестве шины событий (Event Hub) для реализации обмена сообщениями между различными компонентами или модулями одной системы.


Надеюсь, вам было интересно и вы узнали что-то новое.


В следующей статье будут рассмотрены такие паттерны, как:


  • Шаблон / Template;
  • Адаптер / Adapter;
  • Фабрика / Factory;
  • Абстрактная фабрика / Abstract Factory.

Благодарю за внимание и happy coding!