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

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



阿里妹导读


本文借着CRP-付款模块的改造,总结和抽象了一些老系统改造的方法。希望能对遇到类似问题的同学有所帮助。

前言

优酷CRP系统-内容采购版权管理系统,是个存在10年的老系统,技术框架上比较老旧;再加上”人来人往“,必然存在很多”不合理但是能跑“和”不敢改,所以ifelse“等等经典代码,一共81w行java代码,17w的jsp代码。我在今年全面接手CRP-财务部分,整体目标就是全面推进CRP财务的业财一体进程。而这些遗留的技术问题都是推进进程的挑战,所以CRP财务本财年的技术主题就是“老系统重构”。根据以往的工作经验,面对这样的系统,大开大合的重构改版,带来的往往是更加灾难性的”业务不可用“;所以我们的策略,还是要秉着业务优先的原则,跟随业务新需求来逐步重构。但无论产品还是研发心中都要有同一张大图,我们最终要做成什么样子。然后根据大图划清各个业务模块的边界,在保证不会影响其他模块的运行的前提下,进行重构。




81w行java代码中,其实大部分都是废代码,比如:功能和服务还在但是没有人使用;数据都已经迁移到其他系统,下游也不在实际使用,但依赖还在;有很多job还在运行,但并没有实际的数据产出使用方。之前重构的时候跟组里同学开玩笑说“如果随机注释掉一个service中是所有方法实现,系统大概率还是work的”,虽然我们不会这样做,但可能是真的。对于这样的系统,重构的策略如果是重新梳理所有服务的使用情况,无疑是成本特别高的,roi很低。所以应该按需重构和迁移,并保证下游依赖方的不需要做任何改动。

本篇文章会以其中一个模块“付款”来作为示例,原因有二:

一、本财年付款的改版业务述求比较高,这个S的重构进程较其他模块更快一些;

二、想表达的主题更专注在代码重构方向。付款作为整个优酷运营中比较末端的商业行为,在系统上对于付款依赖的下游系统和模块较少。如果是写“合同迁移和改造”,会更偏架构重构和老系统、数据的迁移方案。

付款模块一共涉及大概3w行左右的代码,首先保证下游依赖的接口都不变,还在原有工程服务,并且将老代码迁移到新的工程下。是否迁移工程取决于与迁移的ROI,我们的老工程的前端是用jsp实现的,现在要做前后端分离,所以老代码迁移到新的工程下。


付款


重构的第一原则是以业务为中心,不要为了重构而重构。先来了解一下付款的业务和业务的痛点。




付款要解决的业务问题


付款主要解决俩个问题:1、0资损;2、流程效率

我通过MECE的从下而上的归纳整理后,审慎判断想法建议的“最小公倍数”的方法,对付款进行梳理,先了解一下付款在做一件什么事,以及如何完成目标?

  • 给谁付:收款人是谁?是否有财务或者法务上的风险?以及需要验证对方提供的发票
  • 为谁付:决定了付款的成本归属,归属到节目、部门或者财务口径的入账科目上
  • 付多少钱:是否存在应收款和应付款可以互抵的情况?付款依据是什么?税费如何计算?
  • 怎么付:通过什么方式支付,先票后款还是先款后票,是否支持预约付款?
  • 能不能付:根据不同业务场景以及金额,流转到不同的审批人进行审批。


将这些要解决的业务问题向上抽象总结,付款要想做到


0资损:

  • 信息校验:很多基础信息的校验,最基本的不能付错人
  • 风险拦截:包括风险供应商拦截和风险金额的拦截
  • 金额精准:依据合同、账单、项目等计算出应付金额,然后进行对抵和税费计算(如有)
  • 金额依据状态一致:既然金额的精准决定了最多付多少钱,就要保证金额依据与付款单的状态一致性

提高流程效率:

  • 自动凭证入账
  • 多种付款方式的支持
  • 快捷的流程审批


到这里应该可以看出来,付款不是一个复杂业务流程的模块,它的核心述求是“稳定”与“可扩展”。从这个季度的需求也可以验证这点。

付款的技术痛点


  • 代码臃肿,扩展性低

付款有个特点,没有很复杂的业务流程,但是涉及到资金,在付款之前需要做很多的金额计算和风险校验。而且另外一个特点,付款作为一个工具性质的模块,会接入很多业务方。不同的业务,在金额计算、风险校验等流程上基本一致,但实际接入实现的时候,会有或多或少的差别(比如,付款金额的依据上,主客和OTT会有不同类型的账单)。可以看出付款这部分对于复用性、扩展性要求是比较高的。现在要接入OTT的付款,我们先来看一下如果继续在老代码上升级,会有哪些问题。


@Override    @Transactional(rollbackFor = Exception.class,transactionManager = "transactionManager2")    public Payment submitPayment(PaymentDto paymentDto, User user) {    **只保留能说明问题的关键代码或者注释,省去前整个方法600行左右**
    ***payment对象初始化代码***
    ...省去60行代码...
    Integer r = paymentDao.insertPayment(paymentDto);
***payment付款依赖对象初始化代码***
    //保存关联节目    playComponent.dealPaymentPlay(paymentDto.getId(), ListUtils.emptyIfNull(paymentDto.getPaymentPlayDtoList()), user);//保存文件appendixComponent.dealFile(paymentDto.getId(), ListUtils.emptyIfNull(paymentDto.getFileDtoList()), user);//保存账单paymentAssociatedBillComponent.dealBill(paymentDto.getId(), ListUtils.emptyIfNull(paymentDto.getBillDtoList()).stream().map(AssociatedBillDto::getBillId).collect(Collectors.toList()));//保存责任人和其他操作人comPermissionComponent.saveComPermission(paymentDto, "ALL");
**第一步做金额和风险校验,为简单只保留注释,省去实现代码**
    //1.校验重复提交
    ...省去5行代码...
    //2.提交前校验
    ...省去20行代码...
    //3.校验账单金额&&所属公司
    ...省去5行代码...
    //4.校验娱乐宝账号
    ...省去1行代码...
    //5.校验付款条件
    checkPayCondition(payment);
//6.校验节目金额if (paymentComponent.needPaymentToPlay(payment.getType())) {    checkPaymentSubject(payment);}
    **校验过程中混入payment对象初始化代码**    CrpContract contract = crpContractDao.getContractById(payment.getContractId());Integer operationFlow = contract.getOperationFlow();payment.setContractOperationFlow(operationFlow);
//7.校验本次申请金额是否超过预期
....省去40行代码...
    //8.仅版权采购合同支持预约付款    if (){    throw new RuntimeException("仅版权采购合同支持预约付款!");}//8.校验预约付款不能选择先款后票if (){    throw new RuntimeException("预约付款仅支持先收票后付款!");}    //9.版权采购&&收款账户国家为CN&&签约币种为RMB 才可以使用预约付款
    ...省去10行代码...
    **payment对象初始化代码**    payment.setApplyDate(new Date());
...省去40行代码...
    **多了一次没有必要的数据库update**    paymentDao.updatePayment(payment);
**payment对象初始化代码**    String actualApplyWorkNo = payment.getActualApplyWorkNo();
...省去10行代码...
    paymentDao.updatePayment(payment);
//异步提交审批流BpmsDto bpmsDto = new BpmsDto();
...省去10行代码...
    return payment;}
private xxx(){}



比较典型的“流水账”代码,最直观会导致的问题就是维护困难,比如想查一个字段不正确的bug,最差情况要通读600+代码(还有部分private方法)。在升级的时候,最容易想到的办法就是继续盖楼(比如代码中调用了俩次        paymentDao.updatePayment(payment),应该就是盖楼的时候,代码复制多了),从而使“泥丸”越滚越大。


第二个问题,扩展性不好。比如接入OTT的时候,账单的数据库表和开放平台的不一致。按照原有的方式,最简单的就是在保存账单的时候用ifelse判断一下,如果是ott的付款单,则保存到ottPaymentAssociatedBill中;或者变化特别大的话,干脆ctrl+c -> ctrl+v ,复制一下类改名叫OttPaymentService,又多了一个600+的大方法,显然不妥。

--解决方案:从上而下的业务流程拆解

所以我们需要对付款的保存提交进行重构,先根据金字塔原理,将付款流程分解为一个有层级结构的金字塔结构。从上而下的进行拆解:

按照这个结构来重新组织代码结构:

付款保存Command :PaymentSaveCmdExe



@Servicepublic class PaymentSaveCmdExe {
    @Autowired    SubmitContextInitPhase initPhase;
    @Autowired    SaveValidatePhase validatePhase;
    @Autowired    SaveProcessPhase processPhase;
    @Transactional(rollbackFor = Exception.class,transactionManager = "transactionManager2")    public PaymentSubmitContext execute(PaymentSubmitCmd cmd){        PaymentSubmitContext context = init(cmd);        validate(context);        process(context);        return context;    }
    private void process(PaymentSubmitContext context) {        processPhase.process(context);    }
    private void validate(PaymentSubmitContext context) {        validatePhase.validate(context);    }
    private PaymentSubmitContext init(PaymentSubmitCmd cmd) {        return initPhase.init(cmd);    }
}

付款提交Command :PaymentSubmitCmdExe



@Servicepublic class PaymentSubmitCmdExe {
    @Autowired    SubmitContextInitPhase initPhase;
    @Autowired    SubmitValidatePhase validatePhase;
    @Qualifier("submitProcessPhase")    @Autowired    SubmitProcessPhase processPhase;
    @Autowired    PaymentSaveCmdExe saveCmdExe;
    @Transactional(rollbackFor = Exception.class,transactionManager = "transactionManager2")    public Payment execute(PaymentSubmitCmd cmd){        PaymentSubmitContext context = saveCmdExe.execute(cmd);        validate(context);        process(context);        return context.getPayment();    }
    private void process(PaymentSubmitContext context) {        processPhase.process(context);    }
    private void validate(PaymentSubmitContext context) {        validatePhase.validate(context);    }
}



这样我们就把程序入口的逻辑写清楚,然后再去拆解phase中的不同步骤,以submitValidatePhase为例:



public class SubmitValidatePhase {  public void validate(PaymentSubmitContext context){        /** validate */        //重复提交校验        duplicateSubmissionValidate(context);        //基础信息校验        baseInfoValidate(context);        //关联账单校验        paymentAssociatedBillValidate(context);        //付款条件校验        paymentConditionValidate(context);        //关联节目校验        paymentAssociatedPlayValidate(context);        //款项类型校验        paymentTypePermissionValidate(context);        //预约付款校验        appointmentPayValidate(context);        //其他校验用于扩展        otherVaidate(context);    }       protected void appointmentPayValidate(PaymentSubmitContext context) {...}     protected void paymentAssociatedBillValidate(PaymentSubmitContext context) {...}
}

整个结构按照金字塔结构来编写,每个类都是对应业务步骤上,运维成本会大幅度下降。


按照这个结构来进行扩展,接入OTT付款的话,只需加个入口Command,和有业务差别的Phase继承原有Phase,并重写差异的方法即可。



@Servicepublic class BorrowingDeductionPaymentSubmitCmdExe extends PaymentSubmitCmdExe {
    @Autowired    SubmitContextInitPhase initPhase;
    @Autowired    SubmitValidatePhase validatePhase;
    @Qualifier("borrowingDeductionSubmitProcessPhase")    @Autowired    SubmitProcessPhase processPhase;    }



@Service@Slf4jpublic class BorrowingDeductionSubmitProcessPhase extends SubmitProcessPhase {    @Autowired    BpmsEventPublisher eventPublisher;
    @Override    @Transactional(rollbackFor = Exception.class,transactionManager = "transactionManager2")    public void process(PaymentSubmitContext context) {        super.process(context);    }
    @Override    public void startBpms(PaymentSubmitContext context) {        //异步提交审批流        BpmsDto bpmsDto = new BpmsDto();        bpmsDto.setPaymentId(context.getPayment().getId());        bpmsDto.setProcessType(PaymentBpmsEnum.OTT_PAYMENT_COMMON_APPROVAL.getValue());        bpmsDto.setWorkNo(context.getPayment().getApplyWorkNo());        BpmsEvent event = new BpmsEvent(bpmsDto);        eventPublisher.publishEvent(event);    }}


  • 逻辑不收敛、复用性低

在所有的业务系统中,实体状态的维护一定是特别重要的一环,付款更甚。由于涉及到往外付钱,所以付款单的状态,以及付款依据的状态(比如说账单是否已付款),都可能会影响到我们是否会重复付款、少付(少付合作方会投诉甚至有法律风险)。

在MVC的架构中,service层是可以引用dao层的,这种方式很灵活,比如在合同的service中,也可以做付款表的状态更新。但这同时也会产生问题,如果我想修改付款单的状态更新逻辑或者加减状态枚举值,我需要找到所有service方法中对于付款单状态的操作,很容易漏掉。甚至我碰见过更夸张的,同一张表的更新sql写在了俩个Mapper中,状态的更新逻辑修改后,漏掉了一个Mapper的sql修改,恰好调用的入口是接mq消息来更新状态,所以发生了非常“诡异”的状态异常。

其实不只是“状态”,任何实体属性都会有一样的问题,导致这个问题的原因就是实体修改逻辑不收敛。有没有一种规范或者架构能帮助开发者避免这个问题。


相关文章
|
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
|
Java 开发者 测试技术
Java编程——重构-你为什么要对参数赋值?
对于学过多门语言的开发者来说,应该都明白在不同语言中对参数赋值有着不同的意义, 总的来说参数有值传递和引用传递两种, 而在Java中只有值传递的概念。
1315 0
|
12天前
|
安全 Java 调度
Java编程时多线程操作单核服务器可以不加锁吗?
Java编程时多线程操作单核服务器可以不加锁吗?
32 2
|
4天前
|
Java 数据库 UED
Java的多线程有什么用
Java的多线程技术广泛应用于提升程序性能和用户体验,具体包括:提高性能,通过并行执行充分利用多核CPU;保持响应性,使用户界面在执行耗时操作时仍流畅交互;资源共享,多个线程共享同一内存空间以协同工作;并发处理,高效管理多个客户端请求;定时任务,利用`ScheduledExecutorService`实现周期性操作;任务分解,将大任务拆分以加速计算。多线程尤其适用于高并发和并行处理场景。
|
16天前
|
存储 缓存 Java
java线程内存模型底层实现原理
java线程内存模型底层实现原理
java线程内存模型底层实现原理
|
20天前
|
缓存 Java 应用服务中间件
Java虚拟线程探究与性能解析
本文主要介绍了阿里云在Java-虚拟-线程任务中的新进展和技术细节。