經驗總結 | 重構讓你的程式碼更優美和簡潔

高德技術發表於2021-07-16

1. 前言

最近,筆者有幸對高德叫車訂單Push專案進行了重構,與大家分享一下程式碼重構相關的工作經驗,希望對大家有所啟發。

​有時候,我們在做某個功能需求時,需要花掉大量的時間,才能找到和需求有關聯的程式碼。或者,我們在閱讀別人寫的程式碼、接手別人的專案時,總是“頭皮發麻”,當你面對結構混亂、毫無章法的程式碼結構,詞不達意的變數名、方法名時,我相信你根本沒有讀下去的心情。這不是你的問題,而是你手中的程式碼需要進行重構了。

經驗總結 | 重構讓你的程式碼更優美和簡潔

2. 何為重構

每個人對重構都有自己的定義,我這裡引用的是“Martin Fowler”的,他從兩個維度對重構進行了定義。

作為名詞:重構是對軟體內部結構的一種調整,目的是在不改變軟體可觀察行為前提下,提高其可理解性,降低其修改成本。 

作為動詞:重構是使用一系列重構手法,在不改變軟體可觀察行為的前提下,調整其結構。

我在本文提到的程式碼重構,更偏向它作為動詞的定義,而根據重構的規模程度、時間長短,我們可以將程式碼重構分為小型重構大型重構

小型重構:是對程式碼的細節進行重構,主要是針對類、函式、變數等程式碼級別的重構。比如常見的規範命名(針對詞不達意的變數再命名),消除超大函式,消除重複程式碼等。一般這類重構修改的地方比較集中,相對簡單,影響比較小、時間較短。所以難度相對要低一些,我們完全可以在日常的隨版開發中進行。

大型重構:是對程式碼頂層進行重構,包括對系統結構、模組結構、程式碼結構、類關係的重構。一般採取的手段是進行服務分層、業務模組化、元件化、程式碼抽象複用等。這類重構可能需要進行原則再定義、模式再定義甚至業務再定義。涉及到的程式碼調整和修改多,所以影響比較大、耗時較長、帶來的風險比較大(專案叫停風險、程式碼Bug風險、業務漏洞風險)。這就需要我們具備大型專案重構的經驗,否則很容易犯錯,最後得不償失。

其實大多數人都是不喜歡重構工作的,就像沒有人願意給別人“擦屁股”一樣,主要可能有以下幾個方面的擔憂:

  • 不知道怎麼重構,缺乏重構的經驗和方法論,容易在重構中犯錯。
  • 很難看到短期收益,如果這些利益是長遠的,何必現在就付出這些努力呢?長遠看來,說不定當專案收穫這些利益時,你已經不負責這塊工作了。
  • 重構會破壞現有程式,帶來意想不到的Bug,不想去承受這些意料之外的Bug。
  • 重構需要你付出額外的工作,何況可能需要重構的程式碼並不是你編寫的。

3. 為何重構

如果我純粹為今天工作,明天我將完全無法工作。

程式有兩面價值:“今天可以為你做什麼” 和 “明天可以為你做什麼”。大多數時候,我們都只關注自己今天想要程式做什麼。不論是修復錯誤或是新增特性,都是為了讓程式力更強,讓它在今天更有價值。但是我為什麼還是提倡大家要在合適的時機做程式碼重構,原因主要有以下幾點:

  • 讓軟體架構始終保持良好的設計。改進我們的軟體設計,讓軟體架構向有利的方向發展,能夠始終對外提供穩定的服務、從容的面對各種突發的問題。
  • 增加可維護性,降低維護成本,對團隊和個人都是正向的良性迴圈,讓軟體更容易理解。無論是後人閱讀前人寫的程式碼,還是事後回顧自己的程式碼,都能夠快速瞭解整個邏輯,明確業務,輕鬆的對系統進行維護。
  • 提高研發速度、縮短人力成本。大家可能深有體會,一個系統在上線初期,向系統中增加功能時,完成速度非常快,但是如果不注重程式碼質量,後期向系統中新增一個很小的功能可能就需要花上一週或更長的時間。而程式碼重構是一種有效的保證程式碼質量的手段,良好的設計是維護軟體開發速度的根本。重構可以幫助你更快速的開發軟體,因為它阻止系統腐爛變質,甚至還可以提高設計質量。
經驗總結 | 重構讓你的程式碼更優美和簡潔
經驗總結 | 重構讓你的程式碼更優美和簡潔

4. 如何重構

小型重構

小型重構大部分都是在日常開發中進行的,一般的參考標準即是我們的開發規範和準則,目的是為了解決程式碼中的壞味道,我們來看一下常見的壞味道都有哪些?

泛型擦除

//{"param1":"v1", "param2":"v2", "param3":30, ……}
Map map = JSON.parseObject(msg); //【1】
……
// 將map作為引數傳遞給底層介面
xxxService.handle(map); //【2】

//看一下底層介面定義
void handle(Map<String, String> map); //【3】
經驗總結 | 重構讓你的程式碼更優美和簡潔經驗總結 | 重構讓你的程式碼更優美和簡潔

【2】處將已經泛型擦除的map傳遞給底層已經泛型限定的介面中,相信在介面實現中都是使用“String value = map.get(XXX)”這種方式獲取值的,這樣一旦map中有非String型別的值,這裡就會出現型別轉換異常。讀者肯定和我一樣好奇,為何該業務系統中未丟擲型別轉換異常,原因是業務系統取值的方式並未轉換成String型別。可想而知,一但有人使用標準的方式獲取值時,就會踩雷。

// 文字1${param1}文字2${param2}文字3${param3}
String[] terms = ["文字1","$param1", "文字2", "$param2", "文字3", "$param3"];
StringBuilder builder = new StringBuilder();
for(String term: terms){
  if(term.startsWith("$")){
    builder.append(map.get(term.substring(1)));
  }else{
    builder.append(term);
  }
}
經驗總結 | 重構讓你的程式碼更優美和簡潔經驗總結 | 重構讓你的程式碼更優美和簡潔

無病呻吟

Config config = new Config();
// 設定name和md5
config.setName(item.getName());
config.setMd5(item.getMd5());
// 設定值
config.setTypeMap(map);
// 列印日誌
LOGGER.info("update done ({},{}), start replace", getName(), getMd5());


......

ExpiredConfig expireConfig = ConfigManager.getExpiredConfig();
// 為空初始化
if (Objects.isNull(expireConfig)) {
  expireConfig = new ExpiredConfig();
}

......
Map<String, List<TypeItem>> typeMap = ……;   
Map<String, Map<String, Map<String, List<Map<String, Object>>>>> jsonMap = new HashMap<>();

// 迴圈一級map
jsonMap.forEach((k1, v1) -> {
    // 迴圈裡面的二級map
    v1.forEach((k2, v2) -> {
        // 迴圈裡面的三級map
        v2.forEach((k3, v3) -> {
            // 迴圈最裡面的list,哎!
            v3.forEach(e -> {
                // 生成key
                String ck = getKey(k1, k2, k3);
                // 為空處理
                List<TypeItem> types = typeMap.get(ck);
                if (CollectionUtils.isEmpty(types)) {
                    types = new ArrayList<>();
                    typeMap.put(ck, types);
                }
                // 設定型別
            }
       }
  }
}
經驗總結 | 重構讓你的程式碼更優美和簡潔
經驗總結 | 重構讓你的程式碼更優美和簡潔

程式碼本身一眼就能看明白是在幹什麼,寫程式碼的人非要在這個地方加一個不關痛癢的註釋,這個註釋完全是口水話,毫無價值可言。

if-else過多

try {
  if (StringUtils.isEmpty(id)) {
    if (StringUtils.isNotEmpty(cacheValue)) {
      if (StringUtils.isNotEmpty(idPair)) {
        if (cacheValue.equals(idPair)) {
          // xxx
        } else {
          // xxx
        }
      }
    } else {
      if (StringUtils.isNotEmpty(idPair)) {
        // xxx
      }
    }
    if(xxxx(xxxx){
      // xxx
    }else{
      if(StringUtils.isNotEmpty(idPair)){
        // xxx
      }
      // xxx
    }
  }else if(!check(id, param)){
    // xxx
  }
} catch (Exception e) {
  log.error("error:", e);
}

這樣的程式碼,讓程式碼的閱讀性大大降低,令很多人望而卻步。除非被逼的迫不得已,否則估計開發人員是不會動這樣的程式碼的,因為你不知道你動的一小點,可能會讓整個業務系統癱瘓。

其他壞味道

經驗總結 | 重構讓你的程式碼更優美和簡潔

這裡就不再羅列相關案例了,相信大家在日常也經常看到很多程式碼書寫不合理,讓人不適應的地方,總結一下程式碼中常見的壞味道和解決辦法:

重複程式碼

程式碼壞味道最多的恐怕就是重複程式碼,如果你在一個以上的地方看到相同的程式碼結構,那麼可以肯定:設法將它們合而為一,程式會變得更好。

最常見的一種重複場景就是在“同一個類的兩個函式含有相同的表示式”,這種形式的重複程式碼可以在當前類提取公用方法,以便在兩處複用。

還有一種和這類場景相似,就是在“兩個互為兄弟的子類含有相同的表示式”,這種形式可以將相同的程式碼提取到共同父類中,針對有差異化的部分,使用抽象方法延遲到子類實現,這就是常見的模板方法設計模式。如果兩個毫不相干的類出現了重複程式碼,這個時候應該考慮將重複程式碼提煉到一個新類中,然後在這兩個類中呼叫這個新類的方法。

函式過長

一個好的函式必須滿足單一職責原則,短小精悍,只做一件事。過長的函式體和身兼數職的方法都不利於閱讀,也不利於進行程式碼複用。

命名規範

一個好的命名需要能做到“名副其實、見名知意”,直接了當,不存在歧義。

不合理的註釋

註釋是一把雙刃劍,好的註釋能夠給我們好的指導,不好的註釋只會將人誤導。針對註釋,我們需要做到在整合程式碼時,也把註釋一併進行修改,否則就會出現註釋和邏輯不一致。另外,如果程式碼已清晰的表達了自己的意圖,那麼註釋反而是多餘的。

無用程式碼

無用程式碼有兩種方式,一種是沒有使用場景,如果這類程式碼不是工具方法或工具類,而是一些無用的業務程式碼,那麼就需要及時的刪除清理。另外一種是用註釋符包裹的程式碼塊,這些程式碼在被打上註釋符號的時候就應該被刪除。

過大的類

一個類做太多事情,維護了太多功能,可讀性變差,效能也會下降。舉個例子,訂單相關的功能你放到一個類A裡面,商品庫存相關的也放在類A裡面,積分相關的還放在類A裡面……試想一下,亂七八糟的程式碼塊都往一個類裡面塞,還談啥可讀性。應該按單一職責,使用不同的類把程式碼劃分開。

這些都是比較常見的程式碼“壞味道”,實際開發中當然還會存在其他的一些“壞味道”,比如程式碼混亂,邏輯不清晰,類關係錯綜複雜,當聞到這些不同的“壞味道”時,都應該嘗試去解決掉,而不是放縱不管不顧。

大型重構

相對小型重構,大型重構需要考慮的事情比較多,需要定好節奏,按部就班的執行,因為在大型重構中,情況多變。

將大象裝進冰箱的步驟一般可以分成三步:1)把冰箱門開啟(事前);2)把大象推進去(事中);3)把冰箱門關上(事後)。日常所有的事情都可以採用三步法進行解決,重構也不例外。

經驗總結 | 重構讓你的程式碼更優美和簡潔

事前

事前準備作為重構的第一步,這一部分涉及到的事情比較雜,也是最重要的,如果之前準備不充分,很有可能導致在事中執行或重構上線後產生的結果和預期不一致的現象。

在這個階段大致可分為三步:

  • 明確重構的內容、目的以及方向、目標

在這一步裡面,最重要的是把方向明確清楚,而且這個方向是經得起大家的質疑,能夠至少滿足未來三到五年的方向。另外一個就是這次重構的目標,由於技術限制、歷史包袱等原因,這個目標可能不是最終的目標,那麼需要明確最終目標是怎麼樣的,從這次重構的這個目標到最終的目標還有哪些事情要做,最好都能夠明確下來。

  • 整理資料

這一步需要對涉及重構部分的現有業務、架構進行梳理,明確重構的內容在系統的哪個服務層級、屬於哪個業務模組,依賴方和被依賴方有哪些,有哪些業務場景,每個場景的資料輸入輸出是怎樣的。這個階段就會有產出物了,一般會沉澱專案部署、業務架構、技術架構、服務上下游依賴、強弱依賴、專案內部服務分層模型、內容功能依賴模型、輸入輸出資料流等相關的設計圖和文件。

  • 專案立項

專案立項一般是通過會議進行,對所有參與重構的部門或小組進行重構工作的宣講,周知大概的時間計劃表(粗略的大致時間),明確各組主要負責的人。另外還需要周知重構涉及到哪些業務和場景、大概的重構方式、業務影響可能有哪些,難點及可能在哪些步驟出現瓶頸。

事中

事中執行這一步驟的事情和任務相對來說比較繁重一些,時間付出相對比較多。

  • 架構設計與評審

架構設計評審主要是對標準的業務架構、技術架構、資料架構進行設計與評審。通過評審去發現架構和業務上的問題,這個評審一般是團隊內評審,如果在一次評審後,發現架構設計並不能被確定,那就需要再調整,直到團隊內對方案架構設計都達成一致,才可以進行下一步,評審結果也需要在評審通過後進行郵件周知參與人。

該階段產出物:重構後的服務部署、系統架構、業務架構、標準資料流、服務分層模式、功能模組UML圖等。

  • 詳細落地設計方案與評審

這個落地的設計方案是事中執行最重要的一個方案,關係到後面的研發編碼、自測與聯調、依賴方對接、QA測試、線下發布與實施預案、線上釋出與實施預案、具體工作量、難度、工作瓶頸等。這個詳細落地方案需要深入到整個研發、線下測試、上線過程、灰度場景細節處包括AB灰度程式、AB驗證程式。

在方案設計中最重要的一環是AB驗證程式和AB驗證開關,這是評估和檢驗我們是否重構完成的標準依據。一般的AB驗證程式大致如下:

經驗總結 | 重構讓你的程式碼更優美和簡潔

在資料入口處,使用相同的資料,分別向新老流程都發起處理請求。處理結束之後,將處理結果分別列印到日誌中。最後通過離執行緒序比較新老流程處理的結果是否一致。遵循的原則就是在相同入參的情況下,響應的結果也應該一致。

在AB程式中,會涉及到兩個開關。灰度開關(只有它開啟了,請求才會被髮送到新的流程中進行程式碼執行)。執行開關(如果新流程中涉及到寫操作,這裡需要用開關控制在新流程寫還是在老流程中寫)。轉發之前需要將灰度開關和執行開關(一般配置到配置中心,能隨時調整)寫入到執行緒上下文中,以免出現在修改配置中心開關時,多處獲取開關結果不一致。

  • 程式碼的編寫、測試、線下實施

這一步就是按照詳細設計的方案,進行編碼、單測、聯調、功能測試、業務測試、QA測試。通過後,線上下模擬上線流程和線上開關實施過程,校驗AB程式,檢查是否符合預期,新流程程式碼覆蓋度是否達到上線要求。如果線下資料樣本比較少,不能覆蓋全部場景,需要通過構造流量覆蓋所有的場景,保證所有的場景都能符合預期。當線下覆蓋度達到預期,並且AB驗證程式沒有校驗出任何異常時,才能執行上線操作。

事後

這個階段需要線上上按照線下模擬的實施流程進行線上實施,分為上線、放量、修復、下線老邏輯、覆盤這樣幾個階段。其中最重要最耗費精力的就是放量流程了。

  • 灰度開關流程

逐步放量到新的流程中進行觀察,可以按照1%、5%、10%、20%、40%、80%、100%的進度進行放量,讓新流程逐步的進行程式碼邏輯覆蓋,注意這個階段不會開啟真實執行寫操作的開關。當新流程邏輯覆蓋度達到要求、並且AB驗證的結果都符合預期後,才可以逐步開啟執行寫操作開關,進行真實業務的執行操作。

  • 業務執行開關流程

在灰度新流程的過程中符合預期後,可以逐步開啟業務執行寫操作開關流程,仍然可以按照一定的比例進行逐步放量,開啟寫操作後,只有新邏輯執行寫操作,老邏輯將關閉寫操作。這個階段需要觀察線上錯誤、指標異常、使用者反饋等問題,確保新流程沒有任何問題。

放量工作結束後,在穩定一定版本後,就可以將老邏輯和AB驗證程式進行下線,重構工作結束。如果有條件可以開一個重構覆盤會,檢查每個參與方是否都達到了重構要求的標準,覆盤重構期間遇到的問題、以及解決方案是什麼樣的,沉澱方法論避免後續的工作出現類似的問題。

5. 總結

程式碼技巧

  • 寫程式碼的時候遵循一些基本原則,比如單一原則、依賴介面/抽象而不是依賴具體實現。
  • 嚴格遵循編碼規範、特殊註釋使用 TODO、FIXME、XXX 進行註釋。
  • 單元測試、功能測試、介面測試、整合測試是寫程式碼必不可少的工具。
  • 我們是程式碼的作者,後人是程式碼的讀者。寫程式碼要時刻審視,做前人栽樹後人乘涼、不做前人挖坑後人陪葬的事情。
  • 不做破窗效應的第一人,不要覺得現在程式碼已經很爛了,沒有必要再改,直接繼續堆程式碼。如果是這樣,總有一天自己會被別人的程式碼噁心到,“出來混遲早是要還的”。

重構技巧

  • 從上至下,由外到內進行建模分析,理清各種關係,是重構的重中之重。
  • 提煉類,複用函式,下沉核心能力,讓模組職責清晰明瞭。
  • 依賴介面優於依賴抽象,依賴抽象優於依賴實現,類關係能用組合就不要繼承。
  • 類、介面、抽象介面設計時考慮範圍限定符,哪些可以重寫、哪些不能重寫,泛型限定是否準確。
  • 大型重構做好各種設計和計劃,線下模擬好各種場景,上線一定需要AB驗證程式,能夠隨時進行新老切換。

相關文章