不止於物件導向的SOLID原則

爱喝可乐的咖啡發表於2024-08-02

SOLID原則是由人稱”鮑勃大叔“的Rober C. Martin所提出來的。他用五個物件導向設計原則的首字母組成了SOLID,並使其得到了廣泛傳播。這五個原則羅列如下:

  • 單一指責原則(Single Responsibility Principle):類的職責應該是單一的。所謂單一,是從變化的維度衡量的,既一個類應該只有一個變化的原因。
  • 開閉原則(Open-Closed Principle):設計模組應該對修改封閉,對擴充開放。
  • 里氏替換原則(Liskov Substitution Principle):子類應該完整地實現父類所要求的所有行為。在替換父類後不會導致程式的行為發生變化。
  • 介面隔離原則(Interface Segregation Principle):類之間的依賴應該建立在最小的介面上。不應該讓使用方依賴於它們用不到的方法。
  • 依賴倒置原則(Dependency Inversion Principle):高層模組不應該依賴於低層模組,二者都應該依賴於介面。

SOLID原則涉及到物件導向的許多方面,例如內聚性、耦合性、良好的關注點分離等。儘管這五個原則既不全面,也不正交,卻依然有非常積極的指導意義。它們並不僅僅侷限於物件導向設計中,從函式、類、元件、再到系統架構,在軟體設計的各個層次中它們都是優秀的指導方針。下面筆者將逐一介紹,並從自身的主業 前端/客戶端開發領域中摘取例子來細化講解。

SRP:單一職責原則

單一職責是首要介紹的,同時也是筆者認為最簡單卻最重要的一個原則。我們這樣子來描述它:

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

無論我們在做何種層次的設計中,都是在不斷地根據目標分解模組元素併為這些模組分配職責;模組間互相協作,組合起來成為一個更大的模組從而完成更大的職責,自下而上最終構成了完整的系統。以下從不同視角來舉些正面/反面例子,幫助我們更好的理解該原則:

1. 低內聚的元件

在視覺化編輯器的物料庫中有一種型別的SVG元件如下所示,該SVG元件既能接受一系列配置來更新SVG渲染的樣式、還可以在路徑上播放物移動的動效:

如此帶來的一個後續問題是,processStyle方法依賴於getParsedPath方法;而動畫效果是後續新增的新功能,同樣也直接依賴於getParsedPath。當最初維護該SVG元件的同學接到修改需求後,根據需要對getParsedPath方法內部做了調整,並在驗證滿足了新需求後邊提交了更改。但是,同樣依賴於getParsedPath方法的processAnimation卻並不知情該變更,並在下一次發版後不再能於其正常協作而丟擲了異常。

一個顯而易見的地方是,動畫處理的職責並不應該屬於SVG元件。當我們將這些不同職責的程式碼放在一塊時,就容易產生衝突。要解決上述問題,思路便是:將不該屬於SVG元件的職責給剝離出來。

這裡新產生了一個AnimationController類,專職來處理動效,並被SVG元件所關聯。同時,這兩者都依賴於有關路徑的處理方法,並且在可預見的未來中,類似功能會有很大機率複用到;因此抽離出一組通用於SVG路徑處理相關的工具函式SVG Utils

經過重構之後的程式碼,儘管在程式碼量上略有增加並且呼叫關係相對複雜了一些,卻帶來了更多的好處:

  • 隔離職責,降低了模組變更帶來的風險
  • 關注點分離地更加清晰
  • 程式碼複用性的提升

2. 包含越多職責的模組越容易產生衝突

通常來說,一個模組對應的只有一個原始檔,可能是一個函式、一個類或是一個元件。而同一模組中所包含的職責越多,它所面對的維護者就越多。我們就以上面未重構前的SVG元件為例子:

當某一天SVG元件和動畫效果都接收到了新需求,亦或是需要處理Bug。而這兩項任務剛好是由兩個開發者承接的;這很常見,因為動畫需求往往不是針對某一個元件的而是針對庫中一批同類別的元件,所以將其分配給不同的人開發是合理的。

接著這兩位開發者就從主幹上拉出了新分支到本地,在完成自己的任務後,再將其合併回主幹上。因為他們都在SVG元件的那個原始碼檔案上做了修改,不出意外地在合併程式碼的過程中就產生了衝突。這時就得其中的一位開發者來完成衝突處理的“髒活”,閱讀衝突部分相關的程式碼並同另一位開發者交流確保沒有歧義後再提交合並後的程式碼。

依據職能將程式碼進行分割後可以很大程度避免這種情況發生。儘管對於大部分人尤其是專案熟手來說,會覺得處理程式碼合併過程中產生的衝突只是小事一樁。但對於一個多人協作完成的大型專案來說,頻繁觸發的程式碼合併衝突,就意味著更多的額外工作量以及更高的出錯機率。

3. 高內聚的元件帶來更好的可維護性

我們以Unity下一個支援熱更新的專案架構為例,簡化為如下所示:

自下而上地簡述下各元件的功能:

  • Unity引擎核心:自立項之初就確定的底層框架,在此之後幾乎不可能變更。構建後為一份可執行檔案及DLL。
  • 庫檔案與自己編寫的遊戲指令碼:該部分主要由C#指令碼實現遊戲中的通用支援和效能敏感的邏輯模組,如引擎API橋接、資源載入/解除安裝、網路請求、遊戲尋路演算法等。在不同的構建方式下中間流程會有差異(Mono/IL2CPP),但最終產物都是DLL檔案。
  • Lua指令碼:遊戲中會被頻繁修改的業務邏輯大部分都放在了此處,例如UI、角色戰鬥邏輯、怪物的AI等。其是Unity能夠支援熱更新的原理所在。
  • Asset Bundle:遊戲中程式碼以外的資產(模型、紋理、prefab和音影片等資源)都可以打成ab包。支援在遊戲執行時動態地下載/解除安裝資源。

透過以上對各元件的簡述,我們很容易看出各個元件之間有非常明顯的邊界;它們所擁有的職責,需要被修改的理由都非常明確。如果將SRP原則從系統元件層面上描述的話,可以說是把那些為了相同目的而修改的檔案都放到同一個元件中;那麼在需要修改時我們只需將變更到儘可能少的元件,並能夠獨立地將它們釋出、驗證及部署

當遊戲需要即時修復線上Bug、調整數值時,只需透過熱更新替換lua檔案即可。透過載入ab包,可以在遇到臨時的節日運營活動/稽核政策調整時可以立即替換遊戲內的美術資源。在遊戲底層機制經過許多改動後釋出大版本更新時,則需要以冷更新的方式,在遊戲客戶端停機後替換新構建的DLL等檔案。

OCP:開閉原則

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

先對這句話中的兩個概念再作一次翻譯,“擴充”指的是增加程式碼實體,“修改”指的是在已有的程式碼實體上進行修改。這個原則要求我們不應該在實現新需求時總需要去對既有的程式碼作出修改,而是隻需要增添新程式碼即可;否則的話,隨著後續新需求的不斷增加,同一模組內程式碼的複雜性和出錯的風險就會不斷增加,這就是一個不好的設計。

同單一指責原則一樣,開閉原則大多數時候都不是作為一種設計手段,而是檢驗手段;用於判斷一個設計是否足夠的好。我們來看一個富文字渲染的例子。在一個支援渲染多種元素的富文字應用中,如果缺乏合理抽象的設計,可能會寫出如下“麵條式”的程式碼:

class RichEditor extends React.Component {
    // ...

    renderElement(type, params) {
        if(type === 'img') {
            /* ... */
        } else if(type === 'url') {
            /* ... */
        } else if(type === 'dateTime') {
            /* ... */
        }
    }

    render() {
        // ...
    }
}

很顯然,這是一個不內聚的模組設計,不符合單一指責原則。我們再以開閉原則的視角去審視它:

現在有了一個新需求,富文字還要能夠渲染一塊表格內容。大多數人最自然的選擇就是在renderElement()後邊再加一個新的判斷條件:

else if(type === 'table') {/* ... */}

這是一個典型的違反開閉原則的例子:不支援擴充,需要對既有程式碼作修改。對於渲染型別的關注點都集中在了renderElement()裡面,隨著需求的不斷增加,我們會在這個函式中不斷地追加程式碼,致使renderElement()越來越複雜和臃腫。

讓我們把這個設計重構一下吧。

首先將控制元素渲染的邏輯從RichEditor中剝離出來,實現一個管理器ElementRegister接管這部分邏輯,並提供註冊介面用作擴充入口。這樣一來就成了RichEditor依賴ElementRegisterElementRegister依賴於具體的元素渲染實現(RichEditor ---> ElementRegister ---> xxxElement)。從依賴鏈條來看如此設計和及和原先的程式碼沒本質區別,因為RichEditor始終還是依賴到底層的具體實現,對修改不夠封閉。我們再將依賴方向反轉一下,提供一個介面IElement,變為由RichEditor依賴於介面而不再是具體實現。底層的渲染元素負責實現IElement介面:

調整為這種依賴結構後,底層元素的具體實現對上層就不可見了,RichEditor/ElementRegister面向的只是介面IElement了;對於修改就封閉了。當有新元素需要增加時,則只需要再新增一個符合介面的實體即可了;對於擴充也是開放的了。

組合優於繼承

再來看一個例子,選取自阿里的Galacean引擎專欄中對系統架構的介紹:

場景中的實體(Entity)是在執行時被建立出來的一個個物件,能夠在場景中被渲染出來並透過新增元件(Component)的方式來提供各種能力。基於元件進行架構的系統,組合優先於繼承。比如希望一個實體既可以發光也可以出聲,那麼新增燈光元件和聲音元件就能做到了。這種方式非常適合互動這種複雜度高的業務——特定功能只增加一個元件即可,便於擴充套件。

如果是採用繼承的方式,那麼在每次需要新添有特定功能的實體時,都有可能需要調整原先的繼承關係。尤其是在整棵繼承鏈上的類關聯較複雜,層級結構較深的情況下,這種關係是非常脆弱的。在互動小遊戲業務的迭代中這樣需要頻繁新增實體的場景下,就意味著繼承關係可能會被頻繁破壞。這樣對修改毫不封閉,會造成極大的維護成本。反之,以組合式扁平的結構,對擴充更為友好,透過增刪元件的方式可以擴充出許多中不同的新實體(在這種架構之下,所有的實體通常都只需要繼承一個統一的基類即可)。

LSP:里氏替換原則

里氏替換原則由Barbara Liskov提出,其表述如下:

若每個型別 S 的物件 o1,都存在一個型別 T 的物件 o2,使得在所有針對 T 編寫的程式 P 中,用 o1 替換 o2 後,程式 P 的行為功能不便,則 S 是 T 的派生型別。

將這段學術化的表達翻譯成大白話就是子類應該完整地實現父類所要求的所有行為,這樣一來在使用了父類的程式中,即使後續替換了其他子類後該程式也不會受到影響/感知不到變動。

根據上述表達可以知道,一個符合里氏替換原則的設計最為明顯的好處在於,對於高層模組所依賴的類/介面,如果所有繼承子類/介面都按預期實現了所要求的行為,那麼這些依賴就都具有了可替換性。大大降低了在後期需要替換底層依賴時的遷移成本。下面就舉一個筆者在專案實踐中遇到的具體例子:

設計實現一個地圖建模引擎,除了展示地圖的底圖外還能夠回顯/使用者手動繪製各種圖形、繪製路徑、播放動畫等。業務領域的需求是已經確定好了的,我們準備先使用高德的地圖SDK(AMap)來作為底層的地圖渲染引擎,完成功能後部署一套在公網上方便給客戶演示。後續是在甲方的內網環境上部署的,因此還需要開發去駐場,地圖SDK肯定不能是高德的,而是替換為甲方指定的了。

所以考慮對遮蔽掉具體的地圖引擎,使其對上層的業務方不可見。提供一個抽象類AbstractMapWidgetClass,在該類中定義了底層的地圖引擎應該具備那些行為能力。上層業務依賴於該抽象類:

接著使用高德地圖的SDK來實現一個AMap類,使他繼承自AbstractMapWidgetClass並正確實現父類要求的所有行為。後續在不同的甲方環境中部署時要使用到不同的SDK也是以同樣的方式進行替換;只需新建一個子類繼承自抽象父類即可。對於上層應用來說,因為它只依賴於抽象而不是具體的底層模組,所以我們在替換任意地圖渲染引擎後都不會影響到它。

在軟體架構層面,同樣也應該注意到LSP原則的應用。系統架構中那些在未來預期中可能產生變動的部分如上層應用所依賴的底層模組、平臺的基礎設施等,都應該具備較高可替換性,使得後期遷移時不對上層造成影響(這裡的“上、下層”,都是相對而言的)。

使用方與替換部分之間的銜接橋樑,就是介面(Interface)。這裡可以延伸出面向介面程式設計這一重要的思維方式(這裡所說的介面並不是指面嚮物件語言中的介面,而是廣義上的介面,或許更貼近的叫法應該是“契約”);筆者將在最後再展開這部分的探討。

ISP:介面隔離原則

ISP是一項指導介面該如何設計的原則。它建議介面應儘量的小並且內聚,依賴方使用不到的東西就不應出現在介面中。違反這項原則的場景往往不是在從零實現的新設計中,而是在功能演進時產生的:

起初我們的視覺化設計器只有報告設計器FreeReport(一種類似於PPT的自由佈局),有一個上下文FreeReportContext的依賴。這個上下文實現了相應的介面IContext。這些在一開始看起來都很良好,但問題顯現是在後續我們新增了新的設計器之後。後續新增了儀表盤設計器Dashboard,它也有一個上下文的依賴DashboardContext。該上下文同樣實現自IContext介面。

現在的問題在於,IContext中定義的大多數行為FreeReportContextDashboardContext都應該滿足。但原先的極個別方法例如上圖中的頁面資訊pageCount在新添的DashboardContext沒有相應的行為,而DashboardContext所需要新加入的minimap行為在FreeReportContext中又是不被需要到的。如果後期再繼續擴充新的設計器型別,那麼上述情況則會愈加地多,往IContext新增的任何新行為都會影響到先前所有的介面實現類。

對於不支援的行為,基於TS語法我們可以將其設為可選性?或者直接實現為空。這麼做不符合語義,也沒有必要。合理的解決方案應該是將不同依賴方所依賴的不共同的行為“分離”開來。可以有以下兩種方式:

如此一來FreeReportContextDashboardContext所依賴的介面都變得更乾淨了:介面中不再包含有自己不需要的行為了。至於上面兩個方案哪個更優,這就要取決於系統未來的演進方式了。

DIP:依賴倒置原則

高層模組不應該依賴於低層模組,二者都應該依賴於抽象。

當我們修改抽象介面的時候,對應的具體實現一定也要做修改。但修改了具體實現後,相應的抽象介面則不一定需要做修改。此外,抽象的介面通常來說都是經過精心設計的,在未來的演進過程中會被做調整的機率更小。因此,我們可以說抽象介面這一層是穩定的。讓高層模組和低層模組都依賴於抽象,能夠帶來更為穩定的設計。

依賴倒置原則這兒筆者不打算再舉新的例子。回顧上面幾項設計原則的例子中,特別是富文字和地圖的例子中。我們能發現最佳化後的設計方案都是如下圖形式的,透過讓高層模組和低層模組都依賴於介面,使得本來指向低層模組的依賴箭頭方向“倒置”了上去:

前面還提到了一個概念:面向介面程式設計。我們再回顧一遍里氏替換、介面隔離、依賴倒置這三項原則,它們都是面向介面程式設計的具體表現。所以筆者覺得最後有必要再提一下這一程式設計正規化。

面向介面程式設計的方式把”A依賴於一個具體的B“變成了”A依賴於介面定義的標準“或者”A依賴於介面定義的能力“。這是一個非常重要的思維模式的不同。

”A依賴於一個具體的B“類似於我們在日常生活中遇到的非標準件。假設汽車上的一個小零件壞了,而且這個零件是一個非標準件,那麼需要把車開到專門的汽車門店去修理。這個門店要是沒有這種零件,就還需要花費時間訂購。可如果是家裡的燈泡壞了,那麼只需要到附近的五金店,就可以買到新的,很快就能修好。之所以能有這種便利,是因為所有燈泡都必須遵循國家標準,從而能夠靈活互換。更重要的是:標準化的介面讓所有家庭的照明系統和各家照明裝置製造廠商成功解耦了,僅和國家標準存在耦合。國家標準非常穩定,自然整個照明系統的維護成本就大幅降低了。

標準化是現代工業的基礎。對於在上一段中提到的標準化,現行最新的標準是 GB/T 1406.1-2008《燈頭的形式和尺寸》。例如,日常生活中最常用的燈頭是E27螺口燈頭,更細一點的是E14燈頭。正是因為有了這些標準,各家燈具製造廠商和燈泡製造廠商才可以各自獨立,產品互相相容。這種簡單性和互換性也是軟體系統設計所追求的目標。儘管由於軟體系統的複雜度要遠遠超出照明系統,導致實現完全的標準化定義非常困難,但是依賴於介面,而不是依賴於具體的實現,是一個普遍的原理。

介面作為一項“設計契約”,分離了做什麼(介面)和怎麼做(介面的具體實現)這兩個關注點。介面實現方需要確保自己正確履行抽象介面中定義的所有職責,而介面依賴方在確保自己正確地呼叫介面的情況下則可以獲得相對應的服務。由此帶來更為穩定的設計。

相關文章