代碼重構,越早越好

       還記得當年剛開始工作的時候,不熟悉設計模式,不瞭解阿里開發規範。參與的第一個正式項目是一款人臉實名認證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());
}

 

       寫在最後,其實最好的代碼重構就是養成良好的開發編碼習慣,嚴格遵守開發規範,積極主動及時儘早的解決代碼問題,不給自己找理由,不給懶惰找藉口,發現問題儘早處理,不讓小問題變成大問題,不讓大問題變成爛問題,儘早處理,方便他人,成就自己。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章