天天看點

[5+1]接口隔離原則(二)

前言

面向對象的SOLID設計原則,外加一個迪米特法則,就是我們常說的5+1設計原則。

[5+1]接口隔離原則(二)

↑ 五個,再加一個,就是5+1個。哈哈哈。↑

這六個設計原則的位置有點不上不下。論原則性和理論指導意義,它們不如封裝繼承抽象或者高内聚低耦合,是以在寫代碼或者code review的時候,它們很難成為“應該這樣做”或者“不應該這樣做”的一個有說服力的理由。論靈活性和實踐操作指南,它們又不如設計模式或者架構模式,是以即使你能說出來某段代碼違反了某項原則,常常也很難明确指出錯在哪兒、要怎麼改。

是以,這裡來讨論讨論這六條設計原則的“為什麼”和“怎麼做”。順帶,作為面向對象設計思想的一環,這裡也想聊聊它們與抽象、高内聚低耦合、封裝繼承多态之間的關系。

[5+1] 接口隔離原則(一)

上一部分在這裡。

[5+1] 接口隔離原則(二)

接口隔離與面向對象

我記得,項目管理中有一項“幹系人管理”。在幹系人管理中,我們需要識别出與項目存在利益關系的各方,然後确定各自的關注點,最後根據不同的關注點做不同的溝通協作、資源協調、期望管理、結果與過程彙報等。

[5+1]接口隔離原則(二)

項目幹系人管理

在幹系人管理中,我們需要注意一點:不同關系人的關注點大多不一樣。使用者關注能不能滿足需求;客戶關注能不能賺到錢;boss大佬關注結果,項目經理關注過程;産品經理關注功能,技術經理關注品質;對接系統的開發關注接口文檔,系統内部開發關注流程、類和庫表設計……

在實踐中,我們常常會從一套基礎資料中提取不同内容,以滿足不同幹系人的不同關注點。例如,一份詳細設計文檔就可以滿足産品經理、技術經理、對接開發和内部開發的關注點;一份分工排期表既可以讓大佬知道什麼時候有結果,也可以讓項目經理知道過程中需要注意哪些人、把控哪些點。

盡管有很多不同資料都來自同一個源頭,但我們一般不會把基礎資料直接分發給不同的幹系人。項目經理把進度日報發給boss,boss也許眉頭一皺嫌他太啰嗦然後把他開掉了。遊戲策劃把發給客戶的抽卡/氪金分析資料捅給使用者,使用者也許眉頭一皺遊戲太垃圾然後就退遊保肝了。

[5+1]接口隔離原則(二)

說難聽點就是“見人說人話見鬼說鬼話”

面向對象中也有類似的設計思路。有時候,盡管底層使用的是同一個類,但是,面向不同調用方時,我們會提供不同的接口。典型的例子就是LinkedList:

LinkedList類實作了List<E>(進而實作了Collection<E>)、Deque<E>(進而實作了Queue<E>)等接口。因而,當需要使用有序集合、并且随機寫入資料時,我們就可以通過List<E>接口來操作它。如果隻需要從表頭寫入、從表尾讀取時,我們也可以隻用Queue<E>接口來操作它:

類似的還有new ConcurrentHashmap().keySet()——明明是ConcurrentHashMap,生被用成了ConcurrentHashSet。

我們的業務系統中也有這種類,最典型的就是資料庫操作類。一般來說,資料庫操作的增删改查都會放在同一個Dao或者Mapper類中。其中的讀操作還好說,寫操作必須嚴密封鎖起來,以確定隻能在業務操作、業務事務中以一緻性方式寫入資料。否則的話,“你也說聊齋,我也說聊齋”,大家亂塗亂畫起來,豈不要把嬰甯嫁給甯采臣了?

[5+1]接口隔離原則(二)

聶小倩第一個不答應

封鎖寫操作的最簡單方式,就是接口隔離:讀操作和寫操作定義成不同的接口。讀接口可以任意使用;寫接口隻允許業務操作使用,其它操作想要寫入資料庫,必須調用業務操作接口。這樣,就可以避免完整業務資料被部分寫入、進而違反業務一緻性的問題了。

從不同的方面描述同一件事情,無論在管理上還是在面向對象設計上,都是一種很高效而且很必要的工作方式。在管理上,我們把這種工作方式叫做“見人說人話,見鬼說鬼話”;在面向對象設計上,我們把它叫做“接口隔離原則”。

接口隔離與抽象

很多時候,我們一提到抽象,就會直接把它與接口劃上等号。是以很自然的,談到接口隔離與抽象,我們也會直接地想到把“接口隔離”與“更小的抽象”劃上等号。

這個觀點倒也沒有什麼大問題。尤其是當接口隔離原則被簡化為“把龐大而臃腫的接口拆分成更小、更具體的接口”時,它與抽象之間的關系自然就隻能是“把龐大而臃腫的抽象拆分為更小、更具體的抽象”了。

例如,有時我們會在Dao層之上,增加一個DbService層,将其用作資料庫操作的更高層抽象:

這個資料庫操作抽象看起來不錯,而且蠻通用的。不過,在業務中,我們可以允許任一功能子產品都來讀資料,但隻能允許在特定的業務流程中寫資料。是以,讀操作和寫操作應當差別對待。

然而,DbService所定義的抽象卻把讀、寫兩個操作同時暴露了出來:隻要可以讀資料,就可以寫資料。例如:

實際上這個類中隻需要查詢資料。但是注入DbService接口之後,這個類也具備了寫資料的能力。也就是說,“寫操作”被洩露到了限定的業務流程之外。雖然大多數情況下,洩露出去的“寫操作”都是可控的;然而對“我不想賣、你不能賣”的抽象設計來說,這就是一個設計上的問題。

要改正這個問題,其實也很簡單:把讀寫操作拆分到兩個接口中就可以了:

這種接口拆分,不正是接口隔離原則所要求的嗎?

接口隔離原則與抽象之間的關系,不僅僅是這樣的接口拆分。如果說設計抽象的目的是“我不想賣、你不能買”,那麼接口隔離原則的要求就是“我不想買、你不能賣”——對,合起來就是“不能強買強賣”。嚴格的說,同時符合了這兩個要求的抽象,才是合格的設計。

[5+1]接口隔離原則(二)

旅遊和購物也應該“隔離”開

就像前面列舉的一些例子一樣:調用者并不關心doSth()方法的步驟,接口就不應該提供諸如step1()/step2()這樣的方法;調用者隻需要approve()方法,接口就不應該提供queryUser()方法。雖然從抽象設計的角度來看,它們的确是服務方自願提供的方法。但從接口隔離原則的角度來看,這些方法可不是調用方想要的東西。

這就像去理發店理發時,Tony老師推銷給你一張五折會員卡并說服你預存一千塊錢一樣——看起來是他讓顧客得到了實惠,實際上是他“綁架”了顧客下一次的消費行為。對于消費者來說,換一家理發店的成本也許不高,何況原先的卡還可以挂鹹魚賣掉;但是對系統來說,一次重構調整的成本可就不好說了。如果能把重構範圍限制在抽象内部,那大概就花個工本費;如果重構範圍包括了接口的所有調用方——尤其是分布式環境下的接口調用方——那簡直就是地獄難度了。

接口隔離與高内聚低耦合

其實前面已經把接口隔離與高内聚低耦合之間的關系表述得很清楚了:适當地遵循接口隔離原則,有助于建立高内聚低耦合的抽象和子產品。

例如,把SomeService接口中的step1()/step2()等方法删掉,隻保留doSth()方法,不僅遵循了接口隔離原則,也降低了服務調用者與提供者之間的耦合度。結合《細說幾種耦合》來看,這個改造至少可以避免雙方産生内容耦合。

而把FlowService中關于使用者的功能拆分到UserService中,則可以有效地提高對應子產品的内聚性:使用者相關功能和流程相關功能都放到各自的子產品中,内聚性至少可以從偶然内聚提高到過程内聚甚至順序内聚(參考《細說幾種内聚》)。内聚性提高了,自然地,使用者子產品和流程子產品之間的耦合性也降低了。

雖然遵循接口隔離原則有助于提高内聚性、降低耦合性,但是“過猶不及”,過于強調接口隔離,有時反而會降低内聚、增加耦合。

例如,Java中的Iterator接口中,就有這樣兩個方法:

大多數情況下,hasNext()方法和next()都是配套使用的。可以說,這兩個方法在一起,才能構成一個完整的疊代器抽象。如果我們機械地套用接口隔離原則,把它倆硬生生地拆分到兩個不同的接口中,反而降低了這個抽象設計的内聚性。

實際上,前面所讨論的把DbService接口拆分成DbReader和DbWriter的例子,也可能産生類似的問題。如果DbService不是供業務邏輯使用、而是僅僅提供資源服務,那麼增删改查操作就沒有必要拆開了。

[5+1]接口隔離原則(二)

好好的家夥事兒拆得稀碎也不行

接口隔離與封裝繼承多态

接口隔離原則與封裝的關系非常容易了解;相比之下,它與繼承、多态之間的關系就不那麼清晰了。

在面向對象中,接口是實作封裝特性的最有力也最常見的手段。與接口密切相關的接口隔離原則,自然也與封裝特性有着密切的關系。

相信我們很多人都被“過度包裝”惡心過:實際的商品重不到三兩、大不過拳頭,非要左一層“精美包裝”、右一層“豪華包裝”。結果呢?買的人花一筆冤枉錢買了個不痛快,用的人拆一大堆空盒子用得不痛快。哪怕是用來收禮,如果知道這“禮物”的90%是包裝盒,送禮的人恐怕也會覺得臉面無光吧!

[5+1]接口隔離原則(二)

過度包裝

冗長的接口和過度包裝的問題一樣,都是自以為是地把一大堆使用者不需要的、深惡痛絕的東西強加給使用者。這種“強加于人”,在市場營銷中叫“捆綁銷售”,在面向對象中就叫“不當封裝”:該“封”起來的沒有做好密封,不該“裝”進來的一股腦地裝到了一起。

可見,恰當的接口隔離可以保證我們的類擁有更好的封裝性。同樣的,做好接口隔離,也能在類的繼承方面給我們提供便利。

在Java中,由于接口方法都隻有方法簽名、沒有方法體,是以,實作類隻有兩個選擇:将自身聲明為抽象類,或者實作接口中的所有方法。雖然Java8允許接口方法定義方法體、以提供一個預設實作,但這個預設實作的功能非常弱,基本隻能用來向下相容,真要實作業務功能,還得靠實作類來重寫方法。總之,我們仍可以認為:接口中聲明的方法,最終都要被實作類重寫。由此可以推斷:一個隻有三個方法的接口,和一個包含了十三個方法的接口相比,顯然是前者對實作類更友好。

當然,我們也可以采用接口-基類-實作類的層次結構,來減少實作大接口時的開發量。例如下面這樣:

這樣做,确實可以解決每次實作接口都需要重寫所有方法的問題。不過,此時我們又要面對另一個可能更嚴重的問題:如果我們的SubService隻需要提供method5()這一個方法,而不需要提供BaseService中的其它功能,也就是說前者與後者并不滿足繼承所要求的“is-a”關系,此時我們讓SubService繼承BaseService,真的不是撿起芝麻丢了西瓜嗎?

誠然,并不是所有的大接口都會有這樣的問題;但幾乎所有的小接口都沒有這種問題。反過來說,使用小接口時,我們幾乎不用擔心出現過度繼承問題;而使用大接口時,我們至少應該認真思考一下這個接口及其繼承層次是否合理。這也就是此前提到的接口隔離原則的定位:它并不是絕對不可打破的禁忌,而是潛在問題、系統風險的一種訓示劑。

相比封裝與繼承,接口隔離原則與多态之間的關系更加直覺些。如果不使用多态,那麼我們一定會違反接口隔離原則:當一個接口下隻有一個實作類時,增加新的邏輯是都難免要增加接口方法,久而久之,這個接口就會變成一個巨無霸,接口隔離原則自然就無從談起了。反過來說,使用多态特性,我們就應該遵守接口隔離原則、應該定義和使用“小而美”的接口。否則的話——設想一個聲明了十多個方法的接口,每次借助多态特性來增加新的實作類時,我們都不得不把所有方法都重寫一遍,那得多麼費勁!

當然,我們仍然可以借助接口-基類-實作類的層次結構避免這個問題。但此時,我們又回到了前面提到的那個問題上:“前者與後者并不滿足繼承所要求的‘is-a’關系,此時我們讓SubService繼承BaseService,真的不是撿起芝麻丢了西瓜嗎?”

接口隔離與其它設計原則

接口隔離與單一職責

接口隔離原則與單一職責原則之間的關系是顯而易見的:違反接口隔離原則,就一定會違反單一職責原則。

無論我們把接口隔離原則定義為“用戶端隻需要依賴他們需要的接口”、還是定義為“把大接口拆分成小接口”,隻要違反了這一原則,接口内就勢必會出現不應出現的方法聲明。例如前面示例中反複提到的接口實作步驟、其它子產品功能等。而接口方法一般都是抽象方法,必須由實作類重寫。在兩者的疊加影響下,實作類中一定會出現原本不應出現的方法實作。即使我們使用了接口-基類-實作類的層次結構,或者為接口方法提供了預設方法體,也無法解決這一問題:基類中已實作的方法,以及接口中的預設方法,都會被實作類繼承下來,成為它自己的功能。這樣一來,實作類想要保持單一職責,就隻能是個奢望了。

接口隔離原則與單一職責原則之間的這種關系,歸根結底的說,是接口與實作類之間的關系決定的:接口對外聲明了“我能做什麼”,實作類則為接口提供了“怎麼做”的功能支撐。這就有點像産品和開發一樣:産品提需求,定義“這個産品能做什麼”;開發出設計、寫代碼,解決“怎麼做”的問題。

品質低下的産品需求是開發的一大痛苦之源;類似的,品質低下的接口定義也會給開發帶來無盡的痛苦。應付糟糕的産品需求已經讓人心力交瘁了,開發又何苦為難自己呢?還是認認真真遵守接口隔離原則、定義簡單清晰的接口吧!

接口隔離與開閉

在面向對象思想中,開閉原則的核心在于合理、高效地利用繼承和多态特性來“增加”新的實作類、而不是“修改”原有的實作類。是以,接口隔離原則與開閉原則之間的關系,需要繼承和多态來了解:明白了接口隔離原則與繼承、多态之間的關系,也就很容易了解它與開閉原則的關系了。

接口隔離與裡氏替換

接口隔離原則主要讨論接口的設計,而裡氏替換原則則“下沉”到了繼承層次中,主要讨論子類繼承父類時的問題。是以,二者的關系與接口隔離和開閉之間的關系一樣,也需要繞道繼承和多态。

往期索引

《面向對象是什麼》

從具體的語言和實作中抽離出來,面向對象思想究竟是什麼?公衆号:景昕的花園面向對象是什麼

《抽象》

抽象這個東西,說起來很抽象,其實很簡單。 花園的景昕,公衆号:景昕的花園抽象

《高内聚與低耦合》

《細說幾種内聚》

《細說幾種耦合》

"高内聚"與"低耦合"是軟體設計和開發中經常出現的一對概念。它們既是做好設計的途徑,也是評價設計好壞的标準。 花園的景昕,公衆号:景昕的花園高内聚與低耦合

《封裝》

《繼承》

《多态》

——“面向對象的三大特性是什麼?”——“封裝、繼承、多态。”

《[5+1]單一職責原則》

單一職責原則非常好了解:一個類應當隻承擔一種職責。因為隻承擔一種職責,是以,一個類應該隻有一個發生變化的原因。花園的景昕,公衆号:景昕的花園[5+1]單一職責原則
什麼是擴充?就Java而言,實作接口(implements SomeInterface)、繼承父類(extends SuperClass),甚至重載方法(Overload),都可以稱作是“擴充”。什麼是修改?在Java中,嚴格來說,凡是會導緻一個類重新編譯、生成不同的class檔案的操作,都是對這個類做的修改。實踐中我們會放寬一點,隻有改變了業務邏輯的修改,才會歸入開閉原則所說的“修改”之中。花園的景昕,公衆号:景昕的花園[5+1]開閉原則(一)
裡氏替換原則(Liskov Substitution principle)是一條針對對象繼承關系提出的設計原則。它以芭芭拉·利斯科夫(Barbara Liskov)的名字命名。1987年,芭芭拉在一次名為“資料的抽象與層次”的演講中首次提出這條原則;1994年,芭芭拉與另一位女性計算機科學家周以真(Jeannette Marie Wing)合作發表論文,正式提出了這條面向對象設計原則 花園的景昕,公衆号:景昕的花園[5+1]裡氏替換原則(一)
一般我們會說,接口隔離原則是指:把龐大而臃腫的接口拆分成更小、更具體的接口。不過,這并不是接口隔離原則的定義。實際上,接口隔離原則的定義其實是這樣的……用戶端不應被迫依賴它們壓根用不上的接口;或者反過來說,用戶端應該隻依賴它們要用的接口。 花園的景昕,公衆号:景昕的花園[5+1]接口隔離原則(一)
[5+1]接口隔離原則(二)