воскресенье, 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. При этом никакого влияния на дизайн модели домена он не оказывает — это важно помнить и понимать.