воскресенье, 25 октября 2009 г.

Command-query separation — две стороны одной модели

Паттерн Domain Model, о котором многие наверняка читали в одной небезызвестной книге в сочетании с подходом Domain Driven Design поначалу привлекает множество разработчиков своей логичностью, простотой и универсальностью, которые видятся им в первом приближении. Но как только начинается работа над реальным приложением, то сразу появляется куча нетривиальных проблем, которые вкупе с достаточно строгой «дисциплиной» подхода не дают возможности увидеть каких-то простых и элегантных решений. И тогда проект начинает скатываться в суровую действительность с кучей костылей и инвалидных колясок.

Проблема

Разработаем небольшую но типичную CRM. У нас будет база клиентов, контакты и история общения с ними. С точки зрения UI у нас будет список клиентов, формы редактирования деталей отдельно взятого клиента, что-то для управления логом каждого клиента. Все выглядит действительно просто и предсказуемо. За один вечер мы быстренько набрасываем код и к утру заказчик получает готовое к эксплуатации приложение, а разработчик — глубокую сатисфакцию от проделанной работы.

На следующий день заказчик присылает новое требование: «Хочу видеть в списке клиентов среднее время отклика из логов». Вы кидаетесь в код класса Customer и бодро добавляете свойство AvgCallbackTime примерно такого содержимого:

public TimeSpan AverageCallbackTime
{
    get
    {
        return _calls.Average(c => c.CallbackTime);
    }
}

Добавляете колонку в грид и вуаля. Но откуда-то из вашего кода начинает нести отборным code smell. На следующий день заказчик приходит с еще парой колонок, которые вы так же быстро добавляете. Но одна из колонок в гриде — название последнего заказанного товара, с которым у вас нет никакой связи у кастомера. Вернее, не было до сегодняшнего дня. Сдвинув брови, вы накидывается пару классов в духе CustomerOrder и по длинной цепочке, хитро перебирая все заказы, достаете нужный. И при этом вы стараетесь не думать о том, что приложение стало работать откровенно медленно, утешая себя мыслью о том, что преждевременная оптимизация будет злом.

И вот настает тот самый день «Х». К вам на рабочий стол ложиться задача: «Реализовать редактируемый список клиентов, активно покупающих товар в заданной категории, с адресом доставки, средней суммой чека, частотой покупок, популярным способом оплаты и фамилией ответственного менеджера. При этом пользователь должен иметь возможность отсортировать и отфильтровать список по производному (в том числе и вычисляемому) полю. Кроме этого, список должен выводиться постранично. Ну и список колонок в выборке должен формироваться на основе уровня доступа пользователя к некоторым критичным данным».

Думаю, расписывать дальнейший ход мыслей и стиль принимаемых решения будет бессмысленно :)

Итак, как же нам сохранить модель красивой и лаконичной? Что нам делать с требованиями, касающимися отображения данных и не несущественным с точки зрения каких-то законов поведения объектов?

Двойственность природы модели домена

Если модель является функцией приложения, то было бы логичным предположить, что на нее распространяются всевозможные архитектурные «правила хорошего тона», а в нашем конкретном случае речь пойдет именно Single Responsibility Principle. Как сформулировать эту самую единственную ответственность модели домена? Что именно имплементирует эта модель?

Согласно определению Фаулера, модель домена — это:

An object model of the domain that incorporates both behavior and data.

Но это определение не самое удачное с точки зрения принципа SRP, потому как при наличии двух дополнений в предложении в нем точно так же остается поле для домысливания: что же в модели домена главнее — данные или поведение?

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

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

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

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

Решение рядом

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

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

И вот тут-то мы практически вплотную подошли к тому, чтобы назвать, а затем выделить из модели ту «лишнюю» ответственность, «уродует» ее и мешает нормально развиваться.

Решение было сформулировано много лет назад Бертраном Мейером, как одно из основных правил разрабатываемого им языка программирования Eiffel. Принцип называется Command-query Separation Principle и его самая известная формулировка выглядит так:

Аsking a question should not change the answer

Другими словами:

Каждый метод должен либо выполнять какое-то действие, либо возвращать какие-то данные, но никак ни то и другое вместе.

Этот принцип был сформулирован применительно к языку программирования и стал одним из основных правил разработки чистого кода без сторонних эффектов.

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

Запросы и команды

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

Command-query separation

Другими словами, все данные, которые пользователь видит — подготавливаются подсистемой отчетов: все списки, детали, печатные формы. Как только пользователь подтверждает изменение каких-то данных — его команды обрабатывает модель домена.

В чем плюсы?

С этого места начинаем собирать урожай полученных преимуществ :)

  1. Моделью для отчетов служит самая что ни на есть настоящая модель данных со всеми своими нормальными формами, денормализациями и связками по ключам. Таким образом мы получаем возможность достать для пользователя любые данные, не спотыкаясь о репозитории, агрегаты и, прости господи, value objects;
  2. В подсистеме отчетов отпадает необходимость в поддержке сложной инфраструктуры в виде каких-либо ORM, поддержки транзакций и прочего (хотя, о транзакциях говорят всякое);
  3. Если у нас нет необходимости в сложной инфраструктуре и у нас под руками настоящая схема данных, то самым удобным средством выборки данных является SQL во всех его ипостасях;
  4. Никто не запрещает нам использовать специализированные репортинг-сервисы, всевозможные OLAP и прочие удобности из области Business Intelligence;
  5. Вследствие упрощения инфраструктуры субъективно увеличивается производительность;
  6. Модель домена становится максимально лаконичной и сфокусированной исключительно на поведенческих аспектах бизнес-логики.

В чем минусы?

Не бывает идеальных решений. Главным недостатком я бы назвал дублирование и еще раз дублирование. Это даже общий недостаток, свойственный DDD-подходу, как и любым другим SOLID-решениям в целом: одни и те же данные находят множество воплощений в различных подсистемах, приходится поддерживать множество маппингов, всегда думать о способности модели к рефаткорингу. Все это, безусловно, увеличивает общую стоимость разработки новой функциональности, но в целом, очень значительно уменьшает стоимость модификаций.

Что останется в модели?

Рефакторинг, связанный с CQS прежде всего коснется репозиториев. В самом лучшем случае репозиторий будет предоставлять только какой-нибудь метод типа GetByID — все остальные методы выборок благополучно уйдут в отчеты.

Нужно отметить, что возможность полноценного использования всех этих репозеториев, root aggregates, entities и value objects появляется во многом лишь благодаря правильному использованию CQS.

Из модели исчезнут всевозможные гибридные сущности, задача которых была исключительно в наведении связей между «настоящими» бизнес-сущностями. Как следствие этого, границы между контактами усилятся, уменьшится связанность.

Что в итоге?

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

В целом, CQS является, пожалуй, самой яркой иллюстрацией процесса Separation of Concerns. Кроме этого, понятия запрос и команда применимы к любому аспекту архитектуры системы, связанной с обработкой данных.

Например он очень здорово отвечает на вопросы, возникающие в области SOA. Вкратце: все в курсе, что самый гибкий и удобный способ общение между компонентами — это асинхронный обмен сообщениями, но как быть со случаем, когда компонент просто должен предоставить какую-то информацию только для чтения, причем, в синхронном режиме? Об этом интересно написал Udi Dahan.

У Эванса об этом подходе ничего не сказано, и это неспроста. Дело в том, что сам по себе принцип CQS не имеет ничего общего с DDD. Этот принцип всего лишь обеспечивает некие предусловия, которые всего лишь делают возможным применение паттернов DDD. При этом никакого влияния на дизайн модели домена он не оказывает — это важно помнить и понимать.

Опубликовать

3 комментария:

  1. Вот ссылка на интервью, в котором обсуждается данная проблема:

    http://www.infoq.com/interviews/greg-young-ddd

    Есть там, к примеру, такой кусок:

    QUESTION:
    Are you suggesting that you only convert to the internal domain model when you write stuff?
    ANSWER:
    Absolutely, and it is a very common system architecture in general. If we were to look at a typical domain driven design based architecture, we would have - let's say - an application service sitting there. This application service is going to have probably some query in methods - things like I need to get customer information, so I can put up on the screen, that's why I'm going to have some command methods "I need to change this customer's address, too". All the command-query separation is to recognize that these 2 things exist and to separate them. They both do not need to go to the domain.

    Whereas the first one was going directly to a domain for the queries, we are just going to pull that across and we are only going to have our thin little DTOs, which we had before, but now we are going to populate them directly from some data source. It may or may not be the same data source that we write to. There may be 2 data sources and they may or may not be eventually consistent. Even if we start off with this single data source, we know that we have this ability to move towards an eventually consistent architecture layer, which will allow us for a large amount of scalability, if we needed that at a later point. Beyond that we've cleaned up our domain in terms of making a transactional only on the write side now. Our domain ends up cleaner, our DTO layer ends up cleaner and let me just say I would hate to be the junior programmer who has to write all the DTO mapping code - It's a pain! Been there, done that.

    But the domain code becomes a lot more interesting because you are only passing through a single direction. What I generally do - I will use a pattern as an event sourcing. I read my DTO's and then I create a physical command message based on the data in my DTO's and I send that message to the domain and then the domain will tell me whether it succeeds or fails. On the back end of this you can then move those same messages or you can have a separate event stream coming out of your domain and /or finish then close the loop.

    ОтветитьУдалить
  2. "Но откуда-то из вашего кода начинает нести отборным code smell" класс, поржал, спасибо :)))

    ОтветитьУдалить