Заглянем под капот Doctrine 2

Doctrine 2

Пожалуй, я не совру, если скажу, что в экосистеме Symfony наиболее часто используемой ORM является Doctrine. Поэтому изучение этой библиотеки крайне важно для Symfony-разработчика. В данной статье предлагаю заглянуть «под капот» этого Мустанга в мире ORM-ок и выяснить, на базе каких абстракций и паттернов построена эта библиотека.

Когда я впервые услышал о Doctrine, это была еще 1-я версия. Докладчик, которого я слушал, жаловался, какая это «бяка» и сколько головной боли она доставляла команде на проекте. Но с тех пор утекло много воды, и на момент написания статьи активно ведется разработка уже 3-ей версии библиотеки, существенно отличающейся от того, что она представляла собой изначально. Сами посудите, над библиотекой работают почти 600 контрибьюторов, которые зафиксировали уже почти 12 000 коммитов...однозначно, Doctrine достойна вашего внимания.

Об ORM в целом

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

Классический пример веб-приложения в плане компонентов — это UI, через который пользователь взаимодействует с приложением, backend, содержащий бизнес-логику, и persistence-уровень, в котором хранятся данные и в качестве которого выступает реляционная база данных (БД).

Учитывая, что знакомые нам реляционная БД и классические классы в ООП предполагают разные подходы к хранению данных, а также механизмы управления этими данными, возникает задача синхронизации изменений между этими уровнями.

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

ORM (Object Relational Mapper) — это как раз тот инструмент, который берет решение всех этих задач на себя. Помимо этого он предоставляет ряд дополнительных полезных возможностей, таких как Events, QueryBuilder, DQL для быстрого построения запросов и т. д.

ORM

Архитектура Doctrine 2

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

Data Mapper pattern

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

Data mapper решает эту задачу за счет изоляции объектов и БД относительно друг друга и в качестве основной, концептуальной абстракции использует Entity.

По факту, Entity — это обычный PHP класс, где свойства сопоставлены с полями таблицы из базы данных, для которой и создавалась Entity (далее «сущность»). Всю же работу по маппингу полей, вычислению изменений и т.д. берет на себя mapper.

Doctrine Data mapper pattern

EntityManager

EntityManager (EM) — это классическая реализация паттерна Facade. Через его легкий API мы работаем с рядом подсистем: Unit Of Work, Query language и Repository API. Управление сущностями выполняется исключительно через API EntityManager-а.

Entity states

Сущность всегда находится в одном из 4-х состояний: New, Managed, Removed или Detached.

Когда вы только создали объект сущности и выполнили persist($entity), начальное состояние сущности будет New, которое после отработки логики persist (диспатч события, генерация идентификатора) EM переключает в Managed. Это означает, что теперь все изменения в объекте этой сущности будут отслеживаться EM и после вызова flush зафиксированы в БД. Также состояние Managed получают сущности, которые были получены из БД.

Состояние Detach используется не так часто, но бывают ситуации, когда есть необходимость обратиться к одной и той же сущности в контексте разных EM — тогда оно и может пригодиться. Сущность в состоянии Detach не отслеживается EM. Подчеркну, это не означает, что сущность удаляется, просто все изменения в объекте после вызова detach($entity) не будут никак отражены в базе данных, даже после вызова flush. Также важно помнить, что Doctrine позволяет каскадно применить Detach для всех связанных сущностей (ассоциаций).

И последнее состояние, Removed. Очевидно, что переключение сущности в это состояние имеет смысл только в случае, если сущность находится уже в Managed. Важно понимать, что после переключения сущности в Removed состояние, EM еще отслеживает изменения, но после выполнения flush сущность будет удалена из БД. По аналогии с Detach, также есть возможность управлять каскадным удалением ассоциаций.

Identity Map Pattern

Представьте ситуацию, когда в приложении вам по какой-то причине необходимо два раза запросить один и тот же объект из БД. Стоит ли в этом случае повторно обращаться к БД? Очевидно, что это нецелесообразно, по крайней мере в большинстве ситуаций.

Более логичным выглядит использование подхода, который позволял бы задействовать результаты первой выборки. Паттерн Identity Map (карта соответствий / присутствия) как раз решает эту задачу.

Давайте рассмотрим пример из официальной документации:

public function testIdentityMap()
{
    $objectA = $this->entityManager->find('EntityName', 1);
    $objectB = $this->entityManager->find('EntityName', 1);
 
    $this->assertSame($objectA, $objectB)
}

Второй вызов find не приведет к повторному обращению к БД. Напротив, ORM-ка в карте соответствий обнаружит объект с ID = 1 и вернет уже его.

Другое дело, когда в выборке задействованы критерии:

public function testIdentityMapRepositoryFindBy()
{
    $repository = $this->entityManager->getRepository('Person');
    $objectA = $repository->findOneBy(array('name' => 'Benjamin'));
    $objectB = $repository->findOneBy(array('name' => 'Benjamin'));
 
    $this->assertSame($objectA, $objectB);
}

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

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

Lazy Loading Pattern

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

Когда это полезно? Представьте: вы работаете с системой типа «форум», где пользователи могут оставлять посты в топиках. У вас возникла необходимость запросить сущность топика из БД, и поскольку комментарии являются связанными сущностями, можно предположить, что Doctrine запросит данные по ним, в том числе. Очевидно, что это лишний, неоправданный overhead на передачу данных, лишние запросы, кроме этого, приложению потребуется больше памяти для хранения объектов комментариев.

Эту проблему Doctrine решает с помощью механизма ленивой загрузки (Lazy Loading). По умолчанию, кстати, этот механизм активирован для всех ассоциаций, всего же доступно три варианта:

  • LAZY (по умолчанию) — в память будет загружен только управляемый объект, а ассоциации будут подгружены только при первом обращении к ним;
  • EAGER — в память будет загружен как управляемый объект, так и все ассоциации;
  • EXTRA LAZY — в некоторых случаях, подгрузка всех связанных объектов нецелесообразна, даже если это происходит только при первом обращении. Скажем, вы хотите получить только количество связанных объектов Collection#count(). Для оптимального решения этой задачи можно задействовать EXTRA_LAZY опцию. В этом случае при выполнении любого из представленных ниже методов Doctrine не будет подгружать всю коллекцию в память:
Collection#contains($entity)
Collection#containsKey($key) (доступно начиная с Doctrine 2.5)
Collection#count()
Collection#get($key) (доступно начиная с Doctrine 2.4)
Collection#slice($offset, $length = null)

Proxy pattern

Для реализации lazy-механизма, а также решения partial object problematic, Doctrine на более низких уровнях оперирует, на самом деле, прокси-объектами.

Proxy-объект — это объект, который используется по месту или вместо реального объекта. Когда я говорю «по месту», имеется в виду, что мы можем запросить у EM не оригинальный объект сущности, а его прокси-вариант, и использовать аналогично оригиналу. Какие преимущества это нам дает?

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

$item = $em->getReference('MyProject\Model\Item', $itemId);
$cart->addItem($item);

Если же попытаемся вызвать любой метод из $item, его состояние будет полностью инициализировано из БД. В этом примере $item является экземпляром proxy-класса, который был сгенерирован для Item сущности. Причем обратите внимание: нам не нужно писать дополнительную логику для прокси или реального объекта, Doctrine это делает прозрачно для нас.

Partial object problematic

Partial object — это объект, состояние которого полностью не инициализировано.

С коробки подобная ситуация невозможна, так как Doctrine инициализирует сущность полностью (за исключением ассоциаций). Но у вас вполне может возникнуть желание запрашивать в качестве оптимизации не все поля сущности. Это возможно для DQL с помощью ключевого слова partial:

<?php $q = $em->createQuery("select partial u.{id,name} from MyApp\Domain\User u");

Или напрямую через EM:

$reference = $em->getPartialReference('MyApp\Domain\User', 1);

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

Transactional write-behind

Представьте, если бы каждый раз изменения какого-либо свойства сущности приводили бы к отправке запроса в БД. Очевидно, что в большинстве случаев это нежелательное поведение.

Transactional write-behind подход позволяет решить эту проблему за счет задержки между тем, когда изменились данные и когда они реально обновляются в БД. Мы можем вносить множество изменений, что-то добавлять, удалять или обновлять, но только после вызова flush все изменения уйдут в БД.

Причем Doctrine беспокоится о том, чтобы это происходило оптимальным способом. К примеру, довольно часто возникает ситуация, когда есть необходимость массовой вставки, обновления или удаления записей в таблице. Это так называемый Batch processing. За счет механизма Transactional write-behind, Doctrine справляется с этой задачей максимально эффективно.

Unit of Work pattern

Говоря о Unit of Work, необходимо упомянуть понятие бизнес-транзакции (или бизнес-действия). В контексте PHP это время от начала запуска runtime до его завершения. Задача Unit of Work следить за всеми действиями приложения, которые могут изменить БД в рамках одной бизнес-транзакции. Когда бизнес-транзакция завершается, Unit of Work выявляет все изменения и вносит их в БД, параллельно оптимизируя этот процесс.

Подведем итог

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

Спасибо, что читали, буду рад фидбеку и предложениям. Всех благ!

Как Doctrine 2 помогает повысить производительность проекта на Symfony, читайте здесь.

Stfalcon.com обладает солидной экспертизой в разработке проектов на Symfony, и мы всегда готовы поделиться опытом. Пишите нам — будем рады сотрудничеству!