寫軟體和造樓房一樣需要設計,但是和建築行業嚴謹客觀的設計規範不同,軟體設計常常很主觀,且容易引發爭論。 設計模式被認為是軟體設計的“規範”,但是在互聯網快速發展的過程中,也暴露了一些問題。相比過程式代碼的簡單與易於修改,設計模式常常導致代碼複雜,增加理解與修改的成本,我們稱之為 “過度設計”。... ...
引言
寫軟體和造樓房一樣需要設計,但是和建築行業嚴謹客觀的設計規範不同,軟體設計常常很主觀,且容易引發爭論。
設計模式被認為是軟體設計的“規範”,但是在互聯網快速發展的過程中,也暴露了一些問題。相比過程式代碼的簡單與易於修改,設計模式常常導致代碼複雜,增加理解與修改的成本,我們稱之為 “過度設計”。因而很多人認為,設計模式只是一種炫技,對系統沒有實質作用,甚至有很大的挖坑風險。這個觀點容易讓人因噎廢食,放棄日常編碼中的設計。
本文將深入探索如下問題:
-
為什麼長期來看,設計模式相比過程式代碼是更好的?
-
什麼情況下設計模式是有益的,而什麼情況下會成為累贅?
-
如何利用設計模式的益處,防止其腐化?
設計模式的缺陷
“過度設計” 這個詞也不是空穴來風,首先,互聯網軟體的迭代比傳統軟體快很多,傳統軟體,比如銀行系統,可能一年只有兩個迭代,而網站的後臺可能每周都在發佈更新,所以互聯網非常註重軟體修改的便捷性。其次,設計模式的 “分模塊”,“開閉原則” 等主張,天然地易於拓展而不利於修改,和互聯網軟體頻繁迭代產生了一定的衝突。
開閉原則的缺陷
開閉原則:軟體中對象應該對擴展開放,對修改關閉。
基於開閉原則,誕生了很多中台系統。應用通過插件的方式,可以在滿足自身定製業務需求的同時,復用中台的能力。
當業務需求滿足中台的主體流程和規範時,一切看上去都很順利。一旦需求發生變更,不再符合中台的規範了,往往需要中台進行傷筋動骨的改造,之前看到一篇文章吐嘈 “本來業務上一周就能搞定的需求,提給中台需要8個月”。
所以基於中台無法進行深度的創新,深度創新在軟體上必然也會有深度的修改,而中台所滿足的開閉原則是不利於修改的。
最小知識原則的缺陷
最小知識原則:一個對象對於其他對象的瞭解越少越好。
最小知識原則又稱為 “迪米特法則”,基於迪米特法則,我們會把軟體設計成一個個 “模塊”,然後對每個 “模塊” 只傳遞需要的參數。
在過程式編碼中,代碼片段是擁有上下文的全部信息的,比如下麵的薪資計算代碼:
// 績效 int performance = 4; // 職級 int level = 2; String job = "engineer"; switch (job) { case "engineer": // 雖然計算薪資時只使用了 績效 作為參數, 但是從上下文中都是很容易獲取的 return 100 + 200 * performance; case "pm": // .... 其餘代碼省略 }
而如果我們將代碼改造成策略模式,為了滿足迪米特法則,我們只傳遞需要的參數:
// 績效 int performance = 4; // 職級 int level = 2; String job = "engineer"; // 只傳遞了需要 performance 參數 Context context = new Context(); context.setPerformance(performance); strategyMap.get(job).eval(context);
需求一旦變成 “根據績效和職級計算薪資”,過程式代碼只需要直接取用上下文的參數,而策略模式中需要分三步,首先在 Context 中增加該參數,然後在策略入口處設置參數,最後才能在業務代碼中使用增加的參數。
這個例子尚且比較簡單,互聯網的快速迭代會讓現實情況更加複雜化,比如多個串聯在一起模塊,每個模塊都需要增加參數,修改成本成倍增加。
可理解性的缺陷
設計模式一般都會應用比較高級的語言特性:
-
策略模式在內的幾乎所有設計模式都使用了多態
-
訪問者模式需要理解動態分派和靜態分派
-
...
這些大大增加了設計模式代碼的理解成本。而過程式編碼只需要會基本語法就可以寫了,不需要理解這麼多高級特性。
小結
這三點缺陷造成了設計模式和互聯網快速迭代之間的衝突,這也是應用設計模式時難以避免的成本。
過程式編碼相比設計模式,雖然有著簡單,易於修改的優點,但是卻有永遠無法迴避的本質缺陷。
過程式編碼的本質缺陷
上文中分析,過程式編碼的優點就是 “簡單,好理解,易於修改”。這些有點乍看之下挺對的,但是仔細想想都很值得懷疑:
-
“簡單”:業務邏輯不會因為過程式編碼而變得更加簡單,相反,越是大型的代碼庫越會大量使用設計模式(比如擁有 2400w 行代碼的 Chromium);
-
“好理解”:過程式編碼只是短期比較好理解,因為沒有設計模式的學習成本,但是長期來看,因為它沒有固定的模式,理解成本是更高的;
-
“易於修改”:這一點我相信是對的,但是設計模式同樣也可以是易於修改的,下一節將會進行論述,本節主要論述前兩點。
軟體複雜度
軟體工程著作 《人月神話》 中認為軟體複雜度包括本質複雜度和偶然複雜度。
本質複雜度是指業務本身的複雜度,而偶然複雜度一般是因為方法不對或者技術原因引入的複雜度,比如拆分服務導致的分散式事務問題,就是偶然複雜度。
如果一段業務邏輯本來就很複雜,即本質複雜度很高,相關模塊的代碼必然是複雜難以理解的,無論是採用設計模式還是過程式編碼。“用過程式編碼就會更簡單” 的想法在這種情況下顯然是荒謬的,相反,根據經驗,很多一直在採用過程式編碼的複雜模塊,最後都會變得邏輯混亂,缺乏測試用例,想重構時已經積重難返。
那麼設計模式會增加偶然複雜度嗎?閱讀有設計模式的代碼,除了要理解業務外,還要理解設計模式,看起來是增加了偶然複雜度,但是下文中我們會討論,從長期的角度來看,這不完全正確。
理解單一問題 vs 理解一類問題
開頭提到,設計模式是軟體設計的“規範”,和建築業的設計規範類似,規範能夠幫助不同背景的人們理解工程師的設計,比如,當工人們看到三角形的結構時,就知道這是建築師設計的支撐框架。
過程式代碼一般都是針對當前問題的某個特殊解決方法,不包含任何的 “模式”,雖然錶面上減少了 “模式”的學習成本,但是每個維護者/調用者都要去理解一遍這段代碼的特殊寫法,特殊調用方式,無形中反而增加了成本。
以數據結構的遍歷為例,如果全部採用過程式編碼,比如二叉樹列印的代碼是:
public void printTree(TreeNode root) { if (root != null) { System.out.println(root.getVal()); preOrderTraverse1(root.getLeft()); preOrderTraverse1(root.getRight); } }
圖的節點計數代碼是:
public int countNode(GraphNode root) { int sum = 0; Queue<Node> queue = new LinkedList<>(); queue.offer(root); root.setMarked(true); while(!queue.isEmpty()){ Node o = queue.poll(); sum++; List<Node> list = g.getAdj(o); for (Node n : list) { if (!n.isMarked()) { queue.add(n); n.setMarked(true); } } } return sum; }
這些代碼本質上都是在做數據結構的遍歷,但是每次讀到這樣的代碼片段時,你都要將它讀到底才發現它其實就是一個遍歷邏輯。幸好這裡的業務邏輯還比較簡單,就是一個列印或者計數,在實際工作中往往和更複雜的業務邏輯耦合在一起,更難發現其中的遍歷邏輯。
而如果我們使用迭代器模式,二叉樹的列印代碼就變成:
public void printTree(TreeNode root) { Iterator<TreeNode> iterator = root.iterator(); while (iterator.hasNext()) { TreeNode node = iterator.next(); System.out.println(node); } }
圖的節點計數代碼變成:
public int countNode(GraphNode root) { int sum = 0; Iterator<TreeNode> iterator = root.iterator(); while (iterator.hasNext()) { iterator.next(); sum++; } return sum; }
這兩段代碼雖然有區別,但是它們滿足一樣的 ”模式“,即 “迭代器模式”,看到 Iterator 我們就知道是在進行遍歷,甚至都不需要關心不同數據結構具體實現上的區別,這是所有遍歷統一的解決方案。雖然在第一次閱讀這個模式的代碼時需要付出點成本學習 Iterator,但是之後類似代碼的理解成本卻會大幅度降低。
設計模式中類似上面的例子還有很多:
-
看到 XxxObserver,XxxSubject 就知道這個模塊是用的是觀察者模式,其功能大概率是通過註冊觀察者實現的
-
看到 XxxStrategy 策略模式,就知道這個模塊會按照某種規則將業務路由到不同的策略
-
看到 XxxVisitor 訪問者模式 就知道這個模塊解決的是嵌套結構訪問的問題
-
...
是面對具體問題 case by case 的學習,還是掌握一個通用原理理解一類問題?肯定是學習後者更有效率。
“過程式代碼更加好理解”往往只是針對某個代碼片段的,當我們將範圍擴大到一個模塊,甚至整個系統時,其中會包含大量的代碼片段,如果這些代碼片段全部是無模式的過程代碼,理解成本會成倍增加,相似的模式則能大大降低理解成本,越大的代碼庫從中的收益也就越大。
新人學習過程式編碼和設計模式的學習曲線如下圖:
過程式編碼雖然剛開始時沒有任何學習壓力,但是不會有任何積累。設計模式雖然剛開始時很難懂,但是隨著學習和應用,理解會越來越深刻。
設計模式防腐
前文中提到,互聯網軟體非常註重修改的便捷性,而這是過程式編碼的長處,設計模式天然是不利於修改的。但是過程式編碼又有著很多致命的問題,不宜大規模使用。我們如何才能在發揮設計模式長處的同時,揚長補短,跟上業務的快速演進呢?
腐敗的設計模式
有一條惡龍,每年要求村莊獻祭一個少女,每年這個村莊都會有一個少年英雄去與惡龍搏鬥,但無人生還。
又一個英雄出發時,有人悄悄尾隨,龍穴鋪滿金銀財寶,英雄用劍刺死惡龍。然後英雄坐在屍身上,看著閃爍的珠寶,慢慢地長出鱗片、尾巴和觸角,最終變成惡龍。
以上是緬甸著名的 “屠龍少年變成惡龍” 的傳說。見過很多系統,最初引入設計模式是為了提高可維護性,當時或許實現了這個目標,但是隨著時間推移,變成了系統中沒人敢修改,“不可維護” 的部分,最終成為一個 “過度設計”,主要原因有以下兩點:
-
無法調試: 新的維護者無法通過調試快速學習模塊中的 “模式”,或者說因為學習成本太高,人們常在沒有弄清楚“模式”的情況下就著手改代碼,越改越離譜,最終覆水難收
-
沒有演進: 系統中的設計模式也是要跟隨業務不斷演進的。但是現實中很多系統發展了好幾年,只在剛開始創建的時候進行過一次設計,後來因為時間緊或者懶惰等其他原因,再也沒有人改過模式,最終自然跟不上業務,變成系統中的累贅。
可調試的模塊
“模塊” 是軟體調試的基本單位,一個模塊中可能會應用多種 “設計模式” 來輔助設計。設計模式相比過程式編碼,邏輯不是線性的,無法通過逐行閱讀來確認邏輯,調試就是後來人學習理解設計的重要途徑。在理解的基礎上,後人才能進行正確的模式演進。
“模塊” 在軟體工程中的概念比較含糊:
-
模塊可以是一個獨立的系統。由多個微服務構成的一個系統,每個微服務可以認為是一個 “模塊”;
-
在同一個應用中和一個功能相關的對象集合也可以認為是一個模塊。
隨著微服務概念的興起,很多人誤認為只有將代碼拆成單獨的系統,才能叫 “模塊”,其實不然,我們應該先在同一個應用中將模塊拆分開來,然後再演化成另一個單獨的應用,如果一上來就強行拆的話,只會得到兩個像量子糾纏一樣耦合在一起的應用。
關於軟體調試。有的人傾向於每做一點修改就從應用的入口處(點擊圖形界面或者調用 http 介面)進行測試,對他來說應用內部的分模塊就是一種負擔,因為一旦測試不通過,他需要理解模塊之間複雜的交互,然後確認傳入被修改模塊的參數是什麼。對他來說,肯定是全部使用過程式編碼更好理解一些,然後抱怨系統 ”過度設計“,雖然可能設計並沒有過度。
有經驗的工程師在修改完代碼後,會先測試被修改模塊的正確性,沒有問題後,應用入口處的測試只是走個流程,大多可以一遍通過。但是如果一個模塊沒有辦法獨立調試的話,那麼它所有人來說都是一個累贅。
對於獨立系統的模塊,它的介面應該在脫離整個應用後也明確的含義的,介面參數也應該儘量簡單且容易構造。
對於同一應用中的代碼模塊,它還應該具備完善的單元測試,維護者通過單元測試就可以理解模塊的特性和限制,通過本地 debug 就可以理解模塊的整體設計。
John Ousterhout 教授(Raft 的發明者)的著作 《軟體設計哲學》中提到深模塊的概念,給我們設計模塊提供了非常好的指導。
深模塊是指介面簡單,但是實現複雜的模塊,就像我們的電腦,它看上去只是一塊簡單的板,卻隱藏了內部複雜的功能實現。John 認為設計良好的模塊都應該是深的,設計良好的應用應該由深模塊組成。
從軟體調試的角度來說,介面簡單意味著它易於調試和理解,實現複雜意味著它能夠幫助我們屏蔽掉很多的業務複雜性,分模塊的代價是值得的。
上面的論述可能比較偏向於思想認知層面,關於是實踐層面可以參考我的另一篇文章 代碼重構:面向單元測試。
可調試的模塊能夠讓我們修改設計模式的心理壓力大大降低,因為有任何問題我們都可以很快發現。有了這個基礎,我們才能跟著業務去演進我們的模式。
模式演進
互聯網應用更新迭代頻繁,因為設計模式不易於修改,外加模塊不好調試,很多團隊就懶得對模式進行演進,而是各種繞過的 “黑科技”。很多應用都已經發展了好幾年,用的還是系統剛創建時的模式,怎麼可能還跟得上業務發展,於是就變成了人們眼中的 “過度設計”。
設計模式也是需要跟著業務演進的。當對未來的業務進行規劃,也要同時對系統模式進行思考,系統的模式是否還能跟上未來業務的規劃?在迭代中不斷探索最符合業務的設計模式。
Java8 引入的很多新特性可以幫助我們降低業務頻繁演進時,模式的遷移成本。當我們對是否要應用某個模式猶豫不絕的時候,可以考慮使用 函數式設計模式,以策略模式為例,在面向對象中,策略模式必須採用如下編碼:
interface Strategy { void doSomething(); } class AStrategy implements Strategy { //... 代碼省略 } class BStrategy implements Strategy { //... 代碼省略 } 及 // 業務代碼 class AService { private Map<String, Strategy> strategyMap; public void doSomething(String strategy) { strategyMap.get(strategy).doSomething(); } }
我們新建了好多類,一旦日後反悔,遷移的成本非常高。而使用函數式策略模式,我們可以將他們暫且全部寫在一起:
class AService { private Map<String, Runnable> strategyMap; static { strategyMap.put("a", this::aStrategy); strategyMap.put("b", this::bStrategy); } public void doSomething(String strategy) { strategyMap.get(strategy).run(); } private void aStrategy() { //... } private void bStrategy() { //... } }
可以看到設計模式的函數式版本,相比面向對象版本,在隔離和封裝上相對差些,但是便捷性好一些。
所以我們可以在業務不穩定的初期先使用函數式設計模式,利用它的便捷性快速演進,等到業務逐漸成熟,模式確定之後,再改成封裝性更好的面向對象設計模式。
更多的函數式設計模式可以參考《Java8 實戰》中的 函數式設計模式 相關章節。
小結
“設計模式” 作為對抗 “軟體複雜度” 惡龍的少年,可能業務發展,缺乏演進等原因,最終自己腐壞成了新的 “惡龍”。
為了對抗設計模式的腐壞:
-
構造可調試的模塊,保證後來的維護者能夠通過調試快速理解設計。
-
在業務發展中不斷探索最合適的模式。
開發效率與系統的成長性
在思考業務的同時,還要思考模式的演進,開發效率似乎變低了。但是這額外的時間並沒有被浪費,在設計過程也是對業務的重新思考,進一步加深對業務的理解,編碼和業務之間必然是存在巨大的鴻溝,設計模式能夠幫助我們彌補這條鴻溝,演進出和業務更加貼合的模塊,從而提升長期的效率。
複雜軟體是需要長期成長演化的。JetBrains 花了十幾年時間才讓 Idea 形成優勢,清掃免費 IDE 占據的市場; 米哈游也用了接近十年的時間才形成足夠的技術優勢,在市場上碾壓了同時期的競爭對手。
而設計模式就是在幫助我們對業務進行合理的抽象,儘可能地復用,這樣系統可以從每個模塊地成長中收益,而不是像過程式編碼,每次都重頭開始,重覆解決那些已經解決過的問題。
舉一個我工作中的例子,釘釘審批的表單有著複雜的嵌套結構,它由控制項和明細組成,而明細中又子控制項(有的控制項中還有子控制項,甚至還有關聯其他表單的控制項,總之很複雜就對了),最初我們採用過程式編碼,每當需要處理控制項時,就手寫一遍遍歷:
// 統計 a 控制項的總數 public int countComponentAB(Form form) { int sum = 0; for (Component c: form.getComponents()) { if (c.getType() == "A") { sum++; } else if (c.getType == "Table") { // 明細控制項含有子控制項 for (Component d: c.getChildren()) { if (d.getType() == "A") { sum++; } } } } return sum; }
// 返回表單中所有的 A 控制項和 B 控制項 public List<Component> getComponentAB(Form form) { List<Component> result = new ArrayList<>(); getComponentABInner(result, form.getItems()); return result; } private getComponentABInner(List<Component> result, List<Component> items) { for (Component c: items) { if (c.getType() == "A" || c.getType() == "B") { result.add(c); } else if (!c.getChildren().isEmtpy()) { // 遞歸訪問子控制項 getComponentABInner(result, c.getChildren()); } } }
這兩段代碼各自有點 “小 bug”:
-
第一段代碼只展開了一層子控制項,但是審批表單是支持多層子控制項的
-
第二段代碼雖然用遞歸支持了多層子控制項,但是並不是所有的子控制項都屬於當前表單(前面提到過,審批支持關聯其他比表單的控制項)
兩段代碼風格都不一樣,因此只能分別在上面修修補補,新同學來大概率還會犯相同的錯誤,此時,系統也就談不上 “成長”。
但是 Visitor 模式可以幫助我們將嵌套結構的遍歷邏輯統一抽象出來,使用 Visitor 模式重新編碼後的兩段代碼看起來如下:
// 統計 a 控制項的總數 class CountAVisitor extends Visitor { public int sum; @Override public void visitA(ComponentA a) { sum++; } } public int countComponentAB(Form form) { CountAVisitor aVisitor = new CountAVisitor(); // 遍歷邏輯統一到了 accept 中 form.accept(aVisitor); return aVisitor.sum; }
// 返回表單中所有的 A 控制項和 B 控制項 class GetComponentABVisitor extends Visitor { public List<Component> result; @Override public void visitA(ComponentA a) { result.add(a); } @Override public void visitB(ComponentB b) { result.add(b); } } public List<Component> getComponentAB(Form form) { GetComponentABVisitor abVisitor = new GetComponentABVisitor(); form.accept(abVisitor); return abVisitor.result; }
關於 Visitor 模式的細節,可以參考我的另一篇文章 重新認識訪問者模式。
對於使用者來說,雖然第一次看到這種寫法時,需要花點時間學習模式,和理解其中的特性,但是一旦理解之後,不僅可以快速理解所有類似代碼,還可以利用這個模塊解決所有遍歷問題,而且這個模塊是經過驗證,能夠健壯地解決問題。
相比之下,過程式編碼,儘管都是遍歷邏輯,每一段風格都不一樣,每一次都要重新理解,每一段都有不一樣的特性和 bug,明明知道邏輯就在那裡,但是卻無法復用,每一任維護者只能繼續踩前人踩過的坑,重覆地解決問題。對於系統的長期成長是不利的。
幸福的家庭都是類似的,不幸的家庭各有各的不幸。
因噎廢食的陷阱
軟體工程師的成長
在工程師成長的路上,有很多坎坷,“不要過度設計” 就是其中無比甜蜜的陷阱,因為它給我們偷懶一個很好的理由,讓我們可以安然地停在五十步,反而去嘲笑已經跑了一百步的人。
如果有兩位工程師,前者因為過度設計而犯錯; 後者則是不進行設計,安於系統現狀,認為 “代碼無錯就是優”[引用5]。
我認為前者更有成長性,因為他至少是有代碼和技術上的追求的,只要有正確的指導,遲早會成為一名優秀的工程師。
最怕的是團隊沒有人指導,任由其自由發展,或者批評阻礙其發展。這正是 CR 以及評審機制的意義。
互聯網精耕細作的新時代
設計模式能夠幫助我們大幅度提升複雜軟體的開發與維護效率,也本文圍繞的主要命題。
但是人們總是能找出反例,“很多公司工程做得很糟糕,業務也十分成功”。
之前看紅學會的直播,對抗軟體複雜度的戰爭,也有人問了曉斌類似的問題,曉斌的回答是 “如果你有一片田,種啥長啥,那麼你不需要耕作,只要撒種子就可以了”。
在互聯網野蠻發展時期,大量的人才和熱錢涌入,軟體快速上線比一切都重要,開發效率的問題,只要招聘更多的人就能解決,哪怕在一個公司開發好幾套功能一樣的系統。
但是隨著互聯網人口紅利的消失,不再有充足的資源去承接業務,我們就不得不做好精耕細作的準備,扎實地累積自己的產品和技術優勢,繼續創造下一個十年的輝煌。
本文的邊界情況
真理是有條件的。
本文並非走極端地認為所有代碼都應該應用模式。至少在以下情況下,是不適合用模式的:
-
一次性腳本,沒有多次閱讀和修改的可能。我自己在寫工具類腳本時也不會去應用模式,但是我相信阿裡巴巴的應用代碼,100% 都是要被反覆閱讀和修改的。
-
真的很簡單的模塊。前文提到過 ”模塊應該是深“,如果這個模塊真的很簡單,它或許抽象不足,我們應該將它和其他模塊整合一下,變得更加豐滿。如果應用中抽不出複雜模塊,那可能不是事實,只是我們的實現方式太簡單了(比如全是過程式編碼),反過來又對外宣稱 ”我們的業務很複雜“。
-
團隊內都是喜歡攀比代碼設計的瘋子,需要告誡警醒一下。真的有團隊達到這個程度了嗎?如果到了這個程度,才可以 “反對設計”。
參考:
[1]《人月神話》
[2]《軟體設計哲學》
[3]《Java 8 實戰》
[4]《設計模式 - 可復用的面向對象軟體元素》
[5]《大話設計模式》
[6] 代碼重構:面向單元測試
[7] 重新認識訪問者模式
[8] 對抗軟體複雜度的戰爭
作 者 | 杜沁園(懸衡)
本文來自博客園,作者:古道輕風,轉載請註明原文鏈接:https://www.cnblogs.com/88223100/p/Talk-about-over-design-the-trap-of-giving-up-eating-for-choking.html