Go 工程化 -一- 架構整潔之道閱讀筆記
前言
-
今天的軟件與過去的軟件本質上仍然是一樣的。都是由 if 語句、賦值語句以及 while 循環組成的
-
軟件架構的規則其實就是排列組合代碼塊的規則
這說明什麼呢,說明了可能我們以爲過時的,古老的技術或者解決方案也是有用的
第一部分 概述
第 1 章 設計與架構究竟是什麼
-
架構圖裏實際上包含了所有的**「底層設計細節」**,這些細節信息共同支撐了頂層的架構設計,**「底層設計信息和頂層架構設計」**共同組成了整個房屋的架構文檔。
-
「軟件架構的終極目標是,用最小的人力成本來滿足構建和維護該系統的需求。」
-
一個軟件架構的優劣,「可以用它滿足用戶需求所需要的成本來衡量。」
-
亂麻系統:這種系統一般都是沒有經過設計,匆匆忙忙被構建起來的
-
我們經常使用一句話來欺騙自己 “我們可以未來再重構代碼,產品上線最重要!”
-
另外一個錯誤的觀點:「“在工程中容忍糟糕的代碼存在可以在短期內加快該工程上線的速度,未來這些代碼會造成一些額外的工作量,但是並沒有什麼大不了”」
-
研發團隊最好的選擇是清晰地認識並避開工程師們過度自信的特點,開始認真地對待自己的代碼架構,對其質量負責
軟件的架構的終極目標,以及如何衡量一個架構的優劣,尤其是兩個錯誤的觀點非常感同身受,我也說過類似的話語,還有一句話是 “當前的需求非常緊急,這只是一個臨時的系統很快就會被替換掉,我們先完成它”。作爲一個專業的技術人員我們需要有一些底線來保證我們的代碼架構和質量,不能輕易妥協,這在 Bob 大叔整潔系列的另外一本書中也有提到。
第 2 章 兩個價值緯度
-
行爲價值
只有可以產生收入的代碼纔是有用的代碼,技術是需要爲業務服務的,但是我們的工作並不是說就按照需求文檔寫代碼,修 bug 就行了
-
軟件系統的行爲是其最直觀的價值維度。程序員的工作就是讓機器按照某種指定方式運轉,給系統的使用者創造或者提高利潤。
-
按照需求文檔編寫代碼,並且修復任何 Bug。這真是大錯特錯。
-
「系統行爲,是緊急的,但是並不總是特別重要。」
-
架構價值
架構價值主要就是爲了能夠應對變化,其實舉個反面例子,我們之前有一個系統 A 是直接在 A 中調用接口獲取數據,隨着業務的發展我們拆分了一個應用 B 需要從 B 中獲取對應的數據,這個時候我們發現代碼變更非常嚴重,從裏到外都需要進行重構修改,這就是典型了依賴了 “具體的形狀” 導致的額外成本
-
爲了達到軟件的本來目的,軟件系統必須夠 “軟”——也就是說,軟件應該容易被修改。
-
當需求方改變需求的時候,隨之所需的軟件變更必須可以簡單而方便地實現。
-
變更實施的難度應該和變更的範疇(scope)成等比關係,而與變更的具體形狀(shape)無關。
-
「系統架構,是重要的,但是並不總是特別緊急。」
-
重要緊急的排序
-
重要且緊急
-
重要不緊急
-
不重要但緊急
-
不重要且不緊急
-
業務 / 市場的同事往往是無法評估架構的重要性的,所以,「平衡系統架構的重要性與功能的緊急程度這件事,是軟件研發人員自己的職責。」
我們當前處在公共技術的部門,這也是一個經常困擾的一個例子,所有的業務方在提需求的時候都會表示需求非常緊急,但是這個功能的實現對我們來說重要嗎?這個需要打上一個大大的問號,其他部門的同學其實是無法對評估需求對於我們的重要性的,這個需要我們自己來權衡。
-
爲好的軟件架構而持續鬥爭
這不僅僅是架構師的職責,這是每一位開發同學的職責,忽略架構的價值會導致我們帶來無休止的加班,領導的質疑,產品的 argue
-
軟件架構師這一職責本身就應更關注系統的整體結構,而不是具體的功能和系統行爲的實現。
-
「軟件架構師必須創建出一個可以讓功能實現起來更容易、修改起來更簡單、擴展起來更輕鬆的軟件架構。」
-
如果忽視軟件架構的價值,系統將會變得越來越難以維護,終會有一天,系統將會變得再也無法修改。
第二部分 從基礎構件開始:編程範式
編程範式指的是程序的編寫模式,與具體的編程語言關係相對較小。這些範式會告訴你應該在什麼時候採用什麼樣的代碼結構 當前的三種編程範式,結構化編程,面向對象,函數式編程
第 3 章 編程範式總覽
-
結構化編程(面向過程)
-
結構化編程對程序控制權的直接轉移進行了限制和規範。
-
限制了 goto 語句的使用
-
面向對象
-
面向對象編程對程序控制權的間接轉移進行了限制和規範。
-
限制了函數指針的使用
-
函數式編程
這個角度之前還沒有看到過,對我而言還是比較新奇,從限制的角度來看不同的編程範式有着不同限制,可以減少在編程當中出錯的可能
-
函數式編程對程序中的賦值進行了限制和規範。
-
限制了賦值語句的使用
第 4 章 結構化編程
-
Bohm 和 Jocopini 剛剛證明了人們可以用順序結構、分支結構、循環結構這三種結構構造出任何程序。
-
證明了我們構建可推導模塊所需要的控制結構集與構建所有程序所需的控制結構集的最小集是等同的。
-
結構化編程範式可將模塊遞歸降解拆分爲可推導的單元,這就意味着模塊也可以按功能進行降解拆分。
-
測試只能展示 Bug 的存在,並不能證明不存在 Bug。
結構化編程可以讓我們將一個大的模塊按照功能進行拆分,變成小的功能模塊,同時通過測試我們可以證明其錯誤性,無論是架構上還是實際的開發過程中,大模塊拆小模塊的思路的數不勝數,其實單體應用拆分爲微服務應用也是這個範疇內的。
-
換句話說,一段程序可以由一個測試來證明其錯誤性,但是卻不能被證明是正確的。測試的作用是讓我們得出某段程序已經足夠實現當前目標這一結論。
第 5 章 面向對象編程
-
什麼是面向對象?
-
面向對象理論是在 1966 年提出的,當時 Dahl 和 Nygaard 主要是將函數調用棧遷移到了堆區域中
-
一種常見的回答是 “數據與函數的組合”,這種不太貼切
-
另一種常見的回答是 “面向對象編程是一種對真實世界進行建模的方式”,這有點避重就輕
-
面向對象編程是封裝(encapsulation)、繼承(inheritance)、多態(polymorphism)這三項的有機組合
-
封裝
-
通過採用封裝特性,我們可以把一組相關聯的數據和函數圈起來,使圈外面的代碼只能看見部分函數,數據則完全不可見
-
C 語言也支持完整的封裝特性,使用 C 語言的時候應用頭文件
.h
的模塊是無法知道結構體中的成員變量的,但是 C++ 的頭文件中包含了成員信息。 -
不是面嚮對象語言的 C 語言相對於面嚮對象語言 C++ 反而擁有更好的封裝特性,所以**「我們很難說強封裝是面向對象編程的必要條件」**
-
繼承
-
繼承的主要作用是讓我們可以在某個作用域內對外部定義的某一組變量與函數進行覆蓋
-
C 其實也可以實現繼承,只是相對面向對象語言而言會更加困難。
-
多態
-
歸根結底,多態其實不過就是函數指針的一種應用。但是函數指針非常危險,需要人爲的遵守很多約定,容易出 bug。
-
面向對象編程語言雖然在多態上並沒有理論創新,但它們也確實讓多態變得更安全、更便於使用了。
-
依賴反轉
-
依賴關係(或者叫繼承關係)的方向和控制流正好是相反的,我們稱之爲依賴反轉
-
依賴關係都可以通過引入接口的方式來進行反轉。
-
通過這種方法,軟件架構師可以完全控制採用了面向對象這種編程方式的系統中所有的源代碼依賴關係,
-
而不再受到系統控制流的限制。不管哪個模塊調用或者被調用,軟件架構師都可以隨意更改源代碼依賴關係。
-
當某個組件的源代碼需要修改時,僅僅需要重新部署該組件,不需要更改其他組件,這就是獨立部署能力。
-
** 面向對象編程就是以多態爲手段來對源代碼中的依賴關係進行控制的能力,** 這種能力讓軟件架構師可以構建出某種插件式架構,讓高層策略性組件與底層實現性組件相分離,底層組件可以被編譯成插件,實現獨立於高層組件的開發和部署。
在剛學習編程的時候,學到面向對象一定會說到,封裝、繼承、和多態,但是通過這一章我們可以發現,面嚮對象語言的封裝不一定比面向過程的 C 語言做的更好,這裏強調的更重要的是使用多態的手段對源碼的依賴關係進行控制,主要是指通過接口來實現依賴反轉,這樣就可以將組件進行分離,可以進行獨立開發和部署。我現在主要使用的語言是 Go,有一個常見的問題就是 Go 是不是一個面嚮對象語言,回答也是 Yes or no,是也不是,Go 不支持繼承,也不支持函數重載,運算符重載等在面嚮對象語言非常常見的特性,但是 Go 的接口非常強大,不需要顯示依賴接口的設計讓我們在依賴反轉的使用上更加遊刃有餘。
第 6 章 函數式編程
-
函數式編程語言中的變量(Variable)是不可變(Vary)的。
-
爲什麼軟件架構師要操心變量的可變性呢?答案顯而易見:所有的競爭問題、死鎖問題、併發更新問題都是由可變變量導致的。
-
一個架構設計良好的應用程序應該將狀態修改的部分和不需要修改狀態的部分隔離成單獨的組件,然後用合適的機制來保護可變量。
-
事件溯源體系下,我們只存儲事務記錄,不存儲具體狀態。當需要具體狀態時,我們只要從頭開始計算所有的事務即可。
-
這種數據存儲模式中不存在刪除和更新的情況,我們的應用程序不是 CRUD,而是 CR。因爲更新和刪除這兩種操作都不存在了,自然也就不存在併發問題。
在我們剛剛結束的上一個系列,Go 併發編程 中,我們講到的大量手段來避免數據競爭,這些都是由於在併發時寫入導致的,而函數式編程最重要的一個特性就是變量不可變,由於變量無法被修改所以自然而然就不存在數據競爭,也就不需要加鎖,這樣可以獲得很高的性能。
第三部分 設計原則
軟件構建中層結構的主要目標:
-
使軟件可容忍被改動。
-
使軟件更容易被理解。
-
構建可在多個軟件系統中複用的組件。
在之前的 《Go 設計模式》 系列文章當中也有提到 SOLID 原則,換個角度可以發現這些其實都是殊途同歸的一些東西,SOLID 原則的歷史已經非常悠久了,但是直到現在它仍然非常具有指導意義。
第 7 章 SRP:單一職責原則
-
「任何一個軟件模塊都應該有且僅有一個被修改的原因。」
-
任何一個軟件模塊都應該只對一個用戶(User)或系統利益相關者(Stakeholder)負責。
-
「任何一個軟件模塊都應該只對某一類行爲者負責。」
-
「反例: 代碼合併衝突」
單一職責原則非常容易被誤認爲 “每個模塊應該只做一件事”,沒錯之前我也是這麼理解的,雖然這個描述沒錯,但是這並不是 SRP 的全部。
-
多人爲了不同的目的修改了同一份源代碼,這很容易造成問題的產生。
-
避免這種問題產生的方法就是將服務不同行爲者的代碼進行切分。
第 8 章 OCP:開閉原則
-
設計良好的計算機軟件應該易於擴展,同時抗拒修改。
-
換句話說,「一個設計良好的計算機系統應該在不需要修改的前提下就可以輕易被擴展。」
-
一個好的軟件架構設計師會努力將舊代碼的修改需求量降至最小,甚至爲 0。
-
可以先將滿足不同需求的代碼分組(即 SRP),然後再來調整這些分組之間的依賴關係(即 DIP)
-
如果 A 組件不想被 B 組件上發生的修改所影響,那麼就應該讓 B 組件依賴於 A 組件。
-
軟件架構師可以根據相關函數被修改的原因、修改的方式及修改的時間來對其進行分組隔離,並將這些互相隔離的函數分組整理成組件結構,使得高階組件不會因低階組件被修改而受到影響。
-
OCP 是我們進行系統架構設計的主導原則,其主要目標是讓系統易於擴展,同時限制其每次被修改所影響的範圍。
開閉原則在架構設計上非常常見,其中最常見的做法就是使用接口實現依賴反轉,如果開閉原則實現的不好就有可能導致我們在進行後續功能擴展的時候牽一髮而動全身,成本非常的高。
第 9 章 LSP:里氏替換原則
-
如果對於每個類型是 S 的對象 o1 都存在一個類型爲 T 的對象 o2,能使操作 T 類型的程序 P 在用 o2 替換 o1 時行爲保持不變,我們就可以將 S 稱爲 T 的子類型。
-
比較常見的一個違反 LSP 原則的例子,長方形與正方形
這個反面例子對我的震撼比較大,依稀記得最開始在學習編程語言繼承的例子的時候就常常用長方形正方形來舉例,但是這個其實是違反了裏式替換原則的。在架構設計上這個原則也十分的重要,因爲我們只有做到了 LSP 我們纔可以在例如數據庫類型切換,微服務拆分這種場景下做的遊刃有餘。
-
Square 類並不是 Rectangle 類的子類型,因爲 Rectangle 類的高和寬可以分別修改,而 Square 類的高和寬則必須一同修改。
第 10 章 ISP:接口隔離原則
-
ISP 最初的成因:在一般情況下,任何層次的軟件設計如果依賴於不需要的東西,都會是有害的。
-
任何層次的軟件設計如果依賴了它並不需要的東西,就會帶來意料之外的麻煩。
由於 Go 接口的隱式依賴的特性,讓 ISP 在 Go 中處處可見,我們常常採用的方式就是在調用者處依賴接口,而不管實現,這樣就可以做到,模塊分離以及最小化依賴。
第 11 章 DIP:依賴反轉原則
-
如果想要設計一個靈活的系統,在源代碼層次的依賴關係中就應該多引用抽象類型,而非具體實現。
-
在應用 DIP 時,我們也不必考慮穩定的操作系統或者平臺設施,因爲這些系統接口很少會有變動。
-
主要應該關注的是軟件系統內部那些會經常變動的(volatile)具體實現模塊,這些模塊是不停開發的,也就會經常出現變更。
-
編碼規範
通常來說,接口會比實現更加穩定,舉個反例,如果接口變動實現是必須要跟着修改的,因爲實現是依賴接口的,但是反過來確未必。DIP 原則指導我們無論是在架構設計還是在編碼實現當中都應該儘量的依賴抽象而不是實現細節。
-
應在代碼中多使用抽象接口,儘量避免使用那些多變的具體實現類。
-
不要在具體實現類上創建衍生類。我們對繼承的使用應該格外小心。即使是在稍微便於修改的動態類型語言中,這條守則也應該被認真考慮
-
不要覆蓋(override)包含具體實現的函數
-
應避免在代碼中寫入與任何具體實現相關的名字,或者是其他容易變動的事物的名字。
第四部分 組件構建原則
第 12 章 組件
-
組件是軟件的部署單元,是整個軟件系統在部署過程中可以獨立完成部署的最小實體
-
例如:.jar, .gem, .dll 文件
-
鏈接加載器讓程序員們可以將程序切分成多個可被分別編譯、加載的程序段
-
組件化的插件式架構已經成爲我們習以爲常的軟件構建形式了。
第 13 章 組件聚合
-
構建組件相關的基本原則
-
REP:複用 / 發佈等同原則
-
CCP:共同閉包原則
-
CRP:共同複用原則
-
REP:複用 / 發佈等同原則
-
軟件複用的最小粒度應等同於其發佈的最小粒度。
-
REP 原則就是指組件中的類與模塊必須是彼此緊密相關的
-
一個組件不能由一組毫無關聯的類和模塊組成,它們之間應該有一個共同的主題或者大方向。
-
CCP:共同閉包原則
-
我們應該將那些會同時修改,並且爲相同目的而修改的類放到同一個組件中,而將不會同時修改,並且不會爲了相同目的而修改的那些類放到不同的組件中。
-
CRP:共同複用原則
-
不要強迫一個組件的用戶依賴他們不需要的東西。
-
不要依賴不需要用到的東西。
-
組件張力圖
看到這三個原則會感到有點熟悉,像共同閉包原則就和 SOLID 中的單一職責原則類似,共同複用原則和接口隔離原則看上去也有那麼幾分相似,這些知識從不同的角度看待總結問題的不同術語。最後這個組件張力圖很有意思,這說明我們在進行架構設計的時候是不可能做到每一項都很完美的,這當中會有一個取捨的過程,書中講到,一般而言會項目初期會從三角右側開始,進行一段時間後會滑動到左邊,是因爲在初期爲了效率我們可以犧牲一定的複用性,但是隨着依賴關係越來越複雜,那麼我們就要考慮複用和擴展了。
第 14 章 組件耦合
-
組件依賴關係圖中不應該出現環。
-
當組件結構依賴圖中存在循環依賴時,想要按正確的順序構建組件幾乎是不可能的。
-
打破循環依賴
-
應用依賴反轉原則(DIP)
-
創建一個新的組件,並讓 Entities 與 Authorize 這兩個組件都依賴於它。將現有的這兩個組件中互相依賴的類全部放入新組件
-
組件結構圖是不可能自上而下被設計出來的。它必須隨着軟件系統的變化而變化和擴張,而不可能在系統構建的最初就被完美設計出來。
-
組件依賴結構圖並不是用來描述應用程序功能的,它更像是應用程序在構建性與維護性方面的一張地圖
-
組件結構圖中的一個重要目標是指導如何隔離頻繁的變更
-
如果我們在設計具體類之前就來設計組件依賴關係,那麼幾乎是必然要失敗的。因爲在當下,我們對項目中的共同閉包一無所知,也不可能知道哪些組件可以複用,這樣幾乎一定會創造出循環依賴的組件。
在 Go 中在編譯器上就限制了我們不能出現循環依賴,所以我們大量的使用了 DIP 的方式,但是講層次拔高一點,從微服務的角度來講仍然不應該出現循環依賴,如果出現那麼在版本發佈的時候可能會導致災難性的後果,架構的原則都是想通的,我們要時刻警惕循環依賴的出現,對於微服務來說可以在 api 網關進行判定是否成環
-
穩定依賴原則
-
依賴關係必須要指向更穩定的方向
-
任何一個我們預期會經常變更的組件都不應該被一個難於修改的組件所依賴,否則這個多變的組件也將會變得非常難以被修改
-
讓軟件組件難於修改的一個最直接的辦法就是讓很多其他組件依賴於它。
-
穩定性指標
這一部分提出了一個對我現階段非常有用的一個原則,被大量依賴的組件應該是穩定的,依賴關係必須要指向更穩定的方向,我當前處在公共技術團隊,我們的服務被外部大量的依賴,所以在變更的時候會非常的麻煩,我們 I 值非常的小,幾乎可以說接近於 0,所以我們的服務在設計時一定要滿足開閉原則,保證足夠的擴展性。
-
Fan-in:入向依賴,這個指標指代了組件外部類依賴於組件內部類的數量。
-
Fan-out:出向依賴,這個指標指代了組件內部類依賴於組件外部類的數量。
-
I:不穩定性,I=Fan-out/(Fan-in+Fan-out)。該指標的範圍是 [0,1],I=0 意味着組件是最穩定的,I=1 意味着組件是最不穩定的。
-
其中一種方法是計算所有入和出的依賴關係。通過這種方法,我們就可以計算出一個組件的位置穩定性(positionalstability)。
-
穩定依賴原則(SDP)的要求是讓每個組件的 I 指標都必須大於其所依賴組件的 I 指標。也就是說,組件結構依賴圖中各組件的 I 指標必須要按其依賴關係方向遞減。
-
穩定抽象原則
穩定抽象原則說明了越穩定的組件應該越抽象,從代碼的角度來講,接口是最抽象的組件之一,因爲接口一般不會有其他外部的依賴,而被大量依賴,同時還給出一個統計抽象程度的方法,這個可以用來統計一下我們現在的現狀。
-
只有多變的軟件組件落在痛苦區中才會造成麻煩
-
現在我們來看看靠近(1,1)這一位置點的組件。該位置上的組件不會是我們想要的,因爲這些組件通常是無限抽象的,但是沒有被其他組件依賴,這樣的組件往往無法使用。
-
追求讓這些組件位於主序列線上,或者貼近這條線即可。
-
Nc:組件中類的數量。
-
Na:組件中抽象類和接口的數量。
-
A:抽象程度,A=Na÷Nc
-
A 指標的取值範圍是從 0 到 1,值爲 0 代表組件中沒有任何抽象類,值爲 1 就意味着組件中只有抽象類。
-
一個組件的抽象化程度應該與其穩定性保持一致。
-
如何才能讓一個無限穩定的組件(I=0)接受變更呢?開閉原則(OCP)爲我們提供了答案。這個原則告訴我們:創造一個足夠靈活、能夠被擴展,而且不需要修改的類是可能的,而這正是我們所需
-
假設 A 指標是對組件抽象化程度的一個衡量,它的值是組件中抽象類與接口所佔的比例。那麼:
-
-
D 指標 [8]:距離 D=|A+I-1|,該指標的取值範圍是 [0,1]。值爲 0 意味着組件是直接位於主序列線上的,值爲 1 則意味着組件在距離主序列最遠的位置。
-
對於一個良好的系統設計來說,D 指標的平均值和方差都應該接近於 0
第五部分 軟件架構
第 15 章 什麼是軟件架構
-
軟件架構師自身需要是程序員,並且必須一直堅持做一線程序員,絕對不要聽從那些說應該讓軟件架構師從代碼中解放出來以專心解決高階問題的僞建議
-
如果不親身承受因系統設計而帶來的麻煩,就體會不到設計不佳所帶來的痛苦,接着就會逐漸迷失正確的設計方向。
這個也是常常會遇到的問題,就現在我能觀察到的爲例,架構師級別的基本上沒有看到過再做一線的程序開發工作,僅僅是平時的各種管理,規劃上的事務就已經忙的不可開交,這其實不僅僅導致了架構師本身會脫節,同時也會導致下面的同學很少有機會學習到架構師們過往的經驗。
-
軟件架構這項工作的實質就是規劃如何將系統切分成組件,並安排好組件之間的排列關係,以及組件之間互相通信的方式。
-
設計軟件架構的目的,就是爲了在工作中更好地對這些組件進行研發、部署、運行以及維護。
-
如果想設計一個便於推進各項工作的系統,其策略就是要在設計中儘可能長時間地保留儘可能多的可選項。
-
設計良好的架構可以讓系統便於理解、易於修改、方便維護,並且能輕鬆部署。「軟件架構的終極目標就是最大化程序員的生產力,同時最小化系統的總運營成本。」
-
開發
-
實現一鍵式的輕鬆部署應該是我們設計軟件架構的一個目標
-
運行
人力成本往往會比機器的成本更高,所以這也就是我們在代碼編寫的過程當中對可讀性和性能需要有一個權衡,如果不是差異過大往往代碼的可讀性需要更爲重要
-
幾乎任何運行問題都可以通過增加硬件的方式來解決,這避免了軟件架構的重新設計
-
基於投入 / 產出比的考慮,我們的優化重心應該更傾向於系統的開發、部署以及維護
-
一個設計良好的軟件架構應該能明確地反映該系統在運行時的需求。
-
維護
-
在軟件系統的所有方面中,維護所需的成本是最高的
-
保持可選項
-
軟件的高層策略不應該關心其底層到底使用哪一種數據庫
-
開發的早期階段也不應該選定使用的 Web 服務
-
軟件的高層策略壓根不應該跟這些有關。
-
在開發的早期階段不應過早地採用依賴注入框架
-
軟件有行爲價值與架構價值兩種價值。這其中的第二種價值又比第一種更重要
-
軟件的靈活性則取決於系統的整體狀況、組件的佈置以及組件之間的連接方式。
-
如果在開發高層策略時有意地讓自己擺脫具體細節的糾纏,我們就可以將與具體實現相關的細節決策推遲或延後,因爲越到項目的後期,我們就擁有越多的信息來做出合理的決策。
-
一個優秀的軟件架構師應該致力於最大化可選項數量
-
** 優秀的架構師會小心地將軟件的高層策略與其底層實現隔離開,讓高層策略與實現細節脫鉤,使其策略部分完全不需要關心底層細節,當然也不會對這些細節有任何形式的依賴。** 另外,**「優秀的架構師所設計的策略應該允許系統儘可能地推遲與實現細節相關的決策,越晚做決策越好」**
這一點其實很容易被忽略掉,因爲我們經常做的工作就是細節性的工作,在進行設計的時候很容易就不自覺的假定 Web UI,MySQL 數據庫這些技術選型,在這本書的最後一個章節還會講到,這些細節。
第 16 章 獨立性
-
用例
-
軟件的架構必須爲其用例提供支持。
-
任何一個組織在設計系統時,往往都會複製出一個與該組織內溝通結構相同的系統。
-
一個設計良好的架構通常不會依賴於成堆的腳本與配置文件,也不需要用戶手動創建一堆 “有嚴格要求” 的目錄與文件
-
如果我們按照變更原因的不同對系統進行解耦,就可以持續地向系統內添加新的用例,而不會影響舊有的用例。如果我們同時對支持這些用例的 UI 和數據庫也進行了分組,那麼每個用例使用的就是不同面向的 UI 與數據庫,因此增加新用例就更不太可能會影響舊有的用例了。
-
如果有兩段看起來重複的代碼,它們走的是不同的演進路徑,也就是說它們有着不同的變更速率和變更緣由,那麼這兩段代碼就不是真正的重複
-
解耦模式
“如果兩段看似重複的代碼,如果有不同的變更速率和原因,那麼這兩段代碼就不算是真正的重複” 這有個非常典型的例子就是 API 接口的參數和最後我們模型數據雖然很多時候大部分字段是相同的,但是它們的變更速率和原因其實都是不一樣的,如果把他們耦合在一起雖然前期可能可以減少一些代碼的編寫,但是到最後需要擴展時會發現變更會很困難。之前我還寫了一篇文章 《Go Web 小技巧(三)Gin 參數綁定 》總結這種埋坑的技巧 😂
-
源碼層次:我們可以控制源代碼模塊之間的依賴關係,以此來實現一個模塊的變更不會導致其他模塊也需要變更或重新編譯
-
部署層次:我們可以控制部署單元(譬如 jar 文件、DLL、共享庫等)之間的依賴關係,以此來實現一個模塊的變更不會導致其他模塊的重新構建和部署。
-
服務層次:我們可以將組件間的依賴關係降低到數據結構級別,然後僅通過網絡數據包來進行通信。
-
一個設計良好的架構應該能允許一個系統從單體結構開始,以單一文件的形式部署,然後逐漸成長爲一組相互獨立的可部署單元,甚至是獨立的服務或者微服務。最後還能隨着情況的變化,允許系統逐漸回退到單體結構
第 17 章 劃分邊界
-
軟件架構設計本身就是一門劃分邊界的藝術。
-
通過劃清邊界,我們可以推遲和延後一些細節性的決策,這最終會爲我們節省大量的時間、避免大量的問題。
-
I/O 是無關緊要的
-
GUI 和 BusinessRules 這兩個組件之間也應該有一條邊界線
-
插件式架構的好處
真正核心的是我們業務邏輯,而輸入輸出是細節
-
-
將系統設計爲插件式架構,就等於構建起了一面變更無法逾越的防火牆。換句話說,只要 GUI 是以插件形式插入系統的業務邏輯中的,那麼 GUI 這邊所發生的變更就不會影響系統的業務邏輯。
-
邊界線也應該沿着系統的變更軸來畫。也就是說,位於邊界線兩側的組件應該以不同原因、不同速率變化着。
第 18 章 邊界剖析
-
跨邊界調用指的是邊界線一側的函數調用另一側的函數,並同時傳遞數據的行爲
-
最簡單的跨邊界調用形式,是由低層客戶端來調用高層服務函數,這種依賴關係在運行時和編譯時會保持指向一致,都是從低層組件指向高層組件
-
在單體結構中,組件之間的交互一般情況下都只是普通的函數調用,迅速而廉價,這就意味着這種跨源碼層次解耦邊界的通信會很頻繁
-
服務之間的跨邊界通信相對於函數調用來說,速度是非常緩慢的,其往返時間可以從幾十毫秒到幾秒不等。
不同的邊界的跨邊界調用的成本是不同的,對於服務而言跨服務調用的成本非常高,這樣我們在進行服務劃分的時候一定要儘量的內聚減少頻繁調用的情況。
第 19 章 策略與層次
-
策略
-
本質上,所有的軟件系統都是一組策略語句的集合
-
變更原因、時間和層次相同的策略應該被分到同一個組件中。反之,變更原因、時間和層次不同的策略則應該分屬於不同的組件
-
依賴關係的方向通常取決於它們所關聯的組件層次。一般來說,低層組件被設計爲依賴於高層組件
-
層次
距離 I/O 越遠的策略層次越高,也就是說我們常見的 Web UI 應該屬於最低層次,我們不應該依賴 Web UI 這種輸入輸出設備。同時給出了組件的劃分原則,變更的時間原因和層次相同的屬於同一個組件。
-
一條策略距離系統的輸入 / 輸出越遠,它所屬的層次就越高。而直接管理輸入 / 輸出的策略在系統中的層次是最低的。
-
數據流向和源碼中的依賴關係並不總處於同一方向上
-
我們希望源碼中的依賴關係與其數據流向脫鉤,而與組件所在的層次掛鉤。
-
低層組件應該成爲高層組件的插件
第 20 章 業務邏輯
-
業務邏輯就是程序中那些真正用於賺錢或省錢的業務邏輯與過程
-
“關鍵業務邏輯” 是一項業務的關鍵部分,不管有沒有自動化系統來執行這項業務,這一點是不會改變的。
-
業務實體
-
業務實體這個概念中應該只有業務邏輯,沒有別的。
-
業務實體這個概念只要求我們將關鍵業務數據和關鍵業務邏輯綁定在一個獨立的軟件模塊內。
-
業務實體不一定是類
-
用例(usecase)
用例和業務實體應該是應用當中最重要的,所以我們的單元測試最低的要求就是要覆蓋所有的 usecase 邏輯,這一部分應該保持純淨不依賴數據庫,Web 等 I/O 方式
-
用例本質上就是關於如何操作一個自動化系統的描述,它定義了用戶需要提供的輸入數據、用戶應該得到的輸出信息以及產生輸出所應該採取的處理步驟。
-
用例中包含了對如何調用業務實體中的關鍵業務邏輯的定義。簡而言之,用例控制着業務實體之間的交互方式。
-
用例除非正式地描述了數據流入 / 流出接口以外,並不詳細描述用戶界面。
-
用例並不描述系統與用戶之間的接口,它只描述該應用在某些特定情景下的業務邏輯,這些業務邏輯所規範的是用戶與業務實體之間的交互方式,它與數據流入 / 流出系統的方式無關。
-
業務實體並不會知道是哪個業務用例在控制它們,這也是依賴反轉原則(DIP)的另一個應用情景
-
爲什麼業務實體屬於高層概念,而用例屬於低層概念呢?因爲用例描述的是一個特定的應用情景,這樣一來,用例必然會更靠近系統的輸入和輸出。
-
選擇直接在數據結構中使用對業務實體對象的引用。畢竟,業務實體與請求 / 響應模型之間有很多相同的數據。但請一定不要這樣做!這兩個對象存在的意義是非常、非常不一樣的。隨着時間的推移,這兩個對象會以不同的原因、不同的速率發生變更。
-
這些業務邏輯應該保持純淨,不要摻雜用戶界面或者所使用的數據庫相關的東西。在理想情況下,這部分代表業務邏輯的代碼應該是整個系統的核心,其他低層概念的實現應該以插件形式接入系統中。業務邏輯應該是系統中最獨立、複用性最高的代碼。
再次強調了不要偷懶,今天剛好看到之前寫的一個反面例子的代碼,代碼裏面有一個 GetA 函數,從數據庫當中獲取 A 對象數據和一些統計數據,這個函數中的統計數據部分其實只有在一個 Web 頁面的接口中使用到,但是爲了偷懶,在其他地方查詢的時候也調用了這個函數,導致最後很多地方的接口性能都由於這個沒用的統計數據多耗費了將近 1s 的時間。
第 21 章 尖叫的軟件架構
-
架構設計的主題
-
軟件的系統架構應該爲該系統的用例提供支持。這就像住宅和圖書館的建築計劃滿篇都在非常明顯地凸顯這些建築的用例一樣,軟件系統的架構設計圖也應該非常明確地凸顯該應用程序會有哪些用例
-
架構設計的核心目標
-
一個良好的架構設計應該圍繞着用例來展開,這樣的架構設計可以在脫離框架、工具以及使用環境的情況下完整地描述用例
-
良好的架構設計應該儘可能地允許用戶推遲和延後決定採用什麼框架、數據庫、Web 服務以及其他與環境相關的工具
-
良好的架構設計應該只關注用例,並能將它們與其他的周邊因素隔離。
-
可測試的架構設計
-
我們在運行測試的時候不應該運行 Web 服務,也不應該需要連接數據庫。我們測試的應該只是一個簡單的業務實體對象,沒有任何與框架、數據庫相關的依賴關係。
-
一個系統的架構應該着重於展示系統本身的設計,而並非該系統所使用的框架
用例是架構設計當中最應該關注的部分,框架數據庫 Web 服務的選擇都是細節,這些細節應該延後選擇,我們的用例不應該依賴這些細節,這樣才能很好的測試
第 22 章 整潔架構
-
按照不同關注點對軟件進行切割。也就是說,這些架構都會將軟件切割成不同的層,至少有一層是隻包含該軟件的業務邏輯的,而用戶接口、系統接口則屬於其他層。
-
特點
-
獨立於框架:這些系統的架構並不依賴某個功能豐富的框架之中的某個函數。
-
可被測試:這些系統的業務邏輯可以脫離 UI、數據庫、Web 服務以及其他的外部元素來進行測試。
-
獨立於 UI:這些系統的 UI 變更起來很容易,不需要修改其他的系統部分。
-
獨立於數據庫:我們可以輕易將這些系統使用的
-
獨立於任何外部機構:這些系統的業務邏輯並不需要知道任何其他外部接口的存在。
-
依賴關係規則
-
外層圓代表的是機制,內層圓代表的是策略。
-
源碼中的依賴關係必須只指向同心圓的內層,即由低層機制指向高層策略。
-
外層圓中使用的數據格式也不應該被內層圓中的代碼所使用,尤其是當數據格式是由外層圓的框架所生成時。
-
業務實體
-
業務實體這一層中封裝的是整個系統的關鍵業務邏輯,一個業務實體既可以是一個帶有方法的對象,也可以是一組數據結構和函數的集合。
-
用例
-
軟件的用例層中通常包含的是特定應用場景下的業務邏輯,這裏面封裝並實現了整個系統的所有用例。這些用例引導了數據在業務實體之間的流入 / 流出,並指揮着業務實體利用其中的關鍵業務邏輯來實現用例的設計目標。
-
接口適配器
-
軟件的接口適配器層中通常是一組數據轉換器,它們負責將數據從對用例和業務實體而言最方便操作的格式,轉化成外部系統(譬如數據庫以及 Web)最方便操作的格式。
-
層次越往內,其抽象和策略的層次越高,同時軟件的抽象程度就越高,其包含的高層策略就越多。最內層的圓中包含的是最通用、最高層的策略,最外層的圓包含的是最具體的實現細節。
-
這裏最重要的是這個跨邊界傳輸的對象應該有一個獨立、簡單的數據結構。
-
「不要投機取巧地直接傳遞業務實體或數據庫記錄對象。」
看過前面的部分再來看整潔架構這一章節會發現非常的自然
第 23 章 展示器和謙卑對象
-
謙卑對象模式
-
謙卑對象模式最初的設計目的是幫助單元測試的編寫者區分容易測試的行爲與難以測試的行爲,並將它們隔離。
-
展示器與視圖
-
視圖部分屬於難以測試的謙卑對象。這種對象的代碼通常應該越簡單越好,它只應負責將數據填充到 GUI 上,而不應該對數據進行任何處理。
-
展示器則是可測試的對象。展示器的工作是負責從應用程序中接收數據,然後按視圖的需要將這些數據格式化,以便視圖將其呈現在屏幕上。
-
展示器則是可測試的對象。展示器的工作是負責從應用程序中接收數據,然後按視圖的需要將這些數據格式化,以便視圖將其呈現在屏幕上。
-
視圖部分除了加載視圖模型所需要的值,不應該再做任何其他事情。因此,我們才能說視圖是謙卑對象
-
數據庫網關
-
這些實現也應該都屬於謙卑對象,它們應該只利用 SQL 或其他數據庫提供的接口來訪問所需要的數據。
-
交互器儘管不屬於謙卑對象,卻是可測試的,
-
數據映射器(ORM)
-
這樣的 ORM 系統應該屬於系統架構中的哪一層呢?當然是數據庫層。ORM 其實就是在數據庫和數據庫網關接口之間構建了另一種謙卑對象的邊界。
-
因爲跨邊界的通信肯定需要用到某種簡單的數據結構,而邊界會自然而然地將系統分割成難以測試的部分與容易測試的部分,所以通過在系統的邊界處運用謙卑對象模式,我們可以大幅地提高整個系統的可測試性。
這裏主要是將很難進行單元測試的行爲和容易測試的行爲進行分離,很難被測試的行爲常常會被分離成爲一個謙卑對象,這個對象非常的簡單,不會包含很多邏輯
第 24 章 不完全邊界
-
構建不完全邊界的方式
架構是需要取捨的,我們不可能每一項都做的很完美,邊界的劃分也是這樣,所以就有了不完全的邊界
-
構建不完全邊界的一種方式就是在將系統分割成一系列可以獨立編譯、獨立部署的組件之後,再把它們構建成一個組件。
-
單向邊界
-
門戶模式
第 25 章 層次與邊界
-
「過度的工程設計往往比工程設計不足還要糟糕」
-
現實就是這樣。作爲軟件架構師,我們必須有一點未卜先知的能力。有時候要依靠猜測——當然還要用點腦子。軟件架構師必須仔細權衡成本,決定哪裏需要設計架構邊界,以及這些地方需要的是完整的邊界,還是不完全的邊界,還是可以忽略的邊界
-
架構師必須持續觀察系統的演進,時刻注意哪裏可能需要設計邊界,然後仔細觀察這些地方會由於不存在邊界而出現哪些問題。
不要過度優化,但是也不要什麼都不管的一把梭,架構師需要演進和取捨的,沒有完美的架構只有不斷持續演進優化的架構。
第 26 章 Main 組件
-
Main 是最細節化的部分
-
Main 組件的任務是創建所有的工廠類、策略類以及其他的全局設施,並最終將系統的控制權轉交給最高抽象層的代碼來處理。
-
Main 組件中的依賴關係通常應該由依賴注入框架來注入。
-
我們在這裏的重點是要說明 Main 組件是整個系統中的一個底層模塊,它處於整潔架構的最外圈,主要負責爲系統加載所有必要的信息,然後再將控制權轉交回系統的高層組件。
main 是一個程序的入口,這是最細節的部分,因爲之前爲了很多東西不被依賴,我們一般會採用接口來實現依賴反轉,這時候就會導致我們所有的依賴關係的構建都需要在 main 中進行完成,所以一般而言我們會在 main 中引入依賴注入框架。
第 27 章 服務:宏觀與微觀
-
所謂的服務本身只是一種比函數調用方式成本稍高的,分割應用程序行爲的一種形式,與系統架構無關。
-
服務所帶來的好處?
-
解耦合的謬論
-
獨立開發部署的謬論
-
這種理念有一些道理——但也僅僅是一些而已。首先,** 無數歷史事實證明,大型系統一樣可以採用單體模式,或者組件模式來構建,不一定非得服務化。** 因此服務化並不是構建大型系統的唯一選擇。
-
橫跨型變更(cross-cutting concern)問題,它是所有的軟件系統都要面對的問題,無論服務化還是非服務化的。
-
服務也可以按照 SOLID 原則來設計,按照組件結構來部署,這樣就可以做到在添加 / 刪除組件時不影響服務中的其他組件。
-
系統的架構邊界事實上並不落在服務之間,而是穿透所有服務,在服務內部以組件的形式存在
-
「服務邊界並不能代表系統的架構邊界,服務內部的組件邊界纔是。」
-
系統的架構是由系統內部的架構邊界,以及邊界之間的依賴關係所定義的,與系統中各組件之間的調用和通信方式無關。
雖然現在微服務架構非常火熱,基本上所有的服務都是拆分了服務,但是拆分了服務並不一定表示就解耦合了,也並不一定就真的能獨立部署,想一想這是現在很常見的,一個應用必須要和另外一個應用一同上線,根本做不了獨立部署。
第 28 章 測試邊界
-
可測試性設計
-
如果測試代碼與系統是強耦合的,它就得隨着系統變更而變更。哪怕只是系統中組件的一點小變化,都可能會導致許多與之相耦合的測試出現問題,需要做出相應的變更。
-
軟件設計的第一條原則——不管是爲了可測試性還是其他什麼東西——是不變的,就是不要依賴於多變的東西。
-
沒有按系統組成部分來設計的測試代碼,往往是非常脆弱且難以維護的。
不變的組件不要依賴多變的東西,這樣會導致非常難以測試
第 29 章 整潔的嵌入式架構
-
雖然軟件本身並不會隨時間推移而磨損,但硬件及其固件卻會隨時間推移而過時,隨即也需要對軟件做相應改動
-
雖然軟件質量本身並不會隨時間推移而損耗,但是未妥善管理的硬件依賴和固件依賴卻是軟件的頭號殺手。
-
但如果你在代碼中嵌入了 SQL 或者是代碼中引入了對某個平臺的依賴的話,其實就是在寫固件代碼。
-
軟件構建過程中的三個階段
-
“先讓代碼工作起來”——如果代碼不能工作,就不能產生價值。
-
“然後再試圖將它變好”——通過對代碼進行重構,讓我們自己和其他人更好地理解代碼,並能按照需求不斷地修改代碼
-
“最後再試着讓它運行得更快”——按照性能提升的 “需求” 來重構代碼。
-
整潔的嵌入式架構就是可測試的嵌入式架構
-
軟件與固件集成在一起也屬於設計上的反模式(anti-pattern)
軟件並不會隨着時間磨損但是硬件是會過時的,而且換的還非常頻繁,這時候我們就必須要把硬件以及固件代碼給隔離起來,對了不要認爲我們不做嵌入式開發平時就很少接觸到這個,SQL 語句其實也是一種固件代碼
第六部門 實現細節
第 30 章 數據庫只是實現細節
-
就數據庫與整個系統架構的關係打個比方,它們之間就好比是門把手和整個房屋架構的關係
-
但當問題涉及數據存儲時,這方面的操作通常是被封裝起來,隔離在業務邏輯之外的
-
數據本身很重要,但數據庫系統僅僅是一個實現細節。
數據很重要,但是數據庫系統是一個細節,書上這一章用了一個例子說明有時候可能真的用不到數據庫。換個常見的例子,我們可能系統剛開始的時候使用 SQlite 就可以,隨着業務發展用上了 MySQL,然後隨着併發的提高又會引入緩存組件,這些變化其實和業務邏輯都沒有關係,數據庫的變化是不應該影響到業務邏輯的
第 31 章 Web 是實現細節
- GUI 只是一個實現細節。而 Web 則是 GUI 的一種,所以也是一個實現細節。作爲一名軟件架構師,我們需要將這類細節與核心業務邏輯隔離開來。
第 32 章 應用程序框架是實現細節
-
我們可以使用框架——但要時刻警惕,別被它拖住
-
畢竟 Main 組件作爲系統架構中最低層、依賴最多的組件,它依賴於 Spring 並不是問題。
框架的選擇要慎重,我們業務邏輯本身不能依賴框架
第 33 章 案例分析:視頻銷售網站
- 系統架構設計中的第一步,是識別系統中的各種角色和用例
這一步看起來簡單,但是非常考驗一個人的功力
第 34 章 拾遺
-
分層架構無法展現具體的業務領域信息。把兩個不同業務領域的、但是都採用了分層架構的代碼進行對比,你會發現它們的相似程度極高
-
寬鬆的分層架構,允許某些層跳過直接相鄰的鄰居。
-
一個架構設計原則——內容是 “Web 控制器永遠不應該直接訪問數據層”。
-
系統由一個或者多個容器組成(例如 Web 應用、移動 App、獨立應用、數據庫、文件系統),每個容器包含一個或多個組件,每個組件由一個或多個類組成。
-
如果不考慮具體實現細節,再好的設計也無法長久。必須要將設計映射到對應的代碼結構上,考慮如何組織代碼樹,以及在編譯期和運行期採用哪種解耦合的模式。
-
最好能利用編譯器來維護所選的系統架構設計風格,小心防範來自其他地方的耦合模式,例如數據結構
這一章對比了四種架構風格,同時提出了,架構設計是需要考慮實現細節的,設計需要映射到代碼結構和代碼樹上,這個其實和最開始的 “軟件架構師自身需要是程序員,並且必須一直堅持做一線程序員” 交相呼應。如果可以在編譯時解決的問題,就不要放到運行時,編譯的問題往往要比運行時的問題好解決,這也是爲什麼 Go 的依賴注入框架我更加推薦 wire 的原因,同理作者提出了 如果要防止直接中 web 控制器調用數據層,那麼我們就不應該將數據層(repo)暴露出來,只需要暴露 usecase 就好了。
總結
之前其實也大概瞭解過整潔架構,從最開始覺得它又臭又長,到現在工作兩三年後覺得 “不聽老人言,喫虧在眼前”,當我們在對一個架構或者是事務進行批判的時候一定要了解它面對的場景以及它的理念,這是最重要的。當然軟件領域是沒有銀彈的,我們需要做的是吸收每一種思想,在不同的場景下做不同的取捨,接下來會有幾篇文章結合毛老師課上講的 Go 工程化相關的內容,以及我在工作當中進行的一些總結最後提出一種當下我覺得的 Go 項目的組織方式,這種方式不是最好的,但是我覺得是現階段最適合的。推薦大家在仔細的閱讀一下本書,期望你能有更多的收穫。
參考文獻
- 架構整潔之道 - 羅伯特 ·C· 馬丁 - 微信讀書
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/XwrLHuWyo0lypT1NGaRh7A