下降軟件複雜性的通常原則和方法

1、前言

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

本篇文章是圍繞着「下降複雜性」這個主題展開的,不少重要的結論來源於John Ousterhout,筆者以爲頗有共鳴,就作了一些相關話題的延伸、補充了一些實例。雖然說是"通常原則「,也不意味着是絕對的真理,整理出來,只是爲了引起你們對軟件設計的思考。數據庫

2、如何定義複雜性

關於複雜性,尚無統一的定義,從不一樣的角度能夠給出不一樣的答案。能夠用數量來度量,好比芯片集成的電子器件越多越複雜(不必定對);按層次性[2]度量,複雜度在於層次的遞歸性和不可分解性。在信息論中,使用熵來度量信息的不肯定性。編程

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

子模塊的複雜度cp乘以該模塊對應的開發時間權重值tp,累加後獲得系統的總體複雜度C。系統總體的複雜度並不簡單等於全部子模塊複雜度的累加,還要考慮該模塊的開發維護所花費的時間在總體中的佔比(對應權重值tp)。也就是說,即便某個模塊很是複雜,若是不多使用或修改,也不會對系統的總體複雜度形成大的影響。數據結構

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

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

形成複雜的緣由通常是代碼依賴和晦澀(Obscurity)。其中,依賴是指某部分代碼不能被獨立地修改和理解,一定會牽涉到其餘代碼。代碼晦澀,是指從代碼中難以找到重要信息。併發

3、解決複雜性的通常原則

首先,互聯網行業的軟件系統,很難一開始就作出完美的設計,經過一個個功能模塊衍生迭代,系統纔會逐步成型;對於現存的系統,也很難經過一個大動做,一勞永逸地解決全部問題。系統設計是須要持續投入的工做,經過細節的積累,最終獲得一個完善的系統。所以,好的設計是日拱一卒的結果,在平常工做中要重視設計和細節的改進。編程語言

其次,專業化分工和代碼複用促成了軟件生產率的提高。好比硬件工程師、軟件工程師(底層、應用、不一樣編程語言)能夠在無需瞭解對方技術背景的狀況下進行合做開發;同一領域服務能夠支撐不一樣的上層應用邏輯等等。其背後的思想,無非是經過將系統分紅若干個水平層、明確每一層的角色和分工,來下降單個層次的複雜性。同時,每一個層次只要給相鄰層提供一致的接口,能夠用不一樣的方法實現,這就爲軟件重用提供了支持。分層是解決複雜性問題的重要原則。函數

第三,與分層相似,分模塊是從垂直方向來分解系統。分模塊最多見的應用場景,是現在普遍流行的微服務。分模塊下降了單模塊的複雜性,可是也會引入新的複雜性,例如模塊與模塊的交互,後面的章節會討論這個問題。這裏,咱們將第三個原則肯定爲分模塊。微服務

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

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

4.1 拒絕戰術編程

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

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

設計系統時,很難在開始階段就面面俱到。好的設計應該體如今一個個小的模塊上,修改bug時,也應該抱着設計新系統的心態,完工後讓人感受不到「修補」的痕跡。通過累積,最終造成一個完善的系統。從長期看,對於中大型的系統,將平常開發時間的10-15%用於設計是值得的。有一種觀點認爲,創業公司須要追求業務迭代速度和節省成本,能夠容忍糟糕的設計,這是用錯誤的方法去追求正確的目標。下降開發成本最有效的方式是僱傭優秀的工程師,而不是在設計上作妥協。

4.2 設計兩次

爲一個類、模塊或者系統的設計提供兩套或更多方案,有利於咱們找到最佳設計。以咱們平常的技術方案設計爲例,技術方案本質上須要回答兩個問題,其一,爲何該方案可行? 其二,在已有資源限制下,爲何該方案是最優的?爲了回答第一個問題,咱們須要在技術方案裏補充架構圖、接口設計和時間人力估算。而要回答第二個問題,須要咱們在關鍵點或爭議處提供二到三種方案,並給出建議方案,這樣纔有說服力。一般狀況下,咱們會花費不少的時間準備第一個問題,而忽略第二個問題。其實,回答好第二個問題很重要,大型軟件的設計已經複雜到沒人可以一次就想到最佳方案,一個僅僅「可行」的方案,可能會給系統增長額外的複雜性。對聰明人來講,接受這點更困難,由於他們習慣於「一次搞定問題」。可是聰明人早晚也會碰到本身的瓶頸,在低水平問題上徘徊,不如花費更多時間思考,去解決真正有挑戰性的問題。

5、解決複雜性之分層

5.1 層次和抽象

軟件系統由不一樣的層次組成,層次之間經過接口來交互。在嚴格分層的系統裏,內部的層只對相鄰的層次可見,這樣就能夠將一個複雜問題分解成增量步驟序列。因爲每一層最多影響兩層,也給維護帶來了很大的便利。分層系統最有名的實例是TCP/IP網絡模型。

在分層系統裏,每一層應該具備不一樣的抽象。TCP/IP模型中,應用層的抽象是用戶接口和交互;傳輸層的抽象是端口和應用之間的數據傳輸;網絡層的抽象是基於IP的尋址和數據傳輸;鏈路層的抽象是適配和虛擬硬件設備。若是不一樣的層具備相同的抽象,可能存在層次邊界不清晰的問題。

5.2 複雜性下沉

不該該讓用戶直面系統的複雜性,即使有額外的工做量,開發人員也應當儘可能讓用戶使用更簡單。若是必定要在某個層次處理複雜性,這個層次越低越好。舉個例子,Thrift接口調用時,數據傳輸失敗須要引入自動重試機制,重試的策略顯然在Thrift內部封裝更合適,開放給用戶(下游開發人員)會增長額外的使用負擔。與之相似的是系統裏隨處可見的配置參數(一般寫在XML文件裏),在編程中應當儘可能避免這種狀況,用戶(下游開發人員)通常很難決定哪一個參數是最優的,若是必定要開放參數配置,最好給定一個默認值。

複雜性下沉,並非說把全部功能下移到一個層次,過猶不及。若是複雜性跟下層的功能相關,或者下移後,能大大降低其餘層次或總體的複雜性,則下移。

5.3 異常處理

異常和錯誤處理是形成軟件複雜的罪魁禍首之一。有些開發人員錯誤的認爲處理和上報的錯誤越多越好,這會致使過分防護性的編程。若是開發人員捕獲了異常並不知道如何處理,直接往上層扔,這就違背了封裝原則。

下降複雜度的一個原則就是儘量減小須要處理異常的可能性。而最佳實踐就是確保錯誤終結,例如刪除一個並不存在的文件,與其上報文件不存在的異常,不如什麼都不作。確保文件不存在就行了,上層邏輯不但不會被影響,還會由於不須要處理額外的異常而變得簡單。

6、解決複雜性之分模塊

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

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 拆分和合並

兩個功能,應該放在一塊兒仍是分開?「無論黑貓白貓」,能下降複雜性就好。這裏有一些能夠借鑑的設計思路:

  • 共享信息的模塊應當合併,好比兩個模塊都依賴某個配置項。
  • 能夠簡化接口時合併,這樣能夠避免客戶同時調用多個模塊來完成某個功能。
  • 能夠消除重複時合併,好比抽離重複的代碼到一個單獨的方法中。
  • 通用代碼和專用代碼分離,若是模塊的部分功能能夠通用,建議和專用部分分離。舉個例子,在實際的系統設計中,咱們會將專用模塊放在上層,通用模塊放在下層以供複用。

7、解決複雜性之註釋

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

註釋也能夠做爲系統設計的工具,若是隻須要簡單的註釋就能夠描述模塊的設計思路和功能,說明這個模塊的設計是良好的。另外一方面,若是模塊很難註釋,說明模塊沒有好的抽象。

7.1 註釋的誤區

關於註釋,不少開發者存在一些認識上的誤區,也是形成你們不肯意寫註釋的緣由。好比「好代碼是自注釋的"、"沒有時間「、「現有的註釋都沒有用,爲何還要浪費時間」等等。這些觀點是站不住腳的。「好代碼是自注釋的」只在某些場景下是合理的,好比爲變量和方法選擇合適的名稱,能夠不用單獨註釋。可是更多的狀況,代碼很難體現開發人員的設計思路。此外,若是用戶只能經過讀代碼來理解模塊的使用,說明代碼裏沒有抽象。好的註釋能夠極大地提高系統的可維護性,獲取長期的效率,不存在「沒有時間」一說。註釋也是一種能夠習得的技能,一旦習得,就能夠在後續的工做中應用,這就解決了「註釋沒有用」的問題。

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

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

根據抽象程度,註釋能夠分爲低層註釋和高層註釋,低層次的註釋用來增長精確度,補充完善程序的信息,好比變量的單位、控制條件的邊界、值是否容許爲空、是否須要釋放資源等。高層次註釋拋棄細節,只從總體上幫助讀者理解代碼的功能和結構。這種類型的註釋更好維護,若是代碼修改不影響總體的功能,註釋就無需更新。在實際工做中,須要兼顧細節和抽象。低層註釋拆散與對應的實現代碼放在一塊兒,高層註釋通常用於描述接口。

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

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

避免重複的註釋。若是有重複註釋,開發人員很難找到全部的註釋去更新。解決方法是,能夠找到醒目的地方存放註釋文檔,而後在代碼處註明去查閱對應文檔的地址。若是程序已經在外部文檔中註釋過了,不要在程序內部再註釋了,添加註釋的引用就能夠了。

註釋屬於代碼,而不是提交記錄。一種錯誤的作法是將功能註釋放在提交記錄裏,而不是放在對應代碼文件裏。由於開發人員一般不會去代碼提交記錄裏去查看程序的功能描述,很不方便。

7.3 使用註釋改善系統設計

良好的設計基礎是提供好的抽象,在開始編碼前編寫註釋,能夠幫助咱們提煉模塊的核心要素:模塊或對象中最重要的功能和屬性。這個過程促進咱們去思考,而不是簡單地堆砌代碼。另外一方面,註釋也可以幫助咱們檢查本身的模塊設計是否合理,正如前文中提到,深模塊提供簡單的接口和強大的功能,若是接口註釋冗長複雜,一般意味着接口也很複雜;註釋簡單,意味着接口也很簡單。在設計的早期注意和解決這些問題,會爲咱們帶來長期的收益。

8、後記

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

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

9、參考文檔

  • 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.

做者介紹

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

招聘信息

美團打車調度系統工程團隊誠招高級工程師/技術專家,咱們的目標,是與算法、數據團隊密切協做,建設高性能、高可用、可配置的打車調度引擎, 爲用戶提供更好的出行體驗。歡迎有興趣的同窗發送簡歷到tech@meituan.com(郵件標題註明:打車調度系統工程團隊)。

相關文章
相關標籤/搜索