领域驱动设计DDD系列(四)——汽车定价车业务迁移案例

项目介绍

定价车业务是天猫汽车行业最核心的业务之一,解决的是消费者在4S店买车无法砍价到最低价格的问题。天猫可以从主机厂(货源方)BD一个最低的一口价,用户通过在天猫下定金单,到线下门店通过电子凭证核销、付尾款,最终履约提车的交易模式,完成在线购买新车的完整的交易链路闭环,培养“上天猫,开新车”的心智。其整个链路涉及到了复杂的交易链路、门店新零售的核销和POS付尾款或车秒贷金融、网商银行的垫资链路、汇金和FP的保证金冻结、分佣(到平台和门店)、代扣等等,整体链路非常长而复杂,有大量的逻辑分支,需要平台小二、货源方、门店、用户的多次配置和交互,且涉及到资金和交易,对业务逻辑的正确性有着极高的要求。

定价车业务从7月中KO到9.9上线,中间仅有不到一个月的开发时间,团队里几个开发和测试同学带着几个外包基本上是一整个月“007”完成了这个业务的上线,并且取得了一定的业务结果。但是在这个过程中,由于时间太紧,全链路需要从0到1,工作量太大,而且业务需求在不停的变化,导致了这个项目的设计缺少了架构上和代码质量上的思考,基本上都是胶水代码,带来的大量的维护成本、健壮性差、业务的准确性无法保障,对未来变化的响应速度会比较慢。但更严重的问题是,因为代码量大、质量比较差,极有可能会因为未来的一次评估不到位的变更导致线上故障,甚至产生资损,而这个是我们无论怎么压测也无法解决的稳定性问题,急需要解决。

从双11之后开始,我们淘系行业团队启动了一个架构升级战役,就是为了能通过架构的改造升级,解决行业团队里大量的长尾应用维护成本高,业务逻辑不清晰,稳定性无法保障的问题。而定价车业务,作为一个汽车行业的核心业务,有很高的业务价值,同时在复杂度上也是行业数一数二,所以被选为第一个改造的业务。而通过这次改造,我们希望能沉淀设计模式和实战经验,为未来其他业务的改造打好基础。

业务逻辑简介

定价车的核心业务逻辑是以天猫汽车为主体开设天猫旗舰店,然后通过统一货源方授权代理特定车型。另一方面,与门店代理商签署协议,通过旗舰店+线下门店的方式代销代理的车型。在这个业务模式下,业务流程需要旗舰店、门店、TP方、货源方的参与,流程包括信息流、物流、资金流在各方之间的流转。

image-20200507175423401.png

一个简化版的业务流程图如下:
image-20200507180250523.png

几个核心节点:

  • 定价车车型管理:涉及到车型宝贝的配置、分佣的设置、可售卖门店的设置、仓库的配置等。
  • 下单链路:通过电子凭证多阶段订单的方式,解决在线付少量定金,但是定价全款的模式。同时会生成一个定价车的自有订单。
  • 核销电子凭证:生成尾款订单,同时给天猫分佣。
  • 垫付尾款:对接了网商银行的大额支付功能,在签约后由门店垫资到货源方,货源方才可以发货
  • 到店支付尾款:用云POS支付尾款,同时给门店分佣。

重点问题

定价车第一版代码的几个核心问题包括:

重Service层:定价车代码有及其重的Service(HSF)层,几乎所有的业务逻辑都实现在了7个Service中,每个Service最多有20-30个接口,依赖了10-20个外部依赖,每个接口实现都是长长的脚本代码,且Service间有互相调用的问题。具体案例代码太长不展示了,大家可以想象到。这个直接导致的问题就是代码理解成本高:当一个文件的代码超过200行左右时,其功能就已经不“纯粹”了,很难去理解他的核心逻辑,也无法从文件名和方法名上就理解一个文件是做什么的,这个增加了理解成本和调试对接成本。在定价车业务中,最大的一个文件有近3000行代码,且有较深的调用链路,光去看一遍代码就已经是很有挑战的事情了,更不要说去理解业务含义。

逻辑重复:因为每个方法都是独立的流水账,也就导致了同一个业务逻辑很有可能出现在多个地方。前期业务刚开始的时候还好,但是到了后期当业务逻辑变更时,很有可能会漏掉某个实现,导致故障。在定价车业务中,关于分佣比例校验的逻辑在2处出现、尾款金额计算出现在3个地方、而最重要的分佣金额计算也出现了3个地方,可以想象未来的一次分佣逻辑变更有可能会导致资损。

“贫血”域模型:对于绝大部分域对象,从返回给前端的数据、到DbMapper使用的出入参都是同一个贫血DO对象,仅包含数据,导致业务逻辑散落在Service层中。一个最常见的问题就是定价车订单的状态流转,类似的状态判断和推进逻辑代码散落在多个地方:

FpcarOrderDO fpcarOrderDO = orderDOList.get(0);
if (fpcarOrderDO.getVechileStatus() < FpcarOrderDO.VECHILE_RELEASED) {
   result.setError(ErrorInfoEnum.UPDATE_ERROR);
   result.setMsgInfo("订单还没有发车!");
   return result;
}

if (!(fpcarOrderDO.getVechileStatus() < FpcarOrderDO.VECHILE_ARRIVE_WAREHOUSE)) {
   result.setError(ErrorInfoEnum.UPDATE_ERROR);
   result.setMsgInfo("车已到仓!");
   return result;
}

fpcarOrderDO.setVechileStatus(FpcarOrderDO.VECHILE_ARRIVE_WAREHOUSE);
fpcarOrderDO.setArriveWarehouseTime(new Date());

同时由于业务前台展示形态和后台存储格式不一致,导致有大量的转化逻辑,而由于使用了基本数据格式,业务逻辑里有很多“魔法值”,导致代码很难理解且容易出错。下面的一段代码就是计算门店佣金的逻辑:

//计算分佣金额
String commissionBmount = "0";
BigDecimal amount = new BigDecimal(fpcarConfigDO.getStoreCommissionAmount());
if (2 == fpcarConfigDO.getCommissionType()) {
   //基于分佣基数,按比例分佣
   BigDecimal total = new BigDecimal(fpcarConfigDO.getCommissionBasePrice());

   //total是分,amount是比例的数字,转换成元要除以10000
   BigDecimal commionB = total.multiply(amount).divide(new BigDecimal("10000"), 2,
       BigDecimal.ROUND_HALF_UP);

   commissionBmount = commionB.toString();
} else {
   //如果固定金额的,则直接按配置的金额扣佣(需要将分转换为元)
   BigDecimal commionB = new BigDecimal(fpcarConfigDO.getStoreCommissionAmount()).
       divide(new BigDecimal("100"), 2, BigDecimal.ROUND_HALF_UP);

   commissionBmount = commionB.toString();
}

如果没有注释,很难看出来commissionType == 2是什么意思,为啥要除以10000和100等等。而这里面又涉及到了金额的各种转化,在其他的代码里也有很多yuanToFen和fenToYuan之类的转化,在这里就不一一展示了。

可测试性差、无UT覆盖:由于绝大部分方法都或多或少的直接依赖了DAO和一些二方服务接口,导致这些代码基本上不可被单元测试,只能集成测试,而集成测试由于外部依赖的不可靠性,数据的不完备等不可控因素,导致测试效率极低。这个问题的直接后果就是定价车的代码是没有被测试覆盖过的代码,未来代码变更也没有明确的回归用例,代码质量无法保障,出现问题无法被及时发现。

除了以上的代码问题之外,一个最大的“风格”上的问题,但又直接严重影响了开发效率的问题是:除非你很了解整个业务逻辑和调用链路,否则一个人很难从现在的代码上理解完整的业务流程。从代码层面能看到的仅仅是近百个接口,但每个接口是在什么时候被调用到的、为什么被调用到、以及哪个上游调用到的都很难说清楚。这就像是在看一本小说,但是所有的章节都完全打散无序,你只能从字里行间试图去拼装一个故事,可想而知其难度和糟糕的体验,更别说试图去改变书中的内容。这个风格问题,在简单的CRUD应用中也许影响还不是很大,但是在这种拥有复杂流程,分支逻辑的业务流程中,对未来的维护成本和稳定性来说也许是致命的。

升级方案 - Aslan Framework + SDK

(一)框架简介
Aslan Framework是我们淘系行业架构小组【南晏、逸翰、瑜进、玄麟、殷浩】研发出来的一套DDD+CQRS的框架,运行在Spring Boot/Pandora Boot之上,实现了一套基于六边形架构(Hexagonal Architecture)的DDD规范,通过不同分层的POM隔离和依赖关系降低了核心业务逻辑和胶水逻辑之间的关联度(关于六边形架构的分解可以参考我之前的文章)。而通过CQRS规范,我们做到了对外接口层(HSF、MTOP、TOP、HTTP等)和内部业务逻辑(Application层)的完整解耦,让内部逻辑边界清晰可见。

其次,为了解决行业应用常见的依赖冲突、代码可测试性差等问题,我们将行业依赖的大部分外部二方依赖都封装了基于Spring Boot Starter的防腐层,形成了一个整体的面向垂直业务的SDK层,其价值是让垂直业务接入横向能力更简单。其中,防腐层的Facade接口让业务Application层不再对集团的各个二方包产生依赖,而仅仅依赖了干净的接口和DTO类,确保了核心业务逻辑无外部依赖,干净可测试;统一的POM管理确保引入的依赖之间没有二方包版本冲突,由SDK维护团队统一解决和确保不同Starter包之间的兼容性;统一的starter配置管理可以确保同一个应用中的不同的业务可以共存不冲突【进展中,定价车一期不需要】。

最后,结合了CQRS架构、用例驱动等理念,我们产出了一套业务流程可视化的方案,通过Application层的流程模块化,第一次做到了业务身份隔离、流程可视化,让“代码即文档”。今天哪怕是你手里没有一个PRD,也可以通过代码注解找到业务流程的前后关系,能够知道在业务流程中每一步都能做什么、谁在做、哪个业务在做。

下面,我详细介绍一下我们怎么将定价车业务重构的。

(二)目录及模块规范化

1.模块划分问题
carcenter应用是一个老的Spring MVC应用,其Maven模块比较符合传统业务应用常见的模块规范,除了应用需要的模块之外,只分为carcenter-client和carcenter-core两个模块,截图如下:

image-20200113170418509.png

其中,client为对外提供的二方包,core为接口的实现。这种打包的问题在于所有的核心代码都在core里,无法做到分层隔离,在写代码时很容易导致Bean间的循环依赖。

2.目录结构问题

再看一下其中模块的目录结构,截图如下:

image-20200113155903718.png

这里面能看出来一个更大的问题,那就是carcenter的目录结构是根据 “文件功能” 拆分的。由于carcenter应用包含了很多个不同的业务(直租、专车专用、车秒贷等等),当这些业务都放在一起时,很难从多个不同的目录里面抽离出来哪些代码是仅属于定价车业务的。另外的一个大风险是按照这种目录结构划分,很有可能出现定价车和其他业务共用一个工具类的情况。

简单来说,这种client / core的模块的划分和按功能划分目录结构的方法,很不利于业务之间的隔离,而未来因为其他业务的变更很有可能会导致定价车业务出故障(BTW,这种问题在行业应用里经常会出现)。

3.基于DDD的模块和目录规范

基于DDD架构,我们将整个应用的代码结构重新按照业务做了划分,并且在每个业务里划分出了client、domain、application、infrastructure和interface这5个模块,结构如下:

image-20200113171331981.png

其中,carcenter2-start是Pandora Boot的启动包,相当于一个启动类的容器,而定价车相关的所有代码都放在了fpcar这个Maven模块中,bidcar则是另一个业务“暗标”的代码。不同的业务之间没有pom依赖关系,彻底的做到了业务之间代码的隔离。fpcar下面的模块功能如下:

client:包含了对外接口、DTO、CQRS用到的Command、Query、Event,以及一些无业务属性的值对象。
domain:核心业务逻辑的承载,包括了实体(Entity)和聚合根(Aggregate)、有业务属性的值对象(Domain Primitive)、域服务(Domain Service)、和Repository的接口类。Domain只依赖Client、外部二方包的Facade接口,不依赖任何其他框架(包括Spring)。这就确保了Domain层是完整可单测的。
application:业务流程的封装,包括了应用服务(Application Service)、DTO转化器等传统Application层包含的东西。同时在这次架构升级中,引入了业务流程可视化的元素,详细在后面讲。Application层在原则上只做业务流程的编排,不做核心业务逻辑,所有判断必须下沉到Domain层。

infrastructure:主要是Repository的实现、DAO、数据映射对象DO(Data Object),DO和Entity的转化类等。Infrastructure只依赖数据库、Tair等跟存储相关的依赖,以及Domain层,不依赖Application层。

interface:对外接口类的实现,包括HSF、MTOP、TOP、定时任务、消息等等多种来源。通过CQRS的架构,我们做到了interface和application之间的解耦,所以interface只依赖了中间件和client包。

interface-old:这个是为了做到无缝迁移用的,在新应用中不需要。interface-old实现了老业务client包的功能,确保接口和数据一致性,但是HSF版本升为2.0,这样业务的迁移成本就极大的降低了。

all:all只是一个打包POM,引入了application、infrastructure、interface和interface-old这几个依赖。

对于所有外部依赖,业务侧代码仅需要在client包引入第三方facade,在应用最外部引入第三方starter/SDK即可,Spring框架的依赖注入和SpringBoot的AutoConfiguration会保证真实业务中会调用到必须的服务。

所有的包依赖关系如下图:

image-20200113182703322.png

4.namespace/目录结构和命名规范

命名和目录结构可能是编码里最“烧脑”的问题,所以我们制定了一套规范,让同学们能从namespace中就知道其功能。另一个核心是让不同的业务可以做到完全隔离:

前缀:com.(tmall|taobao).(应用名).(子业务名).(垂直业务域名 | 流程名)

其中,天猫业务用com.tmall,淘宝业务用com.taobao
应用名:比如,carcenter2
子业务名:比如,定价车 fpcar
垂直业务域名 | 流程名:比如,定价车仓库 warehouse、定价车交易 trade、定价车入驻 entry
例子:定价车仓库域 com.tmall.carcenter2.fpcar.warehouse
例子:定价车交易流程 com.tmall.carcenter2.fpcar.trade
例子:定价车入驻流程 com.tmall.carcenter2.fpcar.entry
完整namespace:(前缀).(功能)

domain:域对象,主要是Entity、Enum、Constant
repository:Repository接口
repository.impl:Repository实现类
repository.converter:DataConverter类(DO <-> Entity)
data:数据库映射DO对象、Mybatis Mapper接口类
types、command、query、event、dto:ValueObject类、Command、Event、Query类、DTO类
application:业务流程
module: 流程Workflow模块类
assembler:DTO Assembler对象
service:HSF接口类
service.impl:HSF接口实现类
message:消息Listener类
controller:Controller类
这么做会出现的问题是一些公共的模块,比如Utils类应该放到哪里?这个结构的原则是公共功能也完全不共享,宁可copy出一份一摸一样的。这样一个业务的全部代码一定会在fpcar这个父目录下,真正可以做到“拎包即走”。

(三)沉淀业务逻辑 - Domain层
在传统架构中,核心业务逻辑是写在Service层的,每个Service的方法代表了一个具体的操作或查询,导致核心业务逻辑散乱在多个大文件中,很难被理解。所以第二步我们需要做的事情是抽象核心业务逻辑到Domain层。

但首先我们先看一下为什么原架构中核心业务逻辑会散乱,以下是一个比较简单,但是又比较典型的案例,具体的业务逻辑是在定金订单支付后,收到支付消息后去更新订单状态和一些字段,仅仅展现了部分代码:

// FpcarDealService.class

@Override
public Result<String> paidOrder(BizOrderDO bizOrderDO, PayOrderDO payOrderDO) {
    // (1)
    Result<String> result = new Result<>();
    result.setSuccess(true);
    result.setObject("ok");

    // (1)
    try {
        // (2)
        //定价车标:auto_fixPrice=1-定金订单; auto_fixPrice=2-尾款订单
        String orderFlag = bizOrderDO.getAttribute(FPCAR_ORDER_TAG_NAME);
        if (StringUtils.equalsIgnoreCase(orderFlag, FPCAR_ORDER1_TAG)) {
            // (3)
            Map<String, Object> paraMap = new HashMap<>();
            paraMap.put("earnestOrderId", bizOrderDO.getBizOrderId());
            List<FpcarOrderDO> orderDOList = fpcarOrderDAO.queryList(paraMap);

            if (!CollectionUtils.isEmpty(orderDOList)) {
                FpcarOrderDO fpcarOrderDO = orderDOList.get(0);
                                // (4)
                //生成自有的订单号,供某些环节中使用
                fpcarOrderDO.setFpOrderId(FpcarOrderDO.FPORDER_PREFIX + fpcarOrderDO.getEarnestOrderId());
                //定金支付时间
                fpcarOrderDO.setEarnestOrderPayTime(payOrderDO.getPayTime());

                //定金订单支付成功状态
                fpcarOrderDO.setupStatus(FpcarOrderDO.EARNEST_ORDER_PAID);

                // (3)
                fpcarOrderDAO.update(fpcarOrderDO);
            }
        }
    }
    // (1)
    catch (Exception ex) {
            logger.error("createOrderException=", ex);
            result.setSuccess(false);
            result.setObject(null);
    }

    return result;
}

在上面这段代码里:

(1):属于返回给调用方的Result和try/catch兜底,属于接口层(interface)逻辑,不是业务逻辑

(2):判断逻辑强依赖了订单BizOrderDO的实现,而这个判断逻辑有可能散落在多个地方,造成重复。同样的,这个不属于核心业务逻辑,属于设施层(infrastructure)逻辑,针对于订单具体实现的逻辑需要被隔离开。

(3):查询和储存强依赖了DAO的具体实现,很难测试且和数据库底层强耦合,同样属于infrastructure逻辑

(4):真正的业务逻辑,但是在操作FpcarOrderDO这个“贫血”模型。

从上面可以看出来,之所以传统的Service层非常臃肿,其中一个原因在于传统的Service层除了核心业务逻辑之外,同时包含了接口、设施、第三方等等逻辑,导致了业务逻辑强耦合第三方逻辑,无法有效的隔离和测试。另一个原因是FpcarOrderDO对象实际上是一个”贫血“域模型,也就是说该对象仅包含数据,不包含行为,导致行为代码散落在多个Service方法里。第一个问题我们在后面解决,这里我们先解决第二个问题。

用充血域模型承载核心业务逻辑

为了承载核心业务逻辑,我们新建了一个领域模型,叫FpcarOrder,省略后代码如下:

/**
 * 定价车订单
 */
@Data
public class FpcarOrder implements Aggregate<FpcarOrderId> {

    /**
     * fpOrderId前缀
     */
    public static final String FPORDER_PREFIX = "fpcar_";

    /**
     * 定价车订单ID
     */
    private FpcarOrderId id;

    /**
     * 自有购买单号
     */
    private String fpOrderId;

    /**
     * 定金订单状态
     */
    private FpcarOrderStatus orderStatus = FpcarOrderStatus.UNKNOWN;

    /**
     * 定金订单id
     */
    private BizOrderId earnestOrderId;

    /**
     * 定金订单支付成功时间
     */
    private Date earnestOrderPayTime;

    /**
     * 支付定金订单
     * @param payTime 支付时间
     */
    public void payEarnestOrder(Date payTime) {
        //生成自有的订单号,供某些环节中使用
        this.setFpOrderId(FPORDER_PREFIX + this.getEarnestOrderId().getValue());

        //定金支付时间
        this.setEarnestOrderPayTime(payTime);

        //定金订单支付成功状态
        this.setOrderStatus(FpcarOrderStatus.EARNEST_ORDER_PAID);
    }

}

几个重点:

FpcarOrder实现了Aggregate,这个是阿斯兰框架的一个DDD的规范,显式声明FpcarOrder是一个聚合根,且ID字段是FpcarOrderId强类型。(关于聚合根(Aggregate Root)和实体(Entity)的关系和理论在另外一篇文章会详细介绍)
FpcarOrderId、BizOrderId、以及FpcarOrderStatus都是Value Object或Enum强类型(原始代码里都是long或int),通过强类型聚合校验逻辑,且确保不会出现赋值到错误字段的问题。(关于为何要用强类型,参考我的其他文章)
payEarnestOrder方法封装了对核心业务逻辑:几个字段的赋值和状态流转。
沉淀核心业务逻辑到Entity后,并且加入Repository(见下文)之后,调用方逻辑变成了:

// 查询
FpcarOrder fpcarOrder = orderRepository.findByEarnestOrderId(bizOrder.getBizOrderId());

// 更新状态
fpcarOrder.payEarnestOrder(payOrder.getPayTime());

// 保存
orderRepository.save(fpcarOrder);

虽然上面的调用貌似仅仅是把上面(4)里面的逻辑改到了Entity里,但这个操作最大的意义是所有FpcarOrder对象相关的赋值、状态流转代码都集中到一个类里面,不单单有助于降低代码重复,而且解决了未来业务逻辑变化时,业务“流程”不需要跟着变化的问题。

(四) 隔离持久化逻辑 - Infrastructure层
在原有的代码中,由于业务逻辑直接操作了贫血DO模型和DAO,导致逻辑中跟持久化相关的逻辑很重;同时因为贫血模型无法保证数据的完整性和一致性,很容易导致bug。在前面我们通过封装了一个FpcarOrder的Entity类,把所有行为相关的逻辑全部封装到Entity里,可以避免直接在业务逻辑中直接处理数据持久化,使用方只需要调用Repository.save方法。Repository的具体实现规范比较复杂,参考我之前的一篇文章,在这里不再赘述。

使用了Repository模式带来的一个好处是,原来很多复杂的、容易出错的数据转化逻辑,现在都可以有专门的类负责封装。参考之前所说的计算门店佣金的逻辑:

//计算分佣金额
String commissionBmount = "0";
BigDecimal amount = new BigDecimal(fpcarConfigDO.getStoreCommissionAmount());
if (2 == fpcarConfigDO.getCommissionType()) {
    //基于分佣基数,按比例分佣
    BigDecimal total = new BigDecimal(fpcarConfigDO.getCommissionBasePrice());

    //total是分,amount是比例的数字,转换成元要除以10000
    BigDecimal commionB = total.multiply(amount).divide(new BigDecimal("10000"), 2,
        BigDecimal.ROUND_HALF_UP);

    commissionBmount = commionB.toString();
} else {
    //如果固定金额的,则直接按配置的金额扣佣(需要将分转换为元)
    BigDecimal commionB = new BigDecimal(fpcarConfigDO.getStoreCommissionAmount()).
        divide(new BigDecimal("100"), 2, BigDecimal.ROUND_HALF_UP);

    commissionBmount = commionB.toString();
}

这段代码之所以复杂的最核心原因是,在项目初期设计时,把分佣固定金额(分)和比例(百分比)这两个完全不同类型的数值存到了同一个数据库字段(String类型)里,然后通过另一个commissionType的字段做了区分,导致了大量的格式转化逻辑。在业务上线之后,已经很难再去修改底层数据储存的逻辑了,只能通过上层的封装降低对底层逻辑的依赖和复杂度。

在使用Entity+Repository模式改造时,我们把分佣计算逻辑封装到了一个Value Object中:

@Value
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class CommissionConfig {

    private static final Money THRESHOLD_AMOUNT = new Money("1000");
    private static final BigDecimal HUNDRED_PERCENT = new BigDecimal("100");

    /**
     * 分佣方式:1-固定金额; 2-按比例扣佣
     */
    private CommissionType commissionType;

    /**
     * 按比例分佣时的价格基数
     * 这个字段只有commissionType = 2 时 才生效
     */
    private Money commissionBasePrice;

    /**
     * 车源方分佣给 旗舰店 的 金额
     * 这个字段只有commissionType = 1 时 才生效
     * NOTE:不要直接取这个字段!用getSourceCommission()
     */
    private Money sourceCommissionAmount;

    /**
     * 车源方分佣给 旗舰店 的 比例。百分比
     * 这个字段只有commissionType = 2 时 才生效
     * NOTE:不要直接取这个字段!用getSourceCommission()
     */
    private BigDecimal sourceCommissionPercent;

    /**
     * 车源方分佣给 门店 的 金额
     * 这个字段只有commissionType = 1 时 才生效
     * NOTE:不要直接取这个字段!用getStoreCommission()
     */
    private Money storeCommissionAmount;

    /**
     * 车源方分佣给 门店 的 比例。百分比
     * 这个字段只有commissionType = 2 时 才生效
     * NOTE:不要直接取这个字段!用getStoreCommission()
     */
    private BigDecimal storeCommissionPercent;

    /**
     * 获取平台应该分佣的金额
     */
    public Money getSourceCommission() {
        // 如果 金额 和 比例 都是0,返回0
        if (getSourceCommissionAmount().isZero() && getSourceCommissionPercent().compareTo(BigDecimal.ZERO) == 0) {
            return Money.ZERO;
        }

        //计算分佣金额
        switch (commissionType) {
            case FixedAmount:
                return getSourceCommissionAmount();
            case Percentage:
                BigDecimal multiplier = getSourceCommissionPercent().divide(HUNDRED_PERCENT, RoundingMode.HALF_EVEN);
                return getCommissionBasePrice().multiply(multiplier);
        }

        return Money.ZERO;
    }

    /**
     * 获取门店应该分佣的金额
     */
    public Money getStoreCommission() {
        // 如果 金额 和 比例 都是0,返回0
        if (getStoreCommissionAmount().isZero() && getStoreCommissionPercent().compareTo(BigDecimal.ZERO) == 0) {
            return Money.ZERO;
        }

        //计算分佣金额
        switch (commissionType) {
            case FixedAmount:
                return getStoreCommissionAmount();
            case Percentage:
                BigDecimal multiplier = getStoreCommissionPercent().divide(HUNDRED_PERCENT, RoundingMode.HALF_EVEN);
                return getCommissionBasePrice().multiply(multiplier);
        }

        return Money.ZERO;
    }

    private static boolean isValid(Integer commissionType, Money baseMoney,
                                   Money sourceMoney, BigDecimal sourcePercent,
                                   Money storeMoney, BigDecimal storePercent, Money itemPrice) {
        if (commissionType != 1 && commissionType != 2) {
            return false;
        }

        // 1000元以内价格 或 分佣基数为0的 或 分佣金额<0的 有问题
        if (itemPrice.isLessThanOrEqualTo(THRESHOLD_AMOUNT)
                || baseMoney.isLessThanOrEqualTo(Money.ZERO)
                || sourceMoney.isLessThan(Money.ZERO)
                || storeMoney.isLessThan(Money.ZERO)) {
            return false;
        }

        CommissionType type = CommissionType.valueOf(commissionType);

        if (type == CommissionType.FixedAmount) {
            //天猫分佣、门店分佣固定金额 取值应该在 0-佣金计算基数 范围内
            if (sourceMoney.add(storeMoney).isGreaterThan(baseMoney)) {
                return false;
            }
        } else if (type == CommissionType.Percentage) {
            //天猫分佣、门店分佣对应比例 取值应该在 0-100 范围内
            if (sourcePercent.add(storePercent).compareTo(HUNDRED_PERCENT) > 0) {
                return false;
            }
        }
        return true;
    }
}

然后在FpcarConfig中只需要调用CommissionConfig的计算逻辑:

@Data
public class FpcarConfig implements Aggregate<FpcarConfigId> {

    /**
     * 分佣信息对象封装
     */
    private CommissionConfig commissionConfig;

    /**
     * 获取平台应该分佣的金额
     */
    public Money getSourceCommission() {
        if (commissionConfig != null) {
            return commissionConfig.getSourceCommission();
        }
        return Money.ZERO;
    }

    /**
     * 获取门店应该分佣的金额
     */
    public Money getStoreCommission() {
        if (commissionConfig != null) {
            return commissionConfig.getStoreCommission();
        }
        return Money.ZERO;
    }

}

通过新建一个转化器,可以解决FpcarConfig和FpcarConfigDO之间的转化逻辑:

@Mapper(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE,
        uses = {IdConverters.class, MoneyConverter.class})
public interface FpcarConfigDataConverter {

    FpcarConfigDataConverter INSTANCE = Mappers.getMapper(FpcarConfigDataConverter.class);

    @InheritInverseConfiguration
    FpcarConfig fromData(FpcarConfigDO data);

    FpcarConfigDO toData(FpcarConfig entity);

    @AfterMapping
    default void fillCommissionConfig(FpcarConfigDO configDO, @MappingTarget FpcarConfig entity) {

        Money baseMoney = Money.fromCents(Long.valueOf(configDO.getCommissionBasePrice()));
        Money sourceMoney = Money.fromCents(Long.valueOf(configDO.getSourceCommissionAmount()));
        BigDecimal sourcePercent = new BigDecimal(configDO.getSourceCommissionAmount());
        Money storeMoney = Money.fromCents(Long.valueOf(configDO.getStoreCommissionAmount()));
        BigDecimal storePercent = new BigDecimal(configDO.getStoreCommissionAmount());
        Money itemPrice = Money.fromCents(configDO.getItemPrice());

        CommissionConfig commissionConfig =
                CommissionConfig.buildFromDO(configDO.getCommissionType(), baseMoney, sourceMoney, sourcePercent,
                        storeMoney, storePercent, itemPrice);

        entity.setCommissionConfig(commissionConfig);
    }

    @AfterMapping
    default void fillDOCommission(FpcarConfig entity, @MappingTarget FpcarConfigDO configDO) {
        CommissionConfig comm = entity.getCommissionConfig();
        configDO.setCommissionType(comm.getCommissionType().getValue());
        configDO.setCommissionBasePrice(comm.getCommissionBasePrice().getCents() + "");
        if (comm.getCommissionType() == CommissionType.FixedAmount) {
            configDO.setSourceCommissionAmount(comm.getSourceCommissionAmount().getCents() + "");
            configDO.setStoreCommissionAmount(comm.getStoreCommissionAmount().getCents() + "");
        } else if (comm.getCommissionType() == CommissionType.Percentage) {
            configDO.setSourceCommissionAmount(comm.getSourceCommissionPercent().toString());
            configDO.setStoreCommissionAmount(comm.getStoreCommissionPercent().toString());
        }
    }

}

调用方在使用时只需要,而不再需要关注具体的储存方式:

fpcarConfig.getSourceCommission()

而最重要的是,以上的所有逻辑,基本上都可以100%做到单元测试覆盖,而不依赖底层的储存,这些对于系统的稳定性都是极其重要的。

(五) 业务流程模块化、可视化 - Application层
如同前文提到,对于定价车这种复杂的业务,我们在代码组织上最大的问题是如何去管理所有的接口和事件。用前面的那个paidOrder方法举例,单纯从一个接口上很难看出上游的调用方是谁,以及什么时候、为什么被调用到,这个在理解代码的时候是比较困难的一件事。同时,当FpcarDealService有超过20个类似的方法时,未来的理解和维护成本,会随着业务的发展变得越来越高。所以针对这个问题,我们根据CQRS的思想做了一个模块化方案。

业务流程模块化

在DDD的架构内,Application层的核心是组织业务”流程“。定价车的业务流程主要分为2部分:1.入驻/管理、2.交易。为了让未来的维护方能快速理解这两个流程,我们在代码结构上对原有的几个Service做了拆解和重新组织,如下(entry是入驻流程、trade是交易流程):

image-20200505133934065.png

其中,我们将代码结构拆为两个层级:

Flow:代表业务流程
Module:代表业务流程中的某个节点
具体案例代码如下:

@BizFlow(name = "定价车交易流程", version = "1.0.0")
public class FpcarTradeFlow {}
@BizFlowModule(flow = FpcarTradeFlow.class, name = "定价车定金订单支付模块", parents = {CreateOrderModule.class}, role = "用户")
public class PayEarnestOrderModule {

    @Autowired
    private FpcarOrderRepository orderRepository;

    @EventHandler(name = "定金支付成功")
    public void handle(@NotNull FpcarEarnestOrderPaidEvent event) {
        BizOrderDTO bizOrder = event.getBizOrder();
        PayOrderDTO payOrder = event.getPayOrder();

        //先检查大订单是否存在了
        FpcarOrder fpcarOrder = orderRepository.findByEarnestOrderId(bizOrder.getBizOrderId());
        if (fpcarOrder == null) {
            throw new IllegalStateException("定价车订单还未创建过");
        }

        // 更新状态
        fpcarOrder.payEarnestOrder(payOrder.getPayTime());

        //保存
        orderRepository.save(fpcarOrder);
    }

}

在上面的代码里,我们通过 @BizFlow 和 @BizFlowModule 这两个注解,对PayEarnestOrderModule这个模块做了“文档”化,每个模块代表了业务流程的一部分。其中,flow 参数表示了该模块对应的Flow流程、name 提供了该模块的文档、parents 是该模块的前置模块,role 是该模块的操作方。

那这些信息该如何自动化的识别呢?在我们自己的开发者后台中,会根据代码中的注解自动生成一个业务流程图:

在这里插入图片描述
在这里插入图片描述
这个的好处是哪怕一个新同学完全没看过需求PRD文档,也能通过代码、注解及其生成的流程图,快速上手一个复杂的业务。同时,哪怕未来的代码变化了但是文档没跟上,我们也可以从最新的代码中快速理解一个业务流程,做到”代码即文档“。

模块化的一些规范

在这个流程中,我们经常需要回答的问题是如何合理的划分不同的模块?传统的一些简单的Service,是根据领域模型来划分边界,一般来说要包含“增、删、改、查”,以及一些针对领域对象的操作。但是在DDD架构下,很多增删改查和操作的逻辑应该都抽象到了对应的Entity和Repository中,而Application Service层的逻辑更多的是流程的封装,也就是说通常需要跨多个领域实体,在这个架构下传统的Service划分方式将不再适用。这时候我们需要有一套合理的规范来约束我们的模块划分,在这里我们依赖了单一职责原则(Single Responsibility Principle,SRP)。

SRP的定义其实很多人都会误解,SRP的概念不是一个代码模块/类应该“只做一件事情”,而是一个代码模块应该“只因为一件事情而改变”。也就是说,我们在判断一个模块是否符合SRP原则的时候,只要去看这个类是否会因为多件事而改变。如果我们用SRP原则去看一些传统的Service,能明显的看到几乎所有的Service都不符合SRP的原则,因为一般来说,一个领域模型的增、删、改、查基本上都在业务流程中的不同部分,会因为多个需求的变化而变化。

所以在做模块化的过程中,我们需要考虑的是如何让一个模块能符合SRP,同时尽可能的提升模块的内聚性,降低模块间的关联性。在定价车业务中,我们采用的一个规范是根据前端页面的操作来划分模块,也就是说一个页面上的所有操作都内聚到一个模块中,例子如下:

针对于业务流程中的用户核销后提交资料和垫付车款的需求,

在这里插入图片描述
在这里插入图片描述

代码如下:

@BizFlowModule(flow = FpcarTradeFlow.class, name = "门店垫资信息", parents = {CreateOrderModule.class}, role = "门店")
public class StoreSubmitInvestDataModule {

    @Autowired
    private FpcarOrderRepository orderRepository;

    @Autowired
    private PartnerRepository partnerRepository;

    @Autowired
    private MyBankFacade mybankFacade;

    @CommandHandler(name = "门店垫资")
    public FpcarInvestDTO handleInvest(@NotNull FpcarInvestCommand cmd) {
        // 省略
    }

    @CommandHandler(name = "门店提交信息")
    public Boolean handleSubmitData(@NotNull FpcarSubmitDataCommand cmd) {
        // 省略
    }

    @QueryHandler(name = "查询门店垫资信息")
    public FpcarInvestDTO queryInvestInfo(@NotNull FpcarInvestQuery query) {
        // 省略
    }

}

可以看出来,在这个步骤中,该页面如果有任何需要修改的逻辑,全部都可以在这一个Module类中实现变更,而不需要任何其他Module的变更,符合了SRP原则。

CQRS参数显性化

在上面的代码里,有几个和传统Service参数不一样的地方:

第一,所有Module的公开方法的入参都是Command、Query或者Event,这是我们采用CQRS的代码规范,例子如下:

// 门店提交资料指令
@Data
public class FpcarSubmitDataCommand implements Command<Boolean> {
    private Long storeId;
    private Long id;
    private String investData;
}

// 其中T是该指令应该的返回值
public interface Command<T> {}

这个跟传统的Service接口的方式有明显的差异:

Result<Boolean> submitData(Map<String, Object> paramsMap); // (1)

Result<Boolean> submitData(Long storeId, Long id, String investData); // (2)

在传统的方法中,入参一般有以下两种方式:

(1)通用的大Map,可以保证接口兼容性,但需要在代码里解决Map解析和异常处理的问题。

(2)固定参数,但如果未来的需求变更,可能需要接口变更,造成可能的接口不兼容,通常需要通过增加接口方式向前兼容。

我们通过对所有的入参做强类型显性化包装,可以确保1.接口的兼容、以及2.参数的可拓展性。同时,通过对多个参数的显性封装,我们能更明确的理解该方法的“目的性”,不需要再仅仅通过方法名去理解方法的目的。

第二,在公开方法上我们增加了@CommandHandler、@QueryHandler、和@EventHandler注解。这些注解不仅仅是起到了一定的文档作用,同时在启动时,Aslan框架会对所有标记了注解的方法做入参/出参匹配校验,确保方法的出参符合预期(这个功能后续可以考虑改为静态编译时执行)。具体的实现比较复杂不在此处赘述。因为有了注解和规范,关于该模块的方法信息可以上报到研发平台,供开发快速了解每个模块的方法,如下:

image-20200507153050174.png

第三(可选),增加了注解的方法,Aslan框架在启动时会注册到一个注册中心,让调用方更加简单的去使用该方法,而不必太关注该方法所在的具体模块,同样的案例如下,为了做到和原有HSF接口兼容,我们做了一次转换:

// 原来的HSF接口
public Result<Boolean> submitData(Map<String, Object> paramsMap) {
    Result<Boolean> result = new Result<>();
    try {
        Long storeId = getStoreId(paramsMap);
        String investData = (String) paramsMap.get("investData");
        Long id = (Long) paramsMap.get("id");
        // 省略部分校验代码

        FpcarSubmitDataCommand cmd = new FpcarSubmitDataCommand();
        cmd.setStoreId(storeId);
        cmd.setId(id);
        cmd.setInvestData(investData);
        Boolean success = commandBus.dispatch(cmd); // (1)
        result.setObject(success);
    } catch (FpcarException fpe) {
        result.setSuccess(false);
        result.setObject(false);
        result.setErrorMessage(fpe.getMessage());
    } catch (Exception e) {
        result.setError(ErrorInfoEnum.SERVICE_NOT_USE);
    }
    return result;
}

在上面的代码里,(1)的commandBus可以通过全局注册查找该Command对应的CommandHandler,然后通过dispatch来间接调用该Handler。这种调用方式避免了需要直接引入StoreSubmitInvestDataModule这个依赖,也就是说可以让Interface层和Application层解耦。CommandBus/EventBus/QueryBus的dispatch方法都是同步调用,相当于直接引用,另外提供的dispatchAsync方法,可以通过传入一个自定义Executor,实现异步调用,返回CompletableFuture,在此不再赘述。

通过CommandBus这种间接调用的方式可能有一定的理解成本,业务开发需要对自己的理解做评估,然后选择采用CommandBus。如果不用CommandBus,直接调用Module方法也是一样的,如下:

@Autowired
StoreSubmitInvestDataModule storeSubmitInvestDataModule;

// ...

FpcarSubmitDataCommand cmd = new FpcarSubmitDataCommand();
Boolean success = storeSubmitInvestDataModule.handleSubmitData(cmd);

(六)隔离第三方依赖 - 防腐层Facade

任何一个复杂业务都不可能不依赖第三方服务,在定价车业务里,我们依赖了UIC、IC、交易、网商、新零售、FP、汇金、消息等第三方服务。拥有大量的第三方依赖带来很多问题:

调试对接成本很高:每个依赖方都有完全不同的接入方法,有些复杂依赖(如网商,需要加密解密,解析XML等)的从0开始的对接和调试验证成本可能要几周。
第三方服务升级会导致不兼容或冲突:每年各种中间件、第三方依赖升级所需的工作量巨大,特别是当出现不兼容升级或冲突出现时,需要对代码进行升级和测试,产生大量的成本。
依赖了第三方数据格式:当你在业务逻辑里依赖了第三方的数据格式时,会导致你的代码对第三方产生强依赖,让你的代码变得僵硬,特别是当第三方数据格式不可变时。
无法单测:直接依赖了第三方服务接口,就如同直接依赖DAO一样,会导致业务逻辑无法单元测试,只能集成测试。而在很多情况下集成测试的成本极高,比如绝大部分的第三方缺少稳定的日常环境,而在预发联调的风险又极高。
为了解决这些问题,让我们自己的代码逻辑可单测且不依赖第三方,我们对一些常用的第三方依赖相关的逻辑做了一次封装,并且利用了Spring Boot Starter的特性,通过AutoConfiguration解决了依赖配置的问题,调用方只需要引入一个Starter POM包即可。这个封装的Starter就是我们的防腐层。

一个标准Starter的组成

Facade:新的接口和出参入参的DTO
Core:具体和第三方依赖的对接实现,以及AutoConfiguration相关的配置
在一个正常的代码里,业务逻辑应该只依赖新的Facade,然后在应用的最外层依赖相关的Starter POM即可,starter自带的spring.factories和AutoConfiguration会自动把对应的实现在运行时注入。

举个上面分佣的例子,原有的代码里需要直接调用汇金的接口,需要直接依赖汇金的服务和DTO:

// 构建入参
BizRequestDTO bizRequestDTO = new BizRequestDTO();
// 省略各种参数拼装和校验逻辑
ReturnWrapper<BizResponseDTO> returnWrapper = iBizGwService.submit(bizRequestDTO);
通过封装了一层Facade层,我们可以隔离汇金的接口:

@BizFlowModule(flow = FpcarTradeFlow.class, name = "定价车分佣A模块", parents = {UseEarnestOrderModule.class}, role = "用户")
public class CommissionAModule {

    @Autowired
    private HuijinFacade huijinFacade;

    @EventHandler(name = "定价车分佣A事件")
    public void handleEarnestOrderSuccess(@NotNull FpcarCommissionAEvent event) {
        // ...
        HuijinRequestDTO request = new FpcarSourceHuijinRequestBuilder()
                .fpcarConfig(fpcarConfig)
                .fpcarOrder(fpcarOrder)
                .user(user)
                .build();

        final Result<HuijinBizResponseDTO> commissionResult = huijinFacade.submit(request);
    }

}

在上面的代码中,业务代码不再直接依赖汇金的接口和出入参,而是依赖我们自定义的、可控的Facade和DTO,并且由于该Facade只是一个接口类,可以随意的Mock和Stab。让该方法可控可测试。

除了提供Mock之外,一个防腐层能提供更多的功能如:缓存、兜底、限流、多业务身份隔离等功能,具体的实现规范会在我后面的一篇文章里详细解释。

老业务迁移方案

最后,我们在迁移代码之后,需要考虑如何做老业务的迁移,考虑到修改前端的成本比较高也暂时没有人力去完成,整体的迁移方案希望能做一个成本最低的方案。我们对定价车整体的迁移方案设计为以下两个阶段:

第一阶段 - 新应用,接口签名不变,HSF V2.0

在第一个阶段,迁移成本最低的方案是确保所有的对外HSF接口都保持签名不变,仅仅是HSF版本变化,如下:

image-20200507203148790.png

定价车业务的前台接口都是由carcenter-front应用来提供,然后通过HSF调用到carcenter上,其HSF接口定义在carcenter-client二方包里。

在迁移后的carcenter2上,fpcar-interface-old模块重新实现了carcenter-client的接口,但是其接口实现只相当于参数拼装后转发到application层,然后把结果重新转化为原有的数据格式,案例如下:

@HSFProvider(serviceVersion = "${old.hsf.version}") // old.hsf.version = 2.0.0
public class FpcarPartnerServiceImpl implements FpcarPartnerService {

    @Autowired
    private QueryBus queryBus;

    private FpcarConverters converters = FpcarConverters.INSTANCE;

    @Override
    public Result<List<FpcarPartnerDO>> queryList(Map<String, Object> paraMap) {
        PartnerPagedQuery query = PartnerPagedQuery.fromMap(paraMap);
        Page<PartnerDTO> queryResult = queryBus.dispatch(query);

        Result<List<FpcarPartnerDO>> result = new Result<>();
        List<FpcarPartnerDO> list = queryResult.getContent().stream().map(converters::fromDTO).collect(Collectors.toList());
        result.setObject(list);
        return result;
    }
}

在前台应用carcenter-front中,逐渐切流的方法有很多,最简单的就是配置一个开关通过白名单或用户ID或商家ID,通过配置两个不同版本的HSFConsumer来切流。

第二阶段 - 切换到新接口、新实现

第二个阶段需要一定的前台改造,但是能有效的减少代码转换逻辑和维护老接口的成本,逻辑如下:

在这里插入图片描述

能看出来,新老接口的底层都调用了application层的业务逻辑,所以整体迁移的风险不大,只是前台适配的工作量。这一步需要每个业务按需执行。

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

【江湖】三津

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值