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

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

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

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

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