[5+1]開閉原則(一)

[5+1]開閉原則(一)

前言

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

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

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

所以,這裏來討論討論這六條設計原則的“爲什麼”和“怎麼做”。順帶,作爲面向對象設計思想的一環,這裏也想聊聊它們與抽象、高內聚低耦合、封裝繼承多態之間的關係。

=================================
↑前言↑
↓正文↓

開閉原則(Open-Closed Principle)

開閉原則指的是“對擴展開放、對修改關閉”。

“Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.”

—— Object-Oriented Software Construction, Robert C. Martin

↑ 和門一樣:開的意思是“請這邊走”,關的意思是“此路不通”

什麼是擴展?就Java而言,實現接口(implements SomeInterface)、繼承父類(extends SuperClass),甚至重載方法(Overload),都可以稱作是“擴展”。

什麼是修改?在Java中,嚴格來說,凡是會導致一個類重新編譯、生成不同的class文件的操作,都是對這個類做的修改。

實踐中我們會放寬一點,只有改變了業務邏輯的修改,纔會歸入開閉原則所說的“修改”之中。

例如,把一個很大的方法拆分成三個小方法,但方法的調用流程、業務功能等都沒有變。這個操作當然會導致類重新編譯,但還算可以接受,不必對它亮紅燈。但如果在某個方法中加一個if-else,只在某種情況下沿用原有邏輯、其它情況下使用新的功能,這時,我們就應該像甘道夫那樣,揮舞起“開閉原則”之劍,高呼“You shall not pass”了。

You shall not pass

=================================
↑開閉原則:是什麼↑
↓開閉原則:爲什麼↓

爲什麼

有那麼一段時間,我幾乎成天跟我們老大抱怨。
“這個if-else的條件重複出現了至少五次了,爲什麼不重構一下啊?”
“這個同步請求的處理時間太長了,可以改用異步輪詢來處理吧?”
“這個表的讀寫比這麼高,考慮下給它加個緩存吧。”
……諸如此類。

而我老大呢,一般都是這樣答覆的:
“你有時間就去改一改吧。不過要注意,不要影響業務功能。”

於是我就一直沒有改。

一方面,我一直沒有時間。業務需求一個接一個,每次都要傷筋動骨地改一大堆代碼。而且,我們經常遇到改着改着發現產品漏提了一項需求、測着測着發現開發漏改了一處代碼這樣的問題,開發簡直一刻不得閒,哪兒騰得出手來做技術上的重構優化?更何況,重構優化完了之後,測試組還要進行迴歸驗證。即使開發能改得過來,測試也測不過來。

另一方面,只要修改了代碼,誰敢保證不影響業務功能呢?我曾經遇到過在某處加一行代碼、結果在一百多行外引發bug的情況;也遇到過開發的單元測試和QA的迴歸測試都安然無恙、偏偏在線上環境爆出bug的情況。誰能保證重構優化後的代碼能夠在重重考驗下全身而退呢?

↑ 求而不得,真的很無奈。


開閉原則可以緩解第一個問題。遵循了開閉原則的話,開發和測試都只需要專注於新的代碼、新的功能,而不用操心修改老代碼對老邏輯會有什麼樣的影響。因而,大家可以用較少的時間來完成業務需求,從而騰出工夫來做重構優化。

但是,開閉原則只能幫助開發環節、以及下游的測試環節做一些改進。要真正解決產品需求絡繹不絕、每個需求都要傷筋動骨、產品和開發顧此失彼等問題,需要產品、開發、測試甚至運維等環節全線聯動,用一套完整方案理順整個產品上線流程。不過這裏只聊開閉,這個問題暫且按下不表。

雖然只能緩解第一個問題,但開閉原則可以很好地解決第二個問題。

既然修改代碼會影響業務功能,那就不修改現有代碼。連一行代碼都沒修改,現有功能總不會變了吧?這就是開閉原則中的“對修改關閉”的意義。

↑ 我都沒改代碼你就出bug了?

然後,通過實現接口、繼承父類等方式,爲系統引入新的功能。這就是開閉原則中“對擴展開放”的意義。

在單元測試、迴歸測試和金絲雀測試都通過之後,祖傳的老代碼就可以“壽終正寢”,系統也就正式而平滑地過渡到重構優化後的代碼上來了。


藉助開閉原則,我們用設計模式重構了無數個if-else,用消息隊列、異步回調和異步輪詢改寫了無數個同步請求,用緩存替代了無數次數據庫查詢……我們甚至用這種方法,把一套老系統代碼平滑地合併到了新的系統內,並逐步地用新代碼和新配置替代老系統的代碼和配置。當老系統的代碼和配置都不再被使用時,它就徹底地退出歷史舞臺了。

平滑過渡的一套老系統

當然,不光是重構優化,在開發新需求時,開閉原則也有很大的幫助。究其根本,是“對擴展開放、對修改關閉”的做法能夠使我們的系統兼具擴展性和穩定性。擴展性能幫助系統輕鬆地吸納新技術、實現新需求;而穩定性則能夠降低系統中的新技術、新需求在業務功能、架構設計、核心代碼等方面的影響,從而減少系統bug、需求範圍和開發工作量。

“求木之長者,必固其根本;欲流之遠者,必浚其泉源”。無論是做業務需求,還是做重構優化,“開閉原則”都是我們把系統做成、做完、做好的“根本”和“泉源”。

=================================
↑開閉原則:爲什麼↑
↓開閉原則:怎麼做↓

怎麼做

要把開閉原則運用到開發實踐中,跟所謂的工作閉環“計劃-實施-檢查-處理”非常相似。

首先,我們要做出“開閉計劃”,也就是判斷你的代碼中,哪些地方會發生變化、會發生什麼樣的變化。然後,根據這個“開閉計劃”,把不會改變的代碼嚴密地封裝起來;並根據可能發生的變化、以及發生變化的方式,在代碼中預留好擴展點。接着,合理地運用預留的擴展點來實現業務需求或者重構優化。最後,必要時,根據實際情況去調整當初設計的擴展點。

↑ “工作閉環”在很多場合都能用得上。


開閉計劃

在開閉原則的“閉環”中,“計劃”是這四個步驟中最困難、但也是最重要的一步。

說它困難,是因爲除了“未來一定會不一樣”這一點之外,誰都不知道未來會怎樣。也許下個月這個系統就要重做了;也許下半年這項業務就要下線了;也許明年就要封路封城、在家隔離了;也許我們現在認爲不會改的代碼,明天就被改得面目全非了;也許當改變真的降臨時,我們現在預留的擴展點完全是在幫倒忙……

說它重要,是因爲如果對未來毫無規劃,我們只能走一步算一步,走到哪算哪兒。而在這種情況下,絕大部分時候,我們所做的每一件事都是在爲未來挖坑,都是在增加自己的“技術債”。接口方法返回值類型聲明爲Long,數據庫表中引入一個傳遞依賴,在邏輯相似的兩個類之間大段地copy代碼……當新的需求要求修改這部分代碼時,我們除了“待從頭,收拾舊山河”,似乎也沒有什麼更好的辦法。

這就好像螃蟹這類的甲殼動物一樣,眼前這身甲殼雖然合身,但是當身體逐漸長大,原本“合身”的甲殼逐漸變成了牢籠,最後只能把它脫掉、再長一幅新的來用。

↑ 甲殼動物蛻殼容易;業務系統重構是真難。


因爲做“開閉計劃”會很困難,所以敏捷開發模式提出了“簡單設計”的口號;在互聯網開發中也有“快速試錯”的傳統。但是簡單設計不等於沒有設計。它提倡的是不對未來做過多預測、也不根據這些預測做過多設計。快速試錯也不是技術上的決策。它指的是找不準市場方向時,對業務進行快速迭代、用市場反應來爲業務指路。

而且,即使是簡單設計和快速試錯,也要求我們的系統遵循開閉原則。否則,每次需求都要拆牆砸柱重做水電,敏捷項目怎麼能敏捷得起來?快速試錯又怎麼能快速起來?這也是其重要性的一種體現。


那麼,“開閉計劃”到底要怎麼做呢?

“未來的種子深埋在過去當中”。雖然無法預見未來,但我們可以總結過去。無論是業務還是系統,我們總能從它們過去的發展軌跡中找到一點變化的模式。通過對這些模式進行分析和總結,我們就能夠把未來大部分的可能性握在手中了。

但是,如果業務和系統都剛剛起步,還沒有可用於分析總結的過去,我們要怎麼辦呢?“太陽底下沒有新鮮事”,我們的業務和前人的業務、我們的系統和前人的系統,多多少少都有相似之處。而前人已經對這些業務和系統做過很多分析和總結了——比如這裏說的設計原則,比如以後要分享的設計模式。“它山之石可以攻玉”,我們把這些成果“拿來主義”,當遇到前人經歷過的變化時,就可以輕鬆應對了。

但是,如果對設計模式不熟悉,或者碰到了哪種設計模式都不適用的極端情況,那又該怎麼辦呢?


如果真的遇到了這種情況,那麼可以考慮考慮下面這些建議。

第一,面向接口編程。接口的“開閉”性是不言自明的。雖然具體的實現類也可以“對擴展開放、對修改關閉”,但是相比接口,還是要略遜一籌。

第二,不要用基本數據類型做接口方法的入參和返回值。這一點相信是衆所周知的,不多囉嗦。

第三,在實現接口時,區分處理“數據結構”和“算法”。

↑ 程序=數據結構+算法

在設計數據結構時,我們應該忠於現實:數據結構在現實中是怎樣的,那麼在代碼中就應該是怎樣的。現實中是正整數,代碼中就應該是Integer或Long,而不應該是String;現實中是日期,代碼中就應該是Date或LocalDateTime,而不應該是String;現實中是1:1的關係,代碼中就應該是1:1的關係;現實中是1:N:M的關係,代碼中就應該是1:N:M的關係。

數據是信息的載體。它承載信息的方式不僅僅是數據的取值,還包括數據的類型和數據之間的關係。當看到整數類型的“有效期”時,我們會很自然地認爲它是“從某個日期開始的有效期天數”;而在看到日期類型的“有效期”時,我們又會很自然地把它理解爲“有效期截止日”。這就是數據類型所承載的信息。當銀行卡與用戶之間是1:1的關係時,說明多筆借款申請可以共用一張銀行卡,那麼用戶只需綁定一次即可;但若銀行卡與借款申請之間的關係是1:1,說明借款申請之間不能複用銀行卡數據,則用戶每次申請借款時,都需要重新綁定一次銀行卡。這就是數據關係所承載的信息。

如果我們在系統設計時,不使用實際的數據結構、而是定義某種特殊的數據結構,那就意味着我們將某種特殊的信息隱式地固定在了這種設計中。而這種信息勢必會對後續擴展帶來一定的約束。如果後續擴展時的數據結構與這種約束相牴牾,那麼光是如何處理歷史數據的問題就足夠大家喝一壺了。

這種“隱藏的、特殊的信息”,比較常見於傳遞依賴中。例如,用戶、賬號、銀行卡之間實際的數據關係是1:1:N。但爲了查詢方便,我們庫表中只存了用戶id和銀行卡號。這時,表中就出現了“傳遞依賴”。當某一天,用戶和賬號之間的數據關係擴展爲1:N的時候,只存了用戶id和銀行卡號的表就遇上麻煩了。這也是爲什麼在數據表設計時,我們要遵循第三範式的原因之一:傳遞依賴把一部分信息變成了某種隱藏的、固定的數據約束。這種約束不僅是對當前數據的約束,也是對未來擴展的約束。

忠於現實的數據結構也有可能遇到這種問題。但是相對來說,概率要小得多。因爲在這種情況下,數據所承載的信息和約束都會直接體現在數據上,因而會更加直白、靈活。這就像裝修時水電都走明線一樣,雖然不如走暗線那麼“優雅”,但是當需要改水改電時,明線的優勢就一目瞭然了。


說完數據結構,我們說說算法。對業務系統來說,“算法”實際就是業務功能在系統中的流程邏輯。與設計數據結構時應該忠於現實的做法相反,設計系統流程時恰恰不能照抄業務流程圖。業務流程圖僅僅是根據當前的業務流程來製作的。如果系統流程直接照抄業務流程,那就意味着這個系統徹底失去了對未來的擴展性、以及做出擴展的自主性。

例如,下圖是我們某個系統中“用戶註銷需求”的兩份簡要流程設計。圖中左側的是業務流程,而右側是系統流程。對比之下,我們可以發現:如果按業務流程來開發,那麼,每當需要刪除信息的表發生變更時——加一張表、少一張表、改一個查詢條件等,我們就要修改一次代碼;而按系統流程來開發的話,大多數情況下,這些變更都只需要修改配置即可。顯然,後者更加的“開閉”,對開發和測試也更加的友好。

↑ 業務流程和系統流程的區別

不照抄的話,要怎麼辦呢?我在左耳朵耗子的一篇文章中看到過這樣的觀點:把業務流程拆分爲“控制單元”和“處理單元”兩類邏輯。這是設計算法時的一種思路。放在“開閉計劃”的視角下,“控制單元”是易變的、需要預留擴展點的;而“處理單元”是不那麼容易變的、可以封裝爲“黑盒”的。這樣,在做業務需求或重構優化時,不用修改已有的處理單元、而是通過擴展控制單元來引入新的處理單元,也就很好地遵守了“開閉原則”。

↑ 業務流程=控制+處理

除了按“控制+處理=系統流程”這種思路來設計之外,在單一職責原則一文中提到的“邏輯簡單、結構複雜”也是一種符合開閉原則的設計思路。這種思路在前文中已經介紹過,這裏就不贅述了。

把“邏輯複雜度”轉變爲“結構複雜度”,就是降低邏輯單元內的邏輯複雜度、提高邏輯單元間的結構複雜度。或者簡單來說,就是把系統由“複雜邏輯、簡單結構”轉變成“簡單邏輯、複雜結構”。除了優化算法之外,重構函數、系統分層、拆分服務等方式,本質上都是在按這種思路來應對系統複雜度。

花園的景昕,公衆號:景昕的花園[5+1]單一職責原則

=================================
↑正文↑
↓往期索引↓

往期索引

面向對象概述

《面向對象是什麼》

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

抽象

抽象這個東西,說起來很抽象,其實很簡單。

花園的景昕,公衆號:景昕的花園抽象

高內聚與低耦合

高內聚與低耦合

細說幾種內聚

細說幾種耦合

"高內聚"與"低耦合"是軟件設計和開發中經常出現的一對概念。它們既是做好設計的途徑,也是評價設計好壞的標準。

花園的景昕,公衆號:景昕的花園高內聚與低耦合

封裝繼承多態

封裝

繼承

多態》

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

[5+1]SOLID設計原則+迪米特法則

[5+1]單一職責原則

單一職責原則非常好理解:一個類應當只承擔一種職責。因爲只承擔一種職責,所以,一個類應該只有一個發生變化的原因。花園的景昕,公衆號:景昕的花園[5+1]單一職責原則

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