用DDD(领域驱动设计)重构单据审批项目--续

        之前写了篇如本文题目的文章,但考虑到篇幅就没有介绍项目的重构过程,今天就把这个坑填上,以了却一块心病。

        如果想用DDD,那么相关知识是必不可少的,所以先推荐几本有关DDD的书籍,从“学”开始。第一本当然是DDD的提出者Eric Evans的《领域驱动设计 软件核心复杂性应对之道》,这本花费了作者4年时间的DDD开山之作值得反复阅读。《复杂软件设计之道:领域驱动设计全面解析与实战》,这本书是国内DDD布道者彭晨阳编著,书中实例丰富,对实践很有帮助。顺便说一句,我非常喜欢封面上的那只黑豹。接下来两本书都是出自Vaughn Vernon,一本是《实现领域驱动设计》,这本书是对Eric那本书的丰富,同样值得反复阅读。一本是《领域驱动设计精粹》,这本书很薄,可以快速带领读者走进DDD。最后一本是Chris Richardson的《微服务架构设计模式》。DDD在国内的兴起与微服务被广泛的应用有着密切的关系,而近年来服务拆分成了导致架构师们的发量进一步减少的又一重要原因。这本书中对DDD在服务拆分过程中所发挥的指导作用进行了介绍。

前情回顾

        之前一篇文章提到接手了一个单据审批系统,虽然这个系统的菜单有好几十个,但其实真正的核心功能只有两个,一个是单据审批(由Activiti实现,这部分需求主要集中在审批节点的维护,通常由业务人员在页面中即可完成),一个是为审批完成的单据生成凭证。其余功能则都服务于这两个核心功能,比如会计科目维护等等。也就是说,这个系统的终极目的就是生成会计凭证,我所重构的就是这部分。重构的原因在前一篇文章中进行了详细的介绍,总的来说就是业务扩展性差,且维护与测试难度大。

        凭证的生成是要依赖凭证生成规则,即先根据单据的类型等属性获取之前已在数据库中配置好的规则,然后依照规则创建凭证。重构前代码结构如下图所示。

用DDD(领域驱动设计)重构单据审批项目--续

        重构前最大的问题是凭证生成逻辑分散在门面和凭证两个服务中,也就是说要修改或新增一个规则必须同时修改两个服务,仅从服务拆分的角度看就已不合理,更不用说代码中的坏味道。如下所示,类似这样的代码已随处可见。

if("0".equals(bill.getRelatedParty())) {//关联方交易
			buildAdCreateRequestAmounts_RelatedParty(bill,adRequest);
		}else{//非关联方交易
			buildAdCreateRequestAmounts_common(bill,adRequest);
		}

规则代码化

        之前的文章曾提到过,由于一个凭证生成规则需要在系统中的两个页面进行配置,因为配置涉及到代码中的一些细节,因此这项配置工作仅能由开发人员根据产品提供的相关描述来完成。如果这个规则可以由产品自己进行配置,那么我觉得设计成页面形式是没什么问题的,但很遗憾,产品不会,也不可能会。既然只能由开发人员来配置,那么页面这种配置形式的意义又有多大呢?我认为,页面配置形式要么用于在系统不重启的情况下立即让配置生效的情况,要么可以让系统的使用者通过自行配置改变系统行为的情况,比如列表组件可以选一页最多能显示多少条记录。对于上述两个特征,规则的页面配置方式均不符合。所以我决定首先从凭证生成规则下手,将数据库中记录形式的规则代码化。这样不仅可以减少一次数据库访问,同时也提升了代码的可维护性。 

        经统计,数据库中的凭证生成规则共计55条。如果数据库中每一条规则对应一个类的话,就需要55个类,虽然每个类的代码量不大,但确实会造成类膨胀。起初决定以凭证类型对规则类进行分包处理,但这种方式并没有从根本上解决类膨胀的问题。经过进一步分析,决定以每个凭证类型来定义一个规则类,然后在类中进一步区分具体的规则,虽然每个类中的代码略有增加,但这种设计方案使类的数量从55个降到了7个。与单据不时的增加新的类型不同,凭证类型很稳定,这也是选择以凭证类型作为规则类创建维度的原因。不过在新增规则时就需修改规则类,虽然修改的代码并不多(添加addBillSpecification()),但还是在一定程度上违反了OCP,不过为了避免类膨胀,适度的放宽原则也是可以接受的,它们像是处于天平的两端,找到那个平衡点才是最重要的。如下图是以应付凭证类型定义的规则类,其中createRuleDefinition()是RuleHolder类的抽象方法,其由继承类负责实现。

@Component
public class ShouldPayRuleHolder extends RuleHolder {

    @Override
    public RuleDefinition createRuleDefinition() {
        return RuleDefinition.builder().voucherType(VoucherType.builder().code("00").name("应付").build())
                .addBillSpecification(buildBillSpecificationForSupplierExpenseWithPnRn())
                .addBillSpecification(buildBillSpecificationForSupplierExpenseWithPnRy())
                .addBillSpecification(buildBillSpecificationForPurchaseExpense())
                .addBillSpecification(buildBillSpecificationForFingerWithPnRy())
                .addBillSpecification(buildBillSpecificationForFingerWithPnRn())
                .build();
    }

    private RuleDefinition.BillSpecification buildBillSpecificationForFingerWithPnRn() {
        return RuleDefinition.BillSpecification.builder()
                .billSubTypeEnum(BillSubTypeEnum.FINGER)
                .prepayment(FlagEnum.NO).relateDeal(FlagEnum.NO)
                .voucherItemRule(VoucherItemRule.builder()
                                .creditSubject(VoucherSubjectEnum.COST)
                                .debitSubject(VoucherSubjectEnum.BANK)
                                .creditDigestRule(billItem -> billItem.getBill().getCOMPANY_NAME()
                                + "付" + billItem.getBill().getAPPLY_EMPL_ID() + "个人报销"
                                + ((FingertipItem) billItem.getItem()).getFEE_ITEM_CODE())
                                .debitDigestRule(billItem -> billItem.getBill().getAPPLY_EMPL_ID()
                                + "收到" + billItem.getBill().getCOMPANY_NAME() + "个人报销")
                                .build())
                .build();
    }

定义单据组合

         完成了规则的代码化,接下来就是如何获取到规则。重构前是由凭证服务接收门面服务发送的单据数据,然后根据单据的类型等属性从数据库中获取匹配的规则。下面是原创建凭证的方法,其他的先放在一边,最扎眼也最让人难以忍受的就是那堆魔数。仅就这个方法而言可优化的地方便一大堆。

public ResponseBuilder create(List<AdCreateRequestVO> adCreateRequestVos) {
        //验证请求
        log.info("凭证生成请求参数验证");
        AccountDocumentCheckUtil.checkCreateRequest(adCreateRequestVos);
        //校验是否已经存在凭证:20191202
        AdQueryVO adQueryVO = new AdQueryVO();
        adQueryVO.setBillNo(adCreateRequestVos.get(0).getBillNo());
        Integer count = adAccountDocumentService.getCount(adQueryVO);
        if (count > 0) {
            log.error("凭证生成调用时,系统已存在凭证,提示用户删除,单据编号:{}", adCreateRequestVos.get(0).getBillNo());
            throw new BizException("请刷新页面,先删除凭证");
        }
        //生成凭证
        log.info("凭证生成开始");
        this.saveAccountDocument(adCreateRequestVos);
        log.info("凭证生成结束");
        if ("3".equals(adCreateRequestVos.get(0).getBillTypeKind()) || BILL_TYPE_KIND_FINGERTIP.equals(adCreateRequestVos.get(0).getBillTypeKind())
                || "19".equals(adCreateRequestVos.get(0).getBillTypeKind())) {
            ResponseBuilder responseBuilder = new ResponseBuilder();
            Integer pushType = BILL_TYPE_KIND_FINGERTIP.equals(adCreateRequestVos.get(0).getBillTypeKind()) ? 1 : 0;
            try {
                 responseBuilder = pushVoucherLogic.pushVoucherByBillNo(adCreateRequestVos.get(0).getBillNo(), pushType, null, "");
            }  catch (Exception e) {
                e.printStackTrace();
                log.error("其他异常,凭证推送失败:", e.getCause());
            }

            if("1".equals(responseBuilder.getStatus()) && "9999".equals(responseBuilder.getErrorCode())){
                log.error("凭证推送失败,未获取到凭证信息");
                throw new BizException(responseBuilder.getErrorMsg());
            }
        }
        //成功返回
        return ResponseBuilder.ok();
    }

       通常采用贫血模型实现获取单据对应规则的方式是这样的,将单据标识传递给一个service类的一个方法,该方法中通过单据标识从数据库中查询单据得到一个POJO,然后返回与POJO中相应属性所匹配规则。但是以DDD为指导来实现上述功能就非如此了。

        单据具有唯一标识,因此它是一个实体,同时也是单据聚合的聚合根。DDD中的实体不仅仅是数据的载体,它还拥有与其所承担职责相符的行为,即方法。这里的方法指的不是setter getter,而是业务逻辑。由于单据的最终目的是生成凭证,凭证的生成依赖规则,匹配规则的属性全部来自于单据实体,如此看来为单据实体添加获取规则的方法就是顺理成章的事了。

public class BillAggregate {

    @Getter
    private final Bill bill;

    public List<?> getItems(VoucherService voucherService) {
        return Items.builder().bill(bill).build().getItems(voucherService);
    }

    public List<RuleHolder> getCreateVoucherRuleHolders() throws RuleConfigurationLoader.NoMatchVoucherTypeException
            , RuleConfigurationLoader.NoMatchedRuleHolderException {

         有了上面的聚合后,获取单据所匹配的规则就变得很容易,只需像下面这样。

BillAggregate billAggregate = billRepository.get(billId);
try {
    billAggregate.getCreateVoucherRuleHolders().forEach(ruleHolder -> {

定义领域服务--凭证Service

        获取到规则后就要依据其生成凭证了。每一个凭证都有与其对应的单据,因此凭证与单据一样也是实体。有两个功能是凭证领域必须要实现的,一个是生成凭证,一个是将凭证推送给财务系统。这两个功能在代码中可以体现为两个方法,那么这两个方法是否也可以同获取凭证规则方法放到单据实体中一样,放到凭证实体中?当然可以这么做,但这肯定是不合理的。如果这两个方法在凭证实体中,从OO的角度看那就是,凭证自己创建自己,凭证自己将自己推送出去,很像抓着自己头发把自己提起来。而且凭证的创建还需要单据实体的配合,那么就需要将单据实体也传给凭证实体,最后还要对凭证的创建结果进行处理,比如创建成功就推送给财务系统,失败则通知相关人员。如果上述全部由凭证实体实现,那么它所承担的职责显然太大了,而且这些职责也不应由它承担。领域服务的救场机会来了。

        当一个方法不适合放入任何实体或值对象中时,或者一个方法需要协调多个聚合的功能才能完成一项任务时,那么领域服务就是它的归宿。我用凭证Service承担了创建凭证的职责。下面是原凭证创建方法和凭证推送及推送结果的处理。虽然下面的代码阅读难度不大,但扩展性很差。 

        log.info("凭证生成开始");
        this.saveAccountDocument(adCreateRequestVos);
        log.info("凭证生成结束");
        if ("3".equals(adCreateRequestVos.get(0).getBillTypeKind()) || BILL_TYPE_KIND_FINGERTIP.equals(adCreateRequestVos.get(0).getBillTypeKind())
                || "19".equals(adCreateRequestVos.get(0).getBillTypeKind())) {
            ResponseBuilder responseBuilder = new ResponseBuilder();
            Integer pushType = BILL_TYPE_KIND_FINGERTIP.equals(adCreateRequestVos.get(0).getBillTypeKind()) ? 1 : 0;
            try {
                 responseBuilder = pushVoucherLogic.pushVoucherByBillNo(adCreateRequestVos.get(0).getBillNo(), pushType, null, "");
            }  catch (Exception e) {
                e.printStackTrace();
                log.error("其他异常,凭证推送失败:", e.getCause());
            }

//以下是pushVoucherLogic.pushVoucherByBillNo()所调用方法的一部分
                try {
                    this.pushVoucher(accountDoc, accountDocInfo, errorMessageList, operatorId, operatorName);
                } catch (Exception ex) {
                    errorMessageList.add("发送凭证失败,异常信息:" + ex.getMessage());
                }

        为提升系统的可扩展性,消除代码间耦合,我识别并提取了“凭证生成结果”和“凭证推送结果”两个领域事件,同时遵循SRP,让凭证service只负责凭证的创建,并将凭证的生成结果以事件形式发布出去,供关注相关事件的handler进行处理。下面是重构后的创建凭证代码。事件的处理是通过观察者模式实现。

        BillAggregate billAggregate = billRepository.get(billId);
        try {
            billAggregate.getCreateVoucherRuleHolders().forEach(ruleHolder -> {
                Voucher voucher = buildVoucher(ruleHolder, billAggregate);
                voucherRepository.save(voucher);
                eventPublisher.publish(VoucherCreateSucceedEvent.builder()
                                .billId(billId).voucherId(voucher.getID()).build());
            });
        } catch (RuleConfigurationLoader.NoMatchVoucherTypeException
                | RuleConfigurationLoader.NoMatchedRuleHolderException e) {
            log.error("Voucher create failed, billId -> [{}]", billId, e);
            eventPublisher.publish(VoucherCreateFailedEvent.builder().billId(billId).message(e.getMessage()).build());
        }

去掉凭证服务

        经过上述重构,凭证服务也就没有了存在的意义,因为它的功能已经全部移到门面服务中。其实这个服务拆分出来就是多余的,说白了就是为了拆分而拆分,而我也很理解拆分出这个服务的哥们儿的目的,毕竟以后出去面试时可以说:“我拆分过服务...”。假如这个系统有一位合格的架构师一定不会允许这样的拆分。

        在我看来,服务拆分无非两种目的,一个是交给不同人或团队维护,降低人之间的耦合;另一个是将一个服务的热点功能拆分为独立服务,以提升性能和稳定性。而该凭证服务不仅没有达到上述两个目的,反而是背道而驰。第一,对于新增规则这种需求要同时修改门面和凭证两个服务,如果这两个服务交由不同团队或人员来维护的话,同一个需求就需要他们共同做出修改,这样的沟通成本其实是可以省掉的。第二,如果凭证服务使用一个独立的数据库实例,那么还可以认为它在系统的稳定性提升方面上发挥了作用,但事实上是它与门面服务共用一个数据库实例,因此拆分出的凭证服务在性能和稳定性方面不仅没有任何贡献,反而徒增了一个故障点,如果凭证服务挂掉,即使门面服务正常运行也不能生成凭证。如果两个服务都是99%,那么当它们共同服务于一个系统时就不再是99%了。

        当然,保留凭证服务也不是不可以,不过需要将门面服务中负责单据数据组织的代码全部移到凭证服务中,门面服务仅需要发送单据标识给凭证服务,然后由凭证服务自己根据单据标识去获取单据然后为其生成凭证,这个折中的方案在实现一个需求时就不用同时修改两个服务的代码,至少提升了服务的内聚性。

结语

        至此大致的重构基本介绍完毕,不过还有很多细节并未说明,比如用约定优于配置原则将单据类型与其不同的明细在一个配置类中对应起来,比如在规则代码化时,用Function类针对不同类型单据定义其规则的“凭证摘要说明”等等,这些都是经过多轮重构后权衡利弊最终选择的设计方案。

        经过上述重构,再添加一个新的凭证生成规则要做的就是在RuleHolder的那些继承类中找到相应的类,然后加上一个单据规格的定义即可。因为这是系统的核心功能,为了保证万无一失,重构后的代码与原代码一起部署,重构代码生成的凭证保存到单独表中,并通过程序与原代码生成的凭证进行自动比对。

        想用好DDD,除了“学”之外,更重要的是“习”。路漫漫其修远兮,逆水行舟不进则退,锻炼好自己,把内卷进行到底。

上一篇:DDD领域驱动设计-概述-Ⅰ


下一篇:阿里高级技术专家谈开源DDD框架:COLA4.0,分离架构和组件