2010/10/14

[Winform] 觀察者樣式之應用

Design Pattern 通常都是由許多人推薦的軟體架構模式, 而且有其固定的規則可循。我們可以把一些素有口碑的設計樣式拿來套用在某種情境之下, 但是我十分反對為套用 Design Pattern 而套用 Design Pattern。應該是反過來, 我們最好事先評估自己的情境適合應用何種 Design Pattern, 再來決定是否套用某種 Design Pattern, 不然就會流於削足適履

架構的抉擇

我在最近一個以 Winform 撰寫的個人專用程式中, 就剛好遇到了應該採用何種架構的抉擇。情形是這樣的: 我打算在一個以 PictureBox 控制項為基礎的畫布上, 加入各種不同的繪圖效果; 這些效果各有其選項(參數), 而且有些會互相影響。

如果不考慮架構的話, 把這些效果全部寫在 Form 程式裡面應該是最快、最方便的; 套句英文就是「Quick and dirty」。其實對於這個根本是即興的、沒有事先規劃需求的專案, 我一開始的確就是這樣做的。但是隨著程式逐漸開發, 而且想要加入的效果也愈來愈多的時候, Form 裡面的程式已經長達兩千多行, 真的是不運用架構不行了。

這時, 我把整個程式徹底翻修了一遍, 把不同的效果歸類, 寫成一個一個各具功能的類別, 並且把各個功能的參數 UI 寫到 User Control 裡面。接著, 把 Form 裡面原來的 UI 清除, 再以這些 User Control 來取代。

無可避免的重構

我的程式總共歷經三次重大重構, 而這是我的第一次重大重構。在這次重構中, Form 裡面的程式大幅的縮小, 各項功能也變得清爽而且明確。很可惜的, 我仍然發現兩個嚴重的問題:

首先, 各個 User Controls 和 Form 中間仍有很強烈的相依性, 以英文來講的話, 可以稱之為「Tightly Coupled」。第二個問題和第一個問題差不多, 就是好幾個 User Controls 之間也具有很強烈的相依性。換句話說, 我雖然很成功的把各個效果抽象化, 並且把對參數調整的 UI 部份封裝起來, 但是每個元件之間並沒有切分得很乾淨。

在這一次重構中, 程式在表面上看起來是乾淨了, 其實維護是困難的。因為我無法每次只修改一個效果類別或者 User Control, 就把事情做完; 我必須連同好幾個類別或 User Control 一起改。如果有一個沒有改到, 整個程式都錯了。

其實在這一次重構中, 我已經把簡單工廠樣式(Simple Factory)導進來了。不過, 簡單工廠樣式只能幫助我以動態方式對畫布載入不同效果的部份, 對於上述的問題幫助不大。

經過考慮與比較, 我著手進行了第二次重大的重構工作。這次, 雖然工程並不像第一次重構那般浩大, 但是這卻是我第一次在 Winform 應用程式中採用了觀察者樣式 (Observer Pattern)。那麼, 為什麼我要選擇觀察者樣式呢? 主要就是因為這種樣式很適合我現在面臨的情境。

挑選可用的樣式

若要以最簡單的話語來解釋何謂觀察者樣式, 我們可以說「觀察者樣式以一對多的方式定義了某物件與多個相依物件之間的關係」。如果你無法一眼看出這代表什麼意義的話, 我們可以拿部落格為例子。不是所有部落格都是採用觀察者樣式來建立的 (事實上網路上的部落格因限於網頁的被動特性, 它還真的不是採用觀察者樣式的), 但是部落格卻很適合拿來描述觀察者樣式。為什麼呢? 如果你自己有一個部落格, 而且你有很多訂閱者 (Subscriber), 那麼每當你發布文章時, 所有訂閱者都可以同步看到 (而且看到的是相同的內容); 你不需要知道你的訂閱者是誰、有幾位, 你也不需要把你的文章一一寄給訂閱者。

對我的程式而言, 觀察者樣式對我最大的助益在於, 我在各個不同視窗元件之間(主要是 Form 與 User Control 之間, 以及每個 User Control 彼此之間)得以採用一個標準的方式以互相觸發事件並傳遞資訊, 而且不侷限於既有的 User Control 元件; 事實上, 我可以任意的增加或減少 User Control (以及它所附帶的功能)到專案裡面, 而不會影響到整體架構, 更不用去修改不相干的元件。

不過由於我的程式過於複雜, 而且修改到最後, 我所套用的模式反而比較接近裝飾者樣式 (Decorator), 所以我就不拿那個程式作例子了。我在這裡打算採用微軟網站的一個 Video 教學作為範例, 讓有興趣的讀者可以參考與學習。這個 Video 教學叫做「How Do I: Use the Observer Pattern」, 除了提供畫面解說之外, 還有VBC# 範例程式碼可以下載。

不過我並不會堅持你一定得很用功的把它的 Video 教學看完。因為這個教學只是教你怎麼去建立這個程式, 卻沒有教你觀察者樣式到底是做什麼用、用在什麼地方。而且講師不知道為了什麼, 始終很固執的就是不願意把工具箱、方案總管和 Debug 視窗給關閉, 導致所有程式碼都擠在一個小小的視窗裡面, 坦白說, 這讓人看得很痛苦。就算你再怎麼努力的看, 恐怕也看不出真正的重點是什麼。所以我建議你, 如果你覺得在影片中撰寫程式的過程引不起你的興趣, 你可以把影片快轉到最後面示範程式運作的地方 (約從14:10處開始), 直接去看程式的實際執行結果。

不過有時候影片的載入速度也挺慢的, 我想我乾脆把本程式執行後的邏輯簡單講解一遍, 這樣也可以節省你一點時間:

當你把程式跑起來之後, 你會看到一個有兩個 TextBox 和兩個按鈕的視窗。不用急著打字, 請先按 New Observer 按鈕三次, 那麼它會建立三個新的視窗。

接著, 請在兩個 TextBox 上打幾個字, 再按 Save 按鈕, 那麼這幾個字就會同時出現在剛才那三個新視窗裡面。

範例圖

簡單易懂的範例

這個程式想要達成的目的很簡單, 就是你可以自由的建立多個觀察者視窗, 而你在母視窗對於資料的改變, 都會自動的在觀察者視窗中即時的更新。以白說來講, 不管是哪個觀察者, 他們所看到的東西應該都是一樣的, 所以這種樣式才叫做「觀察者」樣式。

接著, 我建議你直接把範例程式下載回去, 解開壓縮之後, 執行那個 .sln 檔案, 把專案在 Visual Studio 中開啟。在我的 VS2010 中, 它必須經過轉換, 不過過程十分順利。如果到這裡你還願意繼續看下去的話, 你最好是把這個專案一直保持開啟, 並且隨時參考, 否則以下的文字恐怕只會讓你睡著而已。

在專案中, 總共只有幾個程式 (以 C# 版本為例):

  • Form1.cs
  • FormObserver.cs
  • IObserver.cs
  • PatientSubject.cs
  • Subject.cs

我們先來看看類別圖:

Class Diagram

Form1 跟 FormObserver 是主要的展示視窗; 在按下 Form1 裡的按鈕之後, 它會開啟一個新的 FormObserver (觀察者)視窗; 按幾下就開幾個。

Subject 是一個抽象類別, 而 PatientSubject 是繼承自 Subject 的另一個類別 (在觀察者樣式的術語中, 繼承自 Subject 的類別通常稱為 Concrete Subject)。其實以這個範例所描述的簡單情境, 根本可以不需要如此設計的; 你大可以把這兩個類別合併成單一一個類別, 也能達成相同的功能。不過這個範例之所以這麼設計, 其實有它未明說的寓意, 那就是實際上, 我們有可能會需要把情境擴展到更複雜的狀況之下, 意思就是說, 我們也許不是只有 PatientSubject, 未來也可能會有 EmployeeSubject, DoctorSubject 等等。換句話說, 這個範例中預留了未來擴充的彈性, 我們必須能夠看清楚這一點。

IObserver 則是很簡單的一個 Interface。不要因為它很簡單而輕忽它的重要性, 因為這個 Interface 就是整個觀察者樣式中的靈魂; 所有程式之間的溝通都經由這個介面而達成。現在我們回頭來看看 FormObserver 這個視窗的程式碼, 你會發現它實作了 IObserver; 而這就是整個觀察者樣式中第一個關鍵。

接著, 我們來看看 Form1 中 Button2_Click 這個事件處理函式 (也就是按下 New Observer 按鈕後所觸發的函式)。在這裡, 我們下了 mPatientSubject.Subscribe(frm) 這個指令。這個指令則是觀察者樣式中第二個關鍵。我們可以看看循序圖 (抱歉, 下圖中標示有誤; 應為 Button2.Click()):

Sequence Diagram

Subscribe 方法是定義在 Subject 類別之中; 藉由這個指令, 我們把新增的觀察者 (即此例中的 frm 物件) 經由 Concrete Subject 傳給父類別的 Subscribe() 方法加入到觀察者名單裡面。在許多其它的觀察者樣式的範例中, 也時常把 Subscribe 這個指令取名做 Register 或是 Attach。

有訂閱者 (Subscriber) 就一定有發行者 (Publisher), 在觀察者樣式的術語中, Subject 這個類別也經常被稱為 Publisher。

我們繼續來看看 Form1 中 Button1_Click 這個事件處理函式 (也就是按下 Save 按鈕後所觸發的函式)。在這裡, 我們修改了 mPatientSubject 這個物件的 FirstName 與 LastName 兩個值。mPatientSubject 是 PatientSubject 類別, 而 FirstName 與 LastName 則是 PatientSubject 類別的兩個屬性。如果我們打開 PatientSubject.cs 來看的話, 我們會看到在 FirstName 與 LastName 屬性的 set 區段中, 它額外呼叫了 Notify() 方法 (定義於 PatientSubject 類別中)。這個指令就是觀察者樣式中第三個關鍵 (抱歉, 下圖中標示有誤; 應為 Button1.Click()):。

Sequence Diagram

上述 Notify() 方法定義在 Subject 類別中, 我們可以看到, 在這個方法的程式中, 它使用 foreach 迴圈, 對所有觀察者名單內的成員下達了 Update(this) 指令。而這個 Update 指令, 不就是在 IObserver 介面中定義的唯一方法嗎?

從這個程式中, 我們可以看到, 發行者是主動把異動通知給訂閱者的。這也就是為什麼我在前面說過部落格的情境只能拿來幫助你了解觀察者樣式, 而它實際上並不符合觀察者樣式的原因。

Why Design Pattern?

到這裡為止, 對於部份還沒採用過任何 Design Pattern 的人, 以及對於物件導向原理沒有深入概念的人, 恐怕會認為「繞了這麼一大圈, 還不如我用原來的方法, 老早把程式寫好了」。是的, 我已經聽很多人講過類似的話了。不過我只能這樣說, 那些一開始對於 Design Pattern 抱持懷疑態度的人, 當他/她有機會架構稍大型專案的時候, 最後還是都回頭來尋求可用的 Design Pattern, 好像那是什麼救命仙丹似的。為什麼呢? 因為這些 Design Pattern 之所以有資格流傳在眾多程式設計師之間, 就是因為大家口碑相傳的關係。如果沒有任何優點, 自然沒有人會用; 那麼既然大家都在用, 當然是因為有它的好用之處。

從我所推薦的這個範例程式中, 你應該已經學到觀察者模式的基本原則以及做法。不過, 請保持謙虛, 不要以為你已經真正學會了什麼... 你才剛開始學而已! 為什麼這麼說呢? 盡信書不如無書; 你或許學會了觀察者模式的基本寫法, 但是在現實生活中, 你真的剛好需要寫一個輸入姓名之後, 可以自動通知其它視窗的程式嗎?

我的意思是, 你真正必須學會的, 是經由物件導向工具, 達成某些情境之下的某些功能, 而且要能舉一反三、聞一知十。例如, 我們在這個範例中雖然使用了 Interface, 但是它的主要目的並不在制定 contract。當然, 實作 Interface 的類別都要遵循 Interface 中隱含的條件約束, 但是在這個範例中, 我們卻是利用這個 Interface 以作為視窗之間互相呼叫或傳輸資料的一個管道。如果你不透過 Interface, 你也可以透過類別的繼承來達成相同的功能, 雖然我們一般都習慣採用 Interface。你必須能看出這一點, 才不至於誤解其含義。其它 Design Pattern 多半也是這麼設計的; 你一旦看得懂觀察者樣式, 其它樣式就一點也不難懂了。

懂得活用才是重點

當你真正能夠掌握這些技巧, 而且逐漸不把 Design Pattern 看作什麼高不可攀的理論之後, 你就能夠自在的悠遊在不同的 Pattern 之間, 並且可以隨時找一些適合的Pattern 來運用在自己所遭遇的情境中, 並且可以擴充既有的 Pattern, 甚至創造出自己認為適合的 Pattern。

就以這個範例程式來講, 在上述模型中, Concrete Subject 類別(就是範例中的 PatientSubject) 所做的改變, 會同時更新所有的 Observer。但是如果再回到我一開始所提到的程式, 也就是我以 User Control 來實作繪圖效果的那個專案, 使用觀察者模式是絕對不夠的。為什麼呢? 因為這些由 User Control 寫成的元件, 它們所擔任的角色並不是單純的「觀察」者, 它們其實除了觀察之外, 也同時身兼動作者; 意思就是說, 它們也會影響到其它程式; 所以在這個模式之下, 並沒有單純的 Concrete Subject 和單純的 Observer 角色存在。

在這種情形之下, 你或許察覺了某一種 Design Pattern 的優點, 但是你不應該為了符合這個 Pattern 的設計手法, 而去修改你的程式, 甚至去變更其需求, 而是應該反過來, 在邊做邊修的情況下, 把原來的 Pattern 修改成符合你的需求的樣子, 或者也可以再去尋找更符合你的需求的 Pattern (如果有的話)。

同樣的, 我的程式雖然導入了觀察者樣式, 但最後的做法和標準的觀察樣式長得並不一樣。我相信所有程式設計師早晚也都會面臨同樣的問題。懂得活用才是重點, 這就是我的建議。

沒有留言:

張貼留言