Как стать автором
Обновить

Комментарии 52

Интересно) Надо будет на Yii2 попробовать такое разделение и автоматизацию…
На Yii2 это можно решить поведениями в любом наследнике yii\base\Component. Практически один в один стандартные события как в Lifecycle Events. Что Вы имели в виду под разделением?

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

На самом деле самая тривиальная проблема мейнтейнить рабочий проект, в котором вот такое как Вы сказали. Самим часто достаётся подобное. Вы написали про разделение, в Yii2 поведениями решается всё, от трансформации атрибутов модели до запусков бизнес логики. Поэтому у нас в компании действует правило, мы не пишем бизнес логику в поведениях. Та часть процессов, запуск которых связан с изменением AR мы явно вызываем в переопределённых методах insert(), update(), delete().
public function insert($runValidation = true, $attributes = null)
{
    Yii::$app->MoexOperation->clearing($this);
    return parent::insert($runValidation, $attributes);
}

Ничего поэтичного зато выделено. А вы как разделяете?
Я сделал trait, который берёт из свойств $beforeSave и $afterSave массив функций(с ключом приоритета), и вызывает их в аналогичных методах с приоритетом
А что если при событии необходимо изменять (и сильно) другие сущности? Есть какой-то путь для этого?
Если я правильно понял вопрос, то для Вашего случая подходят EntityListener (описаны в статье). Внутри LifecycleEventArgs, который передается в качестве события во внутрь EntityListener, есть ссылка на EntityManager, через который, в свою очередь, через getRepository() Вы можете получить доступ к любому объекту, а также к его изменению.
Делать это на событиях доктрины — очень плохая практика. docs.doctrine-project.org/projects/doctrine-orm/en/latest/reference/events.html#implementing-event-listeners — здесь подробно описаны ограничения- что можно и чего нельзя делать в каждом конкретном событии, но даже после досконального изучения можно словить очень нетривиальные ошибки.
В том-то и беда, что практика уже была :) Столкнулись со всеми прелестями после обновления доктрины, поэтому и нужны пути обхода.
Мы пришли к использованию своих событий через dispatcher, это даёт больше контроля и снимает привязку к доктрине.
Никто и не использует Lifecycle Callbacks для бизнес логики. Вряд ли установку текущей даты внутри Entity можно отнести к бизнес-логике.
А к чему же отнести, как не к бизнес-логике? Она, самая. Информация о изменении заказов нужна менеджеру, нужна бизнес-аналитику.
Ну, между прочим, в презентации выше сказано что установка даты изменения — это ок.

Но формирование аудита или лога изменений — уже не ок.

Вы говорите про LifecycleCallbacks (в котором бизнес логике быть не должно) или про внешние EntityListener / DoctrineEventListener, в которых формирование аудита или формирование лога изменений (особенно если этот лог не связан с БД в которой идут изменения, например лог пишется в файл) вполне ОК?

Вот посмотрите пример на самом doctrine
docs.doctrine-project.org/projects/doctrine-orm/en/latest/reference/events.html#preupdate

public function preUpdate(PreUpdateEventArgs $eventArgs)
    {
        if ($eventArgs->getEntity() instanceof Account) {
            if ($eventArgs->hasChangedField('creditCard')) {
                $this->validateCreditCard($eventArgs->getNewValue('creditCard'));
            }
        }
    }


Валидация номера кредитной карты — это вполне себе бизнес логика.
вполне ОК?

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


Вот посмотрите пример на самом doctrine

Увы документация к доктрине далеко не соответствует их же лучшим практикам. Ребята вроде Окромиуса это довольно часто говорят и это один из основных пунктов к "переписать" для 3-ей версии.


Валидация номера кредитной карты — это вполне себе бизнес логика.

если вы посмотрите doctrine best practice то вы увидите что этот пример не имеет смысла. так как у вас никогда не должно быть невалидного состояния в сущностях. Подобная валидация должна происходить еще на подходе к сущности (в крайнем случае в методе который меняет стэйт). Поменять стэйт а потом разбираться что пошло не так — не очень корректный вариант ведения дел.

Да, не ок. Ну и наверно в контексте заказа дата изменений тоже относится к бизнес логики…
Сохранение OrderHistory это бизнес-логика. Да даже сохранение текущей даты, это бизнес-логика.

Отдельно распишу преимущества:


  • доменные ивенты проще в отладке
  • доменные ивенты по хорошему должны запускаться когда все хорошо, то есть мы можем скажем по postFlush их обработать и мы точно знаем что транзакция была успешна
  • доменные ивенты позволяют переходить на более сложные вещи вроде саг. Когда одна логическая транзакция должна либо инициировать другую либо, в случае если что-то пощло не так, компенсировать предыдущие транзакции. Пример. Первая транзакция создала заказ со статусом pending, это привело к доменному ивенту OrderSubmitted например. По этому ивенту кто-то запускает новую логическую транзакцию например для проведения оплаты. Если оплата не удалась, выкидываем событие PaymentFailed, по которому мы меняем статус заказа на failed.

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

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

$user = new User($email,$password); // создаем eventUserCreated
$user->changeShippingAddress($address) // создаем eventAddressChanged
$user->changeLegal($lega) //создаем eventLegalChanged.


Любой из eventUserCreated, eventAddressChanged, eventLegalChanged должен вызывать отправку смс.
Как вы предлагаете решать задачу, когда СМС должна уходить только одна (после последнего изменения)?

UPD. Это для AR, где save() может вызываться не один раз.

Для того что бы более-менее корректно вам что-то посоветовать мне нужно лучше понимать вашу бизнес логику. Потому несколько уточняющих вопросов:


  • Пользователь может существовать с пустыми значениями для даты рождения, адресов прописки и т.д.? Или это обязательная информация для регистрации?
  • Эти данные меняются по отдельности или вместе? Или иногда по отдельности и иногда вместе? Приведите примеры.
  • Имеет ли смысл событие AddressChanged если это по сути инициализация значения?
Пользователя я привел в качестве примера, чтобы не расписывать тут бизнес логику на лист А4.
Но продолжим с пользователем:
Пользователю для регистрации в магазине достаточно заполнить email.
Дальше он может в ЛК указать ФИО, дату рождения. Если он захочет заказать посылку — он может указать адрес, если адрес не указан — с ним свяжется менеджер и заполнит его данные в админке.
То есть существование отдельных методов на указание адреса, даты рождения отдельно от регистрации оправданно и существует.

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

Допустим, такой вариант кода:
<?php

class User
{
    public function changeInfo($userInfo)
    {
        ....
        $this->rememberThat(new UserChangeInfo($this));
    }

    public function changeAddress($adress)
    {
        ....
        $this->rememberThat(new UserChangeAdress($adress));
    }

    public function changeUserAll($userInfo, $adress)
    {
        $this->changeInfo($userInfo)
        $this->changeAddress($adress);
    }
}


Наш subscriber подписан на UserChangeInfo, UserChangeAdress.
Пока в коде проблема — при вызове changeUserAll метод subscriber будет вызван 2 раза.
НЛО прилетело и опубликовало эту надпись здесь

kernel.terminate не вызывается в консоле. Конкретно в этом случае это не важно, но если, к примеру, данные будут обновляться по крону из какой-нибудь API, то как тогда?

НЛО прилетело и опубликовало эту надпись здесь

если что, кроме kernel.terminate есть еще console.terminate (пруф)

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


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

А как выглядит у вас событие, например, UserChangeInfo? И где вызывается changeUserAll?
Ну допустим new UserChangeInfo($user, array $infoOld, array $infoNew).
changeUserAll — вызываю в контроллере настроек пользователя.
changeAddress, changeInfo — могу вызвать и в админке менеджеров, и в контроллере настроек пользователя.

Я в это случае создаю CQRS команду на отправку SMS уведомления на каждое событие и кладу ее в очередь уникальных команд и по крону разбираю очередь.
Это даёт нам и сохранение уникальности уведомлений и позволяет не тормозить клиент на отправку SMS и контролировать нагрузку на сервер отправки SMS.

Понятно, смс не подходящий пример так как можно отправлять через очередь.
Давайте на примере заказов:
Пользователь создал заказ:
1) Нужно проверить проверить была ли ссылка на оплату реферальной, если была — пользователю «рефералу» зачислить бонусные баллы.
2) При первом создании заказа начислить пользователю бонусные баллы.
3) После оплаты заказа начислить пользователю бонусные баллы, если он оплатил товар акционной категории.

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

Так у нас будет два события:
— заказ создан
— заказ оплачен
После каждого из них мы должны пересчитать бонусные баллы.
И есть три метода:
— создать заказ
— оплатить заказ
— создать заказ и оплатить его (внутри последовательно вызываем «создать заказ» и «оплатить заказ»)

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

Сам я как раз сделал «уникальные команды», но в рантайме, да еще и с приоритетами (сначала обрабатываем события непосредственно оплаты, потом события бонусной программы, потом можем и СМС послать)

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

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


ну то есть, я может быть плохо понял задачу


1) Нужно проверить проверить была ли ссылка на оплату реферальной, если была — пользователю «рефералу» зачислить бонусные баллы.

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

Согласен с Fesor. Очень трудно говорить не зная особенностей бизнеса.
Например мне не понятно почему вы не можете пересчитывать бонусные баллы реферала дважды?
Они пересчитываются по разным событиям и количество начисляемых баллов может, а скорей всего и будет, различаться. Также хорошо бы сохранить в истории начисления баллов за что их начисляли каждый раз. То есть опять же нужно сохранять их раздельно.


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

Добавлю и свою либу
https://github.com/gpslab/domain-event
бандл для интеграции с Symfony
https://github.com/gpslab/domain-event-bundle


Кроме стандартного агрегирования событий ещё позволяет обрабатывать события в самой сущности, имеет функционал для реализации слушателей, подписчиков, очередей и middleware

позволяет обрабатывать события в самой сущности

Это для воспроизведения событий? ну мол event sourcing?

Да. И не только. Например для соблюдения SRP, часть кода можно вынести в обработчик событий, но чтоб не размазывать бизнес логику по проекту, её можно сохранить в сущности.
Например, тот же пример от Fantyk можно решить через обработку событий в сущности заказа.

Пересчет бонусов в сущности заказа?

Скорей: начисление бонусов рефералу за создание заказа.
Пересчет делается в сущности реферала, а вот инициировать процедуру начисления бонусов можно через заказ.

udidahan.com/2009/06/29/dont-create-aggregate-roots/ — вот тут описан схожий пример, к слову.


инициировать процедуру начисления бонусов можно через заказ.

У меня подобное подвешено на события от заказа, в частности когда заказ становится оплаченным.

У меня подобное подвешено на события от заказа, в частности когда заказ становится оплаченным.

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

фактически ивент тригирит следующую "команду", начислить бонусы, которая относится уже к другому контексту. Так что вроде бы все хорошо.

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

Symfony — это же event-based framework, кастомные ивенты для бизнес логики приложения вам в помощь: symfony.com/doc/current/event_dispatcher.html
Ну максимум в доктриновский листенер можно положить время изменения записи, но отправлку письма — это уже переборп.
Вы также внутри EntityListener можете использовать обычные Event через EventDispatcher. Т.е. не реализовывать логику отсылки письма внутри Listener, а вынести ее во вне.
В статье подход упрощенный и акцент сделан не на конкретную реализацию, а на общий принцип.
Помимо вышеупомянутых минусов есть еще проблема производительности. Событие будет вызываться при изменении/создании любой сущности, и для каждой вы будете делать instanceof.

и это будет весьма неплохой источник тупых багов.

Сейчас же есть entity_listener'ы они привязываются к конкретной сущности
НЛО прилетело и опубликовало эту надпись здесь
Вызов метода dispatch достаточно легко заменяется на прямой вызов метода из сервиса, который выполняет все поставленные задачи.

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


Словом, я бы не стал так категорично говорить о том что "ивенты не нужны". Главный вопрос — как вы с ними работаете. Мне к примеру ни к чему отслеживать "цепочки событий". Мне важно найти причину и следствие, и это как раз таки весьма просто становится сделать. И еще проще — переместить причину (тригер операции какой-то) в виду изменений в требованиях.

Тот случай, когда добавляешь статью в избранное в том числе ради комментариев)

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории