文章:RichClient/RIA原则与实践(上)作者 陈金洲 发布于 2009年3月16日 上午8时42分 Web领域的经验在过去十多年的不断的 使用和锤炼中,整个开发领域的技术、理念、缺陷已经趋于成熟,它丰富的积累使得开发者逐渐将更多的精力投入到应用本身。但是,目前仍然没有比较深入的实践 性文章来介绍企业环境下RichClient开发,而只是偏向于小规模特性介绍,但在大规模的企业应用中,这些小的技巧对于架构决策往往帮助很小。作者在 加入ThoughtWorks之后,参加了多个不同的RichClient项目的开发工作,使用/尝试过的语言包括Java Swing、Flex/Adobe Air、.NET WinForm/.NET WPF,对于不同平台之间的种种有些体会。在本文中,作者将这些实践和原则进行了总结。 在讲述“一切皆异步”这条原则时,作者说到: 所有耗时的操作都应当异步进行。这是第一条、也是最重要的原则,违背了这条原则将会导致你的应用完全不可用。 在谈到“视图生命周期管理”时,作者将Web开发和RichClient开发进行了对比: 在WEB开发中,视图的生命周期很短:在进入页面的时候创建,在离开页面的时候销毁。一不小心页面被弄糟了,或者不能按照预期的渲染了,点下刷新按钮,整个世界一片清净。 3 事件管理事件管理应当是整个RichClient/RIA开发中的最难以把握的部分。这部分控制的好,你的程序用起来将如行云流水,用户的思维不会被打断。任何一 个做RichClient开发的程序员,可以对其他方面毫无所知,但这部分应当非常熟悉。事件是RichClient的核心,是“一切皆异步”的终极实现。前面所说的例子,实际上可以被抽象为事件,例如第一个,获取股票数据,从事件的观点看,应该是: 开始获取股票数据
看起来相当复杂。然而这样去考虑的时候,你可以将执行计算与界面展现清晰的分开。界面只需要响应事件,运算可以在另外的地方 悄悄的进行,并当任务完成或者失败的是时候报告相应的事件。从经验看来,往往同样的数据会在不同的地方进行不同的展示,例如skype在通话的时候这个人 的头像会显示为占线,而具体的通话窗口中又是另外不同的展现;MSN的个人签名在好友列表窗口中显示为一个点击可以编辑控件,而同时在聊天窗口显示为一个 不能点击只能看的标签。这是RichClient的特性,你永远不知道同一份数据会以什么形式来展现,更要命的是,当数据在一个地方更新的时候,其他所有 能展现的地方都需要同时做相应的更新。如果我们仍然以第一部分的例子,简单采用 我们曾经犯过一些很严重的错误,导致最终即便重构都积重难返。无视事件的抽象带来的影响是架构级别的,小修小补将无济于事。 事件的实现方式可以有很多种。对于没有事件支持的语言,接口或者干脆某一个约束的方法就可以。有事件支持的语言能够享受到好处,但仍然是语法级别的,根本 是一样的。观察者模式在这里很好用。仍然以股票为例,被观察的对象就是获取股票数据对象 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线程与运算线程之间也变得容易。当尝试以事件的视角去观察整个应用程序的时候,你会更关注于用户与界面之间的交互。 让我们继续抽象。如果把“获取股票数据”这个按钮点击,让 Events { listeners: {} register(eventId, listener) { listeners[eventId].add(listener) } broadcast(eventId) { listeners[eventId].observers.each{|o| o.doSomething(); } } }
Events.register('start_retrive_stock_data', StockDataRetriever) 当点击“获取股票数据”按钮的时候,可以是这样: Events.broadcast('start_retrive_stock_data') 你会发现 需要注意的是,并非将所有事件定义为全局事件是一个好的实践。在更大规模的系统中,将事件进行有效整理和分级是有好处的。在强类型的语言(如 Java/C#)中,抽象出强类型的 StockDataLoadedEvent { StockData theData; StockDataLoadedEvent(StockData theData); } Event.broadcast(new StockDataLoadedEvent(loadedData)) 这个事件的监听者能够不加类型转换的获得 delegate void StockDataLoaded(StockData theData) 事件管理原则我相信并不难理解。然而困难的是具体实现。对一个新的UI框架不熟悉的时候,我们经常在“代码的优美”与“界面提供的特性”之间徘徊。实现这 样的一个事件架构需要在项目一开始就稍具雏形,并且所有的事件都有良好的命名和管理。避免在命名、使用事件的时候的随意性,对于让代码可读、应用稳定有非 常大的意义。一个好的事件管理、通知机制是一个良好RichClient应用的根本基础。一般说来,你正在使用的编程平台如Swing/WinForm /WPF/Flex等能够提供良好的事件响应机制,即监听事件、onXXX等,但一般没有统一的事件的监听和管理机制。对于架构师,对于要使用的编程平台 对于这些的原生支持要了熟于心,在编写这样的事件架构的时候也能兼顾这些语言、平台提供给你的支持。 采用了事件的事件后,你不得不同时实践“线程管理”,因为事件一般来说意味着将耗时的操作放到别的地方完成,当完成的时候进行事件通知。简单的模式下,你可以在所有需要进行异步运算的地方,将运算放到另外一个线程,如 4 线程管理在WEB开发几乎无需考虑线程,所有的页面渲染由浏览器完成,浏览器会异步的进行文字和图片的渲染。我们只需要写界面和JavaScript就好。如果你认同“一切皆异步”,你一定得考虑线程管理。 毫无管理的线程处理是这样的:凡是需要进行异步调用的地方,都新起一个线程来进行运算,例如前面提到的 线程的管理的核心功能是用来统一化所有的耗时操作,最简单的 TaskExecutor { void pendTask(task) { //task: 耗时操作任务 runInThread { task.run(); // 运行任务 } } } RetrieveStockDataTask extends Task { void run() { theData = ... // 直接获取远程数据,不用在另外线程中执行 Events.broadcast(new StockDataLoadedEvent(theData)) // 广播事件 } } 需要进行这个操作的时候,只需要执行类似于下面的代码: TaskExecutor.pendTask(new RetrieveStockDataTask()) 好处很明显。通过引入 耗时任务的定义与执行被分开,使得在任务内部能够按照正常的方式进行编码。测试也很容易写了。 不同的语言平台会提供不同的线程管理能力。.NET2.0提供了 一个完善的
写这样的一个线程管理的不难。最简单的实现就是每当 5 缓存与本地存储纯粹的B/S结构,浏览器不持有任何数据,包括基本不变的界面和实际展现的数据。RichClient的一大进步是将界面部分本地持有,与服务器只作数据通讯,从而降低数据流量。像《魔兽世界》10多G的超大型客户端,在普通的拨号网络都可以顺畅的游戏。 缓存与本地存储之间的差别在于,前者是在线模式下,将一段时间不变的数据缓存,最少的与服务器进行交互,更快的响应客户;后者是在离线模式下,应用仍然能 够完成某些功能。一般来说,凡是需要类似于“查看XXX历史”功能的,需要“点击列表查看详细信息”的,都会存在本地存储的必要,无论这个功能是否需要向 用户开放。 无论是缓存还是本地存储,最需要处理的问题如何处理本地数据与服务器数据之间的更新机制。当新数据来的时候,当旧数据更新的时候,当数据被删除的时候,等 等。一般来说,引入这个实践,最好也实现基于数据变化的“事件管理”。如果能够实现“客户机-服务器数据交互模式”那就更完美了。 我们犯过这样一个错误。系统启动的时候,将当前用户的联系人列表读取出来,放到内存中。当用户双击这个联系人的时候,弹出这个联系人的详细信息窗口。由于 没有本地存储,由于采用了Navigator方式的导航,于是很自然的采用了 本地存储会在根本上影响RichClient程序的架构。除非本地不保存任何信息,否则本地存储一定需要优先考虑。某些编程平台需要你在本地存储界面和数 据,如Google Gears的本地存储,置于Adobe Air的AJAX应用等,某些编程平台只需要存储数据,因为界面完全是本地绘制的,如Java/JavaFX/WinForm/WPF等。缓存界面与缓存 数据在实现上差别很大。 本地存储的存储机制最好是采用某一种基于文件的关系数据库,如SQLite、H2(HypersonicSQL)、Firebird等。一旦确定要采用本地存储,就从成熟的数据库中选择一个,而不要尝试着自己写基于文件的某种缓存机制。你会发现到最后你实现了一个山寨版的数据库。 在没有考虑本地存储之前,与远端的数据访问是直接连接的: 我们上面的例子说明,一旦考虑使用本地存储,就不能直接访问远程服务器,那么就需要一个中间的数据层: 数据层的主要职责是维护本地存储与远程服务器之间的数据同步,并提供与应用相关的数据缓存、更新机制。数据更新机制有两种,一种是Proxy(代理)模式,一种是自动同步模式。 代理模式比较容易理解。每当需要访问数据的时候,将请求发送到这个代理。这个代理会检查本地是否可用,如果可用,如缓存处于有效期,那么直接从本地读取数 据,否则它会真正去访问远端服务器,获取数据,更新缓存并返回数据。这种手工处理同步的方式简单并且容易控制。当应用处于离线模式的时候仍然可以工作的很 好。 自动同步模式下,客户端变成都针对本地数据层。有一个健壮的自动同步机制与服务器的保持长连接,保证数据一直都是更新的。这种方式在应用需要完全本地可运行的时候工作的非常好。如果设计得好,自动同步方式健壮的话,这种方式会给编程带来极大的便利。 说到同步,很多人会考虑数据库自带的自动同步机制。我完全不推荐数据库自带的机制。他们的设计初衷本身是为了数据库备份,以及可扩展性 (Scalability)的考虑。在应用层面,数据库的同步机制往往不知道具体应用需要进行哪些数据的同步,同步周期等等。更致命的是,这种机制或多或 少会要求客户端与服务器端具备类似的数据库表结构,迁就这样的设计会给客户端的缓存表设计带来很大的局限。另外,它对客户机-服务器连接也存在一定的局限 性,例如需要开放特定端口,特定服务等等。对于纯粹的Internet应用,这种方式更是完全不可行的,你根本不知道远程数据库的结构,例如 Flickr, Google Docs. 当本地存储+自动同步机制与“事件管理”都实现的时候,应用会是一种全新的架构:基于数据驱动的事件结构。对于所有本地数据的增删改都定义为事件,将关心 这些数据的视图都注册为响应的观察者,彻底将数据的变化于展现隔离。界面永远只是被动的响应数据的变化,在我看来,这是最极致的方式。 结尾限于篇幅,这篇文章并没有很深入的讨论每一种原则/实践。同时还有一些在RichClient中需要考虑的东西我们并没有讨论:
感谢感谢我的同事周小强、付莹在我写作过程中提供的无私的建议和帮助。小强推荐了介绍Google Gears架构的链接,让我能够写作“本地存储”部分有了更深的体会。 这篇文章是我近两年来在RichClient工作、网络游戏、WebGame众多思考的一个集合。我尝试过JavaFX/WPF/AdobAir 以及相关的文章,然而大多数的例子都是从华丽的界面入手,没有实践相关的内容。有意思的反而是《大型多人在线游戏开发》这本书,给了我在企业 RichClient开发很多启发。我们曾经犯了很多错误,也获得了许多经验,以后我们应当能做得更好。 |
|