Хочу рассказать о своем опыте использования Doctrine ODM в относительно небольшом PHP проекте, в котором основная кодовая база сосредоточена в процессах-демонах. И вообще как мы к Yii2 прикрутили Doctrine ODM. Сразу предупреждаю — рассказ будет очень нудным и скорее всего интересен только тем, кто уже сталкивался с проблемами при работе с Doctrine ODM в процессах-демонах.
Куда будем прикручивать
Лет 6 назад мне поручили задачу: выпилить из одного монолита на Yii1
часть кода в отдельный сервис. Сказано — сделано. Фреймворк выбирал не долго, кроме Yii
я ничего не знал, а тут еще объявили о скором релизе Yii2
, который в то время мне казался очень стильным/модным/молодежным, ну а знания первой версии этого фреймворка позволяли сократить порог вхождения во вторую версию. Еще из модного была MongoDB, с которой мы уже были знакомы и работали в Yii1
при помощи отдельного расширения, зато в Yii2
она поддерживалась "из коробки".
Изначально микросервис был достаточно простым: нужно было получать файлы от пользователей и сохранять их в персистентное хранилище, вести учет этих файлов в базе данных, выдавать информацию касаемо этих файлов и ссылки на них в ответ на соответствующие запросы через API.
Со временем этот микро-сервис стал расти: появилась обработка видео, проверка на вирусы, распределенное хранение по разным регионам (а-ля CDN). Время шло быстро, проект активно развивался и даже подвергался рефакторингу, но в какой-то момент, ввиду новых тенденций да и профессионального роста тоже, стало появляться ощущение какого-то дискомфорта при взаимодействии с этим проектом. Желание работать над этим проектом становилось все меньше и я всерьез задумался.
Одной из причин дискомфорта была не возможность удобной работы с Embedded Document . Неудобство заключалось в том, что приходилось иметь дело с нативными PHP
массивами. По началу это не вызвало проблем, но по мере роста проекта эти массивы начали фигурировать повсеместно, их становилось все больше, а знания о старом коде забывались и понимание всего ухудшалось. Да и вообще хотелось иметь красивые объекты, а не непонятный набор полей, индексы которых нужно постоянно помнить. Я уж не говорю о том, что такой код очень плохо связан статически.
Кроме того в PHP
в 7-ой версии дал больше возможностей по указанию типов аргументов и возвращаемых значений методов, а чуть позже и свойств. Так как ActiveRecord
Yii2
работает на магии — типы данных у свойств "моделей" можно указать, разве что, в phpdoc. В особенности проблема стояла остро при работе с MongoDB
, которая на тот момент не имела строгой схемы валидации документа и в какой-то момент в базе можно было обнаружить данные не с тем типом, что-то вроде такого {user_id : '12'}
Это крайне редко случалось, но одна мысль от такой возможности заставляла волосы шевелиться. В какой-то момент я стал задумываться о том, что Yii2 устарел. Он, конечно, сыграл огромную роль в жизни нашего проекта, можно сказать целое вехо, но мы прекрасно понимали, что ему уже пора на пенсию.
Переход на другой фреймворк
Раз уж мы теперь опытные и знаем на какие грабли точно не хотим наступить повторно, стали анализировать все, что есть на сегодняшний день с хорошей поддержкой MongoDB
.
Первым делом взор упал на Symfony
который уже из коробки имеет Doctrine ORM
, которую можно заменить и на Doctrine ODM
— аналог ORM
только для MongoDB
.
После недельного изучения сложности перехода с Yii2 на Symfony мы поняли, что нельзя просто так взять и сменить один фреймворк на другой, даже если выделить для этого относительно много времени:
во-первых — постоянно поступают новые задачи, которые нужно делать, а переход на другой фреймворк все заморозит как минимум на несколько месяцев.
во-вторых — большую проблему вызывал как раз ActiveRecord
который "намертво прибит" гвоздями в Yii2
Поэтому было решено пойти другим путем, сначала отказаться от ActiveRecord
в пользу Doctrine ODM
, а всю бизнес логику перенести исключительно в сервисный слой. Таким образом мы сможем постепенно заниматься переходом не замораживая развитие проекта, одновременно понемногу делать рефакторинг и менять одну ORM на другую.
В общем с технологиями определились. Основной точкой для работы с сущностями в Doctrine является DocumentManager
(или EntityManager если говорить Doctrine ORM). Я буду далее называть его "менеджер документов".
К тому времени мы уже активно использовали Dependency Injection Container который обнаружили в Yii2
для себя не сразу, и нам не составило труда настроить возможность внедрения менеджера документов в наши сервисы.
В Yii2
множество виджетов работает на базе DataProvider'a
. Мы сделали свою реализацию дата-провадера, который предоставляет доступ к данным через менеджер документов и попробовали сделать несколько CRUD-страниц в панели управления. Всё прошло "как по маслу" и мы только еще больше вдохновились. Всё это не выглядело инородным и прекрасно ложилось в ту архитектуру, что предлагает фреймворк Yii2
. Но все же одно дело страница, окружение которой живет лишь мнгновение, а другое дело — демон, состояние которого долгое время хранится в памяти.
Проблема виделась в том, что Doctrine построена на шаблоне UnitOfWork и менеджер документов хранит в себе состояние которое может передаваться между разными сервисами не явно, если эти сервисы используют один и тот же менеджер документов.
Используем глобальный менеджер документов
У нас с коллегой изначально возникли разногласия в том, как использовать менджер документов: держать в DI-контейнере менеджер документов синглтоном и внедрять один и тот же инстанс, или же создавать каждый раз новый инстанс менеджера. Мы остановились на варианте с синглтоном именно из-за того, что в Symfony было сделано именно так.
И почти сразу мы столкнулись с проблемами, пример я попробую объяснить в комментариях к коду:
while (!$this->isShuttingDown()) {
// получаем Task, это сущность, она будем находиться в
// состоянии MANAGED у глобального менеджера документов,
// то есть будет им отслеживаться
$task = $this->taskProvider->getNextTask();
try {
// пытаемся выполнить какую-то работу и, допустим, получаем исключение
$this->someService->runTask($task);
} catch (SomeException $e) {
// вот теперь всё плохо: мы имеем менеджер документов с неопределенным
// набором сущностей в состоянии MANAGED, а нам нужно текущую сущность
// пометить ошибкой и продолжить работу - перейти к обработке следующей задачи
$this->switchTaskToError($task, $e);
}
}
Повторяю, мы получили ситуацию когда наш менеджер документов содержит какой-то набор сущностей помеченных как dirty (то есть при вызове flush() сделанные в них изменения должны быть записаны в базу данных), но мы не можем этого сделать, так как эти данные в виду случившегося исключения могут привести к неконсистентому состоянию базы данных, не известно что там этот сервис успел добавить в менеджер документов.
Первая мысль — нужно просто вызвать clear()
у менеджера документов и "забыть" все сущности которые он отслеживал. В простейшем случае, как в нашем примере, такое скорее всего получилось бы так как кроме нашего кода больше ничего нет. Но на практике в большинстве случаев вызов clear()
вызовет проблемы на более высоком уровне, нельзя просто так взять и удалить все сущности, нужно удалить только те, что добавил $this->someService->runTask($task);
Отказ от глобального менеджера документов
После того как убедились, что работать с глобальным менеджером документов в демонах не лучшая идея (уж не говоря про то, что глобальное хранилище состояния — антипаттерн), пытаемся сделать менеджер документов не глобальным. То есть при внедрении в каждый сервис создавать новый инстанс. Конечно мы понимали, что одни проблемы заменятся другими, но проблемы с множеством менеджеров уже казались менее страшными. Мы не стали внедрять менеджер документов через контейнер, так как это сделало бы его вечно живущим в случае если он внедрен в синглтон, а нам этого не нужно. Поэтому создали фабрику которая создает менеджер документов и стали внедрять ее.
Давайте рассмотрим тот же пример кода:
while (!$this->isShuttingDown()) {
// получаем задачу, это сущность,
// она отслеживалась каким-то менеджером документов, но теперь
// ее можно считать detached, так как того менеджера уже нет,
// другие про нее ничего не знают
$task = $this->taskProvider->getNextTask();
try {
// пытаемся выполнить какую-то работу и получаем здесь исключение
$this->someService->runTask($task);
} catch (SomeException $e) {
// сохраняем данные об ошибке, в общем-то теперь здесь нет
$this->switchTaskToError($task, $e);
}
}
Здесь taskProvider
и someService
имеют в своем распоряжении фабрику, которая может создать менеджер.
Теперь в нашем коде появилось можество вызовов merge
. Метод runTask()
будет выглядет примерно так:
public function runTask(Task $task) {
$dm = $thic->dmFactory->createDocumentManager();
$task = $dm->merge($task);
....
$task->setStatus(Task::STATUS_OK);
$dm->flush();
}
Собственно метод switchTaskToError()
аналогично:
public function switchTaskToError(Task $task, Throwable $) {
$dm = $this->dmFactory->createDocumentManager();
$task = $dm->merge($task);
$task->setStatus(Task::STATUS_ERROR);
$task->setError($e->getMessage());
$dm->flush();
}
Всё дело в том, что наш локальный $dm
ничего не знает о сущности и нужно сначала заставить его отслеживать эту сущность.
И в целом это вроде не плохой подход, но все же мысль о том, что мы делаем что-то не так не развеяалась. Вот пример, который выглядит совсем плохо:
$tasks = $this->taskProvider->getProblemTasks();
$dm = $thic->dmFactory->createDocumentManager();
foreach ($tasks as $task) {
$task = $dm->merge($task);
$task->setState(..);
}
$dm->flush();
Так как getProblemTasks
это метод отдельного сервиса, то работает он со своим отдельным менеджером документов, и мы получили задачи о которых наш "местный" менеджер ничего не знает, поэтому для каждой Task
пришлось сделать merge()
. Стоит отметить, что merge()
перечитывает данные из базы — т.е. делает дополнительный запрос на каждую сущность, и как вы понимаете это большой оверхед.
Хорошо осознав новую проблему, мы стали передавать менеджер документов в другие методы, что добавило дополнительный параметр к множеству методов:
$dm = $thic->dmFactory->createDocumentManager();
$tasks = $this->taskProvider->getProblemTasks($dm);
foreach ($tasks as $task) {
$task->setState(..);
}
$dm->flush();
Зато избавило от оверхеда в виде дополнительных запросов к базе данных.
Как это сделано в JPA и причем он тут вообще
Ни для кого не секрет, что Doctrine это своеобразная реализация JPA: тот же менеджер сущностей, тот же UnitOfWork
, те же состояния сущностей (managed, detached и т.д.). Но в JPA то подобных проблем, о которых я писал выше, нет. А все потому, что имеется есть еще один слой абстракции — сессия. Вся работа с базой данных может происходить только в рамках сессии (Spring Framework оперирует только понятием транзакция и вообще эти два понятия смешаны). Подробнее о сессиях в JPA можно почитать, например, здесь.
Суть в том, что сессии позволяют манипулировать менеджерами документов так, как вам нужно. Мы можем заставить какой-то метод использовать свой собственный менеджер документов, либо взять уже существующий, который был создан кем-то из вышестоящих методов (? тут бы слова по лучше подобрать), тем самым появляется возможность объединить несколько методов в одну сессию (подробнее здесь). Я опускаю нюансы связанные с транзакциями, потому как нам это сейчас неважно, в любом случае каждая отдельная транзакция строится на базе своего отдельного менеджера документов.
Как вы уже наверное догадались, мы будем создавать аналог сессий из hibernate для нашей Doctrine.
Сессии в Doctrine ODM
Конечно первым делом я поискал информацию в интернете и по запросу "сессии doctrine" нашел лишь информацию о том, как кто-то пытается сохранить данные из http сессии в базе данных, но это нам не интересно, это не то, что нам нужно.
Итак, мы должны иметь возможность открывать и закрывать сессии и управлять их распространением. Так же сразу было принято решение, что сессии могут быть вложенными. Вместо фабрики по созданию менеджеров документов мы будем внедрять менеджер сессий и из него уже получать менеджер документов. Еще не имея реализации сделали наброски кода, как бы это выглядело:
$session = $this->sessionManager->createNewSession();
try {
$dm= $session->getDocumentManager();
$tasks = $this->taskProvider->getProblemTasks();
foreach ($tasks as $task) {
$task->setState(..);
}
$dm->flush();
} finally {
$this->sessionManager->close($session);
}
И метод getProblemTasks
public function getProblemTasks(): array
{
$repo = $this->sessionManager
->getCurrentSession()
->getDocumentManager()
->getRepository(Task::class);
return $repo->findBy(['status' => Task::STATUS_ERROR]);
}
Может показаться, что кода стало сильно больше, но не забывайте, что это очень минималистичные примеры из одной строки рабочего кода, в реальной практике выглядит в подавляющем большинстве случаев всё не так страшно.
А вот так бы выглядел еще один уже знакомый из ранних примеров метод:
public function switchTaskToError(Task $task, Throwable $) {
// создаем новую сессию, так неизвестно, что там с родительской
$session $this->sessionManager->createNewSession();
$dm = $session->getDocumentManager();
try {
$task = $dm->merge($task);
$task->setStatus(Task::STATUS_ERROR);
$task->setError($e->getMessage());
$dm->flush();
finally {
$this->sessionManager->close($session);
}
}
Вот здесь уже случай, когда кода для управления сессиями больше, чем полезной работы, поэтому мы решили для таких кейсов сделать метод wrap
, который оборачивает в новую сессию переданную функцию, но о нем позже.
Как мы видим, нам нужно как минимум 3 метода у SessionManager
createNewSession
— создает новую сессию, делает "текущей" и возвращает её.
getCurrentSession
— возвращает текущую сессию либо бросает исключение если сессия не открыта.
close
— закрывает сессию
Так как нужны вложенные сессии, то их внутри менеджера удобнее хранить в стеке (для таких целей использую расширение php-ds).
Внутри всё просто:
createNewSession
— создает новую сессию, добавляет ее в стек и возвращает.
getCurrentSession
— берет верхний элемент из стека, не удаляя, возвращает его.
close
— удаляет верхний элемент стека.
Код менеджера сессий уж приводить не буду, считаю что если вы дочитали до этого места, скорее всего без проблем сможете написать три не сложных метода, если вдруг вдохновились этим подходом.
Что же касается метода wrap
, то на практике он оказался очень удобен, пример с записью ошибки упрощается до такого варианта:
public function switchTaskToError(Task $task, Throwable $) {
$this->sessionManager->wrapBySession(function(DocumentManager $dm) use ($task, $e) {
$task = $dm->merge($task);
$task->setStatus(Task::STATUS_ERROR);
$task->setError($e->getMessage());
$dm->flush();
});
}
Подытоживая
В общем и целом механизм сессий оказался достаточно удобным. По крайней мере он решил все наши проблемы. Конечно неплохо было бы еще и управлять сессиями при помощи декларативного подхода, как это сделано в Spring Framework, например. Это позволило бы значительно сократить оверхед в виде лишнего кода, но на данный момент даже не представляю как это можно сделать в PHP.