作者 陈金洲 发布于 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可用性进行观察,并对其实现保持兴趣。

 

 

转自:http://www.infoq.com/cn/articles/thoughtworks-practice-partiii-ii