Репозитории и их истинное назначение
Jaroslav Volovyk
В последнее время сообщения и твиты, касающиеся шаблона Репозиторий, снова набирают обороты. Кажется, невозможно предсказать, когда, где и почему такие “острые темы” всплывут на поверхность… Однако искрой, которая вызывает возгорание этих “горячих тем”, почти всегда является следующий вопрос (или что-то подобное):
Сколько раз вы заменяли базовую реализацию базы данных из-за использования паттерна Repository?
— Случайный Технофлюенсер
Вот почему в этой статье я хотел бы внести некоторую ясность относительно этого совершенно непонятного шаблона проектирования программного обеспечения и почему аргумент №1 (вопрос выше) против его использования на самом деле незначителен и почти не имеет значения.
Определение Репозитория
Прежде всего, давайте начнем с определения того, что такое Репозиторий. В PoEAA модель Репозитория определяется следующим образом:
Посредничает между слоями отображения домена и данных, используя интерфейс, подобный коллекции, для доступа к объектам домена.
Крайне важно установить факты, приведенные ниже, прежде чем переходить к другим разделам.
(…) доступ к объектам домена
Объекты домена — это субъекты доменного слоя, которые обладают авторитетным набором бизнес-возможностей для выполнения определенных задач. Эти возможности или модели поведения открываются в виде публичных методов на указанных объектах для последовательного изменения состояния. Объекты домена также известны как модели записи, сущности или агрегаты на жаргоне DDD.
Примечание: Агрегаты и их точное назначение выходят за рамки этой статьи в блоге. Однако, если вы хотите узнать о них больше, я могу порекомендовать эту короткую и емкую статью Шона МакКула.
Вы, вероятно, уже много раз слышали понятие “бизнес-логика”. Так вот, именно эти модели определяют, что должна включать в себя эта “бизнес-логика”.
(…) интерфейс, подобный коллекции (…)
В идеальном мире уровень постоянства для сущностей не нужен, поскольку все можно добавлять и удалять из коллекции в памяти. Например:
final class Users
{
private array $users;
private function __construct(User ...$users)
{
$this->users = $users;
}
public static function empty(): self
{
return new self();
}
public function add(User $user): void
{
$this->users[$user->id()->asString()] = $user;
}
public function find(UserId $id): User
{
return $this->users[$id->asString()]
?? throw CouldNotFindUser::becauseItIsMissing();
}
public function remove(User $user): void
{
unset($this->users[$user->id()->asString()]);
}
}
К сожалению, реальный мир часто отличается от идеального. PHP имеет свой (не)известный жизненный цикл “запрос-ответ”, который приводит к потере каждого бита релевантного контекста после того, как входящий запрос был обработан и ответ отправлен клиенту. Репозиторий помогает нам приблизиться к этому идеальному миру, создавая иллюзию, что мы можем выполнять операции с коллекциями в памяти, которые, кажется, живут вечно. Примером Репозитория может быть:
interface UserRepository
{
public function find(UserId $id): User;
public function save(User $user): void;
public function remove(User $user): void;
}
Обратите внимание на минимальную форму этого интерфейса.
Ориентированность на коллекцию против ориентированности на постоянство
Вон Вернон, автор книги The Big Red Book (iDDD), в главе 12 упоминает ориентированные на коллекцию и ориентированные на постоянство реализации Repository. Я хотел бы кратко упомянуть этот факт, потому что именно по этой причине вы можете встретить различные “вкусы” реализаций Repository в природе. Разница заключается главным образом в семантике.
Дизайн, ориентированный на коллекцию, можно считать традиционным, поскольку он придерживается стандартного интерфейса коллекции in-memory.
$users->add($user);
Дизайн, ориентированный на постоянство(персистентность), также известен как Repository, основанный на сохранении.
$users->save($user);
Лично я предпочитаю ориентацию на постоянство из-за эфемерной природы PHP.
Авторитетная коллекция
Репозиторий — это авторитетная коллекция для взаимодействия с определенным типом сущностей. Он может использоваться для хранения, фильтрации, извлечения и удаления сущностей в зависимости от потребностей приложения. Другими словами, мы делегируем Репозиторию задачу помнить о существовании определенной сущности.
Пояснение на примере: публикация поста
Давайте рассмотрим пример использования, чтобы закрепить наше понимание.
final readonly class PublishPostHandler
{
public function __construct(
private PostRepository $posts,
) {}
public function handle(PublishPost $command): void
{
$post = $this->posts->find($command->id);
$post->publish();
$this->posts->save($post);
}
}
Этот вариант использования предполагает, что для публикации сущности Post она уже должна существовать. Поскольку PostRepository является авторитетным хранилищем для работы с этими сущностями Post, мы можем попросить его предоставить нам сущность Post для заданного PostId:
$post = $this->posts->find($command->id);
Получив экземпляр Post, мы продолжаем выполнять задачу, которую должны были выполнить в первую очередь:
$post->publish();
Метод publish раскрывает поведение, которое отвечает за фактическую “публикацию поста в блоге”. Если мы копнем немного глубже, то увидим, что он также обеспечивает соблюдение важных условий:
public function publish(): void
{
if ($this->isPublished()) {
throw CouldNotPublish::becauseAlreadyPublished();
} elseif ($this->summary->isEmpty()) {
throw CouldNotPublish::becauseSummaryIsMissing();
} elseif ($this->body->isEmpty()) {
throw CouldNotPublish::becauseBodyIsMissing();
} elseif ($this->tags->isEmpty()) {
throw CouldNotPublish::becauseTagsAreMissing();
}
// опущено для краткости
}
Если все идет хорошо, мы двигаемся дальше и говорим PostRepository запомнить Post в его текущем состоянии:
$this->posts->save($post);
В следующий раз, когда мы взаимодействуем с хранилищем PostRepository и запрашиваем точно такую же сущность, мы можем ожидать получения Post в этом состоянии. Хранилище PostRepository будет следить за тем, чтобы это условие всегда выполнялось. В конце концов, это самая важная обязанность Репозитория. PostRepository четко определяет границы вокруг прикладного сервиса, что также дает множество преимуществ, таких как изолированная тестируемость и целенаправленное сохранение (ядра) домена в неведении относительно его окружения.
Агностичность персистентности
Давайте быстро вспомним первоначальное заявление Случайного Технофлюенсера:
Сколько раз вы заменяли базовую реализацию базы данных из-за использования паттерна Repository?
Случайный Технофлюенсер фактически отговаривает от использования Репозитория, потому что “сколько раз вы собираетесь менять источники данных?”.
Теперь, пожалуйста, позвольте мне кое-что прояснить. Замена источника данных — ничтожный аргумент, независимо от того, используете ли вы его для продвижения или препятствования использованию Репозитория. Не имеет значения, к какому лагерю (продвижения/препятствования) вы принадлежите. Вы действительно хотите думать о замене источников данных в процессе проектирования домена? Такое мышление — по моему скромному мнению — ошибочно.
Тот факт, что вы можете легко менять источники данных в дальнейшем, является лишь бонусом, который вы получаете, тщательно устанавливая границы вокруг вашего приложения. Это “границы в проектировании программного обеспечения 101”, и попытка использовать это как основную торговую точку каждый раз не приносит никому пользы.
Вы можете начать свое приложение с использования простых файлов JSON на диске и постепенно переходить к более “тяжелым” решениям по мере возникновения различных потребностей.
final class UserRepositoryUsingJsonFilesOnDisk implements UserRepository
{
public function add(User $user): void
{
// добавить пользователя
}
public function find(UserId $id): User
{
// найти пользователя
}
public function remove(User $user): void
{
// удалить пользователя... вы поняли суть.
}
}
Различные функции могут развиваться независимо друг от друга, а инфраструктурные затраты могут быть сведены к минимуму. Зачем использовать дорогое облачное решение для всего, если 90% других функций хорошо подходят для такого механизма хранения, как SQLite? Зачем продолжать использовать MySQL для всех функций, если 10% функций хорошо подходят для Elastic и Riak?
Тестируемость
По аналогии с агностичностью персистентности, тестируемость — это еще один бонус, который мы получаем, тщательно устанавливая границы вокруг нашего приложения. Реальное приложение может продолжать использовать DoctrinePostRepository, в то время как тесты могут использовать InMemoryPostRepository, что позволяет нам иметь молниеносные тесты.
Тест для примера использования “публикация поста в блоге”, который упоминался ранее, может выглядеть следующим образом:
// Организация $post = $this->aPost(['id' => PostId::fromInt($id = 123)]); // черновик $repository = $this->aPostRepository([$post]); // хранилище в памяти $handler = new PublishPostHandler($repository); // Действие $handler->handle(new PublishPost($id)); // Подтверждение $this->assertTrue($repository->wasSaved($post)); $this->assertTrue($post->isPublished());
В этом примере мы тестируем службу приложения, представленную обработчиком команд. Нам не нужно проверять, что хранилище сохранило данные в базе данных или где-либо еще. Нам нужно проверить конкретное поведение обработчика, которое заключается в публикации объекта Post и передаче его в хранилище для сохранения его состояния.
“Ничего страшного”, — справедливо скажете вы, — “Я могу просто использовать персистентность во время тестирования каждый раз”. Я не уверен, знаете ли вы кого-нибудь, кто работал над проектом, чей набор тестов был полностью закрыт, потому что на все уходило слишком много времени? Я знаю такого человека, и этот человек, к сожалению, я. Интеграционные и системные/E2E тесты определенно имеют свое место, но скорость и быстрый цикл обратной связи модульных тестов все еще очень желательны.
Устранение проблем с производительностью
Производительность — еще одна причина, по которой часто используется Репозиторий. Это не редкий сценарий, когда у нас миллионы экземпляров определенного типа сущностей, поэтому мы вынуждены выгружать их во внешнее хранилище данных.
Представьте следующую выдержку из воображаемой структуры User:
public function changeEmail(Email $newEmail, Users $allUsers)
{
if ($allUsers->findByEmail($newEmail)) {
throw new CannotChangeEmail::becauseEmailIsAlreadyTaken();
}
$this->email = $newEmail;
}
Поведение changeEmail зависит от коллекции Users, чтобы определить, можно ли использовать новый адрес электронной почты. Эксперты (воображаемые) по доменам сказали нам, что изменение адреса электронной почты может не произойти, пока существует другой пользователь, владеющий этим новым адресом электронной почты.
Этот код будет отлично работать до тех пор, пока мы не наберем определенное количество пользователей. Огромный размер коллекции станет узким местом для поиска, который необходимо выполнить, чтобы обеспечить соблюдение инвариантов. Мы можем решить эту проблему, внедрив UserRepository вместо того, чтобы передавать каждого существующего User через коллекцию Users в памяти.
public function changeEmail(Email $newEmail, UserRepository $users)
{
if ($users->findByEmail($newEmail)) {
throw new CannotChangeEmail::becauseEmailIsAlreadyTaken();
}
$this->email = $newEmail;
}
Таким образом, модель домена по-прежнему будет отвечать за соблюдение условий; но нам пришлось пожертвовать чистотой модели домена в ущерб производительности. Тем не менее, это, безусловно, приемлемый компромисс.
Разделение ответственности на команды и запросы(CQRS)
“Я думал, что эта статья в блоге посвящена паттерну Repository? Что это вдруг за дело с CQRS…?”. Пожалуйста, позвольте мне объяснить.
Модели записи(команды)
До сих пор мы видели, как Репозиторий помогает нам работать с жизненным циклом доменных объектов. Мы установили тот факт, что эти доменные объекты также известны как модели записи/сущности/агрегаты, которые отвечают за последовательное выполнение изменений состояния. Другими словами, агрегаты представляют собой границу согласованности, которая должна следовать бизнес-правилам и применять их в любое время, чтобы оставаться согласованной. Естественно, эти изменения состояния всегда происходят в результате поступления команды в приложение.
Модели считывания (запросы)
Мы должны спросить себя, действительно ли нам нужно выполнить изменение состояния или нам просто нужны некоторые данные. Зачем нам “просто нужны некоторые данные”? Ну… вы правильно догадались: для запросов. CQRS — это очень простой шаблон для разделения логических моделей для задач чтения и записи — вот и все. Он не имеет никакого отношения к источникам событий/согласованности событий/разделенным хранилищам данных и т.д. Эти слова часто вбрасываются людьми, которые на самом деле не знают, о чем говорят. Случаи использования, включающие запросы, выиграют от более оптимизированных, специализированных моделей чтения.
Пояснение на примере: отображение таблицы счетов-фактур
Давайте рассмотрим пример использования, чтобы закрепить наше понимание.
final readonly class ViewInvoicesController
{
public function __construct(
private GetMyInvoices $query,
private Factory $view,
) {}
public function __invoke(Request $request): View
{
$invoices = $this->query->get();
return $this->view->make('view-invoices', [
'invoices' => $invoices,
]);
}
}
Этот сценарий использования отвечает за отображение таблицы счетов-фактур пользователю. Вся магия происходит во время этой строки:
$invoices = $this->query->get();
Обработчик запроса GetMyInvoices предоставляет нам коллекцию моделей чтения InvoiceSummary, предназначенных для этой цели. Один экземпляр InvoiceSummary может выглядеть следующим образом:
final readonly class InvoiceSummary
{
public function __construct(
public int $amountOfDiscountsApplied,
public string $paymentTerms,
public string $recipient,
public int $totalAmountInCents,
) {}
}
Зоркие глаза читателей, возможно, уже заметили, что это на самом деле Объект Передачи Данных. DTOs обычно содержат только данные и никакого поведения. Однако это именно то, что нам нужно: модель чтения, предназначенная для отображения соответствующих данных пользователю. Возможно, вы уже заметили, что эта модель не содержит никакой информации об отдельных статьях счета; и это сделано специально! Табличное представление не может отображать отдельные элементы счета. Таким образом, наша модель чтения оптимизирована и тщательно разработана именно для этого случая использования.
Модель записи может выглядеть следующим образом (любезно предоставлено Шоном МакКулом):
final readonly class LineItem
{
public __construct(private bool $isDiscount) {}
public function isDiscount(): bool
{
return $this->isDiscount;
}
}
final class Invoice
{
private RecipientName $recipientName;
private LineItems $lineItems;
public function __construct(
RecipientName $recipientName
) {
$this->recipientName = $recipientName;
$this->lineItems = LineItems::empty();
}
public function addLineItem($item): void
{
if (
$item->isDiscount()
&& $this->lineItems->hasDiscountedItem()
) {
throw CannotAddLineItem::multipleDiscountsForbidden($item);
}
$this->lineItems->add($item);
}
}
Точнее говоря, мы обратились непосредственно к самому источнику данных вместо того, чтобы пытаться впихнуть сценарий использования в модель записи счетов, которая совершенно не предназначена для выполнения специализированного сценария чтения на основе запросов. Зачем нести бремя инстанцирования этой сложной модели записи для того, чтобы выполнить сценарий использования, которому даже не понадобится ни один из строковых элементов, определенных в этой модели записи? Модель записи требует всех линейных элементов для того, чтобы поддерживать ее состояние в неизменном виде, а модель чтения — нет.
К какому уровню относится Репозиторий: к прикладному или доменному?
Мы можем рассматривать прикладной уровень как конкретный уровень в многослойной архитектуре, который обрабатывает детали реализации, уникальные для приложения, такие как персистентность базы данных, знание интернет-протокола (отправка электронной почты, взаимодействие с API) и многое другое. Теперь давайте определим уровень домена как слой в многоуровневой архитектуре, который в основном имеет дело с бизнес-правилами и бизнес-логикой.
Учитывая эти определения, где именно наши репозитории вписываются в картину? Давайте вернемся к варианту примера с исходным кодом, который мы обсуждали ранее:
final class InMemoryUserRepository implements UserRepository
{
private array $users = [];
public function find(UserId $id): User
{
return $this->users[$id->asString()]
?? throw CouldNotFindUser::becauseItIsMissing();
}
public function remove(User $user): void
{
unset($this->users[$user->id()->asString()]);
}
public function save(User $user): void
{
$this->users[$user->id()->asString()] = $user;
}
}
Я наблюдаю множество деталей реализации, которые можно рассматривать как “шум”. Следовательно, эта деталь реализации относится к прикладному уровню. Давайте удалим этот шум и посмотрим, что у нас останется:
final class InMemoryUserRepository implements UserRepository
{
private array $users = [];
public function find(UserId $id): User
{
}
public function remove(User $user): void
{
}
public function save(User $user): void
{
}
}
Вам это что-то напоминает? Может быть, это?
interface UserRepository
{
public function find(UserId $id): User;
public function save(User $user): void;
public function remove(User $user): void;
}
Размещение интерфейса на границах слоев влечет за собой следующее следствие: в то время как сам интерфейс может охватывать специфические для домена понятия, его реализация не должна этого делать. В контексте интерфейсов репозиториев они относятся к уровню домена. Реализация репозиториев относится к прикладному уровню. Следовательно, мы можем свободно использовать подсказки типов для репозиториев в пределах доменного уровня, без какой-либо зависимости от прикладного уровня.
Различные другие преимущества
Ниже приведен неполный список других преимуществ, которые может принести Репозиторий:
- Доступ к шаблону декоратора для добавления дополнительных функций без необходимости модификации домена, например, для использования чего-то вроде hashids для идентификаторов, подобных YouTube.
- Возможность реализовать шаблон транзакционного аутбокса для критически важных систем, управляемых событиями.
- Централизация логики доступа/персистентности, если приложение в основном опирается на модели данных, и вы хотите перейти от них.
- Автоматическое добавление информации о результатах аудита вместе с сохраняемой сущностью.
Подведение итогов
Через это пришлось пройти! Спасибо, что досидели до конца.
В принципе, если бы мы перечислили все преимущества использования Репозитория, агностичность персистентности определенно заняла бы последнее место или, по крайней мере, была бы близка к последнему. Поэтому я надеюсь, что мы сможем перестать принимать концепции за чистую монету и изучить их немного глубже, чтобы найти реальные случаи использования и контексты, в которых они должны применяться.
Репозиторий— это авторитетный субъект для безопасного сбора и сохранения объектов и управления их жизненным циклом- Возможность менять местами драйвер персистентности — это просто бонус
- Возможность легкого тестирования без фактического драйвера персистентности является просто бонусом
- Используйте
Репозиторийдля своих моделей записи - Не используйте
Репозиторийдля своих моделей чтения: вместо этого перейдите к источнику данных
Присоединяйтесь к обсуждению в Twitter! Я буду рад узнать, что вы думаете об этой статье в блоге.
Оригинал: https://muhammedsari.me/repositories-and-their-true-purpose