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

語言: 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 單一職責,避免過長的方法

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