自学内容网 自学内容网

实现领域驱动设计(DDD)系列详解:集成限界上下文

一个项目中通常存在着多个限界上下文,并且我们需要在它们之间进行集成。

上下文映射图中存在两种主要形式:一种是通过绘制一些简单的框图来展示它们之间的集成关系;另一种则是通过代码来实现这些集成关系。

到了具体的技术实现,需要确定限界上下文的物理边界,因为它会直接影响架构的设计与实现。

限界上下文的物理边界,实际指的是通信边界,以进程为单位分为进程内与进程间两种。

一、限界上下文与微服务

(一)进程内的通信边界

若限界上下文之间为进程内的通信方式,意味着它们的代码模型运行在同一个进程中,通过对象实例化的方式即可调用另一个限界上下文内部的对象。

限界上下文的代码模型存在两种级别的设计方式。

以Java为例,归纳如下。

  • 命名空间级别:通过命名空间进行界定,所有的限界上下文位于同一个工程模块(module),编译后生成一个JAR包。
  • 工程模块级别:在命名空间上是逻辑分离的,不同限界上下文属于同一个项目的不同模块,编译后生成各自的JAR包。

两种级别的代码模型仅仅存在编译期的差异,后者的解耦更加彻底,可以更好地应对变化对限界上下文的影响。例如,当限界上下文A的业务场景发生变更时,我们可以只修改和重编译限界上下文A对应的JAR包,其余JAR包并不受到影响。

到了运行期,这两种方式就没有任何区别了,因为它们都运行在同一个Java虚拟机(Java Virtual Machine,JVM)中,当变化发生时,整个系统都需要重新启动和运行。如果所有限界上下文都运行在一个进程,当前架构就属于单体架构(monolithicarchitecture)。

单体架构不一定是大泥球,也未必是糟糕设计的代名词,只要遵循限界上下文的边界(如严格遵循菱形对称架构)来定义代码模型,就能确保清晰的代码结构。

由于限界上下文之间采用进程内通信,跨限界上下文之间的协作可通过下游的客户端端口直接调用上游的应用服务,无须跨进程通信,使得协作变得更加容易,也更加高效。

我们必须警惕复用带来的耦合。编写代码时,需要谨守限界上下文的边界,时刻注意不要越界,并确定限界上下文各自对外公开的接口,避免它们之间产生过多的依赖。

限界上下文之间的复用是业务能力的复用,体现为设计,就是对北向网关远程服务或应用服务的复用。

一旦需要将限界上下文调整为进程间的通信边界,这种重视边界控制的设计与实现能够更好地适应这种演进。

譬如在项目管理系统中,项目上下文与通知上下文之间的通信为进程内通信,当项目负责人将Sprint Backlog成功分配给团队成员之后,系统发送邮件通知该团队成员。

分配职责由项目上下文的SprintBacklogService领域服务承担,发送通知的职责由通知上下文的NotificationAppService应用服务承担。

考虑到未来限界上下文通信边界的变化,我们就不能在SprintBacklogService服务中直接实例化NotificationAppService对象,而是在项目上下文中定义南向网关的客户端端口NotificationClient,并由NotificationClientAdapter实现。

SprintBacklogService服务依赖客户端端口,然后依赖注入适配器实现。协作过程如图所示。

在这里插入图片描述

一旦在未来需要将通知上下文演进为进程间的通信边界,该变动只会影响项目上下文的南向网关适配器,不影响其余内容。

即使将消息通知修改为事件通知的机制,需要调整的内容也仅仅是对端口的调用代码。

在限界上下文边界控制下的单体架构具有清晰的结构,各个限界上下文也遵循了自治原则。

它面临的主要问题是无法对指定的限界上下文进行水平伸缩,也无法对指定限界上下文进行独立替换与升级。

(二)进程间的通信边界

如果限界上下文的边界就是进程的边界,限界上下文之间的协作就必须采用分布式的通信方式。

在物理上,限界上下文的代码模型与数据库是完全分开的,考虑协作时,因为数据库共享方式的不同,产生两种不同的风格:

  • 数据库共享架构;
  • 零共享架构。

1.数据库共享架构

数据库共享架构是一种折中的手段。划分限界上下文时,可能出现一种状况:代码的运行是进程分离的,数据库却共享彼此的数据,即多个限界上下文共享同一个数据库。

共享数据库可以更加便利地保证数据的一致性,这或许是该方案最有说服力的证据,但也可以视为对一致性约束的妥协。

不管在物理上是否共享数据库,限界上下文之间的逻辑边界仍然需要守护,不能让一个限界上下文越界访问另一个限界上下文的数据库。

在针对某手机品牌开发的舆情分析系统中,危机查询服务提供对识别出来的危机进行查询。

查询时,需要通过userId获得危机处理人、危机汇报人的详细信息。图所示的设计就破坏了危机分析上下文的逻辑边界,绕开了用户上下文,直接访问了用户数据表。
在这里插入图片描述

要注意,即便用户数据表和危机数据表位于同一个数据库,按照限界上下文的要求,它们之间其实也存在一条无形的边界,需要遵循跨限界上下文调用的“纪律”​,即形成对业务能力的复用,如图所示。

在这里插入图片描述
考虑到未来可能的演进,无论是单体架构,还是微服务的数据库共享风格,都需要一开始就注意避免在分属两个限界上下文的表之间建立外键约束关系。

某些关系型数据库可能通过这种约束关系提供级联更新与删除的功能,这种功能反过来会影响代码的实现。一旦因为分库而去掉表之间的外键约束关系,需要修改的代码太多,就会导致演进的成本太高,甚至可能因为某种疏漏带来隐藏的bug。

数据库共享架构可能传递“反模式”的信号。当两个分处不同限界上下文的服务需要操作同一张数据表(这张表被称为“共享表”​)时,意味着设计可能出现了错误。

  • 遗漏了一个限界上下文,共享表对应的是一个被复用的服务:买家在查询商品时,商品服务会查询价格表中的当前价格,而在提交订单时,订单服务也会查询价格表中的价格,计算当前的订单总额。共享价格数据的原因是我们遗漏了价格上下文,引入价格服务就可以解除这种不必要的数据共享。
  • 职责分配出现了问题,操作共享表的职责应该分配给已有的服务:舆情服务与危机服务都需要从邮件模板表中获取模板数据,然后调用邮件服务组合模板的内容发送邮件。实际上,从邮件模板表获取模板数据的职责应该分配给已有的邮件服务。
  • 共享表对应两个限界上下文的不同概念:仓储上下文与订单上下文都需要访问共享的产品表,但实际上这两个限界上下文需要的产品信息并不相同,应该按照领域模型的知识语境分开为各自关心的产品建立数据表。

为什么会出现这3种错误的设计?一个可能的原因在于我们没有遵循领域建模的要求,而直接对数据库进行了设计,代码没有体现正确的领域模型,导致了数据库的耦合或共享。

2.零共享架构

如果限界上下文之间没有共享任何外部资源,整个架构就成为零共享架构

如前面介绍的舆情分析系统,在去掉危机查询对用户表的依赖后,同时将用户数据与危机数据分库存储,就演进为零共享架构。

如图所示,危机分析上下文的危机数据存储在Elasticsearch中,用户上下文的用户数据存储在MySQL中,实现了资源的完全分离。

在这里插入图片描述

这是一种限界上下文彻底独立的架构风格,保证了边界内的服务、基础设施乃至于存储资源、中间件等其他外部资源的独立性,形成自治的微服务,体现了微服务架构的特征:每个限界上下文都有自己的代码库、数据存储和开发团队,每个限界上下文选择的技术栈和语言平台也可以不同,限界上下文之间仅仅通过限定的通信协议进行通信。

独立运行的限界上下文实现了真正的自治,不仅每个限界上下文的内部代码能够做到独立演化,在技术选型上也可以结合自身的业务场景做出“恰如其分”的选择。

譬如,危机分析上下文需要存储大规模的非结构化数据,业务上需要支持对危机数据的高性能全文本搜索,故而选择了Elasticsearch作为持久化的数据库。考虑到开发的高效以及对JSON数据的支持,团队选择了Node.js作为后端开发框架。

对于用户上下文,数据量小,结构规范,采用MySQL关系数据库的架构会更简单,并使用Java作为后台开发语言。二者之间唯一的耦合就是危机分析通过HTTP访问上游的用户服务,根据传入的userId获得用户的详细信息。彻底分离的限界上下文变得小而专,使得我们可以很好地安排遵循2PTs规则的领域特性团队去治理它。

然而,这种架构的复杂度也不可低估。限界上下文之间采用进程间通信,必然影响通信的效率与可靠性。

数据库是完全分离的,一旦一个服务需要关联跨库之间的数据,就需要跨限界上下文去访问,无法享受数据库自身提供的关联福利。

每个限界上下文都是分布式的,如何保证数据的一致性也是一件棘手的问题。当整个系统都被分解成一个个可以独立部署的限界上下文时,运维与监控的复杂度也随之而剧增。

(三)限界上下文与微服务的关系

在架构映射阶段,并未明确限界上下文的物理边界究竟是在进程内,还是在进程间。

物理边界的确认并非业务角度的考虑,更多是从质量属性的角度依据分布式通信的优劣势而定。

虽说在设计微服务架构时,领域驱动设计的限界上下文可以帮助团队更好地明确微服务的边界,但这却不能说明它们之间一定存在一对一的映射关系。

在确定限界上下文与微服务之间的关系时,需要考虑团队与代码的边界对它们的影响,包括团队边界和代码模型边界。

1.团队边界

遵循康威定律,需要控制交流成本,不能出现一个限界上下文由两个或多个团队共同承担的情况。微服务也当如此。

如果不同微服务选择了不同的技术栈,团队的边界更需要与微服务对应。

如此看来,微服务的粒度要细于或等于限界上下文的粒度,然而技术栈对微服务边界的影响,也可认为是技术维度对限界上下文边界的影响。

由于技术栈选择,根据业务能力切分的限界上下文,可进一步切分其边界,此时,微服务的边界等同于限界上下文的边界

2.代码模型边界

一个微服务的代码模型不能分别部署在两个不同的进程,如果分别部署了,则应被视为不同的微服务,限界上下文却未必如此。

倘若一个限界上下文采用了CQRS模式,针对相同的业务,查询模型与命令模型可以部署到不同的进程,可以认为是不同的微服务,但它们在逻辑上仍然属于同一个限界上下文。

如此看来,微服务的粒度要细于或等于限界上下文的粒度

3.总结

不管是否采用了限界上下文帮助团队识别微服务,对边界的确定总是无法做到一劳永逸。

作为微服务的布道者之一,Martin Fowler就认为设计者无法一开始就确定稳定的微服务边界。

一旦系统被设计为微服务,而微服务的边界又不合理,对它的重构难度就要远远大于单体架构。

Fowler建议应该单体架构优先,通过该架构风格逐步探索系统的复杂度,确定限界上下文构成组件的边界,待系统复杂度增加,证明了微服务的必要性时,再考虑将这些限界上下文设计为独立的微服务。

一种审慎的做法是在无法明确微服务边界的合理性时,考虑将微服务的粒度设计得更粗一些,而在服务内部,通过限界上下文的边界对代码模型进行控制。

微服务内部存在的多个限界上下文自然采用进程内通信,如此可降低微服务的管理成本,也避免了不必要的分布式通信成本。

与数据库共享风格相似,这可以算是一种折中的服务设计模式。整个软件系统仍然由多个微服务组成,但每个微服务的粒度并不均衡,内部的限界上下文边界却又保留了继续拆分的可能性,增强了架构的演进能力。

这可认为是混合了单体架构与微服务架构的混合架构风格,如图所示。
在这里插入图片描述

图所示的架构充分体现了菱形对称架构北向网关的价值。它的远程服务与应用服务分别适应不同的业务场景,松耦合的结构使得整个架构能够较好地响应变化,遵循了演进式设计的要求。

在这样一种糅合单体架构与微服务架构的混合架构风格中,微服务的粒度又粗于限界上下文的粒度了。

因此,我们很难为限界上下文和微服务确定一个稳定的映射关系,这正是软件设计棘手之处,却也是它的魅力所在。

二、 限界上下文之间的分布式通信

当一个软件系统发展为微服务架构风格的分布式系统时,限界上下文之间的协作将采用进程间的分布式通信方式。菱形对称架构通过网关层减少了通信方式的变化对协作机制带来的影响。

然而,若全然无视这种变化,就无疑是掩耳盗铃了。

无论采用何种编程模式与框架来封装分布式通信,都只能做到让进程间的通信方式尽量透明,却不可抹去分布式通信固有的不可靠性、传输延迟性等诸多问题,选择的输入/输出(Input/Output,I/O)模型也会影响到计算机资源特别是CPU、进程和线程资源的使用,从而影响服务端的响应能力。

分布式通信传输的数据也有别于进程内通信。选择不同的序列化框架、不同的通信机制,对远程服务接口的定义也提出了不同的要求。

一、分布式通信的设计因素

一旦决定采用分布式通信,就需要考虑如下3个因素。

  • 通信协议:用于数据或对象的传输。
  • 数据协议:为满足不同节点之间的统一通信,需确定统一的数据协议。
  • 接口定义:接口要满足一致性与稳定性,它的定义受到通信框架的影响。

1.通信协议

为保障分布式通信的可靠性,网络通信的传输层需要采用TCP,以可靠地把数据在不同的地址空间上搬运。

在传输层之上的应用层,往往选择HTTP,如REST架构风格的框架,或者采用二进制协议的HTTP/2,如Google的RPC框架gRPC。

可靠传输还要建立在网络传输的低延迟基础上。如果服务端无法在更短时间内处理完请求,或者处理并发请求的能力较弱,服务器资源就会被阻塞,影响数据的传输。

数据传输的能力取决于操作系统的I/O模型,分布式节点之间的数据传输本质就是两个操作系统之间通过socket实现的数据输入与输出。

传统的I/O模式属于阻塞I/O,与线程池的线程模型相结合。

由于一个系统内部可使用的线程数量是有限的,一旦线程池没有可用线程资源,当工作线程都阻塞在I/O上时,服务器响应客户端通信请求的能力就会下降,导致通信的阻塞。因此,分布式通信一般会采用I/O多路复用或异步I/O,如Netty就采用了I/O多路复用的模型。

2.数据协议

客户端与服务端的通信受到进程的限制,必须对通信的数据进行序列化和反序列化,实现对象与数据的转换。这就要求跨越进程传递的消息契约对象必须能够支持序列化。

选择序列化框架需要关注以下内容。

  • 编码格式:采用二进制还是字符串等可读的编码。
  • 契约声明:基于接口定义语言(Interface Definition Language,IDL)如ProtocolBuffers/Thrift,还是自描述如JSON、XML。
  • 语言平台的中立性:如Java的Native Serialization只能用于JVM平台,ProtocolBuffers可以跨各种语言和平台。
  • 契约的兼容性:契约增加一个字段,旧版本的契约是否还可以反序列化成功。
  • 与压缩算法的契合度:为了提高性能或支持大量数据的跨进程传输,需要结合各种压缩算法,例如 gzip、snappy。
  • 性能:序列化和反序列化的时间,序列化后数据的字节大小,都会影响到序列化 的性能。

常见的序列化协议包括Protocol Buffers、Avro、Thrift、XML、JSON、Kyro、Hessian等。

序列化协议需要与不同的通信框架结合,例如REST框架选择的序列化协议通常为文本型的XML或JSON,使用HTTP/2协议的gRPC自然与ProtocolBuffers结合。

Dubbo可以选择多种组合形式,例如HTTP+JSON序列化、Netty+Dubbo序列化、Netty+Hession2序列化等。

如果选择异步RPC的消息传递方式,只需发布者与订阅者遵循相同的序列化协议即可。

若业务存在特殊性,甚至可以定义自己的事件消息协议规范。

3.接口定义

采用不同的分布式通信机制,对接口定义的要求也不相同,例如基于XML的WebService与REST风格服务就采用了不同的接口定义。

RPC框架对接口的约束要少一些,它是一种远程过程调用(Remote Procedure Call)协议,目的是封装底层的通信细节,使得开发人员能够以近乎本地通信的编程模式来实现分布式通信。

从本质上讲,REST风格服务实则也是一种RPC,只是REST架构风格对REST风格服务的接口定义给出了设计约束。

至于消息传递机制要求的接口,由于它引入消息队列(或消息代理)解除了发布者与订阅者之间的耦合,因此二者之间的接口是通过事件消息来定义的。

虽然不同的分布式通信机制对接口定义的要求不同,但设计原则却是相同的,即在保证服务的质量属性基础上,尽量解除客户端与服务端之间的耦合,同时保证接口版本升级的兼容性。

(二)分布式通信机制

虽然有多种不同的分布式通信机制,但在微服务架构风格下,主要采用的分布式通信机制包括:

  • REST;
  • RPC;
  • 消息传递。

它们也正好对应服务契约设计中定义的服务资源契约、服务行为契约和服务事件契约。我选择了Java社区最常用的Spring Boot+Spring Cloud、Dubbo和Kafka作为这3种通信机制的代表,分别讨论它们对领域驱动设计带来的影响。

1.REST

遵循REST架构风格的服务即REST风格服务,通常采用HTTP+JSON序列化实现数据的进程间传输。

服务的接口往往是无状态的,要求通过统一的接口来对资源执行各种操作。

正因如此,远程服务的接口定义实则可以分为两个层面。

其一是远程服务类的方法定义,除了方法的参数与返回值必须支持序列化,REST框架对方法的定义几乎没有任何限制。

其二是REST风格服务的接口定义,在Spring Boot中就是使用@RequestMapping注解指定URI以及HTTP动词。

客户端在调用REST风格服务时,需要指定URI、HTTP动词以及请求/响应消息。

通过请求直接传递的参数映射为@RequestParam,通过URI模板传递的参数映射为@PathVariable

遵循REST风格服务定义规范,一般建议参数通过URI模板传递,例如orderld参数:

GET /orders/{orderId}

对应的REST风格服务定义为:

package com.dddexplained.ecommerce.ordercontext.northbound.remote.resource;@RestController
@RequestMapping(value="/orders")
public class OrderResource {
   @RequestMapping(value="/{orderId}", method=RequestMethod.GET)
   public OrderResponse orderOf(@PathVariable String orderId) {   }
}

采用以上方式定义服务接口时,参数往往定义为语言基本类型的集合。

若要传递自定义的请求对象,就要使用@RequestBody注解,HTTP动词需要使用POST、PUT或DELETE。

通过REST风格服务传递的消息契约对象需要支持序列化。实现时,取决于服务设置的Content-Type类型确定为哪一种序列化协议。多数REST风格服务会选择简单的JSON协议。

下游限界上下文若要调用上游的REST风格服务,需通过REST风格客户端发起跨进程调用。在Spring Boot中,可通过RestTemplate发起对远程服务的调用:

package com.dddexplained.ecommerce.ordercontext.southbound.adapter.client;public class InventoryClientAdapter implements InventoryClient {
   // 使用REST客户端
   private RestTemplate restTemplate;public boolean isAvailable(Order order) {
      // 自定义请求消息对象
      CheckingInventoryRequest request = new CheckingInventoryRequest();
      for (OrderItem orderItem : order.items()) {
         request.add(orderItem.productId(), orderItem.quantity());
      }
      // 自定义响应消息对象
      InventoryResponse response = restTemplate.postForObject("http://inventory-service/
inventories/order", request, InventoryResponse.class);
      return response.hasError() ? false : true;
   }
}

订单上下文作为下游,调用了库存上下文的远程REST风格服务:

package com.dddexplained.ecommerce.inventorycontext.northbound.remote.resource;@RestController
@RequestMapping(value="/inventories")
public class InventoryResource {
   @RequestMapping(value="/order", method=RequestMethod.POST)
   public InventoryResponse checkInventory(@RequestBody CheckingInventoryRequest inventoryRequest) {}
}

由于采用了分布式通信,位于订单上下文南向网关的适配器实现并不能复用库存上下文的消息契约对象CheckingInventoryRequestInventoryResponse,需要在当前上下文的南向网关中自行定义。

调用远程REST风格服务的客户端适配器也可以使用Spring Cloud Feign进行简化。在订单上下文,只需要给客户端端口标记@FeignClient等注解即可,如:

package com.dddexplained.ecommerce.ordercontext.southbound.port.client;

@FeignClient("inventory-service")
public interface InventoryClient {
   @RequestMapping(value = "/inventories/order", method = RequestMethod.POST)
   InventoryResponse isAvailable(@RequestBody CheckingInventoryRequest inventoryRequest);
}

这意味着客户端适配器的实现交给了Feign框架,省了不少开发工作。

不过,@FeignClient注解也为客户端端口引入了对Feign框架的依赖,从整洁架构思想来看,难免显得美中不足。

不仅如此,Feign接口除了不强制规定方法名,接口方法的输入参数与返回值必须与上游远程服务的接口方法保持一致。一旦上游远程服务的接口定义发生了变更,就会影响到下游客户端,这实际上削弱了南向网关引入端口的价值。

2.RPC

RPC是一种技术思想,即为远程调用提供一种类本地化的编程模式,封装网络通信和寻址,实现一种位置上的透明性。

因此,RPC并不限于传输层的网络协议,但为了数据传输的可靠性,通常采用的还是TCP。RPC经历了漫长的历史发展与演变,从最初的远程过程调用,到公共对象请求代理体系结构(common object request broker architecture,cORBA)提出的分布式对象(distributed object)技术,微软基于组件对象模型(component objectmodel,COM)推出的分布式COM(distributed COM,DCOM),再到后来的.NET Remoting以及分布式通信的集大成框架Windows CommunicationFoundation(WCF),从Java的远程方法调用(remote method invocation,RMI)到企业级的分布式架构Enterprise JavaBeans(EJB)……随着网络通信技术的逐渐成熟,RPC从简单到复杂,然后又由复杂回归本质,开始关注分布式通信与高效简约的序列化机制,这一设计思想的代表就是Google推出的gRPC+ ProtocolBuffers。

随着微服务架构变得越来越流行,RPC的重要价值又再度得到体现。

许多开发人员发现REST风格服务在分布式通信方面无法满足高并发低延迟的需求,HTTP/1.0的连接协议存在许多限制,以JSON为主的序列化既低效又冗长,这就为RPC带来了新的机会。

阿里的Dubbo就将RPC框架与微服务技术融合起来,既满足了面向接口的远程方法调用,实现分布式通信的智能容错与负载均衡,又实现了服务的自动注册和发现。

这使得它成了限界上下文分布式通信的一种主要选择。

基于Dubbo定义的远程服务属于服务行为契约,远程服务作为服务行为的提供者,调用远程服务的客户端是服务行为的消费者,因此,它的设计思想不同于REST面向资源的设计。

由于Dubbo采用的分布式通信本质上是一种远程方法调用(即通过远程对象代理“伪装”成本地调用)的形式,因而需要服务提供者遵循接口与实现分离的设计原则。

分离出去的服务接口部署在客户端,作为调用远程代理的“外壳”​,真正的服务实现则部署在服务端,并通过ZooKeeper或Consul等框架实现服务的注册。

Dubbo对服务的注册与发现依赖于Spring配置文件。框架对服务提供者接口的定义是无侵入式的,但接口的实现类必须添加Dubbo定义的@Service注解。

例如,检查库存服务提供者的接口定义与普通的Java接口没有任何区别:

package com.dddexplained.ecommerce.inventorycontext.northbound.local.provider;

public interface InventoryProvider {
   InventoryResponse checkInventory(CheckingInventoryRequest inventoryRequest)
}

该接口的实现应与接口定义分开,放在不同的模块,定义为:

package com.dddexplained.ecommerce.inventorycontext.northbound.remote.provider;

@Service
public class InventoryProviderImpl implements InventoryProvider {
   public InventoryResponse checkInventory(CheckingInventoryRequest inventoryRequest) {}
}

接口与实现分离的设计遵循了Dubbo官方推荐的模块与分包原则:基于复用度分包,总是一起使用的放在同一包下,将接口和基类分成独立模块,大的实现也使用独立模块。

这里所谓的基于复用度分包,按照领域驱动设计的原则,其实就是按照限界上下文进行分包。甚至可以说,领域驱动设计的限界上下文为Dubbo服务的划分提供了设计依据。

在Dubbo官方的服务化最佳实践中,给出了如下建议:

  • 建议将服务接口、服务模型、服务异常等均放在API包中,因为服务模型和异常也是API的一部分;
  • 服务接口尽可能粗粒度,每个服务方法应代表一个功能,而不是某功能的一个步骤,否则将面临分布式事务问题;
  • 服务接口建议以业务场景为单位划分,并对相近业务做抽象,防止接口数量爆炸;
  • 不建议使用过于抽象的通用接口,如Map query(Map),这样的接口没有明确语义,会给后期维护带来不便;
  • 每个接口都应定义版本号,为后续不兼容升级提供可能,如<dubbo:serviceinterface= “com.xxx.xxxService” version=“1.0” />;
  • 服务接口增加方法或服务模型增加字段,可向后兼容;删除方法或删除字段将不兼容,枚举类型新增字段也不兼容,需通过变更版本号升级;
  • 如果是业务种类,以后明显会有类型增加,不建议用Enum,可以用String代替;
  • 服务参数及返回值建议使用POJO对象,即通过setter、getter方法表示属性的对象;
  • 服务参数及返回值不建议使用接口;
  • 服务参数及返回值都必须是传值调用,而不能是传引用调用,消费方和提供方的参数或返回值引用并不是同一个,只是值相同,Dubbo不支持引用远程对象。

分析Dubbo服务的最佳实践,了解Dubbo框架自身对服务定义的限制,可以确定在领域驱动设计中使用Dubbo作为分布式通信机制的设计实践。

遵循服务驱动设计,一个业务服务正好对应远程服务和应用服务的一个方法,它的粒度与Dubbo服务的设计要求是一致的。远程服务和应用服务的参数定义为消息契约对象,通常定义为不依赖于任何框架的POJO对象,这也符合Dubbo服务的要求。

Dubbo服务的版本号定义在配置文件中,版本自身并不会影响服务定义。

结合接口与实现分离原则与菱形对称架构,可以认为北向网关的应用服务就是Dubbo服务提供者的接口,对应的消息契约对象也定义在北向网关。远程服务则为Dubbo服务提供者的实现,依赖了Dubbo框架,如图所示。

在这里插入图片描述

客户端在调用Dubbo服务时,除了必要的配置与部署需求,与进程内通信的上下文协作没有任何区别,因为Dubbo服务接口与消息契约对象就部署在客户端,可以直接调用服务接口的方法。

若有必要,仍然建议通过南向网关的端口调用Dubbo服务。服务提供者的实现属于北向网关的远程服务,不仅实现了服务提供者的接口,还调用了应用服务。

根据Dubbo框架的要求,服务提供者的接口与消息契约需要组成一个独立的模块,以便部署到客户端。这意味着应用服务与服务提供者接口需要放到不同的模块中,形成不同的JAR包,但在菱形对称架构的代码模型中,都放在northbound.local命名空间下。

比较服务资源契约,服务提供者的设计多引入了一层抽象和间接调用,但保证了菱形对称架构的一致性和对称性,如图所示。

在这里插入图片描述
Dubbo服务与REST风格服务不同,一旦服务接口发生了变化,不仅需要修改客户端代码,还需要重新编译服务接口包,重新部署在客户端。

若希望客户端不依赖服务接口,可以使用Dubbo提供的泛化服务GenericService。泛化服务接口的参数与返回值只能是Map,若要表达一个自定义契约对象,需要以Map<String,Object>来表达。

获取泛化服务实例需要调用ReferenceConfig,这无疑增加了客户端调用的复杂度。

Dubbo服务的实现皆位于上游限界上下文,如果调用者希望在客户端也执行部分逻辑,如ThreadLocal缓存、验证参数等,根据Dubbo的要求,需要在客户端本地提供存根(Stub)实现,并在服务配置中指定stub的值。这在一定程度上会影响客户端代码的编写。

3.事件消息传递

REST风格服务在跨平台通信与接口一致性方面存在天然的优势。

REST架构风格业已成熟,可以说是微服务通信的首选。然而,现阶段的REST风格服务主要采用了HTTP/1.0协议与JSON序列化,在数据传输性能方面表现欠佳。

RPC服务解决了这一问题,但在跨平台与服务解耦方面又有着一定的技术约束。

通过消息队列进行消息传递作为一种非阻塞跨平台异步通信机制,可以成为REST风格服务与RPC服务之外的有益补充。

如果将限界上下文之间传递的消息定义为事件,这种消息传递的分布式通信方式就形成了事件驱动架构风格。

虽说事件驱动模型与事件驱动架构最为匹配,但只要定义好了应用事件,服务驱动设计中的远程服务、应用服务也可以实现分布式的消息传递,只是扮演的角色略有不同。

如果需要更加清晰地体现它们的职责,可以认为参与事件消息传递的关键角色包括:

  • 事件发布者(event publisher);
  • 事件订阅者(event subscriber);
  • 事件处理器(event handler)。

这一命名遵循了发布者/订阅者模式

发布事件的限界上下文称为发布上下文,属于上游;订阅事件的限界上下文称为订阅上下文,属于下游。

事件发布者定义在发布上下文,事件订阅者与事件处理器定义在订阅上下文。事件消息的传递通过事件总线完成,为了支持分布式通信,通常引入消息中间件实现事件总线,常用的消息中间件有RabbitMQ、Kafka等。

由于事件消息的传递需要支持分布式通信,不管事件是应用事件还是领域事件,都需要支持序列化。

图展示了两个限界上下文通过发布/订阅事件消息进行协作时,各个对象角色之间的关系。

在这里插入图片描述
途中左侧为发布上下文,它的远程服务是满足前端UI请求的控制器,例如下订单用例,就是买家通过前端UI点击“下订单”按钮发起的服务调用请求。

应用服务在收到远程服务委派过来的请求后,会调用领域服务执行对应的业务逻辑。

执行完毕后,由应用服务调用注入的事件发布者适配器,将事件消息发布到由Kafka实现的事件总线。

图中右侧为订阅上下文,远程服务为订阅者。

当它监听到Kafka收到的应用事件后,通过实现了事件处理器接口的应用服务消费事件消息,然后将请求委派给领域服务,完成相应的业务逻辑。

发布者端口定义为接口,与外部框架没有任何关系:

@Port(type=PortType.Publisher)
public interface EventPublisher<T extends Event> {
   void publish(String topic, T event);
}

它的实现作为南向网关的适配器,调用了Spring Kafka的API:

@Adapter(type=PortType.Publisher)
public class EventKafkaPublisher<T> implements EventPublisher<T> {
   @Autowired
    private KafkaTemplate<Object, Object> template;@Override
   public void publish(String topic, T event) {
      template.send(topic, event);
   }
}

应用服务和领域服务都可以通过依赖注入获得EventPublisher适配器,故而它的设计与其他位于南向网关的端口和适配器并无任何差异。

事件订阅者需要一直监听Kafka的主题(topic)。

不同的订阅上下文需要监听不同的主题来获得对应的事件。由于事件订阅者需要调用具体的消息队列实现,一旦接收到它关注的事件后,需要通过事件处理器处理事件,因此可以认为它是远程服务的一种,负责接收事件总线传递的远程消息。

远程服务并不实际处理具体的业务逻辑,而是作为外部事件消息的“一传手”​,在收到消息后,将其转交给应用服务。为了清晰地体现处理事件的含义,应定义EventHandler接口,应用服务会实现该接口。

例如,通知上下文的OrderEventSubscriber在收到OrderPlaced事件后,通过调用OrderEventHandler处理该事件:

public class OrderEventSubscriber {
   @Autowired
   private OrderEventHandler eventHandler;
   @KafkaListener(id = "order-placed", clientIdPrefix = "order", topics = {"topic.e-
commerce.order"}, containerFactory = "containerFactory")
   public void subscribeOrderPlacedEvent(String eventData) {
      OrderPlaced orderPlaced = JSON.parseObject(eventData, OrderPlaced.class);
      eventHandler.handle(orderPlaced);
   }
}

事件处理器与应用服务的定义如下所示:

public interface OrderEventHandler {
   void handle(OrderPlaced orderPlacedEvent);
   void handle(OrderCompleted orderCompletedEvent);
}
public class NotificationAppService implements OrderEventHandler ...

整体来看,无论采用什么样的分布式通信机制,明确限界上下文网关层与领域层的边界仍然非常重要。不管是REST资源或控制器、服务提供者,还是事件订阅者,都是分布式通信的直接执行者。

它们不应该知道领域模型的任何一点知识,故而也不应该干扰领域层的设计与实现。应用服务与远程服务接口保持相对一致的映射关系。

对领域逻辑的调用都交给了应用服务,它扮演了外观的角色。分布式通信传递的消息契约对象,包括消息契约对象与领域模型对象之间的转换逻辑,都交给了外部的网关层,充分体现了菱形对称架构的价值。


原文地址:https://blog.csdn.net/qq_40610003/article/details/142440191

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!