6. 手把手落地 菱形对称架构

包结构

前面提到菱形对称架构是集大成,且相对清晰和适合落地的。目前我所在的团队也尝试过,用的还不错。还不错的意思是大家都认可分层的合理性,并能持续坚持边界。因此这篇文章会介绍一下此分层的实践。

项目实际的分包如下。因为项目其实也比较大,所以会分的细一些,小项目可以看情况合并。

我们根据每一层来讲一下分层架构是如何在具体项目里面应用的

北向网关 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,实际开发过程中还会有很多权衡,例如性能,事务等等,会让非领域层的的设计影响领域层,或者让非领域层对象进入领域层。对于特殊的场景可能会有妥协,但整体还是要把边界明确清楚,不到万不得已不违反分层的原则。

合理的分层会带来很多好处,易读易修改,能有效控制影响范围避免修改扩散,同时也便于分层测试。如果大家都能理解并能遵循分层的规则,我相信很快就能感受到分层带来的收益。

相关推荐
2401_857622662 小时前
SpringBoot框架下校园资料库的构建与优化
spring boot·后端·php
2402_857589362 小时前
“衣依”服装销售平台:Spring Boot框架的设计与实现
java·spring boot·后端
哎呦没3 小时前
大学生就业招聘:Spring Boot系统的架构分析
java·spring boot·后端
_.Switch4 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
韩楚风5 小时前
【linux 多进程并发】linux进程状态与生命周期各阶段转换,进程状态查看分析,助力高性能优化
linux·服务器·性能优化·架构·gnu
杨哥带你写代码5 小时前
足球青训俱乐部管理:Spring Boot技术驱动
java·spring boot·后端
AskHarries6 小时前
读《show your work》的一点感悟
后端
A尘埃6 小时前
SpringBoot的数据访问
java·spring boot·后端
yang-23076 小时前
端口冲突的解决方案以及SpringBoot自动检测可用端口demo
java·spring boot·后端
Marst Code6 小时前
(Django)初步使用
后端·python·django