Как-то мне выпало написать небольшой микросервис, код которого практически полностью состоял их одних реализаций паттернов «Банды четырех». В этой статье я хочу рассказать о том, как последовательное решение поставленной задачи приводило к использованию все новых паттернов, и как эти паттерны взаимодействовали между собой.
Будет много схем и кода, демонстрирующих практические примеры применения паттернов Композит, Билдер, Визитер, Цепочка обязанностей и Декоратор. Не смотря на то, что примеры кода написаны на PHP, статья может оказаться интересной и для разработчиков, использующих другие языки.
Понимание паттернов
Прежде чем приступить к описанию решавшейся задачи, я хотел бы немного рассказать о том, как лично мне удалось начать понимать паттерны и использовать их в своих проектах. Помню, лет семь-восемь назад паттерны были обязательной составляющей любого техинтервью. «Какие паттерны проектирования ты знаешь?» Или: «С помощью каких паттернов можно решить эту задачу?» Или вот эпичный вопрос из одного из стародавних собесов: «Чем Бридж отличается от Адаптера?»
Для того, чтобы устроиться на работу, приходилось регулярно перечитывать, зубрить списки паттернов и какие-то краткие пояснения, что каждый из них делает. Это продолжалось в моей жизни ни один год, пока, наконец, я не решил твердо поставить точку в этом вопросе и не обратился к первоисточнику: книге «Банды четырех».
К некоторому моему удивлению, начало книги было посвящено не столько паттернам, сколько рассуждениям о типах, подтипах, интерфейсах и принципе полиморфизма. Дело в том, что все, или почти все, паттерны «Банды четырех» построены на основе применения полиморфизма.
Именно возможность полиморфно подменять различные компоненты системы в любой момент времени, вплоть до рантайма, делает паттерны такими полезными. Сами же паттерны рассказывают как раз о том, на какие компоненты можно разбить нашу систему, и как эти компоненты между собой взаимодействуют.
Именно когда я понял полиморфизм, я, наконец, понял и паттерны: как и зачем использовать каждый из них. Если вы не до конца уверены в своем понимании паттернов, и хотите его улучшить, то я бы советовал вам в первую очередь разобраться с принципом полиморфизма. Возможно, для этого вам окажется полезным мой пост о полиморфизме. Также, если вы интересуетесь темой паттернов, то вам, возможно, будет интересен мой пост о фабриках.
Ну что ж, со вступлением покончено, давайте, наконец, разбираться с самим паттернами.
Описание решавшейся задачи
Жила-была компания, занимавшаяся интернет-рекламой и торговлей трафиком. Как водится, IT-инфраструктура этой компании развивалась спонтанно на протяжении длительного времени, что привело к появлению целого зоопарка сервисов и микросервисов, взаимодействующих между собой самыми разными способами.
Конкретно мне предстояло написать новый микросервис, отвечающий за сбор и раздачу информации о рекламных кампаниях. Верхнеуровнево задача предельно простая: собрать агрегат кампании, обогатив его данными из разных источников, и отправить получившуюся структуру в Кафку, откуда ее подберут все заинтересованные (назовем их потребителями). Ниже представлена упрощенная схема того, как это должно было работать.

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

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

Класс CompositeCampaignRepresent
является узловым элементом и может агрегировать в себя как другие узлы (инстансы все того же CompositeCampaignRepresent
), так и листовые элементы (остальные реализации абстрактного CampaignRepresent
).
Позднее в интерфейс абстрактного CampaignRepresent
мы добавим операции, которые в клиентском коде можно будет единообразно вызывать для любого представления кампании: будь то отдельный листовой SimpleCampaignRepresent
(некоторым потребителям хватает и такого простого представления), или сложный агрегат с кучей узлов и листьев.
Таким образом, была решена первая проблема: теперь можно независимо развивать доменные классы, описывающие агрегат кампании так, как того диктует бизнес-логика. Также независимо можно развивать и классы представлений кампании в зависимости от нужд потребителей информации.
Инкапсуляция логики набора разных композитов. Паттерн Билдер.
Теперь, когда у нас есть множество классов представлений и возможность произвольно их комбинировать, возникает вопрос, как сделать удобным их использование, чтобы не разбегались глаза. Ведь для каждого потребителя нужно собрать свою комбинацию из узлов и листьев нашего композита.
Хорошо было бы иметь какой-то класс, который инкапсулирует в себе требования конкретных потребителей, то есть код по набору конкретных структур наших композитных представлений кампании. Ну и конечно, хотелось бы иметь возможность удобно расширять этот функционал, ведь число потребителей может расти, а их требования к структуре данных меняться.
То есть нужен не один класс для набора композитов, а линейка классов, объединенная общим интерфейсом. Под эту задачу идеально подходит паттерн Билдер. Ключевой, на мой взгляд, особенностью этого паттерна является возможность развивать линейку классов-директоров. Если вам нужно освежить в памяти, как устроен Билдер, и что такое директор, то вот мой пост об этом паттерне.
Давайте я покажу, как все это устроено, на примерах кода. Вот примеры классов представлений (пока без общих операций, их мы добавим позже).
abstract class CampaignRepresent
{
}
class CompositeCampaignRepresent extends CampaignRepresent
{
/** @var CampaignRepresent[] */
private array $children = [];
public function add(CampaignRepresent $representation): void
{
$this->children[] = $representation;
}
}
class SimpleCampaignRepresent extends CampaignRepresent
{
public readonly string $id;
public readonly CampaignStatus $status;
//десяток других полей...
public function __construct(Campaign $campaign)
{
$this->id = $campaign->getId();
$this->status = $campaign->getStatus();
//инициализируем остальные поля...
}
}
Каждый класс-наследник абстрактного CampaignRepresent
— это строительный блок нашего дерева — лист или узел. В конструкторе каждого конкретного блока представления требуются свои данные из доменного слоя для формирования представления. А вот так выглядит Билдер для строительства готовых представлений:
class CampaignRepresentBuilder
{
private array $represents = [];
public function buildSegmentPrice(SegmentPrice ...$prices): void
{
$this->represents[] = new SegmentPricesCampaignRepresent(...$prices);
}
public function buildSimple(Campaign $campaign): void
{
$this->represents[] = new SimpleCampaignRepresent($campaign);
}
public function buildCalculatedMetadata(Campaign $campaign): void
{
$this->represents[] = new CalculatedMetadataCampaignRepresent($campaign);
}
public function buildCreatives(Creative ...$creatives): void
{
$creativeRepresents = [];
foreach ($creatives as $creative) {
$creativeRepresents[] = new CreativeRepresent($creative);
}
$this->represents[] = new CreativesCampaignRepresent(...$creativeRepresents);
}
public function getResult(): CampaignRepresent
{
$composite = new CompositeCampaignRepresent();
foreach ($this->represents as $presentation) {
$composite->add($presentation);
}
return $composite;
}
}
Как видите, в первой реализации наш Билдер довольно прост: он умеет строить только плоскую структуру с одним узловым элементом, в который включаются все необходимые листья.
Со временем мы сможем развивать этот класс, добавляя в него функционал строительства более сложных структур. Итак, теперь все знание о том, какие блоки представления существуют, и как их добавлять в общую структуру, собраны в нашем CampaignRepresentBuilder
.
Теперь нам необходимо где-то сосредоточить знание о том, какие именно структуры представления нужны для разных потребителей. Вот для этого-то нам и пригодятся классы-директоры. Во-первых, эти классы будут отвечать за извлечение данных, необходимых для обогащения структуры кампании. А во-вторых, именно директоры будут знать, какие методы Билдера нужно надергать, чтобы получить конкретную структуру под нужды каждого конкретного потребителя.
В этом проекте я назвал такие классы-директора словом Enricher
— обогатитель. Вот пример простого обогатителя:
class CampaignEnricher implements CampaignEnricherInterface
{
public function __construct(
private readonly SegmentReadRepositoryInterface $segmentRepository,
private readonly CreativeReadRepositoryInterface $creativeRepository,
private readonly CampaignRepresentBuilder $builder
) {
}
public function enrich(Campaign $campaign): CampaignRepresent
{
$prices = $this->segmentRepository->byCampaign(new SegmentPriceCampaignSpecification($campaign));
$creatives = $this->creativeRepository->byCampaignId($campaign->getId());
$this->builder->buildSimple($campaign);
$this->builder->buildCalculatedMetadata($campaign);
$this->builder->buildSegmentPrice(...$prices->getElements());
$this->builder->buildCreatives(...$creatives->elements);
return $this->builder->getResult();
}
}
Как видите, согласно контракту CampaignEnricherInterface
, обогатитель получает на вход сущность рекламной кампании и отдает экземпляр CampaignRepresent
. Причем, это может быть как отдельный листовой элемент, так и сложный композит. Паттерн Композит как раз про это: про единообразную работу как с целым, так и с его частью.
В каждую конкретную реализацию CampaignEnricherInterface
инжектятся те репозитории, которые необходимы для получения дополнительных данных, помимо кампании. Не знаю, нужно ли говорить, что благодаря принципу инвертирования зависимостей мы можем писать любые реализации репозиториев под любые виды хранилищ, то есть подменять источники данных для обогащения, не меняя код класса CampaignEnricher
.
Ну а в остальном, наш CampaignEnricher
делает все строго по паттерну: дергает в Билдере те этапы строительства, которые ему нужны, и забирает готовый результат.
Трансформация готового представления. Паттерн Визитер.
Теперь, когда мы можем построить любую структуру представления независимо от того, как устроены доменные сущности, из которых это представление строится, предстоит решить следующую проблему. Наше представление — это все еще объект класса CampaignRepresent
. А для скармливания его потребителям нам нужно этот объект сериализовать. При этом во время сериализации нам может потребоваться каким-то образом трансформировать результат, как то:
-
переименовать какое-то поле,
-
заменить camelCase на snake_case или наоборот,
-
отформатировать значение поля,
-
опустить часть структуры на подуровень
-
и так далее.
Плохое и прямолинейное решение этой задачи состоит в том, чтобы плодить классы представлений в зависимости от того, как должны называться поля, и классы-директора в зависимости от того, на каких подуровнях структуры должны находиться одни и те же данные. После этого придется еще для каждого конкретного случая описать параметры сериализации для того, чтобы все значения были в нужном формате.
Но, согласитесь, было бы приятнее развивать логику форматирования представлений также независимо, как мы развиваем доменные сущности, строительные блоки представлений и логику их сборки в готовое «изделие»?
Например, для ситуаций когда двум потребителям требуются одни и те же данные, только с разными названиями некоторых полей и разным расположением на подуровнях каких-то кусков структуры, было бы хорошо иметь одно представление кампании и два разных форматтера.
С паттерном Композит хорошо сочетается паттерн Визитер. Как и в предыдущих случаях, вот вам ссылка на пост об этом паттерне, если нужно освежить память. Трансформер-визитер мог бы итеративно обойти все узлы и листья нашего композита и трансформировать их.
Мы могли бы добавить в интерфейс нашего CampaignRepresent
операцию transform()
для приема трансформера-визитера:
abstract class CampaignRepresent
{
abstract public function transform(CampaignRepresentTransformer $transformer): array;
}
class CompositeCampaignRepresent extends CampaignRepresent
{
public function transform(CampaignRepresentTransformer $transformer): array
{
$result = [];
foreach ($this->children as $child) {
$transform = $child->transform($transformer);
$result = array_merge_recursive($result, $transform);
}
return $result;
}
}
class SimpleCampaignRepresent extends CampaignRepresent
{
public function transform(CampaignRepresentTransformer $transformer): array
{
return $transformer->transformSimple($this)
}
}
В самом трансформере мы объявили бы операции для каждого вида представлений:
class CampaignRepresentTransformer
{
public function transformSimple(SimpleCampaignRepresent $represent): array
{
//...
}
public function transformSegmentPrices(SegmentPricesCampaignRepresent $represent): array
{
//...
}
//...
}
К сожалению, такой подход не до конца решает нашу задачу. Для разных потребителей могут требоваться разные виды трансформации отдельных частей представления. Паттерн визитер как раз позволяет развивать линейку классов-визитеров. То есть, я мог бы написать несколько трансформеров, по-разному выполняющих методы transformSimple()
, transformSegmentPrices()
и. т. д.
Но что, если мне нужно трансформироавть часть листьев моего представления методами из одного трансформера, а часть — методами из другого трансформера? Придется плодить классы-трансформеры, дублируя код. Можно было бы вынести каждый метод трансформера в отдельный класс: SegmentPricesTransformer
, CreativeTransformer
и т. д.
При таком подходе мне не пришлось бы писать кучу «монолитных» трансформеров под каждого конкретного потребителя. Я мог бы просто набирать нужную конфигурацию из готовых мини-трансформеров и отправлять ее на трансформацию нашего композитного представления.
Что же в итоге получается? Композитный трансформер должен обойти композитное представление? Как этого добиться?
Составной трансформер-визитер для обхода композитного представления. Паттерн Цепочка обязанностей.
На самом деле, нам не нужна древовидная структура трансформеров. Нас вполне устроит линейная: эдакая гусеница из шариков, каждый шарик которой пройдет через каждый лист нашего древовидного представления и либо обработает его, либо не станет. Вот схема для наглядности:

Для реализации такого решения мы могли бы применить в нашем трансформере сразу два паттерна: Визитер и Цепочка обязанностей. Паттерн Цепочка обязанностей довольно прост. Все звенья цепочки должны реализовывать общий интерфейс, в котором объявлена какая-то операция. В нашем случае это операция transform()
.
Клиентский код ничего не знает о том, что переданный ему объект — это первое звено в цепи. В клиентском коде мы просто вызываем нашу операцию transform()
, ничего не зная о том, каким именно из звеньев цепи она будет выполнена. Запрос на выполнение операции передается от одного звена к другому до тех пор, пока не будет выполнен, или цепь не закончится.
Давайте рассмотрим добавление в код Трансформера функционала Цепочки обязанностей.
abstract class CampaignRepresentTransformer
{
protected ?self $next = null;
abstract public function supports(CampaignRepresent $represent): bool;
public function transform(CampaignRepresent $represent): ?array
{
if (null !== $this->next) {
return $this->next->transform($represent);
}
return [];
}
public function setNext(?CampaignRepresentTransformer $next): void
{
$this->next = $next;
}
}
Метод transform()
теперь у нас является операцией и из паттерна Визитер (ее вызывает объект-представление в методе приема визитера) и операцией из паттерна Цепочка обязанностей. Если текущий трансформер не может обработать переданное ему представление, то он передаст управление (делегирует выполнение метода) следующему трансформеру в цепи.
Метод supports()
, объявленный в интрфейсе класса CampaignRepresentTransformer
, позволяет определить, может ли конкретный трансформер обработать конкретный листовой элемент нашего композитного представления.
Вот, например, код трансформера, который просто нормальзует объект-представление кампании:
class NormalizeTransformer extends CampaignRepresentTransformer
{
public function __construct(
private readonly NormalizerInterface $normalizer
) {
}
public function supports(CampaignRepresent $represent): bool
{
return true;
}
public function transform(CampaignRepresent $represent): ?array
{
if ($this->normalizer->supportsNormalization($represent)) {
return $this->normalizer->normalize($represent);
}
if (null !== $this->next) {
return $this->next->transform($represent);
}
return null;
}
}
Метод supports()
этого класса говорит, что ему можно скармливать любое представление. Если трансформеру удалось нормализовать переданный ему объект-представление, то работа цепочки обязанностей завершится: запрос на трансформацию успешно обработан. В противном случае, если у NormalizeTransformer
есть следующий элемент цепи («преемник» в терминологии паттерна), то он делегирует выполнение операции своему приемнику. Если же наш трансформер является последним элементом цепи и нормализация не удалась, будет возвращен null
как признак того, что трансформация представления не удалась.
Таким образом, у нас появилась возможность писать по несколько вариантов трансформеров для каждого из составных блоков композитного представления рекламной кампании. Мы можем объединять в цепочки различные реализации трансформеров в зависимости от того, из каких боков состоит представление, и того, какой формат данных требуется для конкретного потребителя.
Осталось решить последнюю проблему. Помимо трансформации каждого листового элемента композитного представления в массив с конкретной структурой, мне хотелось бы иметь возможность выполнять для каждого элемента представления некоторые общие операции: переименовать поле, отформатировать значение, опустить подструктуру на подуровень и т. д.
Наша система и так уже является супер-гибкой и состоит из большого числа независимо развиваемых элементов:
-
Классы доменного агрегата рекламной кампании;
-
Классы композитного представления рекламной кампании;
-
Классы обогатителей-директоров для набора конкретных композитов под нужды разных потребителей;
-
Классы трансформеров-визитеров, собираемые в цепочку, для создания произвольной логики трасформации произвольной структуры композитного представления.
Что же делать, продолжать наворачивать эту систему дальше? Или плюнуть и начать дублировать наши мини-трансформеры: этот трансформирует способом №1, этот — тем же способом, но с переименованием полей. Третий трансформер трансформирует способом №2, а четвертый — способом №2 с опусканием результата трансформации на подуровень. Сами можете представить, каким может быть число комбинаций и сколько кода будет дублироваться.
На самом деле, есть один паттерн, который позволяет добавлять классу новые свойства, не внося изменений в код этого класса. Конечно же, это декоратор.
Декорирование звеньев цепочки трансформеров-визитеров
Паттерн декоратор довольно прост. Если нужно добавить какому-то классу новые свойства, мы просто объявляем класс-наследник, в который будут добавлены требуемые нам расширения поведения или данных. Этот класс-наследник и есть Декоратор. При этом Декоратор аггрегирует в себя инстанс родительского класса и делегирует выполнение всех операций этому инстансу, выполняя при этом какие-то дополнительные действия.
Давайте посмотрим, например, на код трансформера, который опускает результат трансформации на подуровень:
class SubLevelTransformer extends CampaignRepresentTransformer
{
public function __construct(
private readonly string $subLevelKey,
private readonly CampaignRepresentTransformer $wrapped
) {
}
public function supports(CampaignRepresent $represent): bool
{
return $this->wrapped->supports($represent);
}
public function transform(CampaignRepresent $represent): ?array
{
if ($this->supports($represent)) {
return [
$this->subLevelKey => $this->wrapped->transform($represent)
];
}
if (null !== $this->next) {
return $this->next->transform($represent);
}
return null;
}
}
Как видите, SubLevelTransformer
является все тем же трансформером: реализует интерфейс класса CampaignRepresentTransformer
. В то же время, он как бы оборачивает в себя (декорирует) какой-либо другой трансформер. Метод supports()
полностью делегирован вложенному трансформеру: «если обернутый в меня трансформер может обработать это представление, то и я могу».
В методе transform()
сначала вызывается аналогичный метод вложенного трансформера, а затем результат его выполнения помещается на подуровень массива.
Теперь представьте, что существует три потребителя информации о рекламной кампании. Все три ждут информацию о ценах в одном и том же формате. Но первому нужно, чтобы эта информация была в массиве на одном уровне с остальными полями, второму нужно, чтобы она лежала во вложенном массиве под ключом prices
, а третьему нужно, чтобы данные были опущены на два подуровня: $data['segment']['prices] = $pricesData
.
Для первого потребителя мы просто добавим в цепочку трансформеров экземпляр SegmentPricesTransformer
. Для второго потребителя мы обернем один трансформер в другой, а для третьего потребителя мы обернем один трансформер в другой два раза:
$first = new SegmentPricesTransformer();
$second = new SubLevelTransformer('prices', new SegmentPricesTransformer());
$third = new SubLevelTransformer(
'segment',
new SubLevelTransformer('prices', new SegmentPricesTransformer());
);
Аналогичным образом мы можем создать трансформеры для остальных простых операций, таких как переименование полей или форматирование значений. Если вернуться к образу транформера-гусеницы, состоящей из шариков, то декоратор позволяет нам обернуть каждый шарик в произвольное количество дополнительных слоев, изменяя тем самым результат работы каждого «шарика».
Заключение
Вот такой кейс применения сразу пяти паттернов случилось мне реализовать в свое время. Два паттерна — Композит и Билдер — использовались для построения произвольной древовидной структуры представления рекламной кампании. Еще три паттерна — Визитер, Цепочка обязанностей и Декоратор — были использованы для создания наборного трансформера, который мог бы обходить все узлы и листья нашего древовидного представления.
Как всегда, благодарю всех, кто дочитал до конца. Буду рад вопросам и комментариям. Также приглашаю вас проголосовать за тему для следующей статьи: все-таки хочется писать о том, что интересно читателям. Всем спасибо!))
Источник: https://habr.com/ru/articles/888790/