TypeScript: паттерны проектирования. Часть 1
- среда, 16 ноября 2022 г. в 01:16:20
Привет, друзья!
Представляю вашему вниманию перевод первой части серии статей, посвященных паттернам проектирования в TypeScript
.
Спасибо Денису Улесову за помощь в переводе материала.
Паттерны (или шаблоны) проектирования (design patterns) описывают типичные способы решения часто встречающихся проблем при проектировании программ.
В отличие от готовых функций или библиотек, паттерн нельзя просто взять и скопировать в программу. Паттерн представляет собой не какой-то конкретный код, а общую концепцию решения той или иной проблемы, которую нужно будет еще подстроить под нужды вашей программы.
Паттерны часто путают с алгоритмами, ведь оба понятия описывают типовые решения каких-то известных проблем. Но если алгоритм — это четкий набор действий, то паттерн — это высокоуровневое описание решения, реализация которого может отличаться в двух разных программах.
Если привести аналогии, то алгоритм — это кулинарный рецепт с четкими шагами, а паттерн — инженерный чертеж, на котором нарисовано решение, но не конкретные шаги его реализации (источник — https://refactoring.guru/ru/design-patterns, в настоящее время сайт работает только с VPN
).
В данной статье рассматриваются следующие паттерны:
Важным функционалом почти любого веб-приложения является регистрация пользователя (аутентификация) и выполнение пользователем входа в систему (авторизация). Наиболее распространенными способами регистрации являются следующие: учетная запись (логин) / пароль, электронная почта или номер мобильного телефона. После успешной регистрации пользователь может использовать соответствующий метод входа в систему.
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
стратегий аутентификации:
Случаи использования паттерна "Стратегия":
Паттерн "Цепочка обязанностей" (далее также — "Цепочка") позволяет избежать тесной связи и взаимного влияния между отправителем и получателем запроса, предоставляя нескольким объектам возможность последовательно обрабатывать запрос. В рассматриваемом паттерне многочисленные объекты ссылаются друг на друга, формируя цепочку объектов. Запрос передаются по цепочке до тех пор, пока один из объектов не осуществит его окончательную обработку.
Возьмем, к примеру, процедуру согласования отпуска в нашей компании: когда мне нужен выходной, я обращаюсь только к тимлиду, такой запрос не требуется передавать его вышестоящему руководителю и директору. Разные должности в компании предполагают разные обязанности и полномочия. Если звено в цепочке не может обработать текущий запрос и имеется следующее звено, запрос будет перенаправлен в это звено для дальнейшей обработки.
При разработке программного обеспечения распространенным сценарием применения "Цепочки" является посредник (промежуточное ПО, middleware).
Для того, чтобы лучше понять приведенный ниже код, взгляните на следующую диаграмму:
Мы определяем интерфейс Handler
. Данный интерфейс, в свою очередь, определяет следующие методы:
Интерфейс 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);
});
Результат выполнения приведенного кода выглядит следующим образом:
Случаи использования паттерна "Цепочка обязанностей":
Паттерн "Наблюдатель" широко используется в веб-приложениях — 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) для реализации обмена сообщениями между различными компонентами или модулями одной системы.
Надеюсь, вам было интересно и вы узнали что-то новое.
В следующей статье будут рассмотрены такие паттерны, как:
Благодарю за внимание и happy coding!