3

上线十年,81万行代码的老系统重构之旅 - 架构 - dbaplus社群:围绕Data、Blockchain...

 1 year ago
source link: https://dbaplus.cn/news-141-5272-1.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

上线十年,81万行代码的老系统重构之旅

肖迪(墨诩) 2023-05-13 09:56:00


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

前言

优酷CRP系统-内容采购版权管理系统,是个存在10年的老系统,技术框架上比较老旧;再加上”人来人往“,必然存在很多”不合理但是能跑“和”不敢改,所以ifelse“等等经典代码,一共81w行java代码,17w的jsp代码。

我在今年全面接手CRP-财务部分,整体目标就是全面推进CRP财务的业财一体进程。而这些遗留的技术问题都是推进进程的挑战,所以CRP财务本财年的技术主题就是“老系统重构”

根据以往的工作经验,面对这样的系统,大开大合的重构改版,带来的往往是更加灾难性的”业务不可用“。所以我们的策略,还是要秉着业务优先的原则,跟随业务新需求来逐步重构

但无论产品还是研发心中都要有同一张大图,我们最终要做成什么样子。然后根据大图划清各个业务模块的边界,在保证不会影响其他模块的运行的前提下,进行重构。

图片

81w行java代码中,其实大部分都是废代码,比如:功能和服务还在但是没有人使用;数据都已经迁移到其他系统,下游也不在实际使用,但依赖还在;有很多job还在运行,但并没有实际的数据产出使用方。

之前重构的时候跟组里同学开玩笑说“如果随机注释掉一个service中是所有方法实现,系统大概率还是work的”,虽然我们不会这样做,但可能是真的。

对于这样的系统,重构的策略如果是重新梳理所有服务的使用情况,无疑是成本特别高的,roi很低。所以应该按需重构和迁移,并保证下游依赖方的不需要做任何改动。

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

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

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

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

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

图片

一、付款要解决的业务问题

付款主要解决两个问题:0资损与流程效率

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

  • 给谁付:收款人是谁?是否有财务或者法务上的风险?以及需要验证对方提供的发票;



  • 为谁付:决定了付款的成本归属,归属到节目、部门或者财务口径的入账科目上;



  • 付多少钱:是否存在应收款和应付款可以互抵的情况?付款依据是什么?税费如何计算?



  • 怎么付:通过什么方式支付,先票后款还是先款后票,是否支持预约付款?



  • 能不能付:根据不同业务场景以及金额,流转到不同的审批人进行审批。

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

0资损:

  • 信息校验:很多基础信息的校验,最基本的不能付错人;



  • 风险拦截:包括风险供应商拦截和风险金额的拦截;



  • 金额精准:依据合同、账单、项目等计算出应付金额,然后进行对抵和税费计算(如有)



  • 金额依据状态一致:既然金额的精准决定了最多付多少钱,就要保证金额依据与付款单的状态一致性。

提高流程效率:

  • 自动凭证入账



  • 多种付款方式的支持



  • 快捷的流程审批

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

二、付款的技术痛点



1、代码臃肿,扩展性低

付款有个特点,没有很复杂的业务流程,但是涉及到资金,在付款之前需要做很多的金额计算和风险校验。而且另外一个特点,付款作为一个工具性质的模块,会接入很多业务方。

不同的业务,在金额计算、风险校验等流程上基本一致,但实际接入实现的时候,会有或多或少的差别(比如,付款金额的依据上,主客和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);    }}



2、逻辑不收敛、复用性低

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

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

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

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

大家应该都听说过“六边形架构”或者“COLA框架”,具体的概念我就不在这里详述了,我也只是借cola的图来解释一下我们重构是要遵循的准则。

在App层将executor分为query和command,我们上一节已经通过从上而下的方法将command的结构搭建起来。接下来我们要遵守的准则是:Command的实现不能穿透Domain层来直接调用dao,而是把所有的逻辑都收敛到domain和domainService里,由domain层来通过依赖反转的方式来操作数据库。而为了应对复杂的查询(如列表分页查询等场景),Query是可以直接访问Infrastructure层调用dao中的select***方法的。

为了遵守这个准则,我们可以通过maven的多module的依赖关系来实现,或者直接通过组内约定,通过建package来保证都是可以的。

图片

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

//domainService@Servicepublic 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执行@Componentpublic 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(){...}  }



3、审批流技术框架太老

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

图片

一个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***}



//工厂模式直接调用策略实现类@Servicepublic 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-付款模块的改造,总结和抽象了一些老系统改造的方法。重构第一原则是以业务为中心,找到各自业务的痛点与特点,才会有针对性有效的方法。

对于付款的问题:

  • 代码臃肿扩展性低:通过从上而下的流程拆解来解决;



  • 逻辑不收敛复用性低:通过架构隔离与能力下沉来解决;



  • 技术框架老旧:通过复用轮子和设计模式的使用来解决。

希望能对遇到类似问题的同学有所帮助。

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



作者丨肖迪(墨诩) 来源丨公众号:阿里开发者(ID:ali_tech) dbaplus社群欢迎广大技术人员投稿,投稿邮箱:[email protected]


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK