CQRS介绍

我今天找到一篇好文,深入浅出的介绍了CQRS,边翻译边学习了。原文地址:
http://www.codeproject.com/Articles/555855/Introduction-to-CQRS

附带源码下载

转载请注明出处 http://blog.dongderu.net/2013/05/22/2013-5-22-CQRS-Introduction/

CQRS是什么?

CQRS就是指命令和查询职责的分离。许多人认为CQRS是一个完整的构架,但是他们错了。CQRS只是一个小的模式形态。这个模式最初是由Greg Young和Udi Dahan两位大师提出的。他们是从一个叫做“命令与查询分离”的模式得到的灵感,这个模式是由Bertrand Meyer在他撰写的《Object Oriented Software Construction》书里定义的。该模式的关键在于:“一个方法要么是用来改变某个对象的状态的,要么就是返回一个结果,这两者不会同时并存”。换句话说,提问不会改变问题的答案。正式一点的说法是,之所以要方法返回结果,因为他是“引用透明的”,因此不会执行额外的有影响的操作(来自维基百科)。(插一句,这句话你可能看不懂,请自行琢磨原文,我根据自己的理解白话一下:你既然要得到正常的结果,不会蛋疼到查询的时候同时去改变要查询的结果吧==b。。。)根据上述理论,我们可以把方法拆分成两个部分:

  • Commands(命令) - 改变某一个对象或整个系统的状态(有时也叫做modifiers或者mutators)。
  • Queries(查询) - 返回值并且不会改变对象的状态。

在实际操作中很容易把这两者区分开来。查询会返回一个预先定义的类型,而命令返回void。这样一个模式被已经得到广泛的认可而且能帮助大家更好地理解对象。不过另一方面来说,CQRS仅适用于某些特定的场景。

大多数应用都使用了主流的专注于模型的读和写操作的解决方案。但是读和写采用相同的模型会导致模型越来越复杂变得十分难以维护和优化。

命令与查询两种模式的应用的真正优势是你可以将那些改变状态的操作从那些不应该出现的地方剥离出来。这样的分离会给你在处理调试和优化的过程中带来很大的方便。你可以将你的读取端独立地进行优化,而暂且不管写的操作。写操作端就是指领域的范畴了。领域包含了所有的行为。而读取端仅仅是用来做数据呈现的。

采取这样的模式的另一个好处是,在大型的应用中,你可以将你的开发团队进行分割,以分别负责读和写的实现,且两者之间的知识的传递可以不对称。比如负责呈现数据的小组根本不需要了解领域模型,命令和ES的知识和实现,只需要了解展现数据库的结构。(好钢用在刀刃上,这样可以为企业优化组合,节约成本)

Query side(查询端)

查询端仅包含获取数据的方法。从架构的角度出发,所有的方法都应该返回用于显示目的的DTO(Data Transfer Object)。一般来说DTO就是Domain Objects(领域对象)的投射(两者很相像)。在有些情况下,构建DTO的过程会十分蛋疼,尤其在需要构建一些很复杂的DTO的情况下。

如图,Read Layer(读取操作层)可以直连数据库(数据源),直接用存储过程来读在这种模式下不是一个坏主意。直连数据源使得查询维护和优化变得十分容易。这里采用denormalize(反规范化,就是允许数据冗余,比如一张表对应一个页面,不使用Join,允许有重复的字段)的数据库设计也是有道理的。原因在于数据的读取操作往往好几倍频繁于领域行为的执行。而反规范化的应用可以很好的帮助应用提升性能。

Command Side(命令端)

命令由客户端发出,并传送到领域层。命令实际上是一种消息,它包含了一些特定的实体信息来完成某一项操作。命令的命名规则可以是DoSomething(举个例子,ChangeName, DeleteOrder…)。他们通知领域实体执行某种操作并返回一个值或者失败信号。命令由Command Handler(命令执行器)进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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;
}
}

所有的命令都会被传入Command Bus(命令总线),它会将每个命令委派给Command Handler进行处理。这样做保证了领域的单一入口。Command Handler的职责是调用领域层内相应的方法。Command Handler需要具有repository(仓储)的数据连接来加载需要的实体(在CQRS模式中就是指聚合根)以满足某些方法的需要。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
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 Handler负责如下几项工作:

  • 负责从消息基础架构(Command Bus)里接受Command实例。
  • 负责验证Command是否有效。
  • 负责找到针对该Command的聚合实例。
  • 负责调用聚合实例的相关方法,并且从Command类中获取相关字段作为参数传入方法中。
  • 从聚合的角度来看总是在更新状态(可以从代码得到直观体现)。

Internal Events(内部事件)

在讨论这个话题之前,我们可能会先想到一个问题,什么是领域事件?领域事件就是那些在系统中已经发生的事情。事件对应命令的执行结果。让我们来举个例子,客户端请求了一个DTO并且在其基础上做了一些修改,而后产生了一条命令向系统推送。相应的Handler加载了对应的聚合根并且执行了指定的Domain Behavior(领域方法),该方法同时产生了一个事件,这个事件由特定的subscriber(订阅器)接收。聚合随后将该事件发布到事件总线上,由事件总线负责将其分发给相关的Handler执行。而那些在聚合根内部被获取执行的就被称为内部事件。内部事件的Handler出了负责设置聚合根内部属性的状态以外不作任何其他的操作。

Domain Behavior(领域方法)

1
2
3
4
public void ChangeTitle(string title)
{
ApplyChange(new ItemRenamedEvent(Id, title));
}

Domain Event(领域事件)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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; }
}

Internal Domain Event Handler(内部领域事件执行器)

1
2
3
4
public void Handle(ItemRenamedEvent e)
{
Title = e.Title;
}

事件还经常会挂载一套被称为Event Sourcing (ES)(事件溯源)的模式。事件溯源是一种通过将聚合的变化以事件的形式记录,并将其转换成二进制流保存,以实现持久化的途径。

就像之前提到的那样,聚合根内部所有的状态变化都由事件触发,而且内部事件执行器除了改变的聚合根状态外其他啥事不管。为了要得到聚合根某一个事件点的状态,就需要通过回放事件来完成。在这里我必须提醒一点,就是所有的事件都是只写操作。你不可以改动或者删除已生成的事件。如果你发现系统内部产生了一些错误的事件,你就必须再创建一些修正的事件来弥补之前的错误。

External Events(外部事件)

外部事件经常用来向展示数据库同步当前领域状态的信息。要做到这点,首先要将内部事件发布到领域外部(通过事件总线)。当事件被发布以后,相应的Handler就会接收执行后续的工作。外部事件可以同时面向多个Handler发布。外部事件的Handler主要负责如下几项工作:

  • 负责从messaging infrastructure (Event Bus)(消息机制(事件总线))接收事件的实例。
  • 负责加载作业管理器实例(举个例子来说,针对仅负责同步展示数据库的Handler,作业管理器就是ORM框架,或者SqlHelper类)来处理事件。
  • 负责将事件内部的信息作为参数传入,并调用执行作业管理器实例的相应方法。
  • 从作业管理器的角度来看总是在更新状态(可以从代码里得到直观体现)。

示例代码说明

我创建了一个非常简单的例子来演示怎么样实现CQRS模式。这个例子可以让你创建和修改你的日志。该解决方案包含如下项目:

  • Diary.CQRS
  • Diary.CQRS.Configuration
  • Diary.CQRS.Web

第一个项目包含了所有领域和消息对象。Configuration项目给WebUI提供了IoC注入支持。我们现在来自己看一下这些项目。

Diary.CQRS

就像我先前提到过的那样,这个项目包含了本例所需的所有领域和消息对象。本CQRS示例项目的唯一入口就是通过将Command发布到Command Bus上。CommandBus类只有一个Send(T command)方法。该方法负责通过调用CommandHandlerFactory来创建对应的Command Handler。如果某个Command没有找到对应的Command Handler,则会抛出异常。正常情况下,Execute方法作为某个行为执行的一部分被调用。该行为会创建一个内部事件,且该事件会存入一个名为_changes的内部成员。该成员的定义可以在AggregateRoot基类里找到。接下来,该事件会由内部事件处理器处理来更新聚合实例的属性状态。在整个行为执行完毕以后,所有该聚合实例下的未保存事件都会通过仓储进行持久化操作。仓储会将当前聚合实例的版本与已经被持久化保存的实例进行版本比较,查看是否有冲突。如果版本不同,就意味着对象可能已经被其他人改过了,系统将抛出ConcurrencyException异常。正常情况下,事件将通过Event Storage进行持久化。

Repository(仓储)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
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接口并重写了四个方法:

1
2
3
4
5
6
7
8
9
10
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;
}

该方法返回聚合实例下的所有事件,如果没有,则意味着该聚合实例不存在。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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);
}
}

该方法负责将事件存储到内存中,另外,每执行三个事件存储操作就会产生一个对应聚合实例的快照。该快照实例包含了聚合的所有状态和版本信息。通过使用快照可以提高性能,因为这样做不需要把所有的发生过的事件都读取出来,只要处理最后三个就可以了。

当所有的事件都被持久化以后,他们就通过Event Bus进行发布然后由外部Event Handler接收并处理。

1
2
3
4
5
6
7
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;
}

返回聚合的快照。

1
2
3
4
public void SaveMemento(BaseMemento memento)
{
_mementos.Add(memento);
}

将聚合转存为快照。

Aggregate Root(聚合根)

AggregateRoot类是所有聚合的基类。该类继承并实现了IEventProvider接口。他内部保存着所有未被提交的事件列表。它同时还带有ApplyChange方法用来调用对应的内部事件处理器。LoadsFromHistory方法用来加载并应用内部领域事件产生的状态变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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类。该类的唯一职责就是将事件发布到subscribers(订阅者)那里。一个单一事件可以被发布到多个订阅者手中。不过在本例中,我们还用不到手工订阅(针对某些事件做某些特殊处理)。Event handler factory(事件处理器工厂)会返回一个EventHanlder的列表来处理当前事件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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事件。通过读取事件内保存的信息,它创建了一个新的对象并将其存入展示数据库中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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项目是一个标准的ASP.NET MVC4应用,里面仅包含一个控制器HomeController和六个ActionResult方法:

  • ActionResult Index() - 该方法将返回Index视图,Index视图作为应用的主界面以列表的形式呈现所有日志内容。
  • ActionResult Delete(Guid id) - 该方法创建了一个新的DeleteItemCommand命令消息实例并将其发布至CommandBus。当命令发送成功后,将返回Index视图。
  • ActionResult Add() - 返回添加视图,你可以在该视图上输入新的日志内容。
  • ActionResult Add(DiaryItemDto item) - 该方法创建了一个新的CreateItemCommand命令消息实例并将其发布至CommandBus。当日志创建成功后,将返回Index视图。
  • ActionResult Edit(Guid id) - 返回选定日志项的编辑视图。
  • ActionResult Edit(DiaryItemDto item) - 该方法创建了一个新的ChangeItemCommand命令消息实例并将其发布至CommandBus。当日志更新成功后,将返回Index视图。当ConcurrencyError(并发错误)发生时,页面上将显示被抛出的异常信息。

下图即为展示日志列表的主界面。

何时应该采用CQRS

总的来说,CQRS模式的应用会让在你应对需要处理需要高度协作以及大型,多用户,高复杂度,包含不断变更的业余规则,还有业务优先的系统中体验到巨大的价值。另外,当你需要实现追踪和记录历史数据功能时它会显得特别有用。

通过CQRS,你可以做到让读写性能飞速提升。而且系统原生就支持了scaling out(横向扩展)。通过将读和写的操作分开,你可以针对任意一方面进行优化。

当你需要面对非常困难的业务逻辑时,CQRS模式就会显得非常有用。CQRS会强制性地避免你将领域逻辑和基础架构的操作进行混淆。

通过应用CQRS模式,你可以在定义好通信接口以后,将开发工作分开交付给不同的团队进行实施。

何时不应该采用CQRS

如果你所开发的项目不需要进行高度地协作,就是指你不需要将同一个系列的数据操作拆分给多个人来写代码的话,那就不应该使用CQRS。

全文翻译完毕 by 止觀。