Symfony Отделение бизнес логики от PHP Doctrine

stalxed

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

Есть сущность Order. Обычный заказ клиента. Он не важен.
Есть сущность BadOrderEntry, простая сущность, в которой есть следующие поля:
- id
- order - unidirectional one-to-one relationship with Order
- createdAt - дата создания
Есть репозиторий BadOrderEntryRepository
Фабрика BadOrderEntryFactory, создающая BadOrderEntry

Необходимо сделать простой список BadOrderList:
Код:
<?php
class BadOrderList
{
    public function has(Order $order);
    public function add(Order $order);
    public function remove(Order $order);
}
При повторном добавление Order в список нужно просто ничего не делать.
Есть несколько решений этой задачи:
  1. Отлавливать Exception по коду mysql и ругани на уникальный индекс.Минус: рвётся соединение с доктриной и его необходимо восстанавливать.
  2. Самому реализовывать update if exists, так как php doctrine 2 его не поддерживает.
  3. Выполнять поиск записи, и если она не существует, то выполнять добавление. НО! Это нужно обворачивать в транзакцию. Минус: производительность. Но меня этот способ устраивает больше всех.
Хорошо, мне в BadOrderList необходимы:
  • Доступ к фабрике BadOrderEntryFactory
  • Доступ к репозиторию BadOrderEntryRepository (получаю как описано тут)
  • Доступ к \Doctrine\Common\Persistence\ObjectManager (получаю как описано тут)
Возникают вопросы:
  1. Где и как создавать транзакцию?
  2. Кто должен отвечать за сохранение/редактирование/удаление?
Попытаюсь сам на них ответить:
Создавать транзакцию может:
  1. Репозиторий BadOrderEntryRepository выполняет сам всю транзакцию целиком, включая бизнес логику.
  2. Репозиторий BadOrderEntryRepository, в нём функция transactional, которая принимает callback и выполняет транзакцию через вызов $em->transactional($callback).
  3. Некий централизованный класс TransactionMaker, в нём функция transactional, которая принимает callback и выполняет транзакцию через вызов $em->transactional($callback).
  4. Создать BadOrderEntryManager, который выполняет сам всю транзакцию целиком, включая бизнес логику. BadOrderList просто вызывает его.
  5. BadOrderList выполняет сам всю транзакцию целиком, включая бизнес логику.
  6. Другие варианты?
Сохранять/редактировать/удалять можно:
(т.е. где разместить функцию вида:
PHP:
public function save(BadOrderEntry $entry, $andFlush = true)
{
    $this->entityManager->persist($entry);
    if ($andFlush) {
        $this->entityManager->flush();
    }
}
)
  1. в репозитории
  2. создать BadOrderEntryManager и в нём
  3. в BadOrderList
Прихожу к тому, что более уместна схема:
  1. Фабрика BadOrderEntryFactory для создания BadOrderEntry
  2. Репозиторий BadOrderEntryRepository только для операций поиска.
  3. Менеджер BadOrderEntryManager для управления операциями сохранения/редактирования/удаления.
  4. Некий централизованный класс TransactionMaker для транзакций.
  5. Всё это инжектится в BadOrderList, который и управляет ими, представляя клиентскому коду всего 3 метода has, add, remove.
Сомнения в том:
  1. Не объединить ли пункты 2 и 3(т.е. методы менеджера перенести в репозиторий).
  2. Очень сильно сомневаюсь в пункте 4. Не знаю, куда вообще пристроить транзакции.
Помогите пожалуйста с моими сомнениями!
 

keltanas

marty cats
Я фиг чего понял. Почему просто не сделать для сущности заказа свойство badCreatedAt?
Что такое BadOrderEntryFactory, BadOrderList и зачем это нужно?
Поясни пожалуйста, чем не устраивают имеющиеся средства доктрины?
 

stalxed

Новичок
keltanas, спасибо за ответ.
Что такое BadOrderEntryFactory, BadOrderList и зачем это нужно?
BadOrderList- список плохих заказов.
Подобных списков сейчас насчитываю штук 5. Заказы исчерпавшие лимит, замороженные заказы, fake заказы и прочее.
Эти списки нужны для проверки при добавление в очередь исполнения и ещё ряда действий.
BadOrderEntryFactory - фабрика создающая BadOrderEntry.

Почему просто не сделать для сущности заказа свойство badCreatedAt?
Да, можно добавить boolean свойство badOrder. И ещё 4 подобных.
НО! Количество списков может увеличиваться, поэтому придётся добавлять эти свойства. Из-за новых список трогать структуру таблицы заказов?
Да и логически, зачем достаточно большую сущность Order ещё нагружать свойствами BadOrder, FakeOrder, LimitExceeded, etc?
Хотя технически это сделать просто.

Я сейчас, после раздумий, прихожу к такому коду(немного упростил, там ещё есть проверки):
PHP:
<?php
class BadOrderList
{
    private $factory;
    private $repository;
    private $manager;

    public function __construct(BadOrderEntryFactory $f, BadOrderEntryRepository $r, BadOrderEntryManager $m)
    {
        $this->factory = $f;
        $this->repository = $r;
        $this->manager = $m;
    }

    public function has(Order $order)
    {
        return $this->repository->existsByOrder($order);
    }

    public function add(Order $order)
    {
        if (! $this->has($order)) {
            $entry = $this->factory->create($order);
            $this->manager->save($entry);
        }
    }

    public function remove(Order $order)
    {
        $entry = $this->repository->findOneByOrder($order);
        if ($entry !== null) {
            $this->manager->delete($entry);
        }
    }
}
Мне нравится как выглядит.
Суть в том, что сейчас есть время, и немного обдумываю работу с PHP Doctrine. На будущее, чтобы использовать полученный сейчас опыт.

Но остаётся вопрос, как обернуть операции в методах add и remove в транзакции?
Инжектить Entity Manager или Connection(для выполнения $em->getConnection()->beginTransaction();)? Это обозначает давать доступ к сердцу PHP Doctrine.
Просто в коде выше вся зависимость к БД(и к PHP Doctrine соответственно) убрана в BadOrderEntryRepository и BadOrderEntryManager, и из-за транзакций снова приобретать зависимость?
 

keltanas

marty cats
Когда я писал badCreatedAt, то как бы намекал, что значение может принимать значения NULL|DATETIME.
На мой взгляд проще добавить одно свойство к классу, чем создавать 5 классов и дополнительную таблицу для получения сомнительных преимуществ. Тем более, что у доктрины вполне годная библиотека для миграций.
Если хочется отслеживать журнал изменения состояния заказов, я бы попробовал завести соответствующий журнал
OrderStateLog:
* Id
* orderId
* stateId (или state: varchar)
* changedAt

Мне кажется, что своим BadOrderList ты пытаешься делать работу, которую должна делать доктрина.

$this->manager->save($entry);
$this->manager->delete($entry);

Но остаётся вопрос, как обернуть операции в методах add и remove в транзакции?
А тебя в этом ничего вообще говоря не смущает? Доктрина работает через UOW.
 
Последнее редактирование:

stalxed

Новичок
keltanas, я понял мысль. Спасибо за ответ.

Но некоторые списки имеют дополнительные свойства, например FakeOrder - причина, какие данные вызывают сомнения, и т.д.
Я решил вопрос по данной теме.
Опишу решение, чтобы самому ещё раз вглянуть на него не в коде, а ясно попытаться оформить мысль.
Буду очень рад критике!

Смысл в том, что в моём приложение много блокировок PESSIMISTIC WRITE.
С производительностью всё ок, и практически все блокировки по ID.

Основная проблема возникает в пересечение бизнес логики и блокировки средствами БД.
Реализовывать собственные механизмы Optimistic Offline Lock или Pessimistic Offline Lock слишком затратно, да и не к чему.
Время жизни бизнес транзакции и "время общения" с Базой Данных приблизительно равны.

Моё решение основано на PHP Doctrine 2.

1) Для операций сохранения/редактирования/удаления использую:
  • интерфейс BadOrderEntryManagerInterface:):save(BadOrderEntry $entry, $andFlush = true), ::delete(BadOrderEntry $entry, $andFlush = true))
  • реализацию BadOrderEntryManager, которая является адаптером над механизмом EntityManager, т.е. тупо вызывает соответствующие методы EntityManager.
2) Для операций поиска использую:
  • интерфейс BadOrderEntryRepositoryInterface(existsByOrder(Order $order), findByOrder(Order $order)
  • реализацию BadOrderEntryRepository
  • реализацию BadOrderEntryLockingRepository - реализация тех же методов поиска, но с использованием Query#setLockMode(\Doctrine\DBAL\LockMode::pESSIMISTIC_WRITE)
Получается немного дублирования кода, но это лучшее, что смог придумать. Метод setLockMode для всего репозитория менее явен и может порождать ошибки.
Идею подобного разделения подчерпнул с паттерна Implicit Lock(книга Фаулера шаблоны корпоративных приложений).
3) Для создания тупую фабрику BadOrderEntryFactory:):create(Order $order)).
Наверное это лишнее, но личная шиза к фибрикам...
4) Теперь код BadOrderList выглядит так:
PHP:
<?php
class BadOrderList
{
    private $factory;
    private $repository;
    private $manager;

    public function __construct(BadOrderEntryFactory $f, BadOrderEntryRepositoryInterface $r, BadOrderEntryManagerInterface $m)
    {
        $this->factory = $f;
        $this->repository = $r;
        $this->manager = $m;
    }

    public function has(Order $order)
    {
        return $this->repository->existsByOrder($order);
    }

    public function add(Order $order)
    {
        if (! $this->has($order)) {
            $entry = $this->factory->create($order);
            $this->manager->save($entry);
        }
    }

    public function remove(Order $order)
    {
        $entry = $this->repository->findOneByOrder($order);
        if ($entry !== null) {
            $this->manager->delete($entry);
        }
    }
}
Данный класс можно даже сделать абстрактным, хотя, он вполне себе будет работать.
5) Конкретная реализация BadOrderList - BadOrderListORM
PHP:
class BadOrderListORM extends BadOrderList
{
    private $entityManager;

    public function __construct(
        BadOrderEntryFactory $f,
        BadOrderEntryLockingRepository $r, // обратите внимание!
        BadOrderEntryManager $m,
        EntityManagerInterface $em
    ) {
        parent::__construct($f, $r, $m);
        $this->entityManager = $em;
    }

    public function add(Order $order)
    {
        $this->entityManager->beginTransaction();
        try {
            parent::add($order);

            $this->entityManager->commit();
        } catch (\Exception $e) {
            $this->entityManager->rollback();

            throw $e;
        }
    }

    public function remove(Order $order)
    {
        // здесь копия метода add выше
    }
}
Я решил этим проблемы в моём коде:
1) Вложенные транзакции, теперь для классов бизнес логики других бандлов можно использовать BadOrderList, а для ORM реализаций этих классов BadOrderListORM.
2) PHP Doctrine 2 использует мульти EntityManager, теперь хотя бы в случае вложенных транзакций можно вручную проследить по классам ***ORM(например BadOrderListORM), что они используют один EntityManager, да и автоматическую проверку добавить не трудно.
3) Теперь в репозиториях есть чёткое разделение - с обычным поиском и блокирующие записи.

Как-то так.

P.S. данной архитектурой вдохновлён этим ответом и бандлом JmsPaymentCoreBundle.
P.S.S.: извините за длинный код, урезал по максимуму.
 

keltanas

marty cats
Под пессимистичной блокировкой ты понимаешь использование транзакций?
Что, интересно, делают методы $this->manager->save($entry) и $this->manager->delete($entry)?
Подобных списков сейчас насчитываю штук 5.
будешь под каждый такой огород городить?

До сих пор не понимаю, чем не устраивает обычная бизнес логика?
PHP:
$order->setBadOrder(new BadOrder);
$em->persist($order);
$em->flush();

$order->setBadOrder(null);
$em->persist($order);
$em->flush();

$repository->findOrdersThatBad();
PS: Ты еще забыл написать, что это должен быть BadEntryBundle :D
 
Последнее редактирование:

stalxed

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

> Что, интересно, делают методы $this->manager->save($entry) и $this->manager->delete($entry)?
Вызывают соответствующие методы EntityManager Doctrine ORM.

> До сих пор не понимаю, чем не устраивает обычная бизнес логика?
Я придерживался такой логики, пока был 1 список, потом 2ой, к третьему я понял, что модель Order перегружена.
Она растолстел и другими вещами, например подсчет лимитов.
Сейчас выполняю "разбиение".

> PS: Ты еще забыл написать, что это должен быть BadEntryBundle :D
Почему бы и нет? Если списков будет десяток, то создам OrderListBundle и перенесу все списки туда, т.е. OrderListBundle будет зависеть от OrderBundle, но не наоборот. Однонаправленная зависимость, не вижу никаких проблем.
 

keltanas

marty cats
Вызывают соответствующие методы EntityManager Doctrine ORM.
Все бы хорошо, но у доктрины нет соответствующих методов.
Но, даже если предположить, что ты имеешь ввиду persist и remove, то при вызове flush будет автоматически происходить
блокирование определенных записей в границах действия транзакции.
что делает не понятным для меня явный вызов транзакций.
Я придерживался такой логики, пока был 1 список, потом 2ой, к третьему я понял, что модель Order перегружена.
Чем перегружена, геттерами и сеттерами? Да и у меня есть подозрение в нецелесообразности твоего подхода на счет каких-то там списков заказов.
Она растолстел и другими вещами, например подсчет лимитов.
Может стоит попробовать выполнять посчет лимитов в субскрайберах?
Почему бы и нет?
Жэсть...
 

stalxed

Новичок
Все бы хорошо, но у доктрины нет соответствующих методов.
Я использую подход, как тут.
Считаю ужасным, когда операции создания/редактирования/удаление находятся "далеко друг от друга". Стараюсь в этом плане как-то сгруппировать подобные операции в одном месте.
Общался тут как-то с людьми, знаете некоторые что применяют?
https://github.com/mmoreram/ControllerExtraBundle#flush
Т.е. flush идёт автоматом в конце действия контролера.
Мне этот подход не нравится.

что делает не понятным для меня явный вызов транзакций.
что делает не понятным для меня явный вызов транзакций.
Следующий код необходимо оборачивать в транзакцию:
PHP:
if (! $this->has($order)) {
    $entry = $this->factory->create($order);
    $this->manager->save($entry);
}
Вначале идёт проверка существования записи в таблице, если не существует, то запись добавляется.
Если между ними вклинится параллельная транзакция, то доктрина выкинет Exception(т.к. поле orderId - уникальное).

Чем перегружена, геттерами и сеттерами? Да и у меня есть подозрение в нецелесообразности твоего подхода на счет каких-то там списков заказов.
Вы так говорите, как будто создание списков - ужасная вещь.

Может стоит попробовать выполнять посчет лимитов в субскрайберах?
Теперь это отдельная иерархия классов, построил по принципу валидаторов, главный класс, содержащий правила и 10 классов - валидаторов.

Почему жесть? Что такого в вынесение ряда классов, имеющих одну смысловую нагрузку в отдельный bundle, если зависимость bundles однонаправленная.
 
Сверху