轉 http://www.infoq.com/cn/articles/thoughtworks-practice-partiii-ii
RichClient/RIA原則與實踐(下)
作者 陳金洲 發布于 2009年3月11日 下午10時7分
- .NET,
- Agile,
- Java
- 主題
- RIA,
- 富客戶端/桌面
- 標簽
- 原則
3 事件管理
事件管理應當是整個RichClient/RIA開發中的最難以把握的部分。這部分控制的好,你的程序用起來將如行云流水,用戶的思維不會被打斷。任何一 個做RichClient開發的程序員,可以對其他方面毫無所知,但這部分應當非常熟悉。事件是RichClient的核心,是“一切皆異步”的終極實現。前面所說的例子,實際上可以被抽象為事件,例如第一個,獲取股票數據,從事件的觀點看,應該是:
- 開始獲取股票數據
- 正在獲取股票數據
- 獲取數據完成
- 獲取數據失敗
看起來相當復雜。然而這樣去考慮的時候,你可以將執行計算與界面展現清晰的分開。界面只需要響應事件,運算可以在另外的地方 悄悄的進行,并當任務完成或者失敗的是時候報告相應的事件。從經驗看來,往往同樣的數據會在不同的地方進行不同的展示,例如skype在通話的時候這個人 的頭像會顯示為占線,而具體的通話窗口中又是另外不同的展現;MSN的個人簽名在好友列表窗口中顯示為一個點擊可以編輯控件,而同時在聊天窗口顯示為一個 不能點擊只能看的標簽。這是RichClient的特性,你永遠不知道同一份數據會以什么形式來展現,更要命的是,當數據在一個地方更新的時候,其他所有 能展現的地方都需要同時做相應的更新。如果我們仍然以第一部分的例子,簡單采用runInAnoterThread
是完全不能解決這個問題的。
我們曾經犯過一些很嚴重的錯誤,導致最終即便重構都積重難返。無視事件的抽象帶來的影響是架構級別的,小修小補將無濟于事。
事件的實現方式可以有很多種。對于沒有事件支持的語言,接口或者干脆某一個約束的方法就可以。有事件支持的語言能夠享受到好處,但仍然是語法級別的,根本 是一樣的。觀察者模式在這里很好用。仍然以股票為例,被觀察的對象就是獲取股票數據對象StockDataRetriver
,觀察的就是StockWindow
:
StockDataRetriver {
observers: []
retrieve() {
try {
theData = ...// 從遠程獲取數據
observers.each {|o| o.stockDataReady(theData)} // 觸發數據獲取成功事件
} catch {
observers.each { |o| o.stockDataFailed() } // 觸發事件獲取失敗事件
}
}
}
StockDataRetriver.observers.add(StockWindow) // 將StockWindow加入到觀察者隊列
StockWindow {
stockDataReady(theData) {
showDataInUIThread(); // 在UI線程顯示數據
}
stockDataFailed() {
showErrorInUIThread(); // 在UI線程顯示錯誤
}
}
你會發現代碼變得簡單。UI與計算之間的耦合被事件解開,并且區分UI線程與運算線程之間也變得容易。當嘗試以事件的視角去觀察整個應用程序的時候,你會更關注于用戶與界面之間的交互。
讓我們繼續抽象。如果把“獲取股票數據”這個按鈕點擊,讓StockDataRetriver
去獲取數據當作事件來處理,應該怎么寫呢?將按鈕作為被觀察 者,StockDataRetriver
作為觀察者顯然不好,好不容易分開的耦合又黏在一起。引入一個中間的Events
看起來不錯:
Events {
listeners: {}
register(eventId, listener) {
listeners[eventId].add(listener)
}
broadcast(eventId) {
listeners[eventId].observers.each{|o| o.doSomething(); }
}
}
Events
中維護了一個listeners
的列表,它是一個簡單的Hash結構,key是eventId
,value是observer
的列表;它提供了兩個方法,用來注冊事件監聽以及通知事件產生。對于上面的案例,可以先注冊StockDataRetriver
為一個觀察者,觀察start_retrive_stock_data
事件:
Events.register('start_retrive_stock_data', StockDataRetriever)
當點擊“獲取股票數據”按鈕的時候,可以是這樣:
Events.broadcast('start_retrive_stock_data')
你會發現StockDataRetriver
能夠老老實實的開始獲取數據了。
需要注意的是,并非將所有事件定義為全局事件是一個好的實踐。在更大規模的系統中,將事件進行有效整理和分級是有好處的。在強類型的語言(如 Java/C#)中,抽象出強類型的EventId
,能夠幫助理解系統和進行編程,避免到處進行強制類型轉換。例如,StockEvent
:
StockDataLoadedEvent {
StockData theData;
StockDataLoadedEvent(StockData theData);
}
Event.broadcast(new StockDataLoadedEvent(loadedData))
這個事件的監聽者能夠不加類型轉換的獲得StockData
數據。上面的例子是不支持事件的語言,C#語言支持自定義強類型的事件,用起來要自然一些:
delegate void StockDataLoaded(StockData theData)
事件管理原則我相信并不難理解。然而困難的是具體實現。對一個新的UI框架不熟悉的時候,我們經常在“代碼的優美”與“界面提供的特性”之間徘徊。實現這 樣的一個事件架構需要在項目一開始就稍具雛形,并且所有的事件都有良好的命名和管理。避免在命名、使用事件的時候的隨意性,對于讓代碼可讀、應用穩定有非 常大的意義。一個好的事件管理、通知機制是一個良好RichClient應用的根本基礎。一般說來,你正在使用的編程平臺如Swing/WinForm /WPF/Flex等能夠提供良好的事件響應機制,即監聽事件、onXXX等,但一般沒有統一的事件的監聽和管理機制。對于架構師,對于要使用的編程平臺 對于這些的原生支持要了熟于心,在編寫這樣的事件架構的時候也能兼顧這些語言、平臺提供給你的支持。
采用了事件的事件后,你不得不同時實踐“線程管理”,因為事件一般來說意味著將耗時的操作放到別的地方完成,當完成的時候進行事件通知。簡單的模式下,你可以在所有需要進行異步運算的地方,將運算放到另外一個線程,如ThreadPool.QueueUserWorkItem
, 在運算完成的時候通知事件。但從資源的角度考慮,將這些線程資源有效的管理也是很重要的,在“線程管理”部分有詳細的闡述。另外,如果能將你的應用轉變為 數據驅動的,你需要關注“緩存以及本地存儲”。
4 線程管理
在WEB開發幾乎無需考慮線程,所有的頁面渲染由瀏覽器完成,瀏覽器會異步的進行文字和圖片的渲染。我們只需要寫界面和JavaScript就好。如果你認同“一切皆異步”,你一定得考慮線程管理。
毫無管理的線程處理是這樣的:凡是需要進行異步調用的地方,都新起一個線程來進行運算,例如前面提到的runInThread
的實現。這種方式如果托管在 在“事件管理”之下,問題不大,只會給測試帶來一些麻煩:你不得不wait一段時間來確定是否耗時操作完成。這種方式很山寨,也無法實現更高級功能。更好 的的方式是將這些線程資源進行統籌管理。
線程的管理的核心功能是用來統一化所有的耗時操作,最簡單的TaskExecutor
如下:
TaskExecutor {
void pendTask(task) { //task: 耗時操作任務
runInThread {
task.run(); // 運行任務
}
}
}
RetrieveStockDataTask extends Task {
void run() {
theData = ... // 直接獲取遠程數據,不用在另外線程中執行
Events.broadcast(new StockDataLoadedEvent(theData)) // 廣播事件
}
}
需要進行這個操作的時候,只需要執行類似于下面的代碼:
TaskExecutor.pendTask(new RetrieveStockDataTask())
好處很明顯。通過引入TaskExecutor
,所有線程管理放在同一個地方,耗時操作不需要自行維護線程的生命周期。你可以在TaskExecutor
中靈活定義線程策略實現一些有趣的效果,如暫停執行,監控任務狀況等,如果你愿意,為了更好的進行調試跟蹤,你甚至可以將所有的任務以同步的方式執行。
耗時任務的定義與執行被分開,使得在任務內部能夠按照正常的方式進行編碼。測試也很容易寫了。
不同的語言平臺會提供不同的線程管理能力。.NET2.0提供了BackgroundWorker
, 提供了一序列對多線程調用的封裝,事件如開始調用,調用,跨線程返回值,報告運算進度等等。它內部也實現了對線程的調度處理。在你要開始實現類似的TaskExecutor時,參考一下它的API設計會有參考價值。Java 6提供的Executor也不錯。
一個完善的TaskExecutor
可以包含如下功能:
Task
的定義:一個通用的任務定義。最簡單的就是run()
,復雜的可以加上生命周期的管理:start()
、end()
、success()
、fail()
..取決于要控制到多么細致的粒度。
pendTask
,將任務放入運算線程中
reportStatus
,報告運算狀態
- 事件:任務完成
- 事件:任務失敗
寫這樣的一個線程管理的不難。最簡單的實現就是每當pendTask
的時候新開線程,當運算結束的時候報告狀態。或者使用像BackgroundWorker
或者Executor
這樣的高級API。對于像ActionScript/JavaScript這樣的,只能用偽線程, 或者干脆將無法拆解的任務扔到服務器端完成。
5 緩存與本地存儲
純粹的B/S結構,瀏覽器不持有任何數據,包括基本不變的界面和實際展現的數據。RichClient的一大進步是將界面部分本地持有,與服務器只作數據通訊,從而降低數據流量。像《魔獸世界》10多G的超大型客戶端,在普通的撥號網絡都可以順暢的游戲。
緩存與本地存儲之間的差別在于,前者是在線模式下,將一段時間不變的數據緩存,最少的與服務器進行交互,更快的響應客戶;后者是在離線模式下,應用仍然能 夠完成某些功能。一般來說,凡是需要類似于“查看XXX歷史”功能的,需要“點擊列表查看詳細信息”的,都會存在本地存儲的必要,無論這個功能是否需要向 用戶開放。
無論是緩存還是本地存儲,最需要處理的問題如何處理本地數據與服務器數據之間的更新機制。當新數據來的時候,當舊數據更新的時候,當數據被刪除的時候,等 等。一般來說,引入這個實踐,最好也實現基于數據變化的“事件管理”。如果能夠實現“客戶機-服務器數據交互模式”那就更完美了。
我們犯過這樣一個錯誤。系統啟動的時候,將當前用戶的聯系人列表讀取出來,放到內存中。當用戶雙擊這個聯系人的時候,彈出這個聯系人的詳細信息窗口。由于 沒有本地存儲,由于采用了Navigator方式的導航,于是很自然的采用了Navigator.goTo('ContactDetailWindow', theContactInfo)
。由于列表頁面一般是不變的,因此顯示出來的永遠是那份舊的數據。后來有了編輯聯系人信息的功能,為了總是顯示更新的數 據,我們將調用更改為Navigator.goTo('ContactDetailWindow', 'contactId')
,然后在ContactDetailWindow
中按照contactId
把聯系人信息重新讀取一次。遠在南非的用戶抱怨慢。還 好我沒養狗,沒有狗離開我。后來我們慢慢的實現了本地存儲,所有的數據讀取都從這個地方獲得。當數據需要更新的時候,直接更新這個本地存儲。
本地存儲會在根本上影響RichClient程序的架構。除非本地不保存任何信息,否則本地存儲一定需要優先考慮。某些編程平臺需要你在本地存儲界面和數 據,如Google Gears的本地存儲,置于Adobe Air的AJAX應用等,某些編程平臺只需要存儲數據,因為界面完全是本地繪制的,如Java/JavaFX/WinForm/WPF等。緩存界面與緩存 數據在實現上差別很大。
本地存儲的存儲機制最好是采用某一種基于文件的關系數據庫,如SQLite、H2(HypersonicSQL)、Firebird等。一旦確定要采用本地存儲,就從成熟的數據庫中選擇一個,而不要嘗試著自己寫基于文件的某種緩存機制。你會發現到最后你實現了一個山寨版的數據庫。
在沒有考慮本地存儲之前,與遠端的數據訪問是直接連接的:

我們上面的例子說明,一旦考慮使用本地存儲,就不能直接訪問遠程服務器,那么就需要一個中間的數據層:

數據層的主要職責是維護本地存儲與遠程服務器之間的數據同步,并提供與應用相關的數據緩存、更新機制。數據更新機制有兩種,一種是Proxy(代理)模式,一種是自動同步模式。
代理模式比較容易理解。每當需要訪問數據的時候,將請求發送到這個代理。這個代理會檢查本地是否可用,如果可用,如緩存處于有效期,那么直接從本地讀取數 據,否則它會真正去訪問遠端服務器,獲取數據,更新緩存并返回數據。這種手工處理同步的方式簡單并且容易控制。當應用處于離線模式的時候仍然可以工作的很 好。

自動同步模式下,客戶端變成都針對本地數據層。有一個健壯的自動同步機制與服務器的保持長連接,保證數據一直都是更新的。這種方式在應用需要完全本地可運行的時候工作的非常好。如果設計得好,自動同步方式健壯的話,這種方式會給編程帶來極大的便利。

說到同步,很多人會考慮數據庫自帶的自動同步機制。我完全不推薦數據庫自帶的機制。他們的設計初衷本身是為了數據庫備份,以及可擴展性 (Scalability)的考慮。在應用層面,數據庫的同步機制往往不知道具體應用需要進行哪些數據的同步,同步周期等等。更致命的是,這種機制或多或 少會要求客戶端與服務器端具備類似的數據庫表結構,遷就這樣的設計會給客戶端的緩存表設計帶來很大的局限。另外,它對客戶機-服務器連接也存在一定的局限 性,例如需要開放特定端口,特定服務等等。對于純粹的Internet應用,這種方式更是完全不可行的,你根本不知道遠程數據庫的結構,例如 Flickr, Google Docs.
當本地存儲+自動同步機制與“事件管理”都實現的時候,應用會是一種全新的架構:基于數據驅動的事件結構。對于所有本地數據的增刪改都定義為事件,將關心 這些數據的視圖都注冊為響應的觀察者,徹底將數據的變化于展現隔離。界面永遠只是被動的響應數據的變化,在我看來,這是最極致的方式。
結尾
限于篇幅,這篇文章并沒有很深入的討論每一種原則/實踐。同時還有一些在RichClient中需要考慮的東西我們并沒有討論:
- 純Internat應用離線模式的實現。像AdobeAir/Google Gears都有離線模式和本地存儲的支持,他們的特點是緩存的不僅僅是數據,還包括界面。雖然常規的企業應用不太可能包含這些特性,但也具備借鑒意義。
- 狀態的控制。例如管理員能夠看到編輯按鈕而普通用戶無法看見,例如不同操作系統下的快捷鍵不同。簡單情況下,通過if-else或者對應編程平臺下提供的綁定能夠完成,然而涉及到更復雜的情況時,特別是網絡游戲中大量互斥狀態時,一個設計良好的分層狀態機模型能夠解決這些問題。如何定義、分析這些狀態之間的互斥、并行關系,也是處理超復雜
- 測試性。如何對RichClient進行測試?特別是像WPF、JavaFX、Adobe Air等用Runtime+編程實現的框架。它們控制了視圖的創建過程,并且傾向于綁定來進行界面更新。采用傳統的MVP/MVC方式會帶來巨大的不必要的工作量(我們這么做過!),而且測試帶來的價值并沒有想象那么高。
- 客戶機-服務器數據交互模式。如何進行客戶機服務器之間的數據交互?最簡單的方式是類似于Http Request/Response。這種方式對于單用戶程序工作得很好,但當用戶之間需要進行交互的時候,會面臨巨大挑戰。例如,股票代理人關注亞洲銀行板塊,剛好有一篇新的關于這方面的評論出現,股票代理人需要在最多5分鐘內知道這個消息。如果是Http Request/Response, 你不得不做每隔5分鐘刷一次的蠢事,雖然大多數時候都不會給你數據。項目一旦開始,就應當仔細考慮是否存在這樣的需求來選擇如何進行交互。這部分與本地存儲也有密切的關系。
- 部署方式。RichClient與B/S 直接最大的差異就是,它需要本地安裝。如何進行版本檢測以及自動升級?如何進行分發?在大規模訪問的時候如何進行服務器端分布式部署?這些問題有些被新技術解決了,例如Adobe Air以及Google Gears,但仍然存在考慮的空間。如果是一個安全要求較高的應用,還需要考慮兩端之間的安全加密以及客戶端正確性驗證。新的UI框架層出不窮。開始一個新的RichClient項目的時候,作為架構師/Tech Lead首先應當關注的不是華麗的界面和效果,應當觀察如何將上述原則和時間華麗的界面框架結合起來。就像我們開始一個web項目就會考慮domain 層、持久層、服務層、web層的技術選型一樣,這些原則和實踐也是項目一開始就考慮的問題。
感謝
感謝我的同事周小強、付瑩在我寫作過程中提供的無私的建議和幫助。小強推薦了介紹Google Gears架構的鏈接,讓我能夠寫作“本地存儲”部分有了更深的體會。
這篇文章是我近兩年來在RichClient工作、網絡游戲、WebGame眾多思考的一個集合。我嘗試過JavaFX/WPF/AdobAir 以及相關的文章,然而大多數的例子都是從華麗的界面入手,沒有實踐相關的內容。有意思的反而是《大型多人在線游戲開發》這本書,給了我在企業 RichClient開發很多啟發。我們曾經犯了很多錯誤,也獲得了許多經驗,以后我們應當能做得更好。
參考
相關閱讀:
[ ThoughtWorks實踐集錦(1)] 我和敏捷團隊的五個約定。
[ ThoughtWorks實踐集錦(2)] 如何在敏捷開發中做好數據遷移。
[ ThoughtWorks實踐集錦(3)] RichClient/RIA原則與實踐(上)。
作者介紹:陳金洲,Buffalo AJAX中文問題 Framework作者,ThoughtWorks咨詢師,現居北京。目前的工作主要集中在RichClient開發,同時一直對Web可用性進行觀察,并對其實現保持興趣。
給InfoQ中文站投稿或者參與內容翻譯工作,請郵件至editors@cn.infoq.com。也歡迎大家加入到InfoQ中文站用戶討論組中與我們的編輯和其他讀者朋友交流。