Июль 2015

ТОМ 30 ВЫПУСК 7

На переднем крае - CQRS и приложения, основанные на сообщениях

Дино Эспозито

Дино ЭспозитоВ конечном счете Command and Query Responsibility Segregation (CQRS) — это такое проектирование программного обеспечения, при котором код, изменяющий состояние, отделяется от кода, просто читающего это состояние. Подобное разделение может быть логическим и основываться на разных уровнях. Кроме того, оно может быть физическим и включать разные звенья (tiers), или уровни. За CQRS не стоят ни программные документы, ни сверхсовременная философия. Стимул применения CQRS один: простота проектирования. Упрощенное проектирование в наше безумное время невыносимой сложности бизнеса — единственный способ обеспечить эффективность, оптимизацию и добиться успеха.

В прошлой статье (msdn.microsoft.com/magazine/mt147237) я предложил перспективный подход к CQRS, который сделал ее подходящей для приложений любого типа. В тот момент, когда вы решаете использовать архитектуру CQRS с отделенными стеками команд и запросов, вы начинаете думать о способах раздельной оптимизации каждого стека.

Больше нет ограничений модели, которые делают определенные операции рискованными, нецелесообразными или просто слишком дорогостоящими. Видение системы становится гораздо более центрированным на задачах. Еще важнее, что это происходит естественным образом. Даже некоторые концепции проектирования, управляемого предметной областью (domain-driven design, DDD), такие как агрегация, перестают казаться столь раздражающими. Даже они находят свое естественное место в общем проекте. В этом сила упрощенного проектирования (simplified design).

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

Архитектура, основанная на сообщениях

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

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

Сообщение может быть командой или событием. В коде вы обычно определяете базовый класс Message, а от него наследуете дополнительные классы для команд и событий, как показано на рис. 1.

Рис. 1. Определение базового класса Message

public class Message
{
  public DateTime TimeStamp { get; proteted set; }
  public string SagaId { get; protected set; }
}
public class Command : Message
{
  public string Name { get; protected set; }
}
public class Event : Message
{
  // Здесь размещаются любые свойства, способные помочь
  // в извлечении и сохранении событий
}

С точки зрения семантики, команды и события являются немного разными сущностями и служат разным, но связанным целям. Событие практически совпадает с таковым в Microsoft .NET Framework: это класс, которые несет данные и уведомляет вас, когда что-то происходит. Команда является действием, выполняемым применительно к серверной части, которую запросил пользователь или какой-то другой системный компонент. События и команды следуют довольно стандартным соглашениям по именованию. Команды указываются в повелительном наклонении вроде SubmitOrderCommand, а события в прошедшем времени, например OrderCreated.

Как правило, щелкнув какой-либо UI-элемент, вы порождаете некую команду. Как только система получает команду, она создает задачу. Задачей может быть что угодно — от длительно выполняемого процесса с состоянием до отдельной операции или рабочего процесса без состояний. Такую задачу часто называют сагой (saga).

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

Явные действия пользователя — не единственный способ инициации команд. Вы можете поместить команду с автономными сервисами, которые асинхронно взаимодействуют с системой. Подумайте о сценарии B2B, например о поставке продукции, в котором коммуникации между партнерами осуществляются через какой-то HTTP-сервис.

События в архитектуре, основанной на сообщениях

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

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

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

Шина

Для начала посмотрим на самодельный компонент-шину. Базовый интерфейс шины показан ниже:

public interface IBus
{
  void Send<T>(T command) where T : Command;
  void RaiseEvent<T>(T theEvent) where T : Event;
  void RegisterSaga<T>() where T : Saga;
  void RegisterHandler<T>();
}

Как правило, шина относится к синглтонам (объектам, создаваемым только в одном экземпляре). Она принимает запросы на выполнение команд и уведомления о событиях. Шина не делает никакой конкретной работы. Она просто выбирает зарегистрированный компонент для обработки команды или события. Шина хранит список известных бизнес-процессов, запускаемых командами и событиями или продвигаемых дополнительными командами.

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

Рис. 2. Пример реализации класса шины

public class InMemoryBus : IBus
{
  private static IDictionary<Type, Type> RegisteredSagas =
    new Dictionary<Type, Type>();
  private static IList<Type> RegisteredHandlers =
    new List<Type>();
  private static IDictionary<string, Saga> RunningSagas =
    new Dictionary<string, Saga>();
  void IBus.RegisterSaga<T>() 
  {
    var sagaType = typeof(T);
    var messageType = sagaType.GetInterfaces()
      .First(i => i.Name.StartsWith(typeof(IStartWith<>).Name))
      .GenericTypeArguments
      .First();
    RegisteredSagas.Add(messageType, sagaType);
  }
  void IBus.Send<T>(T message)
  {
    SendInternal(message);
  }
  void IBus.RegisterHandler<T>()
  {
    RegisteredHandlers.Add(typeof(T));
  }
  void IBus.RaiseEvent<T>(T theEvent) 
  {
    EventStore.Save(theEvent);
    SendInternal(theEvent);
  }
  void SendInternal<T>(T message) where T : Message
  {
    // Стадия 1: запуск саг, инициируемых данным сообщением.
    // Стадия 2: доставка сообщения всем уже выполняемым сагам,
    // которые имеют совпадающий ID (сообщение содержит
    // ID саги). Стадия 3: доставка сообщения
    // зарегистрированным обработчикам.
  }
}

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

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

Написание компонента-саги

Сага — это компонент, объявляющий следующую информацию: команду или событие, которое запускает бизнес-процесс, связанный с этой сагой, список команд, обрабатываемых сагой, и список событий, в которых заинтересована сага. Класс саги реализует интерфейсы, через которые объявляет интересующие его команды и события. Интерфейсы вроде IStartWith и ICanHandle определяются так:

public interface IStartWith<T> where T : Message
{
  void Handle(T message);
}
public interface ICanHandle<T> where T : Message
{
  void Handle(T message);
}

Вот пример сигнатуры примера класса саги:

public class CheckoutSaga : Saga<CheckoutSagaData>,
       IStartWith<StartCheckoutCommand>,
       ICanHandle<PaymentCompletedEvent>,
       ICanHandle<PaymentDeniedEvent>,
       ICanHandle<DeliveryRequestRefusedEvent>,
       ICanHandle<DeliveryRequestApprovedEvent>
{
  ...
}

В данном случае сага представляет процесс оформления и оплаты заказа (checkout process) в онлайновом магазине. Сага запускается, когда пользователь щелкает кнопку оформления заказа и прикладной уровень отправляет команду Checkout в шину. Конструктор саги генерирует уникальный идентификатор, необходимый для параллельной обработки нескольких экземпляров одного и того же бизнес-процесса. Вы должны быть в состоянии обрабатывать несколько параллельно выполняемых саг оформления заказов. Идентификатором может быть GUID, уникальное значение, посылаемое с запросом команды, или даже идентификатором сеанса.

Для саги обработка команды или события заключается в вызове метода Handle в интерфейсе ICanHandle или IStartWith из компонента шины. В методе Handle сага выполняет вычисления или обращается к данным. Затем она отправляет другую команду другим слушающим сагам или просто генерирует событие как уведомление. Например, вообразите рабочий процесс оформления и оплаты заказа, как на рис. 3.

Рабочий процесс оформления и оплаты заказа
Рис. 3. Рабочий процесс оформления и оплаты заказа

 

Start Начало
command Команда CheckIfGoodsAreInStock (далее подставляйте имена других команд)
event СобытиеPaymentAccepted (далее подставляйте имена других событий)
End Конец

Сага выполняет все стадии вплоть до приема платежа. В этот момент она посылает команду AcceptPayment в шину, чтобы работу продолжила PaymentSaga. PaymentSaga запускается, выполняется и генерирует либо событие PaymentCompleted, либо событие PaymentDenied. Эти события будут вновь обработаны CheckoutSaga. Затем эта сага перейдет на стадию доставки (delivery step), поместив другую команду в шину для другой саги, которая взаимодействует с внешней подсистемой партнерской компании, осуществляющей доставку.

Конкатенация команд и событий обеспечивает существование саги до тех пор, пока она не достигнет точки завершения. В этом отношении сагу можно рассматривать как классический рабочий процесс с точками начала и окончания. Также стоит отметить, что сага обычно сохраняется. Сохранение, как правило, обрабатывается шиной. Пример класса шины, представленный здесь, не поддерживает сохранение. Коммерческая шина вроде NServiceBus или даже шина с открытым исходным кодом, такая как Rebus, может использовать SQL Server. Чтобы сохранение стало возможным, вы должны присваивать уникальный идентификатор каждому экземпляру саги.

Заключение

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


Дино Эспозито (Dino Esposito) — соавтор книг «Microsoft .NET: Architecting Mobile Applications Solutions for the Enterprise» (Microsoft Press, 2014) и «Programming ASP.NET MVC 5» (Microsoft Press, 2014). Идеолог в области технологий для платформ .NET Framework и Android в JetBrains. Часто выступает на конференциях по всему миру, делится своим видением ПО на software2cents.wordpress.com и пишет заметки в twitter.com/despos.

Выражаю благодарность за рецензирование статьи эксперту Джону Арну Сетересу (Jon Arne Saeteras).