Статьи

Реализация единицы работы — обработка доменных объектов через транзакционную модель

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

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

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

Короче говоря, это означает, что даже фиктивные приложения CRUD, которые не инкапсулируют обширные фрагменты дополнительной бизнес-логики, могут быстро стать раздутыми и создавать большие накладные расходы, когда дело доходит до выполнения нескольких операций записи в базу данных. Что если они достигнут точки, где необходимо обработать огромное количество доменных объектов, которые должны быть сохранены и удалены синхронно, без ущерба для того, что наши программисты свободно называют целостностью данных?

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

Правда, вопрос риторический. На самом деле, возможно обернуть коллекции объектов домена в довольно гибкую модель бизнес-транзакций и просто выполнить несколько операций записи / удаления базы данных за один раз, что позволяет избежать необходимости разбивать процесс на более атомарные и дорогие вызовы базы данных, которые всегда приводят к антипаттерн сеанса за операцией. Кроме того, этот основанный на транзакциях механизм опирается на академические формальности шаблона проектирования, обычно известного как Unit of Work (UOW), и его реализация в нескольких популярных пакетах уровня предприятия, таких как Hibernate , довольно плодовитая и процветающая.

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

Регистрация доменных объектов с единицей работы

В своей книге « Шаблоны архитектуры корпоративных приложений» Мартин Фаулер обсуждает два основных подхода, которым можно следовать при реализации UOW: первый делает UOW непосредственно ответственным за регистрацию или постановку в очередь объектов домена для вставки, обновления или удаления, а также вторая перекладывает эту ответственность на сами доменные объекты.

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

Облегченная реализация UOW может выглядеть так:

<?php namespace ModelRepository; use ModelEntityInterface; interface UnitOfWorkInterface { public function fetchById($id); public function registerNew(EntityInterface $entity); public function registerClean(EntityInterface $entity); public function registerDirty(EntityInterface $entity); public function registerDeleted(EntityInterface $entity); public function commit(); public function rollback(); public function clear(); } 
 <?php namespace ModelRepository; use MapperDataMapperInterface, LibraryStorageObjectStorageInterface, ModelEntityInterface; class UnitOfWork implements UnitOfWorkInterface { const STATE_NEW = "NEW"; const STATE_CLEAN = "CLEAN"; const STATE_DIRTY = "DIRTY"; const STATE_REMOVED = "REMOVED"; protected $dataMapper; protected $storage; public function __construct(DataMapperInterface $dataMapper, ObjectStorageInterface $storage) { $this->dataMapper = $dataMapper; $this->storage = $storage; } public function getDataMapper() { return $this->dataMapper; } public function getObjectStorage() { return $this->storage; } public function fetchById($id) { $entity = $this->dataMapper->fetchById($id); $this->registerClean($entity); return $entity; } public function registerNew(EntityInterface $entity) { $this->registerEntity($entity, self::STATE_NEW); return $this; } public function registerClean(EntityInterface $entity) { $this->registerEntity($entity, self::STATE_CLEAN); return $this; } public function registerDirty(EntityInterface $entity) { $this->registerEntity($entity, self::STATE_DIRTY); return $this; } public function registerDeleted(EntityInterface $entity) { $this->registerEntity($entity, self::STATE_REMOVED); return $this; } protected function registerEntity($entity, $state = self::STATE_CLEAN) { $this->storage->attach($entity, $state); } public function commit() { foreach ($this->storage as $entity) { switch ($this->storage[$entity]) { case self::STATE_NEW: case self::STATE_DIRTY: $this->dataMapper->save($entity); break; case self::STATE_REMOVED: $this->dataMapper->delete($entity); } } $this->clear(); } public function rollback() { // your custom rollback implementation goes here } public function clear() { $this->storage->clear(); return $this; } } 

Должно быть ясно, что UOW — это не что иное, как простое хранилище объектов в памяти, которое отслеживает, какие объекты домена должны быть запланированы для вставки, обновления и удаления. Короче говоря, соглашение может быть сведено к чему-то следующему: объекты домена, которые должны быть добавлены в хранилище, будут зарегистрированы как «НОВЫЕ»; те, которые обновляются, будут помечены как «грязные»; помеченные как «УДАЛЕННЫЕ» будут… ага, удалены из базы данных. Кроме того, любой объект, зарегистрированный как «CLEAN», будет храниться в памяти и храниться в памяти до тех пор, пока клиентский код явно не запросит изменение своего связанного состояния.

Конечно, метод, который выполняет эти связанные с персистентностью операции только в одной транзакции, это commit() , который использует функциональность еще неопределенного преобразователя данных для получения доступа к уровню персистентности. Для вас будет еще проще понять внутреннюю работу UOW, если я покажу вам реализацию коллабораторов, внедренных в его конструктор, поэтому вот компоненты, составляющие модуль хранения объектов:

 <?php namespace LibraryStorage; interface ObjectStorageInterface extends Countable, Iterator, ArrayAccess { public function attach($object, $data = null); public function detach($object); public function clear(); } 
 <?php namespace LibraryStorage; class ObjectStorage extends SplObjectStorage implements ObjectStorageInterface { public function clear() { $tempStorage = clone $this; $this->addAll($tempStorage); $this->removeAll($tempStorage); $tempStorage = null; } } 

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

Имея собственный класс ObjectStorage , давайте посмотрим на реализацию вышеупомянутого преобразователя данных:

 <?php namespace Mapper; use ModelEntityInterface; interface DataMapperInterface { public function fetchById($id); public function fetchAll(array $conditions = array()); public function insert(EntityInterface $entity); public function update(EntityInterface $entity); public function save(EntityInterface $entity); public function delete(EntityInterface $entity); } 
 <?php namespace Mapper; use LibraryDatabaseDatabaseAdapterInterface, ModelCollectionEntityCollectionInterface, ModelEntityInterface; abstract class AbstractDataMapper implements DataMapperInterface { protected $adapter; protected $collection; protected $entityTable; public function __construct(DatabaseAdapterInterface $adapter, EntityCollectionInterface $collection, $entityTable = null) { $this->adapter = $adapter; $this->collection = $collection; if ($entityTable !== null) { $this->setEntityTable($entityTable); } } public function setEntityTable($entityTable) { if (!is_string($table) || empty($entityTable)) { throw new InvalidArgumentException( "The entity table is invalid."); } $this->entityTable = $entityTable; return $this; } public function fetchById($id) { $this->adapter->select($this->entityTable, array("id" => $id)); if (!$row = $this->adapter->fetch()) { return null; } return $this->loadEntity($row); } public function fetchAll(array $conditions = array()) { $this->adapter->select($this->entityTable, $conditions); $rows = $this->adapter->fetchAll(); return $this->loadEntityCollection($rows); } public function insert(EntityInterface $entity) { return $this->adapter->insert($this->entityTable, $entity->toArray()); } public function update(EntityInterface $entity) { return $this->adapter->update($this->entityTable, $entity->toArray(), "id = $entity->id"); } public function save(EntityInterface $entity) { return !isset($entity->id) ? $this->adapter->insert($this->entityTable, $entity->toArray()) : $this->adapter->update($this->entityTable, $entity->toArray(), "id = $entity->id"); } public function delete(EntityInterface $entity) { return $this->adapter->delete($this->entityTable, "id = $entity->id"); } protected function loadEntityCollection(array $rows) { $this->collection->clear(); foreach ($rows as $row) { $this->collection[] = $this->loadEntity($row); } return $this->collection; } abstract protected function loadEntity(array $row); } 

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

 <?php namespace Mapper; use ModelUser; class UserMapper extends AbstractDataMapper { protected $entityTable = "users"; protected function loadEntity(array $row) { return new User(array( "id" => $row["id"], "name" => $row["name"], "email" => $row["email"], "role" => $row["role"])); } } 

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

Определение базовой модели предметной области

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

 <?php namespace Model; interface EntityInterface { public function setField($name, $value); public function getField($name); public function fieldExists($name); public function removeField($name); public function toArray(); } 
 <?php namespace Model; abstract class AbstractEntity implements EntityInterface { protected $fields = array(); protected $allowedFields = array(); public function __construct(array $fields = array()) { if (!empty($fields)) { foreach ($fields as $name => $value) { $this->$name = $value; } } } public function setField($name, $value) { return $this->__set($name, $value); } public function getField($name) { return $this->__get($name); } public function fieldExists($name) { return $this->__isset($name); } public function removeField($name) { return $this->__unset($name); } public function toArray() { return $this->fields; } public function __set($name, $value) { $this->checkAllowedFields($name); $mutator = "set" . ucfirst(strtolower($name)); if (method_exists($this, $mutator) && is_callable(array($this, $mutator))) { $this->$mutator($value); } else { $this->fields[$name] = $value; } return $this; } public function __get($name) { $this->checkAllowedFields($name); $accessor = "get" . ucfirst($name); if (method_exists($this, $accessor) && is_callable(array($this, $accessor))) { return $this->$accessor(); } if (!$this->__isset($name)) { throw new InvalidArgumentException( "The field '$name' has not been set for this entity yet."); } return $this->fields[$name]; } public function __isset($name) { $this->checkAllowedFields($name); return isset($this->fields[$name]); } public function __unset($name) { $this->checkAllowedFields($name); if (!$this->__isset($name)) { throw new InvalidArgumentException( "The field "$name" has not been set for this entity yet."); } unset($this->fields[$name]); return $this; } protected function checkAllowedFields($field) { if (!in_array($field, $this->allowedFields)) { throw new InvalidArgumentException( "The requested operation on the field '$field' is not allowed for this entity."); } } } с <?php namespace Model; abstract class AbstractEntity implements EntityInterface { protected $fields = array(); protected $allowedFields = array(); public function __construct(array $fields = array()) { if (!empty($fields)) { foreach ($fields as $name => $value) { $this->$name = $value; } } } public function setField($name, $value) { return $this->__set($name, $value); } public function getField($name) { return $this->__get($name); } public function fieldExists($name) { return $this->__isset($name); } public function removeField($name) { return $this->__unset($name); } public function toArray() { return $this->fields; } public function __set($name, $value) { $this->checkAllowedFields($name); $mutator = "set" . ucfirst(strtolower($name)); if (method_exists($this, $mutator) && is_callable(array($this, $mutator))) { $this->$mutator($value); } else { $this->fields[$name] = $value; } return $this; } public function __get($name) { $this->checkAllowedFields($name); $accessor = "get" . ucfirst($name); if (method_exists($this, $accessor) && is_callable(array($this, $accessor))) { return $this->$accessor(); } if (!$this->__isset($name)) { throw new InvalidArgumentException( "The field '$name' has not been set for this entity yet."); } return $this->fields[$name]; } public function __isset($name) { $this->checkAllowedFields($name); return isset($this->fields[$name]); } public function __unset($name) { $this->checkAllowedFields($name); if (!$this->__isset($name)) { throw new InvalidArgumentException( "The field "$name" has not been set for this entity yet."); } unset($this->fields[$name]); return $this; } protected function checkAllowedFields($field) { if (!in_array($field, $this->allowedFields)) { throw new InvalidArgumentException( "The requested operation on the field '$field' is not allowed for this entity."); } } } 
 <?php namespace Model; class User extends AbstractEntity { const ADMINISTRATOR_ROLE = "Administrator"; const GUEST_ROLE = "Guest"; protected $allowedFields = array("id", "name", "email", "role"); public function setId($id) { if (isset($this->fields["id"])) { throw new BadMethodCallException( "The ID for this user has been set already."); } if (!is_int($id) || $id < 1) { throw new InvalidArgumentException( "The user ID is invalid."); } $this->fields["id"] = $id; return $this; } public function setName($name) { if (strlen($name) < 2 || strlen($name) > 30) { throw new InvalidArgumentException( "The user name is invalid."); } $this->fields["name"] = htmlspecialchars(trim($name), ENT_QUOTES); return $this; } public function setEmail($email) { if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { throw new InvalidArgumentException( "The user email is invalid."); } $this->fields["email"] = $email; return $this; } public function setRole($role) { if ($role !== self::ADMINISTRATOR_ROLE && $role !== self::GUEST_ROLE) { throw new InvalidArgumentException( "The user role is invalid."); } $this->fields["role"] = $role; return $this; } } 

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

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

 <?php namespace ModelCollection; use ModelEntityInterface; interface EntityCollectionInterface extends Countable, ArrayAccess, IteratorAggregate { public function add(EntityInterface $entity); public function remove(EntityInterface $entity); public function get($key); public function exists($key); public function clear(); public function toArray(); } 
 <?php namespace ModelCollection; use ModelEntityInterface; class EntityCollection implements EntityCollectionInterface { protected $entities = array(); public function __construct(array $entities = array()) { if (!empty($entities)) { $this->entities = $entities; } } public function add(EntityInterface $entity) { $this->offsetSet($entity); } public function remove(EntityInterface $entity) { $this->offsetUnset($entity); } public function get($key) { $this->offsetGet($key); } public function exists($key) { return $this->offsetExists($key); } public function clear() { $this->entities = array(); } public function toArray() { return $this->entities; } public function count() { return count($this->entities); } public function offsetSet($key, $entity) { if (!$entity instanceof EntityInterface) { throw new InvalidArgumentException( "Could not add the entity to the collection."); } if (!isset($key)) { $this->entities[] = $entity; } else { $this->entities[$key] = $entity; } } public function offsetUnset($key) { if ($key instanceof EntityInterface) { $this->entities = array_filter($this->entities, function ($v) use ($key) { return $v !== $key; }); } else if (isset($this->entities[$key])) { unset($this->entities[$key]); } } public function offsetGet($key) { if (isset($this->entities[$key])) { return $this->entities[$key]; } } public function offsetExists($key) { return $key instanceof EntityInterface ? array_search($key, $this->entities) : isset($this->entities[$key]); } public function getIterator() { return new ArrayIterator($this->entities); } } с <?php namespace ModelCollection; use ModelEntityInterface; class EntityCollection implements EntityCollectionInterface { protected $entities = array(); public function __construct(array $entities = array()) { if (!empty($entities)) { $this->entities = $entities; } } public function add(EntityInterface $entity) { $this->offsetSet($entity); } public function remove(EntityInterface $entity) { $this->offsetUnset($entity); } public function get($key) { $this->offsetGet($key); } public function exists($key) { return $this->offsetExists($key); } public function clear() { $this->entities = array(); } public function toArray() { return $this->entities; } public function count() { return count($this->entities); } public function offsetSet($key, $entity) { if (!$entity instanceof EntityInterface) { throw new InvalidArgumentException( "Could not add the entity to the collection."); } if (!isset($key)) { $this->entities[] = $entity; } else { $this->entities[$key] = $entity; } } public function offsetUnset($key) { if ($key instanceof EntityInterface) { $this->entities = array_filter($this->entities, function ($v) use ($key) { return $v !== $key; }); } else if (isset($this->entities[$key])) { unset($this->entities[$key]); } } public function offsetGet($key) { if (isset($this->entities[$key])) { return $this->entities[$key]; } } public function offsetExists($key) { return $key instanceof EntityInterface ? array_search($key, $this->entities) : isset($this->entities[$key]); } public function getIterator() { return new ArrayIterator($this->entities); } } 

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

Испытание UOW

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

 <?php require_once __DIR__ . "/Library/Loader/Autoloader.php"; $autoloader = new Autoloader; $autoloader->register(); $adapter = new PdoAdapter("mysql:dbname=test", "myfancyusername", "myhardtoguesspassword"); $unitOfWork = new UnitOfWork(new UserMapper($adapter, new EntityCollection), new ObjectStorage); $user1 = new User(array("name" => "John Doe", "email" => "[email protected]")); $unitOfWork->registerNew($user1); $user2 = $unitOfWork->fetchById(1); $user2->name = "Joe"; $unitOfWork->registerDirty($user2); $user3 = $unitOfWork->fetchById(2); $unitOfWork->registerDeleted($user3); $user4 = $unitOfWork->fetchById(3); $user4->name = "Julie"; $unitOfWork->commit(); 

Оставляя в стороне некоторые не относящиеся к делу детали, такие как предположение, что где-то фактически существует адаптер PDO, логика управления более ранним сценарием должна быть довольно легко усваиваемой. Проще говоря, он демонстрирует, как все работает с UOW, которое перетаскивает некоторые пользовательские объекты из базы данных и ставит их в очередь для вставки, обновления и удаления с использованием соответствующих методов регистрации. В конце процесса commit() просто зацикливается внутри зарегистрированных объектов и выполняет все необходимые операции за один раз.

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

Заключительные мысли

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

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

Изображение через Жукова Олега / Shutterstock