深入抽象和動態建模

肖鵬老師,ZenUML.com 作者,獨立諮詢師,服務於澳洲領先的銀行、零售企業,前 ThoughtWorks 中國持續交付 Practice Lead,《面向模式的軟件架構》卷 4、5 譯者。

以下是肖鵬老師的 “帶你深入理解抽象,及抽象在軟件設計中的運用” 視頻分享整理稿。

什麼是動態建模

靜態模型和動態建模的區別

我們來講動態建模,與之對應的是靜態建模,大家可以通過對比兩者在幾個概念上差異進行理解。

靜態模型關注的概念是靜態的:類 (Class),屬性 (Attribute),方法 (Method),類關係 (Class relationship),類職責 (Responsibility),是用類的語言來描述一個靜態的類。例如用鳥類理解,靜態模型就是關注的是鳥 (類),含有哪些屬性 (眼,嘴巴,翅膀),包含哪些方法 (飛,睡覺),包含哪些類關係 (繼承了動物類),擁有哪些職責 (睡眠,鳴叫,飛行)。

而動態模型關注的概念是動態的:對象 (Object),狀態 (State),交互 (Interactions),對象關係 (Object relationship),業務邏輯 (Business Logic),是用對象的語言來描述一個動態的對象。例如用麻雀對象理解,動態模型關注的是麻雀 (對象),含有哪些狀態 (眼睛是否閉合),含有哪些對象間的交互 (麻雀和啄木鳥的行爲是否用差異化的形式實現),含有哪些對象關係 (這個麻雀是另一個麻雀的媽媽),包含哪些業務邏輯 (麻雀睡覺的時候是否需要閉上眼睛)。

爲什麼需要動態建模

在面向對象這個領域裏邊,靜態建模的書籍和文章都是汗牛充棟。《設計模式》就是最經典的書籍之一,我個人認爲策略模式是其中最經典的一個模式。它的概念可以這樣理解,有一個策略,對應一個抽象類或者一個接口,然後這個策略有幾個具體的子類,當我們在上下文裏邊注入這個策略之後,便可以在運行時選擇具體的類執行。

策略模式看似很好,因爲它符合開閉原則,解耦合了具體的類和實現。但如果你沒有足夠經驗的話,其實想把它和具體工作結合起來,會發現是一件很困難的事情,因爲當你真的去實現這個模式的時候,還會有很多問題需要回答:

第一個,策略是怎麼構建的?它是在運行時 new 出來,還是在程序啓動的時候創建出來,還是在某個類需要的時候創建出來。

第二個,策略是怎麼注入的?是在構造函數里面注入進來,還是用 setter 注入,還是用容器來注入。

第三個,策略是怎麼執行的?或者說怎麼樣選中一個策略,是用 if else,還是說根據模式匹配等等。

這些話題,其實才是當我們真正落實到寫代碼的時候,必須思考和解決的問題,也是爲什麼我們需要研究動態面向對象建模的原因。

動態建模爲什麼沒有流行

我猜測大家在工作中或者在書裏,看到關於動態面向對象建模的概念比較少。我覺得可能有兩個方面的原因。

第一點,簡單的例子無法體現工具或者方法論的價值,比如說設計一個 hello world,你沒有辦法說這個要用什麼樣的策略,什麼設計模式去實現它,以及如何設計它的生命週期等等,這些東西都沒有實際應用的價值。

第二點,複雜的例子無法在短時間內交代上下文,或者說需要一定的先驗知識。如果用複雜的例子來講述的話,可能介紹上下文背景的時間就大於講工具或者方法論的時間了,這一點可能限制了他的傳播。

不過即便書籍或者文章比較少,但還是有人研究的,如果你用 dynamic object oriented programming 去搜的話,還是有一些論文會講這個。

動態建模的相關研究

我個人認爲,有一些研究跟動態建模這個領域是相關的,比如測試驅動設計 / 開發,重構和 specification by example 等。

第一個,測試驅動設計 / 開發。它其實就是從一個大程序裏取出來一部分,使我們要測試的類和方法在一個運行時的環境裏來驗證,實際是從所有代碼中,隔離出來一個小的上下文,進行運行時代碼邏輯的驗證。

第二個,重構。它和動態和靜態都有關係,它既關心靜態的部分,比如說怎麼樣抽出類來,怎麼樣抽出接口,也關心動態的部分。比如說方法調用,if-else 怎麼樣把它變成多態,等等。

第三個,specification by example。我記得我在國內的時候,還在一些公司做過相關的培訓,不知道現在有沒有人還在用這個東西,我個人是非常喜歡這個方法的,但在國外我沒有見過用這個的公司。

我們今天會用一個非常簡化版的 specification by example ,來做一個例子,從而探討動態面向對象建模的一個過程。

動態建模案例

需求說明

給武夷山的一個茶葉鋪設計一個系統,該系統能計算顧客購買茶葉的總價格(圖表對應相關費用的規則)

對比靜態建模

靜態建模也有一些方法論的指導,這些是我在上學時的老師講的一些方法,比如字典法,就是你先看一段需求用例的描述,把這個描述裏邊的名詞提出來,作爲類的類名。把描述裏面的動詞提出來,作爲類的方法。把描述裏面的賓語提出來,作爲方法的參數。這個茶鋪系統案例的主謂賓挺全的。

大家也可以自己嘗試一下。如果採用靜態建模的方法,想象一下你會創建幾個類?每個類有什麼方法?屬性?以下是我嘗試用靜態建模思路設計的幾個類:訂單類 (Order),地址類 (Address),顧客類 (Customer),價格類 (Price),運費類 (ShippingFee),總價計算類 (PriceCalculator)。

但是這樣的方法設計出來的結果,往往會存在一些問題,例如靜態建模設計出來的代碼可能不符合 SOLID 原則,可能沒有辦法回答如何構建如何注入如何執行的問題。

接下來,讓我們嘗試用動態建模的方式來設計這個茶鋪系統,大家可以在這個設計過程中,對比下其中的差異。

做動態設計的一個好處是什麼呢?尤其是當你有工具去輔助設計的話,你可以非常靈活的去調整你的設計,這個我覺得是非常重要的,它有點類似於敏捷和瀑布的一個區別,你不需要一開始就設計一個非常好的,完美的設計,而是不斷迭代和重構,在這個過程中,設計變得更好。

第一步:設計最外層接口

在計算購買茶葉總價格這個例子中,我們假設最關鍵的是設計一個總價計算類 (TotalPriceCal)。這個類提供一個方法,可以計算指定訂單的總價。外部系統調用這個方法,就會返回一個價格,它就是我們最外層的一個接口。

第二步:嘗試實現

建議大家跟着我一起寫一下這個代碼,在寫這個代碼的過程中,你可能會理解爲什麼我們把它稱爲動態建模。

根據例子中的條件,我們在這個 TotalPriceCal.cal() 方法裏,需要傳入一個地址參數 (adress),一個最初的價格 (price)。根據圖表中的規則,我們用 Zen 工具來編寫僞代碼來實現對應的邏輯:

如果 address 是江浙滬 (JZH),並且 price 大於等於 100,則運費爲 0,總價不變。

如果 address 是江浙滬 (JZH),並且 price 小於 100,我們就要計算一個運費,運費是價格(price) 乘以 (multiply) 運費標準 0.3。這裏我們引入了一個類叫做運費類 (shippingFee),然後用總價(totalPrice) 加上這個值。

如果 address 的國家是中國 (CN),我們就要統一按照 3% 的來算這個額外的價格,因爲我們這兒用了 else 了,所以不用考慮這個江浙滬了,

如果 address 的國家是澳大利亞 (AU),就要引入一個稅費 (gst),總的價格加上稅費,然後總的價格再加上運費。

其他國際地區的邏輯,以此類推。最終,我們設計出了三個類,這是我們第一遍的設計。

第三步:重構實現

看左側的僞代碼或者右側的時序圖,大家應該都能看出一些壞味道來:有很多重複的 if-else,這個時候我們就要利用一些重構的知識來優化了,我們需要把代碼邏輯中重複的部分提煉出來,並進行重構。

如何將不同的邏輯變得通用呢?我們可以認爲,對於每一個 if 分支,它們都有一個計算稅費 (gstFee) 和運費 (shippingFee) 的邏輯,只不過有的地方稅費 (gstFee) 是 0,有的是根據地址來計算,同樣的,有的地方運費 (shippingFee) 是 0,有的地方運費需要根據地址和訂單來計算。

據此,我們可以引入一個稅費計算器 (gstFeeCal),和運費計算器 (shippingFeeCal),我們可以對每一個 if-else 的部分,使用這兩個計算器補全邏輯:沒有計算稅費的地方,加入稅費計算的邏輯,gst=GstCal.get(add, price),同理,沒有計算運費的部分,加入運費計算的邏輯,ShippingFeeCal.get(add,price)。

這麼做的目的是什麼呢?我們可以看到,重構後的代碼,對每一個 if 分支,處理邏輯是完全一樣的,然後,我們可以把這些重複的代碼去掉了(此處應有掌聲)。

可以看到,我們通過動態面向對象建模的方式,用重構的思想,把重複的邏輯去掉之後,我們的設計立刻變得非常的清晰了。

我們引入兩個計算器 (calculator),一個稅費計算器 (gstCalculator),一個運費計算器 (shippingCalculator),分別用來計算稅費和運費,並且把它加到總價格 (totalPrice) 上去。那你可能說了,這個只不過是把邏輯給藏到另一邊去了,這樣的設計真的是好的嗎?

這是個非常好的問題,接下來讓我們實現具體的計算運費邏輯,即 ShippingCalculator.get(add, price)這段代碼,入參是地址 (add) 和價格 (price) 參數,實現方法可以參考第一遍的設計過程。

讓我們分析 ShippingCalculator.get(add, price)的實現,如果你要擴展運費的邏輯的話,比如說加上京津冀 (JJH),我們只需要改一個地方,新增一個 if(address == JJH && price ...) { rate.set(xxx); } 就可以了。符合開閉原則。

OK,這一步做完了之後,我們再來回看一下這個僞代碼的設計(當然也可以看時序圖)。大家有沒有覺得,這個圖其實還是有重複的地方,也就是紅色圈中的這兩處的邏輯。他們都是獲取 get 一個費用,然後再把它加到這個總價格 (totalPrice) 上。

這意味着我們可以抽象出一個叫做價格計算器 (priceCalculator) 的東西,這樣,我們其實就把 gstCalculator 和 shippingFeeCalculator 這兩種情況都給概括了,這也就意味着我們又消除了一處重複代碼(此處也應該有掌聲)。

這段代碼最終的實現到底代表什麼呢?看上去就是策略模式。在我們這個案例中,同時使用了兩種策略來計算價格。

以上討論的就是動態建模的部分了,就是應該怎麼樣去構建它,怎麼樣去注入它,以及怎麼樣去執行它。

第一個,對象的構建。本例,我們是直接 new 實例的方式構建,沒有采用 IOC 容器。

第二個,對象的注入。本例,我們通過創建 gstCal,shippingFeeCal,covidTxCal 等實例,並把它們加入到總價計算器 (TotalPriceCal) 的構造方法,來實現注入。

第三個,對象的執行。本例,我們採用 for-each 的形式,順序執行幾個計算器。

最後,我們可以用 SOLID 設計原則來驗證我們的設計。

首先是單一職責 (single responsibility)。稅費 (gst) 就在稅費計算器 (gstCalculator) 裏面做了,稅費的調整不會影響運費計算器 (shippingFeeCalculator) 的邏輯,是滿足單一職責的。

然後第二個開閉原則,如果我們要加入新的計算器,例如因爲疫情我們要加了一個專門的疫情費用計算器 (covidTaxCal)。只要它也實現計算器這個接口,然後加入到計算器集合 (cals) 即可。這樣基本上你可以認爲它是符合開閉原則的。對於這個擴展是開放的,對於修改是閉合的(這個方法你都不需要改)。

其他的設計原則我們就不一一的去講了,大家可以去用這些面向對象設計的原則去再去驗證一遍。

第四步:對象生命週期

此時,我們就可以通過動態建模後的時序圖,清晰的知道對象的生命週期:對象是何時構建和注入以及執行的。

下面這個圖的實現,其實和前三步中代碼的實現還不完全一樣,這個就是一個很有意思的點。前三步的實現裏其實是把各種計算器通過構造器注入的,而這個圖是在方法裏面去創建實例來實現注入的。

在現有的上下文裏,其實很難說哪個好哪個壞,說到底,我們只不過是用這種動態建模的方式,用更低的成本,將對象的邏輯展示出來。如果讓大家去根據這些圖做溝通的話,就比較容易方便,並且不用等到把它的所有邏輯全部實現成代碼後,才能做溝通。

相關文章和工具

b 站視頻搜索:帶你深入理解抽象,及抽象在軟件設計中的運用

相關研究:https://www.tutorialspoint.com/object_oriented_analysis_design/ooad_dynamic_modeling.htm

ZenUml 源碼:https://github.com/ZenUml

提問環節

  1. 動態體現在哪裏?

提問者:

我最大的問題是不理解如何去 “動態” 的,我稍微收窄下提問,鋪墊下場景,我覺得今天分享的案例,最終的效果就是通過一個 parent calculator,然後往裏面傳了 N 個 child calculator。我認爲它唯一可能存在動態的場景,就是去提供這個 child calculator,請問是這樣理解嗎?

回答者:

回到我們剛分享的爲什麼需要動態建模那一個部分,我覺得至少有三個部分能體現這個動態:如何構建,如何注入,如何執行。

也就是說爲什麼我覺得動態建模重要,我是覺得靜態建模裏邊真的沒有體現這些概念。關於動態建模,我是針對靜態建模講的,我們這個行業裏面很多東西,其實你都可以從不同的角度去看,而且他也有相通的地方,例如其他的關於建模的方法論,它也會牽扯到動態建模,因此方法論中也會存在類似的概念。

提問者:

這個動態就意味着修改結構上的一些事情,從而實現不用改動細節,然後去改變它的行爲嗎?

回答者:

我們看這張圖來理解,各種講面向對象的書,裏邊基本上都是這些詞:類,方法,屬性,類和類的關係,類的職責。我們很少看到動態模型的這些概念:對象,狀態,交互,對象關係,業務邏輯。

我們先找到我們共同的地方,比如說你打開設計模式這本書。他會跟你講。我要有一個策略類,它有幾個子類。然後這個策略類是作爲一個字段,被這個上下文這個類使用,策略模式是講這麼一個東西,你同意嗎?

提問者:

同意。

回答者:

其實對於動態建模,我也是有出發點的,你看我翻譯的這本面向模式的軟件架構這本書,它的第五卷的名字叫模式語言 (pattern language)。他要解決一個什麼問題呢?作者說,我們已經有這麼多設計模式了。這些設計模式我們把它稱爲詞語。我們要寫的程序是一篇文章,這篇文章的最基本的單位,如果要表達一個意思的話,它至少要有一句話。

我們自然語言裏面的一句話有主語、謂語、賓語。這 23 個設計模式裏邊,哪些是處於主語的位置,哪些是處於謂語的位置,哪些是處於賓語的位置,並且通過怎麼樣的組合能講一個故事呢?

我舉一個最簡單的面向對象模式語言的例子,我首先要把一個對象創建起來,這個對象,我們可以用最簡單的創建型的設計模式——單例模式來創建出來,接着我要用策略模式來裝配他的行爲,然後用選擇器來匹配一個策略(選擇器這個模式不在什麼不在 23 個設計模式裏面,但是它也算一種設計模式),然後進行執行,這樣就構成了一句話。

但是這句話在對象設計模式第五卷(模式語言)出現之前,這個領域裏面其實沒有把模式語言這個概念抽象出來,動態建模其實跟模式語言是很接近的,面向模式的軟件架構第五卷,這本書是我思考這個問題,進入一個比較系統化的時期,我在讀和翻譯這本書的時候,我就意識到,面向對象分析裏邊缺少的就是這部分。

我不知道,是否回答了你的問題。

提問者:

那比如說一句話:我吃了一個蘋果,請問該如何用動態建模來理解。

回答者:

蘋果就像策略模式。你告訴我說我有個蘋果,這個蘋果你給我描述的再漂亮,我也不會喫它。我也不知道我是要喫它,還是我要把它籽兒拿出來種一下,還是把它切成片兒看一下里邊的細胞結構。你需要把它放到一個上下文裏面去,說清楚你要把這個蘋果怎麼樣用起來,是把它喫掉還是來種樹?這個時候你就要考慮動態模型。如果你不考慮動態模型的話,就無法解答靜態模型遺漏的那三個問題。

我十幾年前看設計模式這本書的時候,我也是看的如癡如醉。但是回到項目上,我就是不會用,比如說大家耳熟能詳的 MVC,C 是在哪個地方創建的?是 C 創建 M,還是 C 創建 V,M 和 V 是什麼關係?通過這個例子你會發現,MVC 的這麼多的書,都沒有明確的跟你講,誰創建誰這件事情。

這個時候,你就需要一個模式語言去來講,當然一個故事有 N 多種講法,你可能 C 創建 M,也可能 M 創建 C,但是這種創建的過程和選擇,就體現了動態這兩個字。

提問者:

如果不說蘋果之類的太抽象的東西。就是說這個創建這一塊,如果說我用這種類似於 spring framework(一個完整的 IOC container )的框架來創建東西,那創建的這個步驟是不是就已經解決了。

回答者:

這是個很好的問題,這就是你認識到了原來創建這件事情還是發生了,只不過是給你隱藏起來了。作爲一個菜鳥或者說新手這樣理解這個問題是可以的,因爲創建這件事情太難了,正確的選擇一個正確的時間去創建一個對象太難了,工具或者框架的出現,就是爲了解決這個問題的,框架直接把對象創建好了,程序員直接從裏面取出來用就行了。

但是,作爲一個有經驗的程序員,你必須要理解他是怎麼創建的,因爲被隱藏雖然有好處,但是你也要知道被隱藏也是有風險的,如果你工作過五年或者十年以上的話,你一定會遇到過一個問題:“我丟,爲什麼把這個東西隱藏了?我需要辦法把它修改了!”。所以,並不是說創建對象的問題不存在了,它是仍然存在的,而且它是非常重要的一件事情,我認爲是需要我們來理解這個過程的。

再進一步講,就是注入的問題,那我是不是全部的對象都需要自動注入,以 spring 爲例,是不是全部對象都可以用這種方式創建?

如果你真的把所有對象都用這種方式注入,很有可能你的代碼就會變成一坨 shit。當一個類裏面有幾十個依賴的情況,其實你就要想了,我真的需要這些東西嗎?我真的需要用這種方法去注入它嗎?還是說我可以有更好的方法去管理這個注入呢?

如果你去看重構那本書的話,它的作者非常傾向於不要用這樣的方法,而是用 constructor 去管理注入。尤其是對於小的業務的類,不要去用那種 service 的方式去去注入。

說完注入,那接下來就是執行,你怎麼樣去執行呢?你去看一下我們剛纔的案例,執行到底是並行的,還是用一個數組做 for each 的去執行呢?這些都是執行的細節。你會發現在靜態建模裏邊,是不會有人講這個事情的,只有重構這樣的書纔會講。

最後,你如果覺得動態沒有辦法理解的話,可以把它替換成【運行時】。

提問者:

那就對了,我想起來自己使用 go 時,需要考慮運行時注入對象的一系列問題了,我知道你說的是什麼了。

  1. 案例建模是通過一系列重構講原有設計(各種 if-else)變爲的設計模式嗎?

提問者:

老師,我進來的比較晚,我進來的時候,你已經在講那個就是那個案例了,雖然你倆聊了很多,但是我還是沒太聽懂動態建模這個概念。

你講案例寫的這個過程,給我的感覺就是一個重構的過程。把原有的代碼設計,重構成了一個更抽象的一個設計,抽象之後,發現這個更像是一個設計模式(而不是先知道有一個設計模式然後再往上套),這種就是您指的動態嗎?

回答者:

這個還不是。這個是個結果,我個人認爲建模一定是個過程。而且我比較推崇的一個詞叫驅動,就是 driven,就是你看到很多 DD(BDD,DDD,FDD)之類的。

如果你告訴我一個結果的話,這個通常是大師型的人物才能做的。就是看一眼就知道這個應該是什麼樣子的,但是對於凡人來說,我們要有一個驅動,相當於說是你告訴我 1 和 1,然後我能算出 2 來就不錯了。

驅動是什麼?這個驅動的過程就像你說的,就是在我們這個裏面,其實就是一個重構,發現重複進行重構。

動態並不是說我建模的這個過程是動態的,而是說我關注的點是什麼?你看我在這個過程中關注的點都不是類,屬性,方法,類和類的關係,類的職責等。我關注的都是這個對象。這個對象是什麼狀態的?當然這裏面我們也考慮到了一些對象之間的交互,對象之間的關係,對象的邏輯。比如說我是順序的調用,還是 for each,這個對象是什麼時候創建出來的,什麼時候初始化的,是怎麼裝配的,是怎麼結構的,是怎麼銷燬的。這個是這個動態的來源。

我剛纔講了,就是說如果你覺得動態比較難理解,你可以把它想成運行時。但是爲什麼我們這個名字不叫運行時呢?是爲了跟這個靜態對比,這個是 dynamic 翻譯過後的意思。翻譯的過程中我覺得有一點點損失,回到你的問題上,什麼樣是動態,就是我們關心的是運行時發生的事情和狀態。

如果咬文嚼字一點,不是說建模的過程是動態的,而是說建模的驅動力是動態的,建模的驅動力是來自於這些動態的概念(對象,狀態,交互,對象關係,業務邏輯),而不是來自於這些靜態的概念(類,方法,屬性,類和類的關係,類的職責)。

你如果用這樣的驅動力去建模的時候,慢慢的就會發現一些好處,可能得到一個更好的設計。我是希望我覺得對於我們這樣平凡的程序員來說,需要一些驅動力,來幫助我們做出更好的設計來。

  1. 動態建模除了時序圖這種方式有沒有更好的辦法?

提問者:

動態建模的目的需要表達靜態模型隱藏的一些邏輯,這種交互邏輯,我可以用其他的模型去展現嗎?而不是用這種時序圖的形式。因爲我認爲時序圖的形式,這種方法級別的模型粒度太細了。

回答者:

實際我工作了這麼多年,坦白說,我沒有找到其他更好的方法。沒有一個其他的圖,能把這個描述的平衡做的這麼好,這個平衡是指什麼呢?就是既不描述太多的細節而又不缺失細節,並且你又可以隨時靈活調整(比如說我不關心這一個條件,那我就把它去掉)。

對於模型粒度的問題,其實你可以在非常高的粒度上做這件建模,比如說我有一個圖書館系統。我有一個第三方的系統,叫 Payment,我要檢查用戶有沒有罰款,如果有的話,就不能借給你書,這個 Payment 到底對應着一個接口還是一個類都可以。此外,假設還有一個外部系統 Splunk,我們可以通過 notice 調用,那 Splunk 其實也不對應一個類,而對應的是一個系統,這個系統到底是通過 in process 的調用還是 http 的 rest 調用,都是允許的。

所以,這就是我爲什麼說,它是平衡的最好的一個工具,它相當於橫軸鋪開,縱軸鋪開,是最容易理解的一種展現方式。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/x-hAJfpqoyemhmrdCDTEvw