《实现领域驱动设计》笔记——架构
DDD的一大好处便是它并不需要使用特定的架构。由于核心域位于限界上下文中,我们可以在整个系统中使用多种风格的架构。有些架构包围着领域模型,能够全局性地影响系统,而有些架构则满足了某些特定的需求。我们的目标是选择合适于自己的架构和架构模式。
在选择架构风格和架构模式时,我们应该将软件质量考虑在内,而同时,避免滥用架构风格和架构模式也是重要的。质量驱动的架构选择是种风险驱动方式,即我们采用的架构是用来减少失败风险的,而不是增加失败风险。因此,我们必须对每种架构做出正确的评估。
对架构风格和模式的选择受到功能需求的限制,比如用例或用户故事。换句话说,在没有功能需求的情况下,我们是不能对软件质量做出评判的,亦不能做出正确的架构选择。这也说明用例驱动架构在当今的软件开发中依然使用。
分层
分层架构模式被认为是所有架构的始祖。它支持N层架构系统,因此被广泛应用于Web、企业级应用和桌面应用。在这种架构中,我们将一个应用程序或系统分为不同的层次。
在分层架构中,我们将领域模型和业务逻辑分离出来,并减少对基础设施、用户界面甚至应用层逻辑的依赖,因为它们不属于业务逻辑。将一个复杂的系统分为不同的层,每层都应该具有良好的内聚性,并且只依赖于比其自身更低的层。
分层架构的一个重要原则是:每层只能与位于其下方的层发生耦合。分层架构也分为几种:在严格分层架构中,某层只能与直接位于其下方的层发生耦合;而松散分层架构则允许任意上方层与任意下方层发生耦合。由于用户界面层和应用服务通常需要与基础设施打交道,许多系统都是基于松散分层架构的。
事实上,较低层也是可以和较高层发生耦合的,但这只局限于采用观察者模式或者调停者模式的情况。 较低层绝对不能直接访问较高层的。例如,在使用调停者模式时,较高层可能实现了较低层的接口,然后将实现对象作为参数传递到较低层。当较低层调用该实现时,它并不知道实现出自何处。
用户界面只用于处理用户显示和用户请求,它不应该包含领域或业务逻辑。有人可能会认为,既然用户界面需要对用户输入进行验证,那么它就应该包含业务逻辑。事实上,用户界面所进行的验证和对领域模型的验证是不同。在实体中会讲到,对于那些粗制滥造的,并且只面向领域模型的验证行为,我们依然应该予以限制。
如果用户界面使用了领域模型中的对象,那么此时的领域对象仅限于数据的渲染展示。在采用这种方式时,可以使用展现模型对用户界面与领域对象进行解耦。
由于用户可能是人,也可能是其他的系统,有时用户界面层将采用开放主机服务的方式向外提供API。
用户界面层是应用层的直接用户。
应用服务位于应用层中。应用服务和领域服务是不同的,因此领域逻辑也不应该出现在应用服务中。应用服务可以用于控制持久化事务和安全认证,或者向其他系统发送基于事件的消息通知,另外还可以用于创建邮件以发送给用户。应用服务本身并不处理业务逻辑,但它确实领域模型的直接用户。
应用服务是很轻量的,它主要用于协调对领域对象的操作,比如聚合。同时,应用服务是表达用例和用户故事的主要手段。因此,应用服务的通常用途是:接受来自用户界面的输入参数,再通过资源库获取聚合实例,然后执行相应的命令操作。
如果应用服务功能比较复杂,这通常意味着领域逻辑已经渗透到应用服务中了,此时的领域模型将变成贫血模型。因此,最佳实践是将应用层做成很薄的一层。当需要创建新的聚合时,应用服务应该使用工厂或聚合的构造函数来实例化对象,然后采用资源库对其进行持久化。应用服务还可以调用领域服务来完成和领域相关的任务操作,此时操作应该是无状态的。
在传统的分层架构中,却存在着一些与领域相关的挑战。在分层架构中,领域层或多或少地需要使用基础设施层。并不是说核心的领域对象会直接参与其中,而是领域层的中的有些接口实现依赖于基础设施层。比如,资源库接口的实现需要基础设施层提供的持久化机制。那么,如果我们将资源库接口直接实现在基础设施层会怎样?由于基础设施层位于领域层之下,从基础设施层向上引用领域层则违反了分层架构的原则。
依赖倒置原则
有一种方法可以改进分层架构——依赖倒置原则(Dependency Inversion Principle,DIP),它通过改变不同层之间的依赖关系达到改进目的。
高层模块不应该依赖于低层模块,两者都应该依赖于抽象。
抽象不应该依赖于细节,细节应该依赖于抽象。
根据定义,低层服务(比如基础设施层)应该依赖于高层组件(比如用户界面层,应用层或领域层)所提供的接口。在架构中采用依赖倒置原则有很多种表达方式,这里我们采用下图的方式:
我们应该将关注点放在领域层上,采用依赖倒置的原则,使领域层和基础设施层都只依赖于由领域模型所定义的抽象接口。由于应用层是领域层的直接客户,它将依赖于领域层接口,并且间接地访问资源库和由基础设施层提供的实现。应用层可以采用不同的方式来获取这些实现,包括依赖注入(Dependency Injection)、服务工厂(Service Factory)和插件(Plug In)。
当我们在分层中采用依赖倒置原则时,我们可能会发现,事实上已经不存在分层的概念了。无论是高层还是低层,它们都只依赖于抽象,好像把整个分层架构给推平了一样。
六边形架构(端口与适配器)
六边形架构是一种具有对称性特征的架构风格。在这种架构中,不同的客户通过“平等”的方式与系统交互。需要新的客户吗?不是问题,只需要添加一个新的适配器将客户输入转化成能被系统API所理解的参数就行。同时,系统输出,比如图形界面、持久化和消息等都可以通过不同的方式实现,并且可以互换。这是可能的,因为对于每种特定的输出,都有一个新建的适配器负责完成相应的转化功能。
现在很多声称使用分层架构的团队实际上使用的是六边形架构。这是因为很多项目都使用了某种形式的依赖注入。并不是说依赖注入天生就是六边形架构,而是说使用了依赖注入的架构自然地具有了端口与适配器风格。
我们通常将客户与系统交互的地方称为“前端”;同样,我们将系统中获取、存储持久化数据和发送输出数据的地方称为“后端”。但是六边形架构提倡用一种新的视角来看待整个系统,如下图所示。该架构中存在两个区域,分别是“外部区域”和“内部区域”。在外部区域中,不同的客户均可以提交输入;而内部的系统则用于获取持久化数据,并对程序输出进行存储(比如数据库),或者在中途将输出转发到另外的地方(比如消息)。
在上图中,每种类型的客户都有它自己的适配器,该适配器用于将客户输入转化为程序内部API所能理解的输入。六边形每条不同的边代表了不同种类型的端口,端口要么处理输入,要么处理输出。图中有3个客户请求均抵达相同的输入端口(适配器A、B和C),另一个客户请求使用了适配器D。可能前3个请求使用了HTTP协议(浏览器、REST和SOAP等),而后一个请求使用了AMQP协议(比如Rabbit MQ)。
端口并没有明确的定义,它是一个非常灵活的概念。无论采用哪种方式对端口进行划分,当客户请求到达时,都应该有相应的适配器对输入进行转化,然后端口将调用应用程序的某个操作或者向应用程序发送一个事件,控制权由此交给内部区域。
我们不必自己实现端口
通常来说,我们都不用自己实现端口。我们可以将端口想成是HTTP,而将适配器想成是请求处理类。或者可以为NServiceBus 或 Rabbit MQ 创建消息监听器,在这种情况下,端口是消息机制,而适配器则是消息监听器,因为消息监听器将负责从消息中提取数据,并将数据转化为应用层API(领域模型的客户)所需的参数。
应用程序通过公共API接收客户请求。应用程序边界,即内部六边形,也是用例边界。换句话说,我们应该根据应用程序的功能需求来创建用例,而不是客户数量或输出机制。当应用程序通过API接收到请求时,它将使用领域模型来处理请求,其中便包括对业务逻辑的执行。因此,应用层API通过应用服务的方式展现给外部。再次提醒,这里的应用服务是领域模型的直接客户,就像在分层架构中一样。
对于上图中右侧的端口和适配器,我们应该如何看待呢?我们可以将资源库的实现看作是持久化适配器,该适配器用于访问先前存储的聚合实例,或者保存新的聚合实例。正如图中的适配器E、F和G所展示的,我们可以通过不同的方式实现资源库,比如关系型数据库、基于文档的存储、分布式缓存和内存存储等。如果应用程序向外界发送领域事件信息,我们将使用适配器H进行处理。该适配器处理消息输出,而刚才提到的处理AMQP消息的适配器则是处理消息输入的,因此因该使用不同的端口。
六边形架构的一大好处在于,我们可以轻易地开发用于测试的适配器。整个应用程序和领域模型可以在没有客户和存储机制的条件下进行设计。
如果你采用的是严格分层架构,那么你应该考虑推平这种架构,然后开始采用端口与适配器。如果设计得当,内部六边形——也即应用程序和领域模型——是不会泄漏到外部区域的,这样也有助于形成一种清晰的应用程序边界。在外部区域,不同的适配器可以支持自动化测试和真实的客户请求,还有存储、消息和其他输出机制。
六边形架构的功能如此强大,以以致于它可以用来支持系统中的其他架构。比如,我们可能采用SOA架构、REST或者事件驱动架构;也有可能采用CQRS;或者数据网织或基于网格的分布式缓存;还有可能采用Map- Reduce这种分布式并行处理方式。
以下是一个简单案例的代码Demo:
假设我们有一个在线商店系统,需要处理订单、库存管理、支付等业务。我们可以使用六边形架构来设计系统。
核心业务逻辑包括订单处理、库存管理和支付逻辑,这些逻辑位于系统的中心部分。我们可以定义接口(端口)来定义订单处理、库存管理和支付逻辑的行为。 外部适配器可以包括与数据库的交互、与支付网关的通信、与物流系统的集成等。每个外部适配器通过实现核心业务逻辑定义的接口来与核心业务逻辑进行通信。
这种架构使得系统的核心业务逻辑与外部适配器分离,易于扩展和维护。例如,当需要更换支付网关时,我们只需实现新的支付适配器,并通过接口与核心业务逻辑进行交互,而不需要修改核心逻辑。
// 定义订单类 public class Order { public int Id { get; set; } // 其他订单属性 } // 定义订单仓储接口 public interface IOrderRepository { void Save(Order order); } // 实现订单仓储 public class DatabaseOrderRepository : IOrderRepository { public void Save(Order order) { // 保存订单到数据库 } } // 定义订单处理器接口 public interface IOrderProcessor { void ProcessOrder(Order order); } // 实现订单处理器 public class OrderProcessor : IOrderProcessor { public void ProcessOrder(Order order) { // 处理订单逻辑 } } // 定义订单服务接口 public interface IOrderService { void PlaceOrder(Order order); } // 实现订单服务 public class OrderService : IOrderService { private readonly IOrderRepository _orderRepository; private readonly IOrderProcessor _orderProcessor; public OrderService(IOrderRepository orderRepository, IOrderProcessor orderProcessor) { _orderRepository = orderRepository; _orderProcessor = orderProcessor; } public void PlaceOrder(Order order) { _orderProcessor.ProcessOrder(order); // 调用订单处理器的逻辑 _orderRepository.Save(order); // 保存订单到数据库 } } // 示例用法 public class Program { public static void Main() { IOrderRepository orderRepository = new DatabaseOrderRepository(); IOrderProcessor orderProcessor = new OrderProcessor(); IOrderService orderService = new OrderService(orderRepository, orderProcessor); // 客户端代码可以直接使用订单服务来处理订单 Order order = new Order(); orderService.PlaceOrder(order); } } ```
面向服务架构
面向服务架构(Service- Oriented Architecture,SOA)对于不同人来说具有不同的意思。以下是由 Thomas Erl 所定义的一些SOA原则。服务除了拥有互操作性外,还具有以下8种设计原则:
下面是SOA的概念描述:
面向服务架构(Service-Oriented Architecture,SOA)是一种软件架构模式,它将应用程序的不同功能划分为独立的服务,这些服务可以独立部署、管理和使用。每个服务都是一个具有明确定义接口的独立单元,可以通过网络进行通信并与其他服务进行交互。
面向服务架构的核心思想是将应用程序的功能分解为可重用的服务,这些服务可以被其他应用程序或服务所使用。每个服务都提供特定的功能,并且可以被动态地组合和重用,从而提高了系统的灵活性和可扩展性。
面向服务架构通常使用标准的通信协议和数据格式,如Web服务(如SOAP和RESTful)来实现服务之间的通信。这使得不同平台和技术的应用程序能够相互协作,从而促进了系统的集成和互操作性。
通过面向服务架构,企业可以更好地管理和组织其软件系统,降低系统的复杂性和耦合度,提高系统的可维护性和可扩展性。同时,面向服务架构也可以促进业务流程的优化和自动化,提高企业的业务灵活性和响应速度。
我们可以将上面的服务设计原则和六边形架构结合起来,此时服务边界位于最左侧,而领域模型位于中心位置,如下图所示。消费方可以通过 REST、SOAP和消息机制获取服务。请注意,一个六边形架构系统支持多种类型的服务端点(endpoint),这依赖于DDD是如何应用于SOA的。
业务服务可以由任意数目的技术服务来提供。
技术服务可以是REST资源、SOAP接口或者消息类型。业务服务强调业务战略,即如何对业务和技术进行整合。然而,定义单个业务服务与定义单个子域或限界上下文是不同的。在我们对问题空间和解决方案空间进行评估时,我们会发现,此两者均包含业务服务。因此,上图只是单个限界上下文的架构,该限界上下文可以提供一系列的技术服务,包括REST资源、SOAP接口或者消息类型,而这些技术服务只是整个业务服务的一部分。在SOA的解决方案空间中,我们希望看到更多个限界上下文,而不管这些上下文使用的是六边形架构还是其他结构。SOA和DDD均没有必要制定如何对技术服务进行设计和部署,因为存在很多中这样的方式。
在使用DDD时,我们所创建的限界上下文应该包含一个完整的,能很好表达通用语言的领域模型。在限界上下文中已经提到,我们并不希望架构对领域模型的大小产生影响。但是,如果一个或多个技术服务端点,比如REST资源、SOAP接口或消息类型被用于决定限界上下文的大小,那么上述情况是有可能发生的,结果是将导致许多非常小的限界上下文和领域模型,这样的模型中很有可能只包含一个实体对象,并且该实体作为某个单一聚合的根对象而存在。
虽然这种方式在技术上具有优点,但是它却没有达到战略DDD所要求的目标。对于通用语言来说,这种方式会起分化破坏作用。非自然地分化限界上下文并不是SOA精神所在:
- 业务价值高于技术策略
- 战略目标高于项目利益
就像限界上下文中所讲到的,技术组件对于划分模型来说并没有那么重要。
REST
REST作为一种架构风格
在使用REST之前,我们首先需要理解什么是架构风格。架构风格之于架构就像设计模式之于设计一样。它将不同架构实现所共有的东西抽象出来,使得我们在谈及到架构时不至于陷入技术细节中。
REST属于Web架构的一种架构风格。当然,Web——体现为URI、HTTP和HTML。
那么现在,我们为什么将REST作为构建系统的另一种方式呢?或者更严格地说,是构建Web服务的一种方式。原因在于,和其他技术一样,我们可以通过不同的方式来使用Web协议。有些使用方式符合设计者的初衷,而有些就不见得了。比如,关系型数据库管理系统便是一例。
同样的道理,Web协议即可以按照它原先的设计初衷为人所用——此时便是一种遵循REST架构风格的方式——也可以通过一种不遵循其设计初衷的方式为人所用。因此,在我们没有获得由使用“REST”风格的HTTP所带来的好处时,另一种不同的分布式系统架构可能是合适的。
RESTful HTTP 服务器的关键方面
那么,对于采用“RESTful HTTP”的分布式系统来说,它具有哪些关键方面呢?我们先看服务器端。请注意,在我们讨论服务器端时,无论客户是操作Web浏览器的某个人,还是由编程语言开发的客户端程序,对它们都是同等处理的,没有什么区别。
首先,就像其名字所指出的,资源是关键的概念。作为一个系统设计者,你决定哪些有意义的“东西”可以暴露给外界,并且给这些“东西”一个唯一的身份标识。通常来说,每种资源都拥有一个URI,更重要的是,每个URI都需要指向某个资源——即你向外界暴露的“东西”。比如,你可能会做出这样的决定:每一个客户、产品、产品列表、搜索结果和每次对产品目录的修改都应该分别作为一种资源。资源是具有展现(representation)和状态的,这些展示的格式可能不同。客户通过资源的展现与服务器交互,格式可以为XML、JSON、HTML或二进制数据。
另一个关键方面是无状态通信,此时我们将采用具有自描述功能的消息。比如,HTTP请求便包含了服务器所需的所有信息。当然,服务器也可以使用其本身的状态来辅助通信,但是重要的是:我们不能依靠请求本身来创建一个隐式上下文环境(对话)。无状态通信保证了不同请求之间的相互独立性,这在很大程度上提高了系统的可伸缩性。
如果你将资源看作对象——这是合理的——那么你应该问问它们应该拥有什么样的接口。这个问题的答案是REST的另一个关键面,它将REST与其他架构风格区别开来。你可以调用的方法集合是固定的。每一个对象都支持相同的接口。在RESTful HTTP中,对象方法便可以表示为操作资源的HTTP动词,其中最重要的有GET、PUT、POST和DELETE。
虽然乍一看这些方法将会转化成CRUD操作,但是事实却并非如此。通常,我们所创建的资源并不表示任何持久化实体,而是封装了某种行为,当我们将HTTP动词应用在这些资源上时,我们实际上调用这些行为。在HTTP规范中,每种HTTP方法都有一个明确的定义。比如,GET方法只能用于“安全”的操作:(1)它可能完成一些客户并没有要求的动作行为;(2)它总是读取数据;(3)它可能被缓存起来。
有些HTTP方法是幂等的,即我们可以安全地对失败的请求进行重试。这些方法包括GET、PUT和DELETE等。
最后通过,通过使用超媒体,REST服务器的客户端可以沿着某种路径发现应用程序可能的状态变化。简单来说,就是单个资源并不独立存在。不同资源是相互链接在一起的。这并不意外,毕竟,这就是Web称为Web的原因。对于服务器来说,这意味着在返回中包含对其他资源的链接,由此客户便可以通过这些链接访问到相应的资源。
RESTful HTTP客户端的关键方面
RESTful HTTP客户端可以通过两种方式在不同资源之间进行转换,一种是上面提到的超媒体,一种是服务器端的重定向。服务器端和客户端将协同工作以动态地影响客户端的分布式行为。由于URI包含了对地址进行解引用的所有信息——包括主机名和端口——客户端可以根据超媒体链接访问到不同的应用程序,不同的主机,甚至不同公司的资源。
在理想情况下,REST客户端将从单个众所周知的URI开始访问,然后通过超媒体链接继续访问不同的资源。这和Web浏览器显示HTML页面是一样的,HTML中包含了各种链接和表单,浏览器根据用户输入与不同的Web应用程序交互,此时它并不需要知道Web应用程序的接口或实现。
然而,浏览器并不能算是一个自给自足的客户端,它需要由人来做出实际决定。但是一个程序客户端却可以模拟人来做出决定,其中甚至包含了一些硬编码逻辑。它可以跟随不同的链接访问不同的资源,同时它将根据不同的媒体类型发出不同的请求。
REST和DDD
RESTful HTTP是具有诱惑力的,但是我们并不建议将领域模型直接暴露给外界,因为这样会使系统接口变得非常脆弱,原因在于对领域模型的每次改变都会导致对系统接口的改变。要将DDD与RESTful HTTP结合起来使用,我们有两种方式。
第一种方法是为系统接口层单独创建一个限界上下文,再在此上下文中通过适当的策略来访问实际的核心模型。这是一种经典的方法,它将系统接口看作一个整体,通过资源抽象将系统功能暴露给外界,而不是通过服务或者远程接口。
这在核心域和系统接口模型之间完成了解耦,这使得我们可以优先对领域模型进行修改,然后再决定修改哪些应该反映到系统接口模型上。
下面是一个demo:
这里应用服务来隐藏领域模型。应用服务是领域模型和系统接口之间的中介,它将外部请求转换为领域模型可以理解的操作,并处理领域模型的调用和返回结果。这样可以更好地保护领域模型,避免直接暴露给外界。
// 应用服务 public class OrderAppService { private readonly OrderService _orderService; public OrderAppService(OrderService orderService) { _orderService = orderService; } public OrderDto GetOrder(int id) { var order = _orderService.GetOrderById(id); // 将领域模型转换为DTO并返回给外界 return MapToDto(order); } public void CreateOrder(OrderDto orderDto) { var order = MapToDomain(orderDto); _orderService.CreateOrder(order); } // 其他应用服务方法 } // DTO public class OrderDto { public int Id { get; set; } public string CustomerName { get; set; } public List<OrderItemDto> OrderItems { get; set; } // 其他属性 } public class OrderItemDto { public int Id { get; set; } public string ProductName { get; set; } public int Quantity { get; set; } // 其他属性 } // 控制器 public class OrderController : ApiController { private readonly OrderAppService _orderAppService; public OrderController(OrderAppService orderAppService) { _orderAppService = orderAppService; } [HttpGet] public IHttpActionResult GetOrder(int id) { var order = _orderAppService.GetOrder(id); if (order == null) { return NotFound(); } return Ok(order); } [HttpPost] public IHttpActionResult CreateOrder(OrderDto orderDto) { _orderAppService.CreateOrder(orderDto); return Created(Request.RequestUri + orderDto.Id.ToString(), orderDto); } // 其他接口方法 }
上述示例中并没有为系统接口层创建一个单独的限界上下文。要创建一个单独的限界上下文,您可以将接口层视为一个独立的上下文,与领域模型和服务层进行明确的分离。
要创建一个单独的限界上下文,您可以考虑以下步骤:
1. 定义接口层的上下文:明确接口层的职责和范围,并将其与领域模型和服务层进行分离。这可以通过创建一个单独的命名空间、项目或微服务等方式实现。
2. 定义接口层的接口:为接口层定义一组明确的接口,用于与领域模型和服务层进行通信。这些接口应该只暴露与RESTful HTTP资源相关的操作和功能。
3. 实现接口层的逻辑:在接口层中实现与RESTful HTTP资源相关的操作和功能。这可以包括处理HTTP请求、验证输入、调用领域模型和服务层的方法等。
4. 映射和转换:在接口层中进行适当的映射和转换,以确保RESTful HTTP资源与领域模型之间的数据一致性。这可能涉及使用DTO(数据传输对象)或其他映射技术。
5. 管理依赖关系:确保接口层与领域模型和服务层之间的依赖关系得到适当管理。这可以通过依赖注入、接口隔离原则等技术实现。
另一种方法用于需要使用标准媒体类型的时候。如果某种媒体并用于支持单个系统接口,而是用于一组相似的客户端-服务器交互场景,此时我们可以创建一个领域模型来处理每一种媒体类型。
using System; using System.Collections.Generic; using System.Net; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; namespace MyApplication.Domain { public class CoreModel { // 核心模型的属性和行为 } } namespace MyApplication.MediaTypes { public class JsonMediaType { public class RequestData { // JSON请求数据的结构定义 } public class ResponseData { // JSON响应数据的结构定义 } public class JsonMediaTypeHandler { public CoreModel ProcessRequest(RequestData requestData) { // 处理JSON请求数据的逻辑... return new CoreModel(); } public ResponseData ProcessResponse(CoreModel coreModel) { // 处理JSON响应数据的逻辑... return new ResponseData(); } } } } namespace MyApplication.Controllers { [ApiController] [Route("api/[controller]")] public class MyController : ControllerBase { private readonly JsonMediaType.JsonMediaTypeHandler _jsonMediaTypeHandler; public MyController(JsonMediaType.JsonMediaTypeHandler jsonMediaTypeHandler) { _jsonMediaTypeHandler = jsonMediaTypeHandler; } [HttpPost] [Consumes("application/json")] [Produces("application/json")] public IActionResult Post([FromBody] JsonMediaType.RequestData requestData) { var coreModel = _jsonMediaTypeHandler.ProcessRequest(requestData); var responseData = _jsonMediaTypeHandler.ProcessResponse(coreModel); return Ok(responseData); } } }
为什么是REST?
符合REST原则的系统将具有更好的松耦合性。通常来讲,添加新资源并在已有资源中创建新资源的链接是非常简单的。要添加新的格式同样如此。另外,基于REST的系统也是非常容易理解的,因为此时系统被分为很多较小的资源块,每一个资源块都可以独立地测试和调试,并且每一个资源块都表示了一个可重用的入口点。HTTP设计本身以及URI成熟的重写与缓存机制使得RESTful HTTP 成为一种不错的架构选择,该架构具有很好的松耦合性和可伸缩性。
命令和查询职责分离——CQRS
从资源库中查询所有需要显示的数据是困难的,特别是在需要显示来自不同聚合类型与实例的数据时。领域越复杂,这种困难程度越大。
因此,我们并不期望单单使用资源库来解决这个问题。因为我们需要从不同的资源库获取聚合实例,然后再将这些实例数据组装成一个数据传输对象(Data Transfer Object,DTO)。或者,我们可以在同一个查询中使用特殊的查找方法将不同资源库的数据组合在一起。如果这些办法都不合适,我们可能需要在用户体验上做出妥协,使界面显示生硬地服从于模型的聚合边界。然而,很多人都认为,这种机械式的用户界面从长远看来是不够的。
那么,有没有一种完全不同的方法可以将领域数据映射到界面显示中呢?答案是CQRS(Command- Query Responsibility Segregation)。CQRS 是将紧缩对象设计原则和命令-查询分离(CQS)应用在架构模式中的结果。
在对象层面,这意味着:
- 如果一个方法修改了对象的状态,该方法便是一个命令(Command),它不应该返回数据。在Java和C#中,这样的方法应该声明为void。
- 如果一个方法返回了数据,该方法便是一个查询(Query),此时它不应该通过直接的或间接的手段修改对象的状态。在Java和C#中,这样的方法应该以其返回的数据类型进行声明。
这样的指导原则是非常直接明了的,同时具有实践和理论基础作为支撑。但是,在DDD的架构模式中,我们为什么应该使用CQRS呢,又如何使用呢?
在领域模型中,我们通常会看到同时包含有命令和查询的聚合。同时,我们也经常在资源库中看到不同的查找方法,这些方法对对象属性进行过滤。但是在CQRS中,我们将忽略这些看似常态的情形,我们将通过不同的方式来查询用于显示的数据。
现在,对于同一个模型,考虑将那些纯粹的查询功能从命令功能中分离出来。聚合将不再有查询方法,而只有命令方法。资源库也将变成只有add() 或 save() 方法(分别支持创建和更新操作),同时只有一个查询方法,比如fromId() 。这个唯一的查询方法将聚合的身份标识作为参数,然后返回该聚合实例。资源库不能使用其他方法来查询聚合,比如对属性进行过滤等。在将所有查询方法移除之后,我们将此时的模型称为命令模型(Command Model)。但是我们仍然需要向用户显示数据,为此我们将创建第二个模型,该模型专门用于优化查询,我们称之为查询模型(Query Model)。
这不是增加了复杂性吗?
你可能会认为:这种架构风格需要大量的额外工作,我们解决了一些问题,但同时又带来了另外的问题,并且我们需要编写更多的代码。
但无论如何,不要急于否定这种架构。在某些情况下,新增的复杂性是合理的。请记住,CQRS旨在解决数据显示复杂性问题,而不是什么绚丽的新风格。
因此,领域模型将被一分为二,命令模型和查询模型分开进行存储。最终,我们得到的组件系统如下图:
CQRS的各个方面
1、客户端和查询处理器
客户端(图最左侧)可以是Web浏览器,也可以是定制开发的桌面应用程序。它们将使用运行在服务器端的一组查询处理器。图中并没有显示服务器的架构层次。不管使用什么样的架构层,查询处理器都表示一个只知道如何向数据库执行基本查询的简单组件。
这里并不存在多么复杂的分层,查询组件最多是对数据存储进行查询,然后可能将查询结果以某种格式进行序列化。如果客户端运行的是Java或者C#,那么它可以直接对数据库进行查询。然而,这可能需要大量的数据库连接,此时使用数据库连接池则是最佳办法。
如果客户端可以处理数据库结果集(比如JDBC),此时我们可能不需要对查询结果进行序列化,但依然建议使用。这里存在两种不同的观点。一种观点是客户直接处理结果集,或者是一些非常基本的序列化数据,比如XML和JSON。另一种观点认为应该是将返回数据转换成DTO让客户端处理。这可能只是一个偏好问题,但是任何时候我们引入DTO和DTO组装器(DTO Assembler),系统的复杂性都会随之增加。
2、查询模型(读模型)
查询模型是一种非规范化数据模型,它并不反映领域行为,只是用于数据显示(也有可能是生成数据报告)。如果数据模型是SQL数据库,那么每张数据库表便是一种数据显示视图,它可以包含很多列,甚至是所显示数据的一个超集。表视图可以通过多张表进行创建,此时每张表代表整个显示数据的一个逻辑子集。
3、客户端驱动命令处理
用户界面客户端向服务器发送命令(或者间接地执行应用服务)以在聚合上执行相应地行为操作,此时的聚合即属于命令模型。提交的命令包含了行为操作的名称和所需参数。命令数据包是一个序列化的方法调用。由于命令模型拥有设计良好的契约和行为,将命令匹配到相应的契约是很直接的事情。
要达到这样的目的,用户界面客户端必须收集到足够的数据以完成命令调用。这表明我们需要慎重考虑用户体验设计,因为用户体验设计需要引导用户如何正确地提交命令,此时最好的方法是使用一种诱导式的,任务驱动式的用户界面设计,这种方法会把不必要的数据过滤掉,然后执行准确的命令调用。因此,设计出一种演绎式的,能够生成显式命令的用户界面式可能的。
4、命令处理器
客户端提交的命令将被命令处理器所接收。命令处理器可以有不同的类型风格,这里我们将分别讨论它们的优缺点。
我们可以使用分类风格,此时多个命令处理器位于同一个应用服务中。在这种风格中,我们根据命令类别来实现应用服务。每一个应用服务都拥有多个方法,每个方法处理某种类型的命令。该风格最大的优点是简单。分类风格命令处理器易于理解,创建简单,维护方便。
using System; using System.Collections.Generic; // 定义命令接口 public interface ICommand { } // 定义几个具体的命令 public class CreateUserCommand : ICommand { public string Username { get; set; } public string Password { get; set; } } public class UpdateUserCommand : ICommand { public string Username { get; set; } public string NewPassword { get; set; } } // 定义命令处理器接口 public interface ICommandHandler<T> where T : ICommand { void Handle(T command); } // 定义具体的命令处理器 public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand> { public void Handle(CreateUserCommand command) { Console.WriteLine($"Creating user: {command.Username}"); // 这里可以添加创建用户的实际逻辑 } } public class UpdateUserCommandHandler : ICommandHandler<UpdateUserCommand> { public void Handle(UpdateUserCommand command) { Console.WriteLine($"Updating user: {command.Username}"); // 这里可以添加更新用户的实际逻辑 } } // 定义命令总线,用于分发命令给相应的命令处理器 public class CommandBus { private readonly Dictionary<Type, object> _handlers = new Dictionary<Type, object>(); public void RegisterHandler<T>(ICommandHandler<T> handler) where T : ICommand { _handlers[typeof(T)] = handler; } public void Send<T>(T command) where T : ICommand { if (_handlers.ContainsKey(typeof(T))) { var handler = _handlers[typeof(T)]; ((ICommandHandler<T>)handler).Handle(command); } else { throw new InvalidOperationException($"No handler registered for command of type {typeof(T).Name}"); } } }
使用示例:
ar bus = new CommandBus(); bus.RegisterHandler(new CreateUserCommandHandler()); // 注册命令处理器到命令总线 bus.RegisterHandler(new UpdateUserCommandHandler()); // 注册命令处理器到命令总线 var createCommand = new CreateUserCommand { Username = "Alice", Password = "password123" }; bus.Send(createCommand); // 通过命令总线发送命令 var updateCommand = new UpdateUserCommand { Username = "Alice", NewPassword = "newpassword123" }; bus.Send(updateCommand); // 通过命令总线发送命令
在这个示例中,我们定义了一个`ICommand`接口,以及两个实现该接口的具体命令:`CreateUserCommand`和`UpdateUserCommand`。然后,我们定义了一个`ICommandHandler<T>`接口,以及两个实现该接口的具体命令处理器:`CreateUserCommandHandler`和`UpdateUserCommandHandler`。每个命令处理器都负责处理特定类型的命令。最后,我们定义了一个`CommandBus`类,它负责维护一个命令处理器字典,并根据命令的类型分发命令给相应的处理器。
我们也可以使用专属风格,此时每种命令都对应于某个单独的类,并且该类只有一个方法。这种风格的优点是:每个处理器的职责是单一的,命令处理器之间相互独立,我们可以通过增加处理器类来处理更多的命令。
专属风格可能发展成为消息风格,其中每个命令将通过异步的消息发送到某个命令处理器。消息风格使得每个命令处理器可以处理某种特殊的消息类型,同时我们可以通过单独增加单种处理器的数量来缓解消息负载。但是,消息风格并不能作为默认的命令处理方式,因为它的设计比其他两种都复杂。因此,我们应该首先考虑使用前两种同步方式的命令处理器,只有在有伸缩性需要的情况下才采用异步方式。
using System; // 定义订单命令 public class CreateOrderCommand { public string CustomerId { get; set; } public string ProductId { get; set; } public int Quantity { get; set; } } // 定义订单领域模型 public class Order { public string Id { get; set; } public string CustomerId { get; set; } public string ProductId { get; set; } public int Quantity { get; set; } public decimal TotalPrice { get; set; } } // 定义订单命令处理器 public class OrderCommandHandler { private readonly IOrderRepository _orderRepository; // 注入订单仓库接口 private readonly IProductRepository _productRepository; // 注入产品仓库接口 public OrderCommandHandler(IOrderRepository orderRepository, IProductRepository productRepository) { _orderRepository = orderRepository; _productRepository = productRepository; } public void Handle(CreateOrderCommand command) { // 验证命令数据,例如检查顾客和产品是否存在,数量是否有效等 if (string.IsNullOrEmpty(command.CustomerId)) throw new ArgumentException("Customer ID is required."); if (string.IsNullOrEmpty(command.ProductId)) throw new ArgumentException("Product ID is required."); if (command.Quantity <= 0) throw new ArgumentException("Quantity must be greater than zero."); // 从产品仓库获取产品价格信息 var product = _productRepository.GetById(command.ProductId); if (product == null) throw new InvalidOperationException($"Product with ID '{command.ProductId}' not found."); var unitPrice = product.Price; // 计算订单总价格 var totalPrice = unitPrice * command.Quantity; // 创建订单对象并设置相关属性 var order = new Order { Id = Guid.NewGuid().ToString(), // 生成唯一订单ID CustomerId = command.CustomerId, ProductId = command.ProductId, Quantity = command.Quantity, TotalPrice = totalPrice }; // 将订单保存到仓库中 _orderRepository.Save(order); } }
使用示例:
var command = new CreateOrderCommand { CustomerId = "123", ProductId = "456", Quantity = 2 }; var orderCommandHandler = new OrderCommandHandler(orderRepository, productRepository); orderCommandHandler.Handle(command); Console.WriteLine("Order created successfully.");
在这个示例中,我们创建了一个名为`CreateOrderCommand`的命令,该命令包含创建订单所需的所有信息。然后,我们创建了一个名为`OrderCommandHandler`的类,该类负责处理`CreateOrderCommand`命令。在`Handle`方法中,我们进行了命令数据验证、产品价格获取、订单总价计算和订单保存等操作。这个命令处理器是专门为处理订单创建命令而设计的,并紧密集成了相关的领域模型(如`Order`和`Product`)。这确保了业务规则的准确实施,并提供了更好的代码组织和可读性。
我们使用分类风格实现一下这个电子商务案例:
// 定义命令接口 public interface ICommand { } // 定义创建订单命令类 public class CreateOrderCommand : ICommand { public string CustomerId { get; set; } public string ProductId { get; set; } public int Quantity { get; set; } } ``` 接下来,我们定义一个订单领域模型类,并实现一个用于处理创建订单命令的方法: ```csharp // 定义订单领域模型类 public class Order { public string Id { get; set; } public string CustomerId { get; set; } public string ProductId { get; set; } public int Quantity { get; set; } public decimal TotalPrice { get; set; } // 实现处理创建订单命令的方法 public void HandleCreateOrder(CreateOrderCommand command) { // 在这里实现订单创建的业务逻辑和验证 // ... } } ``` 然后,我们定义一个命令处理器类,该类负责将命令分派给相应的领域模型进行处理: ```csharp // 定义命令处理器类 public class CommandHandler { private readonly IDictionary<Type, Action<ICommand>> _handlers; public CommandHandler() { _handlers = new Dictionary<Type, Action<ICommand>>(); } // 注册领域模型的处理程序 public void RegisterHandler<T>(Action<T> handler) where T : ICommand { _handlers[typeof(T)] = cmd => handler((T)cmd); } // 处理命令 public void Handle(ICommand command) { if (_handlers.ContainsKey(command.GetType())) { _handlers[command.GetType()].Invoke(command); } else { throw new InvalidOperationException($"No handler registered for command of type {command.GetType().Name}"); } } }
使用示例:
var order = new Order(); var command = new CreateOrderCommand { CustomerId = "123", ProductId = "456", Quantity = 2 }; order.HandleCreateOrder(command); // 直接在领域模型上处理命令 var commandHandler = new CommandHandler(); commandHandler.RegisterHandler<CreateOrderCommand>(cmd => order.HandleCreateOrder(cmd)); // 注册领域模型的处理程序 commandHandler.Handle(command); // 通过命令处理器处理命令 Console.WriteLine("Order created successfully.");
在这个示例中,我们创建了一个名为`CommandHandler`的类,该类负责将命令分派给相应的领域模型进行处理。我们通过`RegisterHandler`方法将领域模型(即`Order`类)的处理程序注册到命令处理器中。当接收到一个命令时,命令处理器使用反射来确定要调用的处理程序,并将命令分派给相应的处理程序进行处理。在领域模型类(即`Order`类)中,我们实现了一个名为`HandleCreateOrder`的方法,该方法包含订单创建的业务逻辑和验证。当接收到一个`CreateOrderCommand`命令时,命令处理器将调用该方法来处理该命令。请注意,这种方法将业务逻辑和验证直接实现在领域模型中,而不是在单独的命令处理器中。这与通用风格和专属风格的实现方式有所不同。这种分类风格的实现方式强调了领域模型的责任和自治性,使其能够更好地封装和管理自己的业务逻辑和状态。然而,它可能需要更多的代码和复杂性来支持命令的分派和处理机制。
分类风格和专属风格两种风格的本质区别在于命令处理器与领域模型之间的关系以及业务逻辑的实施方式。
在通用风格中,命令处理器被设计为处理多种不同类型的命令,并且通常使用一种通用的方式来处理业务逻辑。这种风格强调命令处理器的通用性和灵活性,以便能够适应不同的领域模型和业务需求。通用风格的命令处理器通常会包含一些公共的逻辑和验证,以确保命令的有效性和一致性。然而,由于通用风格的命令处理器需要处理多种不同类型的命令,因此可能会在处理某些特定领域的命令时缺乏针对性和精确性。
相比之下,在专属风格中,每个命令处理器都是为特定领域模型和业务逻辑量身定制的。这种风格强调命令处理器与领域模型之间的紧密关系,以确保业务规则的准确实施。专属风格的命令处理器通常会包含与该命令相关的所有业务逻辑和验证,以确保在处理该领域的命令时具有高度的针对性和精确性。由于专属风格的命令处理器是针对特定领域的,因此可以更容易地添加新的业务规则和逻辑,而无需对整个应用程序进行大规模的修改。
因此,两种风格的本质区别在于命令处理器的设计目标和处理业务逻辑的方式。通用风格强调通用性和灵活性,而专属风格强调针对性和精确性。选择哪种风格取决于应用程序的具体需求和架构师的偏好。
无论采用哪种风格的命令处理器,我们都应该在不同的处理器间进行解耦,不能使一个处理器依赖于另一个处理器。这样,对一种处理器的重新部署不会影响到其他处理器。
5、命令模型(写模型)执行业务行为
命令模型上每个方法在执行完成时都将发布领域事件。
6、事件订阅更新查询模型
一个特殊的事件订阅器用于接收命令模型所发出的所有领域事件。有了领域事件,订阅器会根据命令模型的更改来更新查询模型。这意味着,每种领域事件都应该包含足够的数据以正确地更新查询模型。
对查询模型的更新应该是同步还是异步?这取决于系统的负荷,也有可能取决于查询模型数据库的存储位置。数据的一致性约束和性能需求等因素对此也有很大的影响作用。
7、处理具有最终一致性的查询模型
如果查询模型需要满足最终一致性——即在命令模型更新之后,查询模型会得到相应的异步更新——那么用户界面可能有些额外的问题需要处理。比如,当上一个用户提交命令之后,下一个用户是否能够及时地查看到更新后的查询模型数据?这可能与系统负荷等因数有关。但是,我们最好还是假定:在用户界面所查看到的数据永远都不能与命令模型保持一致。因此,我们需要为最坏的情况考虑。
一种方式是让用户界面临时性的显示先前提交给命令模型的参数,这使得用户可以及时地看到将来对查询模型的改变。
但是,对于某些用户界面,以上方式可能并不现实。而即便是现实的,同样有可能发生在用户界面中显示陈旧数据的情况,比如在一个用户进行操作的刹那,另一个用户却正试图查看数据。那么,我们应该如何应对?
另一种方法是显式地在用户界面上显示出当前查询模型的日期和时间。要达到这样的目的,查询模型的每一条记录都需要维护最后更新时的日期和时间。这是很容易的,通常可以借助于数据库触发器。有了最近更新的日期和时间,用户界面便可以通知用户数据的新旧程度。如果用户认为数据过于陈旧,他们可以发出更新数据的请求。
然而,有时命令模型和查询模型之间的不同步并不是什么大的问题。我们也可以通过其他方式予以客服,比如Comet(即Ajax Push);或者通过另一种静默更新的方式,比如观察者或者分布式缓存/网络的事件订阅。
事件驱动架构
事件驱动架构一种用于处理事件的生成、发现和处理等任务的软件架构。
一个系统的输出端口所发出的领域事件将被发送到另一个系统的输入端口,此后输入端口的事件订阅方将对事件进行处理。对于不同的限界上下文来说,不同的领域事件具有不同含义,也有可能没有任何含义。在一个限界上下文处理某个事件时,应用程序API将采用该事件中的属性值来执行相应的操作。应用程序API所执行的命令操作将反映到命令模型中。
有可能出现这样一种情况:在一个多任务处理过程中,某种领域事件只能表示该过程的一部分。只有在所有的参与事件都得到处理之后,我们才能认为这个多任务处理过程完成了。但是,这个过程是如何开始的?它是如何分布在整个企业范围之内的?我们如何跟踪处理进度?这些问题将在“长时处理过程”部分回答。
下面时一些基础知识,基于消息的系统呈现出一种管道和过滤器风格。
管道和过滤器
长时处理过程(也叫Saga)
长时处理过程(Long- Running Process)是一种事件驱动的、分布式的并行处理模式。
设计长时处理过程的不同方法:
- 将处理过程设计成一个组合任务,使用一个执行组件对任务进行跟踪,并对各个步骤和任务完成情况进行持久化。
- 将处理过程设计成一组聚合,这些聚合在一系列的活动中相互协作。一个或多个聚合实例充当执行组件并维护整个过程的状态。
- 设计一个无状态的处理过程,其中每一个消息处理组件都将对所接收到的消息进行扩充——即向其中加入额外的数据信息——然后再将消息发送到下一个处理组件。在这种方法中,整个处理过程的状态包含在每条消息中。
执行器和跟踪器?
有人认为执行器和跟踪器这两种概念合并成一个对象——聚合——是最简单的方法。此时,我们在领域模型中实现这样一个聚合,再通过该聚合来跟踪长时处理过程的状态。这是一种解放性的技术,我们不需要开发一个单独的跟踪器来作为状态机,而事实上这也是实现基本长时处理过程的最好方法。
在六边形架构中,端口—适配器的消息处理组件将简单地将任务分发给应用服务(或命令处理器),之后应用服务加载目标聚合,再调用聚合上的方法。同样,聚合也会发出领域事件,该事件表明聚合已经完成了。
这种方式属于上面提到的第二种方法。然而,将执行器和跟踪器分开讨论时一种更有效的方法。
在实际的领域中,一个长时处理过程的执行器将创建一个新的类似聚合的状态对象来跟踪事件的完成情况。该状态对象在处理过程开始时创建,它将与所有的领域事件共享一个唯一标识。同时,将处理过程开始时的事件戳保存在该状态对象中也是有好处的。
当并行处理的每个执行流运行完毕时,执行器都会接收到相应的完成事件。然后,执行器根据事件中的过程标识获取到与该过程相对应的状态跟踪对象实例,再在这个对象实例中修改该执行流所对应的属性值。
长时处理过程的状态实例通常有一个名为isCompleted() 的方法。每当某个执行流执行完成,其对应的状态属性也将随之更新,随后执行器将调用isCompleted() 方法。该方法检查所有的并行执行流是否全部执行完毕。当isCompleted()返回true时,执行器将根据业务需要发布最终的领域事件。如果该长时处理过程是更大的并行处理过程的一个分支,那么向外发布该事件便是非常有必要的了。
有些消息机制可能并不能保证消息的单次投递。对于一个领域事件有可能被多次投递的情况,我们可以通过长时处理过程的状态实例来消除重复。那么,这是否需要消息机制提供额外的特殊功能呢?让我们看看在没有这些特殊功能的时候应该被如何处理。
当一个完成事件到达时,执行器将检查该事件中相应的状态属性,该状态属性表示该事件是否已经存在。如果状态已经被设值,那么该事件便是一个重复事件,执行器将忽略事件,但是还是会对该事件做出应答。另一种方式将状态对象设计成幂等的。这样,如果执行器接收到了重复消息,它将同等对待,即执行器依然会使用该消息来更新处理过程的状态,但是此时的更新不会产生任何效果。在以上两种方法中,虽然只有第二种方法将状态对象本身设计成幂等的,但是在结果上他们都能达到消息传输的幂等性。关于事件消重的更多信息,在后面领域事件部分会讲到。
对于跟踪有些长时处理过程来说,我们需要考虑时间敏感性。在过程处理超时,我们既可以采用被动的,也可以采用主动。回忆一下,状态跟踪器可以包含处理过程开始时的时间戳。如果再向跟踪器添加一个最大允许处理时间,那么执行器便可以管理那些对时间敏感的长时处理过程了。
被动超时检查由执行器在每次并行执行流的完成事件达到时执行。执行器根据状态跟踪器来决定是否出现超时,比如调用名为hasTimedOut() 的方法。如果执行流的处理时间超过了最大允许处理时间,状态跟踪器将被标记为“遗弃”状态。此时,执行器甚至可以发布一个表明处理失败的领域事件。被动超时检查的一个缺点是,如果由于某些原因导致执行器始终接收不到完成领域事件,那么即便处理过程已经超时,执行器还是会认为处理过程正处于活跃状态。如果还有更大的并发过程依赖于该处理过程,那么这将是不可接受的。
主动超时检查可以通过一个外部定时器来进行管理。在处理过程开始时,定时器便被设以最大允许处理时间。定时时间到,定时监听器将访问状态跟踪器的状态。如果此时的状态显示处理还未完毕,那么处理状态将被标记为“遗弃”状态。主动超时检查的一个缺点是,它需要更多的系统资源,这可能加重系统的运行负担。同时,定时器和完成事件之间的竟态条件有可能会造成系统失败。
长时处理过程通常和分布式并行处理联系在一起,但是它与分布式事务没有什么关系。长时处理过程需要的是最终一致性。我们应该慎重地设计长时处理过程,在基础设施或处理过程本身失败的时候,我们应该能够采取适当的修复措施。只有在执行器接收到整个处理过程成功的通知时,我们才能认为处理过程的各个参与方达到了最终一致性。诚然,对于有些长时处理过程来说,整个处理过程的成功并不需要所有的并行执行流都成功。还有可能出现的情况是,一个处理过程在成功完成之前可能会延迟好几天的时间。但是,如果一个处理过程被搁浅,那么所有的参与系统都将处于一种不一致的状态,此时做出一些补偿是必要的。但是,补偿可能增加处理过程的复杂性。还有可能是,业务需求是允许失败情况发生的,此时采用工作流方案可能更加合适。
值得注意的是,长时处理过程的执行器可以发布一个或者多个事件来触发并行处理流程。同时,事件的订阅方也不见得只能是两个,而是可以有多个。换句话说,在一个长时处理过程中,可能存在许多彼此分离的业务处理过程同时运行。
当与遗留系统的集成存在很大的时间延迟时,采用长时处理过程将非常有用。当然,即便时间延迟和遗留系统并不是我们的主要关注点,我们依然能从长时处理过程中得到好处,即由分布式和并行处理带来的优雅性,这样也有助于我们开发高可伸缩性、高可用性的业务系统。
有些消息机制中已经构建了对长时处理过程的支持,[NServiceBus]和[MassTransit]。