Шаблон проектирования: Хранилище

Определение шаблону проектирования Хранилище (Репозитарий) дано Эриком Эвенсом в его книге Domain Driven Design – по его мнению это один из самых полезных и наиболее широко применяемых шаблонов проектирования из когда-либо изобретенных.

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

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

Шаблон проектирования Фабричный метод

Как мы уже упоминали во вступлении, Хранилище будет объединяться с Фабричным методом через Шлюзы (сохранение). Это также шаблоны проектирования, и, если вы не знакомы с ними, то задачей данного раздела является как раз пролить свет на данный аспект.

Фабричный метод является простым шаблоном, который задает удобный способ создания объектов. Это класс или набор классов, отвечающих за создание объектов, в которых нуждается наша бизнес-логика. Фабричный метод обычно поддерживает метод «make()», с его помощью класс распознает, как принимать всю поступающую информацию, необходимую для создания объекта и возвращает объект уже готовым к применению согласно бизнес-логике.

Более подробно Шаблон Фабричный Метод был рассмотрен в предыдущей статье: Шаблоны проектирования: Пособие для начинающих. Если вы желаете еще более предметно разобраться в этой теме, советуем вам изучить статью Agile Design Patterns.

Шаблон Шлюз

Также известен под названием «Шлюз таблицы данных» – это простой шаблон, который обеспечивает связь между бизнес-логикой и непосредственно базой данных. Его основная задача заключается в том, чтобы создавать запросы к базе данных и предоставлять полученные данные, сообразуясь со структурой понятной языкам программирования (например, массив в PHP).

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

Шаблон проектирования Шлюз подробно рассмотрен и проиллюстрирован примерами в пособии Evolving Toward a Persistence Layer. Кроме того, в том же курсе Agile Design Patterns второй урок по шаблонам проектирования также посвящен данной тем.

Проблемы, которые нам нужно решить

Дублирование при обработке данных

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

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

Из всех этих точек вы будете обращаться к тем же элементам Фабричного метода, с той же структурой данных, но, очевидно, что сами данные будут различаться. С помощью Фабричного метода объекты будут созданы и внедрены.

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

Дублирование данных при перестройке логики

Еще одна проблема, которая у нас возникнет, заключается в том, как с помощью Шлюза сформировать необходимые запросы. Каждый раз, когда нам необходима некоторая информация от Шлюза, мы должны четко представлять себе, что нам нужно. Нужны ли нам все данные по какой-либо теме? Или нам нужна только определенная информация?

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

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

Дублирование данных в хранилищах при перестройке логики

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

Основные концепции

Хранилище для извлечения данных

Хранилище может функционировать в двух направлениях: извлечение данных и запись данных.

Хранилище

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

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

Шлюз в свою очередь предоставляет исходные данные объекта (массив со значениями). После этого Хранилище принимает эти данные, совершает необходимые преобразования и вызывает соответствующие методы Фабричного метода.

Фабричный метод обеспечивает формирование объектов, созданных на основе данных, предоставленных Хранилищем. Также Хранилище будет объединять эти объекты и вернет их в виде набора объектов (например, массив объектов или набор объектов.

Хранилище для хранения данных

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

создание массивов

При использовании для хранения информации клиентского класса инициируется одно прямое обращение к Фабричному методу. Представьте себе ситуацию, когда в блоге появляется новый комментарий. Объект «Комментарий» создан для нашей бизнес-логики (класс Клиент), а затем отправлен в хранилище для хранения.

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

Точки соединения

На рисунке ниже показана общая схема, как Фабричный метод, Шлюз и Клиент интегрируются в одну систему.

Точки соединения

В центре располагается наше Хранилище. Слева — интерфейс Шлюза, реализация и непосредственно процесс сохранения данных. Справа — интерфейс для Фабричного метода и непосредственно его реализация. И вверху показан класс Клиент.

Как можно видеть по направлению стрелок, зависимости здесь обоюдные. Хранилище связано только с абстрактными интерфейсами Фабричного метода и Шлюза. Шлюз связан со своим интерфейсом и процессом сохранения данных. Фабричный метод связан только с интерфейсом.

Клиент связан с Хранилищем, которое должно иметь соответствующий формат. Существует тенденция того, что Хранилище часто имеет не настолько корректную форму, как Клиент.

не корректная форма

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

Управление комментариями к записям в блоге с помощью Хранилища

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

Комментарии

Начнем с теста, который даст нам информацию о том, что наш объект Комментария должен содержать:

class RepositoryTest extends PHPUnit_Framework_TestCase {
 
   function testACommentHasAllItsComposingParts() {
      $postId = 1;
      $commentAuthor = "Joe";
      $commentAuthorEmail = "joe@gmail.com<script type="text/javascript">
/* <![CDATA[ */
(function(){try{var s,a,i,j,r,c,l,b=document.getElementsByTagName("script");l=b[b.length-1].previousSibling;a=l.getAttribute('data-cfemail');if(a){s='';r=parseInt(a.substr(0,2),16);for(j=2;a.length-j;j+=2){c=parseInt(a.substr(j,2),16)^r;s+=String.fromCharCode(c);}s=document.createTextNode(s);l.parentNode.replaceChild(s,l);}}catch(e){}})();
/* ]]> */
</script>";
      $commentSubject = "Joe Has an Opinion about the Repository Pattern";
      $commentBody = "I think it is a good idea to use the Repository Pattern to persist and retrieve objects.";
 
      $comment = new Comment($postId, $commentAuthor, $commentAuthorEmail, $commentSubject, $commentBody);
   }
 
}

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

На основании данного примера мы можем просто предположить, что это простой объект данных. Построенный с набором переменных:

class Comment {
 
}

Просто создав пустой класс и направив к нему запрос, мы выполняем тест:

require_once '../Comment.php';
 
class RepositoryTest extends PHPUnit_Framework_TestCase {
 
[ ... ]
 
}

Но это еще далеко не совершенство. Наш тест по сути еще ничего не тестирует. Давайте пропишем все элементы, которые обращаются к этому классу:

function testACommentsHasAllItsComposingParts() {
   $postId = 1;
   $commentAuthor = "Joe";
   $commentAuthorEmail = "joe@gmail.com<script type="text/javascript">
/* <![CDATA[ */
(function(){try{var s,a,i,j,r,c,l,b=document.getElementsByTagName("script");l=b[b.length-1].previousSibling;a=l.getAttribute('data-cfemail');if(a){s='';r=parseInt(a.substr(0,2),16);for(j=2;a.length-j;j+=2){c=parseInt(a.substr(j,2),16)^r;s+=String.fromCharCode(c);}s=document.createTextNode(s);l.parentNode.replaceChild(s,l);}}catch(e){}})();
/* ]]> */
</script>";
   $commentSubject = "Joe Has an Opinion about the Repository Pattern";
   $commentBody = "I think it is a good idea to use the Repository Pattern to persist and retrieve objects.";
 
   $comment = new Comment($postId, $commentAuthor, $commentAuthorEmail, $commentSubject, $commentBody);
 
   $this->assertEquals($postId, $comment->getPostId());
   $this->assertEquals($commentAuthor, $comment->getAuthor());
   $this->assertEquals($commentAuthorEmail, $comment->getAuthorEmail());
   $this->assertEquals($commentSubject, $comment->getSubject());
   $this->assertEquals($commentBody, $comment->getBody());
}

Чтобы не растягивать данную статью до бесконечности, я прописал все операторы сразу, и запускать мы будем их также сразу. На практике же вы должны применять их по одному:

class Comment {
 
   private $postId;
   private $author;
   private $authorEmail;
   private $subject;
   private $body;
 
   function __construct($postId, $author, $authorEmail, $subject, $body) {
      $this->postId = $postId;
      $this->author = $author;
      $this->authorEmail = $authorEmail;
      $this->subject = $subject;
      $this->body = $body;
   }
 
   public function getPostId() {
      return $this->postId;
   }
 
   public function getAuthor() {
      return $this->author;
   }
 
   public function getAuthorEmail() {
      return $this->authorEmail;
   }
 
   public function getSubject() {
      return $this->subject;
   }
 
   public function getBody() {
      return $this->body;
   }
 
}

За исключением списка частных переменных, остальная часть кода была сгенерирована через IDE NetBeans, поэтому во время тестирования автоматически сгенерированного кода время от времени могут возникать накладки.

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

Эти тестовые методы и тестовые классы мы можем рассматривать в качестве класса «Клиент» из рассмотренной выше схемы.

Наш Шлюз для записи

Чтобы сделать этот пример как можно более простым, мы будем внедрять только объект InMemoryPersistence, чтобы не усложнять его еще и моментами, которые касаются файловых систем или баз данных:

require_once '../InMemoryPersistence.php';
 
class InMemoryPersistenceTest extends PHPUnit_Framework_TestCase {
 
   function testItCanPerisistAndRetrieveASingleDataArray() {
      $data = array('data');
 
      $persistence = new InMemoryPersistence();
      $persistence->persist($data);
 
      $this->assertEquals($data, $persistence->retrieve(0));
   }
 
}

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

В этом тесте мы создаем новый объект InMemoryPersistence и попытаемся записать и извлечь массив данных:

require_once __DIR__ . '/Persistence.php';
 
class InMemoryPersistence implements Persistence {
 
   private $data = array();
 
   function persist($data) {
      $this->data = $data;
   }
 
   function retrieve($id) {
      return $this->data;
   }
 
}

Это простейший код, который оперирует входящей переменной $data и частными переменными и вызывает их через метод retrieve. На данной стадии в нем не предусмотрено отсылки переменной $id.

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

interface Persistence {
 
   function persist($data);
   function retrieve($ids);
 
}

Этот интерфейс определяет два метода, которые должны применяться для любого Шлюза. Запись и вывод. Как вы, наверное, уже догадались, наш Шлюз и является классом InMemoryPersistence, а физическое сохранение является частной переменной, обрабатывающей данные в регистрах памяти.

Но давайте вернемся к реализации этой записи:

function testItCanPerisistSeveralElementsAndRetrieveAnyOfThem() {
   $data1 = array('data1');
   $data2 = array('data2');
 
   $persistence = new InMemoryPersistence();
   $persistence->persist($data1);
   $persistence->persist($data2);
 
   $this->assertEquals($data1, $persistence->retrieve(0));
   $this->assertEquals($data2, $persistence->retrieve(1));
}

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

require_once __DIR__ . '/Persistence.php';
 
class InMemoryPersistence implements Persistence {
   private $data = array();
   function persist($data) {
      $this->data[] = $data;
   }
 
   function retrieve($id) {
      return $this->data[$id];
   }
}

Для теста нам пришлось немного изменить наш код. Теперь нам нужно добавить данные в массив, а не просто заменить их на те, что были отправлены на хранение в persists(). Мы также должны рассмотреть параметр $id и вернуть элемент по этому индексу.

Для объекта InMemoryPersistence этого достаточно. При необходимости мы можем вернуться к нему позже.

Наш Фабричный метод

У нас есть клиент (наши тесты), метод записи через Шлюз и объекты комментариев, которые нужно сохранить. Следующим недостающим звеном цепи является Фабрика (Фабричный метод).

Мы начали составлять наш код с файла RepositoryTest. Затем этот тест создал реальный объект Comment.

Теперь нам нужно создать тесты для проверки того, сможет ли наша Фабрика создавать такие объекты Comment. Похоже, что на предыдущем этапе мы немного ошиблись в предпосылках и наш тест скорее был предназначен для проверки Фабричного метода, нежели Хранилища в целом.

Мы можем переместить его в другой файл CommentFactoryTest:

require_once '../Comment.php';
 
class CommentFactoryTest extends PHPUnit_Framework_TestCase {
 
   function testACommentsHasAllItsComposingParts() {
      $postId = 1;
      $commentAuthor = "Joe";
      $commentAuthorEmail = "joe@gmail.com<script type="text/javascript">
/* <![CDATA[ */
(function(){try{var s,a,i,j,r,c,l,b=document.getElementsByTagName("script");l=b[b.length-1].previousSibling;a=l.getAttribute('data-cfemail');if(a){s='';r=parseInt(a.substr(0,2),16);for(j=2;a.length-j;j+=2){c=parseInt(a.substr(j,2),16)^r;s+=String.fromCharCode(c);}s=document.createTextNode(s);l.parentNode.replaceChild(s,l);}}catch(e){}})();
/* ]]> */
</script>";
      $commentSubject = "Joe Has an Opinion about the Repository Pattern";
      $commentBody = "I think it is a good idea to use the Repository Pattern to persist and retrieve objects.";
 
      $comment = new Comment($postId, $commentAuthor, $commentAuthorEmail, $commentSubject, $commentBody);
 
      $this->assertEquals($postId, $comment->getPostId());
      $this->assertEquals($commentAuthor, $comment->getAuthor());
      $this->assertEquals($commentAuthorEmail, $comment->getAuthorEmail());
      $this->assertEquals($commentSubject, $comment->getSubject());
      $this->assertEquals($commentBody, $comment->getBody());
   }
}

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

Мы хотим создать объект Factory, передать его массиву и попробовать создать для нас объект Comment:

require_once '../CommentFactory.php';
 
class CommentFactoryTest extends PHPUnit_Framework_TestCase {
 
   function testACommentsHasAllItsComposingParts() {
      $postId = 1;
      $commentAuthor = "Joe";
      $commentAuthorEmail = "joe@gmail.com<script type="text/javascript">
/* <![CDATA[ */
(function(){try{var s,a,i,j,r,c,l,b=document.getElementsByTagName("script");l=b[b.length-1].previousSibling;a=l.getAttribute('data-cfemail');if(a){s='';r=parseInt(a.substr(0,2),16);for(j=2;a.length-j;j+=2){c=parseInt(a.substr(j,2),16)^r;s+=String.fromCharCode(c);}s=document.createTextNode(s);l.parentNode.replaceChild(s,l);}}catch(e){}})();
/* ]]> */
</script>";
      $commentSubject = "Joe Has an Opinion about the Repository Pattern";
      $commentBody = "I think it is a good idea to use the Repository Pattern to persist and retrieve objects.";
 
      $commentData = array($postId, $commentAuthor, $commentAuthorEmail, $commentSubject, $commentBody);
 
      $comment = (new CommentFactory())->make($commentData);
 
      $this->assertEquals($postId, $comment->getPostId());
      $this->assertEquals($commentAuthor, $comment->getAuthor());
      $this->assertEquals($commentAuthorEmail, $comment->getAuthorEmail());
      $this->assertEquals($commentSubject, $comment->getSubject());
      $this->assertEquals($commentBody, $comment->getBody());
   }
}

Не стоит называть наши классы теми же именами, что и шаблоны проектирования, которые они реализуют. Но Factory и Repository представляют собой больше, чем просто шаблон проектирования.

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

Данный тест слегка отличается от предыдущего, но не слишком. В нем мы попытаемся создать объект CommentFactory. Этого класса еще не существует.

Мы также попытаемся вызвать метод make() в нем вместе с массивом, в котором будут содержаться все данные о комментариях. Этот метод определяет интерфейс Фабрики:

interface Factory {
   function make($data);
}

Это довольно распространенный интерфейс Фабрики. Он задает единственный необходимый для Фабрики метод — метод, который фактически создает нужные нам объекты:

require_once __DIR__ . '/Factory.php';
require_once __DIR__ . '/Comment.php';
 
class CommentFactory implements Factory {
 
   function make($components) {
      return new Comment($components[0], $components[1], $components[2], $components[3], $components[4]);
   }
 
}

И CommentFactory успешно реализует интерфейс Factory. Применяя параметр $components для метода make(), он создает и извлекает новые объекты Comment со всей информаций.

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

Использование Хранилища для сохранения комментариев

Как мы уже сказали, Хранилище может использоваться для двух целей. Для извлечения информации, а также для сохранения информации.

Использование TDD — это (по большей части) проще, чем сперва запускать часть логики, предназначенной для записи, а затем использовать реализованный метод для проверки целостности данных:

require_once '../../../vendor/autoload.php';
require_once '../CommentRepository.php';
require_once '../CommentFactory.php';
 
class RepositoryTest extends PHPUnit_Framework_TestCase {
 
   protected function tearDown() {
      Mockery::close();
   }
 
   function testItCallsThePersistenceWhenAddingAComment() {
 
      $persistanceGateway = Mockery::mock('Persistence');
      $commentRepository = new CommentRepository($persistanceGateway);
 
      $commentData = array(1, 'x', 'x', 'x', 'x');
      $comment = (new CommentFactory())->make($commentData);
 
      $persistanceGateway->shouldReceive('persist')->once()->with($commentData);
 
      $commentRepository->add($comment);
   }
 
}

Мы используем Эммуляцию, чтобы создать виртуальное хранилище, а затем извлечь из него виртуальный объект. Затем мы вызываем в хранилище метод add().

Данный метод имеет параметр Comment. Мы ожидаем, что из хранилища будет извлечен массив данных аналогичный $commentData:

require_once __DIR__ . '/InMemoryPersistence.php';
 
class CommentRepository {
 
   private $persistence;
 
   function __construct(Persistence $persistence = null) {
      $this->persistence = $persistence ? : new InMemoryPersistence();
   }
 
   function add(Comment $comment) {
      $this->persistence->persist(array(
         $comment->getPostId(),
         $comment->getAuthor(),
         $comment->getAuthorEmail(),
         $comment->getSubject(),
         $comment->getBody()
      ));
   }
 
}

Как вы можете видеть, метод Add () довольно интеллектуальный. Он инкапсулирует информацию о том, как преобразовать объект PHP в простой массив, совместимый с хранилищем. Помните, наш шлюз хранилища обычно является общим объектом для всех данных.

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

Потом, точно так же, как и раньше мы создаем класс InMemoryPersistence, это делается довольно быстро. Мы можем использовать его, как альтернативу виртуальному шлюзу:

function testAPersistedCommentCanBeRetrievedFromTheGateway() {
 
   $persistanceGateway = new InMemoryPersistence();
   $commentRepository = new CommentRepository($persistanceGateway);
 
   $commentData = array(1, 'x', 'x', 'x', 'x');
   $comment = (new CommentFactory())->make($commentData);
 
   $commentRepository->add($comment);
 
   $this->assertEquals($commentData, $persistanceGateway->retrieve(0));
}

Конечно, если у вас нет встроенной реализации записи в хранилище, эмуляция является единственным разумным способом действий. Иначе ваш тест будет слабо применим из-за медленной скорости:

function testItCanAddMultipleCommentsAtOnce() {
 
   $persistanceGateway = Mockery::mock('Persistence');
   $commentRepository = new CommentRepository($persistanceGateway);
 
   $commentData1 = array(1, 'x', 'x', 'x', 'x');
   $comment1 = (new CommentFactory())->make($commentData1);
   $commentData2 = array(2, 'y', 'y', 'y', 'y');
   $comment2 = (new CommentFactory())->make($commentData2);
 
   $persistanceGateway->shouldReceive('persist')->once()->with($commentData1);
   $persistanceGateway->shouldReceive('persist')->once()->with($commentData2);
 
   $commentRepository->add(array($comment1, $comment2));
}

Следующий наш шаг – сделать так, чтобы можно было добавлять сразу несколько комментариев. Для вашего проекта, конечно, такой углубленный функционал может и не потребоваться, и по канонам шаблона этого тоже не требуется.

Шаблон Хранилище просто предусматривает то, что это обеспечит требования ваших пользователей и будет соответствовать нашей бизнес-логике.

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

function add($commentData) {
   if (is_array($commentData))
      foreach ($commentData as $comment)
         $this->persistence->persist(array(
            $comment->getPostId(),
            $comment->getAuthor(),
            $comment->getAuthorEmail(),
            $comment->getSubject(),
            $comment->getBody()
         ));
   else
      $this->persistence->persist(array(
         $commentData->getPostId(),
         $commentData->getAuthor(),
         $commentData->getAuthorEmail(),
         $commentData->getSubject(),
         $commentData->getBody()
      ));
}

И самый простой способ проверить нашу модель – это просто протестировать, извлекаются ли параметры массивом или нет. Если они извлекаются массивом, мы сможем обработать каждый элемент и записать его через массив, который мы сгенерировали из одного объекта Comment.

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

function add($commentData) {
   if (is_array($commentData))
      foreach ($commentData as $comment)
         $this->addOne($comment);
   else
      $this->addOne($commentData);
}
 
private function addOne(Comment $comment) {
   $this->persistence->persist(array(
      $comment->getPostId(),
      $comment->getAuthor(),
      $comment->getAuthorEmail(),
      $comment->getSubject(),
      $comment->getBody()
   ));
}

После того, как все проведенные тесты дали положительный результат, нужно провести рефакторинг, прежде чем мы сможем продолжить испытывать нашу модель дальше. Мы можем сделать это просто, применив метод Add ().

Мы извлекли один и тот же комментарий через частный метод и вызвали его в двух различных местах через общий метод Add (). Это не только снижает вероятность дублирования, но и позволяет сделать метод addOne() общим, оставляя бизнес-логике возможность решать, нужно ли нам добавлять один комментарий или несколько одновременно.

В результате этого наше Хранилище может иметь несколько реализаций: одну – через метод addOne(), вторую через — addMany ( ). Они обе являются вполне легитимными реализациями Шаблона проектирования Хранилища.

Вывод комментариев через Хранилище

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

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

function testItCanFindAllComments() {
   $repository = new CommentRepository();
 
   $commentData1 = array(1, 'x', 'x', 'x', 'x');
   $comment1 = (new CommentFactory())->make($commentData1);
   $commentData2 = array(2, 'y', 'y', 'y', 'y');
   $comment2 = (new CommentFactory())->make($commentData2);
 
   $repository->add($comment1);
   $repository->add($comment2);
 
   $this->assertEquals(array($comment1, $comment2), $repository->findAll());
}

Первый метод называется findAll(). Согласно нему должны получаться все объекты, за которые отвечает Хранилище. В нашем случае Comments. Его проверка осуществляется очень просто. Мы добавляем комментарий, затем еще один, а потом вызываем их через метод findAll() и получаем список, содержащий оба комментария.

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

function retrieveAll() {
   return $this->data;
}

Вот и все. Мы просто добавили метод RetrieveAll (), который возвращает весь массив $data из класса. Просто и эффективно. Пора применить метод FindAll ( ) к CommentRepository:

function findAll() {
   $allCommentsData = $this->persistence->retrieveAll();
   $comments = array();
   foreach ($allCommentsData as $commentData)
      $comments[] = $this->commentFactory->make($commentData);
   return $comments;
}

findAll() будет вызывать метод retrieveAll(). Этот метод создаст линейный массив данных. findAll() обработает каждый элемент и передаст необходимые данные Фабричному методу. Фабрика создаст один объект Comment. Будет создан массив с этим комментарием и через метод findAll() он будет выведен. Просто и эффективно.

Другой распространенный метод, который применяется для Хранилища — это поиск конкретного объекта или группы объектов по их характерным ключам. Например, все наши комментарии связаны с блогом через внутреннюю переменную $ PostId.

Я думаю, что согласно бизнес-логике нашего блога практически всегда нам нужно, чтобы все комментарии, связанные с конкретной записью, выводились на экран вместе с ней. Поэтому, по моему мнению, было бы вполне разумно для таких случаев применять метод findByPostId ( $ ID ):

function testItCanFindCommentsByBlogPostId() {
   $repository = new CommentRepository();
 
   $commentData1 = array(1, 'x', 'x', 'x', 'x');
   $comment1 = (new CommentFactory())->make($commentData1);
   $commentData2 = array(1, 'y', 'y', 'y', 'y');
   $comment2 = (new CommentFactory())->make($commentData2);
   $commentData3 = array(3, 'y', 'y', 'y', 'y');
   $comment3 = (new CommentFactory())->make($commentData3);
 
   $repository->add(array($comment1, $comment2));
   $repository->add($comment3);
 
   $this->assertEquals(array($comment1, $comment2), $repository->findByPostId(1));
}

Мы просто создаем три комментария. Первые два имеют одинаковое значение $ PostId — 1, третий имеет $ PostId = 3. Добавим все три записи в хранилище, после чего после запроса findByPostId () при $ PostId = 1 мы ожидаем, что будет выведен список из первых двух комментариев:

function findByPostId($postId) {
   return array_filter($this->findAll(), function ($comment) use ($postId){
      return $comment->getPostId() == $postId;
   });
}

Реализация данного метода будет уже сложнее. Сначала нам нужно найти все комментарии, используя уже реализованный метод findAll(), и отфильтровать массив. У нас нет возможности применить фильтр в хранилище, поэтому это придется делать отдельно.

Код будет запрашивать каждый объект, и сравнивать его $postId с тем параметром, который мы зададим. Отлично. Тест пройден. Но кое-что мы, кажется, все-таки упустили:

function testItCanFindCommentsByBlogPostId() {
   $repository = new CommentRepository();
 
   $commentData1 = array(1, 'x', 'x', 'x', 'x');
   $comment1 = (new CommentFactory())->make($commentData1);
   $commentData2 = array(1, 'y', 'y', 'y', 'y');
   $comment2 = (new CommentFactory())->make($commentData2);
   $commentData3 = array(3, 'y', 'y', 'y', 'y');
   $comment3 = (new CommentFactory())->make($commentData3);
 
   $repository->add(array($comment1, $comment2));
   $repository->add($comment3);
 
   $this->assertEquals(array($comment1, $comment2), $repository->findByPostId(1));
   $this->assertEquals(array($comment3), $repository->findByPostId(3));
}

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

Эти простые дополнительные операторы или методы тестирования могут выявить скрытые проблемы. Как в нашем случае array_filter () не переиндексирует массив на выходе. И, так как у нас уже есть массив с корректными элементами, все индексы перепутались:

RepositoryTest::testItCanFindCommentsByBlogPostId
Failed asserting that two arrays are equal.
--- Expected
+++ Actual
@@ @@
 Array (
-    0 => Comment Object (...)
+    2 => Comment Object (...)
 )

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

На самом деле проблема с логикой была в CommentRepository.

function findByPostId($postId) {
   return array_values(
      array_filter($this->findAll(), function ($comment) use ($postId) {
         return $comment->getPostId() == $postId;
      })
   );
}

Да, вот так вот просто. Мы просто перед выводом пропускаем результат через array_values(). И тогда наш массив красиво индексируется. Миссия выполнена.

Несколько слов напоследок

Вся большая миссия по построению нашего Хранилища (Репозитария) также успешно завершена. У нас есть класс, который может быть использован любым другим классом бизнес-логики. С его помощью можно довольно просто сохраняться и извлекать объекты.

Он также отделяет бизнес-логику от Шлюзов Фабрики и сохраняемых данных. С его помощью можно существенно уменьшить количество дублирований и значительно упростить операции по сохранению и выводу наших комментариев.

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

Хранилища разнятся между собой, в зависимости от типа объектов, с которыми они работают, нет какого-то общего шаблона.

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

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

Если используется кэширование, Хранилище можно успешно сочетать с Одиночным шаблоном проектирования. Как обычно, спасибо за ваше время. Я искренне надеюсь, что вы узнали сегодня что-то новое.

Перевод статьи «The Repository Design Pattern» был подготовлен дружной командой проекта Сайтостроение от А до Я.