1. 程式人生 > >降低軟體複雜性一般原則和方法

降低軟體複雜性一般原則和方法

一、前言

斯坦福教授、Tcl語言發明者John Ousterhout 的著作《A Philosophy of Software Design》[1],自出版以來,好評如潮。按照IT圖書出版的慣例,如果冠名為“實踐”,書中內容關注的是某項技術的細節和技巧;冠名為“藝術”,內容可能是記錄一件優秀作品的設計過程和經驗;而冠名為“哲學”,則是一些通用的原則和方法論,這些原則方法論串起來,能夠形成一個體系。正如”知行合一”、“世界是由原子構成的”、“我思故我在”,這些耳熟能詳的句子能夠一定程度上代表背後的人物和思想。用一句話概括《A Philosophy of Software Design》,軟體設計的核心在於降低複雜性。

本篇文章是圍繞著“降低複雜性”這個主題展開的,很多重要的結論來源於John Ousterhout,筆者覺得很有共鳴,就做了一些相關話題的延伸、補充了一些例項。雖說是”一般原則“,也不意味著是絕對的真理,整理出來,只是為了引發大家對軟體設計的思考。

二、如何定義複雜性

關於複雜性,尚無統一的定義,從不同的角度可以給出不同的答案。可以用數量來度量,比如晶片整合的電子器件越多越複雜(不一定對);按層次性[2]度量,複雜度在於層次的遞迴性和不可分解性。在資訊理論中,使用熵來度量資訊的不確定性。

John Ousterhout選擇從認知的負擔和開發工作量的角度來定義軟體的複雜性,並且給出了一個複雜度量公式:

子模組的複雜度cp乘以該模組對應的開發時間權重值tp,累加後得到系統的整體複雜度C。系統整體的複雜度並不簡單等於所有子模組複雜度的累加,還要考慮該模組的開發維護所花費的時間在整體中的佔比(對應權重值tp)。也就是說,即使某個模組非常複雜,如果很少使用或修改,也不會對系統的整體複雜度造成大的影響。

子模組的複雜度cp是一個經驗值,它關注幾個現象:

  • 修改擴散,修改時有連鎖反應。
  • 認知負擔,開發人員需要多長時間來理解功能模組。
  • 不可知(Unknown Unknowns),開發人員在接到任務時,不知道從哪裡入手。

造成複雜的原因一般是程式碼依賴和晦澀(Obscurity)。其中,依賴是指某部分程式碼不能被獨立地修改和理解,必定會牽涉到其他程式碼。程式碼晦澀,是指從程式碼中難以找到重要資訊。

三、解決複雜性的一般原則

首先,網際網路行業的軟體系統,很難一開始就做出完美的設計,通過一個個功能模組衍生迭代,系統才會逐步成型;對於現存的系統,也很難通過一個大動作,一勞永逸地解決所有問題。系統設計是需要持續投入的工作,通過細節的積累,最終得到一個完善的系統。因此,好的設計是日拱一卒的結果,在日常工作中要重視設計和細節的改進。

其次,專業化分工和程式碼複用促成了軟體生產率的提升。比如硬體工程師、軟體工程師(底層、應用、不同程式語言)可以在無需瞭解對方技術背景的情況下進行合作開發;同一領域服務可以支撐不同的上層應用邏輯等等。其背後的思想,無非是通過將系統分成若干個水平層、明確每一層的角色和分工,來降低單個層次的複雜性。同時,每個層次只要給相鄰層提供一致的介面,可以用不同的方法實現,這就為軟體重用提供了支援。分層是解決複雜性問題的重要原則。

第三,與分層類似,分模組是從垂直方向來分解系統。分模組最常見的應用場景,是如今廣泛流行的微服務。分模組降低了單模組的複雜性,但是也會引入新的複雜性,例如模組與模組的互動,後面的章節會討論這個問題。這裡,我們將第三個原則確定為分模組。

最後,程式碼能夠描述程式的工作流程和結果,卻很難描述開發人員的思路,而註釋和文件可以。此外,通過註釋和文件,開發人員在不閱讀實現程式碼的情況下,就可以理解程式的功能,註釋間接促成了程式碼抽象。好的註釋能夠幫助解決軟體複雜性問題,尤其是認知負擔和不可知問題(Unknown Unknowns)。

四、解決複雜性之日拱一卒

4.1 拒絕戰術程式設計

戰術程式設計致力於完成任務,新增加特性或者修改Bug時,能解決問題就好。這種工作方式,會逐漸增加系統的複雜性。如果系統複雜到難以維護時,再去重構會花費大量的時間,很可能會影響新功能的迭代。

戰略程式設計,是指重視設計並願意投入時間,短時間內可能會降低工作效率,但是長期看,會增加系統的可維護性和迭代效率。

設計系統時,很難在開始階段就面面俱到。好的設計應該體現在一個個小的模組上,修改bug時,也應該抱著設計新系統的心態,完工後讓人感覺不到“修補”的痕跡。經過累積,最終形成一個完善的系統。從長期看,對於中大型的系統,將日常開發時間的10-15%用於設計是值得的。有一種觀點認為,創業公司需要追求業務迭代速度和節省成本,可以容忍糟糕的設計,這是用錯誤的方法去追求正確的目標。降低開發成本最有效的方式是僱傭優秀的工程師,而不是在設計上做妥協。

4.2 設計兩次

為一個類、模組或者系統的設計提供兩套或更多方案,有利於我們找到最佳設計。以我們日常的技術方案設計為例,技術方案本質上需要回答兩個問題,其一,為什麼該方案可行? 其二,在已有資源限制下,為什麼該方案是最優的?為了回答第一個問題,我們需要在技術方案裡補充架構圖、介面設計和時間人力估算。而要回答第二個問題,需要我們在關鍵點或爭議處提供二到三種方案,並給出建議方案,這樣才有說服力。通常情況下,我們會花費很多的時間準備第一個問題,而忽略第二個問題。其實,回答好第二個問題很重要,大型軟體的設計已經複雜到沒人能夠一次就想到最佳方案,一個僅僅“可行”的方案,可能會給系統增加額外的複雜性。對聰明人來說,接受這點更困難,因為他們習慣於“一次搞定問題”。但是聰明人遲早也會碰到自己的瓶頸,在低水平問題上徘徊,不如花費更多時間思考,去解決真正有挑戰性的問題。

五、解決複雜性之分層

5.1 層次和抽象

軟體系統由不同的層次組成,層次之間通過介面來互動。在嚴格分層的系統裡,內部的層只對相鄰的層次可見,這樣就可以將一個複雜問題分解成增量步驟序列。由於每一層最多影響兩層,也給維護帶來了很大的便利。分層系統最有名的例項是TCP/IP網路模型。

在分層系統裡,每一層應該具有不同的抽象。TCP/IP模型中,應用層的抽象是使用者介面和互動;傳輸層的抽象是埠和應用之間的資料傳輸;網路層的抽象是基於IP的定址和資料傳輸;鏈路層的抽象是適配和虛擬硬體裝置。如果不同的層具有相同的抽象,可能存在層次邊界不清晰的問題。

5.2 複雜性下沉

不應該讓使用者直面系統的複雜性,即便有額外的工作量,開發人員也應當儘量讓使用者使用更簡單。如果一定要在某個層次處理複雜性,這個層次越低越好。舉個例子,Thrift介面呼叫時,資料傳輸失敗需要引入自動重試機制,重試的策略顯然在Thrift內部封裝更合適,開放給使用者(下游開發人員)會增加額外的使用負擔。與之類似的是系統裡隨處可見的配置引數(通常寫在XML檔案裡),在程式設計中應當儘量避免這種情況,使用者(下游開發人員)一般很難決定哪個引數是最優的,如果一定要開放參數配置,最好給定一個預設值。

複雜性下沉,並不是說把所有功能下移到一個層次,過猶不及。如果複雜性跟下層的功能相關,或者下移後,能大大下降其他層次或整體的複雜性,則下移。

5.3 異常處理

異常和錯誤處理是造成軟體複雜的罪魁禍首之一。有些開發人員錯誤的認為處理和上報的錯誤越多越好,這會導致過度防禦性的程式設計。如果開發人員捕獲了異常並不知道如何處理,直接往上層扔,這就違背了封裝原則。

降低複雜度的一個原則就是儘可能減少需要處理異常的可能性。而最佳實踐就是確保錯誤終結,例如刪除一個並不存在的檔案,與其上報檔案不存在的異常,不如什麼都不做。確保檔案不存在就好了,上層邏輯不但不會被影響,還會因為不需要處理額外的異常而變得簡單。

六、解決複雜性之分模組

分模組是解決複雜性的重要方法。理想情況下,模組之間應該是相互隔離的,開發人員面對具體的任務,只需要接觸和了解整個系統的一小部分,而無需瞭解或改動其他模組。

6.1 深模組和淺模組

深模組(Deep Module)指的是擁有強大功能和簡單介面的模組。深模組是抽象的最佳實踐,通過排除模組內部不重要的資訊,讓使用者更容易理解和使用。

Unix作業系統檔案I/O是典型的深模組,以Open函式為例,介面接受檔名為引數,返回檔案描述符。但是這個介面的背後,是幾百行的實現程式碼,用來處理檔案儲存、許可權控制、併發控制、儲存介質等等,這些對使用者是不可見的。

int open(const char* path, int flags, mode_t permissions);

與深模組相對的是淺模組(Shallow Module),功能簡單,介面複雜。通常情況下,淺模組無助於解決複雜性。因為他們提供的收益(功能)被學習和使用成本抵消了。以Java I/O為例,從I/O中讀取物件時,需要同時建立三個物件FileInputStream、BufferedInputStream、ObjectInputStream,其中前兩個建立後不會被直接使用,這就給開發人員造成了額外的負擔。預設情況下,開發人員無需感知到BufferedInputStream,緩衝功能有助於改善檔案I/O效能,是個很有用的特性,可以合併到檔案I/O物件裡。假如我們想放棄緩衝功能,檔案I/O也可以設計成提供對應的定製選項。


FileInputStream fileStream = new FileInputStream(fileName);
BufferedInputStream bufferedStream = new BufferedInputStream(fileStream);
ObjectInputStream objectStream = new ObjectInputStream(bufferedStream);

關於淺模組有一些爭議,大多數情況是因為淺模組是不得不接受的既定事實,而不見得是因為合理性。當然也有例外,比如領域驅動設計裡的防腐層,系統在與外部系統對接時,會單獨建立一個服務或模組去適配,用來保證原有系統技術棧的統一和穩定性。

6.2 通用和專用

設計新模組時,應該設計成通用模組還是專用模組?一種觀點認為通用模組滿足多種場景,在未來遇到預期外的需求時,可以節省時間。另外一種觀點則認為,未來的需求很難預測,沒必要引入用不到的特性,專用模組可以快速滿足當前的需求,等有後續需求時再重構成通用的模組也不遲。

以上兩種思路都有道理,實際操作的時候可以採用兩種方式各自的優點,即在功能實現上滿足當前的需求,便於快速實現;介面設計通用化,為未來留下餘量。舉個例子。


void backspace(Cursor cursor);
void delete(Cursor cursor);
void deleteSelection(Selection selection);

//以上三個函式可以合併為一個更通用的函式
void delete(Position start, Position end);

設計通用性介面需要權衡,既要滿足當前的需求,同時在通用性方面不要過度設計。一些可供參考的標準:

  • 滿足當前需求最簡單的介面是什麼?在不減少功能的前提下,減少方法的數量,意味著介面的通用性提升了。
  • 介面使用的場景有多少?如果介面只有一個特定的場景,可以將多個這樣的介面合併成通用介面。
  • 滿足當前需求情況下,介面的易用性?如果介面很難使用,意味著我們可能過度設計了,需要拆分。

6.3 資訊隱藏

資訊隱藏是指,程式的設計思路以及內部邏輯應當包含在模組內部,對其他模組不可見。如果一個模組隱藏了很多資訊,說明這個模組在提供很多功能的同時又簡化了介面,符合前面提到的深模組理念。軟體設計領域有個技巧,定義一個”大”類有助於實現資訊隱藏。這裡的“大”類指的是,如果要實現某功能,將該功能相關的資訊都封裝進一個類裡面。

資訊隱藏在降低複雜性方面主要有兩個作用:一是簡化模組介面,將模組功能以更簡單、更抽象的方式表現出來,降低開發人員的認知負擔;二是減少模組間的依賴,使得系統迭代更輕量。舉個例子,如何從B+樹中存取資訊是一些資料庫索引的核心功能,但是資料庫開發人員將這些資訊隱藏了起來,同時提供簡單的對外互動介面,也就是SQL指令碼,使得產品和運營同學也能很快地上手。並且,因為有足夠的抽象,資料庫可以在保持外部相容的情況下,將索引切換到雜湊或其他資料結構。

與資訊隱藏相對的是資訊暴露,表現為:設計決策體現在多個模組,造成不同模組間的依賴。舉個例子,兩個類能處理同類型的檔案。這種情況下,可以合併這兩個類,或者提煉出一個新類(參考《重構》[3]一書)。工程師應當儘量減少外部模組需要的資訊量。

6.4 拆分和合並

兩個功能,應該放在一起還是分開?“不管黑貓白貓”,能降低複雜性就好。這裡有一些可以借鑑的設計思路:

  • 共享資訊的模組應當合併,比如兩個模組都依賴某個配置項。
  • 可以簡化介面時合併,這樣可以避免客戶同時呼叫多個模組來完成某個功能。
  • 可以消除重複時合併,比如抽離重複的程式碼到一個單獨的方法中。
  • 通用程式碼和專用程式碼分離,如果模組的部分功能可以通用,建議和專用部分分離。舉個例子,在實際的系統設計中,我們會將專用模組放在上層,通用模組放在下層以供複用。

七、解決複雜性之註釋

註釋可以記錄開發人員的設計思路和程式功能,降低開發人員的認知負擔和解決不可知(Unkown Unkowns)問題,讓程式碼更容易維護。通常情況下,在程式的整個生命週期裡,編碼只佔了少部分,大量時間花在了後續的維護上。有經驗的工程師懂得這個道理,通常也會產出更高質量的註釋和文件。

註釋也可以作為系統設計的工具,如果只需要簡單的註釋就可以描述模組的設計思路和功能,說明這個模組的設計是良好的。另一方面,如果模組很難註釋,說明模組沒有好的抽象。

7.1 註釋的誤區

關於註釋,很多開發者存在一些認識上的誤區,也是造成大家不願意寫註釋的原因。比如“好程式碼是自注釋的”、”沒有時間“、“現有的註釋都沒有用,為什麼還要浪費時間”等等。這些觀點是站不住腳的。“好程式碼是自注釋的”只在某些場景下是合理的,比如為變數和方法選擇合適的名稱,可以不用單獨註釋。但是更多的情況,程式碼很難體現開發人員的設計思路。此外,如果使用者只能通過讀程式碼來理解模組的使用,說明程式碼裡沒有抽象。好的註釋可以極大地提升系統的可維護性,獲取長期的效率,不存在“沒有時間”一說。註釋也是一種可以習得的技能,一旦習得,就可以在後續的工作中應用,這就解決了“註釋沒有用”的問題。

7.2 使用註釋提升系統可維護性

註釋應當能提供程式碼之外額外的資訊,重視What和Why,而不是程式碼是如何實現的(How),最好不要簡單地使用程式碼中出現過的單詞。

根據抽象程度,註釋可以分為低層註釋和高層註釋,低層次的註釋用來增加精確度,補充完善程式的資訊,比如變數的單位、控制條件的邊界、值是否允許為空、是否需要釋放資源等。高層次註釋拋棄細節,只從整體上幫助讀者理解程式碼的功能和結構。這種型別的註釋更好維護,如果程式碼修改不影響整體的功能,註釋就無需更新。在實際工作中,需要兼顧細節和抽象。低層註釋拆散與對應的實現程式碼放在一起,高層註釋一般用於描述介面。

註釋先行,註釋應該作為設計過程的一部分,寫註釋最好的時機是在開發的開始環節,這不僅會產生更好的文件,也會幫助產生好的設計,同時減少寫文件帶來的痛苦。開發人員推遲寫註釋的理由通常是:程式碼還在修改中,提前寫註釋到時候還得再改一遍。這樣的話就會衍生兩個問題:

  • 首先,推遲註釋通常意味著根本就沒有註釋。一旦決定推遲,很容易引發連鎖反應,等到程式碼穩定後,也不會有註釋這回事。這時候再想添加註釋,就得專門抽出時間,客觀條件可能不會允許這麼做。
  • 其次,就算我們足夠自律抽出專門時間去寫註釋,註釋的質量也不會很好。我們潛意識中覺得程式碼已經寫完了,急於開展下一個專案,只是象徵性地新增一些註釋,無法準確復現當時的設計思路。

避免重複的註釋。如果有重複註釋,開發人員很難找到所有的註釋去更新。解決方法是,可以找到醒目的地方存放註釋文件,然後在程式碼處註明去查閱對應文件的地址。如果程式已經在外部文件中註釋過了,不要在程式內部再註釋了,添加註釋的引用就可以了。

註釋屬於程式碼,而不是提交記錄。一種錯誤的做法是將功能註釋放在提交記錄裡,而不是放在對應程式碼檔案裡。因為開發人員通常不會去程式碼提交記錄裡去檢視程式的功能描述,很不方便。

7.3 使用註釋改善系統設計

良好的設計基礎是提供好的抽象,在開始編碼前編寫註釋,可以幫助我們提煉模組的核心要素:模組或物件中最重要的功能和屬性。這個過程促進我們去思考,而不是簡單地堆砌程式碼。另一方面,註釋也能夠幫助我們檢查自己的模組設計是否合理,正如前文中提到,深模組提供簡單的介面和強大的功能,如果介面註釋冗長複雜,通常意味著介面也很複雜;註釋簡單,意味著介面也很簡單。在設計的早期注意和解決這些問題,會為我們帶來長期的收益。

八、後記

John Ousterhout累計寫過25萬行程式碼,是3個作業系統的重要貢獻者,這些原則可以視為作者程式設計經驗的總結。有經驗的工程師看到這些觀點會有共鳴,一些著作如《程式碼大全》、《領域驅動設計》也會有類似的觀點。本文中提到的原則和方法具有一定實操和指導價值,對於很難有定論的問題,也可以在實踐中去探索。

關於原則和方法論,既不必刻意拔高,也不要嗤之以鼻。指導實踐的不是更多的實踐,而是實踐後的總結和思考。應用原則和方法論實質是借鑑已有的經驗,可以減少我們自行摸索的時間。探索新的方法可以幫助我們適應新的場景,但是新方法本身需要經過時間檢驗。

九、參考文件

  • John Ousterhout. A Philosophy of Software Design. Yaknyam Press, 2018.

  • 梅拉尼·米歇爾. 複雜. 湖南科學技術出版社, 2016.

  • Martin Fowler. Refactoring: Improving the Design of Existing Code (2nd Edition) . Addison-Wesley Signature Series, 2018.

作者介紹

政華,順譜,陶鑫,美團打車排程系統工程團隊工程師。

招聘資訊

美團打車排程系統工程團隊誠招高階工程師/技術專家,我們的目標,是與演算法、資料團隊密切協作,建設高效能、高可用、可配置的打車排程引擎, 為使用者提供更好的出行體驗。歡迎有興趣的同學傳送簡歷到[email protected](郵件標題註明:打車排程系統工程團隊)。