工作一年,我重新理解了《重構》

語言: CN / TW / HK

作者:尹夢雨(惜時)

前言

很久之前團隊師兄向我推薦了《重構:改善既有程式碼的設計》這本書,粗略翻閱看到很多重構的細節技巧,但當時還處於未接觸過工程程式碼,只關注程式碼功能,不太考慮後期維護的階段,讀起來覺得枯燥無味,幾乎沒有共鳴,一直沒有細細閱讀。在工作一年後,終於在師兄的督促下,利用一個月左右的早起時光讀完了這本書,收穫很多,感謝師兄的督促,感謝這本書陪伴我找回了閱讀習慣。把這本書推薦給已經接觸了工程程式碼、工作一年左右的新同學,相信有了一定的經驗積累,再結合日常專案實踐中遇到的問題,對這本書的內容會有很多自己的思考感悟。

重構的定義

書中給出了重構的定義:對軟體內部結構的一種調整,目的是在不改變軟體可觀察前提下,提高其可理解性,降低其修改成本。

每個人對重構有自己的理解,我理解的重構:重構是一種在不改變程式碼本身執行效果的前提下,讓程式碼變得更加整潔易懂的方式。程式碼不僅要讓機器能夠實現預期的處理邏輯,更要能夠面向開發人員簡潔易懂,便於後期維護升級。

為什麼要重構

我對書中的一句話印象很深刻,“減少重複程式碼,保證一種行為表述的邏輯只在一個地方出現,即使程式本身的執行時間、邏輯不會有任何改變,但減少重複程式碼可以提高可讀性,降低日後修改的難度,是優秀設計的根本”。回想在剛畢業工作不久時,我也曾對同組師兄的程式碼重構意見有所疑惑,重構本身可能不會改變程式碼實際的執行邏輯,也不一定會對效能產生優化,為什麼一定要對程式碼的整潔度、可複用性如此執著?結合書中的答案以及自己工作中的體會,主要有以下幾點:

2.1 提升開發效率

在日常研發過程中,首先需要理解已有程式碼,再在已有程式碼基礎上進行功能迭代升級。在開發過程中,大部分時間用於閱讀已有程式碼,程式碼的可讀性必然會影響開發效率。而在專案進度緊張的情況下,為保證功能正常上線,經常會出現過程中的程式碼,可讀性不強。如果沒有後續重構優化,在專案完成一段時間後,當初的開發同學都很難在短時間內從程式碼看出當初設計時主要的出發點和以及需要注意的點,後續維護成本高。因此,通過重構增強程式碼的可讀性,更便於後續維護升級,也有助於大部分問題通過CR階段得以發現、解決。

2.2 降低修改風險

程式碼的簡潔程度越高、可讀性越強,修改風險越低。 在實際專案開發過程中,由於時間緊、工期趕,優先保證功能正常,往往權衡之下決定先上線後續再重構,但隨著時間的推移實際後續再進行修改的可能性很低,暫且不談後續重構本身的ROI,對於螞蟻這種極重視穩定性的公司,後續的修改無疑會帶來可能的風險,秉持著“上線穩定運行了那麼久的程式碼,能不動儘量不要動”的思想,當初的臨時版本很有可能就是最終版本,長此以往,系統累積的臨時程式碼、重複程式碼越來越多,降低了可讀性,導致後續的維護成本極高。因此,必要的重構短期看可能會增加額外成本投入,但長期來看重構可以降低修改風險。

重構實踐

3.1 減少重複程式碼

思前想後,重構例子的第一條,也是個人認為最重要的一條 ,就是減少重複程式碼。 如果系統中重複程式碼意味著增加修改風險:當需要修改重複程式碼中的某些功能,原本只應需要修改一個函式,但由於存在重複程式碼,修改點就會由1處增加為多處,漏改、改錯的風險大大增加。減少重複程式碼主要有兩種方法,一是及時刪除程式碼遷移等操作形成的無流量的重複檔案、重複程式碼;二是減少程式碼耦合程度,儘可能使用單一功能、可複用的方法,堅持複用原則。

問題背景: 在開發過程中,未對之前的程式碼進行提煉複用,存在重複程式碼。在開發時對於剛剛接觸這部分程式碼的同學增加了閱讀成本,在修改重複的那部分程式碼時,存在漏改、多處改動不一致的風險。

``` public PhotoHomeInitRes photoHomeInit() { if (!photoDrm.inUserPhotoWhitelist(SessionUtil.getUserId())) { LoggerUtil.info(LOGGER, "[PhotoFacade] 使用者暫無使用許可權,userId=", SessionUtil.getUserId()); throw new BizException(ResultEnum.NO_ACCESS_AUTH); } PhotoHomeInitRes res = new PhotoHomeInitRes(); InnerRes innerRes = photoAppService.renderHomePage(); res.setSuccess(true); res.setTemplateInfoList(innerRes.getTemplateInfoList()); return res; }

public CheckStorageRes checkStorage() { if (!photoDrm.inUserPhotoWhitelist(SessionUtil.getUserId())) { LoggerUtil.info(LOGGER, "[PhotoFacade] 使用者暫無使用許可權,userId=", SessionUtil.getUserId()); throw new BizException(ResultEnum.NO_ACCESS_AUTH); } CheckStorageRes checkStorageRes = new CheckStorageRes(); checkStorageRes.setCanSave(photoAppService.checkPhotoStorage(SessionUtil.getUserId())); checkStorageRes.setSuccess(true); return checkStorageRes; } ```

重構方法:及時清理無用程式碼、減少重複程式碼。

``` public PhotoHomeInitRes photoHomeInit() { photoAppService.checkUserPhotoWhitelist(SessionUtil.getUserId()); PhotoHomeInitRes res = new PhotoHomeInitRes(); InnerRes innerRes = photoAppService.renderHomePage(); res.setSuccess(true); res.setTemplateInfoList(innerRes.getTemplateInfoList()); return res; }

public CheckStorageRes checkStorage() { photoAppService.checkUserPhotoWhitelist(SessionUtil.getUserId()); CheckStorageRes checkStorageRes = new CheckStorageRes(); checkStorageRes.setCanSave(photoAppService.checkPhotoStorage(SessionUtil.getUserId())); checkStorageRes.setSuccess(true); return checkStorageRes; }

public boolean checkUserPhotoWhitelist(String userId) { if (!photoDrm.openMainSwitchOn(userId) && !photoDrm.inUserPhotoWhitelist(userId)) { LoggerUtil.info(LOGGER, "[PhotoFacade] 使用者暫無使用許可權, userId=", userId); throw new BizException(ResultEnum.NO_ACCESS_AUTH); } return true; } ```

我們在系統中或多或少都看到過未複用已有程式碼產生的重複程式碼或者已經無流量的程式碼,但對形成背景不瞭解,出於穩定性考慮,不敢貿然清理,時間久了堆積越來越多。因此,我們在日常開發過程中,對專案產生的無用程式碼、重複程式碼要及時清理,防止造成後面同學在看程式碼時的困惑,以及不夠熟悉背景的同學改動相關程式碼時漏改、錯改的風險。

3.2 提升可讀性

3.2.1 有效的註釋

問題背景: 業務程式碼缺乏有效註釋,需要閱讀程式碼細節才能瞭解業務流程,排查問題時效率較低。

``` List voucherMarkList = CommonUtil.batchfetchVoucherMark(voucherList); if (CollectionUtil.isEmpty(voucherMarkList)) { return StringUtil.EMPTY_STRING; } BatchRecReasonRequest request = new BatchRecReasonRequest(); request.setBizItemIds(voucherMarkList); Map> recReasonDetailDTOMap = relationRecReasonFacadeClient.batchGetRecReason(request); if (CollectionUtil.isEmpty(recReasonDetailDTOMap)) { return StringUtil.EMPTY_STRING; }

for (String voucherMark : recReasonDetailDTOMap.keySet()) { List reasonDetailDTOS = recReasonDetailDTOMap.get(voucherMark); for (RecReasonDetailDTO recReasonDetailDTO : reasonDetailDTOS) { if (needUpdateRecMaxCount(recReasonDetailDTO, RecReasonTypeEnum.FRIEND, recTypeList, friendRecMaxCount)) { friendRecText = recReasonDetailDTO.getRecommendText(); friendRecMaxCount = recReasonDetailDTO.getCount(); friendRecMaxCountDetailDTOS = reasonDetailDTOS; continue; }

  if (needUpdateRecMaxCount(recReasonDetailDTO, RecReasonTypeEnum.LBS, recTypeList, lbsRecMaxCount)) {
      lbsRecText = recReasonDetailDTO.getRecommendText();
      lbsRecMaxCount = recReasonDetailDTO.getCount();
  }

} return bulidRecText(friendRecMaxCountDetailDTOS, friendRecText, lbsRecText); ```

重構方法:補充相應的業務註釋,說明方法的核心思想和業務處理背景。

``` //1.生成對應的券標識,查推薦資訊 List voucherMarkList = CommonUtil.batchfetchVoucherMark(voucherList); if (CollectionUtil.isEmpty(voucherMarkList)) { return StringUtil.EMPTY_STRING; }

BatchRecReasonRequest request = new BatchRecReasonRequest(); request.setBizItemIds(voucherMarkList); Map> recReasonDetailDTOMap = relationRecReasonFacadeClient.batchGetRecReason(request); if (CollectionUtil.isEmpty(recReasonDetailDTOMap)) { return StringUtil.EMPTY_STRING; } //2.解析對應的推薦文案,取使用量最大的推薦資訊,且好友推薦資訊優先順序更高 for (String voucherMark : recReasonDetailDTOMap.keySet()) { List reasonDetailDTOS = recReasonDetailDTOMap.get(voucherMark); for (RecReasonDetailDTO recReasonDetailDTO : reasonDetailDTOS) { //2.1 獲取好友推薦資訊 if (needUpdateRecMaxCount(recReasonDetailDTO, RecReasonTypeEnum.FRIEND, recTypeList, friendRecMaxCount)) { friendRecText = recReasonDetailDTO.getRecommendText(); friendRecMaxCount = recReasonDetailDTO.getCount(); friendRecMaxCountDetailDTOS = reasonDetailDTOS; continue; } //2.2 獲取地理位置推薦資訊 if (needUpdateRecMaxCount(recReasonDetailDTO, RecReasonTypeEnum.LBS, recTypeList, lbsRecMaxCount)) { lbsRecText = recReasonDetailDTO.getRecommendText(); lbsRecMaxCount = recReasonDetailDTO.getCount(); } } //3.組裝結果並返回,若好友推薦量最大的券推薦資訊中包含地理位置資訊,則返回組合文案(好友推薦資訊與地理位置推薦資訊均來自同一張券) return bulidRecText(friendRecMaxCountDetailDTOS, friendRecText, lbsRecText); ```

重構這本書中表達了對註釋的觀點,作者認為程式碼中不應有過多註釋,程式碼功能應該通過恰當的方法命名體現,但相比於國內大多數工程師,書中作者對英文的理解和運用更加擅長,所以書中有此觀點。但每個人的命名風格和對英文的理解不同,僅通過命名不一定能快速瞭解背後的業務邏輯。個人認為,業務註釋而非程式碼功能註釋,清晰直觀的業務註釋能夠在短時間內大致瞭解程式碼對應的業務邏輯,可以幫助閱讀者快速理解為什麼這樣做,而不是做什麼,因此,簡潔的業務註釋仍然是有必要的。

3.2.2 簡化複雜的條件判斷

問題背景: if語句中的判斷條件過於複雜,難以理解業務語義

for (RecReasonDetailDTO recReasonDetailDTO : reasonDetailDTOS) { //2.1 獲取好友推薦資訊 if (StringUtil.equals(recReasonDetailDTO.getRecReasonType(), RecReasonTypeEnum.FRIEND.name()) && recTypeList.contains(RecReasonTypeEnum.FRIEND.name()) && StringUtil.isNotBlank(recReasonDetailDTO.getRecommendText()) && recReasonDetailDTO.getCount() != 0 && Long.valueOf(recReasonDetailDTO.getCount()) > friendRecMaxCount) { friendRecText = recReasonDetailDTO.getRecommendText(); friendRecMaxCount = recReasonDetailDTO.getCount(); friendRecMaxCountDetailDTOS = reasonDetailDTOS; continue; } //2.2 獲取地理位置推薦資訊 if (StringUtil.equals(recReasonDetailDTO.getRecReasonType(), RecReasonTypeEnum.LBS.name()) && recTypeList.contains(RecReasonTypeEnum.LBS.name()) && StringUtil.isNotBlank(recReasonDetailDTO.getRecommendText()) && recReasonDetailDTO.getCount() != 0 && Long.valueOf(recReasonDetailDTO.getCount()) > lbsRecMaxCount) { lbsRecText = recReasonDetailDTO.getRecommendText(); lbsRecMaxCount = recReasonDetailDTO.getCount(); } }

重構方法:將判斷條件單獨放在獨立方法中並恰當命名,提升可讀性

for (RecReasonDetailDTO recReasonDetailDTO : reasonDetailDTOS) { //2.1 獲取好友推薦資訊 if (needUpdateRecMaxCount(recReasonDetailDTO, RecReasonTypeEnum.FRIEND, recTypeList, friendRecMaxCount)) { friendRecText = recReasonDetailDTO.getRecommendText(); friendRecMaxCount = recReasonDetailDTO.getCount(); friendRecMaxCountDetailDTOS = reasonDetailDTOS; continue; } //2.2 獲取地理位置推薦資訊 if (needUpdateRecMaxCount(recReasonDetailDTO, RecReasonTypeEnum.LBS, recTypeList, lbsRecMaxCount)) { lbsRecText = recReasonDetailDTO.getRecommendText(); lbsRecMaxCount = recReasonDetailDTO.getCount(); } }

private boolean needUpdateRecMaxCount(RecReasonDetailDTO recReasonDetailDTO, RecReasonTypeEnum reasonTypeEnum, List<String> recTypeList, long recMaxCount) { if (StringUtil.equals(recReasonDetailDTO.getRecReasonType(), reasonTypeEnum.name()) && recTypeList.contains(reasonTypeEnum.name()) && StringUtil.isNotBlank(recReasonDetailDTO.getRecommendText()) && recReasonDetailDTO.getCount() != 0 && Long.valueOf(recReasonDetailDTO.getCount()) > recMaxCount) { return true; } return false; }

將複雜的判斷條件提煉到獨立的方法中,並通過恰當命名來幫助提升可讀性。在閱讀含有條件語句的程式碼時,如果判斷條件過於複雜,容易將閱讀注意力放在理解判斷條件中,而對方法整體的業務邏輯理解可能更困難,耗時更久。因此,簡化判斷條件並將其語義化更利於快速專注理解整體業務邏輯。

3.2.3 重構多層巢狀條件語句

問題背景: if條件多層巢狀,影響可讀性。在寫程式碼的過程中,保證功能正確的前提下按照思維邏輯寫了多層條件巢狀,正常的業務邏輯隱藏較深。開發者本身對業務流程足夠熟悉,可以一口氣讀完整段方法,但對於其他同學來說,在閱讀此型別程式碼時,讀到正常邏輯時,很容易已經忘記前面判斷條件的內容,對於前面的校驗攔截印象不深。

if (Objects.nonNull(cardSaveNotifyDTO) && !noNeedSendOpenCardMsg(cardSaveNotifyDTO)) { CardDO cardDO = CardDAO.queryCardInfoById(cardSaveNotifyDTO.getCardId(), cardSaveNotifyDTO.getUserId()); if (Objects.isNull(cardDO)) { LoggerUtil.warn(LOGGER, "[CardSaveMessage] cardDO is null"); return; } openCardServiceManager.sendOpenCardMessage(cardDO); LoggerUtil.info(LOGGER, "[CardSaveMessage] send open card message, cardSaveNotifyDTO=" + cardSaveNotifyDTO); }

重構方法:對於多層if巢狀的程式碼,可以將不滿足校驗條件的情況快速返回,增強可讀性。

if (Objects.isNull(cardSaveNotifyDTO)) { LoggerUtil.warn(LOGGER, "[CardSaveMessage] cardSaveNotifyDTO is null"); return; } LoggerUtil.info(LOGGER, "[CardSaveMessage] receive card save message, cardSaveNotifyDTO=" + cardSaveNotifyDTO); if (noNeedSendOpenCardMsg(cardSaveNotifyDTO)) { LoggerUtil.info(LOGGER, "[CardSaveMessage] not need send open card message, cardSaveNotifyDTO=" + cardSaveNotifyDTO); return; } CardDO cardDO = CardDAO.queryCardInfoById(cardSaveNotifyDTO.getCardId(), cardSaveNotifyDTO.getUserId()); if (Objects.isNull(cardDO)) { LoggerUtil.warn(LOGGER, "[CardSaveMessage] cardDO is null"); return; } openCardServiceManager.sendOpenCardMessage(cardDO); LoggerUtil.info(LOGGER, "[CardSaveMessage] send open card message, cardSaveNotifyDTO=" + cardSaveNotifyDTO);

如果是程式本身多種情況的返回值,可以減少出口,提升可讀性。對於業務程式碼的前置校驗,更適合通過快速返回代替if巢狀的方式簡化條件語句。雖然實際上實現功能相同,但可讀性及表達含義不同。用多分支(if else)表明多種情況出現的可能性是同等的,而判斷特殊情況後快速返回的寫法,表明只有很少部分出現其他情況,所以出現後快速返回。簡化判斷條件更易讓人理解業務場景。

3.2.4 固定規則語義化

問題背景: 在開發過程中,程式碼中存在包含多個列舉的組合或固定業務規則,在閱讀程式碼時不清楚背景,容易產生困惑。例如,圖中所示程式碼在滿足切換條件下,將方法中的變數scene以預設的字串拼接生成新的scene,但這種隱含的預設規則需要閱讀程式碼細節才能瞭解,在排查問題時,根據實際日誌中的具體scene值來搜尋也無法定位到具體程式碼,理解成本高。

if (isMrchCardRemind(appId, appUrl)) { args.put(MessageConstant.MSG_REMIND_APP_ID, appId); args.put(MessageConstant.MSG_REMIND_APP_URL, appUrl); if (StringUtil.isNotBlank(memberCenterUrl)) { args.put(MessageConstant.MEMBER_CENTER_URL, memberCenterUrl); scene = scene + "_WITH_MEMBER_CENTER"; } scene = scene + "_MERCH"; }

重構方法:可以將其語義抽象為欄位放入列舉中,降低修改時的風險,增強可讀性

``` /* * 積分變動 / CARD_POINT_UPDATE("CARD_POINT_UPDATE", "CARD_POINT_UPDATE_MERCH", "CARD_POINT_UPDATE_WITH_MEMBER_CENTER", "CARD_POINT_UPDATE_MERCH_WITH_MEMBER_CENTER"),

/* * 餘額變動 / CARD_BALANCE_UPDATE("CARD_BALANCE_UPDATE", "CARD_BALANCE_UPDATE_MERCH", "CARD_BALANCE_UPDATE_WITH_MEMBER_CENTER", "CARD_BALANCE_UPDATE_MERCH_WITH_MEMBER_CENTER"),

/* * 等級變動 / CARD_LEVEL_UPDATE("CARD_LEVEL_UPDATE", "CARD_LEVEL_UPDATE_MERCH", "CARD_LEVEL_UPDATE_WITH_MEMBER_CENTER", "CARD_LEVEL_UPDATE_MERCH_WITH_MEMBER_CENTER"), ```

if (isMrchCardRemind(appId, appUrl)) { args.put(MessageConstant.MSG_REMIND_APP_ID, appId); args.put(MessageConstant.MSG_REMIND_APP_URL, appUrl); if (StringUtil.isNotBlank(memberCenterUrl)) { args.put(MessageConstant.MEMBER_CENTER_URL, memberCenterUrl); return remindSceneEnum.getMerchRemindWithMemberScene(); } return remindSceneEnum.getMerchRemindScene(); }

在閱讀程式碼瞭解業務細節時,程式碼中的固定規則會額外增加閱讀成本。在評估相關改動對現有業務影響時,程式碼中包含固定規則需要特別注意。將固定規則語義化,更有助於對已有程式碼理解和分析。如上例中,將自定義的固定字串拼接規則替換為列舉中的具體值,雖然在重構後增加了程式碼行數,但在提升可讀性的同時也更便於根據具體值搜尋定位具體程式碼,其中列舉值的含義和關聯關係更加清晰,一目瞭然。

總結思考

程式碼的整潔度與程式碼質量成正比,整潔的程式碼質量更高,也更利於後期維護。重構本身不是目的,目的是讓程式碼更整潔、可讀性更高、易於維護,提升開發效率。 因此,比起如何進行後續重構,在開發過程中意識到什麼樣的程式碼是好程式碼,在不額外增加太多研發成本的前提下 ,有意識地保持程式碼整潔更加重要。 即使是在日常開發過程中小的優化,哪怕只有很少的程式碼改動,只要能讓程式碼更整潔,仍然值得去做。

4.1 去除重複程式碼

重複程式碼包含程式碼遷移產生的過程程式碼、程式碼檔案中重複的程式碼、相近的邏輯以及相似的業務流程。對於程式碼遷移產生的重複程式碼,在遷移完成後要及時去除,避免增加後續閱讀複雜度。對於相似的功能函式以及相似的業務流程,我們可以通過提煉方法、繼承、模板方法等方式重構,但與其後續通過重構手段消除程式碼,更應在日常寫程式碼的時候堅持合成複用原則,減少重複程式碼。

4.2 恰當直觀的命名

怎樣的命名算是好的命名?書中給出了關於命名的建議:好的命名不需要用註釋來補充說明,直觀明瞭,通過命名就可以判斷出函式的功能和用法,提升可讀性的同時便於根據常量的語義搜尋查詢。同理,程式碼中有含義的數字、字串要用常量替換的原則,目的是相同的。在日常編碼中,要用直觀的命名來描述函式功能。 例如用結合業務場景的用動詞短語來命名,在區分出應用場景的同時,也便於根據業務場景來搜尋相關功能函式。

4.3 單一職責,避免過長的方法

看到書中提到避免過長的方法這樣的觀點時,我也有這樣的疑問,多少行的方法算過長的方法?對於函式多少行算長這個問題,行數本身不重要,重要的是函式名稱與語義的距離。將實現每個功能的步驟提煉出獨立方法,雖然提煉後的函式程式碼量不一定大,但卻是如何做與做什麼之間的語義轉變,提煉後的函式通過恰當直觀命名,可明顯提升可讀性。 以上總結了一些關於日常研發過程中應該堅持程式碼整潔原則的思考,雖小但只要保持,相信程式碼整潔度會有很大的提高,共勉。