開發好能重構的程式碼,都是這麼幹的

華為雲開發者社群發表於2021-11-30
摘要:絕大多數碼農沒日沒夜被需求憋著肝出來的程式碼,無論有多麼的吭哧癟肚,都不可能有重構,只有重新寫。

本文分享自華為雲社群《還重構?就你那程式碼只能鏟了重寫!》,作者:小傅哥。

一、前言

我們不一樣,就你沒物件! 對,你是程式導向程式設計的!

我說的,絕大多數碼農沒日沒夜被需求憋著肝出來的程式碼,無論有多麼的吭哧癟肚,都不可能有重構,只有重新寫。為什麼?因為重新寫所花的時間成本,遠比重構一份已經爛成團的程式碼,要節省時間。但誰又不敢保證重寫完的程式碼,就比之前能好多少,況且還要承擔著重寫後的程式碼事故風險和幾乎體現不出來的業務價值!

雖然程式碼是給機器執行的,但同樣也是給人看的,並且隨著每次需求的迭代、變更、升級,都需要研發人員對同一份程式碼進行多次開發和上線,那麼這裡就會涉及到可維護、易擴充套件、好交接的特點。

而那些不合理分層實現程式碼邏輯、不寫程式碼註釋、不按規範提交、不做格式化、命名隨意甚至把 queryBatch 寫成 queryBitch 的,都會造成後續程式碼沒法重構的問題。那麼接下來我們就分別介紹下,開發好能重構的程式碼,都要怎麼幹!

開發好能重構的程式碼,都是這麼幹的

二、程式碼優化

1. 約定規範

# 提交:主要 type
feat:     增加新功能
fix:      修復bug

# 提交:特殊 type
docs:     只改動了文件相關的內容
style:    不影響程式碼含義的改動,例如去掉空格、改變縮排、增刪分號
build:    構造工具的或者外部依賴的改動,例如webpack,npm
refactor: 程式碼重構時使用
revert:   執行git revert列印的message

# 提交:暫不使用type
test:     新增測試或者修改現有測試
perf:     提高效能的改動
ci:       與CI(持續整合服務)有關的改動
chore:    不修改src或者test的其餘修改,例如構建過程或輔助工具的變動

# 註釋:類註釋配置
/**
* @description: 
* @author: ${USER}
* @date: ${DATE}
*/
  • 分支:開發前提前約定好拉分支的規範,比如日期_使用者_用途,210905_xfg_updateRuleLogic
  • 提交:作者,type: desc 如:小傅哥,fix:更新規則邏輯問題 參考Commit message 規範
  • 註釋:包括類註釋、方法註釋、屬性註釋,在 IDEA 中可以設定類註釋的頭資訊 Editor -> File and Code Templates -> File Header 推薦下載安裝 IDEA P3C 外掛 Alibaba Java Coding Guidelines,統一標準化編碼方式。

2. 介面標準

在編寫 RPC 介面的時候,返回的結果中一定要包含明確的Code碼和Info描述,否則使用方很難知道這個介面是否呼叫成功還是異常,以及是什麼情況的異常。

定義 Result

public class Result implements java.io.Serializable {

    private static final long serialVersionUID = 752386055478765987L;

    /** 返回結果碼 */
    private String code;

    /** 返回結果資訊 */
    private String info;

    public Result() {
    }

    public Result(String code, String info) {
        this.code = code;
        this.info = info;
    }

    public static Result buildSuccessResult() {
        Result result = new Result();
        result.setCode(Constants.ResponseCode.SUCCESS.getCode());
        result.setInfo(Constants.ResponseCode.SUCCESS.getInfo());
        return result;
    }
 
    // ...get/set
}

返回結果包裝:繼承

public class RuleResult extends Result {

    private String ruleId;
    private String ruleDesc;

    public RuleResult(String code, String info) {
        super(code, info);
    }
 
    // ...get/set
}

// 使用
public RuleResult execRule(DecisionMatter request) {
    return new RuleResult(Constants.ResponseCode.SUCCESS.getCode(), Constants.ResponseCode.SUCCESS.getInfo());
}

返回結果包裝:泛型

public class ResultData<T> implements Serializable {

    private Result result;
    private T data;

    public ResultData(Result result, T data) {
        this.result = result;
        this.data = data;
    }   
 
    // ...get/set
}  

// 使用
public ResultData<Rule> execRule(DecisionMatter request) {
    return new ResultData<Rule>(Result.buildSuccessResult(), new Rule());
}
  • 兩種介面返回結果的包裝定義,都可以規範返回結果。在這樣的方式包裝後,使用方就可以用統一的方式來判斷Code碼並做出相應的處理。

3. 庫表設計

三正規化:是資料庫的規範化的內容,所謂的資料庫三正規化通俗的講就是設計資料庫表所應該遵守的一套規範,如果不遵守就會造成設計的資料庫不規範,出現資料庫欄位冗餘,資料的查詢,插入等操作等問題。

資料庫不僅僅只有三正規化(1NF/2NF/3NF),還有BCNF、4NF、5NF…,不過在實際的資料庫設計時,遵守前三個正規化就足夠了。再向下就會造成設計的資料庫產生過多不必要的約束。

0NF

開發好能重構的程式碼,都是這麼幹的

  • 第零正規化是指沒有使用任何正規化,資料存放冗餘大量表欄位,而且這樣的表結構非常難以維護。

1NF

開發好能重構的程式碼,都是這麼幹的

  • 第一正規化是在第零正規化冗餘欄位上的改進,把重複欄位抽離出來,設計成一個冗餘資料較少便於儲存和讀取的表結構。
  • 同時在第一正規化中也指出,表中的所有欄位都應該是原子的、不可再分割的,例如:你不能把公司僱員表的部門名稱和職責存放到一個欄位。需要確保每列保持原子性

2NF

開發好能重構的程式碼,都是這麼幹的

  • 滿足1NF後,要求表中的列,都必須依賴主鍵,確保每個列都和主鍵列之間聯絡,而不能間接聯絡,也就是一個表只能描述一件事情。需要確保表中的每列都和主鍵相關。

3NF

開發好能重構的程式碼,都是這麼幹的

  • 不能存在依賴關係,學號、姓名,到院系,院系到宿舍,需要確保每列都和主鍵列直接相關,而不是間接相關。

反三正規化

三大正規化是設計資料庫表結構的規則約束,但是在實際開發中允許區域性變通:

  1. 有時候為了便於查詢,會在如訂單表冗餘上當時使用者的快照資訊,比如使用者下單時候的一些設定資訊。
  2. 單列列表資料彙總到總表中一個數量值,便於查詢的時候可以避免列表彙總操作。
  3. 可以在設計表的時候冗餘一些欄位,避免因業務發展情況多變,考慮不周導致該表繁瑣的問題。

4. 演算法邏輯

通常在我們實際的業務功能邏輯開發中,為了能滿足一些高併發的場景,是不可能對資料庫表上鎖釦減庫存、也不能直接for迴圈大量輪訓操作的,通常需要考慮 在這樣場景怎麼去中心化以及降低時間複雜度。

秒殺:去中心化

開發好能重構的程式碼,都是這麼幹的

  • 背景:這個一個商品活動秒殺的實現方案,最開始的設計是基於一個活動號ID進行鎖定,秒殺時鎖定這個ID,使用者購買完後就進行釋放。但在大量使用者搶購時,出現了秒殺分散式獨佔鎖後的業務邏輯處理中發生異常,釋放鎖失敗。導致所有的使用者都不能再拿到鎖,也就造成了有商品但不能下單的問題。
  • 優化:優化獨佔競態為分段靜態,將活動ID+庫存編號作為動態鎖標識。當前秒殺的使用者如果發生鎖失敗那麼後面的使用者可以繼續秒殺不受影響。而失敗的鎖會有worker進行補償恢復,那麼最終會避免超賣以及不能售賣。

演算法:反面教材

開發好能重構的程式碼,都是這麼幹的

@Test
public void test_idx_hashMap() {
    Map<String, String> map = new HashMap<>(64);
    map.put("alderney", "未實現服務");
    map.put("luminance", "未實現服務");
    map.put("chorology", "未實現服務");
    map.put("carline", "未實現服務");
    map.put("fluorosis", "未實現服務");
    map.put("angora", "未實現服務");
    map.put("insititious", "未實現服務");
    map.put("insincere", "已實現服務");
 
    long startTime = System.currentTimeMillis();
    for (int i = 0; i < 100000000; i++) {
        map.get("insincere");
    }
    System.out.println("耗時(initialCapacity):" + (System.currentTimeMillis() - startTime));
}
  • 背景:HashMap 資料獲取時間複雜度在 O(1) -> O(logn) -> O(n),但經過特殊操作,可以把這個時間複雜度,拉到O(n)
  • 操作:這是一個定義HashMap存放業務實現key,通過key呼叫服務的功能。但這裡的key,只有insincere有用,其他的都是未實現服務。那你看到有啥問題了嗎?
    • 這點程式碼乍一看沒什麼問題,看明白了就是程式碼裡下砒霜!它的目的就一個,要讓所有的key成一個連結串列放到HashMap中,而且把有用的key放到連結串列的最後,增加get時的耗時!
    • 首先,new HashMap<>(64);為啥預設初始化64個長度?因為預設長度是8,插入元素時,當連結串列長度為8時候會進行擴容和連結串列樹化判斷,此時就會把原有的key雜湊了,不能讓所有key構成一個時間複雜度較高的連結串列。
    • 其次,所有的 key 都是刻意選出來的,因為他們在 HashMap 計算下標時,下標值都為0,idx = (size - 1) & (key.hashCode() ^ (key.hashCode() >>> 16)),這樣就能讓所有 key 都雜湊到同一個位置進行碰撞。而且單詞 insincere 的意思是;不誠懇的、不真誠的
    • 最後,前7個key其實都是廢 key,不起任何作用,只有最後一個 key 有服務。那麼這樣就可以在HashMap中建出來很多這樣耗時的碰撞連結串列,當然要滿足0.75的負載因子,不要讓HashMap擴容。

其實很多演算法包括:雜湊、倒排、負載等,都是可以用到很多實際的業務場景中的,包括:人群過濾、抽獎邏輯、資料路由等等方面,這些功能的使用可以降低時間複雜度,提升系統的效能,降低介面響應時常。

5. 職責分離

為了可以讓程式的邏輯實現更具有擴充套件性,通常我們都需要使用設計模式來處理各個場景的程式碼實現結構。而設計模式的使用在程式碼開發中的體現也主要為介面的定義、抽象類的包裝和繼承類的實現。通過這樣的方式來隔離各個功能領域的開發,以此保障每次需求擴充套件時可以更加靈活的新增,而不至於讓程式碼因需求迭代而變得更加混亂。

案例

public interface IRuleExec {

    void doRuleExec(String req);

}

public class RuleConfig {

    protected Map<String, String> configGroup = new ConcurrentHashMap<>();

    static {
        // ...
    }

}

public class RuleDataSupport extends RuleConfig{

    protected String queryRuleConfig(String ruleId){
        return "xxx";
    }

}

public abstract class AbstractRuleBase extends RuleDataSupport implements IRuleExec{

    @Override
    public void doRuleExec(String req) {
        // 1. 查詢配置
        String ruleConfig = super.queryRuleConfig("10001");

        // 2. 校驗資訊
        checkRuleConfig(ruleConfig);

        // 3. 執行規則{含業務邏輯,交給業務自己處理}
        this.doLogic(configGroup.get(ruleConfig));
    }

    /**
     * 執行規則{含業務邏輯,交給業務自己處理}
     */
    protected abstract void doLogic(String req);

    private void checkRuleConfig(String ruleConfig) {
        // ... 校驗配置
    }

}

public class RuleExec extends AbstractRuleBase {

    @Override
    protected void doLogic(String req) {
        // 封裝自身業務邏輯
    }

}

類圖

開發好能重構的程式碼,都是這麼幹的

  • 這是一種模版模式結構的定義,使用到了介面實現、抽象類繼承,同時可以看到在 AbstractRuleBase 抽象類中,是負責完成整個邏輯呼叫的定義,並且這個抽象類把一些通用的配置和資料使用單獨隔離出去,而公用的簡單方法放到自身實現,最後是關於抽象方法的定義和呼叫,而業務類 RuleExec 就可以按需實現自己的邏輯功能了。

6. 邏輯縝密

你的程式碼出過線上事故嗎?為什麼出的事故,是樹上有十隻鳥開一槍還剩幾隻的問題嗎?比如:槍是無聲的嗎、鳥聾嗎、有懷孕的嗎、有綁在樹上的鳥嗎、邊上的樹還有鳥嗎、鳥害怕槍聲嗎、有殘疾的鳥嗎、打鳥的人眼睛花不花,… …

實際上你的線上事故基本回圍繞在:資料庫連線和慢查詢、伺服器負載和當機、異常邏輯兜底、介面冪等性、資料防重性、MQ消費速度、RPC響應時常、工具類使用錯誤等等。

下面舉個例子:使用者積分多支付,造成批量客訴。

開發好能重構的程式碼,都是這麼幹的

  • 背景:這個產品功能的背景可能很大一部分研發都參與開發過,簡單說就是滿足使用者使用積分抽獎的一個需求。上圖左側就是研發最開始設計的流程,通過RPC介面扣減使用者積分,扣減成功後進行抽獎。但由於當天RPC服務不穩定,造成RPC實際呼叫成功,但返回超時失敗。而呼叫RPC介面的uuid是每次自動生成的,不具備呼叫冪等性。所以造成了使用者積分多支付現象。
  • 處理:事故後修改抽獎流程,先生成待抽獎的抽獎單,由抽獎單ID呼叫RPC介面,保證介面冪等性。在RPC介面失敗時由定時任務補償的方式執行抽獎。流程整改後發現,補償任務每週發生1~3次,那麼也就是證明了RPC介面確實有可用率問題,同時也說明很久之前就有流程問題,但由於使用者客訴較少,所以沒有反饋。

7. 領域聚合

不夠抽象、不能寫死、不好擴充套件,是不是總是你的程式碼,每次都像一錘子買賣,完全是寫死的、繫結的,根本沒有一點縫隙讓新的需求擴充套件進去。

為什麼呢,因為很多研發寫出來的程式碼都不具有領域聚合的特點,當然這並不一定非得是在DDD的結構下,哪怕是在MVC的分層裡,也一樣可以寫出很多好的聚合邏輯,把功能實現和業務的呼叫分離開。

開發好能重構的程式碼,都是這麼幹的

  • 依靠領域驅動設計的設計思想,通過事件風暴建立領域模型,合理劃分領域邏輯和物理邊界,建立領域物件及服務矩陣和服務架構圖,定義符合DDD分層架構思想的程式碼結構模型,保證業務模型與程式碼模型的一致性。通過上述設計思想、方法和過程,指導團隊按照DDD設計思想完成微服務設計和開發。
    • 拒絕泥球小單體、拒絕汙染功能與服務、拒絕一加功能排期一個月
    • 架構出高可用極易符合網際網路高速迭代的應用服務
    • 物料化、組裝化、可編排的服務,提高人效

8. 服務分層

如果你想讓你的系統工程程式碼可以支撐絕對多數的業務需求,並且能沉澱下來可以服用的功能,那麼基本你就需要在做程式碼開發實現的時候,抽離出技術元件、功能領域和業務邏輯這樣幾個分層,不要把頻繁變化的業務邏輯寫入到各個功能領域中,應該讓功能領域更具有獨立性,可以被業務層串聯、編排、組合實現不同業務需求。這樣你的功能領域才能被逐步沉澱下來,也更易於每次需求都 擴充套件。

開發好能重構的程式碼,都是這麼幹的

  • 這是一個簡化的分層邏輯結構,有聚合的領域、SDK元件、中介軟體和程式碼編排,並提供一些通用共性凝練出的服務治理功能。通過這樣的分層和各個層級的實現方式,就可以更加靈活的承接需求了。

9. 併發優化

在分散式場景開發系統,要儘可能運用上分散式的能力,從程式設計上儘可能的去避免一些集中的、分散式事物的、資料庫加鎖的,因為這些方式的使用都可能在某些極端情況下,造成系統的負載的超標,從而引發事故。

開發好能重構的程式碼,都是這麼幹的

  • 所以通常情況下更需要做去集中化處理,使用MQ消除峰,降低耦合,讓資料可以最終一致性,也更要考慮在 Redis 下的使用,減少對資料庫的大量鎖處理。
  • 合理的運用MQ、RPC、分散式任務、Redis、分庫分表以及分散式事務只有這樣的操作你才可能讓自己的程式程式碼可以支撐起更大的業務體量。

10. 原始碼能力

你有了解過 HashMap 的拉鍊定址資料結構嗎、知道雜湊雜湊和擾動函式嗎、懂得怎麼結合Spring動態切換資料來源嗎、AOP 是怎麼實現以及使用的、MyBatis 是怎麼和 Spring 結合交管Bean物件的,等等。看似都是些面試的八股文,但在實際的開發中其實是可以解決很多問題的。

開發好能重構的程式碼,都是這麼幹的

@Around("aopPoint() && @annotation(dbRouter)")
public Object doRouter(ProceedingJoinPoint jp, DBRouter dbRouter) throws Throwable {
    String dbKey = dbRouter.key();
    if (StringUtils.isBlank(dbKey)) throw new RuntimeException("annotation DBRouter key is null!");

    // 計算路由
    String dbKeyAttr = getAttrValue(dbKey, jp.getArgs());
    int size = dbRouterConfig.getDbCount() * dbRouterConfig.getTbCount();

    // 擾動函式
    int idx = (size - 1) & (dbKeyAttr.hashCode() ^ (dbKeyAttr.hashCode() >>> 16));

    // 庫表索引
    int dbIdx = idx / dbRouterConfig.getTbCount() + 1;
    int tbIdx = idx - dbRouterConfig.getTbCount() * (dbIdx - 1);   

    // 設定到 ThreadLocal
    DBContextHolder.setDBKey(String.format("%02d", dbIdx));
    DBContextHolder.setTBKey(String.format("%02d", tbIdx));
    logger.info("資料庫路由 method:{} dbIdx:{} tbIdx:{}", getMethod(jp).getName(), dbIdx, tbIdx);
 
    // 返回結果
    try {
        return jp.proceed();
    } finally {
        DBContextHolder.clearDBKey();
        DBContextHolder.clearTBKey();
    }
}
  • 這是 HashMap 雜湊桶陣列 + 連結串列 + 紅黑樹的資料結構,通過擾動函式 (size - 1) & (key.hashCode() ^ (key.hashCode() >>> 16)); 解決資料碰撞嚴重的問題。
  • 但其實這樣的雜湊演算法、定址方式都可以運用到資料庫路由的設計實現中,還有整個陣列+連結串列的方式其實庫+表的方式也有類似之處。
  • 資料庫路由簡化的核心邏輯實現程式碼如上,首先我們提取了庫表乘積的數量,把它當成 HashMap 一樣的長度進行使用。
  • 當 idx 計算完總長度上的一個索引位置後,還需要把這個位置折算到庫表中,看看總體長度的索引因為落到哪個庫哪個表。
  • 最後是把這個計算的索引資訊存放到 ThreadLocal 中,用於傳遞在方法呼叫過程中可以提取到索引資訊。

三、總結

  • 講道理,你幾乎不太可能把一堆已經爛的不行的程式碼,通過重構的方式把他處理乾淨。細了說,你要改變程式碼結構分層、屬性物件整合、呼叫邏輯封裝,但任何一步的操作都可能會對原有的介面定義和呼叫造成風險影響,而且外部現有呼叫你的介面還需要隨著你的改動而升級,可能你會想著在包裝一層,但這一層包裝仍需要較大的時間成本和幾乎沒有價值的適配。
  • 所以我們在實際開發中,如果能讓這些程式碼具有重構的可能,幾乎就是要實時重構,每當你在新增新的功能、新的邏輯、修復異常時,就要考慮是否可以通過程式碼結構、實現方式、設計模式等手段的使用,改變不合理的功能實現。每一次,一點的優化和改變,也不會有那麼難。
  • 當你在接需求的時候,認真思考承接這樣的業務訴求,都需要建設怎樣的資料結構、演算法邏輯、設計模式、領域聚合、服務編排、系統架構等,才能更合理的搭建出良好的具有易維護、可擴充套件的系統服務。如果你對這些還沒有什麼感覺,可以閱讀設計模式和手寫Spring,這些內容可以幫助你提升不少的程式設計邏輯設計。

 

點選關注,第一時間瞭解華為雲新鮮技術~

相關文章