上线十年,81万行Java代码的老系统如何重构(2)

简介: 上线十年,81万行Java代码的老系统如何重构

--解决方案:架构隔离、能力下沉

大家应该都听说过“六边形架构”或者“COLA框架”,具体的概念我就不在这里详述了,我也只是借这着cola的图来解释一下我们重构是要遵循的准则。在App层将executor分为query和command,我们上一节已经通过从上而下的方法将command的结构搭建起来。那接下来我们要遵守的准则是:Command的实现不能穿透Domain层来直接调用dao,而是把所有的逻辑都收敛到domain和domainService里,由domain层来通过依赖反转的方式来操作数据库。而为了应对复杂的查询(如列表分页查询等场景),Query是可以直接访问Infrastructure层调用dao中的select***方法的。为了遵守这个准则,我们可以通过maven的多module的依赖关系来实现,或者直接通过组内约定,通过建package来保证都是可以的。

将逻辑都收敛到domain中无疑是可以增强复用性的,不用再多说;通过实体操作内聚的办法来收敛之后,还有另一个好处,就是代码看起来会更具备业务表达能力。下面代码是收款的时候写的代码↓↓↓↓↓↓


//domainService
@Service
public class CashCollectionReceiptService {
    @Autowired
    private CashCollectionReceiptRepository receiptRepository;
    @Autowired
    private IContractGateway contractGateway;
    /**
     * 确认回款
     */
    public void confirmCollection(CashCollectionReceipt receipt){
        isCollectionBills(receipt.getBillList());
        receipt.canConfirm();
        receiptRepository.confirm(receipt);
    }
 }
 //App层Command执行
@Component
public class CollectionConfirmCmdExe {
         List<CashCollectionBill> billList = billGateway.findByIdList(dto.getBillIdList());
            AbstractReceiptAmountProcessor amountProcess = new OttCollectionReceiptAmountProcessor();
            CashCollectionReceipt receipt = CashCollectionReceipt.builder()
                .billList(billList)
                .totalAmount(dto.getTotalAmount())
                .receiptAmountProcessor(amountProcess)
                .build();
            receiptService.confirmCollection(receipt);
 }


懂行的一定能看出来我马上要提到DDD了,是的!DDD的整个使用过程是要先通过事件风暴或者use case出发,抽象出用到的实体以及他们之间的关系,然后来进行领域划分。但我们这是在重构老系统,如果我们完全按照DDD的方式来重构,那就回到了最开始我们担心的问题,推倒重来只会带来更灾难的“业务不可用”。所以在重构老系统的时候,我们应该怎么使用DDD?


我特别同意COLA作者张建飞大佬的观点,不要为了DDD而DDD。

COLA可以称其为分层框架但并也不是DDD框架,Domain层使用全部或者部分DDD标准都是可以的,只要Coworker拉通统一即可。DDD只是一个规范标准,是手段不是目标,不管通过什么样的方式,只要能保证能力都是内聚可复用就可以。

在重构的时候,我们面临的状况是已经有大量的逻辑代码,我并不提倡把service中所有方法全部梳理,然后将这些方法全部复制粘贴到重新定义的domain或者domainService中,这样会增加重构的风险和测试成本,ROI很低。我们只需合并同类项,将出现的重复代码,作为通用能力下沉到domain层。

指导下沉有两个关键指标:代码的复用性和内聚性。

复用性是告诉我们When(什么时候该下沉了),即有重复代码的时候。内聚性是告诉我们How(要下沉到哪里),功能有没有内聚到恰当的实体上,有没有放到合适的层次上(因为Domain层的能力也是有两个层次的,一个是Domain Service这是相对比较粗的粒度,另一个是Domain的Model这个是最细粒度的复用)。



按照这个原则在重构付款代码,截止目前为止(重构没有完全完成),也只有俩个方法下沉到了Domain中。而其他的实体也并没有放到聚合根里,比如说付款关联账单等,还是使用之前的实现方式,所有的方法都收敛在各自的service类中,比如:PaymentAssociatedBillComponent。

Payment{
  ***省略属性定义***
    public BigDecimal getPaymentAmountRmb() {
        return BigDecimals.multiply(paymentAmount, expectExchangeRate);
    }
    /**
     * 综合付款状态
     * @return
     */
    public void initUnionStatusEnum(){...}
  }
  • 审批流技术框架太老

前言介绍过CRP是一个存在了10年的老系统,系统的工作流审批框架用的不是集团的bpms,而是Activity5(2010年发布,怎么说呢,比我工作年限还要长😂)。由于activity只管流程编排,几乎所有的动作实现都要使用者做开发,再加上“前任”们没有做抽象和解耦,审批逻辑和业务逻辑全都耦合在同一个类中。带大家近距离感受一下历代“继承人”的绝望。

image.png

一个service中4000行代码,641个if else判断;你以为这就完了?同样的类还有10+个,刚举的例子只是bottom。

--解决方案:复用轮子,用好设计模式

复用已有的服务,重构后,审批流迁移到了集团的bpms,并且对动作和回调做了进一步的服务封装。审批流只需要在bpms里配置,并在数据库中注册一下,异步提交,而回调只需要通过hsfprovider的方式部署,加上注册的服务版本即可。



 //异步提交审批流publishEvent
      protected void startBpms(PaymentSubmitContext context) {
        //异步提交审批流
        BpmsDto bpmsDto = new BpmsDto();
        bpmsDto.setPaymentId(context.getPayment().getId());
        bpmsDto.setProcessType(PaymentBpmsEnum.PAYMENT_COMMON_APPROVAL.getValue());
        bpmsDto.setWorkNo(context.getPayment().getApplyWorkNo());
        BpmsEvent event = new BpmsEvent(bpmsDto);
        eventPublisher.publishEvent(event);
    }
    //BpmsEventListener
    @Override
    public Result<String> submitBpms(BpmsDto bpmsDto) {
        try {
            Payment payment = paymentDao.getPaymentById(bpmsDto.getPaymentId());
            if (payment == null) {
                return Result.valueOfERROR("付款不存在");
            }
            String billId = billHelper.submitApproval(payment, bpmsDto.getWorkNo(), bpmsDto.getProcessType());
            return Result.valueOfOK(billId);
        } catch (Exception e) {
            log.error("submitBpms_error e={}", e);
        }
        return Result.valueOfERROR("error");
    }


@HSFProvider(serviceInterface = BillCallBackService.class, serviceVersion = "CRP_PAYMENT_BILL_CALL_BACK_1.0.0")public class PaymentBillCallBackServiceImpl implements BillCallBackService {    @Override    public Result<Void> callBackCommit(String billId, String bizId) {...}
    /**     * 审批不同意     */    @Override    public Result<Void> callBackDisagree(String billId, String bizId) {...}
    /**     * 审批同意     */    @Override    public Result<Void> callBackAgree(String billId, String bizId) {...}
    /**     * 审批流终止     */    @Override    public Result<Void> callBackRecall(String billId, String bizId) {...}
    @Override    public Result<Void> callBackCancel(String billId, String bizId) {... }
    @Override    public Map<String, String> getProcessInitData(String billId, String bizId) {...}


这样,整个审批流的流转全部有审批单据服务封装,做到了很好的解耦;与业务状态相关action代码都写在回调中,但付款的审批流程特别长,而且对应了很多业务操作,这是600+个ifelse判断的主要来源。这个时候可以使用工厂+策略模式干掉ifelse判断

//审批流执行抽象策略类
public abstract class BpmsAbstractExecutor {
    public abstract void execute();
}
//财务审批
@Service("finaceExecutor")
public class FinanceExecutor extends BpmsAbstractExecutor {
    @Override
    public void execute() {...}
}
//税务审批
@Service("taxExecutor")
public class TaxExecutor extends BpmsAbstractExecutor {
    @Override
    public void execute() {...}
}

//用枚举类注册服务的策略实现类
public enum BpmsExecutorEnum {
    FINANCE("finace", "finaceExecutor", "财务审批"),
    TAX("tax", "taxExecutor", "税务审批");
    private final String key;
    private final String executorName;
    private final String desc;
    BpmsExecutorEnum(String key, String executorName, String desc) {
        this.executorName = executorName;
        this.key = key;
        this.desc = desc;
    }
***省略getter***
}

//工厂模式直接调用策略实现类
@Service
public class BpmsExecutorFactory {
    private static final Map<String, String> executorNames = new ConcurrentHashMap<>();
    static {
        BpmsExecutorEnum[] executorEnums = BpmsExecutorEnum.values();
        for (BpmsExecutorEnum executorEnum : executorEnums) {
            executorNames.put(executorEnum.getKey()), executorEnum.getExecutorName());
        }
    }
    @Autowired
    private Map<String, BpmsAbstractExecutor> executorMap;
    public void execute(String groupNameEn) {
        String executorName = executorNames.get(groupNameEn);
        if (StringUtils.isEmpty(executorName)) {
            return;
        }
        BpmsAbstractExecutor executor = executorMap.get(executorName);
        if (Objects.isNull(executor)) {
            return;
        }
        executor.execute();
    }
}


策略+工厂模式比较适用于审批操作的业务处理特别多,并且业务复杂的情况,正好适用于解决4000+行代码,600+ifelse判断的老代码重构。如果只是简单的逻辑重构、ifelse没有很多的话,在service类中extract几个private方法就好了,毕竟策略+工厂模式会引入额外的类和入口,使用不当也会增加程序复杂度。

这样,通过老技术框架的迁移、服务封装+设计模式进行了重构,4000+行代码其实还在,只不过现在已经拆分到各自单一职责的模块中,而找到他们的入口文件只有不到200行,这样就可以做到清晰可维护了。


如何保证改动的质量问题


有人问到了这个问题,简单整理了一下方案

付款这个功能,如果出现质量问题很有可能会产生资损。为了拆解这个问题还是从业务出发,付款中有俩个非常重要的风险因素,只要卡住这俩个点就不会出大问题,

1、付款单 | 付款凭据 的金额和状态是否正确;

2、下游依赖是否符合预期;

解决方案如下:

1、规则校验这边是用“资损平台”进行规则配置,可以通过接口、sql和binlog变动来做编排,用来监控重点1

2、冒烟卡口主要用在对下游提供服务的hsf服务上,用来监控重点2

3、单测:单元测试在之前“流水账代码”阶段比较难做单测,尤其迭代多了之后,ifelse膨胀,mock工作量巨大;现在改成分层架构+DDD,只把单测用在核心业务逻辑上,mock会更简单也更有效。目前单测也只用在新业务上,整体覆盖率还很低很低。


结尾


这个财年借着CRP-付款模块的改造,总结和抽象了一些老系统改造的方法。重构第一原则是以业务为中心,找到各自业务的痛点与特点,才会有针对性有效的方法。对于付款的问题,1、代码臃肿扩展性低:通过从上而下的流程拆解来解决;2、逻辑不收敛复用性低:通过架构隔离与能力下沉来解决;3、技术框架老旧:通过复用轮子和设计模式的使用来解决。希望能对遇到类似问题的同学有所帮助。

最后的最后,CRP业务包含了合同、结算、财务三大业务,我只是负责其中一块,81w行代码重构不是靠我一个人;复用的审批流封装的服务也是上一任“继承人”留下的特别棒的抽象服务,起这个标题也只是希望大家能关注到多提意见和建议。老系统问题的形成是个历史积累的过程,而后续重构的人最重要的是要有好的心态以及“业务枷锁”下的极致技术追求

相关文章
|
2天前
|
存储 Java 数据安全/隐私保护
Java中的域,什么是域?计算机语言中的域是什么?(有代码实例)
文章解释了Java中域的概念,包括实例域、静态域、常量域和局部域,以及它们的特点和使用场景。
9 2
|
1天前
|
安全 算法 Java
数据库信息/密码加盐加密 —— Java代码手写+集成两种方式,手把手教学!保证能用!
本文提供了在数据库中对密码等敏感信息进行加盐加密的详细教程,包括手写MD5加密算法和使用Spring Security的BCryptPasswordEncoder进行加密,并强调了使用BCryptPasswordEncoder时需要注意的Spring Security配置问题。
18 0
数据库信息/密码加盐加密 —— Java代码手写+集成两种方式,手把手教学!保证能用!
|
2天前
|
Java
Java关键字 —— super 与 this 详细解释!一看就懂 有代码实例运行!
本文介绍了Java中this和super关键字的用法,包括在构造方法中使用this来区分参数和成员变量、使用super调用父类构造方法和方法,以及它们在同一个方法中同时使用的场景。
10 0
Java关键字 —— super 与 this 详细解释!一看就懂 有代码实例运行!
|
2天前
|
Java
Java关键字 —— static 与 final 详细解释!一看就懂 有代码实例运行!
这篇文章详细解释了Java中static和final关键字的用法,包括它们修饰类、方法、变量和代码块时的行为,并通过代码示例展示了它们的具体应用。
18 0
Java关键字 —— static 与 final 详细解释!一看就懂 有代码实例运行!
|
2天前
|
算法 Java 测试技术
数据结构 —— Java自定义代码实现顺序表,包含测试用例以及ArrayList的使用以及相关算法题
文章详细介绍了如何用Java自定义实现一个顺序表类,包括插入、删除、获取数据元素、求数据个数等功能,并对顺序表进行了测试,最后还提及了Java中自带的顺序表实现类ArrayList。
5 0
|
2月前
|
Java 数据安全/隐私保护
Java代码的执行顺序和构造方法
构造方法是类的一种特殊方法,用于初始化新对象。在 Java 中,每个类默认都有一个与类名同名的构造方法,无需返回类型。构造方法不能用 static、final、synchronized、abstract 或 native 修饰。它可以重载,通过不同的参数列表实现多种初始化方式。构造方法在对象实例化时自动调用,若未显式声明,默认提供一个无参构造方法。构造代码块和静态代码块分别用于对象和类的初始化,按特定顺序执行。
23 0
|
4月前
|
Java
Java代码的执行顺序
Java代码的执行顺序
24 1
|
Java
Java基础-代码执行顺序(重要)
Java代码初始化顺序:     1.由 static 关键字修饰的(如:类变量(静态变量)、静态代码块)将在类被初始化创建实例对象之前被初始化,而且是按顺序从上到下依次被执行。静态(类变量、静态代码块)属于类本身,不依赖于类的实例。     2.没有 static 关键字修饰的(如:实例变量(非静态变量)、非静态代码块)初始化实际上是会被提取到类的构造器中被执行的,但是会比类构造器中的代码
2349 1
LearnJava(四) | Java代码块执行顺序测试
最近笔试常常遇到考察Java代码块执行顺序的题目,网上查看博客错漏百出,特地自己测试了一下。 如有错漏,希望路过的大佬指出来,以便我进行更改。   先上代码吧! public class ClassA { private static St...
944 0
|
Java 机器学习/深度学习