Pull to refresh
12
0
Артем Родыгин @arodygin

Веб разработчик

Send message
Да, пожалуй, пример получился слишком уж «сферическим», но поверьте — я этого не имел в виду. Экшен, реагирующий на запрос создания, и в моем бы исполнении возвращал ID созданного ресурса, но обязательно не из команды, а именно последующим запросом непосредственно в экшене (я даже упоминал об этом вскользь в этом комментарии). Но, сфокусировавшись на «С»-составляющей из CQRS и стремясь максимально упростить код, я действительно опустил это в «newAction» из примера. Только после вашего коммента я понял, что этого не следовало делать — это позволило бы избежать повторяющегося вопроса в комментариях «а что делать, если мне нужно вернуть ID ресурса». Честно говоря, когда я писал статью, я почему-то думал, что мне не нужно объяснять, что такое Command Bus и как его готовить (я, разумеется, не про вас) — целью статьи было лишь поделиться частным опытом конкретной реализации. Был не прав. )
Ну, тогда лично я буду считать, что мы разобрались. Говорим мы об одном и том же, даже если я и путаюсь в используемой вами терминологии.
Каюсь, виноват. Сейчас мне уже очевидно, что у меня извращенное понимание бизнес-логики. Для меня это «юз-кейсы», типа «отредактировать юзера», «послать юзеру письмо» и пр. И мне странно, когда люди создают в сущностях соответствующие функции, типа «editUser» или «sendEmail». Тем более что во многих «юз-кейсах» вовлечено более одного типа сущностей.

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

Возможно, кто-то скажет «тонкая грань», но для меня критерий очевиден — если некая функция требует внешних зависимостей, значит это бизнес-логика, которую надо выносить из сущности. Делать сущность с DI-зависимостями — преступление. А передавать эти зависимости через параметры функции — по-моему, неудобно.
То есть, в контроллер приходит http-запрос, он преобразуется в команду (хоть тупо в самом контроллере установкой свойств команды из параметров запроса, хоть формой, хоть ParamConverter'ом), проходя легкую техническую валидацию (валидный json/xml, правильный формат команды и т. п.), вызывает обработчик команды, который, или создаёт новую сущность, сохраняя её в «репозитории», или достаёт существующую из репозитория и вызывает метод (очень редко несколько), изменяющие её состояние, плюс сервисные штуки типа логирования или генерации событий. Обработчик команды не возвращает результата напрямую (кроме исключений, информирующих об ошибке в обработке команды, в том числе об ошибках бизнес-валидации, [...]), но контроллер после успешного выполнения команды вызывает соответствующий запрос или запросы (особенно характерно для классических приложений) для получения данных для представления.

Не уловил — в чем буквальность? Вы же по сути слово в слово изложили то, что я и делаю. :) Мне импонирует, что кто-то еще делает также, но непонятно, почему я оказался «буквальнее» вас.
(промахнулся)
Тот же Marco Pivetta, видео которого вы мне ранее порекомендовали посмотреть (кстати, посмотрел — спасибо), тоже использует "ramsey/uuid" — http://ocramius.github.io/doctrine-best-practices/#/64.
Также интересно в чем плюс SimpleBus по сравнению с tactician?
Поковырял я Tactician, посравнивал исходники этих двух библиотек, посмотрел на историю развития и даже погуглил альтернативы. Результаты:

1. Кроме этих двух библиотек достойных альтернатив не найдено. Обе предоставляют «plain vanilla» реализацию, которую можно использовать в любом PHP-проекте, а также целую пачку плагинов, с помощью которых можно прикрутиться к Symfony, Laravel, привязаться к Doctrine (для автотранзакций команд), или завязаться на RabbitMQ, или на Bernard (чтобы выполнять команды в фоне).

2. Обе библиотеки появились примерно в одно время, имеют сравнительно равное количество форков и звезд. В настоящий момент архитектура и технические решения обеих библиотек в целом идентичны (хотя год назад SimpleBus был сильно другим). То ли они эволюционировали одинаково, то ли SimpleBus в итоге «причесался» под Tactician — не знаю.

3. Сами авторы (Ross Tuck и Matthias Noback) постоянно ссылаются на библиотеку «соседа» в своих постах, твитах, и даже на конференциях, когда выступают на эту тематику. Matthias Noback (SimpleBus) также признался, что когда начинал работу над SimpleBus, много общался с Ross Tuck на тему дизайна такой библиотеки. Может, поэтому они такие одинаковые. )

4. Плюсы Tactician — можно сконфигурировать несколько command bus.

5. Плюсы SimpleBus — помимо command bus есть еще и event bus.

Других отличий не нашел.
Если что, вот хорошая реализация UUID — ramsey/uuid.
Честно говоря, даже не знаю, может написать отдельную статью в качестве продолжения? Общую мысль я выразил в этом комментарии, возможно, этого будет достаточно.

Сам я в качестве Query просто использую custom repositories, которые оформляю как сервисы. Т.е. например я могу описать для себя интерфейс ProjectRepositoryInterface и реализовать класс, вроде такого:


    class ProjectRepository extends EntityRepository implements ProjectRepositoryInterface
    {
        ...
    }

в который спрятать все нюансы получения данных из БД. Далее регистрируем репозиторий как сервис:


    repository.projects:
        class: Doctrine\ORM\EntityRepository
        factory: [ "@doctrine.orm.default_entity_manager", "getRepository" ]
        arguments: [ AppBundle\Entity\Project ]

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

Обращаю внимание — используя подобные репозитории вам не придется "инжектить" в команды саму "@doctrine", вместо этого вы "инжектите" только те репозитории, которые там реально нужны. Сильно рекомендую.

P.S. Кто-нибудь знает, как сделать оформленный код в комментариях? В статье работает, в комментариях — нет. Или у меня просто карма маленькая?
Дело в том, что Command Bus все же больше заточен под распределяемые системы, а с распределенностью приходит асинхронность. В асинхронной системе вы не станете ждать завершения команды, вы отдадите ее на исполнение и пойдете дальше заниматься другими делами. Когда команда завершена, вам прилетит сообщение, содержащее необходимую инфу (например, тот самый ID созданного ресурса), которую вы отправите обратно клиенту.

Впихивая Command Bus в синхронное CRUD-приложение, приходится отказываться от прямого получения ID созданного ресурса (строго говоря, это единственное, от чего мне пришлось отказаться при переходе на Command Bus). Если я использую UUID (а вам стоит его использовать, если система планируется быть распределенной), то я просто генерирую очередной UUID до вызова команды и передаю его в команду; после успешного завершения команды я возвращаю UUID клиенту. Если у меня автоинкремент, то как правило на соответствующей таблице БД есть другие уникальные индексы (название проекта в примере из статьи, или login только что зарегистрированного пользователя), по которым можно найти только что созданную запись и вернуть ее ID. Хотя это уже немного "костыльно", не спорю.

В целом — Command Bus не является единственной панацеей от "толстых" контроллеров и от привязанной к фреймворку бизнес-логики. Это лишь пример подхода. С таким же успехом можно перенести бизнес-логику в сервисы, где вместо хэндлеров команд будут аналогичные функции, и вызывать их вместо "command_bus->handle". Руки никто не выкручивает. ;)
Спасибо за наводку! Как раз на прошлой неделе перенес все на SendGrid по той же причине, а оказывается есть более "вкусные" варианты.
Да, похоже я застрял где-то в 2.3 еще. За подсказку спасибо, плюсанул.
Ваше заявление несколько противоречин «официальной позиции» разработчиков Doctrine
Беда. :)

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

А поскольку Doctrine не является частью Symfony о каких best practice мы вообще говорим?
Я такого не говорил (хотя пара других комментаторов действительно их упоминали).

Вообще-то скорее одна сущность и внутри может быть иерархия value object-ов.
Я выше привел пример — было бы интересно узнать, как бы вы рекомендовали реализовать это.
Если есть дублирование, то может стоит от него избавится. Вы так не считаете?
Если честно — нет. Принцип DRY надо готовить с умом и без фанатизма. Если следовать ему слепо, например генерируя общие классы-предки только для того, чтобы вынести туда три повторяющихся поля, то вы:
  • усложняете ваше семейство классов,
  • усложняете понимание системы новым на проекте людям,
  • вырываете себе волосы, когда один из двух классов-потомков вдруг меняется так, что исчезает та часть, которую вы считали общей, и ваш класс-предок отныне имеет лишь одного потомка.

Почему вы считаете что ORM не может быть объектом предметной области?
Да я в общем-то так и не считаю. Я лишь считаю, что ORM не обязана быть объектом предметной области.

Хотите пример? На одном моем проекте пользователи могут создавать шаблоны неких псевдо-документов, указывая, какие поля эти документы должны иметь. Поля могут быть разных типов — логическое (что в UI превращается в checkbox), целочисленное, вещественное и строковое. Создавая в своем шаблоне строковое поле, пользователь должен указать ограничение на длину значений в этом поле. Создавая численные — диапазон допустимых значений. У логических полей вообще нет ограничений.

Все эти «кастомные» поля хранятся в одной таблице БД. В таблице три столбца (помимо FK на шаблон) — type, parameter1 (nullable), parameter2 (nullable). Если поле строковое, ограничение на длину значений хранится в «parameter1», а «parameter2» — NULL. Если поле целочисленное, минимальное значение хранится в «parameter1», максимальное — в «parameter2». Если поле вещественное, то минимальное и максимальное значения хранятся в другой таблице, а в «parameter1» и «parameter2» лежат ключи на нее. Если поле логическое — оба столбца содержат NULL.

В данном случае моя ORM-сущность (Field) содержит геттеры «getParameter1» и «getParameter2». А еще у меня есть несколько классов-фасадов (доменных сущностей), например:

class StringField
{
    protected $field;

    public function __construct(Field $field)
    {
        $this->field = $field;
    }

    /**
     * Sets maximum allowed length of field values.
     *
     * @param int $length
     *
     * @return self
     */
    public function setMaxLength($length)
    {
        $this->field->setParameter1($length);

        return $this;
    }

    /**
     * Returns maximum allowed length of field values.
     *
     * @return int
     */
    public function getMaxLength()
    {
        return $this->field->getParameter1();
    }
}


А в фасаде «IntegerField» нет геттеров/сеттеров «MaxLength», зато есть два других — «MinValue» и «MaxValue». А в фасаде «FloatValue» такие же две пары геттеров/сеттеров как и в «IntegerField», но их реализация иная (и вообще этот фасад имеет дополнительную DI-зависимость от соответствующего репозитория). А еще типов полей больше, чем я перечислил. А еще иногда пользователи просят добавить новый тип (например, дату). Как всю эту волшебную бизнес-логику втоптать в одну несчастную ORM-сущность, не превращая ее в монстра?

Вот именно поэтому ORM-сущности и domain-сущности — это имхо разные вещи. Это как «вордовский» документ — вроде, открывая его в редакторе, вы видите текст, но если посмотреть в файл на бинарном уровне — там непонятная жесть. Так вот ваш WYSIWYG-редактор — это доменная сущность, а бинарное представление в файле — ORM. В случае plain text вы можете не заметить разницы, но не дайте себя обмануть.

Конечно же, не утверждаю, что на каждую ORM-сущность вы всегда должны иметь отдельную domain-сущность — нет, только когда необходимо. Если ORM-сущности мне достаточно для работы (а это в большинстве случаев так) — я использую ее как есть. Но это не повод пихать domain-правила в ORM-представление.

я могу ошибаться, но по моему анонимные функции создаются каждый раз заново при каждом выполнении куска кода с ее инициализацией
Да и пусть создается. Если бы там не нужна была рекурсия, там бы был просто цикл прохода по массиву. И если бы я этот цикл вынес в отдельную функцию, я сам бы себя заминусовал за это. А так — часть алгоритма хочет вызывать саму себя рекурсивно — отличное применение для анонимной функции, как мне кажется. И хотя я считаю адекватными оба варианта, а не только свой — мое мнение в этом вопросе не менее субъективно, чем ваше, так что предлагаю оставить эту часть дискуссии. :)
Врать не буду, когда я только начал работать с этим паттерном, мне тоже было тесно от этого ограничения — команды ничего не возвращают. [краснея] Я даже навелосипедил свой Command Bus поначалу, который умел возвращать что мне нужно. Но когда я начал его использовать, с опытом пришло понимание и в итоге я выпилил его нафик.

В Command/Query Separation помимо "Command" есть и "Query". Грубо говоря, "command" — запись, "query" — чтение. Command Bus — это только "commands". В вашем конкретном примере я бы сделал этот генератор как "query", т.е. он ничего никуда не пишет, он просто отдает мне данные (неважно — вычитал он их из базы, или придумал сам). Если же мне нужно сохранять в базу сгенеренные значения — дополнительная команда именно на сохранение.
Вот здесь уже хорошо ответили, посмотрите, пожалуйста.
Валидирование размазано по командам. [...] А так вы дублируете правила валидации от команды к команде и велика вероятность что где-то что-то потеряете.
Валидирование как раз сконцентировано в командах. Некоторое дублирование имеет место между «парными» операциями типа «создать/изменить», но такие «парные» операции далеко не всегда пересекаются настолько сильно. Лично мне это кажется меньшим из зол, и я до сих пор ничего не терял, несмотря на упомянутую вероятность.

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

ORM-сущности не должны содержать никакой логики вообще. Это схема данных. А вот domain-сущности наоборот — хорошо, когда они не только описывают объект предметной области, но и предоставляют функции работы с ним. Еще один ньюанс между этими двумя типами сущностей — они далеко не всегда соотносятся один-в-один. Например, в некой предметной области некоего проекта есть понятие «адрес». Обычный физический адрес — город, улица, номер дома, и пр. У меня будет соответствующая domain-сущность, реализующая заодно и необходимую бизнес-логику (например, найти latitude/longitude адреса). А вот ORM-сущностей у меня будет несколько, т.к. есть отдельно таблица городов, отдельно — стран, и мало ли чего еще, и все они друг с другом связаны на уровне БД (например, «один ко многим», хотя может быть что угодно).

Более того, бывает даже наоборот — одна таблица в базе, а в предметной области у нее несколько domain-сущностей. Поэтому я никогда не смею добавлять в ORM-сущности ни логику, ни валидацию, ни что-либо еще кроме «геттеров»/«сеттеров» и комментариев. Честно говоря, я бы и «сеттеры» припрятал, чтобы возвращаемые из базы ORM-сущности оставались immutable value objects, какими они на мой взгляд и должны быть (но не хочется потом городить огород с фасадами, с которыми возможно потом придется разбираться другим разработчикам — проще оставить в том виде, в каком оно навязано Symfony-генератором).

Не нужно создавать анонимную функцию $empty2null
Чем плоха анонимная функция в данном контексте? «Можно и без нее» — слабый аргумент. :)

Для преобразования запроса в сущность лучше подходит механизм форм
Я упоминал в статье, что формы — не единственный источник данных. Идея как раз в том, что преобразование запроса непосредственно в сущность — частный случай. В реальной жизни значительная часть реквестов вообще ни в какие сущности не преобразуются. Строго говоря: запросы — это действия, сущности — это субъекты, в эти действия вовлеченные. Нельзя замапить действие на субъект, можно выполнить первое над последним.
Ну как размазана… В editAction ее нет, там только проверка entity на существование, да и то это легко выпиливается через ParamConverter, как посоветовали выше. Действительно, она есть и в команде (валидация входных данных), и в entity (валидация на уникальность). Но это двойственность присуща и без Command Bus (вместо команды она в форме). Она не появилась, она просто не исчезла. ;)
ExceptionListener — надо будет пожалуй попробовать, вдруг мне даже понравится.
Насчет двух переменных в конструкторе — да, безусловно, можно и так. Признаться, я сначала второпях решил, что ты пишешь про вызов обработчика, типа:
$this->container->get('command_bus')->handle($id, $command);
Это действительно нарушало бы паттерн. Но коли речь про инициализацию самой команды — лично я не возражаю.
А почему бы не вынести try… catch на более глобальный уровень, и не дублировать его в каждом контроллере?
Вот тут написано как сделать это в Laravel laravel.com/docs/5.2/errors#the-exception-handler, в симфони это можно сделать создав ExceptionListener.
Вообще, можно. Но во-первых меня смущает, что это спрячется под капот — не отпускает ощущение, что с точки зрения maintainability кода станет хуже. Во-вторых, не все контроллеры возвращают json, есть и HTML и даже stream.

А почему не передать в UpdateProjectCommand первым параметром id, примерное так UpdateProjectCommand($id, $data), это разве будет не более грамотно?
Нет, это ломает паттерн. Все входные данные должны быть единой структурой (командой).

Также интересно в чем плюс SimpleBus по сравнению с tactician?
С tactician не работал, не могу сказать.

Ну и по возможности мне кажется лучше использовать ParamConverter как рекомендует Symfony BestPractice, он сильно облегчает код.
Стыдно признаться, но я как-то уже и забыл о такой фиче. Спасибо что напомнили. )

Information

Rating
Does not participate
Location
Окленд, Auckland, Новая Зеландия
Date of birth
Registered
Activity