包结构
前面提到菱形对称架构是集大成,且相对清晰和适合落地的。目前我所在的团队也尝试过,用的还不错。还不错的意思是大家都认可分层的合理性,并能持续坚持边界。因此这篇文章会介绍一下此分层的实践。
项目实际的分包如下。因为项目其实也比较大,所以会分的细一些,小项目可以看情况合并。
我们根据每一层来讲一下分层架构是如何在具体项目里面应用的
北向网关 ohs
ohs 不是北向网关的简称,而是开放主机模式 (open host service) 的简称,其实就是对外提供服务,包括本地的和远程的。
远程服务的类型很多,有 http,dubbo (rpc),mq,task (定时任务) 等等。为了方便分类所以就按协议的不同分包了。
本地服务还有一个pl,是发布语言 (public language) 的简写,就是存 dto (数据传输对象) 。dto 和领域层的聚合,实体,值对象不同,dto 是没有业务行为的,因为他只是承载数据,一般只有 getter 和 setter。
最理想的情况下是每一层的都有自己的 pl,例如远程服务叫 remote_pl,本地服务叫 local_pl,这样 dto 就不会相互影响。但实际使用上,远程服务和本地服务差别不是特别大,就算有也可以通过通用协议弥补两者的差异。
例如有一个更新订单的业务能力,我们本地服务是这样提供的
java
public class OrderAppService {
public modifyOrder(req: ModifyOrderReqDto): ModifyOrderRespDto
}
// 入参类, 忽略 getter 和 setter
public class ModifyOrderReqDto{
private userId: String,
private orderId: String,
// 修改订单地址
private address: String
}
// 出参类, 忽略属性
public class ModifyOrderRespDto{
}
本地服务和技术无关,是一种通用协议,为了统一入参出参的格式,不管是一个参数还是多个参数,这里统一用一个 dto 承接。
如果远程服务是 http 协议的 Controller,一开始我们只能拿到一个 httpRequest 的对象,数据存在于 pathVariable,header,body 等地方,我们需要通过 httpRequest 转成上面的 ModifyOrderReqDto 才行。在转之前为了方便,一般框架都会提供更方便获取参数的方式, 不用开发人员直接使用 httpRequest 了。 例如下面常见的 Controller 方法定义。
java
@RestController
public class OrderController {
@PutMapping("/order/{orderId}")
public ModifyOrderRespBodyDto modifyOrder(
@RequestBody body: ModifyOrderReqBodyDto,
@PathVariable orderId: String
@Header("token") token)
}
public class ModifyOrderReqBodyDto {
private address: String
}
public class ModifyOrderRespBodyDto {
}
在 controller 这一层我们也会定义 dto,例如 ModifyOrderReqBodyDto,这层的 dto 就是属于 remote_pl。remote_pl 的 dto 除了属性放置位置和本地服务的 dto 不同之外,还有部分值的定义都不同。例如 token,在 http 里面传的是 token,但本地服务用的 userId。我们可以对比一下本地服务定义协议用 token 好,还是用 userId 好。本地服务是可以通过不同协议提供出去的,例如 rpc,如果我提供一个能力给其他服务例如后台管理系统去改订单地址,那是用 token 还是用 userId 合适呢? 应该是 userId 更合适,因为客服去改订单不应该去用户的 token 去改。因此 Controller 层还需要把 token 转成 userId。
java
@RestController
public class OrderController{
@Autowire
private OrderAppService orderAppService;
@PutMapping("/order/{orderId}")
public ModifyOrderRespDto modifyOrder(
@RequestBody reqDto: ModifyOrderReqDto,
@PathVariable orderId: String
@Header(token) token) {
// 通过 http 协议构造出完整的 reqDto
reqDto.setOrder(orderId);
reqDto.setUserId(getUserIdByToken(token))
return orderAppService.modifyOrder(reqDto)
}
}
因为大部分 body 的属性和应用层的 dto 的属性都是一样的,定义两个 dto 再转比较麻烦。为了不用定义多个 dto,我尝试以应用层的 dto 为主,把应用层的 dto 当成 body 的 dto,然后在 Controller 方法里面再对 dto 赋值。虽然 Controller 看上去会有点奇怪,但也避免了 dto 转换带来的可读性的下降。
其他类型的处理方法也差不多,这也是为了简化分层实现的一种妥协。
领域层
应用层的对象和领域层的对象是没法通用的,因为差别太大了,所以应用层要转一下,转成领域层的对象。有时候入参或者返回值的类型过于复杂,会导致应用层有很多转换的代码降低可读性,因此可以引入 Assembler (转换器) 负责 dto 和领域实体的相互转换。南向网关的适配器也是同理,可以使用 Assembler 把一部分转换职责隔离出去。
java
// 本地服务
public class OrderAppService {
@Autowire
private ModifyOrderService modifyOrderService;
public modifyOrder(req: ModifyOrderReqDto): ModifyOrderRespDto {
// 转成领域对象 -- 聚合
Order order = orderRepository.getById (req.orderId);
modifyOrderService.modifyOrder(order)
}
}
// 领域服务, 如果聚合能完成就不用领域服务, 但这里要通知, 所以就用领域服务
public class ModifyOrderService {
@Autowire
private OrderRepository orderRepository;
@Autowire
private OrderNotifyService orderNotifyService;
public modifyOrder(order: Order): ModifyOrderRespDto {
// 执行领域方法
order.modifyAddress(req.address);
orderRepository.save(order);
// 通知商家地址变了
OrderNotifyService.notifyMerchantOrderModified(order);
}
}
//端口, 也在领域层这里定义了
public class OrderNotifyService {
public notifyMerchantOrderModified(Order order);
}
//仓储, 也在领域层这里定义了
public class OrderRepository {
public Order getById (id:String);
}
领域层特别在设计层面要避免面向适配器设计。另外如果某个上下文业务比较复杂,领域实体比较多,也可以按照聚合来进行分包,方便管理。
南向网关
南向网关前面提到只有适配器,端口是属于领域层。这点没有疑问,但在落地的时候端口是否可以单独一个包,和适配器挨在一块呢? 也是可以的。首先更明确端口适配器的概念,端口和适配器总是这么一起说,看到了适配器但端口又藏在领域层会让人有疑惑,还不如放一起。而且我们查找起来也比较方便。但这样也会带来一个问题,端口的定义容易被适配器影响,容易定义出一些通用的方法的命名,而非面向领域的命名。因此建议还是放领域层会更合适一些。
端口本质上和聚合,领域服务,仓储没什么区别,只是实现方式不同罢了。
这个例子里面有 2 个适配器,一个是 OrderNotifyService 另一个是 OrderRepository。
java
public class OrderNotifyServiceImpl extends OrderNotifyService{
@Autowire
private PushService pushService;
@Override
public void notifyMerchantOrderModified (Order order) {
PushDto pushDto = OrderNotifyAssembler.toOrderModifiedPushDto(order)
pushService.pushToUser(pushDto)
}
}
public class OrderRepositoryImpl extends OrderRepository{
@Autowire
private OrderMapper orderMapper;
@Override
public void save(Order order) {
orderMapper.insertOnDuplicate(OrderEntityAssembler.toOrderEntity(order))
}
@Override
public void save(Order order) {
orderMapper.insertOnDuplicate(OrderEntityAssembler.toOrderEntity(order))
}
}
PushService 其实也是一个接口,推送服务可能会有不同的实现,但这已经不在菱形对称架构范畴里面,算不上分层架构里面的端口。应该算是适配器内部的分层,务必要和端口分开。
总结
一个修改订单的案例基本上把各层都串起来了,但毕竟这只是一个 demo,实际开发过程中还会有很多权衡,例如性能,事务等等,会让非领域层的的设计影响领域层,或者让非领域层对象进入领域层。对于特殊的场景可能会有妥协,但整体还是要把边界明确清楚,不到万不得已不违反分层的原则。
合理的分层会带来很多好处,易读易修改,能有效控制影响范围避免修改扩散,同时也便于分层测试。如果大家都能理解并能遵循分层的规则,我相信很快就能感受到分层带来的收益。