值得一讀的《架構整潔之道》筆記

“如何寫出整潔的架構”這樣的話題,的確每個人都可以侃侃而談,但真理往往掌握在少數人的手中,他們是資深專家,他們是技術達人。其實某年之前我也範範的談過應用架構設計應遵循的原則,那如何才能獲取少數人的架構思想呢?《架構整潔之道》是一本非常棒的書,它值得每位架構家去參悟,看它如何秉持最簡單的兩個觀點(分層和依賴規則)開發,就能開發出乾淨整潔的系統架構。

1. 軟件的價值

我們所說的架構是軟件系統的架構,所以首先要明確軟件系統的價值,價值有兩方面:

1. 行爲價值

它是軟件的核心價值,包括按需求文檔編寫代碼以及可用性保障(功能性bug、性能、穩定性),這幾乎佔據了我們90%的工作內容,快速響應業務需求也是我們工程師的首要責任。

2. 架構價值

架構價值非常明確,就是讓我們的軟件(Software)更軟(Soft),可以從兩方面理解:

  • 當需求變更時,所需的軟件變更必須簡單方便
  • 變更實施的難度應該和變更的範疇(scope)成等比,而與變更的具體形狀(shape)無關

當我們只關注行爲價值,不關注架構價值時,會發生什麼事情?

書中記錄的真實案例,隨着版本迭代,工程師團隊的規模持續增長,但總代碼行數卻趨於穩定,每行代碼的變更成本升高、工程師的生產效率降低,從老闆的視角,就是公司的成本增長迅猛,如果營收跟不上就要開始賠錢啦。

那架構價值這麼重要性,該如何處理好行爲價值和架構價值的關係?

重要緊急矩陣中,做事的順序是這樣的:1.重要且緊急 > 2.重要不緊急 > 3.不重要但緊急 > 4.不重要且不緊急。

敏捷項目管理

分佈式計算著名的CAP

區塊鏈中的不可能三角

  • 實現行爲價值的需求通常是PD提出的,都比較緊急,但並不總是特別重要
  • 架構價值的工作內容,通常是開發同學提出的,都很重要但基本不是很緊急,短期內不做也死不了

所以行爲價值的事情落在1和3(重要且緊急、不重要但緊急),而架構價值落在2(重要不緊急)。我們研發同學,在低頭敲代碼之前,一定要把雜糅在一起的1和3分開,把我們架構工作插進去。

按筆者的經歷,一般平臺型公司是這樣平衡二者的衝突:工作類型劃分爲262(20%人員處理生產級問題、60%的人員響應新需求、20%的人員響應架構類技術債務)。

2. 架構工作的目標

追求架構價值就是架構工作的目標,說白了,就是用最少的人力成本支撐軟件系統的全生命週期,讓系統便於修改、方便維護、輕鬆部署。優秀的架構需要關注生命週期裏的每個環節:

  • 開發階段:組件不要使用大量複雜的腳手架;不同團隊負責不同的組件,從而避免不必要的協作。
  • 部署階段:部署工作不要依賴成堆的腳本和配置文件;組件越多部署工作越繁重,而部署工作本身是沒有價值的,做的越少越好,所以要減少組件數量。
  • 運行階段:架構設計要考慮到不同的吞吐量、不同的響應時長要求;架構應起到揭示系統運行的作用:用例、功能、行爲設置應該都是對開發者可見的一級實體,以類、函數或模塊的形式佔據明顯位置,命名能清晰地描述對應的功能。
  • 維護階段:減少探祕成本和風險。探祕成本是對現有軟件系統的挖掘工作,確定新功能或修復問題的最佳位置和方式。風險是做改動時,可能衍生出新的問題。

3. 編程範式

其實所謂架構就是限制,限制源碼放在哪裏、限制依賴、限制通信的方式,但這些限制比較上層。

編程範式是最基礎的限制,它限制我們的控制流和數據流:結構化編程限制了控制權的直接轉移,面向對象編程限制了控制權的間接轉移,函數式編程限制了賦值。這三個編程範式最近提出的一個也有半個世紀的歷史了,半個世紀以來沒有提出新的編程範式,以後可能也不會了。因爲編程範式的意義在於限制,限制了控制權轉移限制了數據賦值,其他也沒啥可限制的了。很有意思的是,這三個編程範式提出的時間順序可能與大家的直覺相反,從前到後的順序爲:函數式編程(1936年)、面向對象編程(1966年)、結構化編程(1968年)。

1. 結構化編程

結構化編程證明了人們可以用順序結構、分支結構、循環結構這三種結構構造出任何程序,並限制了 goto 的使用(就是前面提到結構化編程限制了控制權的直接轉移)。遵守結構化編程,工程師就可以像數學家一樣對自己的程序進行推理證明,用代碼將一些已證明可用的結構串聯起來,只要自行證明這些額外代碼是確定的,就可以推導出整個程序的正確性。

什麼叫做控制權的直接轉移?就是函數調用或者 goto 語句,代碼在原來的流程裏不繼續執行了,轉而去執行別的代碼,並且你指明瞭執行什麼代碼。爲什麼要限制 goto 語句?因爲 goto 語句的一些用法會導致某個模塊無法被遞歸拆分成更小的、可證明的單元。而採用分解法將大型問題拆分正是結構化編程的核心價值。

其實遵守結構化編程,工程師們也無法像數學家那樣證明自己的程序是正確的,只能像物理學家一樣,說自己的程序暫時沒有被證僞(沒被找到bug)。數學公式和物理公式的最大區別,就是數學公式可被證明,而物理公式無法被證明,只要目前的實驗數據沒有把它證僞,我們就認爲它是正確的。程序也是一樣,所有的 test case 都通過了,沒發現問題,我們就認爲這段程序暫時是正確的。

2. 面向對象編程

面向對象編程包括封裝、繼承和多態,從架構的角度,這裏只關注多態。多態讓我們更方便、安全地通過函數調用的方式進行組件間通信,它也是依賴反轉(讓依賴與控制流方向相反)的基礎。

在非面向對象的編程語言中,我們如何在互相解耦的組件間實現函數調用?答案是函數指針。比如採用C語言編寫的操作系統中,定義瞭如下的結構體來解耦具體的IO設備,具體 IO 設備的驅動程序只需要把函數指針指到自己的函數就可以了。

struct FILE {
    void (*open)(char* name, int mode);
    void (*close)();
    int (*read)();
    void (*write)(char);
    void (*seek)(long index, int mode);
}

這種通過函數指針進行組件間通信的方式非常脆弱,工程師必須嚴格按照固定約定來初始化函數指針,並嚴格地按照約定來調用這些指針,只要一個人沒有遵守約定,整個程序都會產生極其難以跟蹤和消除的 Bug。所以面向對象編程限制了函數指針的使用(就是前面提到面向對象編程對控制權的間接轉移進行了限制),通過接口-實現、抽象類-繼承的方式來替代。

什麼叫做控制權的間接轉移?就是代碼在原來的流程裏不繼續執行了,轉而去執行別的代碼,但具體執行了啥代碼你也不知道,你只調了個函數指針或者接口。

3. 函數式編程

函數式編程有很多種定義很多種特性,這裏從架構的角度,只關注它的沒有副作用和不修改狀態。

什麼叫限制了賦值?函數式編程中,函數要保持獨立,所有功能就是返回一個新的值,沒有其他行爲,尤其是不得修改外部變量的值。

在架構領域所有的競爭問題、死鎖問題、併發問題都是由可變變量導致的。如果有足夠大的存儲量和計算量,應用程序可以用事件溯源的方式,用完全不可變的函數式編程,只通過事務記錄從頭計算狀態,就避免了前面提到的幾個問題。目前要讓一個軟件系統完全沒有可變變量是不現實的,但是我們可以通過將狀態修改的部分和不需要修改的部分分隔成單獨的組件,提高系統的穩定性和效率。

小結

沒有結構化編程,程序就無法從一塊塊可證僞的邏輯搭建,沒有面向對象編程,跨越組件邊界會是一個非常麻煩而危險的過程,而函數式編程,讓組件更加高效而穩定。沒有編程範式,架構設計將無從談起。

4. 設計原則

和編程範式相比,設計原則和架構的關係更加緊密,設計原則就是架構設計的指導思想,它指導我們如何將數據和函數組織成類,如何將類鏈接起來成爲組件和程序。反向來說,架構的主要工作就是將軟件拆解爲組件,設計原則指導我們如何拆解、拆解的粒度、組件間依賴的方向、組件解耦的方式等。

設計原則有很多,

架構設計的主導原則是OCP(開閉原則)

設計良好的軟件應該易於擴展,同時抗拒修改。這是我們進行架構設計的主導原則,其他的原則都爲這條原則服務

在類和代碼的層級上有:SRP(單一職責原則)、LSP(里氏替換原則)、ISP(接口隔離原則)、DIP(依賴反轉原則)

1. SRP(單一職責原則)

任何一個軟件模塊,都應該有且只有一個被修改的原因,”被修改的原因“指系統的用戶或所有者。該原則指導我們如何拆分組件。

舉個例子,CTO和COO都要統計員工的工時,當前他們要求的統計方式可能是相同的,我們複用一套代碼,這時COO說週末的工時統計要乘以二,按照這個需求修改完代碼,CTO可能就要過來罵街了。當然這是個非常淺顯的例子,實際項目中也有很多代碼服務於多個價值主體,這帶來很大的探祕成本和修改風險。另外當一份代碼有多個所有者時,就會產生代碼合併衝突的問題。

2. LSP(里氏替換原則)

當用同一接口的不同實現互相替換時,系統的行爲應該保持不變。

你一定很疑惑,實現了同一個接口,他們的行爲也肯定是一致的呀,還真不一定。比如說讓正方形繼承矩形,在調用setW和setH時,正方形做的其實是同一個事情,設置它的邊長。這時單元測試用矩形能通過,用矩形的子類正方形就不能通過,實現同樣的接口,但是系統行爲變了,這是違反 LSP 的經典案例。

3. ISP(接口隔離原則)

不依賴任何不需要的方法、類或組件。該原則指導我們的接口設計。

當我們依賴一個接口但只用到了其中的部分方法時,其實我們已經依賴了不需要的方法或類,當這些方法或類有變更時,會引起我們類的重新編譯,或者引起我們組建的重新發布,這些都是不必要的。所以我們最好定義個小接口,把用到的方法拆出來。

4. DIP(依賴反轉原則)

跨越組建邊界的依賴方向永遠與控制流的方向相反。該原則指導我們設計組件間依賴的方向。

依賴反轉原則是個可操作性非常強的原則,當你要修改組件間的依賴方向時,將需要進行組件間通信的類抽象爲接口,接口放在邊界的哪邊,依賴就指向哪邊。

在組件的層級上有:REP(複用、發佈等同原則)、CCP(共同閉包原則)、CRP(共同複用原則)

REP、CCP、CRP 三個原則之間存在彼此競爭的關係,REP 和 CCP 是黏合性原則,它們會讓組件變得更大,而 CRP 原則是排除性原則,它會讓組件變小。遵守REP、CCP而忽略CRP,就會依賴了太多沒有用到的組件和類,而這些組件或類的變動會導致你自己的組件進行太多不必要的發佈;遵守REP、CRP而忽略CCP,因爲組件拆分的太細了,一個需求變更可能要改n個組件,帶來的成本也是巨大的。

1. REP(複用、發佈等同原則)

軟件複用的最小粒度應等同於其發佈的最小粒度。直白地說,就是要複用一段代碼就把它抽成組件。該原則指導我們組件拆分的粒度。

2. CCP(共同閉包原則)

將爲了相同目的而同時修改的類應該放在同一個組件中。CCP原則是SRP原則在組件層面的描述。該原則指導我們組件拆分的粒度。

對大部分應用程序而言,可維護性的重要性遠遠大於可複用性,由同一個原因引起的代碼修改,最好在同一個組件中,如果分散在多個組件中,那麼開發、提交、部署的成本都會上升。

3. CRP(共同複用原則)

不要強迫一個組件依賴它不需要的東西。CRP原則是ISP原則在組件層面的描述。該原則指導我們組件拆分的粒度。

我們項目中的一個真實反例就是谷歌的 Firebase,依賴了10個左右的其他組件,整個就是谷歌全家桶,基本全都用不上,感覺自己被強姦了。

優秀的架構師應該能在上述三角形張力區域中定位一個最適合目前研發團隊狀態的位置,例如在項目早起,CCP比REP更重要,隨着項目的發展,這個最合適的位置也要不停調整。

在組件依賴處理的原則:無依賴環原則、穩定依賴原則、穩定抽象原則

1. 無依賴環原則

健康的依賴應該是個有向無環圖(DAG),互相依賴的組件,實際上組成了一個大組件,這些組件要一起發佈、一起做單元測試。我們可以通過依賴反轉原則 DIP 來解除依賴環。

2. 穩定依賴原則

依賴必須要指向更穩定的方向。

這裏組件的穩定性指的是它的變更成本,和它變更的頻繁度沒有直接的關聯(變更的頻繁程度與需求的穩定性更加相關)。影響組件的變更成本的因素有很多,比如組件的代碼量大小、複雜度、清晰度等等,最最重要的因素是依賴它的組件數量,讓組件難於修改的一個最直接的辦法就是讓很多其他組件依賴於它!

組件穩定性的定量化衡量指標是:不穩定性(I) = 出向依賴數量 / (入向依賴數量 + 出向依賴數量)。如果發現違反穩定依賴原則的地方,解決的辦法也是通過 DIP 來反轉依賴。

3. 穩定抽象原則

一個組件的抽象化程度應該與其穩定性保持一致。爲了防止高階架構設計和高階策略難以修改,通常抽象出穩定的接口、抽象類爲單獨的組件,讓具體實現的組件依賴於接口組件,這樣它的穩定性就不會影響它的擴展性。

組件抽象化程度的定量化描述是:抽象程度(A)= 組件中抽象類和接口的數量 / 組件中類的數量。

5. 組件拆分

組件是一組描述如何將輸入轉化爲輸出的策略語句的集合,在同一個組件中,策略的變更原因、時間、層次相同。從定義就可以看出,組件拆分需要在兩個維度進行:

  • 按變更原因拆分,變更原因就是業務用例,組件拆分的例子是:訂單組件、聊天組件
  • 按層次拆分,可以拆爲:業務實體、用例、接口適配器、框架與驅動程序

業務實體:關鍵業務數據和業務邏輯的集合,與界面無關、與存儲無關、與框架無關,只有業務邏輯

用例:特定場景下的業務邏輯,可以理解爲 輸入 + 業務實體 + 輸出 = 用例

接口適配器:包含整個整個MVC,以及對存儲、設備、界面等的接口聲明和使用

一條策略距離系統的輸入、輸出越遠,它的層次越高,所以業務實體是最高的層,框架與驅動程序是最低的層。

6. 組件依賴處理

前面拆好了組件分好了層,依賴就很好處理了:依賴關係與數據流控制流脫鉤,而與組件所在層次掛鉤,始終從低層次指向高層次,如下圖。越具體的策略處在的層級越低,越插件化。切換數據庫是框架驅動層的事情,接口適配器完全無感知,切換展示器是接口適配器層面的事情,用例完全無感知,而切換用例也不會影響到業務實體。

 

7. 組件邊界處理

一個完整的組件邊界包括哪些內容?首先跨越組件邊界進行通信的兩個類都要抽象爲接口,另外需要聲明專用的輸入數據模型、聲明專用的返回數據模型,想一想每次進行通信時都要進行的數據模型轉換,就能理解維護一個組件邊界的成本有多高。

除非必要,我們應該儘量使用不完全邊界來降低維護組件邊界的成本。不完全邊界有三種方式:

  • 省掉最後一步:聲明好接口,做好分割後,仍然放在一個組件中,等到時機成熟時再拆出來獨立編譯部署。
  • 單向邊界:正常的邊際至少有兩個接口,分別抽象調用方和被調用方。這裏只定義一個接口,高層次組件用接口調用低層次組件,而低層次組件直接引用高層次組件的類。
  • 門戶模式:控制權的間接轉移不用接口和實現去做,而是用門戶類去做,用這種方式連接口都不用聲明瞭。

除了完全邊界和不完全邊界的區分,邊界的解耦方式也可以分爲3個層次:

  • 源碼層次:做了接口、類依賴上的解耦,但是放在同一個組件中,通常放在不同的路徑下。和不完全邊界的省略最後一步一樣。
  • 部署層次:拆分爲可以獨立部署的不同組件,比如 iOS的靜態庫、動態庫,真正運行時處於同一臺物理機器上,組件之間通常通過函數調用通訊。
  • 服務層次:運行在不同的機器上,通過url、網絡數據包等方式進行通訊。

從上到下,(開發、部署)成本依次升高,如果低層次的解耦已經滿足需要,不要進行高層次的解耦。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章