以下内容均为看完原文后自己的理解。并非一字一句翻译,会尽量保持原文意思。
什么是 CQRS:
CQRS 意思就是命令查询职责分离(Command Query Responsibility Segregation)。很多人认为 CQRS 是一个完整的架构,但是他们错了。它只是一个小小的模式。Greg Young 和 Udi Dahan 首先介绍了这种模式。他们是从 Bertrand Meyer 的 “面向对象的软件结构”一书中得到了 CQS(查询与命令分离( Command Query Separation )) 模式的设计灵感。CQS 背后的主要灵感是:“一个方法更改对象的状态或返回一个结果,但是不能同时包含这两个行为。更正式的说,如果它们之间的引用是透明的且无副作用的,那么这个方法只需要返回一个值。”,因此我们可以把方法分为两组:
- Commands : 更改一个对象或整个系统的状态。
- Query : 返回结果但并不会改变对象的状态。
在实际使用中很容易分清哪个是 Command 哪个是 Query 。查询将返回一个类型,而命令的返回是 void 类型的。这种模式被广泛使用,它使推理相关对象更容易。另一方面,CQS 仅适用于特定的问题。
许多应用程序使用觉的主流方法,包括模型的读取和写入。同一个模型如果拥有读取和写入的功能,则可能很维护和优化。
这两种模式的真正功能是可以分开改变那些没有状态的方法。这种分享可以在非常方便的情况下处理性能和调优。你可以从写入端单独优化系统的的读取端。写入端称之为领域。领域包含所有行为(业务逻辑)。读取端专门汇报需求。
这种模式的另一个好处是在大型应用程序中。可以将开发者分为较小的团队工作在不同方面的系统(读取或写入)。例如:在读取端的开发人员就不需要了解领域模型。
查询端:
查询只包含获取数据的方法。从架构的角度查看这些都会返回 DTO 对象。DTO 对象通常是领域对象。在某些情况下这可能是一个非常痛苦的过程,特别是在获取复杂的 DTO 对象时。
使用 CQRS 可以避免这种情况的发生。相反,它可能介绍一种新的映射到 DTO 的方法。你可以绕过领域模型通过读取端直接从数据存储介质中获取 DTO 对象。当应用程序获取数据时,可以通过读取端调用一个单一的方法来返回一个包含所需数据的 DTO 对象。
读取层可以直接连接数据库(数据模型)而且使用存储过程也不是一个坏主意。直接连接到数据源的查询很容易维护和优化。它就是使用反规范化数据的意义。这样做的原因是:数据查询的次数通常是执行领域模型行为的的许多倍(即领域模型中行为的执行次数比常规的数据查询少很多)。这种反规范化可以提高应用程序的性能。
命令端:
由于读取端被分离,所以领域模型只集中处理命令(Command)。现在不再需要公开领域对象的内部状态,仓储(即数据持久化的存储介质)除了GetById方法,只有几个查询方法。
命令由客户端应用程序创建并发送到领域层。命令信息指定一个特定的实体执行某些行为(操作)。命令的命名方法如:ChangeName, DeleteOrder,...。它们指定目标实体执行一些行为返回不同的结果或者执行行为失败收到错误信息。命令通过命令处理程序处理。
public interface ICommand { Guid Id { get; } } public class Command : ICommand { public Guid Id { get; private set; } public int Version { get; private set; } public Command(Guid id,int version) { Id = id; Version = version; } } public class CreateItemCommand:Command { public string Title { get; internal set; } public string Description { get;internal set; } public DateTime From { get; internal set; } public DateTime To { get; internal set; } public CreateItemCommand(Guid aggregateId, string title, string description,int version,DateTime from, DateTime to) : base(aggregateId,version) { Title = title; Description = description; From = from; To = to; } }
所有的命令将被发送到命令处理程序并委托给每个命令的命令总线。这表明,进入一个领域实体只有一个入口点。命令处理程序的责任是执行对应的领域模型上的行为。当执行命令时命令处理程序应该有一个连接到仓储且提供加载所需的实体的能力(在这里称为聚合根)。
public interface ICommandHandler<TCommand> where TCommand : Command { void Execute(TCommand command); } public class CreateItemCommandHandler : ICommandHandler<CreateItemCommand> { private IRepository<DiaryItem> _repository; public CreateItemCommandHandler(IRepository<DiaryItem> repository) { _repository = repository; } public void Execute(CreateItemCommand command) { if (command == null) { throw new ArgumentNullException("command"); } if (_repository == null) { throw new InvalidOperationException("Repository is not initialized."); } var aggregate = new DiaryItem(command.Id, command.Title, command.Description, command.From, command.To); aggregate.Version = -1; _repository.Save(aggregate, aggregate.Version); } }
命令处理程序需要执行以下任务:
- 从消息基础设施(命令总线)接收 Command 实例。
- 它验证该命令是一个有效的命令。
- 它确定命令的目标是聚合的实例。
- 在命令上可以传递任何参数,然后在聚合实例上调用适当的方法。
- 它保存新聚合的状态到存储介质。
内部事件:
第一个问题我们要问的是:什么是领域事件。领域事件是在系统中已经发生的事件。事件通常是一个命令的结果。例如:客户端获取一个 DTO 对象和改变对象的状态就会导致事件的发生。适当的命令处理程序加载正确的聚合根并执行适当的行为。然后行为将引发一个事件。事件被特定的订阅者处理。聚合发布一个事件到一个事件总线(Event Bus)并传递事件到适当的事件处理程序。聚合根内处理的事件称为内部事件。事件处理程序不需要处理任何逻辑从而取代设置状态。
领域行为:
public void ChangeTitle(string title) { ApplyChange(new ItemRenamedEvent(Id, title)); }
领域事件:
public class ItemCreatedEvent:Event { public string Title { get; internal set; } public DateTime From { get; internal set; } public DateTime To { get; internal set; } public string Description { get;internal set; } public ItemCreatedEvent(Guid aggregateId, string title , string description, DateTime from, DateTime to) { AggregateId = aggregateId; Title = title; From = from; To = to; Description = description; } } public class Event:IEvent { public int Version; public Guid AggregateId { get; set; } public Guid Id { get; private set; } }
内部领域事件处理器:
public void Handle(ItemRenamedEvent e) { Title = e.Title; }
事件通常连接到另一个模式被称为事件溯源(Event Sourcing. ES.)ES是为了记录在聚合状态变化时保存事件流并持久化聚合状态的一种途径。
如前面所说的,聚合根的每一个状态的改变都是有事件引发的且聚合根的内部处理程序除了设置正确的状态没有其它作用。为了获取聚合根的状态我们不得不在内部重新播放(引发)所有的事件。这里我必须指出,事件是只写的。你不能改变或删除现有事件。如果你发现某些逻辑在系统中生成错误的事件,你必须生成一个新的补偿事件并纠正以前的错误事件的结果。
外部事件:
外部事件通常用于报告数据库同步与当前领域的状态。它是通过内部事件发布到外部领域的。当事件发布的时候适当的事件处理程序会处理这个事件。外部事件可以发布到多个事件处理程序。事件处理程序执行以下任务:
- 从消息基础设施(Event Bus)接收事件实例。
- 它确定事件的目标是进程管理器。
- 在事件上可以传递任何参数,然后在聚合实例上调用适当的方法。
- 它保存进程管理器的新状态到存储介质。
但是谁可以发布事件呢?通常领域仓储可以发布外部事件。
示例程序:
我创建了一个非常简单的示例,演示如何使用 CQRS 模式。这个简单的示例允许你创建日记记录和修改它们。
该解决方案有三个项目:
- Diary.CQRS
- Diary.CQRS.Configuration
- Diary.CQRS.Web
第一个是基础项目,它包含所有领域模型和消息处理对象。现在让我们进一步了解一下这些主要项目。
Diary.CQRS
如前面所说,这个项目包含所有的领域模型和消息对象在这个示例中。在发送命令时 CQRS 示例的入口点是事件总线。这个 class 只有一个通用的方法 Send(T command) 。该方法使用 CommandHandlerFactory 创建适当的命令处理程序。如果没有命令处理程序与命令相关联,那么将会抛出一个异常。在其它情况下,调用 Execute 方法将会执行其行为(即命令处理程序中的行为)。行为创建一个内部事件且这个事件存储到一个名称为 _changes 的字段中,这个字段在聚合根基类中声明。接下来,是由内部处理此事件的事件处理程序更改聚合的状态。在行为被执行之后,所有聚合的更改将保存在仓储中。仓储比较聚合与预期版本不一样的地方然后作上标记,然后存储到仓储中。如果这些版本是不同的,这意味着该对象已被某个人修改了并且将引发 ConcurrencyException 异常。在其它情况下这些改变将存储在事件仓储中。
仓储(Repository):
public class Repository<T> : IRepository<T> where T : AggregateRoot, new() { private readonly IEventStorage _storage; private static object _lockStorage = new object(); public Repository(IEventStorage storage) { _storage = storage; } public void Save(AggregateRoot aggregate, int expectedVersion) { if (aggregate.GetUncommittedChanges().Any()) { lock (_lockStorage) { var item = new T(); if (expectedVersion != -1) { item = GetById(aggregate.Id); if (item.Version != expectedVersion) { throw new ConcurrencyException(string.Format("Aggregate {0} has been previously modified", item.Id)); } } _storage.Save(aggregate); } } } public T GetById(Guid id) { IEnumerable<Event> events; var memento = _storage.GetMemento<BaseMemento>(id); if (memento != null) { events = _storage.GetEvents(id).Where(e=>e.Version>=memento.Version); } else { events = _storage.GetEvents(id); } var obj = new T(); if(memento!=null) ((IOriginator)obj).SetMemento(memento); obj.LoadsFromHistory(events); return obj; } }
InMemoryEventStorage:
在这个简单的示例中,我已经创建了一个 InMemoryEventStorage 用于存储所有的事件到内存中。这个类实现了 IEventStorage 接口,此接口包含 4 个方法。
public IEnumerable<Event> GetEvents(Guid aggregateId) { var events = _events.Where(p => p.AggregateId == aggregateId).Select(p => p); if (events.Count() == 0) { throw new AggregateNotFoundException(string.Format( "Aggregate with Id: {0} was not found", aggregateId)); } return events; }
此方法返回聚合的所有事件且当聚合没有事件时将抛出一个错误(即聚合是不存在的)。
public void Save(AggregateRoot aggregate) { var uncommittedChanges = aggregate.GetUncommittedChanges(); var version = aggregate.Version; foreach (var @event in uncommittedChanges) { version++; if (version > 2) { if (version % 3 == 0) { var originator = (IOriginator)aggregate; var memento = originator.GetMemento(); memento.Version = version; SaveMemento(memento); } } @event.Version=version; _events.Add(@event); } foreach (var @event in uncommittedChanges) { var desEvent = Converter.ChangeTo(@event, @event.GetType()); _eventBus.Publish(desEvent); } }
这个方法存储事件到内存且为此聚合创建三个事件副本(备忘)。这些副本将为这个聚合保存所有状态和版本。使用副本增加应用程序的性能。因为这是加载所有不重要的事件。
当所有事件都被存储,它们将被事件总线发布且由外部处事程序执行。
public T GetMemento<T>(Guid aggregateId) where T : BaseMemento { var memento = _mementos.Where(m => m.Id == aggregateId).Select(m=>m).LastOrDefault(); if (memento != null) return (T) memento; return null; }
返回一个聚合的副本(备忘)。
public void SaveMemento(BaseMemento memento) { _mementos.Add(memento); }
存储聚合的副本(备忘)。
聚合根(Aggregate Root):
聚合根是所有聚合的基类。这个类实现了 IEventProvider 接口。它包含有关 _changes 列表中的所有未提交的更改的信息。这个类还有一个 ApplyChange 方法执行适当的内部事件处理程序的方法。 LoadFromHistory 方法加载并应用内部事件。
public abstract class AggregateRoot:IEventProvider { private readonly List<Event> _changes; public Guid Id { get; internal set; } public int Version { get; internal set; } public int EventVersion { get; protected set; } protected AggregateRoot() { _changes = new List<Event>(); } public IEnumerable<Event> GetUncommittedChanges() { return _changes; } public void MarkChangesAsCommitted() { _changes.Clear(); } public void LoadsFromHistory(IEnumerable<Event> history) { foreach (var e in history) ApplyChange(e, false); Version = history.Last().Version; EventVersion = Version; } protected void ApplyChange(Event @event) { ApplyChange(@event, true); } private void ApplyChange(Event @event, bool isNew) { dynamic d = this; d.Handle(Converter.ChangeTo(@event,@event.GetType())); if (isNew) { _changes.Add(@event); } } }
事件总线(EventBus):
事件描述系统状态的变化。 事件的主要目的是更新所读取的领域模型。为了这个目的我创建了 EventBus 类。EventBus 类的唯一行为就是发布事件到订阅服务器,一个事件可以发布到多个订阅服务器。在本例中不需要手动订阅。事件处理工厂返回所有 EventHandler 的一个列表并可以处理当前的事件。
public class EventBus:IEventBus { private IEventHandlerFactory _eventHandlerFactory; public EventBus(IEventHandlerFactory eventHandlerFactory) { _eventHandlerFactory = eventHandlerFactory; } public void Publish<T>(T @event) where T : Event { var handlers = _eventHandlerFactory.GetHandlers<T>(); foreach (var eventHandler in handlers) { eventHandler.Handle(@event); } } }
事件处理器(Event Handlers):
事件处理程序的主要目的是接收事件并更新所读取的领域模型。在下面的例子你可以看到 ItemCreatedEventHandler 。它处理 ItemCreatedEvent 。使用事件中的信息创建一个新对象且存储它到报表数据库。
public class ItemCreatedEventHandler : IEventHandler<ItemCreatedEvent> { private readonly IReportDatabase _reportDatabase; public ItemCreatedEventHandler(IReportDatabase reportDatabase) { _reportDatabase = reportDatabase; } public void Handle(ItemCreatedEvent handle) { DiaryItemDto item = new DiaryItemDto() { Id = handle.AggregateId, Description = handle.Description, From = handle.From, Title = handle.Title, To=handle.To, Version = handle.Version }; _reportDatabase.Add(item); } }
Diary.CQRS.Web:
这个例子作为用户界面的 CQRS 的例子。这个 Web UI 项目是一个简单的 ASP.NET MVC4 应用程序,只有一个 HomeController 和 6 个 ActionResult 方法:
ActionResult Index() - 这个方法返回 Index View 包含所有日记项的列表。
ActionResult Delete(Guid id) - 这个方法创建一个新的 DeleteItemCommand 且发送它到 CommandBus 。当一个命令发送时,并返回 Index View。
ActionResult Add() - 当添加一条新日记将返回一个 Add View 。
ActionResult Add(DiaryItemDto item) - 这个方法创建一个新的 CreateItemCommand 并将其发送到 CommandBus 。当新的项被添加将返回 Index View 。
ActionResult Edit(Guid id) - 返回选中日记项的 Edit View 。
ActionResult Edit(DiaryItemDto item) - 这个方法创建一个新的 CreateItemCommand 并将其发送到 CommandBus 。当一个列表项被成功更新时,将返回 Index View 。在 ConcurrencyError 的情况下,将返回 Edit View 并显示一条异常信息。
在下面的图片中你可以看到主屏幕和日记条目的列表。
什么时候使用 CQRS:
一般情况下,DDD+CQRS,都会应用到大型系统中,这些系统复杂且需要互相协作。
什么时候不用 CQRS:
一般的小项目使用 DDD + CQRS 则没有这个必要,小项目使用简单的数据库驱动开发更来的快。
原文地址:http://www.codeproject.com/Articles/555855/Introduction-to-CQRS