• CQRS架构中同步服务的一种实现方式


    概述

    同步服务(Synchronization Service)在CQRS架构中有着重要的作用。它通过访问事件总线来读取事件数据,并对事件进行派发。应用程序会向同步服务注册事件处理器,以便同步服务在派发事件的过程中,能够通过事件处理器对事件进行处理。在此,我将针对“查询数据库的同步”这一基本的CQRS应用场景,来给出一种最简单的同步服务实现方式。

    image

    回顾一下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中保留这个消息,以便在下一时间到来时试图再次处理该消息
      image

    • 创建一个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),我们可以通过同步服务的输出结果看到消息已经被处理:
      image

    • 事件处理器(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
    }
    • 最后,检查查询数据库,我们发现UserAccounts数据表中已经产生了所需的记录:
      image

    总结

    通过这篇文章的介绍,我们不仅了解了Tiny Library CQRS演示案例中同步服务的实现方式,我们还了解了CQRS架构中同步服务的主要任务和大致上的操作过程。当然,本文给出的这种实现方式也不是100%的能够确保所有的消息都能够被准确、正确地处理,或许有可能还是会造成数据丢失,但这至少是一种解决方案,而且还是具有相当的改进余地。针对这种方案,我们会有两个疑惑:1、MSMQ查询频率应该是多少?我在案例中使用的是5秒,太频繁会导致服务器严重过载,但太不频繁又会导致数据的不实时性。对于这个不实时性的处理,我提个方案,就是对领域事件的优先级进行规划,并根据优先级对领域事件进行路由,采用不同的同步服务进行处理。2、对于某些需要多个领域事件进行确认的业务逻辑,很抱歉,本文提供的演示案例暂不支持Saga,Apworks目前的版本也不支持Saga,这个问题我会在后续版本的Apworks框架中逐步解决。

    部分代码示例

    单击此处下载与本文相关的部分代码示例。整个Tiny Library CQRS项目的最新版目前正在进行中,因此在codeplex上并无任何与此版本相关的签入代码。敬请谅解。

  • 相关阅读:
    Integration Services 学习(4):包配置
    Integration Services 学习(3)
    AsyncTask的使用
    asp.net下实现支持文件分块多点异步上传的 Web Services
    wcf/web service 编码
    c# 接发邮件2
    Integration Services 学习
    使用AChartEngine画柱状图
    Cocos2d开发系列(五)
    灵活使用XMultipleSeriesRenderer设置自定义的轴标签
  • 原文地址:https://www.cnblogs.com/daxnet/p/2134430.html
Copyright © 2020-2023  润新知