馴服爛程式碼之實踐、總結與討論

infoq發表於2014-03-06

  國內程式設計師們在每天的程式設計工作中,都有可能面對爛程式碼。在國外,爛程式碼被稱為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個馴服爛程式碼的原則:

  1. 正在被客戶端使用的服務端的公共介面不能改
  2. 如果沒有測試保護,則不能改相關程式碼
  3. 讓不能改的公共介面儘量地窄
  4. 儘量早地消除重複程式碼
  5. 儘量用整潔的程式碼替代註釋
  6. 對於無法修改且“詞不達意”的公共介面,要新增what註釋來描述介面做了什麼事情
  7. 要編寫粒度大些的驗收級別的測試,比如驗收特徵測試(Acceptance Characterization Test),來覆蓋儘可能大的範圍,且與實現細節解偶,有利於方便地進行程式碼介面實現層面的重構,減少測試編寫和維護的數量
  8. 儘量多用SonarQube做程式碼內在質量的靜態掃描

  姚若舟的點評與伍斌的回覆的要點:

  1. 對遺留程式碼的重構和新增單元測試應該伴隨著新功能的開發(或者其他有業務價值的事情)。如果這個能執行的爛程式碼不需要維護,那麼就沒有必要馴服。但如果需要維護,比如增加新功能或修改 bug,那麼就需要先馴服那些與維護相關的爛程式碼,再做維護。
  2. 程式碼分為服務端和客戶端,都會有程式碼腐臭。服務端的程式碼腐臭的馴服優先順序要高於客戶端。馴服爛程式碼,要先從尋找服務端的程式碼腐臭開始。
  3. 在重構程式碼時,若能先把程式碼的可讀性和可擴充套件性重構好,那麼就能讓提高效能的工作更加輕鬆。
  4. 改善效能不是程式碼重構的目標,通常情況下,重構之後結構良好的程式碼效能都是不錯的。
  5. 剛開始重構時,如果的確怕影響現有程式碼的效能,可以考慮建立一些效能測試來守護一下。
  6. 如果在測試類中定義如下成員變數obj: ClassUnderTest obj = new ClassUnderTest(),那麼obj作為一個測試類成員變數初始化和在@Before中初始化是沒有任何區別的。原因是JUnit(及很多其他的單元測試框架)在執行每一個測試方法時,都會建立一個新的測試類例項,因此不會共享那個被測試的obj。當然也有個別框架不是這樣處理的。
  7. 在JUnit中,@Before是保證該方法在每個test case執行之前都能執行。如果把JUnit視作一個軟體系統的服務端,程式設計師作為使用JUnit的@Before功能的客戶端時,@Before就可視作JUnit的一個介面,程式設計師要儘量面向介面程式設計。
  8. 結對程式設計的目的是團隊內部知識的相互傳遞,以提升個人技能,並讓團隊避免關鍵路徑上的單點故障。
  9. 當結對程式設計的兩人意見不一致時,從心法上要做到“溝通不是為了說服對方,而是為了瞭解對方。”在手法上可以採用“擱置、驗證、讓步”的策略。

  [1] 程式設計操練題目Trivia的各種程式語言的原始碼參見:https://github.com/wubin28/trivia

  [2] 第一次馴服Trivia的原始碼:https://github.com/wubin28/TriviaJava

  第二次馴服Trivia的版原始碼:https://github.com/wubin28/TriviaJava-2nd

相關文章