換種思路去理解設計模式(下)

8       對象行爲與操作對象

8.1     過程描述

所謂對象行爲和操作對象,需要三方面內容:

l  操作過程:

一般表現爲一個方法。該方法接收一個對象或者組合類型的參數,然後對這個對象或者組合進行操作,例如修改屬性、狀態或者結構等。

l  操作的對象或組合:

會作爲實參傳入操作過程,會被操作過程修改屬性、狀態或者結構等。

l  受影響的對象或組合:

由於修改其他對象或者組合,可能會影響到另外的對象或者組合,因此需要考慮這種影響關係,一般會用通知或者消息訂閱的方式實現。

 

從上文的對象創建和對象組合兩個模塊,應該理解出在系統設計中會遇到各種複雜的情況。而對象操作比前兩者更加複雜,因爲前兩者是創建一個靜態的結構,而對象操作則是一個動態的變化。在日常的開發工作中,對象操作也是我們付出精力最多的地方。

下面我們就對象操作過程中遇到的一些常見情況做詳細的分析。

8.2     情況1:“多配置”操作的優化

  當我們的一個方法因爲需要實現多配置而不得不寫許多條件判斷語句時,我們會考慮將這個方法抽象出來,然後派生不同的子類。這是基本的設計思路。

  現在我們將這個情況複雜化,業務場景多了,一個方法無法實現這些功能,就需要拆分。

  

  如果這種情況下,再出現因爲很多配置而不得不寫許多條件判斷語句時,我們肯定還需要再次考慮抽象和派生。效果如下圖:

  

   這就是——模板方法模式

  理解這個模式其實很簡單,只要知道根據多配置需要抽象、拆分即可。至於這裏的“模板”,可根據實際情況來使用或者改變。

 

8.3     情況2:串行操作的優化

  針對對象進行操作時,類似於流程一樣的串行操作,在系統中應用非常廣泛。而且各個串行的節點都有相對統一的操作過程,例如工作流的每個審批節點,都會修改對象狀態以及設置下級審批人等。

  遇到這種場景,我們最初會思考以下思路:

  

  後來隨着系統的升級和變更,代碼越來越多,維護越來越困難。我們會先考慮將每一步操作都獨立成一個方法:

  

   一般的串行操作,可以用以上代碼結構來處理,需要修改處可以再根據實際情況再重構。但如果串行操作中有條件因素,可能就有優化的空間了。如下代碼:

  

  當隨着我們的條件越來越多,業務關係越來越負責時,維護這段代碼就越來越複雜,也可能因爲多人維護而帶來版本問題。需要優化。

  分析任何問題,都先要從業務上抽象。以上代碼抽象出來有兩點:“操作”和“條件”。“操作”很容易抽象的,但是“條件”卻不好抽象。沒關係,職責鏈模式給了我們靈感,我們可以把“條件”看作“下一步的操作”。

  好了,我們把“操作”和“下一步的操作”抽象出來。然後將每一步的操作處理作爲抽象的一個派生類。

  

  如上圖,每個子類是一步處理。每一步處理都實現了抽象類的RequestHandler()方法,都繼承了抽象類的success屬性(即下一步執行者)。這樣我們就可以設計ConcreteHandler1的success爲ConcreteHandler2,ConcreteHandler2的success爲ConcreteHandler3,從而構成了一個“職責鏈”。

  這就是職責鏈模式的設計思想。

 

8.4     情況3:遍歷對象各元素

  當對一個對象操作時,遍歷對象元素是最常見的操作之一,使用Java和C#的人用的最多的就是for和foreach(先放下foreach不說,後文會有解釋),C和C++一般用For循環。For循環簡單易用,但是有它設計上的缺點,先看一段for循環代碼:

  

  代碼中obj是個特別刺眼的變量,如果代碼較多了,就會出現滿篇的obj。這裏的代碼是客戶端,過多的obj就會導致了大量的耦合。如果obj的類型一點有修改,就會可能導致整個代碼都要修改。

  設計是爲了抽象和分離。我們需要一種設計來封裝這裏的obj以及對obj的遍歷過程。我們定義一個類型,它接收obj,負責遍歷obj,並把遍歷的接口開放給客戶端。

  

  代碼中,我們通過Next()和IsDone()就可以完成一個遍歷。可以給客戶端提供First()、Current()、Last()等快捷接口。

  這樣,我們可以用這種方式迭代對象。

  

  代碼中只用到一個obj,因爲我們如果再有迭代過程,可以用iterator對象,而不是obj。這就是迭代器模式的設計思路。

  

  前文中提到了foreach循環。其實foreach是C#和java已經封裝好的一個迭代器,它的實現原理就是上文中講到的方法。在日常應用中,foreach在大部分情況下能滿足我們的需求。但是要真正理解foreach的用以,還需這個迭代器模式的幫助。

 

8.5     情況4:對象狀態變化

  改變一個對象的狀態,是再常見不過的操作了。例如一個對象的狀態變化是:

  

  這幾乎是最簡單的流程了,我們一般的開發思路如下:

  

  這是最基本的思路,如果再改進,可能會把狀態統一維護成一個枚舉,而不是硬編碼在代碼中直接寫“編輯”“結束”等。

  

  但是這樣改進,代碼的邏輯和結構是不變的。仍然存在一個問題——當狀態變化的邏輯變複雜時,這段代碼將變得非常複雜——大家應該都明白,在真正的系統中,狀態變化可比上面那個圖片複雜N倍。

  大家可能都注意到了,一旦遇到這種問題,那肯定是違反了開放封閉原則和單一職責原則。要改變這個問題,我們就得重構這段代碼,從設計上徹底避免。

  首先要做的還是抽象,然後再去隔離和解耦。當前這個業務場景中,能抽象出來的是“狀態”和“狀態變化”。

  那麼我們就把狀態作爲一個抽象類,把狀態變化這個都做作爲抽象類中的抽象方法,然後針對每個狀態,都實現成一個子類。結構如下:

  

  然後再把對象關聯到狀態上,根據依賴倒置原則,對象將依賴於抽象類而非子類。

  

  上圖中,Context的狀態是一個State類型,所以無論State派生出多少個子類,都能成爲Context的狀態。至於Context的狀態變化,就在State子類的Handle方法中實現。例如ConcreateStateA的handle方法,可以將Context的狀態賦值成ConcreteStateB對象,ConcreteStateB的handle方法,可以將Context的狀態賦值成ConcreteStateC(圖中沒有)對象……一次類推。這樣就將一個複雜的狀態變化鏈,分解到每一步狀態對象中。

  這種設計思路就是——狀態模式

8.6     情況5:記錄變化,撤銷操作

  上文提到如何改變對象的狀態,從這裏我們可以想到狀態的撤銷,以及其他各個屬性修改之後的撤銷操作。撤銷操作的主要問題就在於如何去保存、恢復舊數據。

  最簡單的思路是直接定義一個額外的對象來存儲舊數據,

  

  如果需要撤銷,再從存儲舊數據的對象中獲取信息,重新賦值給主對象。

  

  由此可能發現,上圖中客戶端的代碼非常繁瑣,而且客戶端幾乎查看到了主對象類型和封裝對象類型的所有信息,沒有了所謂的“封裝”。這樣帶來的後果是,如果主對象屬性有變化,客戶端立刻就不可用,必須修改。

  其實客戶端應該只關注“備忘”和“撤銷”這兩件事情、這兩個操作,不必去關心主對象中有什麼屬性,以及備忘對象有什麼屬性。再思考,“備忘”和“撤銷”這兩個動作都是針對主對象進行的,主對象是這兩個動作的執行者和受益者。那麼爲何不把這兩個動作直接交給主對象進行呢?

  根據以上思路重構代碼可得:

  

  在原來代碼的基礎上,我們又給主對象增加了兩個方法——創建備忘和撤銷。接下來客戶端的代碼就簡單了。

  

  正如我們上面所說的,客戶端關注的只是這兩個動作,而不關心具體的屬性和內容。

  這就是——備忘錄模式。看起來很簡單,也很好理解。它沒有用到面向對象的太多特點,也沒有很複雜的代碼,僅僅體現了一個設計原則——單一職責原則。利用簡單的代碼對代碼職責就行分離,從而解耦。

8.7    情況6:對象之間的通訊 – 一對多

  一個對象的屬性反生變化,或者一個對象的某方法被調用時,肯定會帶來一些影響。所謂的“影響”具體到系統中來看,無非就是導致其他對象的屬性發生變化或者事件被觸發。

  近來在生活中遇到這樣的兩個場景。第一,白天用手機上的某客戶端軟件看NBA文字直播,發現只要某個球員有進球或者籃板之類的數據,系統中所有的地方都會更新這個數據,包括兩隊的總分比分。第二,看jquery源碼解讀時,jquery的callbacks的應用也是這種情況,事例代碼之後貼出。這兩種情況都是一對多通訊的情況。

  

  (看以上代碼的形式,很像C#中的委託)

  

   先不管上面的代碼或者例子。先想想這種一對多的通訊,該如何去設計,然後慢慢重構升級。最簡單的當然是在客戶端直接寫出來,淺顯易懂。這樣寫代碼的人肯定大有人在(因爲我之前曾是這樣寫的):

        

  如果系統中這段代碼只用一次,這樣寫是沒有問題的。但是如果系統有多地方都是context.Update(),那將導致一旦有修改,每個地方都得修改。耦合太多,不符合開放封閉原則。

  解決這個問題很簡單,我們把受影響對象的更新,全部放到主對象的更新中。

  

  再想想還會遇到一個問題:難道我們每次調用Context的Update時,受影響的對象都是固定的嗎?有工作經驗的人肯定回答否。所以,我們這樣盲目的把受影響對象的更新全部塞到Context的Update是有問題的。

  其實我們應該抽象出來的是“更新”這個動作,而應該把具體的哪些對象受影響交給客戶端。如下:

  

  上圖中,我們把受影響對象的類型抽象出一個Subject類,它有一個Update()方法。在主對象類型中,將保存一個Subject類型的列表,將存儲受影響的對象,更新時,循環這個列表,調用Update方法。可見,Context依賴的是一個抽象類——依賴倒置原則。

  這樣,我們客戶端的代碼就成了這樣:

  

  這次終於實現了我們的預想。可以對比一下我一開始貼出來的js代碼。效果差不多。

  

  這就是大家耳熟但並不一定能詳的——觀察者模式。最常見的例子除了jquery的callbacks之外,還有.net中的委託和事件。此處不再深入介紹,有機會再把委託和事件如何應用觀察者模式的思路介紹一下。

8.8     情況7:對象之間的通訊 – 多對多

  上文中提到一對多的通訊情況,比一對多更復雜的是多對多的通訊。如果用最原始的模式,那將出現這種情況,並且這種情況會隨着對象的增加而變得更加複雜。N個對象就會有N*(N-1)個通訊方式。

  

  所以,當你確定系統中遇到這種問題,並且越發不可收拾的時候,就需要重構代碼優化設計。思路還是一樣的——抽象、隔離、解耦。當前場景的複雜之處是過多的“通訊”鏈條。我們需要把所有的“通訊”鏈條都抽象出來,並隔離通訊雙方直接聯繫。所以,我們希望的結構是這樣的。

  

  其實這就是中介者模式

  以上只是一個思路,看它是怎麼實現的。

 

  

  首先,把所有需要通訊的類型(成爲“同事”類型),抽象出一個基類,基類中包含一箇中介者Mediator對象,這樣所有的子類中都會有一個Mediator對象。子類有Send()方法,用於發送請求;Notify()方法用於接收請求。

  

  其次,中介者Mediator類型,基類中定義Send()抽象方法,子類中要重寫。子類中定義了所有需要通訊的對象,然後重寫Send()方法時,根據不同情況,調用不同的同事類型的Notify()方法。如下:

  

  這樣,在同事類型中,每個同事類的Send()方法,就可以直接調用中介者Mediator的send()方法。如下:

  

  最後,總體的設計結構如下:

  

  越看似簡單的東西,就越難用。因爲簡單的東西具有通用性,而通用就必須適應各種環境,環境不同,應用不同。中介者模式就是這樣一種情況。如果不信,可以具體思考一下,你的系統中哪個場景可以用中介者模式,並且不影響其他功能和設計。

  在具體應用中,還是把重點放在這個設計的思路上,不必太拘泥與它的代碼和類圖,這只是一個demo而已。

8.9     情況8:如何調用一個操作?

  對於這個問題,我想大部分人都會一笑而過:如何調用?調用就是了。一般情況下是觸發一個單獨的方法或者一個對象的某個方法。但是你應該知道,我既然在這個地方提出這個問題,就肯定不是這樣簡單的答案。

   難點在於如何分析“操作”這個業務過程。其實“操作”分爲以下三部分:

  • 調用者
  • 操作
  • 執行者

  首先,調用者不一定都是客戶端,可能是一個對象或者集合。例如我們常見的電視遙控器,就是一個典型的調用者對象。

  其次,操作和執行者不一樣。操作時,除了真正執行之外,還可能有其他的動作,如緩存、判斷等。

  最後,這樣的劃分是爲了職責單一和充分解耦。當你的需求可以用簡單的函數調用解決時,那當然最好。但是如果後期隨着系統的升級和變更而變得業務複雜時,就應該考慮用這種設計模式——命令模式

  

  上圖是命令模式的類圖。左側是的Command和ConcreteCommand是操作(命令)的抽象和實現,這個不重要,我們可以把這兩個統一看成一個“操作”整體。Invoker是調用者,Receiver是真正的執行者。

  調用過程是:Invoker.Excute() -> Command.Excute() -> Receiver.Action()。這樣我們還可以在Command中實現一些緩存、判斷之類的業務操作。可以按照自己具體的需求編寫代碼。

  具體的代碼這裏就不寫了,把這個業務過程理解了,寫代碼也很簡單。重點還是在於理解“操作”(命令)的業務過程,以及在複雜過程下對調用者、操作、執行者之間的解耦。

8.10     情況9:一種策略,多種算法

  

  假如上圖是我們系統中一個功能的類圖,定義一個接口,用兩個不同的類去實現。客戶端的代碼調用爲:

  

  有出現了比較討厭的條件判斷,任何條件判斷的複雜化都將導致職責的混亂和代碼的臃腫。如果想要解決這種問題,我們需要把這些邏輯判斷分離出來。先定義一個類來封裝客戶端和實現類的直接聯繫。

  

  如此一來,客戶端的調用代碼爲:

  

  這就是——策略模式。類圖如下:

  

  

  附:關於這個策略模式,思考了很久也沒有想出一個何時的表達方法,我沒有真正理解它的用處,感覺它說的很對,但是我覺得它沒多少用處。所以,針對這個模式的表述,大家先以參考爲主。如果我有了更好的理解方式,再修改。

8.11     情況10:簡化一個對象組合的操作

  針對一個對象組合,例如一個遞歸的樹形結構,往往對每個節點都會有相同的操作。代碼如下:

  

   如果對象結構較複雜,而且新增功能較多,代碼將會變得非常臃腫。

  解決這個問題時,不好直接去抽象。一來是因爲現在已經在一個抽象的結構中,二來也因爲每個節點新增的功能,不一定都相同。所以,現在我們最好的方式是將“新增功能”這個未來不確定的事情,交給另外對象去做。先去隔離。

  另外定義一個Visitor類,由它來接收一個Element對象,然後執行各種操作。

  

  此時在Element類中,就不需要每次新增功能時,都重寫代碼。只需要在Element類中加入一個方法,該方法將調用Visitor的方法來實現具體的功能。

  

  這就是——訪問者模式,它使你可以在不改變各元素的類的前提下定義作用於這些元素的新操作。

8.12     總結

  注:Interpreter解釋器模式不常用,暫不說明。

  本節介紹了對象行爲和對象操作過程中的一些常用業務過程以及其中遇到的問題,同時針對每種情況引入了相應的設計模式。

  這塊兒的過程較複雜,梳理起來也比較麻煩,當前的描述和一個系統的流程相比,我想還是有不少差距的。但是相信大家在看到每種情況的時候,或多或少的肯定有過類似的開發經歷,每種情況都是和我們日常的開發工作息息相關的。如果這些介紹起不到一個教程或者引導的作用,那就權當是一次拋磚引玉,或者一次學習交流。

  最終還是希望大家能從系統的設計、重構、解決問題的角度去引出設計模式,然後才能真正理解設計模式。

 

9      總結

  從5.12開始寫,到今天6.4,磕磕絆絆的總算寫完了初稿。雖然不到一個月,但是堅持下來也很不容易。而且這只是一個開始,我想再在這個基礎上繼續寫第二版、第三版,當然還需要再去看更多的書、博客以及結合實際的開發經驗和例證。

  且先不說應用,即便是真正理解設計模式,也不是易事,沒有開發經驗、沒有一個有效的方法,學起來也是事倍功半。甚至會像我之前一樣,學一次忘一次。我覺得我現在提出的思路有一定效果,至少對於我是有效的。大家如果有其他建議或者思路,歡迎和我交流 wangfupeng1988$163.com($->@)

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