写业务系统,更重要的是设计,不是吗?

592 阅读7分钟

什么是不好的设计?

创建订单与编辑订单使用同一个接口,你觉得是好的设计吗?

运营人员修改订单与用户修改订单使用同一个接口,你觉得是好的设计吗?

创建订单、编辑订单都用同一个类接收参数,你觉得是好的设计吗?

以上设计都违背了单一职责原则。如果这些问题都存在,我相信这样的接口代码都会是一坨坨又长又难理解的代码。

违背单一职责原则的接口存在哪些问题?

以创建订单和修改订单为同一接口为例。

从接口文档编写来看,即便是自动生成的接口文档,你也需要标注类似“当编辑订单时,订单ID不能为空”、“编辑订单时xx字段不能修改”这样的声明,你喜欢这样的文档吗?

从参数校验来看,由于存在“创建订单时,订单ID为空”这样的条件,因此我们就不能给字段添加注解实现自动校验,而且需要在代码里面写上if订单id不为空嵌套ifxx字段不能为空这样的代码。

从理解层面、扩展性来看,接口实现代码逻辑会复杂化,且难以理解、难以维护。如修改编辑订单逻辑时需要考虑会不会影响到创建订单逻辑。

多个接口使用相同「类」接收入参会存在哪些问题?

我见过不少这样的设计,直接使用PO接收接口入参,这是省事了,直接调用DAO update到数据库完事,可怎么像是在写数据库代理服务呢?偷懒不如直接点,倒不如直接面向数据库编程,去掉Service,在Controller中直接操作DAO写数据库不是更省事。

更让人难受的是,创建订单使用PO接收入参,编辑订单信息使用PO接收入参,修改订单金额使用PO入参,啥都是PO入参。想找某个字段有哪个接口或者有哪些接口修改,看代码你都找不出来。

对于修改订单信息接口,遵循单一职责原则,应区分不同场景分别提供接口。如用户修改订单接口、运营人员修改订单信息接口。而不同用例接口,要求的入参也会不同,用户能修改的订单信息与运营所能修改的订单信息是不同的,因此应该为每个接口创建不同的入参类(在CQE模式中叫Command)。

我很推荐CQE+DTO模式,即接口入参区分Command、Query、Event,分别对应操作命令、查询参数、事件参数,接口出参为DTO。

如创建订单命令为CreateOrderCommand、后台搜索订单为AdminOrderSearchQuery、处理埋点事件为UserInHomeEvent等。

不仅入参需要遵循单一职责原则,接口出参同样需要。

以订单查询为例,对于用户查询订单,我们可能不想给用户看到其它一些信息,而后台运营查询则需要,因此不应该统一为OrderDTO,而应有OrderDto、OrderDetailsDto、OrderSearchDto、AdminOrderSearchDTO…。

我从很多项目中看到一个有趣的现象,不知道这个现象是从哪里流行起来的,我猜测可能是那些PHP开发者转Java后携带过来的习惯,习惯入参统一为HttpServletRequest,然后一个个参数get,出参也习惯都用Map,虽然看不出这样写有什么问题,但看着好费劲,也很别扭,特别是这样写导致的方法又臭又长。

为什么要分层?分层不就是为了解耦吗,既然要解耦,我们就要守规矩。其中就有:上层可以依赖下层,而下层不能依赖上层。

有的人喜欢将HttpServletRequest作为参数传递给Service层方法。Controller层作为Service的上层,而Service层直接依赖了上层的类。这就好比:“爸,你结婚时为什么没请我喝喜酒”,建楼都可以先盖3楼再盖2楼的吗?

还有一个很多人都忽略的案例,就是在Service方法中调用WebMVC框架提供的ThreadLocal全局获取Session以获取登录用户ID,这同样是下层依赖了上层,如果我们将WebMvc切换到其它的框架,那么还能获取到用户ID吗?不仅获取不到,反而还要修改大量代码。因此,登录用户ID应该是在Controller调用Service时传入,而不是在Service中获取。这才是真正意义上的解耦。

下面聊聊DDD中,微服务间调用的问题。

由于缺少DDD实战经验的积累,笔者已经对最近半年做的一个项目的框架重构了好几次,也是因为在实战过程中遇到问题,为了解决问题而重构。

目前,笔者已经重新采用六边形架构(适配器架构)将项目各个微服务重构了一遍,每个微服务框架分为三层,从上到下分别是适配层、应用层、领域层,如下图所示。

image.png

(图片来源于网络)

下面这张图可能更容易理解。

image.png (图片来源于网络)

在重构过程中发现一个问题,应用层依赖其它服务接口,应该在应用层直接实现远程调用吗?类似这样:

package com.mmg.storecontext
@FeignClient
public interface StoreOpenFeignClient{
    Response<com.mmg.storecontext.application.StoreDto> findStore(Long storeId);
}

package com.mmg.ordercontext
@Service
public class OrderCreateUseCase{
    private StoreOpenFeignClient storeClient;
    
    public OrderCreateUseCase(StoreOpenFeignClient storeClient){
      this.storeClient = storeClient;
    }
    @Transactional(rollbackFor = Throwable.class)
    public void createOrder(CreateOrderCommand command, 
         Long loginUserId){
          Response<com.mmg.storecontext.application.StoreDto> response = 
              storeClient.findStore(command.getStoreId());
          ......
    }
}

StoreOpenFeignClient是店铺服务提供的SDK包中的类,因此上述代码我用完整包名做区分。店铺服务SDK包中定义了DTO类、CQE类以及Dubbo接口或OpenFeign接口(StoreOpenFeignClient)。

如上案例代码所示,创建订单需要获取店铺信息,而在此案例中,应用服务直接使用了OpenFeign接口,这是不推荐的,且是强耦合的。应该抽离出一层代理,类似这样:

public class StoreClientProxy{
   private StoreOpenFeignClient storeClient;
   
   public com.mmg.storecontext.application.StoreDto findStore(Long storeId){
       Response<com.mmg.storecontext.application.StoreDto> response = 
             storeClient.findStore(command.getStoreId());
       if(response.isSuccess()){
            return response.getData(); 
       }
       // 抛异常
   }
}

有了这一层抽象,StoreClientProxy的实现即可以通过OpenFeign或者Dubbo去实现RPC调用店铺服务接口,当调用方式修改时,订单应用服务不需要修改代码。

但StoreClientProxy依然存在耦合,首先,StoreClientProxy是在应用服务层定义的,应用服务层不关注实现,应该抽象为接口由适配层实现,其次,findStore方法的返回值还是依赖了店铺服务SDK。

一开始笔者实现的就是在订单服务中直接使用了店铺服务SDK包中的DTO类,这已经违背了下层不能依赖上层的原则,因此每当我重构店铺服务,修改到店铺SDK的DTO包名或者字段名时,订单服务的应用层就要修改好多代码。

针对上述案例,我们需要进一步解耦,改为在订单服务的应用层定义StoreGateway接口,在订单服务的适配层实现StoreGateway接口。

public interface StoreGateway {
    StoreValobj findStore(Long storeId);
}

StoreGateway是在应用层定义的,而定义接口时就已经需要明确方法入参和出参,虽然此时还没有实现接口,但我们已经可以使用StoreGateway完成业务代码了,所以StoreGateway入参和出参也在应用层定义,订单服务只关心自己需要哪些店铺信息(店铺id、店铺名称、店铺地址)。

// 适配层实现
public class StoreGatewayImpl{
   private StoreOpenFeignClient storeClient;
   
   public StoreValobj findStore(Long storeId){
       Response<com.mmg.storecontext.application.StoreDto> response = 
             storeClient.findStore(command.getStoreId());
       if(response.isSuccess()){
            com.mmg.storecontext.application.StoreDto dto = response.getData(); 
            return new StoreValobj(dto.getStoreId(),dto.getStoreName,dto.getAddress());
       }
   }
}

这样就完成解耦了,以后无论店铺服务SDK中的StoreOpenFeignClient、com.mmg.storecontext.application.StoreDto怎么修改,我们都只需要修改StoreGatewayImpl,而不需要调整任何业务代码。

从这个案例可以看出,步骤虽然多了,但这些步骤都是分层解耦所必须的,任何取巧偷懒的后果都需要付出更多的修改成本。

在DDD中,我们有非常多的对象类型转换,都是为了解决分层解偶,以及数据安全问题,如Command转领域层的值对象、领域实体(DO)转持久化对象(PO)、PO转DO、DO转DTO。虽然麻烦,但设计如此。

另外,实战DDD过程中,建议牢记这句话:设计是设计,实现是实现!我们要做的就是如何在按照设计去实现的基础上想方设法解决效率问题,而不是为了效率去颠覆设计。

好的设计才有好的扩展性。

写业务系统,我们应该更注重设计,好的设计能解决百分之八十的问题。