SOLID架構設計原則

程式設計師順仔發表於2021-03-01

最近通讀了《架構整潔之道》,受益匪淺,遂摘選出設計原則部分,與大家分享,希望大家能從中獲益。

以下為書中第3部分 設計原則的原文。


設計原則概述

通常來說,要想構建—個好的軟體系統,應該從寫整潔的程式碼開始做起。畢竟,如果建築所使用的磚頭質量不佳,那麼架構所能起到的作用也會很有限。反之亦然,如果建築的架構設計不佳,那麼其所用的磚頭質量再好也沒有用。這就是SOLID設計原則所要解決的問題。

SOLID原則的主要作用就是告訴我們如何將資料和函式組織成為類,以及如何將這些類連結起來成為程式。請注意,這裡雖然用到了“類”這個詞,但是並不意味著我們將要討論的這些設計原則僅僅適用於物件導向程式設計。這裡的類僅僅代表了一種資料和函式的分組,每個軟體系統都會有自己的分類系統,不管它們各自是不是將其稱為“類”,事實上都是SOLID原則的適用領域。

一般情況下,我們為軟體構建中層結構的主要目標如下:

  • 使軟體可容忍被改動

  • 使軟體更容易被理解

  • 構建可在多個軟體系統中複用的元件

我們在這裡之所以會使用“中層”這個詞,是因為這些設計原則主要適用於那些進行模組級程式設計的程式設計師。SO凵D原則應該直接緊貼於具體的程式碼邏輯之上,這些原則是用來幫助我們定義軟體架構中的元件和模組的。

當然了,正如用好磚也會蓋歪樓一樣,採用設計良好的中層元件並不能保證系統的整體架構運作良好。正因為如此,我們在講完SOLID原則之後,還會再繼續針對元件的設計原則進行更進一步的討論,將其推進到高階軟體架構部分。

SOLID原則的歷史已經很悠久了,早在20世紀80年代末期,我在 USENET新聞組(該新聞組在當時就相當於今天的 Facebook)上和其他人辯論軟體設計理念的時候,該設計原則就已經開始逐漸成型了。隨著時間的推移,其中有一些原則得到了修改,有一些則被拋棄了,還有一些被合併了,另外也增加了一些。它們的最終形態是在2000年左右形成的,只不過當時採用的是另外一個展現順序。

2004年前後, Michael feathers的一封電子郵件提醒我:如果重新排列這些設計原則,那麼它們的首字母可以排列成SOLID——這就是SOLID原則誕生的故事。

在這一部分中,我們會逐章地詳細討論每個設計原則,下面先來做一個簡單摘要。

SRP:單一職責原則。

該設計原則是基於康威定律( Conway‘s Law)的一個推論——軟體系統的最佳結構高度依賴於開發這個系統的組織的內部結構。這樣,每個軟體模組都有且只有一個需要被改變的理由。

OCP:開閉原則。

該設計原則是由 Bertrand Meyer在20世紀80年代大力推廣的,其核心要素是:如果軟體系統想要更容易被改變,那麼其設計就必須允許新增程式碼來修改系統行為,而非只能靠修改原來的程式碼。

LSP:里氏替換原則。

該設計原則是 Barbara liskov在1988年提出的著名的子型別定義。簡單來說,這項原則的意思是如果想用可替換的元件來構建軟體系統,那麼這些元件就必須遵守同一個約定,以便讓這些元件可以相互替換。

ISP:介面隔離原則。

這項設計原則主要告誡軟體設計師應該在設計中避免不必要的依賴。

DIP:依賴反轉原則。

該設計原則指出高層策略性的程式碼不應該依賴實現底層細節的程式碼,恰恰相反,那些實現底層細節的程式碼應該依賴高層策略性的程式碼。

這些年來,這些設計原則在很多不同的出版物中都有過詳細描述。在接下來的章節中,我們將會主要關注這些原則在軟體架構上的意義,而不再重複其細節資訊。如果你對這些原則並不是特別瞭解,那麼我建議你先通過腳註中的文件熟悉一下它們,否則接下來的章節可能有點難以理解。

SRP:單一職責原則

SRP是SOLID五大設計原則中最容易被誤解的一。也許是名字的原因,很多程式設計師根據SRP這個名字想當然地認為這個原則就是指:每個模組都應該只做一件事。

沒錯,後者的確也是一個設計原則,即確保一個函式只完成一個功能。我們在將大型函式重構成小函式時經常會用到這個原則,但這只是一個面向底層實現細節的設計原則,並不是SRP的全部。

在歷史上,我們曾經這樣描述SRP這一設計原則:

任何一個軟體模組都應該有且僅有一個被修改的原因。

在現實環境中,軟體系統為了滿足使用者和所有者的要求,必然要經常做出這樣那樣的修改。而該系統的使用者或者所有者就是該設計原則中所指的“被修改的原因”。所以,我們也可以這樣描述SRP:

任何一個軟體模組都應該只對一個使用者(User)或系統利益相關者( Stakeholder)負責。

不過,這裡的“使用者”和“系統利益相關者”在用詞上也並不完全準確,它們很有可能指的是一個或多個使用者和利益相關者,只要這些人希望對系統進行的變更是相似的,就可以歸為一類——一個或多有共同需求的人。在這裡,我們將其稱為行為者( actor)。

所以,對於SRP的最終描述就變成了:

任何一個軟體模組都應該只對某一類行為者負責。

那麼,上文中提剄的“軟體模組”究竟又是在指什麼呢?大部分情況下,其最簡單的定義就是指一個原始碼檔案。然而,有些程式語言和程式設計環境並不是用原始碼檔案來儲存程式的。在這些情況下,“軟體模組”指的就是一組緊密相關的函式和資料結構。

在這裡,“相關”這個詞實際上就隱含了SRP這一原則。程式碼與資料就是靠著與某一類行為者的相關性被組合在一起的。

或許,理解這個設計原則最好的辦法就是讓大家來看一些反面案例。

反面案例1:重複的假象。

這是我最喜歡舉的一個例子:某個工資管理程式中的 Employee類有三個函式 calculate Pay()、reportHours()和save()。

如你所見,這個類的三個函式分別對應的是三類非常不同的行為者,違反了SRP設計原則。

calculatePay()函式是由財務部門制定的,他們負責向CFO彙報。

reportHours()函式是由人力資源部門制定並使用的,他們負責向COO彙報。

save()函式是由DBA制定的,他們負責向CTO彙報。

這三個函式被放在同一個原始碼檔案,即同一個Employee類中,程式設計師這樣做實際上就等於使三類行為者的行為耦合在了一起,這有可能會導致CFO團隊的命令影響到COO團隊所依賴的功能。

例如, calculatePay()函式和 reportHours()函式使用同樣的邏輯來計算正常工作時數。程式設計師為了避免重複編碼,通常會將該演算法單獨實現為個名為 regularHours()的函式(見下圖)。

接下來,假設CFO團隊需要修改正常工作時數的計算方法,而COO帶領的HR團隊不需要這個修改,因為他們對資料的用法是不同的。

這時候,負責這項修改的程式設計師會注意到calculate Pay()函式呼叫了 regularHours()函式,但可能不會注意到該函式會同時被reportHours()呼叫。

於是,該程式設計師就這樣按照要求進行了修改,同時CFO團隊的成員驗證了新演算法工作正常。這項修改最終被成功部署上線了。

但是,COO團隊顯然完全不知道這些事情的發生,HR仍然在使用 reportHours()產生的報表,隨後就會發現他們的資料出錯了!最終這個問題讓COO十分憤怒,因為這些錯誤的資料給公司造成了幾百萬美元的損失。

與此類似的事情我們肯定多多少少都經歷過。這類問題發生的根源就是因為我們將不同行為者所依賴的程式碼強湊到了一起。對此,SRP強調這類程式碼一定要被分開。

反面案例2:程式碼合併

一個擁有很多函式的原始碼檔案必然會經歷很多次程式碼合併,該檔案中的這些函式分別服務不同行為者的情況就更常見了。

例如,CTO團隊的DBA決定要對 Employee資料庫表結構進行簡單修改。與此同時,COO團隊的HR需要修改工作時數報表的格式。

這樣一來,就很可能出現兩個來自不同團隊的程式設計師分別對 Employee類進行修改的情況。不出意外的話,他們各自的修改一定會互相沖突,這就必須要進行程式碼合併。

在這個例子中,這次程式碼合併不僅有可能讓CTO和COO要求的功能出錯,甚至連CFO原本正常的功能也可能受到影響。

事實上,這樣的案例還有很多,我們就不一一列舉了。它們的一個共同點是,多人為了不同的目的修改了同一份原始碼,這很容易造成問題的產生。

而避免這種問題產生的方法就是將服務不同行為者的程式碼進行切分。

解決方案

我們有很多不同的方法可以用來解決上面的問題每一種方法都需要將相關的函式劃分成不同的類。

其中,最簡單直接的辦法是將資料與函式分離,設計三個類共同使用一個不包括函式的、十分簡單的EmployeeData類(見下圖),每個類只包含與之相關的函式程式碼,互相不可見,這樣就不存在互相依賴的情況了。

這種解決方案的壞處在於:程式設計師現在需要在程式裡處理三個類。另一種解決辦法是使用 Facade設計模式(見下圖)。

這樣一來, Employee Facade類所需要的程式碼量就很少了,它僅僅包含了初始化和呼叫三個具體實現類的函式。

當然,也有些程式設計師更傾向於把最重要的業務邏輯與資料放在一起,那麼我們也可以選擇將最重要的函式保留在 Employee類中,同時用這個類來呼叫其他沒那麼重要的函式(見下圖)。

讀者也許會反對上面這些解決方案,因為看上去這裡的每個類中都只有一個函式。事實上並非如此,因為無論是計算工資、生成報表還是儲存資料都是一個很複雜的過程,每個類都可能包含了許多私有函式。

總而言之,上面的每一個類都分別容納了一組作用於相同作用域的函式,而在該作用域之外,它們各自的私有函式是互相不可見的。

本章小結

單一職責原則主要討論的是函式和類之間的關係——但是它在兩個討論層面上會以不同的形式出現。在元件層面,我們可以將其稱為共同閉包原則( Common Closure Principle),在軟體架構層面,它則是用於奠定架構邊界的變更軸心( Axis of Change)。我們在接下來的章節中會深入學習這些原則。

OCP:開閉原則

開閉原則(OCP)是 Bertrand Meyer在1988年提出的,該設計原則認為:

設計良好的計算機軟體應該易於擴充套件,同時抗拒修改。

換句話說,一個設計良好的計算機系統應該在不需要修改的前提下就可以輕易被擴充套件。

其實這也是我們研究軟體架構的根本目的。如果對原始需求的小小延伸就需要對原有的軟體系統進行大幅修改,那麼這個系統的架構設計顯然是失敗的。

儘管大部分軟體設計師都已經認可了OCP是設計類與模組時的重要原則,但是在軟體架構層面,這項原則的意義則更為重大。

下面,讓我們用一個思想實驗來做一些說明。

思想實驗

假設我們現在要設計一個在Web頁面上展示財務資料的系統,頁面上的資料要可以滾動顯示,其中負值應顯示為紅色。

接下來,該系統的所有者又要求同樣的資料需要形成一個報表,該報表要能用黑白印表機列印,並且其報表格式要得到合理分頁,每頁都要包含頁頭、頁尾及欄目名。同時,負值應該以括號表示。

顯然,我們需要增加一些程式碼來完成這個要求。但在這裡我們更關注的問題是,滿足新的要求需要更改多少舊程式碼。

一個好的軟體架構設計師會努力將舊程式碼的修改需求量降至最小,甚至為0。

但該如何實現這一點呢?我們可以先將滿足不同需求的程式碼分組(即SRP),然後再來調整這些分組之間的依賴關係(即DIP)

利用SRP,我們可以按下圖中所展示的方式來處理資料流。即先用一段分析程式處理原始的財務資料,以形成報表的資料結構,最後再用兩個不同的報表生成器來產生報表。

這裡的核心就是將應用生成報表的過程拆成兩個不同的操作。即先計算出報表資料,再生成具體的展示報表(分別以網頁及紙質的形式展示)。

接下來,我們就該修改其原始碼之間的依賴關係了。這樣做的目的是保證其中一個操作被修改之後不會影響到另外一個操作。同時,我們所構建的新的組織形式應該保證該程式後續在行為上的擴充套件都無須修改現有程式碼。

在具體實現上,我們會將整個程式程式劃分成一系列的類,然後再將這些類分割成不同的元件。下面,我們用下圖中的那些雙線框來具體描述一下整個實現。在這個圖中,左上角的元件是Controller,右上角是 Interactor,右下角是Database,左下角則有四個元件分別用於代表不同的 Presente和VieW。

在圖中,用“I”標記的類代表介面,用標記的則代表資料結構;開放箭頭指代的是使用關係,閉合箭頭則指代了實現與繼承關係。

首先,我們在圖中看到的所有依賴關係都是其原始碼中存在的依賴關係。這裡,從類A指向類B的箭頭意味著A的原始碼中涉及了B,但是B的原始碼中並不涉及A。因此在圖中,FinancialDataMapper在實現介面時需要知道FinancialDataGateway的實現,而FinancialDataGateway則完全不必知道FinancialDataMapper的實現。

其次,這裡很重要的一點是這些雙線框的邊界都是單向跨越的。也就是說,上圖中所有元件之間的關係都是單向依賴的,如下圖所示,圖中的箭頭都指向那些我們不想經常更改的元件。

讓我們再來複述一下這裡的設計原則:如果A元件不想被B元件上發生的修改所影響,那麼就應該讓B元件依賴於A元件。

所以現在的情況是,我們不想讓發生在 Presenter上的修改影響到 Controller,也不想讓發生在view上的修改影響到 Presenter。而最關鍵的是,我們不想讓任何修改影響到 Interactor。

其中, Interactor元件是整個系統中最符合OCP的。發生在 Database、 Controller、 Presenter甚至view上的修改都不會影響到 Interactor。

為什麼 interactor會被放在這麼重要的位置上呢?因為它是該程式的業務邏輯所在之處, Interactor中包含了其最高層次的應用策略。其他元件都只是負責處理周邊的輔助邏輯,只有 Interactor才是核心元件。

雖然 Controller元件只是 interactor的附屬品,但它卻是 Presenter和vew所服務的核心。同樣的,雖然 Presenter元件是 Controller的附屬品,但它卻是view所服務的核心。

另外需要注意的是,這裡利用“層級”這個概念創造了一系列不同的保護層級。譬如, Interactor是最高層的抽象,所以它被保護得最嚴密,而Presenter比view的層級高,但比 Controller和Interactor的層級低。

以上就是我們在軟體架構層次上對OCP這一設計原則的應用。軟體架構師可以根據相關函式被修改的原因、修改的方式及修改的時間來對其進行分組隔離,並將這些互相隔離的函式分組整理成元件結構,使得高階元件不會因低階元件被修改而受到影響。

依賴方向的控制

如果剛剛的類設計把你嚇著了,別害怕!你剛剛在圖表中所看到的複雜度是我們想要對元件之間的依賴方向進行控制而產生的。

例如,FinancialReportGenerator和FinancialDataMapper之間的FinancialDataGateway介面是為了反轉 interactor與Database之間的依賴關係而產生的。同樣的,FinancialReportPresente介面與兩個View介面之間也類似於這種情況。

資訊隱藏

當然, FinancialReportRequester介面的作用則完全不同,它的作用是保護FinancialReportController不過度依賴於Interactor的內部細節。如果沒有這個介面,則Controller將會傳遞性地依賴於 Financialentities。

這種傳遞性依賴違反了“軟體系統不應該依賴其不直接使用的元件”這一基本原則。之後,我們會在討論介面隔離原則和共同複用原則的時候再次提到這一點。

所以,雖然我們的首要目的是為了讓 Interactor遮蔽掉髮生在 Controller上的修改,但也需要通過隱藏 Interactor內部細節的方法來讓其遮蔽掉來自Controller的依賴。

本章小結

OCP是我們進行系統架構設計的主導原則,其主要目標是讓系統易於擴充套件,同時限制其每次被修改所影響的範圍。實現方式是通過將系統劃分為一系列元件,並且將這些元件間的依賴關係按層次結構進行組織,使得高階元件不會因低階元件被修改而受到影響。

LSP:里氏替換原則

1988年, Barbara liskov在描述如何定義子型別時寫下了這樣一段話:

這裡需要的是一種可替換性:如果對於每個型別是S的物件o1都存在一個型別為T的物件o2,能使操作T型別的程式P在用o2替換o1時行為保持不變,我們就可以將S稱為T的子型別。

為了讓讀者理解這段話中所體現的設計理念,也就是里氏替換原則(LSP),我們可以來看幾個例子。

繼承的使用指導

假設我們有一個 License類,其結構如下圖所示。該類中有一個名為 callee()的方法,該方法將由Billing應用程式來呼叫。而 License類有兩個“子型別” :PersonalLicense與 Businesslicense,這兩個類會用不同的演算法來計算授權費用。

上述設計是符合LSP原則的,因為 Billing應用程式的行為並不依賴於其使用的任何一個衍生類。也就是說,這兩個衍生類的物件都是可以用來替換License類物件的。

正方形/長方形問題

正方形/長方形問題是一個著名(或者說臭名遠揚)的違反LSP的設計案例。

在這個案例中, Square類並不是 Rectangle類的子型別,因為 Rectangle類的高和寬可以分別修改,而 Square類的高和寬則必須一同修改。由於User類始終認為自己在操作 Rectangle類,因此會帶來一些混淆。例如在下面的程式碼中:

Rectangle r

r.setw(5)

r.setH(2)

assert( rarea()==10)

很顯然,如果上述程式碼在…處返回的是 Square類,則最後的這個 assert是不會成立的。

如果想要防範這種違反LSP的行為,唯一的辦法就是在User類中增加用於區分 Rectangle和 Square的檢測邏輯(例如增加if語句)。但這樣一來,User類的行為又將依賴於它所使用的類,這兩個類就不能互相替換了。

LSP與軟體架構

在物件導向這場程式設計革命興起的早期,我們的普遍認知正如上文所說,認為LSP只不過是指導如何使用繼承關係的一種方法,然而隨著時間的推移,LSP逐漸演變成了一種更廣泛的、指導介面與其實現方式的設計原則。

這裡提到的介面可以有多種形式——可以是Java風格的介面,具有多個實現類;也可以像Ruby一樣,幾個類共用一樣的方法簽名,甚至可以是幾個服務響應同一個REST介面。

LSP適用於上述所有的應用場景,因為這些場景中的使用者都依賴於一種介面,並且都期待實現該介面的類之間能具有可替換性。

想要從軟體架構的角度來理解LSP的意義,最好的辦法還是來看幾個反面案例。

違反LSP的案例

假設我們現在正在構建一個提供計程車排程服務的系統。在該系統中,使用者可以通過訪問我們的網站,從多個計程車公司內尋找最適合自己的計程車。當使用者選定車子時,該系統會通過呼叫 restful服務介面來排程這輛車。

接下來,我們再假設該 restful排程服務介面的UR被儲存在司機資料庫中。一旦該系統選中了最合適的計程車司機,它就會從司機資料庫的記錄中讀取相應的URI資訊,並通過呼叫這個URI來排程汽車。

也就是說,如果司機Bob的記錄中包含如下排程URI:

purplecab. com/driver/ Bob

那麼,我們的系統就會將排程資訊附加在這個URI上,併傳送這樣一個PUT請求:

purplecab. com/driver/Bob

/pickup Address/24 Maple St

/pickupTime/153

/destination/ORD

很顯然,這意味著所有參與該排程服務的公司都必須遵守同樣的REST介面,它們必須用同樣的方式處理 pickupAddress、 pickup Time和 destination欄位。

接下來,我們再假設Acme計程車公司現在招聘的程式設計師由於沒有仔細閱讀上述介面定義,結果將destination欄位縮寫成了dest。而Acme又是本地最大的計程車公司,另外, Acme CEO的前妻不巧還是我們CEO的新歡……你懂的!這會對系統的架構造成什麼影響呢?

顯然,我們需要為系統増加一類特殊用例,以應對Acme司機的排程請求。而這必須要用另外一套規則來構建。

最簡單的做法當然是增加一條i語句:

if(driver.getDispatchUri().startsWith(“acme.com))...

然而很明顯,任何一個稱職的軟體架構師都不會允許這樣一條語句出現在自己的系統中。因為直接將“acme“這樣的字串寫入程式碼會留下各種各樣神奇又可怕的錯誤隱患,甚至會導致安全問題。

例如,Acme也許會變得更加成功,最終收購了Purple計程車公司。然後,它們在保留了各自名字的同時卻統一了彼此的計算機系統。在這種情況下,系統中難道還要再增加一條“ purple“的特例嗎?

軟體架構師應該建立一個排程請求建立元件,並讓該元件使用一個配置資料庫來儲存URI組裝格式,這樣的方式可以保護系統不受外界因素變化的影響。例如其配置資訊可以如下

但這樣一來,軟體架構師就需要通過増加一個複雜的元件來應對並不完全能實現互相替換的 restful服務介面。

本章小結

LSP可以且應該被應用於軟體架構層面,因為一旦違背了可替換性,該系統架構就不得不為此増添大量複雜的應對機制。

ISP:介面隔離原則

“介面隔離原則”這個名字來自下圖所示的這種軟體結構。

在圖中所描繪的應用中,有多個使用者需要操作OPS類。現在,我們假設這裡的User1只需要使用op1,User2只需要使用op2,User3只需要使用op3。

在這種情況下,如果OPS類是用Java程式語言編寫的,那麼很明顯,User1雖然不需要呼叫op2、op3,但在原始碼層次上也與它們形成依賴關係。

這種依賴意味著我們對OPS程式碼中op2所做的任何修改,即使不會影響到User1的功能,也會導致它需要被重新編譯和部署。

這個問題可以通過將不同的操作隔離成介面來解決,具體如下圖所示。

同樣,我們也假設這個例子是用Java這種靜態型別語言來實現的,那麼現在User1的原始碼會依賴於U1Ops和op1,但不會依賴於OPS。這樣一來,我們之後對OPS做的修改只要不影響到User1的功能,就不需要重新編譯和部署User1了。

ISP與程式語言

很明顯,上述例子很大程度上也依賴於我們所採用的程式語言。對於Java這樣的靜態型別語言來說,它們需要程式設計師顯式地 Import、use或者 include其實現功能所需要的原始碼。而正是這些語句帶來了原始碼之間的依賴關係,這也就導致了某些模組需要被重新編譯和重新部署。

而對於Ruby和 Python這樣的動態型別語言來說,原始碼中就不存在這樣的宣告,它們所用物件的型別會在執行時被推演出來,所以也就不存在強制重新編譯和重新部署的必要性。這就是動態型別語言要比靜態型別語言更靈活、耦合度更鬆的原因。

當然,如果僅僅就這樣說的話,讀者可能會誤以為ISP只是一個與程式語言的選擇緊密相關的設計原則而非軟體架構問題,這就錯了。

ISP與軟體架構

回顧一下ISP最初的成因:在一般情況下,任何層次的軟體設計如果依賴於不需要的東西,都會是有害的。從原始碼層次來說,這樣的依賴關係會導致不必要的重新編譯和重新部署,對更高層次的軟體架構設計來說,問題也是類似的。

例如,我們假設某位軟體架構師在設計系統S時,想要在該系統中引入某個框架F。這時候,假設框架F的作者又將其捆綁在一個特定的資料庫D上,那麼就形成了S依賴於F,F又依賴於D的關係。

在這種情況下,如果D中包含了F不需要的功能,那麼這些功能同樣也會是S不需要的。而我們對D中這些功能的修改將會導致F需要被重新部署,後者又會導致S的重新部署。更糟糕的是,D中一個無關功能的錯誤也可能會導致F和S執行出錯。

本章小結

本章所討論的設計原則告訴我們:任何層次的軟體設計如果依賴了它並不需要的東西,就會帶來意料之外的麻煩。

DIP:依賴反轉原則

依賴反轉原則(DIP)主要想告訴我們的是,如果想要設計一個靈活的系統,在原始碼層次的依賴關係中就應該多引用抽象型別,而非具體實現。

也就是說,在Java這類靜態型別的程式語言中,在使用use、 Import、 include這些語句時應該只引用那些包含介面、抽象類或者其他抽象型別宣告的原始檔,不應該引用任何具體實現。

同樣的,在Ruby、 Python這類動態型別的程式語言中,我們也不應該在原始碼層次上引用包含具體實現的模組。當然,在這類語言中,事實上很難清晰界定某個模組是否屬於“具體實現′。

顯而易見,把這條設計原則當成金科玉律來加以嚴格執行是不現實的,因為軟體系統在實際構造中不可避免地需要依賴到一些具體實現。例如,Java中的 String類就是這樣一個具體實現,我們將其強迫轉化為抽象類是不現實的,而在原始碼層次上也無法避免對 java.lang.String的依賴,並且也不應該嘗試去避免。

但 String類本身是非常穩定的,因為這個類被修改的情況是非常罕見的,而且可修改的內容也受到嚴格的控制,所以程式設計師和軟體架構師完全不必擔心String類上會發生經常性的或意料之外的修改。

同理,在應用DIP時,我們也不必考慮穩定的作業系統或者平臺設施,因為這些系統介面很少會有變動。

我們主要應該關注的是軟體系統內部那些會經常變動的( volatile)具體實現模組,這些模組是不停開發的,也就會經常出現變更。

穩定的抽象層

我們每次修改抽象介面的時候,一定也會去修改對應的具體實現。但反過來,當我們修改具體實現時,卻很少需要去修改相應的抽象介面。所以我們可以認為介面比實現更穩定。

的確,優秀的軟體設計師和架枃師會花費很大精力來設計介面,以減少未來對其進行改動。畢竟爭取在不修改介面的情況下為軟體增加新的功能是軟體設計的基礎常識。

也就是說,如果想要在軟體架構設計上追求穩定,就必須多使用穩定的抽象介面,少依賴多變的具體實現。下面,我們將該設計原則歸結為以下幾條具體的編碼守則:

應在程式碼中多使用抽象介面,儘量避免使用那些多變的具體實現類。這條守則適用於所有程式語言無論靜態型別語言還是動態型別語言。同時,物件的建立過程也應該受到嚴格限制,對此,我們通常會選擇用抽象工廠( abstract factory)這個設計模。

不要在具體實現類上建立衍生類。上一條守則雖然也隱含了這層意思,但它還是值得被單獨拿出來做次詳細宣告。在靜態型別的程式語言中,繼承關係是所有一切原始碼依賴關係中最強的、最難被修改的,所以我們對繼承的使用應該格外小心。即使是在稍微便於修改的動態型別語言中,這條守則也應該被認真考慮。

不要覆蓋( override)包含具體實現的函式。呼叫包含具體實現的函式通常就意味著引入了原始碼級別的依賴。即使覆蓋了這些函式,我們也無法消除這其中的依賴——這些函式繼承了那些依賴關係在這裡,控制依賴關係的唯一辦法,就是建立一個抽象函式,然後再為該函式提供多種具體實現。

應避免在程式碼中寫入與任何具體實現相關的名字或者是其他容易變動的事物的名字。這基本上是DIP原則的另外一個表達方式。

工廠模式

如果想要遵守上述編碼守則,我們就必須要對那些易變物件的建立過程做一些特殊處理,這樣的謹慎是很有必要的,因為基本在所有的程式語言中,建立物件的操作都免不了需要在原始碼層次上依賴物件的具體實現。

在大部分物件導向程式語言中,人們都會選擇用抽象工廠模式來解決這個原始碼依賴的問題。

下面,我們通過下圖來描述一下該設計模式的結構。如你所見, Application類是通過 Service介面來使用 Concretelmp類的。然而, Application類還是必須要構造 Concretelmpl類例項。於是,為了避免在原始碼層次上引入對 Concretelmpl類具體實現的依賴,我們現在讓 Application類去呼叫ServiceFactory介面的 makeSvc方法。這個方法就由 ServiceFactorylmpl類來具體提供,它是ServiceFactoryl的一個衍生類。該方法的具體實現就是初始化一個 Concretelmpl類的例項,並且將其以 Service型別返回。

中間的那條曲線代表了軟體架構中的抽象層與具體實現層的邊界。在這裡,所有跨越這條邊界原始碼級別的依賴關係都應該是單向的,即具體實現層依賴抽象層。

這條曲線將整個系統劃分為兩部分元件:抽象介面與其具體實現。抽象介面元件中包含了應用的所有高階業務規則,而具體實現元件中則包括了所有這些業務規則所需要做的具體操作及其相關的細節資訊。

請注意,這裡的控制流跨越架構邊界的方向與原始碼依賴關係跨越該邊界的方向正好相反,原始碼依賴方向永遠是控制流方向的反轉——這就是DIP被稱為依賴反轉原則的原因。

具體實現元件

在上圖中,具體實現元件的內部僅有一條依賴關係,這條關係其實是違反DIP的。這種情況很常見,我們在軟體系統中並不可能完全消除違反DIP的情況。通常只需要把它們集中於少部分的具體實現元件中,將其與系統的其他部分隔離即可。

絕大部分系統中都至少存在一個具體實現元件我們一般稱之為main元件,因為它們通常是main函式所在之處。在圖中,main函式應該負責建立 ServiceFactorylmp例項,並將其賦值給型別為 ServiceFactory的全域性變數,以便讓 Application類通過這個全域性變數來進行相關呼叫。

本章小結

在系統架構圖中,DIP通常是最顯而易見的組織原則。我們把圖中的那條曲線稱為架構邊界,而跨越邊界的、朝向抽象層的單向依賴關係則會成為一個設計守則——依賴守則。

相關文章