概述
同步服务(Synchronization Service)在CQRS架构中有着重要的作用。它通过访问事件总线来读取事件数据,并对事件进行派发。应用程序会向同步服务注册事件处理器,以便同步服务在派发事件的过程中,能够通过事件处理器对事件进行处理。在此,我将针对“查询数据库的同步”这一基本的CQRS应用场景,来给出一种最简单的同步服务实现方式。
回顾一下CQRS架构,在《EntityFramework之领域驱动设计实践【扩展阅读】:CQRS体系结构模式》一文中,我给出了一个简单的CQRS架构模型图,在该图的“事件总线(Event Bus)”与“查询数据库(Query Database)”之间,有一个Denormalizers/Synchronizers的组件,它负责侦听事件总线,并将事件数据同步到查询数据库中。在具体实现上,Denormalizer/Synchronizer通常会以服务(Service)的方式存在,也就是这里所说的“同步服务”。同步服务的实现是多样化的,基本上也都是按照项目和应用程序的具体情况进行设计,不过大体上离不开两种方式,即主动方式和被动方式。主动方式就是同步服务主动监视事件总线,发现有事件到达后便读取事件数据然后更新查询数据库;被动方式则是由事件发起方(通常是领域仓储,或者是基础结构设施所支持的通知服务等)负责通知同步服务,服务接到通知后,再到事件总线获取数据。其实两种方式都各有利弊,主动方式需要定期对事件总线做查询,这个“定期”的度就不太好把握,时间间隔太短会影响性能,间隔太长又会影响实时性;被动方式避免了定期查询带来的系统开销,但同时又加大了Command部分与Query部分之间的耦合,它需要依靠一些技术手段(比如WCF)来实现进程间通信,又或者,还需要利用一些位于基础结构层的系统组件(比如MSMQ Trigger)。我的一个想法是,在实际项目中根据情况对事件进行路由,分别结合两种方式实现事件派发与数据同步,当然,这只是我的一个设想,并没有真正实践过。
之前,我发布了Apworks开发框架的Alpha版本(地址:http://apworks.codeplex.com),同时也针对这个版本发布了一个基于CQRS架构的演示案例:TinyLibrary CQRS(地址:http://tlibcqrs.codeplex.com)。在Alpha版本中,Apworks仅提供了基于内存对象的“直接事件总线(Direct Event Bus)”,它在获得来自领域仓储的事件后,会直接调用派发器实现事件派发,于是查询数据库也将被同步更新。Direct Event Bus的最大弊病就是要求Event Bus与Command部分在物理上被部署在同一台机器上(因为是直接内存对象实现的),而且其它任何外部系统都无法访问Event Bus,这在系统整合方面就造成了很大的困难。现在,Apworks已经能够支持基于MSMQ的总线机制了,无论是Command Bus还是Event Bus,都可以基于MSMQ来实现。通过使用MSMQ,基于CQRS架构的应用程序在系统整合的方案选取上获得了巨大的发挥空间,比如我们可以使用Biztalk Server的MSMQ Adapter来访问MSMQ。有关Biztalk Server与CQRS架构的整合,我会在另外的文章中讨论,这里不作太多介绍。
为了使得TinyLibrary CQRS演示案例能够支持当前版本的Apworks,并希望在演示中使用MSMQ替代原有的Direct Event Bus作为事件总线,就需要实现一个具有完整功能的同步服务。对于这个同步服务的实现,我对上述主动与被动两种方式进行了分析,最后决定还是采用主动方式(即定期查询MSMQ)。如果是采用被动方式,那么又有如下三个选项:
- 使用WCF,在仓储完成Event Store与Event Bus的两次提交(2PC)之后,以WCF客户端的角色,调用同步服务(同时也是WCF服务端的角色)中的方法,并在该方法中完成MSMQ的读取与数据库的同步
- 使用MSMQ Trigger,但这种方式需要实现并注册COM组件,实现起来不方便
- 通过Query端的查询请求来通知同步服务完成同步,也就是说仓储不需要对同步服务进行通知,同步服务本身也不去定期地查询MSMQ,而是在出现Query端的查询请求时,触发通知并完成同步任务
由于Command部分的仓储操作和Query部分的操作是非常频繁的,因此事实上第一个选项和第三个选项会频繁地通知同步服务,造成同步服务不断地读取MSMQ并处理事件同步任务,这又加重了同步服务的负担,降低了系统性能。而基于MSMQ Trigger的方式,实现则相对更为复杂。权衡一下,针对TinyLibrary CQRS这个演示案例,我还是打算采取主动方式实现同步服务。
TinyLibrary CQRS中基于MSMQ的同步服务的实现方式
总体上讲,TinyLibrary CQRS演示案例的同步服务的设计,主体上有以下几个方面:
- 结合Windows Service和控制台应用的实现方式
- MSMQ的定期查询
- 事件数据读取
结合Windows Service和控制台应用的实现方式
在做服务程序调试的过程中,与读取日志相比,我们更希望能够看到一些实时的结果;而在生产环境中,服务通常以后台的形式运行,并会将一些结果、错误信息写到日志中。TinyLibrary CQRS同步服务结合了这两种方式,在开发的时候可以以控制台方式运行,后台则以Windows Service的形式运行。实现这样的效果其实很简单,首先创建一个控制台应用程序,然后向其中添加一个继承于System.ServiceProcess.ServiceBase的类,并在该类中重写OnStart、OnStop等方法以实现服务运行逻辑。控制台应用程序通常会有一个Main的静态函数作为其执行入口,那么我们只需要在这个Main静态函数中以new关键字创建刚刚新建的类的实例,即可启动服务。大致代码如下:
public sealed class SynchronizationServiceProc : ServiceBase { #if !CONSOLE static void Main() { ServiceBase.Run(new SynchronizationServiceProc()); } #endif public void StartProc() { // 处理启动逻辑 } public void StopProc() { // 处理停止逻辑 } protected override void OnStart(string[] args) { this.StartProc(); } protected override void OnStop() { this.StopProc(); } } #if CONSOLE class Program { static void Main(string[] args) { using (SynchronizationServiceProc proc = new SynchronizationServiceProc()) { proc.StartProc(); Console.ReadLine(); proc.StopProc(); } } } #endif
你会发现在上面的代码中有两个Main的静态函数,如果让它们同时存在的话,是无法编译通过的。因此,我在这个控制台程序的Build选项中,向Conditional compilation symbols添加了CONSOLE宏,并在上面的代码中加入了#if/#endif的宏判断以支持两种不同的编译方式。另外,如需通过installutil.exe命令行安装Windows Service的话,还需向这个控制台程序添加Installer Class。在此就不详述这个过程了。
MSMQ的定期查询
TinyLibrary CQRS的同步服务中,使用System.Timers.Timer类,实现对MSMQ的定期查询。事实上,TinyLibrary CQRS的同步服务并不是真正在Timer的Elapsed事件被触发的时候进行同步操作的。同步操作会被BackgroundWorker分派到另一个线程中执行,这个待会我会介绍。Timer的Elapsed事件只对MSMQ中是否有消息进行判断,首先,确定BackgroundWorker是空闲的,然后读取MSMQ并判断其中是否有消息,若有,则启动BackgroundWorker进行同步操作,否则直接返回。当下一次间隔时间到来时,如果BackgroundWorker正在处理上一次触发的任务,那么Elapsed处理函数会直接返回,于是就达到了既能持续监听MSMQ,又能有效地处理同步任务的目的。Timer的Elapsed代码如下:
private void timer_Elapsed(object sender, ElapsedEventArgs e) { // 如果BackgroundWorker为空闲状态,则 // 查询MSMQ以确定是否有消息 if (!worker.IsBusy) { int messageCount = 0; List<string> messageIds = new List<string>(); using (MessageQueue messageQueue = new MessageQueue(this.EventMessageQueue)) { var messages = messageQueue.GetAllMessages(); messageCount = messages.Length; messageIds = messages.Select(p => p.Id).ToList(); messageQueue.Close(); } // 如果MSMQ中有消息,则启动BackgroundWorker // 并将所有消息的ID作为参数传给BackgroundWorker if (messageCount > 0) { worker.RunWorkerAsync(messageIds); } } }
事件数据读取
这个功能是在一个单独的线程中完成的。Tiny Library CQRS的同步服务采用Background Worker实现这一机制。在Background Worker的DoWork事件处理函数中,首先读取由Timer传入的消息ID列表,然后使用MSMQ的PeekById方法根据ID读取消息内容,同时对读入的消息进行组织(比如判断消息的正确性、获取消息的二进制代码、将二进制代码反序列化为XML字符串、从XML字符串解析出领域事件的类型以及事件触发时间等信息)。最后,通过这些已组织好的数据信息构建出领域事件的实体,并使用消息派发器(Message Dispatcher)将事件派发出去。
在这里有两个需要认真思考的问题:
- 如果事件处理失败怎么办? - 所以我们用的是PeekById,而不是ReceiveById。PeekById只会根据ID从MSMQ读取出消息,而不会将其移除;ReceiveById则会将消息移除
- Peek、PeekById、Receive、ReceiveById都是阻塞式调用,如果读取消息不成功怎么办? - 有网上资料提议使用异步的方式,比如使用BeginReceive等,但这种方式在异步完成处理时仍需要另一个BeginReceive请求来完成下一个消息的读取操作,从实现上看无非就是多出了几个处理线程,并没有对系统性能带来太大好处,而且增加了实现的复杂度
Background Worker的DoWork事件处理函数大致如下:
private void worker_DoWork(object sender, DoWorkEventArgs e) { BackgroundWorker localWorker = sender as BackgroundWorker; if (localWorker.CancellationPending) { e.Cancel = true; return; } List<string> allMessageIds = e.Argument as List<string>; var messageCount = allMessageIds.Count; List<DomainEventMessageContent> messageContents = new List<DomainEventMessageContent>(); using (MessageQueue messageQueue = new MessageQueue(this.EventMessageQueue)) { messageQueue.MessageReadPropertyFilter.SentTime = true; for (int i = 0; i < messageCount; i++) { Message message = messageQueue.PeekById(allMessageIds[i], this.EventMessageReceiveTimeout); var messageContent = new DomainEventMessageContent(message); messageContents.Add(messageContent); } messageQueue.Close(); } var sortedMessageContents = messageContents.OrderBy(mc => mc.SentTime); foreach (var mc in sortedMessageContents) { bool canRemove = true; try { if (!mc.IsValidMessage) throw new Exception("Invalid Message Content."); OnProcessing(mc); Type eventType = Type.GetType(mc.Type); if (eventType != null) { DomainEventXmlSerializer xmlSerializer = new DomainEventXmlSerializer(); var domainEvent = xmlSerializer.Deserialize(eventType, mc.Bytes); messageDispatcher.DispatchMessage(domainEvent); } else canRemove = false; } catch (Exception ex) { OnProcessFailed(mc.MessageId, mc, ex); canRemove = false; } finally { if (canRemove) { using (MessageQueue messageQueue = new MessageQueue(this.EventMessageQueue)) { try { messageQueue.ReceiveById(mc.MessageId, this.EventMessageReceiveTimeout); } finally { messageQueue.Close(); } } } } } }
从上面的代码可以看到,在处理和派发消息时,如果失败,则会引发ProcessFailed事件,同时会将canRemove设置为false,以防止未成功处理的消息从消息队列中移除,造成数据丢失。在finally代码块中,会对已成功处理的消息进行移除操作。
此外,在处理所有获得的消息之前,程序会首先根据消息的发送事件对消息进行排序。这样做的目的是确保消息是按照其发布的顺序进行处理的。比如修改客户信息的消息一定是在创建客户信息之后被处理的。貌似MSMQ并不能够100%确保其Send、Receive的操作是FIFO(First In First Out)的,好像是与队列是否为事务性队列有关系,这部分内容还值得继续研究。不管怎样,对消息排序总归还是行得通的。
运行效果
- 启动同步服务
- 向MSMQ中随意发送一条文本消息,同步服务会读取这个消息并试图处理。由于在处理时发现消息格式不正确,同步服务会显示出错误信息,并在MSQM中保留这个消息,以便在下一时间到来时试图再次处理该消息
- 创建一个UserAccountCreated的领域事件,以表示有一个用户账号被创建。通过发起RegisterUserAccount命令,Command Handler会向领域仓储保存新创建的UserAccount实体。领域仓储在保存实体(确切地说是实体的领域事件序列)时,同时会将领域事件发送到MSMQ事件总线。以下是发起这个RegisterUserAccount命令的测试代码:
[TestMethod] public void CommandBus_HandleRegisterUserAccountCommandTest() { RegisterUserAccountCommand registerUserAccountCommand = new RegisterUserAccountCommand { UserName = "daxnet", Password="password", DisplayName="Sunny Chen", Email = "daxnet@live.com", ContactPhone = "1234567", ContactAddressZip="201203", ContactAddressCity="Shanghai", ContactAddressState="Shanghai", ContactAddressCountry="China", ContactAddressStreet="Zuchongzhi Rd.", }; using (ICommandBus commandBus = appIniter .Application .ObjectContainer .GetService<ICommandBus>()) { commandBus.Publish(registerUserAccountCommand); commandBus.Commit(); } long msgCnt = TestEnvironment.GetMessageCount(); int recordCnt = TestEnvironment.GetDomainEventsTableRecordCount(); Assert.AreEqual(1, recordCnt); }
- 同步服务在获得了来自Command部分的领域事件消息后,便对消息进行信息提取,然后使用事件派发器派发到相应的事件处理器(Event Handler),我们可以通过同步服务的输出结果看到消息已经被处理:
- 事件处理器(Event Handler)在获得了来自消息派发器(Event Dispatcher)的事件之后,直接使用SQL语句更新查询数据库。Event Handler代码如下:
public class TinyLibraryCQRSEventHandler : IEventHandler<UserAccountCreatedEvent> { private string queryDBConnectionString = null; private string QueryDBConnectionString { get { if (queryDBConnectionString == null) queryDBConnectionString = ConfigurationManager .ConnectionStrings["QueryDBConnectionString"].ConnectionString; return queryDBConnectionString; } } #region IHandler<UserAccountCreatedEvent> Members public bool Handle(UserAccountCreatedEvent message) { string insertUserAccoutSql = @"INSERT INTO [UserAccounts] ([UserName], [Password], [DisplayName], [Email], [ContactPhone], [Address_Country], [Address_State], [Address_Street], [Address_City], [Address_Zip]) VALUES (@userName, @password, @displayName, @email, @contactPhone, @country, @state, @street, @city, @zip)"; var rowsAffected = SqlHelper.ExecuteNonQuery(QueryDBConnectionString, CommandType.Text, insertUserAccoutSql, new SqlParameter("@userName", message.UserName), new SqlParameter("@password", message.Password), new SqlParameter("@displayName", message.DisplayName), new SqlParameter("@email", message.Email), new SqlParameter("@contactPhone", message.ContactPhone), new SqlParameter("@country", message.ContactAddressCountry), new SqlParameter("@state", message.ContactAddressState), new SqlParameter("@street", message.ContactAddressStreet), new SqlParameter("@city", message.ContactAddressCity), new SqlParameter("@zip", message.ContactAddressZip)); return rowsAffected > 0; } #endregion }
总结
通过这篇文章的介绍,我们不仅了解了Tiny Library CQRS演示案例中同步服务的实现方式,我们还了解了CQRS架构中同步服务的主要任务和大致上的操作过程。当然,本文给出的这种实现方式也不是100%的能够确保所有的消息都能够被准确、正确地处理,或许有可能还是会造成数据丢失,但这至少是一种解决方案,而且还是具有相当的改进余地。针对这种方案,我们会有两个疑惑:1、MSMQ查询频率应该是多少?我在案例中使用的是5秒,太频繁会导致服务器严重过载,但太不频繁又会导致数据的不实时性。对于这个不实时性的处理,我提个方案,就是对领域事件的优先级进行规划,并根据优先级对领域事件进行路由,采用不同的同步服务进行处理。2、对于某些需要多个领域事件进行确认的业务逻辑,很抱歉,本文提供的演示案例暂不支持Saga,Apworks目前的版本也不支持Saga,这个问题我会在后续版本的Apworks框架中逐步解决。
部分代码示例
请单击此处下载与本文相关的部分代码示例。整个Tiny Library CQRS项目的最新版目前正在进行中,因此在codeplex上并无任何与此版本相关的签入代码。敬请谅解。