關於爛程式碼的那些事(下)

蛋疼的axb發表於2016-01-11

假設你已經讀過爛程式碼系列的前兩篇:瞭解了什麼是爛程式碼,什麼是好程式碼,但是還是不可避免的接觸到了爛程式碼(就像之前說的,幾乎沒有程式設計師可以完全避免寫出爛程式碼!)接下來的問題便是:如何應對這些身邊的爛程式碼。

1.改善可維護性

改善程式碼質量是項大工程,要開始這項工程,從可維護性入手往往是一個好的開始,但也僅僅只是開始而已。

1.1.重構的悖論

很多人把重構當做一種一次性運動,程式碼實在是爛的沒法改了,或者沒什麼新的需求了,就召集一幫人專門拿出來一段時間做重構。這在傳統企業開發中多少能生效,但是對於網際網路開發來說卻很難適應,原因有兩個:

  1. 網際網路開發講究快速迭代,如果要做大型重構,往往需要暫停需求開發,這個基本上很難實現。
  2. 對於沒有什麼新需求的專案,往往意味著專案本身已經過了發展期,即使做了重構也帶來不了什麼收益。

這就形成了一個悖論:一方面那些變更頻繁的系統更需要重構;另一方面重構又會耽誤開發進度,影響變更效率。

面對這種矛盾,一種方式是放棄重構,讓程式碼質量自然下降,直到工程的生命週期結束,選擇放棄或者重來。在某些場景下這種方式確實是有效的,但是我並不喜歡:比起讓工程師不得不把每天的精力都浪費在毫無意義的事情上,為什麼不做些更有意義的事呢?

1.2.重構step by step

1.2.1.開始之前

開始改善程式碼的第一步是把IDE的重構快捷鍵設到一個順手的鍵位上,這一步非常重要:決定重構成敗的往往不是你的新設計有多麼牛逼,而是重構本身會佔用多少時間。

比如對於IDEA來說,我會把重構選單設為快捷鍵:
螢幕快照 2015-12-27 上午11.15.58

這樣在我想去重構的時候就可以隨手開啟選單,而不是用滑鼠慢慢去點,快捷鍵每次只能為重構節省幾秒鐘時間,但是卻能明顯減少工程師重構時的心理負擔,後面會提到,小規模的重構應該跟敲程式碼一樣屬於日常開發的一部分。

我把重構分為三類:模組內部的重構、模組級別的重構、工程級別的重構。分為這三類並不是因為我是什麼分類強迫症,後面會看到對重構的分類對於重構的意義。

1.2.2.隨時進行模組內部的重構

模組內部重構的目的是把模組內部的邏輯梳理清楚,並且把一個巨大無比的函式拆分成可維護的小塊程式碼。大部分IDE都提供了對這類重構的支援,類似於:

  • 重新命名變數
  • 重新命名函式
  • 提取內部函式
  • 提取內部常量
  • 提取變數

這類重構的特點是修改基本集中在一個地方,對程式碼邏輯的修改很少並且基本可控,IDE的重構工具比較健壯,因而基本沒有什麼風險。

以下例子演示瞭如何通過IDE把一個冗長的函式做重構:

上圖的例子中,我們基本依靠IDE就把一個冗長的函式分成了兩個子函式,接下來就可以針對子函式中的一些爛程式碼做進一步的小規模重構,而兩個函式內部的重構也可以用同樣的方法。每一次小規模重構的時間都不應該超過60s,否則將會嚴重影響開發的效率,進而導致重構被無盡的開發需求淹沒。

在這個階段需要對現有的模組補充一些單元測試,以保證重構的正確。不過以我的經驗來看,一些簡單的重構,例如修改區域性變數名稱,或者提取變數之類的重構,即使沒有測試也是基本可靠的,如果要在快速完成模組內部重構和100%的單元測試覆蓋率中選一個,我可能會選擇快速完成重構。

而這類重構的收益主要是提高函式級別的可讀性,以及消除超大函式,為未來進一步做模組級別的拆分打好基礎。

1.2.3.一次只做一個較模組級別的的重構

之後的重構開始牽扯到多個模組,例如:

  • 刪除無用程式碼
  • 移動函式到其它類
  • 提取函式到新類
  • 修改函式邏輯

IDE往往對這類重構的支援有限,並且偶爾會出一些莫名其妙的問題,(例如修改類名時一不小心把配置檔案裡的常量字串也給修改了)。

這類重構主要在於優化程式碼的設計,剝離不相關的耦合程式碼,在這類重構期間你需要建立大量新的類和新的單元測試,而此時的單元測試則是必須的了。

為什麼要建立單元測試?

  • 一方面,這類重構因為涉及到具體程式碼邏輯的修改,靠整合測試很難覆蓋所有情況,而單元測試可以驗證修改的正確性。
  • 更重要的意義在於,寫不出單元測試的程式碼往往意味著糟糕的設計:模組依賴太多或者一個函式的職責太重,想象一下,想要執行一個函式卻要模擬十幾個輸入物件,每個物件還要模擬自己依賴的物件……如果一個模組無法被單獨測試,那麼從設計的角度來考慮,無疑是不合格的。

還需要囉嗦一下,這裡說的單元測試只對一個模組進行測試,依賴多個模組共同完成的測試並不包含在內-例如在記憶體裡模擬了一個資料庫,並在上層程式碼中測試業務邏輯-這類測試並不能改善你的設計。

在這個期間還會寫一些過渡用的臨時邏輯,比如各種adapter、proxy或者wrapper,這些臨時邏輯的生存期可能會有幾個月到幾年,這些看起來沒什麼必要的工作是為了控制重構範圍,例如:

如果要把函式宣告改成:

那麼最好通過加一個過渡模組來實現:

這樣做的好處是修改函式時不需要改動所有呼叫方,爛程式碼的特徵之一就是模組間的耦合比較高,往往一個函式有幾十處呼叫,牽一髮而動全身。而一旦開始全面改造,往往就會把一次看起來很簡單的重構演變成幾周的大工程,這種大規模重構往往是不可靠的。

每次模組級別的重構都需要精心設計,提前劃分好哪些是需要修改的,哪些是需要用相容邏輯做過渡的。但實際動手修改的時間都不應該超過一天,如果超過一天就意味著這次重構改動太多,需要控制一下修改節奏了。

1.2.4.工程級別的重構不能和任何其他任務並行

不安全的重構相對而言影響範圍比較大,比如:

  • 修改工程結構
  • 修改多個模組

我更建議這類操作不要用IDE,如果使用IDE,也只使用最簡單的“移動”操作。這類重構單元測試已經完全沒有作用,需要整合測試的覆蓋。不過也不必緊張,如果只做“移動”的話,大部分情況下基本的冒煙測試就可以保證重構的正確性。

這類重構的目的是根據程式碼的層次或者型別進行拆分,切斷迴圈依賴和結構上不合理的地方。如果不知道如何拆分,可以依照如下思路:

  1. 優先按部署場景進行拆分,比如一部分程式碼是公用的,一部分程式碼是自己用的,可以考慮拆成兩個部分。換句話說,A服務的修改能不能影響B服務。
  2. 其次按照業務型別拆分,兩個無關的功能可以拆分成兩個部分。換句話說,A功能的修改能不能影響B功能。
  3. 除此之外,儘量控制自己的程式碼潔癖,不要把程式碼切成一大堆豆腐塊,會給日後的維護工作帶來很多不必要的成本。
  4. 案可以提前review幾次,多參考一線工程師的意見,避免實際動手時才冒出新的問題。

而這類重構絕對不能跟正常的需求開發並行執行:程式碼衝突幾乎無法避免,並且會讓所有人崩潰。我的做法一般是在這類重構前先演練一次:把模組按大致的想法拖來拖去,通過編譯器找到依賴問題,在日常上線中把容易處理的依賴問題解決掉;然後集中團隊裡的精英,通知所有人暫停開發,花最多2、3天時間把所有問題集中突擊掉,新的需求都在新程式碼的基礎上進行開發。

如果歷史包袱實在太重,可以把這類重構也拆成幾次做:先大體拆分成幾塊,再分別拆分。無論如何,這類重構務必控制好變更範圍,一次嚴重的合併衝突有可能讓團隊中的所有人幾個周緩不過勁來。

1.3.重構的週期

典型的重構週期類似下面的過程:

  1. 在正常需求開發的同時進行模組內部的重構,同時理解工程原有程式碼。
  2. 在需求間隙進行模組級別的重構,把大模組拆分為多個小模組,增加腳手架類,補充單元測試,等等。
  3. (如果有必要,比如工程過於巨大導致經常出現相互影響問題)進行一次工程級別的拆分,期間需要暫停所有開發工作,並且這次重構除了移動模組和移動模組帶來的修改之外不做任何其他變更。
  4. 重複1、2步驟

1.3.1.一些重構的tips

  1. 只重構經常修改的部分,如果程式碼一兩年都沒有修改過,那麼說明改動的收益很小,重構能改善的只是可維護性,重構不維護的程式碼不會帶來收益。
  2. 抑制住自己想要多改一點的衝動,一次失敗的重構對程式碼質量改進的影響可能是毀滅性的。
  3. 重構需要不斷的練習,相比於寫程式碼來說,重構或許更難一些。
  4. 重構可能需要很長時間,有可能甚至會達到幾年的程度(我之前用斷斷續續兩年多的時間重構了一個專案),主要取決於團隊對於風險的容忍程度。
  5. 刪除無用程式碼是提高程式碼可維護性最有效的方式,切記,切記。
  6. 單元測試是重構的基礎,如果對單元測試的概念還不是很清晰,可以參考《使用Spock框架進行單元測試》。

2.改善效能與健壯性

2.1.改善效能的80%

效能這個話題越來越多的被人提起,隨便收到一份簡歷不寫上點什麼熟悉高併發、做過效能優化之類的似乎都不好意思跟人打招呼。

說個真事,幾年前在我做某公司的ERP專案,裡面有個功能是生成一個報表。而使用我們系統的公司裡有一個人,他每天要在下班前點一下報表,匯出到excel,再發一封郵件出去。

問題是,那個報表每次都要2,3分鐘才能生成。

我當時正年輕氣盛,看到有個兩分鐘才能生成的報表一下就來了興趣,翻出了那段不知道誰寫的程式碼,發現裡面用了3層迴圈,每次都會去資料庫查一次資料,再把一堆資料拼起來,一股腦塞進一個tableview裡。

面對這種程式碼,我還能做什麼呢?

  • 我立刻把那個三層迴圈幹掉了,通過一個儲存過程直接輸出資料。
  • sql資料計算的邏輯也被我精簡了,一些沒必要做的外聯操作被我幹掉了。
  • 我還發現很多ctrl+v生成的無用的控制元件(那時還是用的delphi),那些控制元件密密麻麻的貼在顯示介面上,只是被前面的大table擋住了,我當然也把這些玩意都刪掉了;
  • 開啟介面的時候還做了一些雜七雜八的工作(比如去資料庫裡更新點選數之類的),我把這些放到了非同步任務裡。
  • 後面我又覺得沒必要每次開啟介面都要載入所有資料(那個tableview有幾千行,幾百列!),於是我hack了預設的tableview,每次開啟的時候先計算當前實際顯示了多少內容,把引數發給儲存過程,初始化只載入這些資料,剩下的再通過執行緒非同步載入。

做了這些之後,介面只需要不到1s就能展示出來了,不過我要說的不是這個。

後來我去客戶公司給那個操作員演示新的模組的時候,點一下,刷,資料出來了。那個人很驚恐的看著我,然後問我,是不是資料不準了。

再後來,我又加了一個功能,那個模組每次開啟之後都會顯示一個進度條,上面的標題是“正在校驗資料……”,進度條走完大概要1分鐘左右,我跟那人說校驗資料計算量很大,會比較慢。當然,實際上那60秒里程式毛事都沒做,只是在一點點的更新那個進度條(我還做了個彩蛋,在讀進度的時候按上上下下左右左右BABA的話就可以加速10倍讀條…)。客戶很開心,說感覺資料準確多了,當然,他沒發現彩蛋。

我寫了這麼多,是想讓你明白一個事實:大部分程式對效能並不敏感。而少數對效能敏感的程式裡,一大半可以靠調節引數解決效能問題;最後那一小撮需要修改程式碼優化效能的程式裡,價效比高的工作又是少數。

什麼是價效比?回到剛才的例子裡,我做了那麼多事,每件事的收益是多少?

  • 把三層迴圈sql改成了儲存過程,大概讓我花了一天時間,讓載入時間從3分鐘變成了2秒,模組載入變成了”唰“的一下。
  • 後面的一坨事情大概花了我一週多時間,尤其是hack那個tableview,讓我連週末都搭進去了。而所有的優化加起來,大概優化了1秒左右,這個資料是通過日誌查到的:即使是我自己,開啟模組也沒感覺出有什麼明顯區別。

我現在遇到的很多面試者說程式優化時總是喜歡說一些玄乎的東西:呼叫棧、尾遞迴、行內函數、GC調優……但是當我問他們:把一個普通函式改成行內函數是把原來執行速度是多少的程式優化成多少了,卻很少有人答出來;或者是扭扭捏捏的說,應該很多,因為這個函式會被呼叫很多遍。我再問會被呼叫多少遍,每遍是多長時間,就答不上來了。

所以關於效能優化,我有兩個觀點:

  1. 優化主要部分,把一次網路IO改為記憶體計算帶來的收益遠大於捯飭編譯器優化之類的東西。這部分內容可以參考Numbers you should know;或者自己寫一個for迴圈,做一個無限i++的程式,看看一秒鐘i能累加多少次,感受一下cpu和記憶體的效能。
  2. 效能優化之後要有量化資料,明確的說出優化後哪個指標提升了多少。如果有人因為”提升效能“之類的理由寫了一堆讓人無法理解的程式碼,請務必讓他給出效能資料:這很有可能是一坨沒有什麼收益的爛程式碼。

至於具體的優化措施,無外乎幾類:

  1. 讓計算靠近儲存
  2. 優化演算法的時間複雜度
  3. 減少無用的操作
  4. 平行計算

關於效能優化的話題還可以講很多內容,不過對於這篇文章來說有點跑題,這裡就不再詳細展開了。

2.2.決定健壯性的20%

前一陣聽一個技術分享,說是他們在程式設計的時候要考慮太陽黑子對cpu計算的影響,或者是農民伯伯的豬把基站拱塌了之類的特殊場景。如果要優化程式的健壯性,那麼有時候就不得不去考慮這些極端情況對程式的影響。

大部分的人應該不用考慮太陽黑子之類的高深的問題,但是我們需要考慮一些常見的特殊場景,大部分程式設計師的程式碼對於一些特殊場景都會有或多或少考慮不周全的地方,例如:

  • 使用者輸入
  • 併發
  • 網路IO

常規的方法確實能夠發現程式碼中的一些bug,但是到了複雜的生產環境中時,總會出現一些完全沒有想到的問題。雖然我也想了很久,遺憾的是,對於健壯性來說,我並沒有找到什麼立竿見影的解決方案,因此,我只能謹慎的提出一點點建議:

  • 更多的測試測試的目的是保證程式碼質量,但測試並不等於質量,你做覆蓋80%場景的測試,在20%測試不到的地方還是有可能出問題。關於測試又是一個巨大的話題,這裡就先不展開了。
  • 謹慎發明輪子。例如UI庫、併發庫、IO client等等,在能滿足要求的情況下儘量採用成熟的解決方案,所謂的“成熟”也就意味著經歷了更多實際使用環境下的測試,大部分情況下這種測試的效果是更好的。

3.改善生存環境

看了上面的那麼多東西之後,你可以想一下這麼個場景:

在你做了很多事情之後,程式碼質量似乎有了質的飛躍。正當你以為終於可以擺脫天天踩屎的日子了的時候,某次不小心瞥見某個類又長到幾千行了。

你憤怒的翻看提交日誌,想找出罪魁禍首是誰,結果卻發現每天都會有人往檔案裡提交那麼十幾二十行程式碼,每次的改動看起來都沒什麼問題,但是日積月累,一年年過去,當初花了九牛二虎之力重構的工程又成了一坨爛程式碼……

任何一個對程式碼有追求的程式設計師都有可能遇到這種問題,技術在更新,需求在變化,公司人員會流動,而程式碼質量總會在不經意間偷偷的變差……

想要改善程式碼質量,最後往往就會變成改善生存環境。

3.1.1.統一環境

團隊需要一套統一的編碼規範、統一的語言版本、統一的編輯器配置、統一的檔案編碼,如果有條件最好能使用統一的作業系統,這能避免很多無意義的工作。

就好像最近渣浪給開發全部換成了統一的macbook,一夜之間以前的很多問題都變得不是問題了:字符集、換行符、IDE之類的問題只要一個配置檔案就解決了,不再有各種稀奇古怪的程式碼衝突或者不相容的問題,也不會有人突然提交上來一些編碼格式稀奇古怪的檔案了。

3.1.2.程式碼倉庫

程式碼倉庫基本上已經是每個公司的標配,而現在的程式碼倉庫除了儲存程式碼,還可以承擔一些團隊溝通、程式碼review甚至工作流程方面的任務,如今這類開源的系統很多,像gitlab(github)、Phabricator這類優秀的工具都能讓程式碼管理變得簡單很多。我這裡無意討論svn、git、hg還是什麼其它的程式碼管理工具更好,就算最近火熱的git在複雜性和集中化管理上也有一些問題,其實我是比較期待能有替代git的工具產生的,扯遠了。

程式碼倉庫的意義在於讓更多的人能夠獲得和修改程式碼,從而提高程式碼的生命週期,而程式碼本身的生命週期足夠持久,對程式碼質量做的優化才有意義。

3.1.3.持續反饋

大多數爛程式碼就像癌症一樣,當爛程式碼已經產生了可以感覺到的影響時,基本已經是晚期,很難治好了。

因此提前發現程式碼變爛的趨勢很重要,這類工作可以依賴類似於checkstyle,findbug之類的靜態檢查工具,及時發現程式碼質量下滑的趨勢,例如:

  1. 每天都在產生大量的新程式碼
  2. 測試覆蓋率下降
  3. 靜態檢查的問題增多

有了程式碼倉庫之後,就可以把這種工具與倉庫的觸發機制結合起來,每次提交的時候做覆蓋率、靜態程式碼檢查等工作,jenkins+sonarqube或者類似的工具就可以完成基本的流程:伴隨著程式碼提交進行各種靜態檢查、執行各種測試、生成報告並供人蔘考。

在實踐中會發現,關於持續反饋的五花八門的工具很多,但是真正有用的往往只有那麼一兩個,大部分人並不會去在每次提交程式碼之後再開啟一個網頁點選“生成報告”,或者去登陸什麼系統看一下測試的覆蓋率是不是變低了,因此一個一站式的系統大多數情況下會表現的更好。與其追求更多的功能,不如把有限的幾個功能整合起來,例如我們把程式碼管理、迴歸測試、程式碼檢查、和code review整合起來,就是這個樣子:

當然,關於持續整合還可以做的更多,篇幅所限,就不多說了。

3.1.4.質量文化

不同的團隊文化會對技術產生微妙的影響,關於程式碼質量沒有什麼共同的文化,每個公司都有自己的一套觀點,並且似乎都能說得通。

對於我自己來說,關於程式碼質量是這樣的觀點:

  1. 爛程式碼無法避免
  2. 爛程式碼無法接受
  3. 爛程式碼可以改進
  4. 好的程式碼能讓工作更開心一些

如何讓大多數人認同關於程式碼質量的觀點實際上是有一些難度的,大部分技術人員對程式碼質量的觀點是既不贊成、也不反對的中立態度,而程式碼質量就像是熵值一樣,放著不管總是會像更加混亂的方向演進,並且寫爛程式碼的成本實在是太低了,以至於一個實習生花上一個禮拜就可以毀了你花了半年精心設計的工程。

所以在提高程式碼質量時,務必想辦法拉上團隊裡的其他人一起。雖然“引導團隊提高程式碼質量”這件事情一開始會很辛苦,但是一旦有了一些支持者,並且有了可以參考的模板之後,剩下的工作就簡單多了。

這裡推薦《佈道之道:引領團隊擁抱技術創新》這本書,裡面大部分的觀點對於程式碼質量也是可以借鑑的。僅靠喊口號很難讓其他人寫出高質量的程式碼,讓團隊中的其他人體會到高質量程式碼的收益,比喊口號更有說服力。

4.最後再說兩句

優化程式碼質量是一件很有意思,也很有挑戰性的事情,而挑戰不光來自於程式碼原本有多爛,要改進的也並不只是程式碼本身,還有工具、習慣、練習、開發流程、甚至團隊文化這些方方面面的事情。

寫這一系列文章前前後後花了半年多時間,一直處在寫一點刪一點的狀態:我自身關於程式碼質量的想法和實踐也在經歷著不斷變化。我更希望能寫出一些能夠實踐落地的東西,而不是喊喊口號,忽悠忽悠“敏捷開發”、“測試驅動”之類的幾個名詞就結束了。

但是在寫文章的過程中就會慢慢發現,很多問題的改進方法確實不是一兩篇文章可以說明白的,問題之間往往又相互關聯,全都展開說甚至超出了一本書的資訊量,所以這篇文章也只能刪去了很多內容。

我參與過很多程式碼質量很好的專案,也參與過一些質量很爛的專案,改進了很多專案,也放棄了一些專案,從最初的單打獨鬥自己改程式碼,到後來帶領團隊優化工作流程,經歷了很多。無論如何,關於爛程式碼,我決定引用一下《佈道之道》這本書裡的一句話:

“‘更好’,其實不是一個目的地,而是一個方向…在當前的位置和將來的目標之間,可能有很多相當不錯的地方。你只需關注離開現在的位置,而不要關心去向何方。”

相關文章