天天看点

代码重构,越早越好

       还记得当年刚开始工作的时候,不熟悉设计模式,不了解阿里开发规范。参与的第一个正式项目是一款人脸实名认证APP,主要负责底层逻辑实现。没有模板,不考虑代理,从下单到支付,从第三方API调用到本地认证对比逻辑处理,全都在一个service,一个主方法中。随着业务的持续发展,各种附加逻辑的持续迭代,半年后这个service就迭代到了上千行代码,主方法好几百行代码。尴尬的是这个业务发展的还不错,是我们当时的一个主营业务,后面公司人越来越多,我也负责别的业务去了,不时就有新人需要去熟悉实名的业务,去读实名业务的代码,听着别人对实名业务代码的吐槽与调侃,说不尴尬那肯定是在自欺欺人了。这个事情对我的影响挺大,也使得我在后来的工作生涯中,更加关注编码的规范以及代码的重构。

       开发规范以及代码重构是一个老生长谈的问题了,每个公司的技术部门都会对这一块再三强调。开发规范这块目前业界的一个标杆就是阿里开发规范,这块就不再赘述了,今天主要是想总结一下我本人在代码重构这块在这些年的工作中的一些个人经验与心得。

      首先,先总结下三个W--what , why , when。

       那么,什么是代码重构呢?代码重构就是在不改变代码本身功能的前提下对代码的结构及内部实现进行合理的优化调整,提升代码的可读性,可靠性,可维护性以及可扩展性。注意这里说的不是项目或者系统重构,项目或者系统的重构更多的是架构层次的调整,虽然有技术方面设计不合理或者技术升级方面的原因导致的系统重构,但更多的还是业务需求变更导致的架构重构。

       为什么要进行代码重构?前文已说,代码重构本质上是为了提升代码的可读性,可靠性,可维护性以及可扩展性。直白点的说,就是让后来人可以看得懂你写的什么东西,接手你的业务的时候少一点吐槽,以及在扩展新功能的时候多一丢惊喜。当我们需要对某些代码进行重构的时候,那这部分的代码肯定或多或少都有了一些“坏味道”,常见的主要有过大的接口,过大的类,过长的方法,重复的代码,重复的逻辑,重复的接口,必要性判断缺失,参数列过长等。

       什么时候进行代码重构?代码重构不是系统重构,一般不会单独立项,所以合适的代码重构时机是在项目迭代的时候进行,这样不需要额外的开发、测试时间,不需要单独的系统发布重启,无论是在用户体验还是技术资源消耗上都是比较理想的。最重要的是,代码的重构一定要在“味道还小”的时候进行,越早越好。很多“坏味道”代码的产生无外乎“时间紧,没时间了”、“下一次再弄”、“以后再说”、“这不是我的,我不管”、“太乱了,算了,我自己写一个吧”、“之前的人就这样做的,我也这样做”------技术都知道这样是不对的,但是能做到“越早越好”的却不多,这也是“坏味道”代码越来越多,越来越大的原因。

       最后,如何进行代码重构?代码重构的出发点是减少项目中的坏味道,目的是为了提升代码的可读性,可靠性,可维护性,可扩展性,这与Java开发规范以及设计模式的初衷是一致的。所以我们进行代码重构的基本思想还是Java本身的抽象、封装、继承、多态,基本原则就是设计模式六大原则,基本手段包括Java的特性,各种开源工具,23种设计模式等等。

       我个人将代码重构分成三步走,分别是拆,合,重构。

       第一步:拆----将大的接口拆小,将大的类拆小,将大的方法拆小。大接口,大类,大方法是比较常见的一种编码坏味道,也是大多数后来者吐槽颇多的地方。有的接口类中大大小小接口有几十上百,且不说实现类该有多大,单单是从接口类中寻找某一个方法都需要滚动半天,这种情况下最好是对该接口进行合理的拆分。拆分的方式比较灵活,可以从业务上拆分,比如把PayementService拆分为BusiAPaymentService,BusiBPaymentService;也可以从功能上进行拆分,比如把PayementService拆分为BusiPaymentService,TaskPaymentService等,尽可能实现接口功能的纯粹与独立。对于过大的类一方面可以考虑拆分成不同的业务类或者功能类,另一方面可以考虑多使用工具类,组件类,处理器等。具体的说,我们可以把一些使用频率较高的、和具体业务不直接相关的方法封装成工具类,可以把某一些具有高度共性操作并且和业务直接相关的方法封装成组件类,比如处理OSS文件读取及下载的OSSComponent类以及管理异步任务的开启,结束,失败消息通知等功能的TaskComponent类。还可以把某些特殊逻辑处理封装成处理器,比如负责账单资金单生成的SellerCashPaymentHandler等。对于大的方法,可以剥离出主流程,不同流程拆分出不同的方法,凸出主逻辑,明确函数功能,尽量保证函数功能独立,逻辑条理清晰。

       第二步:合---- 将重复的代码合成方法,将重复的方法合到基类。阿里开发规范的插件可以很容易看出哪些代码是重复的。如果一段代码在项目中出现的次数超过2次,那么就有必要将这段代码进行封装---如果只是本类中使用那么就封装类的私有方法,如果是多类共享,那就要考虑提取出这些类的共同属性或方法封装基类,由各类继承;同时,不要总想着表现自己,避免重复造轮子,尽可能的重用开源或者公司封装的工具类;另外,对于重复或者近似接口,这块主要是dao接口,可以考虑整合为通用接口,避免使用多参数的形式定义dao,当强求参数超过2个的时候就考虑使用对象的形式进行数据库操作。

       第三步:重构---用更简洁的、更合理的、更有扩展性的实现循序渐进的替换低效的、呆板的,生硬的实现。拆和合说是重构的手段,实际上更像是重构前的准备,它们更多的是提升了代码的可阅读性,为接下来的代码结构上的优化做准备。重构更多的是借助于框架的各种特性以及各种设计模式,举一些常见的处理方式:

1、使用Spring框架的全局异常替换传统的高重复性的错误码处理方案;

2、使用dubbo的过滤器 + ThreadLocal 方式实现消息链路追踪替换接口传参;

3、尽可能使用设计模式----设计模式是一套被反复使用、多数人知晓的、经过分类编目的、代码设计经验的总结,项目中合理的运用设计模式可以十分完美的提升系统的可读性,可靠性和扩展性,举例----结算系统的异步任务处理器:

      结算系统的异步任务执行器都是仅有一个execute方法的接口,如下:

public interface CodBillConfirmMainTask {

    /**
     * COD账单确认
     * @param taskId
     * @return
     */
    public String execute(Long taskId);
}
           

它们的实现具有高度一致性:

1)查询asy_task表中task信息;

2)任务信息校验;

3)启动任务;

4)线程池执行任务;

5)响应调用方;

6)任务执行完毕更新任务状态,如果有异常则发送企业微信通知消息。

     这是一个典型的模板模式使用场景,除了第4步中的具体任务执行,其他步骤几乎固定,因此考虑使用模板模式:

@Slf4j
public abstract class BaseMainTask {

    public String execute(Long taskId) {
        if (null == taskId) {
            throw new BusinessException("taskId is null");
        }

        //查询任务
        AsyTaskDO task = taskCompenet.getAsyTaskMain(taskId);

        log.info("开始执行任务[{}], 任务对象:{}", taskId, JSON.toJSONString(task));

        //任务校验
        if (!getTaskType().getCode().equals(task.getTaskType())) {
            throw new BusinessException("task type is not " + getTaskType());
        }

        if (!getTaskModel().getCode().equals(task.getTaskModel())) {
            throw new BusinessException("task model is not " + getTaskModel());
        }

        if (!AsyTaskStateEnum.WAITING.getCode().equals(task.getState())) {
            throw new BusinessException("task state is not waiting");
        }

        //任务状态更新为处理中
        taskCompenet.startMainTask(taskId);

        AtomicInteger retries = new AtomicInteger(0); //重试次数
        //异步执行
        asyTaskExecutor.execute(() -> {
            try {
                processTask(task);
            } catch (Exception e) {
                log.error("任务第[{}]次理失败", retries.get() + 1, e);
                sendAlertMsg(task.getTaskModel(), task.getTaskName(), retries.get() + 1, e);
                taskCompenet.failMainTask(taskId, e.getMessage());
                throw new BusinessException("task process fail");
            }
        });

        return SUCCESS;
    }

    protected abstract AsyTaskModelEnum getTaskModel();

    protected abstract AsyTaskTypeEnum getTaskType();

    protected abstract void processTask(AsyTaskDO task) throws Exception;
}
           

4、对于具有扩展可能并且处理逻辑基本一致的业务,尽量抽象出与具体业务无关的逻辑进行封装,便于后续的扩展。

       结算系统的seller资金单生成逻辑,最开始只涉及到POD业务和COD业务,后续增加了penalty业务,接下来增加了TDS业务,这些业务之间的资金余额和押金会按一定规则权重进行抵扣销账:当PPD资金余额为负时,依次使用penalty、COD、TDS的正数资金余额进行抵账;PPD处理完后再依次进行penalty,COD,TDS的抵账操作。此处如果生硬的使用PPD,COD,penalty,TDS等具体业务进行逻辑处理,不但会出现大量if判断,大量重复代码,最重要的是一旦有新的业务加入或者旧的业务去除,或者业务权重及顺序调整,都会导致代码重复开发测试,扩展性太差,考虑将该过程的对账业务进行抽象,对账过程进行封装,如下:

@Data
@Builder
public static class CalTmp{

    /**
     * 字段: biz_type, 业务类型1:cod 2:ppd 3:penalty
     */
    private Integer bizType;

    /**
     * 字段: 冲抵计算金额 自后金额为变动结果
     */
    private BigDecimal billBalance;

    /**
     * 字段: hedge_type, 冲抵类型 参考biz_type
     */
    private List<Integer> hedgeType;

    /**
     * 字段: hedge_amount, 冲抵金额
     */
    private List<BigDecimal> hedgeAmount;

    /**
     * 字段: 对冲计算金额原始 不变
     */
    private BigDecimal finalBillBalance;

    /**
     * 押金
     */
    private BigDecimal depositBalance;

    /**
     * 权重优先级 越小越大
     */
    private Integer weight;

    private CalTmp next;

}
           
public enum CashCalcBizEnum {
    COD(1,"cod",2),
    PPD(2,"ppd",0),
    PENALTY(3,"penalty",1),
    TDS(4,"tds",4),
    ;
   ...
           

}

/**
 * 按业务类型及权限配置进行金额抵扣
 * @param amountMap
 * @return
 */
public List<CalTmp> hedge(Map<Integer, Amount> amountMap) {

    List<CalTmp> ori = new ArrayList<>();

    if(CollectionUtils.isEmpty(amountMap)){
        return ori;
    }

    amountMap.keySet().stream().forEach(key -> {
        Amount amount = amountMap.get(key);
        CashCalcBizEnum cashCalcBizEnum = CashCalcBizEnum.getCashCalcBizEnum(key);
        ori.add(CalTmp.builder().bizType(cashCalcBizEnum.getCode()).billBalance(amount.getBalanceAmt()).finalBillBalance(amount.getBalanceAmt()).depositBalance(amount.getDepositAmt()).hedgeType(new ArrayList<>()).hedgeAmount(new ArrayList<>()).weight(cashCalcBizEnum.getWeight()).build());
    });

    //按权重升序
    ori.sort(Comparator.comparingInt(CalTmp::getWeight));
    for(int i=0; i<ori.size()-1; i++){
        CalTmp tmp = ori.get(i);
        tmp.setNext(ori.get(i+1));
    }

    ori.forEach(e->hed(e,ori.get(0)));
    return ori;
}
           
/**
 * 递归进行抵扣
 * @param cmp
 * @param next
 */
private void hed(CalTmp cmp,CalTmp next){
    if(cmp ==null) return;
    if(next ==null) return;

    if (cmp.getBillBalance().compareTo(BigDecimal.ZERO) == -1 && cmp.getBizType() != next.getBizType()) {
            if (next.getBillBalance().compareTo(BigDecimal.ZERO) == 1) {
                if (cmp.getBillBalance().abs().compareTo(next.getBillBalance()) >= 0) {
                    cmp.getHedgeType().add(next.bizType);
                    cmp.getHedgeAmount().add(next.getBillBalance());
                    cmp.setBillBalance(cmp.getBillBalance().add(next.getBillBalance()));
                    //置为0 全部被抵扣
                    next.setBillBalance(BigDecimal.ZERO);
                } else {
                    cmp.getHedgeType().add(next.bizType);
                    cmp.getHedgeAmount().add(cmp.getBillBalance().negate());
                    next.setBillBalance(next.getBillBalance().add(cmp.getBillBalance()));
                    cmp.setBillBalance(BigDecimal.ZERO);
                }
            }
        }
    hed(cmp,next.getNext());
}
           

       写在最后,其实最好的代码重构就是养成良好的开发编码习惯,严格遵守开发规范,积极主动及时尽早的解决代码问题,不给自己找理由,不给懒惰找借口,发现问题尽早处理,不让小问题变成大问题,不让大问题变成烂问题,尽早处理,方便他人,成就自己。