馴服爛代碼之實踐、總結與討論
何為“馴服爛代碼”?
國內程序員們在每天的編程工作中,都有可能面對爛代碼。在國外,爛代碼被稱為 Legacy Code(遺留代碼),其在國外通行的含義出自 2004 年出版的 Michael Feathers 所著的 Working Effectively with Legacy Code 一書(中文版書名譯為《修改代碼的藝術》)的前言對它的定義:遺留代碼就是沒有測試的代碼。而在國內,程序員們對爛代碼的定義會比上面的定義的范圍更廣一 些,即除了沒有測試之外,爛代碼還經常是難以理解和難以擴展的代碼。
如果我們把爛代碼定義為“沒有測試、難以理解和難以擴展的代碼”,那么想要馴服爛代碼,首先就要為爛代碼編寫測試,然后在測試的保護之下將其重 構到容易理解和容易擴展的狀態。這里的“重構”,指的是在不改變軟件外在行為的前提下,改進軟件內部的實現代碼,使其容易理解和擴展,以便于當需求變更時 對軟件進行修改。而首先為爛代碼編寫測試,正是保證重構 “不改變軟件外在行為”的前提的手段。
程序員要在一坨爛代碼上新增功能或修改 bug 時,首先要馴服這坨爛代碼。理解上述馴服爛代碼的過程并不難,但真的要馴服時,你會發現困難重重且沒有頭緒。要想讓馴服爛代碼的工作做得有章法,需要長期 和刻意地操練,總結其中的收獲,并與其他程序員不斷交流,以不斷改進馴服爛代碼的工作。
馴服 Trivia 爛代碼的編程操練
2014 年 2 月 23 日,bjdp.org 北京設計模式學習組的第 13 次編程道場中,24 位匠友(軟件匠藝之友)用 Java 語言,對一個編程操練題目 Trivia[1]進行了馴服爛代碼的結對編程操練。Trivia 是一個答題闖關游戲,比賽時參賽者在游戲盤上按次序擲色子,根據色子擲出的數字來在游戲盤上前進相應的步數,并回答智力問題,如果回答正確會獲得金幣,如 果回答錯誤則要被關禁閉,不能參與下一次回答問題。
作為 bjdp.org 的發起者和組織者,伍斌在這次活動前,已經對 Trivia 先后進行了兩次馴服操練[2]。在本次活動中,伍斌首先分享了他在兩次馴服 Trivia 爛代碼過程中的一些體會,并現場編程演示了一些重構步驟。接著十幾位匠友結對進行了編程操練。
伍斌隨后在 bjdp.org 的微信公眾號 bjdp_org 中,撰寫微信文章“8 個馴服爛代碼的原則:bjdp.org 第 13 次編程道場回顧”,對這次活動進行了回顧,并轉發到敏捷教練姚若舟所在的微信群。姚若舟隨即發郵件對上述回顧給出了精彩的點評,伍斌隨后給出了回復。下面 是帶有姚若舟和武可的點評和伍斌回復的一些馴服爛代碼編程道場的回顧要點。
參加編程道場的其他 23 匠友分享了一些有關“馴服爛代碼”的體會:
- 在重構前,一定要先寫測試代碼,把要重構的代碼先保護好,之后才能重構。
- 在重構代碼時應該要考慮性能。
【伍斌的觀點】在重構代碼時,若能先把代碼的可讀性和可擴展性重構好,那么就能讓提高性能的工作更加輕松。
【姚若舟的點評】我很同意你的觀點。改善性能不是代碼重構的目標,通常情況下,重構之后結構良好的代碼性能都是不錯的。剛開始重構時,如果的確怕影 響現有代碼的性能,可以考慮建立一些性能測試來守護一下。我最近就遇到過這樣的情況,由于是第一次重構 PLSQL 的代碼,我也不確定是否會影響性能。所以,我和同伴就寫了一個性能測試,每 45 分鐘運行一次,來確保性能沒有比老代碼來的差。實際的測試結果也證明了我們做的所有重構都沒有影響性能,甚至重構后的代碼可能還比原來的要快一點。
【伍斌的回復】性能測試守護是個好主意!
- 除了消除明顯的重復代碼,也要消除那些不大明顯的重復代碼
- 在消除魔法數的過程中,同時也想把魔法數轉移到另一個新類中,感覺有些顧此失彼。建議一次只作一件事,即可以先在那個“身兼數職”的原有類中消除魔法數,再把魔法數轉移到一個新類中。
- 在測試代碼中,創建待測的類的實例這條語句,應該放到@Before里面,使得每個測試運行前,都能得到一個嶄新的實例。而不要作為測試類的一個成員變量,以避免不同測試之間共享一個實例而造成相互干擾。
【姚若舟的點評】這一點和我的實踐結果不一致。 如果“創建待測的類的實例這條語句”指的是 ClassUnderTest obj = new ClassUnderTest (),那么我可以很負責的說 obj 作為一個測試類成員變量初始化和在@Before 中初始化是沒有任何區別的。原因是 JUnit(及很多其他的單元測試框架)在運行每一個測試方法時,都會創建一個新的測試類實例,因此不會共享那個被測試的 obj。當然也有個別框架不是這樣處理的。
【伍斌的回復】贊嘆您為驗證而所做的實踐。不過即使是這樣,我個人還是愿意把創建待測實例放到 @Before 里面,因為 @Before 就是為解決不同測試相互獨立而設計的接口,而我更愿意面向接口編程。
【武可的點評】@Before 只是保證方法在 test case 執行之前執行吧。我認為和測試是否相互獨立,以及面向接口沒有什么關系。
【伍斌的回復】我認為“@Before 是保證方法在 test case 執行之前執行”這句話本身就描述了@Before 的功能。如果把 JUnit 視作一個軟件系統的服務端,那么程序員作為客戶端使用 JUnit 的@Before 功能時,@Before 就可視作 JUnit 的一個接口。
匠友們在活動中還產生了以下疑問:
- 馴服爛代碼不知從哪里開始
【伍斌的觀點】先從區分哪些是不能修改的接口開始。
【姚若舟的點評】應該從找到代碼臭味開始吧。:)
【伍斌的回復】我原先也是先找代碼腐臭。但我發現不僅服務端的代碼有腐臭,往往客戶端代碼也有腐臭。我認為先消除服務端的腐臭優先級更高,所以就先區分不能修改的服務端接口來定位服務端。
- 結對編程的目的是什么?兩人如何配合?如果兩人想法不同該怎么辦?
【伍斌的觀點】結對編程的目的就是“知識的相互傳遞”,對于個人能增長技能,對于公司能減少因專職負責某個模塊的程序員生病、休假而造成的“單點故 障”,讓團隊更健壯。結對編程中,兩人的想法肯定會有所不同,這一點即使在日常不編程的工作中,也會時時碰到。我個人認為,解決方法也和日常碰到的情況一 樣,即需要掌握良好的溝通方法,比如要擺正溝通的位置:溝通不是為了說服對方,而是為了了解對方。您了解對方越多,您就越能和對方配合好。
【姚若舟的點評】我同意你的觀點。再補充幾點結對遇到想法不同(爭議)時可能做的事情:
- 如果爭議可以擱置,那就先把它記下來,之后有空時再討論。我看到過很多結對時的爭議其實都是可以被擱置的。
- 如果爭議無法被擱置,可以考慮通過一些手段(比如測試)來驗證一下,避免大家空對空爭論。
- 如果爭議無法被擱置,且不能簡單或者馬上得到驗證(如一些代碼設計爭議),那么我覺得結對雙方都要有讓一步的覺悟。 畢竟很多時候不同選擇之間的差異不大,走哪條路都是可以。不要糾結誰對誰錯,而是要想辦法盡快驗證那些爭議(假設)。
我可以分享一個真實的例子。我有次和 Lance Kind(康美國,長居中國的一位敏捷教練)結對,我們對上面提到的那個測試類成員變量初始化的問題有不同的理解。這個爭議對于當時要解決的問題沒有什么 影響。于是,我們決定擱置爭議,事后再解決。后來,我們通過郵件的方式做了討論和澄清。
【伍斌的回復】我很贊同“擱置、驗證、讓步”的解決策略。我曾和 Lance 作為同事一起工作過,他是個高手。
【姚若舟的點評】Trivia 這個 Kata 我大概兩年前也練習過,代碼在 https://github.com/JosephYao/refactored-trivia/tree/master /UglyTriviaGame (有不少問題,呵呵)。這個 Kata 是 Legacy Coderetreat 的練習,相信你也了解過 http://legacycoderetreat.typepad.com/。 我一直沒有嘗試做過 Legacy Coderetreat,因為我并不建議重構遺留代碼和給遺留代碼添加單元測試(尤其反對所謂的重構項目)。在沒有任何業務價值驅動的前提下做這件事,我 覺得是沒有意義的。雖然說 Trivia 作為練習無可厚非,但是會給參與者一種“此事可行”的假象。
我建議對遺留代碼的重構和添加單元測試應該伴隨著新功能的開發(或者其他有業務價值的事情),原因如下:
- 一邊增加新功能(產生業務價值),一邊給修改涉及到的遺留代碼重構和添加單元測試 (償還技術債務),這樣做更加經濟合理。
- 這樣做需要考慮如何最少的修改遺留代碼,如何讓新代碼和修改涉及的代碼與遺留代碼隔離(在單元測試中),如何用最少的時間和成本做好這件事。這些都是對程序員很好的鍛煉
- 如果說遺留代碼是一個“坑”的話,那么以上面這個方式工作下去(填坑),這個 “坑”可能永遠都不需要填滿,原因是:
- 有一部分遺留代碼已經很穩定了(雖然可能沒有自動化測試覆蓋),這樣的代碼是不需要為他做任何事情的(如重構和添加單元測試)。
- 另外有一部分遺留代碼對應的業務功能其實并沒有被使用或者使用的很少,這樣的代碼也是不需要為他做任何事情的。
基于上面的考慮,我會選擇一個既要重構遺留代碼(同時添加單元測試)又要增加新功能的 Kata 來做練習。我記得這樣的 Kata 在 Coding Dojo Handbook 那本書里面看到過。
【伍斌的回復】贊同。我個人對“爛代碼”的定義是:能夠運行但對于代碼維護者來說反饋遲緩的代碼。這里的 “反饋遲緩”包括: 難理解、難擴展、難測試。 我的這個"爛代碼"的定義,與 Michael Feathers 在 Working Effectively with Legacy Code 書中對 Legacy code 的定義(沒有測試的代碼就是 Legacy Code)本質上是一樣的,即“沒有測試”就是反饋遲緩。如果這個能運行的爛代碼不需要維護,那么就沒有必要馴服。但如果需要維護,比如增加新功能或修改 bug,那么就需要先馴服那些與維護相關的爛代碼,再做維護。我也買了 Emily 的 Coding Dojo Handbook,Trivia 就是讀了這本書后才嘗試的。下次一定嘗試一下這本書另外幾個與 Legacy code 相關的 katas: Gilded Rose 和 Four Katas on a Racing-Car Theme。馴服 Trivia 這個操練本身我認為意義還是很大的,至少能收獲一些馴服爛代碼的心得。但我發現 Trivia 這個 kata 還算相對簡單的爛代碼,里面只有一個類。我想找一些有多個類、且這些類相互緊緊耦合、能運行的爛代碼,這樣能練習馴服爛代碼的解偶手法,不知您是否有這樣 的 kata?
總結
伍斌根據兩次馴服 Trivia 爛代碼的體會,整理出下面 8 個馴服爛代碼的原則:
- 正在被客戶端使用的服務端的公共接口不能改
- 如果沒有測試保護,則不能改相關代碼
- 讓不能改的公共接口盡量地窄
- 盡量早地消除重復代碼
- 盡量用整潔的代碼替代注釋
- 對于無法修改且“詞不達意”的公共接口,要添加 what 注釋來描述接口做了什么事情
- 要編寫粒度大些的驗收級別的測試,比如驗收特征測試(Acceptance Characterization Test),來覆蓋盡可能大的范圍,且與實現細節解偶,有利于方便地進行代碼接口實現層面的重構,減少測試編寫和維護的數量
- 盡量多用 SonarQube 做代碼內在質量的靜態掃描
姚若舟的點評與伍斌的回復的要點:
- 對遺留代碼的重構和添加單元測試應該伴隨著新功能的開發(或者其他有業務價值的事情)。如果這個能運行的爛代碼不需要維護,那么就沒有必要馴服。但如果需要維護,比如增加新功能或修改 bug,那么就需要先馴服那些與維護相關的爛代碼,再做維護。
- 代碼分為服務端和客戶端,都會有代碼腐臭。服務端的代碼腐臭的馴服優先級要高于客戶端。馴服爛代碼,要先從尋找服務端的代碼腐臭開始。
- 在重構代碼時,若能先把代碼的可讀性和可擴展性重構好,那么就能讓提高性能的工作更加輕松。
- 改善性能不是代碼重構的目標,通常情況下,重構之后結構良好的代碼性能都是不錯的。
- 剛開始重構時,如果的確怕影響現有代碼的性能,可以考慮建立一些性能測試來守護一下。
- 如果在測試類中定義如下成員變量 obj: ClassUnderTest obj = new ClassUnderTest (),那么 obj 作為一個測試類成員變量初始化和在@Before 中初始化是沒有任何區別的。原因是 JUnit(及很多其他的單元測試框架)在運行每一個測試方法時,都會創建一個新的測試類實例,因此不會共享那個被測試的 obj。當然也有個別框架不是這樣處理的。
- 在 JUnit 中,@Before 是保證該方法在每個 test case 執行之前都能執行。如果把 JUnit 視作一個軟件系統的服務端,程序員作為使用 JUnit 的@Before 功能的客戶端時,@Before 就可視作 JUnit 的一個接口,程序員要盡量面向接口編程。
- 結對編程的目的是團隊內部知識的相互傳遞,以提升個人技能,并讓團隊避免關鍵路徑上的單點故障。
- 當結對編程的兩人意見不一致時,從心法上要做到“溝通不是為了說服對方,而是為了了解對方。”在手法上可以采用“擱置、驗證、讓步”的策略。
[1] 編程操練題目 Trivia 的各種編程語言的源代碼參見:https://github.com/wubin28/trivia
[2] 第一次馴服 Trivia 的源代碼:https://github.com/wubin28/TriviaJava;第二次馴服 Trivia 的版源代碼:https://github.com/wubin28/TriviaJava-2nd
感謝侯伯薇對本文的審校。
<span id="shareA4" class="fl"> </span> </div>