【重構】一、重構的原則

重構的原則

0

這個系列是《重構——改善既有代碼的設計(第2版)》的讀書筆記,沒錯就是不久前2019年5月纔出版的第二版中文譯本。這是繼1999第一版後時隔20年的第二版,距2009年的第一版再版也過去了10年。

我對重構在過去的樣子不是很瞭解,無法體會重構從無到有是具有劃時代意義的,但是在實際的開發中,重構無處不在,大多數時候,重構的結果是很好的、過程是痛苦的。我一直把第一版當做一本字典,當我覺得代碼需要重構的時候,翻一翻這本字典,然後適當地“重構”,現在,我想把第二版再讀一遍,結合自己的開發和重構經驗,整理成一本具有個人特色的字典。

什麼是重構

重構(Refactoring),是對軟件內部結構的一種調整,目的是在不改變軟件可觀察行爲的前提下,提高軟件的可理解性,降低其修改成本。

重構和重寫最大的區別在於:重構是用微小且保持軟件行爲的步驟,一步步達成大規模的修改,即使沒有重構完成,軟件依然可以使用,可以隨時停下來;而重寫可能會導致一段時間軟件不能使用。

兩頂帽子

在使用重構技術開發軟件時,開發時間可以分配給兩種截然不同的行爲:添加新功能和重構。添加新功能時,不應該修改既有代碼,只管添加新功能、添加測試並通過;重構時不能添加新功能,只管調整代碼結構,不應該添加任何測試,除非有遺漏的測試或接口發生變化時才允許添加測試。

這兩種狀態在開發過程中會不停變化,可能剛剛嘗試添加新功能,發現重構一下以前的代碼會更容易擴展,重構完成後又繼續添加新功能,添加完新功能後,又覺得代碼寫得難以理解,於是又繼續重構……這就像兩頂帽子,工作中時常戴上不同的帽子工作,無論何時都應該清除自己戴的是哪一頂帽子,並且明白不同帽子對自己和程序的要求。

爲什麼要重構

重構改進軟件的設計

如果沒有重構,軟件內部設計或架構會逐漸變質,因爲開發者很容易只爲了短期目而修改代碼,忽略了整個程序的整體設計,也可能開發一段時間後才真正明白代碼應該有的結構。

重構使軟件更容易理解

能寫出計算機能理解的代碼並不能稱爲好的程序員,只有寫出讓其他程序員都能理解的代碼才能被稱爲好的程序員。沒有必要覺得自己寫出了讓別人能夠理解的代碼時多麼無私的一件事,因爲這份代碼未來的維護者很可能就是自己。

重構能幫助找到bug

重構有助於理清程序的結構和邏輯,縮小bug的範圍,還可能發現隱藏的bug。

重構提高編程速度

在初級程序員眼裏,重構和單元測試似乎嚴重佔用了編程時間,因爲他們接觸到軟件開發的時間還不夠、做一個產品的週期還不夠長、還沒有接觸到由糟糕的設計和混亂的代碼組成的難以修改的軟件。沒有持續重構的系統,問題會在未來的某一天顯現,添加新功能的難度成倍增加、修改bug的時間超過了開發功能的時間、修復一個bug會導致更多的bug出現。而一個持續重構的系統,好處會在未來的某一天變得明顯,而這個時間點和未持續重構系統問題出現的時間點可能剛好吻合。

什麼時候重構

三次法則
第一次做某件事只管去做;第二次做類似的事會產生反感,但無論如何還是可以去做;第三次再做類似的事,你就應該重構。
正如老話所說:事不過三,三則重構。

預備性重構:讓添加新功能更容易

重構的最佳時機是在添加新功能之前。在動手寫代碼之前,先看看現有的代碼哪裏可以複用,如果大部分可以複用但是小部分需要修改,爲了這一小部分的修改寫很多重複的代碼可能不是一個好方法。

比如新功能需要使用已有功能某一方法的大部分代碼,那麼完全可以使用提取函數方法重構,將這部分代碼複用,因爲如果這部分代碼導致了某一個bug,那麼修改此bug會導致兩處修改,並且假設這部分代碼需要更換成新的邏輯,也會導致兩處修改。

再比如可以預見到這個模塊以後還會添加類似的新功能,那麼可以是用某種設計模式來替換現在的代碼,以便於後續更容易、更優雅地擴展。

幫助理解的重構:使代碼更容易懂

修改某一部分代碼之前,必須要先理解這部分代碼。如果一段代碼過了一段時間自己得重新花好幾分鐘時間才能理解,一直在思考“這段代碼到底幹了啥”,可能是因爲某個函數或變量的命名導致困惑,或者某個方法實在是太複雜了,這都是重構的機會。

把註釋寫得足夠詳細並沒有什麼問題,但是如果代碼本身就能體現邏輯,那註釋就顯得多餘了。我不需要在一個名爲username的變量上添加/* 用戶名 */的註釋,我也不需要在queryUserById()的方法調用上註釋/* 通過用戶ID查詢用戶信息 */,因爲這些變量名或方法名就是最好的註釋,如果某個變量或方法必須添加註釋,那一定是這個變量表達的意義和它的實際意義不匹配。

同樣的,很多程序員喜歡在if...else...語句中寫大量註釋,好像能幫助別的程序員理解,但實際上註釋恰恰反映了這段代碼有些難懂,一定可以通過重構找到更合適的方法,讓這段代碼脫離複雜的註釋也能被人理解。

撿垃圾式重構

有時候自己已經理解代碼的邏輯,或者正在幹其他緊急的事情,但是發現一些做的並不好的地方,可以稍微地重構一下。比如兩個不同名的方法,內部邏輯是一樣的,一些if語句完全可以合併,這些“垃圾”如果留在原地,可能並不會對整個軟件的修改和理解帶來太多的負擔,但是保不齊未來某一天會垃圾成山。

童子軍有一條規則:“讓營地比你剛來時更乾淨。”在軟件開發過程中也是如此,如果你使用了某段代碼,或者修改了某段代碼,如果發現了垃圾,那麼立即清理垃圾。有時候可能清理垃圾會花費好些時間,但即便如此,清理垃圾通常都是值得的。

有計劃地重構和見機行事的重構

上面幾條重構時機都是“見機行事”的,並沒有特地地安排一段時間重構,但是有計劃地重構有必要嗎?在敏捷開發中,可能會在幾次迭代後有一個短暫的修復時間,這段修復時間可以看做是有計劃地重構,這相當於在整個軟件生命週期中的兩頂帽子交替。

但是有計劃地重構應當儘量的少,因爲重構是小的修改步驟彙集成大的修改,各種見機行事的重構彙集成整個模塊的重構,如果需要花時間專門重構,那麼一定是見機行事做得不夠好,或者設計架構到了不得不改的地步。

長期重構

大多數重構可以在較短的時間完成,比如幾分鐘幾小時,但有的重構可能需要好幾周才能重構完成,比如基礎模塊的重構。因爲在重構過程中,程序必須持續可用,可以在基於抽象或接口開發,如果沒有可以在重構時引入,抽象或接口同時支持新舊實現,一旦新的實現重構完成,立即將舊的實現替換。

複審代碼時重構

很多公司都會做code review,但是很多時候都是複審者單獨瀏覽代碼,代碼作者並不在旁邊,或者複審代碼時走馬觀花,並不會嘗試去理解代碼,也不會給出有效的建議,因此複審時最好及時指出代碼中的缺陷,讓不規範的代碼在進入代碼倉庫之前就解決,否則複審就失去了意義。

怎麼對leader說

如果leader很重視技術,你說你花了一定的時間在重構上,導致開發進度有一些滯後,leader會理解並支持;但是如果leader沒有這種意識,他只會責備你沒有按時交付功能。但是如果回過頭來想想,沒有重構導致的bug和添加新功能的難度是不是影響了之後的開發,在有一定年限的開發經驗後,這種意識應該越來越強烈,不要告訴leader,老老實實加班重構,一個月後,添加完新功能準時下班的自己會感謝一個月前加班重構的自己。

何時不應該重構

如果醜陋的代碼隱藏在一個API之下,而這個API已經被長時間測試驗證過,沒有新功能需要添加的時候,可以忍受補充夠API之下的代碼;如果一個模塊重寫的難度比重構容易,就別重構了,但要命的是,往往需要花一點兒時間嘗試重構後,才能瞭解一塊代碼重構的難度,所以決定重構還是重寫因人而異,需要豐富的經驗和判斷力。

重構的挑戰

延緩新功能的開發

比如現在手中的一個項目,PO一直在趕進度,但是代碼已經開始腐敗,作爲開發,我知道應該花些時間重構了,否則這個產品一定會變得難以維護,但是PO現在需要的就是進度。這也許是前期工作沒有做好的後遺症,因爲重構的好處就是添加新功能更快,可以花更少的工作量創造更大的價值,重構的好處也許不會馬上顯現,但是在未來的一個月或幾個月會逐漸浮現;而不重構的壞處也許一開始並不會暴露出來,甚至還有不錯的進度,但是和重構的好處一樣,一個月或幾個月後,添加新功能和BUG排查將是一件令人沮喪的事情。

所以戴上重構的帽子時,不會添加任何新功能,因此延緩新功能的開發不可避免,但是我認爲這點時間是值得的,哪怕是加班去完成這件事,因爲重構可以讓以後戴上添加新功能的帽子時效率更高。但也應當在兩頂帽子之間做出取捨,比如只需要添加一個很小的功能,並不會增加未重構代碼的複雜度,那麼可以在趕進度的情況下暫時添加,然後再戴上重構的帽子,因爲進度優先,重構並不影響現有功能的使用。

代碼所有權

細粒度代碼所有權的情況比較少見——細到某個接口屬於一個團隊甚至只屬於某個開發者,其他團隊或開發者無法修改這個接口,當進行較大規模的重構時,這種代碼所有權邊界會妨礙重構,雖然可以使用橋接模式、代理模式、適配器模式等等來隱藏無權限修改的接口或類,但是這又增加了系統的複雜度。

因此不建議代碼所有權粒度太細,目前也沒有見到有這種情況的項目或團隊。

分支

版本控制有兩種方式:feature分支開發完成後merge到主分支或將主分支rebase到feature分支;在一個dev分支開發,每次提交代碼之前必須先pull並解決衝突,最後在commit並push。這兩種版本管理方式我都有使用過,前一種在開發過程中很爽,沒有任何多餘的代碼影響自己的feature開發,但是一旦開發完成後要merge到主分支時,解決衝突是一個讓人頭大的問題,特別是feature分支開發持續一兩週,和主分支的差異越來越大,衝突的機率隨之增加,其中一種解決方法時定時從主分支pull代碼,讓feature分支和主分支的差異變小,儘早解決衝突;後一種方式沒有複雜的分支管理,每個成員每天都必須提交一次代碼,所有開發者當天的代碼都在同一個分支上持續集成(Continuous Integration, CI),這種方式可以降低代碼合併難度,相比於前一種方式,持續集成少了複雜的分支管理。

回到重構,如果某個成員重構了一處代碼,比如修改了某個方法名,但是另一個成員沒有及時拉取新的代碼,依然使用了舊的方法名,那麼無論誰先提交,另一方都需要做二次修改,但是這個問題可以在持續集成的時候儘快發現,而不是使用feature分支兩週後才發現。

測試

重構是在不改變軟件可觀測行爲的情況下改變軟件的內部結構,這是重構的重要特徵。我們如何保證“不改變程序可觀測行爲”呢?一個很重要的保障就是單元測試。比如重構一個複雜的方法,如果有足夠多的測試支撐,我可以隨心所欲的重構,只需要重構完成後跑一跑這個方法的所有單元測試,看看是不是所有可能的輸入都得到了原先期望的結果,如果是那麼至少可以保證本次重構沒有改變程序的可觀測行爲。

單元測試配合持續集成,可以儘早的發現問題所在,比如jinkens配合sonar做持續集成,在代碼進入倉庫之前跑一次單元測試,可以保證此次重構不對程序已有單元測試的邏輯產生影響。

遺留代碼

接手維護過老系統的人可能都一聽到別人寫的代碼都是聞風喪膽,如果沒有測試就添加測試,這看起來不太可能,因爲不是自己寫的代碼,連用例都不太清楚,不能保證自己能覆蓋所有分支。所以對於遺留代碼,還是遵循童子軍原則,每次觸碰一塊邏輯時,首先完善或建立單元測試,然後對這一小塊邏輯重構,等到一個小模塊的單元測試基本完成時,在準備整體重構,等到多個模塊單元測試完善時,在進行更大規模的重構。這聽起來是個大工程,但對於一個功能較爲穩定的老系統,真正會頻繁修改的代碼一般是引起系統不穩定的因素(Bug或功能完善),如果只重構這些頻繁改動的邏輯,重構這部分代碼將會得到豐厚的回報。

數據庫

對於持續迭代的敏捷開發模式,數據庫設計可能會隨着迭代的進行而更改,如果代碼已經部署在生產環境,但數據庫的更改沒有及時應用到生產環境數據庫,比如一個字段名修改了,那將會是一個線上bug或生產事故。

解決重構可能導致的數據庫變更有兩種方案:如果生產數據庫是固定的,可以選擇原始的人工執行sql腳本(有的自動化部署和審批系統可以有相關功能);但假設生產數據庫有多套,比如產品部署在各個客戶現場,現在要針對某一版本的系統做一次升級,甚至是每個客戶現場的版本還不一致,這種情況如何找到每個本版升級到最新版本需要執行哪些sql?這時候最簡單的方法是將sql腳本的變更也納入版本控制,比如flyway,liquibase等工具,這是任何一個持續發佈的、用數據庫存儲數據的多環境產品首先就應該想到的事情。

總結

重構作爲敏捷開發的重要特性,每個敏捷團隊成員都必須在重構上有足夠的能力和熱情,但這些重構意識的缺乏,讓很多敏捷團隊只是徒有其名,甚至認爲敏捷==快,敏捷是快,但敏捷並不僅僅是快。

重構之前必須有足夠的單元測試支撐,重構的第一塊基石就是單元測試,重構後必須有及時的持續集成,以最快的方式告訴其他成員:我重構了,你拉下代碼。

大多數系統都是重構不足,幾乎不會出現重構過度,因此知道什麼是重構、爲什麼要重構、何時重構、重構的挑戰在哪裏是很重要的,但這也僅僅是第一步,後面如何去重構,也就是重構的方法,才真正涉及到代碼實踐。

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