网站开发推进计划表,网页设计标准的尺寸是多少,网站建设兆金手指下拉,wordpress怎么实现会员登录在前面两篇文章中#xff0c;我详细介绍了基本事件系统的实现#xff0c;包括事件派发和订阅、通过事件处理器执行上下文来解决对象生命周期问题#xff0c;以及一个基于RabbitMQ的事件总线的实现。接下来对于事件驱动型架构的讨论#xff0c;就需要结合一个实际的架构案例… 在前面两篇文章中我详细介绍了基本事件系统的实现包括事件派发和订阅、通过事件处理器执行上下文来解决对象生命周期问题以及一个基于RabbitMQ的事件总线的实现。接下来对于事件驱动型架构的讨论就需要结合一个实际的架构案例来进行分析。在领域驱动设计的讨论范畴CQRS架构本身就是事件驱动的因此我打算首先介绍一下CQRS架构下相关部分的实现然后再继续讨论事件驱动型架构实现的具体问题。当然CQRS架构本身的实现也是根据实际情况的不同需要具体问题具体分析的不仅如此CQRS架构的实现也是非常复杂的绝不是一套文章一套案例能够解释清楚并涵盖全部的。所以我不会把大部分篇幅放在CQRS架构实现的细节上而是会着重介绍与我们的主题相关的内容并对无关的内容进行弱化。或许在这个系列文章结束的时候我们会得到一个完整的、能够运行的CQRS架构系统不过这套系统极有可能仅供技术研讨和学习使用无法直接用于生产环境。基于这样的前提我们今天首先看一下CQRS架构中聚合与聚合根的实现或许你会觉得目前讨论的内容与你本打算关心的事件驱动架构没什么关系而事实是CQRS架构中聚合与聚合根的实现是完全面向事件驱动的而这部分内容也会为我们之后的讨论做下铺垫。不仅如此我还会在本文讨论一些基于.NET/C#的软件架构设计的思考与实践请注意文章中我添加了Note字样并且字体加粗的句子因此我还是会推荐你继续读完这篇文章。CQRS架构知识回顾早在2010年我针对CQRS架构总结过一篇文章题目是《EntityFramework之领域驱动设计实践【扩展阅读】CQRS体系结构模式》当然这篇文章跟Entity Framework本没啥关系只是延续了领域驱动设计这一话题进行的扩展讨论罢了。这篇文章介绍了CQRS架构模式所产生的背景、结构以及相关的一些概念比如最近非常流行的词语“事件溯源”、解决事件溯源性能问题的“快照”、用于存取事件数据的“事件存储Event Store”还有重新认识了什么叫做“对象的状态”等等。此外在后续的博文中我也经常对CQRS架构中的实现细节做些探讨有兴趣的读者可以翻看我过去的博客文章。总体上讲CQRS架构基本符合下图所描述的结构看上去是不是特别复杂没错特别复杂而且每个部分都可以使用不同的工具、框架以不同的形式进行实现。整个架构甚至可以是语言、平台异构的还可以跟外部系统进行整合实现大数据分析、呈现等等玩法可谓之五花八门这些统统都不在我们的讨论范围之内。我们今天打算讨论的就是上图右上部分“领域模型”框框里的主题CQRS架构中的聚合与聚合根。说到聚合与聚合根了解过领域驱动设计DDD的读者肯定对这两个概念非常熟悉。通常情况下具有相同生命周期组合起来能够共同表述一种领域概念的一组模型对象就可以组成一个聚合。在每个聚合中衔接各个领域模型对象并向外提供统一访问聚合的对象就是聚合根。聚合中的所有对象离开聚合根就不能完整地表述一个领域概念。比如收货地址无法离开客户订单详情无法离开订单库存无法离开货品等等。所以从定义上来看一个聚合大概就是这样聚合中的对象可以是实体也可以是值对象聚合中所有对象具有相同的生命周期外界通过聚合根访问整个聚合聚合根通过导航属性Navigation Properties进而访问聚合中的其它实体和值对象通过以上两点可以得出工厂和仓储必须针对聚合根进行操作聚合根是一个实体聚合中的对象是有状态的通常会通过C#的属性Properties将状态曝露给外界好吧对这些概念比较熟悉的读者来说我在此算是多啰嗦了几句。接下来让我们结合CQRS架构中命令处理器对领域模型的更改过程来看看除了以上这些常规特征之外聚合与聚合根还有哪些特殊之处。当命令处理器接到操作命令时便开始对领域模型进行更改步骤如下首先命令处理器通过仓储获取具有指定ID值的聚合聚合的ID值就是聚合根的ID值然后仓储访问事件存储数据库根据需要获取的聚合根的类型以及ID值获取所有关联的领域事件其次仓储构造聚合对象实例并依照一定的顺序逐一将领域事件重新应用在新构建的聚合上每当有一个领域事件被应用在聚合上时聚合本身的内联事件处理器会捕获这个领域事件并根据领域事件中的数据设置聚合中对象的状态当所有的领域事件全部应用在聚合上时聚合的状态就是曾经被保存时的状态然后仓储将已经恢复了状态的聚合返回给命令处理器命令处理器调用聚合上的方法对聚合进行更改在调用方法的时候方法本身会产生一个领域事件这个领域事件会立刻被聚合本身的内联事件处理器捕获并进行处理。在处理的过程中会更新聚合中对象的状态同时这个领域事件还会被缓存在聚合中命令处理器在完成对聚合的更改之后便会调用仓储将更改后的模型保存下来接着仓储从聚合中获得所有缓存的未曾保存的领域事件并将所有这些领域事件逐个保存到事件存储数据库。在成功完成保存之后会清空聚合中的事件缓存最后仓储将所有的这些领域事件逐个地派发到事件消息总线接下来在事件消息总线和事件处理器中将会发生的事情我们今后还会讨论这里就不多说了。从这个过程我们不难得出CQRS的聚合中更改对象状态必须通过领域事件也就是说不能向外界曝露直接访问对象状态的接口更通俗地说表示对象状态的属性Property不能有设置器SetterCQRS聚合的聚合根中会有一套内联的领域事件处理机制用来捕获并处理聚合中产生的领域事件CQRS聚合的聚合根会有一个保存未提交领域事件的本地缓存对该缓存的访问应该是线程安全的CQRS的聚合需要能够向仓储提供必要的接口比如清除事件缓存的方法等此外CQRS聚合是有版本号的版本号通常是一个64位整型表述历史上发生在聚合上的领域事件一共有多少个。当然这个值在我们目前的讨论中并非能够真正用得上但是在仓储重建聚合需要依赖快照时这个版本号就非常重要了。我会在后续文章中介绍听起来是不是非常复杂确实如此。那我们就先从领域事件入手逐步实现CQRS中的聚合与聚合根。领域事件领域事件顾名思义就是从领域模型中产生的事件消息。概念上很简单比如客户登录网站就会由客户登录实体产生一个事件派发出去例如CustomerLoggedOnEvent表示客户登录这件事已经发生了。虽然在DDD的实践中领域事件更多地在CQRS架构中被讨论其实即便是非事件驱动型架构也可以通过领域模型来发布消息达到系统解耦的目的。延续之前的设计我们的领域事件继承了IEvent接口并增加了三个属性/方法此外为了编程方便我们实现了领域事件的抽象类UML类图如下图中的绿色部分就是在之前我们的事件模型上新加的接口和类用以表述领域事件的概念。其中aggregateRootId发生该领域事件的聚合的聚合根的ID值aggregateRootType发生该领域事件的聚合的聚合根的类型sequence该领域事件的序列号好了如果说我们将发生在某聚合上的领域事件保存到关系型数据库那么当需要获得该聚合的所有领域事件时只需要下面一句SQL就行了1SELECT * FROM [Events] WHERE [AggregateRootId]aggregateRootId AND [AggregateRootType]aggregateRootType ORDER BY [Sequence] ASC这就是最简单的事件存储数据库的实现了。不过我们暂时不介绍这些内容。事实上与标准的事件IEvent接口相比除了上面三个主要的属性之外领域事件还可以包含更多的属性和方法这就要看具体的需求和设计了。不过目前为止我们定义这三个属性已经够用了不要把问题搞得太复杂。有了领域事件的基本模型我们开始设计CQRS下的聚合。聚合的设计与实现由于外界访问聚合都是通过聚合根来实现的因此针对聚合的操作都会被委托给聚合根来处理。比如当用户地址发生变化时服务层会调用Customer.ChangeAddress方法这个方法就会产生一个领域事件并通过内联的事件处理机制更改聚合中Address值对象中的状态。于是从技术角度聚合的设计也就是聚合根的实现。接口与类之间的关系首先需要设计的是与聚合相关的概念所表述的接口、类及其之间的关系。结合领域驱动设计中的概念我们得到下面的设计其中实体IEntity、聚合根IAggregateRoot都是大家耳熟能详的领域驱动设计的概念。由于实体都是通过Id进行唯一标识所以IEntity会有一个id的属性为了简单起见我们使用Guid作为它的类型。聚合根IAggregateRoot继承于IEntity接口有趣的是在我们目前的场景中IAggregateRoot并不包含任何成员它仅仅是一个空接口在整个框架代码中它仅作为泛型的类型约束。Note这种做法其实也是非常常见的一种框架设计模式。具有事件溯源能力的聚合根IAggregateRootWithEventSourcing又继承于IAggregateRoot接口并且有如下三个成员uncommittedEvents用于缓存发生在当前聚合中的领域事件version表示当前聚合的版本号Replay将指定的一系列领域事件“应用”到当前的聚合上也就是所谓的事件回放此外你还发现我们还有两个神奇的接口IPurgable和IPersistedVersionSetter。这两个接口的职责是IPurgable表示实现了该接口的类型具有某种清空操作比如清空某个队列或者将对象状态恢复到初始状态。让IAggregateRootWithEventSourcing继承于该接口是因为当仓储完成了聚合中领域事件的保存和派发之后需要清空聚合中缓存的事件以保证在今后发生在同一时间点的同样的事件不会被再次保存和派发IPersistedVersionSetter接口允许调用者对聚合的“保存版本号”进行设置。这个版本号表示了在事件存储中属于当前聚合的所有事件的个数。试想如果一个聚合的“保存版本号”为4即在事件存储中有4个事件是属于该聚合的那么如果再有2个事件发生在这个聚合中于是该聚合的版本就是426.Note为什么不将这两个接口中的方法直接放在IAggregateRootWithEventSourcing中呢是因为单一职责原则。聚合本身不应该存在所谓之“清空缓存”或者“设置保存版本号”这样的概念这样的概念对于技术人员来说比较容易理解可是如果将这些技术细节加入领域模型中就会污染领域模型造成领域专家无法理解领域模型这是违背面向对象分析与设计的单一职责原则的也违背了领域驱动设计的原则。那么即使把这些方法通过额外的接口独立出去实现了IAggregateRootWithEventSourcing接口的类型不还是要实现这两个接口中的方法吗这样聚合的访问者不还是可以访问这两个额外的方法吗的确如此这些接口是需要被实现的但是我们可以使用C#中接口的显式实现这样的话如果不将IAggregateRootWithEventSourcing强制转换成IPurgable或者IPersistedVersionSetter的话是无法直接通过聚合根对象本身来访问这些方法的这起到了非常好的保护作用。接口的显式实现在软件系统的框架设计中也是常用手段。抽象类AggregateRootWithEventSourcing的实现在上面的类图中IAggregateRootWithEventSourcing最终由AggregateRootWithEventSourcing抽象类实现。不要抱怨类的名字太长它有助于我们理解这一类型在我们的领域模型中的角色和功能。下面的代码列出了该抽象类的主要部分的实现public abstract class AggregateRootWithEventSourcing : IAggregateRootWithEventSourcing{private readonly LazyDictionarystring, MethodInfo registeredHandlers;private readonly QueueIDomainEvent uncommittedEvents new QueueIDomainEvent();private Guid id;private long persistedVersion 0;private object sync new object();protected AggregateRootWithEventSourcing(): this(Guid.NewGuid()){ }protected AggregateRootWithEventSourcing(Guid id){registeredHandlers new LazyDictionarystring, MethodInfo(() {var registry new Dictionarystring, MethodInfo();var methodInfoList from mi in this.GetType().GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)let returnType mi.ReturnTypelet parameters mi.GetParameters()where mi.IsDefined(typeof(HandlesInlineAttribute), false) returnType typeof(void) parameters.Length 1 typeof(IDomainEvent).IsAssignableFrom(parameters[0].ParameterType)select new { EventName parameters[0].ParameterType.FullName, MethodInfo mi };foreach (var methodInfo in methodInfoList){registry.Add(methodInfo.EventName, methodInfo.MethodInfo);}return registry;});Raise(new AggregateCreatedEvent(id));}public Guid Id id;long IPersistedVersionSetter.PersistedVersion { set Interlocked.Exchange(ref this.persistedVersion, value); }public IEnumerableIDomainEvent UncommittedEvents uncommittedEvents;public long Version this.uncommittedEvents.Count this.persistedVersion;void IPurgable.Purge(){lock (sync){uncommittedEvents.Clear();}}public void Replay(IEnumerableIDomainEvent events){((IPurgable)this).Purge();events.OrderBy(e e.Timestamp).ToList().ForEach(e {HandleEvent(e);Interlocked.Increment(ref this.persistedVersion);});}[HandlesInline]protected void OnAggregateCreated(AggregateCreatedEvent event){this.id event.NewId;}protected void RaiseTDomainEvent(TDomainEvent domainEvent)where TDomainEvent : IDomainEvent{lock (sync){// 首先处理事件数据。this.HandleEvent(domainEvent);// 然后设置事件的元数据包括当前事件所对应的聚合根类型以及// 聚合的ID值。domainEvent.AggregateRootId this.id;domainEvent.AggregateRootType this.GetType().AssemblyQualifiedName;domainEvent.Sequence this.Version 1;// 最后将事件缓存在“未提交事件”列表中。this.uncommittedEvents.Enqueue(domainEvent);}}private void HandleEventTDomainEvent(TDomainEvent domainEvent)where TDomainEvent : IDomainEvent{var key domainEvent.GetType().FullName;if (registeredHandlers.Value.ContainsKey(key)){registeredHandlers.Value[key].Invoke(this, new object[] { domainEvent });}}}上面的代码不算复杂它根据上面的分析和描述实现了IAggregateRootWithEventSourcing接口篇幅原因就不多做解释了不过有几点还是可以鉴赏一下的使用Lazy类型来保证领域事件处理器的容器在整个聚合生命周期中只初始化一次通过lock语句和Interlocked.Exchange来保证类型的线程安全和数值的原子操作聚合根被构造的时候会找到当前类型中所有标记了HandlesInlineAttribute特性并具有一定特征的函数将它们作为领域事件的内联处理器注册到容器中每当聚合中的某个业务操作方法需要更改聚合中的状态时就调用Raise方法来产生领域事件由对应的内联处理器捕获领域事件并在处理器方法中设置聚合的状态Replay方法会遍历所有给点的领域事件调用HandleEvent方法实现事件回放现在我们已经实现了CQRS架构下的聚合与聚合根虽然实际上这个结构有可能比我们的实现更为复杂但是目前的这个设计已经能够满足我们进一步研究讨论的需求了。下面我们再更进一步看看CQRS中仓储应该如何实现。仓储实现初探为什么说是“初探”因为我们目前打算实现的仓储暂时不包含事件派发的逻辑这部分内容我会在后续文章中讲解。首先看看仓储的接口是什么样的。在CQRS架构中仓储只具备两种操作保存聚合根据聚合ID也就是聚合根的ID值获取聚合对象你或许会问那根据某个条件查询满足该条件的所有聚合对象呢注意这是CQRS架构中查询部分的职责不属于我们的讨论范围。通常仓储的接口定义如下public interface IRepository{Task SaveAsyncTAggregateRoot(TAggregateRoot aggregateRoot)where TAggregateRoot : class, IAggregateRootWithEventSourcing;TaskTAggregateRoot GetByIdAsyncTAggregateRoot(Guid id)where TAggregateRoot : class, IAggregateRootWithEventSourcing;}1与之前领域事件的设计类似我们为仓储定义一个抽象类所有仓储的实现都应该基于这个抽象类public abstract class Repository : IRepository{protected Repository(){ }public async TaskTAggregateRoot GetByIdAsyncTAggregateRoot(Guid id)where TAggregateRoot : class, IAggregateRootWithEventSourcing{var domainEvents await LoadDomainEventsAsync(typeof(TAggregateRoot), id);var aggregateRoot ActivateAggregateRootTAggregateRoot();aggregateRoot.Replay(domainEvents);return aggregateRoot;}public async Task SaveAsyncTAggregateRoot(TAggregateRoot aggregateRoot)where TAggregateRoot : class, IAggregateRootWithEventSourcing{var domainEvents aggregateRoot.UncommittedEvents;await this.PersistDomainEventsAsync(domainEvents);aggregateRoot.PersistedVersion aggregateRoot.Version;aggregateRoot.Purge();}protected abstract TaskIEnumerableIDomainEvent LoadDomainEventsAsync(Type aggregateRootType, Guid id);protected abstract Task PersistDomainEventsAsync(IEnumerableIDomainEvent domainEvents);private TAggregateRoot ActivateAggregateRootTAggregateRoot()where TAggregateRoot : class, IAggregateRootWithEventSourcing{var constructors from ctor in typeof(TAggregateRoot).GetTypeInfo().GetConstructors()let parameters ctor.GetParameters()where parameters.Length 0 ||(parameters.Length 1 parameters[0].ParameterType typeof(Guid))select new { ConstructorInfo ctor, ParameterCount parameters.Length };if (constructors.Count() 0){TAggregateRoot aggregateRoot;var constructorDefinition constructors.First();if (constructorDefinition.ParameterCount 0){aggregateRoot (TAggregateRoot)constructorDefinition.ConstructorInfo.Invoke(null);}else{aggregateRoot (TAggregateRoot)constructorDefinition.ConstructorInfo.Invoke(new object[] { Guid.NewGuid() });}// 将AggregateRoot下的所有事件清除。事实上在AggregateRoot的构造函数中已经产生了AggregateCreatedEvent。aggregateRoot.Purge();return aggregateRoot;}return null;}}代码也是非常简单、容易理解的GetByIdAsync方法根据给定的聚合根类型以及ID值从后台存储中读取所有属于该聚合的领域事件并在聚合上进行回放以便将聚合恢复到存储前的状态SaveAsync方法则从聚合根上获得所有未被提交的领域事件将这些事件保存到后台存储然后设置聚合的“已保存版本”最后清空未提交事件的缓存。剩下的就是如何实现LoadDomainEventsAsync以及PersistDomainEventsAsync两个方法了。而这两个方法原本就应该是事件存储对象的职责范围了。Note你也许会问如果某个聚合从开始到现在已经发生了大量的领域事件了那么这样一条条地将事件回放到聚合上岂不是性能非常低下没错这个问题我们可以通过快照来解决。在后续文章中我会介绍。你还会问日积月累事件存储系统中的事件数量岂不是会越来越多吗需要删除吗答案是不删不过可以对数据进行归档或者依赖一些第三方框架来处理这个问题但是从领域驱动设计的角度领域事件代表着整个领域模型系统中发生过的所有事情事情既然已经发生就无法再被抹去因此删除事件存储系统中的事件是不合理的。那数据量越来越大怎么办答案是或许存储硬件设备要比业务数据更便宜。仓储的实现我们暂且探索到这一步目前我们只需要有一个正确的聚合保存、读取通过领域事件重塑的逻辑就可以了并不需要关心事件本身是如何被读取被保存的。接下来我们在.NET Core的测试项目中借助Moq框架通过Mock一个假想的仓储来验证整个系统从聚合、聚合根的实现到仓储设计的正确性。使用Moq框架通过单元测试验证聚合、聚合根以及仓储设计的正确性Moq是一个很好的Mock框架简单轻量而且支持.NET Core在单元测试的项目中使用Moq是一种很好的实践。Moq上手非常简单只需要在单元测试项目上添加Moq的NuGet依赖包就可以开始着手编写测试用例了。为了测试我们的聚合根以及仓储对聚合根保存、读取的设计首先我们定义一个简单的聚合public class Book : AggregateRootWithEventSourcing{public void ChangeTitle(string newTitle){this.Raise(new BookTitleChangedEvent(newTitle));}public string Title { get; private set; }[HandlesInline]private void OnTitleChanged(BookTitleChangedEvent event){this.Title event.NewTitle;}public override string ToString(){return Title;}}Book类是一个聚合根它继承AggregateRootWithEventSourcing抽象类同时它有一个属性Title表示书的名称而ChangeTitle方法业务方法会直接产生一个BookTitleChangedEvent领域事件之后OnTitleChanged成员函数会负责将领域事件中的NewTitle的值设置到Book聚合根的Title状态上完成书本标题的更新。与之相关的BookTitleChangedEvent的定义如下public class BookTitleChangedEvent : DomainEvent{public BookTitleChangedEvent(string newTitle){this.NewTitle newTitle;}public string NewTitle { get; set; }public override string ToString(){return ${Sequence} - {NewTitle};}}首先下面两个测试用例用于测试Book聚合本身产生领域事件的过程是否正确如果正确那么当Book本身本构造时会产生一个AggregateCreatedEvent如果更改书本的标题则又会产生一个BookTitleChangedEvent所以第一个测试中book的版本应该为1而第二个则为2[Fact]public void CreateBookTest(){// Arrange Actvar book new Book();// AssertAssert.NotEqual(Guid.Empty, book.Id);Assert.Equal(1, book.Version);}[Fact]public void ChangeBookTitleEventTest(){// Arrangevar book new Book();// Actbook.ChangeTitle(Hit Refresh);// AssertAssert.Equal(Hit Refresh, book.Title);Assert.Equal(2, book.UncommittedEvents.Count());Assert.Equal(2, book.Version);}接下来测试仓储保存Book聚合的正确性因为我们没有实现一个有效的仓储实例因此这里借助Moq帮我们动态生成。在下面的代码中让Moq对仓储抽象类的PersisDomainEventsAsync受保护成员进行动态生成指定当它被任何IEnumerableIDomainEvent作为参数调用时都将这些事件保存到一个本地的List中于是最后只需要检查List中的领域事件是否符合我们的要求就可以了。代码如下[Fact]public async Task PersistBookTest(){// Arrangevar domainEventsList new ListIDomainEvent();var mockRepository new MockRepository();mockRepository.Protected().SetupTask(PersistDomainEventsAsync,ItExpr.IsAnyIEnumerableIDomainEvent()).CallbackIEnumerableIDomainEvent(evnts domainEventsList.AddRange(evnts)).Returns(Task.CompletedTask);var book new Book();// Actbook.ChangeTitle(Hit Refresh);await mockRepository.Object.SaveAsync(book);// AssertAssert.Equal(2, domainEventsList.Count);Assert.Empty(book.UncommittedEvents);Assert.Equal(2, book.Version);}同理我们还可以测试仓储读取聚合并恢复聚合状态的正确性同样还是使用Moq对仓储的LoadDomainEventsAsync进行Mock[Fact]public async Task RetrieveBookTest(){// Arrangevar fakeId Guid.NewGuid();var domainEventsList new ListIDomainEvent{new AggregateCreatedEvent(fakeId),new BookTitleChangedEvent(Hit Refresh)};var mockRepository new MockRepository();mockRepository.Protected().SetupTaskIEnumerableIDomainEvent(LoadDomainEventsAsync,ItExpr.IsAnyType(),ItExpr.IsAnyGuid()).Returns(Task.FromResult(domainEventsList.AsEnumerable()));// Actvar book await mockRepository.Object.GetByIdAsyncBook(fakeId);// AssertAssert.Equal(fakeId, book.Id);Assert.Equal(Hit Refresh, book.Title);Assert.Equal(2, book.Version);Assert.Empty(book.UncommittedEvents);}好了其它的几个测试用例就不多做介绍了使用Visual Studio运行一下测试然后查看结果就可以了总结本文又是一篇长篇幅的文章好吧要介绍的东西太多而且这些内容又不能单独割开成多个主题所以也就很难控制篇幅了。文章主要介绍了基于CQRS架构的聚合以及聚合根的设计与实现同时引出了仓储的部分实现这些内容也是为今后进一步讨论事件驱动型架构做准备。本文介绍的内容对于一个真实的CQRS系统实现来说还是有一定差距的但总体结构也大致如此。文中还提及了快照的概念这部分内容我今后在介绍事件存储的实现部分还会详细讨论下一章打算扩展一下仓储本身了解一下仓储对领域事件的派发以及事件处理器对领域事件的处理。源代码的使用本系列文章的源代码在https://github.com/daxnet/edasample这个Github Repo里通过不同的release tag来区分针对不同章节的源代码。本文的源代码请参考chapter_4这个tag如下相关文章 ASP.NET Core Web API下事件驱动型架构的实现一一个简单的实现ASP.NET Core Web API下事件驱动型架构的实现二事件处理器中对象生命周期的管理ASP.NET Core Web API下事件驱动型架构的实现三基于RabbitMQ的事件总线原文地址:https://www.cnblogs.com/daxnet/p/8594287.html.NET社区新闻深度好文欢迎访问公众号文章汇总 http://www.csharpkit.com