Паттерн «Репозиторий». Основы и разъяснения

http://shawnmc.cool/the-repository-pattern
  • Перевод
  • Tutorial
Repository commonly refers to a storage location, often for safety or preservation.
— Wikipedia

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

Репозиторий как коллекция


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

Я хочу внести ясность в этот вопрос. Репозиторий — это коллекция. Коллекция, которая содержит сущности и может фильтровать и возвращать результат обратно в зависимости от требований вашего приложения. Где и как он хранит эти объекты является ДЕТАЛЬЮ РЕАЛИЗАЦИИ.

В мире PHP мы привыкли к циклу запрос/ответ, который оканчивается смертью процесса. Все, что пришло извне и не сохранилось — ушло навсегда, в этой точке. Так вот, не все платформы работают именно так.

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

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

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

Взаимодействие с Репозиторием


Представьте, что мы создаем сущность Member. Мы приводим объект к необходимому состоянию, затем запрос заканчивается и объект исчезает. Пользователь пытается авторизоваться в нашем приложении и не может. Очевидно, что нам необходимо сделать этот объект доступным и для других частей приложения.

$member = Member::register($email, $password);
$memberRepository->save($member);

Теперь мы можем получить доступ к объекту позже. Примерно так:

$member = $memberRepository->findByEmail($email);
// or
$members = $memberRepository->getAll();

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

Должны ли репозитории создавать сущности?


Вы можете встретить такие примеры:

$member = $memberRepository->create($email, $password);

Я видел множество аргументов приводящихся в пользу этого, но совершенно не заинтересован в подобном подходе.

Прежде всего, репозитории — это коллекции. Я не уверен в том, зачем коллекция должна быть коллекцией и фабрикой. Я слышал аргументы вроде «если обращаться удобнее так, то почему бы не повесить обработчик на подобные действия»?

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

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

В чем выгода использования репозиториев?


Основное преимущество репозиториев — это абстрактный механизм хранения для коллекций сущностей.

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

interface MemberRepository {
    public function save(Member $member);
    public function getAll();
    public function findById(MemberId $memberId);
}


class ArrayMemberRepository implements MemberRepository {
    private $members = [];

    public function save(Member $member) {
        $this->members[(string)$member->getId()] = $member;
    }

    public function getAll() {
        return $this->members;
    }

    public function findById(MemberId $memberId) {
        if (isset($this->members[(string)$memberId])) {
            return $this->members[(string)$memberId];
        }
    }
}


class RedisMemberRepository implements MemberRepository {
    public function save(Member $member) {
        // ...
    }

    // you get the point
}

Таким образом, большинство наших приложений знает только абстрактное понятие MemberRepository и его использование может быть отделено от фактической реализации. Это очень раскрепощает.

К чему относятся репозитории: Domain или Application Service Layer?


Итак, вот интересный вопрос. Во-первых, давайте определим, что Application Service Layer — это многоуровневая архитектура, которая отвечает за специфические детали реализации приложения, такие как целостность базы данных, и различные реализации работы с интернет-протоколами (отправка электронной почты, API) и др.

Определим термин Domain Layer как слой многоуровневой архитектуры, которая отвечает за бизнес-правила и бизнес-логику.

Куда же попадет репозиторий при таком подходе?

Давайте посмотрим на нашем примере. Вот код, написанный ранее.

class ArrayMemberRepository implements MemberRepository {
    private $members = [];

    public function save(Member $member) {
        $this->members[(string) $member->getId()] = $member;
    }

    public function getAll() {
        return $this->members;
    }

    public function findById(MemberId $memberId) {
        if (isset($this->members[(string)$memberId])) {
            return $this->members[(string)$memberId];
        }
    }
}

В этом примере я вижу много деталей реализации. Они, несомненно, должны входить в слой приложения

А теперь давайте удалим все детали реализации из этого класса…

class ArrayMemberRepository implements MemberRepository {
    public function save(Member $member) {
    }

    public function getAll() {
    }

    public function findById(MemberId $memberId) {
    }
}

Хм… это начинает выглядеть знакомо… Что же мы забыли?

Возможно, получившийся код напоминает вам это?

interface MemberRepository {
    public function save(Member $member);
    public function getAll();
    public function findById(MemberId $memberId);
}

Это означает, что интерфейс находится на границе слоев. и на самом деле может содержать доменно-специфические концепты, но сама реализация не должна этого делать.

Интерфейсы репозиториев принадлежат к слою домена. Реализация же относятся к слою приложения. Это означает, что мы свободны при построении архитектуры на уровне доменного слоя без необходимости зависеть от слоя сервиса.

Свобода смены хранилищ данных


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

По-моему, это не совсем правда… я бы даже сказал, что это очень плохой аргумент. Самой большой проблемой объяснения концепции репозиториев является то, что сразу напрашивается вопрос «вы действительно хотите это делать?». Я НЕ хочу чтобы подобные вопросы влияли на использование паттерна репозитория.

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

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

Тестирование при использовании паттерна «Репозиторий»


Ну, тут все просто. Давайте предположим, что у вас есть объект, который обрабатывает что-то вроде регистрации участников…

class RegisterMemberHandler {
    private $members;

    public function __construct(MemberRepository $members) {
        $this->members = $members;
    }

    public function handle(RegisterMember $command) {
        $member = Member::register($command->email, $command->password);
        $this->members->save($member);
    }
}

Во время очередной операции, я могу взять экземпляр DoctrineMemberRepository. Однако, во время тестирования легко можно заменить его на экземпляр ArrayMemberRepository. Они оба реализуют один и тот же интерфейс.

Упрощенный пример теста может выглядеть примерно так…

$repo = new ArrayMemberRepository;
$handler = new RegisterMemberHandler($repo);

$request = $this->createRequest(['email' => 'bob@bob.com', 'password' => 'angelofdestruction']);

$handler->handle(RegisterMember::usingForm($request));

AssertCount(1, $repo->findByEmail('bob@bob.com'));

В этом примере мы тестируем обработчик. Нам не нужно проверять корректность хранения данных репозитория в БД (или еще где). Мы тестируем конкретное поведение этого объекта: регистрируем пользователя на основе данных формы, а затем передаем их в репозиторий.

Коллекция или Состояние


В книге Implementing Domain-Driven Design Vaughn Vernon делает различие между типами репозиториев. Идея коллекцио-ориентированного репозитория (ориг. — collection-oriented repository) в том, что работа с репозиторием идет в памяти, как с массивом. Репозиторий, ориентированный на хранение состояний (ориг. — persistence-oriented repository) содержит в себе идею, что в нем будет какая-то более глубокая и продуманная система хранения. По сути различия лишь в названиях.

// collection-oriented
$memberRepository->add($member);

// vs persistence-oriented
$memberRepository->save($member);

Замечу, что это лишь мое мнение и пока что я придерживаюсь именно его в вопросах использования репозиториев. Однако, хотел бы предупредить, что возможно могу передумать. В конце-концов, я сосредотачиваюсь на них как на коллекциях объектов с теми же обязанностями, что и у любого другого объекта-коллекции.

Дополнительная информация


everzet создал проект на Github о репозиториях на который, безусловно, стоит посмотреть. Внутри вы найдете примеры работы с хранением в памяти и файлах.

Итоги


Я считаю, что…
  1. … важно дать репозиториям сингулярную задачу функционировать как коллекция объектов.
  2. … мы не должны использовать репозитории для создания новых экземпляров объектов.
  3. … мы должны избегать использования репозиториев как способа перехода от одной технологии к другой, так как они имеют очень много преимуществ, от которых трудно отказаться.

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

Если у вас есть вопросы или если ваше мнение отличается от моего, пожалуйста, пишите комментарии ниже.

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

Подробнее
Реклама
Комментарии 15
  • 0
    Можно абстрактный вопрос?

    Как всё-таки правильно, репозитарий или репозиторий. Я немного поискал в интернетах и пришёл к заключению, что репозитОрий — это компьютерный новояз. У Зализняка вон был только репозитарий.

    Или может начать различать репозитарий ( система контроля версий) и репозиторий (шаблон программирования)? Смотрите, как ладно это слово рифмуется со словом суппозиторий.
    • +3
      В подавляющем большинстве источников все-таки используется именно «репозиторий».
      Логично использовать максимально похожий на оригинал вариант, либо заменять термином «хранилище».
        • +1
          это из той же серии, что «браузер» и «броузер»
          • 0
            В cumputer science устоялось именно «репозиторий». Репозитарий же употребляется в контексте ценных бумаг, но и там похоже постепенно идут к написанию через «о».
          • +3
            Часто приходится использовать похожую организацию в своих проектах. Небольшое отличие: называю объекты не репозиторием (Repository), а хранилищем (Storage).
            Хранилище организовано в виде интерфейса, примерно так:

            interface StorageInterface {
                public function add($item);
                public function update($item);
                public function delete($item);
                
                public function getById($id);
                public function getByIds(array $id);
            }
            


            Далее, следует реализации интерфейса под нужные базы данных, например:

            class RedisStorage implements StorageInterface {
                ...
            }
            
            class MysqlStorage implements StorageInterface {
                ...
            }
            
            class MemoryStorage implements StorageInterface {
                ...
            }
            


            Реализации хранилищ баз данных оперируют с массивами/строками и ничего не знают о том, какие данные к ним приходят. Вся валидация и подготовка данных происходит выше.
            Следующим слоем абстракции идет реализация хранилищ сущностей в нужных базах данных. Будь то User, News, Event, Point, etc…

            class UsersRedisStorage extends RedisStorage {
                public function add(User $item);
                public function update(User $item);
                public function delete(User $item);
            }
            
            class PointsMysqlStorage extends MysqlStorage {
                public function add(Point $item);
                public function update(Point $item);
                public function delete(Point $item);
            }
            


            На этом слое, производится частичная валидация и подготовка данных. Кейсы вроде:
            — База данных принимает фиксированное число полей для сущности, а на вход пришли только обязательные поля? Не беда, добавим недостающие поля со значениями по умолчанию.
            — Хранилище базы данных работает с JSON? Сконвертируем и принятые данные и прокинем дальше.

            Реализация сущностей проста. Это класс-обертка над данными (можно назвать стрктурой), которая имеет примерно такой интерфейс:

            interface Entity {
                public function get($field);
                public function set($field, $value);
                public function imort(array $data);
                
                /**
                 * @return array
                 */
                public function export();
            }
            


            Хранилище сущностей принимает экземпляр сущности, делает экспорт данных и посылает их в реализацию базы данных. Примерно так:

            class User implements Entity {
               ...
            }
            
            class UsersRedisStorage extends RedisStorage {
                public function add(User $item) {
                    $userData = $item->export();
                    // Вот тут валидируем $userData
                    // Или переводим в json: $userData = json_encode($userData);
                    parent::add($userData);
                }
            
                public function getById($id) {
                    $userData = parent::getById($id);
                    $User = new User();
                    $User->import($userData);
                    return $User;
                }
            }
            


            Вокруг хранилища сущностей можно обернуть какую-нибудь фабрику или фасад, например.
            В итоге, система хранилищ получается очень гибкая. Быстро пишутся реализации хранения различных сущностей в различных базах данных.
            • 0
              Различие между вашими Storage и приведенными автором репозиториями в том, что последние (их интерфейс) принадлежат (или могут принадлежать) доменной области. В то время как Storage это просто внутренние сервисы, не более. Они никакого отношения не имеют к нашим доменным моделям. Скажем имея репозиторий UserRepositoryInterface можно взять вашу реализацию хранилища или взять Doctrine2 с их репозиториями, и для приложения разницы не будет.

              namespace Acme\MyApp\Repository;
              
              use Acme\Storage\Storage;
              use Acme\MyApp\Domain\Repository\UserRepositoryInterface;
              
              class UserRepository implements UserRepositoryInterface
              {
                  /**
                   * @var Storage
                  private $sorage;
              
                  function __constructor(Storage $storage) 
                  {
                      $this->storage = $storage;
                  }
              
                  getBannedUsers()
                  {
                      // ...
                      return $this->storage->find($criteria);
                  }
              
                  // ...
              }
              
              


              Ну короче вы поняли, вся соль в интерфейсе а не в названиях и реализации.
              • +1
                А если нам нужно JOIN-ить несколько таблиц? Типичная задача: вывести N последних постов активных пользователей вместе с именем автора и первым изображением, которое привязано к этому посту.

                1. В каком репозитории должен находится метод для получения этой информации? PostRepository? Тогда получается, что PostRepository «знает» о других сущностях. Как-то нехорошо.

                2. Для текущей операции нам нужны не все поля сущности Post — не хочется загружать в память тексты всех 10 постов. Нормальная ли практика возвращать «урезанный» объект?
                • +2


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

                  2. Репозиторий — это паттерн для интерфейсов, которые работают с хранением данных, не реализация
                • +1
                  Захотел откомментить, что интерфейс должен быть на уровне домена, а реализация на уровне приложения, через минуту об этом прочитал. Захотел добавить ссылку на либу пользователя everzet, увидел через минуту. =) Прям согласен с каждым словом в статье!

                  Добавлю от себя: кто-то (возможно everzet, но я не уверен) предложил очень интересный способ ускорения даже UI тестов. Нужно заменить Doctrine репозитории на репозитории на обычных файлах на время выполнения UI тестов. Т.о. у нас сохраняется persistence, но убирается лишняя нагрузка на парсинг DQL, гидрацию и пр, а так же убирается необходимость чистить БД перед каждым сценарием.
                  • 0
                    Для интеграционных тестов такой подход норм, но на функциональных/ui тестах подменять сервисы уже как-то не хорошо. Так же стоит при таком подходе не просто файлами вооружиться а иметь какие-то дата-фэктори, аля phpmachinist и т.д. которые умеют сущности конструировать по каким-то правилам. Тогда и поддержка тестов будет не сильно болезненной.
                    • 0
                      Не понял, при чем тут дата-фэктори? Мы заменяем доктрин репозиторий на репозиторий, который кладет сериализованный массив энтити в файл вместо БД. Базовый класс для такого репозитория можно взять на гитхабе по ссылке в статье, кстати — оно для этого и задумывалось. Про поддержку тестов, смотря с чем сравнивать. Если сравнивать с подходом того же everzt-а Modelling By Example, то использование data-factory на его фоне выглядит так себе.

                      Хорошо оно ли для функциональных тестов, вопрос, конечно, спорный. Я считаю, что можно написать кучу интеграционных тестов чисто на доктрин-репозитории, а UI тесты уже с моками делать. Сам пока не пробовал такой подход.
                      • 0
                        Дата фэктори гибче и проще поддерживать, нет обращений к файловой системе (быстрее чем тупо сериализованные энтити которые должны все же откуда-то взяться) и я не вижу в них ничего страшного в контексте Modelling by example.
                        • 0
                          Мы, видимо, говорим о разных вещах. Вы говорите о том, как готовить данные для тестов. А я говорю о том, как хранить данные внутри тестов. Так вот если весь сценарий проходит внутри одного процесса, то можно юзать InMemoryRepository. Если нет (UI тесты, например, где надо хранить состояние между запросами), то InMemoryRepository уже не подойдет, и надо юзать что-то, что кладет коллекции энтитей в файлы. Например что-то на основе репозитория по ссылке в статье.

                          Как готовить данные для теста это отдельный вопрос.

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.