[Перевод] TypeScript: паттерны проектирования. Часть 2
Привет, друзья!
Представляю вашему вниманию перевод второй части серии статей, посвященных паттернам проектирования в TypeScript
.
Спасибо Денису Улесову за помощь в переводе материала.
Паттерны (или шаблоны) проектирования (design patterns) описывают типичные способы решения часто встречающихся проблем при проектировании программ.
В отличие от готовых функций или библиотек, паттерн нельзя просто взять и скопировать в программу. Паттерн представляет собой не какой-то конкретный код, а общую концепцию решения той или иной проблемы, которую нужно будет еще подстроить под нужды вашей программы.
Паттерны часто путают с алгоритмами, ведь оба понятия описывают типовые решения каких-то известных проблем. Но если алгоритм — это четкий набор действий, то паттерн — это высокоуровневое описание решения, реализация которого может отличаться в двух разных программах.
Если привести аналогии, то алгоритм — это кулинарный рецепт с четкими шагами, а паттерн — инженерный чертеж, на котором нарисовано решение, но не конкретные шаги его реализации (источник — https://refactoring.guru/ru/design-patterns, в настоящее время сайт работает только с VPN
).
В данной статье рассматриваются следующие паттерны:
- Шаблон / Template;
- Адаптер / Adapter;
- Фабрика / Factory;
- Абстрактная фабрика / Abstract Factory.
Первая часть опубликована ранее, и в ней рассмотрены такие паттерны, как:
- Стратегия / Strategy;
- Цепочка обязанностей / Chain of Responsibility;
- Наблюдатель / Observer;
- Издатель-Подписчик / Publisher/Subscriber как частный случай использования паттерна «Наблюдатель».
❯ Шаблон / Template
CSV
(comma separated values — значения, разделенные запятыми) — это относительно простой формат хранения данных. Файлы CSV
содержат табличные данные в виде обычного текста. Процесс обработки данных в формате CSV
выглядит следующим образом:
Реализуем функцию разбора (парсинга — parse) CSV-файлов
в Node.js
.
users.csv
id,Name
1,Bytefer
2,Kakuqo
parse-csv.ts
import fs from "fs";
import path from "path";
import * as url from "url";
import { csvParse } from "d3-dsv";
const __dirname = url.fileURLToPath(new URL(".", import.meta.url));
const processData = (fileData: any[]) => console.dir(fileData);
const content = fs.readFileSync(path.join(__dirname, "users.csv"), "utf8");
const fileData = csvParse(content);
processData(fileData);
В приведенном примере для парсинга CSV
используется модуль d3-dsv.
Для обработки parse-cvs.ts
воспользуемся инструментом командной строки esno:
$ npx esno parse-csv.ts
Результат выполнения данной команды будет выглядеть следующим образом:
[
{ id: '1', Name: 'Bytefer' },
{ id: '2', Name: 'Kakuqo' },
columns: [ 'id', 'Name' ]
]
Markdown
— это язык разметки, который позволяет создавать документы в простом текстовом формате, который легко писать и читать. Но для того, чтобы отобразить документ MD
на веб-странице, его необходимо преобразовать в HTML-документ
.
Процесс преобразования MD
в HTML
может выглядеть следующим образом:
Реализуем соответствующую функцию.
Users.md
### Users
- Bytefer
- Kakuqo
parse-md.ts
import fs from "fs";
import path from "path";
import * as url from "url";
import { marked } from 'marked';
const __dirname = url.fileURLToPath(new URL(".", import.meta.url));
const processData = (fileData: any[]) => console.dir(fileData);
const content = fs.readFileSync(path.join(__dirname, "Users.md"), "utf8");
const fileData = marked.parse(content);
processData(fileData);
В приведенном примере для парсинга MD
используется модуль marked.
Обрабатываем parse-md.ts
с помощью esno
:
$ npx esno parse-md.ts
Результат:
'Users
\n\n- Bytefer
\n- Kakuqo
\n
\n'
Несмотря на разные типы данных, анализируемых в рассмотренных примерах, сам процесс анализа идентичен:
Он состоит из трех основных этапов:
- Чтение файла.
- Парсинг файла.
- Обработка данных.
Мы можем инкапсулировать данный процесс с помощью паттерна «Шаблон» (Шаблонный метод).
«Шаблон» состоит из двух основных частей: абстрактного родительского класса и конкретного подкласса реализации. Обычно, структура алгоритма подкласса инкапсулируется в абстрактном родительском классе, который также включает в себя реализацию некоторых общедоступных методов и порядок их выполнения. Наследуя от этого абстрактного класса, подклассы получают готовую структуру алгоритма и могут переопределять родительские методы.
Реализуем анализатор CSV
и MD
с помощью паттерна «Шаблон».
Для того, чтобы лучше понять приведенный ниже код, взгляните на следующую диаграмму, описывающую взаимосвязи между классами:
Сначала мы определяем абстрактный класс FileParser
, затем — два подкласса реализации: CsvParser
и MarkdownParser
.
Класс FileParser
abstract class FileParser {
// Шаблонный метод
parse(filePath: string) {
const content = this.readFile(filePath);
const fileData = this.parseFile(content);
this.processData(fileData);
}
readFile(filePath: string) {
if (fs.existsSync(filePath)) {
return fs.readFileSync(filePath, "utf8");
}
}
abstract parseFile(fileContent: string): any;
processData(fileData: any[]) {
console.log(fileData);
}
}
Метод parse
— это шаблонный метод, инкапсулирующий процесс обработки файла.
Класс CsvParser
class CsvParser extends FileParser {
parseFile(fileContent: string) {
return csvParse(fileContent);
}
}
Класс MarkdownParser
class MarkdownParser extends FileParser {
parseFile(fileContent: string) {
return marked.parse(fileContent);
}
}
Пример использования классов CsvParser
и MarkdownParser
:
const csvParser = new CsvParser();
const mdParser = new MarkdownParser();
csvParser.parse(path.join(__dirname, "Users.csv"));
mdParser.parse(path.join(__dirname, "Users.md"));
Результат выполнения приведенного кода:
Таким образом, «Шаблон» позволяет повторно использовать большую часть кода для реализации парсеров CSV-
и MD-файлов
. С помощью FileParser
можно легко разработать новые парсеры для других типов данных.
Итак, паттерн «Шаблон» хорошо подходит для случая, когда общие шаги алгоритма идентичны, но некоторые части различаются по своей внутренней реализации, позволяя абстрагировать такие части с помощью отдельных подклассов.
❯ Адаптер / Adapter
Модуль nodemailer позволяет легко реализовать функцию отправки электронных писем в Node.js
. После установки данного модуля для отправки email достаточно выполнить следующий код:
const transporter = nodemailer.createTransport(transport[, defaults]);
transporter.sendMail(data[, callback])
Во избежание привязки сервиса электронной почты к конкретному провайдеру, определим следующий интерфейс:
interface EmailProvider {
sendMail(options: EmailOptions): Promise;
}
interface EmailOptions {
to: string | string[];
subject: string;
html: string;
from?: string;
text?: string;
}
interface EmailResponse {}
С помощью этого интерфейса можно легко создавать новые почтовые сервисы:
class EmailService {
constructor(public emailProvider: EmailProvider) {}
async sendMail(options: EmailOptions): Promise {
const result = await this.emailProvider.sendMail(options);
return result;
}
}
Во многих случаях этого будет достаточно. Однако может потребоваться поддержка поставщика облачных услуг электронной почты, например, sendgrid или mailersend. В их SDK
можно найти метод для отправки email — send
.
Определяем интерфейс CloudEmailProvider
:
interface CloudEmailProvider {
send(options: EmailOptions): Promise;
}
Сравнивая этот интерфейс с EmailProvider
, мы обнаружим следующую проблему:
Очевидно, что использовать EmailService
для доступа к облачным службам электронной почты невозможно из-за несовпадения названий методов. Данную проблему можно решить разными способами, одним из которых является использование паттерна «Адаптер».
Цель «Адаптера» — обеспечить совместную работу объектов с несовместимыми интерфейсами. Это как клей, объединяющий два разных предметов в единое целое. В «Адаптере» существует четыре основных роли:
- Client (EmailService): объект, который будет использовать целевой интерфейс (
Target
); - Target (EmailProvider): интерфейс, ожидаемый
Client
; - Adapter (CloudEmailAdapter): приспосабливает интерфейс
Adaptee
к интерфейсуTarget
; - Adaptee (CloudEmailProvider): описывает интерфейс, который необходимо адаптировать.
Создаем класс CloudEmailAdapter
:
class CloudEmailAdapter implements EmailProvider {
constructor(public emailProvider: CloudEmailProvider) {}
async sendMail(options: EmailOptions): Promise {
const result = this.emailProvider.send(options);
return result;
}
}
Интерфейсы EmailProvider
и CloudEmailProvider
не совпадают по названию метода. Для решения проблемы их совместимости предназначен CloudEmailAdapter
.
Воспользуемся модулем @sendgrid/mail для реализации класса SendgridEmailProvider
:
import { MailService } from "@sendgrid/mail";
class SendgridEmailProvider implements CloudEmailProvider {
private sendgridMail: MailService;
constructor(
private config: {
apiKey: string;
from: string;
}
) {
this.sendgridMail = new MailService();
this.sendgridMail.setApiKey(this.config.apiKey);
}
async send(options: EmailOptions): Promise {
const result = await this.sendgridMail.send(options);
return result;
}
}
Обратите внимание: приведенный код предназначен только для демонстрации и не должен использоваться в реальных проектах без необходимых доработок.
Пример совместного использования SendgridEmailProvider
и CloudEmailAdapter
:
const sendgridMail = new SendgridEmailProvider({
apiKey: "******",
from: "bytefer@gmail.com",
});
const cloudEmailAdapter = new CloudEmailAdapter(sendgridMail);
const emailService = new EmailService(cloudEmailAdapter);
emailService.sendMail({
to: "******",
subject: "Adapter Design Pattern",
html: "Adapter Design Pattern
",
from: "bytefer@gmail.com",
});
Случаи использования паттерна «Адаптер»:
- когда интерфейс существующего класса не соответствует потребностям системы, т.е. когда интерфейс этого класса по каким-либо причинам несовместим с системой;
- при использовании стороннего сервиса, интерфейс которого несовместим с нашим кодом.
❯ Фабрика / Factory
В паттерне «Фабрика» родительский класс (супер-класс) отвечает за определение публичного интерфейса производных объектов, а подкласс — за создание конкретных «продуктовых» объектов. Суть в том, что реализация специфических свойств продукта делегируется подклассу фабрики. Другим словами, именно в подклассе фабрики определяется, какой продукт создается.
На приведенной диаграмме смоделирован процесс покупки автомобиля пользователем. Bytefer
и Chris1993
заказали модели SuperX01
и SuperX02
на фабриках SuperX01
и SuperX02
, соответственно. Затем фабрики произвели соответствующие модели и доставили их пользователям.
Посмотрим, как можно использовать «Фабрику» для описания процесса производства конкретной модели автомобиля на заводе.
Для того, чтобы лучше понять приведенный ниже код, взгляните на следующую диаграмму:
В «Фабрике» существует четыре основные роли:
- Product (Vehicle): абстрактный продукт;
- Concrete Product (SuperX01): конкретный продукт;
- Factory (VehicleFactory): абстрактная фабрика;
- ConcreteFactory (SuperX01Factory): конкретная фабрика.
Определяем абстрактный класс Vehicle
и два его подкласса — SuperX01
и SuperX02
для конкретных типов транспортных средств:
abstract class Vehicle {
abstract run(): void;
}
class SuperX01 extends Vehicle {
run(): void {
console.log("SuperX01 start");
}
}
class SuperX02 extends Vehicle {
run(): void {
console.log("SuperX02 start");
}
}
Затем определяем класс VehicleFactory
для представления завода по производству автомобилей. Абстрактный класс содержит абстрактный метод produceVehicle
, который является фабричным методом:
abstract class VehicleFactory {
abstract produceVehicle(): Vehicle;
}
На основе VehicleFactory
определяются фабричные классы SuperX01Factory
и SuperX02Factory
для производства моделей автомобилей SuperX01
и SuperX02
:
class SuperX01Factory extends VehicleFactory {
produceVehicle(): Vehicle {
return new SuperX01();
}
}
class SuperX02Factory extends VehicleFactory {
produceVehicle(): Vehicle {
return new SuperX02();
}
}
Приступаем к «производству» автомобилей:
const superX01Factory = new SuperX01Factory();
const superX02Factory = new SuperX02Factory();
const superX01Vehicle = superX01Factory.produceVehicle();
const superX02Vehicle = superX02Factory.produceVehicle();
superX01Vehicle.run();
superX02Vehicle.run();
Результат выполнения приведенного кода выглядит следующим образом:
SuperX01 start
SuperX02 start
Итак, в паттерне «Фабрика» абстрактный класс предоставляет интерфейс для создания продуктов, а его подклассы определяют конкретные создаваемые объекты. С помощью объектно-ориентированного полиморфизма и принципа подстановки Лисков в процессе выполнения программы объекты подкласса переопределяют объекты родительского класса, упрощая расширение системы.
❯ Абстрактная фабрика / Abstract Factory
Паттерн «Абстрактная фабрика» предоставляет интерфейс категории связанных или взаимозависимых объектов без определения их конкретных свойств (классов).
В паттерне «Фабрика» конкретная фабрика отвечает за производство конкретных продуктов, каждая конкретная фабрика соответствует определенному продукту, и метод каждой фабрики уникален. Как правило, в конкретной фабрике существует только один метод для создания объектов или группа перезагружаемых (overloaded) методов, но они предназначены только для этой фабрики. Однако нам может потребоваться фабрика, способная производить не один, а несколько похожих продуктов.
На приведенном изображении смоделирован процесс покупки автомобиля пользователем. Bytefer
заказал SuperX01
на заводе SuperX
. Завод изготовил его по модели, соответствующей SuperX01
, и доставил Bytefer
. Chris1993
заказал SuperX02
на той же фабрике SuperX
. Тот же завод изготовил его по образцу, соответствующему SuperX02
, и доставил Chris1993
.
Посмотрим, как использовать «Абстрактную фабрику» для описания процесса производства определенной модели автомобиля на заводе.
Для того, чтобы лучше понять приведенный ниже код, взгляните на следующую диаграмму:
В «Абстрактной фабрике» существует четыре основные роли:
- Product (Vehicle): абстрактный продукт;
- Concrete Product (SuperX01): конкретный продукт;
- Factory (VehicleFactory): абстрактная фабрика;
- ConcreteFactory (SuperX01Factory): конкретная фабрика.
Определяем абстрактный класс Vehicle
и два его подкласса — SuperX01
и SuperX02
для конкретных типов транспортных средств:
abstract class Vehicle {
abstract run(): void;
}
class SuperX01 extends Vehicle {
run(): void {
console.log("SuperX01 start");
}
}
class SuperX02 extends Vehicle {
run(): void {
console.log("SuperX02 start");
}
}
Далее определяем класс SuperXFactory
для представления фабрики по производству автомобилей. Эта абстрактная фабрика содержит абстрактные методы для производства моделей автомобилей SuperX01
и SuperX02
:
abstract class SuperXFactory {
abstract produceSuperX01(): SuperX01;
abstract produceSuperX02(): SuperX02;
}
На основе SuperXFactory
определяется «заводской» класс ConcreteSuperXFactory
для производства моделей автомобилей SuperX01
и SuperX02
:
class ConcreteSuperXFactory extends SuperXFactory {
produceSuperX01(): SuperX01 {
return new SuperX01();
}
produceSuperX02(): SuperX02 {
return new SuperX02();
}
}
Запускаем «производство» транспортных средств:
const superXFactory = new ConcreteSuperXFactory();
const superX01 = superXFactory.produceSuperX01();
const superX02 = superXFactory.produceSuperX02();
superX01.run();
superX02.run();
Результат выполнения приведенного кода выглядит следующим образом:
SuperX01 start
SuperX02 start
В предыдущем разделе мы рассмотрели паттерн «Фабрика». В чем же разница между ним и «Абстрактной фабрикой»?
Основное отличие между «Абстрактной фабрикой» и просто «Фабрикой» заключается в том, что «Фабрика» нацелена на производство уникального (иерархического) продукта. «Абстрактная фабрика» должна иметь возможность производить несколько видов похожих продуктов (это требование должно учитываться уже на уровне структуры класса фабрики).
Проще говоря, когда нам требуется создавать разные типы продуктов, принадлежащих определенной категории, применение «Абстрактной фабрики» вместо обычной позволит упростить код и сделает его более эффективным.
Надеюсь, вам было интересно и вы узнали что-то новое.
Благодарю за внимание и happy coding!