巴西国家医疗保健系统曾被喻为全球最大的 Java 企业应用,涉及 200 多万行代码,囊括一个 350 个类的区域模块。该系统把所有能想象到全国范围内的各类行政地区模块化,其所实现的自动化给公共医疗保健系统创造了巨大的价值,让巴西人受益匪浅。本案例分析,从系统构架、解决方案、教训与启发以及项目的未来趋向等做全方面详细深入的探索。
问题域
巴西是世界上仅有的几个提供完全免费公共医疗保健系统的国家之一。和所有重大的公共服务设施一样,它存在很多操作性问题,原先很多工作都基于大量的纸质文件之上,政府部门和地区医疗保健部门的 IT 系统间只有很小一部分实行了集成。例如:
- 医疗保健单位间没有帮助病人到对方部门预约就诊的调度系统。假如一个病人需要预约心脏科专家门诊,他往往只能在多个专科门诊前排长队做同样的预约,直到排到其中一个能接受预约和治疗。
- 缺乏病人过去的医疗信息,比如手术记录、使用过的药物纪录和反应等详细病历。
- 关于同一个病人的病历常常在多个互不关联的数据库中被重复记录多次。
- 由于像出生、死亡、医疗手术、疾病分析等重要统计数据分别存储于各自的纸质文件系统中,因此政府部门无法进行任何医疗资源安排计划,或对资源缺乏及时的应急措施。
- 由于医疗政策法规的 IT 系统模块没有和保健系统站点集成,所以无法预测和防止医疗系统的腐败。
- 许多当地医生根本没有任何信息系统,缺乏调度,以至于所有病人都必须成天排队等待医治。
考虑到这么多问题的存在,政府批准创建一个自动化医疗保健系统,命名为 Siga Saude。系统设计目标是覆盖所能想到的公共医疗保健信息系统的所有方面,包括会诊调度,医生和医疗器械的目录清单管理,帐单,病理跟踪,审核报告,规章制度的遵循,以及安全访问控制等。
系统首先在圣保罗市(巴西的最大城市,有近 2000 万人口,也是世界第 4 大城市)的所有医疗保健单位投入使用。如今,该应用程序除了在圣保罗和其他 20 个城市中投入使用以外,也正逐渐覆盖到巴西其他一些城市。除此之外,还有一些想要实现全国医疗保健系统自动化的国家对此系统也产生浓厚的兴趣,例如葡萄牙语国家安哥拉和莫桑比克等。
解决方案概述
该应用开发基于 EJB2.1 和 Struts,采用成熟的 EJB 设计模式,引入数据传送对象 (data transfer objects)、会话外观 (session facade),服务定位 ( service locator),业务委托 ( business delegates),完美定义了一个层次分明的构架。整个开发在 Eclipse 环境下完成,测试和最后部署使用 JBoss 应用服务器。在一些特定部分,也采用了 Drools 规测引擎 (rules engine)。目前,应用程序非集群地 (non-clustered) 部署运行于 Dual Xeon 3.1 服务器之上,该服务器具有 4GRAM,Linux 操作系统,并运行 JBoss 3.2.7。
从需求到开发,150 多人参与了系统的功能定义工作,这些人中包括医生和健康资讯专家。根据功能定义,研发团队最终把系统分解成以下这些模块:
上述模块图中所标出的每个模块代码行的数量均包括了服务层和与该模块相对应的域模型的代码。另外,在服务层,有 10 万行代码是模块间共享,有 57 万行是各域模型间共享。代码行的数量听上去有点吓人,但实际上 58%是自动生成代码,这在下文主题中关于注释(annotation)的部分会讨论到。
上述所有业务模型都包含 JSP 页面,Struts Actions 和在服务层实现的业务委托。服务层包括 EJB2.1 的会话 beans(session beans) 和自动生成的会话外观 (session facade)。域模块包括 pojo’s 和提供自动生成实例 Bean 的注释 (annotation)。接下来会从 URL 到 SQL 一步一步详细分析最后实现的调度系统。这里提到的系统模块划分、分层和其他的功能实现相一致,该模块划分图实际上也帮助我们清晰地去了解该系统是如何构建的。
经验一:专家门诊预约调度用例
无论从经济还是技术观点出发,该系统最重要的用例之一是描述一个地方小诊所的前台如何帮助病人预约专家门诊。用例在调度模块中实现,同时牵涉到许多其他模块。这个预约调度用例是这样的:
首先,前台在时间安排表中找到空的时间隙。这可以通过许多途径查找,比如查找一个指定的医生、专科、特定的医疗设备、医疗过程等等 (点击这里查看输入界面), “getSlots”的序列图参考下图。当他们找到某个空的时间隙 (在序列图上标识为 chooseSlot()) 以后,可以通过扫描病人医疗卡上的条形码或者在病人忘了携带医疗卡的情况下通过输入病人的姓名、出生日期或病人任何其他联系方式查找到该病人的记录,使用国家医疗保健卡模块将病人简历关联到该时间隙 (查看 searchPatient())。然后,在同一界面上输入所需的疗程类型,(参考第二个用户输入界面,序列图上标识为 saveAppointment())。 如果诊所没有病人所需要的疗程,可能的原因之一是诊所没有空的时间隙安排给病人预约就诊,或者就是诊所不提供这样的疗程,在这种情况下,前台或者医生可以帮助病人通过系统在其他诊所预约合适的诊疗。
此序列图描绘了一个典型用例在系统中的执行过程。首先,一个 Struts Action 类 (ScheduleAction) 执行用户请求并处理表示层逻辑。当需要读取数据或者需要引用业务规则的时候,Action 类调用实现了业务委托设计模式 (ScheduleCF) 的 POJO 对象的方法。这个类从会话 Facade 类 (ScheduleCF) 自动生成,并将 Web 层从服务层使用的技术中独立出来。那些实现了会话 Facade 设计模式 (ScheduleCF) 的会话 Beans 负责对其他类执行业务规则。例如,类 AppointmentService 实现方法 lockSlot() 和 searchForSlots(),当用例需要保持数据的时候,方法 setAppointmentVO() 在一个实例 Bean 上被调用。实例 Bean 机制不执行数据库查询,但可以使用一个称为 Searcher 的查询优化控件来弥补这个空缺。
会话 Facade 对象负责提供事务处理服务 (transaction service)、数据库连接和对应用程序其他部分的安全服务。因为服务对象是 POJO,它无法从 Web 层直接访问实例 Beans,这时就需要加入一个会话 bean 层来帮忙了。
如果某个时间隙被锁定,在会诊预约安排数据库中会存入相应的时间戳和锁定号 (lock ID)。为了防止竞争条件 (race condition),这两项数据通过一个 SELECT FOR UPDATE 命令保存。时间戳用来决定该预约是否过期,而储存锁定号的目的是为了避免对象试图储存过期预约的情况出现。当对象请求储存锁定预约的时候,它会将收到的锁定号和数据库当前锁定号做比较,如果这两个号不相同,那么这个新的诊疗预约信息不会被储存。这样一来,即使某个锁定发生在发送锁定请求和诊疗预约储存之间,都能被应用系统侦测到且向用户发送警告。该用例实现了一个逻辑锁定。无论是数据库还是 EJB 锁定都不需要很长的时间,利于实现业务规则。
该用例中涉及到的域对象的简化类图如下:
经验二:应用注释 (annotation) 生成代码
项目初期,开发团队采用 XDoclet1 来生成部署描述器 (deployment descriptors),值对象 (value objects),查询,会话 facades,Struts 表和动作,值验证,以及本地和远程 EJB 接口等。由于项目的短时间开发周期 (short time frame) 计划,需要应用一些额外技术帮助开发团队在短时间内和保证减少 bug 的前提下尽可能多产,代码生成也就成为这个项目中的一个关键策略。也因为它的关键性,最初的 XDoclets 模板后来被修改生成“专家代码”。
起初的 10 个月里,XDoclet 方式在项目进行中很适用,帮助开发团队达到了所需的开发能力,但没过多久,随着代码的不断增多,生成代码的速度却越来越慢,以至于生产率惨遭严重影响。于是,开发团队决定采用另一种代码生成策略,这就是 Java 5 注释 (annotations)。
注释策略可以解决 XDoclet 的以下几项缺点:
- 注释允许像部署描述器这样渐增的代码生成文件:XDoclet 中,假使一个类被修改,XDoclet 需要再次读取所有类才能生成新的部署描述器。但使用注释的话,可以仅仅重新生成部署描述器中由于类被修改而受影响的部分。鉴于大部分时候,开发人员只修改很少一部分类,注释策略的采用就帮助省去很多时间。
- Xdoclet 在生成文件的过程中需要访问子组件的源代码,这增加了连接和构建的复杂度。所有开发人员需要同样的权限访问所有源代码,而非如最初计划那样仅访问编译后的 jar 包。但采用注释的话,就可以避免这个问题。
- 注释可以在运行时 (runtime) 被处理,除了生成代码,这个特点可以带来更多好处。
团队为 EJB 3.0 注释和其他一些自定义注释开发了处理器,使其最后生成的代码和采用 XDoclet 时所生成代码的相似。这些生成的代码在 EJB 2.1 下也同样兼容。
注释处理器使用 Velocity 模板。从 XDoclet 转移到自己编写使用的模板中,最大的挑战是构建和 XDoclet 提供的同样容易理解的一组模板和帮助类。
采用注释以后,代码生成时间缩短很多。使用 XDoclet,如果其中一个类有所修改,需要 1 分 50 秒的时间从 400 个类中重新生成代码,但是使用 APT 以后,同样的操作仅需 10 秒钟。
经验三:规则引擎如何简化业务逻辑
根据政府立法,一些业务逻辑一直在修改中,有些按城市各异,系统某些部分不得不处理这些特殊的业务逻辑。比方说,某诊所想要提供 x 光透视服务,这等价于几项服务规则,拥有 x 光透视设备,有授权资格的放射科医生等。由于这些规则经常在变,所以最好把它们留在主代码库外头,这样即使规则被修改也不用去修改代码。
在 SIGA-Saude 系统中,因为引入了 Drools 规则引擎而使这个难题迎刃而解。Drools 实现了 JSR-94-Java 规则引擎应用程序接口。规则由 SIGA-Saude 系统中一个称作 Decision 的控件来处理。该控件工作于一组规则组和一个工作记忆区 (working memory)。规则组工作于一组消息,每条规则都相应有一条由实现了 br.com.vidatis.common.decision.message.RuleMessage 接口的类描述的消息。当一条规则或是规则组的子集被满足的时候,对应的消息会从队列中移除,这样做可以追踪规则的处理。如果最后在队列中没有任何消息剩下,表明规则组中的所有的规则都被满足。任何一条规则都可以触发许多不同的动作,也可以触发新的规则组,规则在 XML 文件中描述,然后交由 Drools 引擎处理。
XML 文件范例:
<rule name="if_clinic_code_then_checksum_digit_valid"><br></br> <parameter identifier="clinic"><br></br> <java:class>br.atech.smssp.domain.clinic.vo.ClinicVO</java:class><p> </parameter></p><br></br> <java:condition>clinic.getCode() != null</java:condition><br></br> <java:condition>!clinic.getCode().equals("")</java:condition><br></br> <java:consequence><br></br> //clinic code should be mandatory and valid<br></br> if(br.com.vidatis.common.decision.rule.CodeRule.isValidCone(clinic.getCode())){<br></br> ruleMessage.markRuleAsValid("if_clinic_code_then_checksum_digit_valid");<br></br> }<br></br> </java:consequence><p></rule></p><p><rule name="if_maintainer_code_then_checksum_digit_valid"></p><br></br> <parameter identifier="clinic"><br></br> <java:class>br.atech.smssp.domain.clinic.vo.ClinicVO</java:class><br></br> </parameter><br></br> <java:condition>clinic.getMaintainerCode() != null</java:condition><p> <java:condition>!clinic.getMaintainerCode().equals("")</java:condition></p><br></br> <java:consequence><br></br> //Maintainer code is optional, but should be valid if it is informed.<br></br> if(br.com.vidatis.common.decision.rule.CodeRule.isValidCode(clinic.getMaintainerCode())){<br></br> ruleMessage.markRuleAsValid("if_maintainer_code_then_checksum_digit_valid");<br></br> }<br></br> </java:consequence><br></br></rule><p><rule name="if_maintainer_code_then_checksum_digit_valid_OPTIONAL_EMPTY"></p><br></br> <parameter identifier="clinic"><p> <java:class>br.atech.smssp.domain.clinic.vo.ClinicVO</java:class></p><br></br> </parameter><br></br> <java:condition>clinic.getMaintainerCode() != null</java:condition><br></br> <java:condition>clinic.getMaintainerCode().equals("")</java:condition><br></br> <java:consequence><p> //Maintainer code is optional and can be empty</p><br></br> ruleMessage.markRuleAsValid("if_maintainer_code_then_checksum_digit_valid");<br></br> </java:consequence><br></br></rule><p><rule name="if_maintainer_code_then_checksum_digit_valid_OPTIONAL_NULL"></p><br></br> <parameter identifier="clinic"><br></br> <java:class>br.atech.smssp.domain.clinic.vo.ClinicVO</java:class><p> </parameter></p><br></br> <java:condition>clinic.getMaintainerCode() == null</java:condition><br></br> <java:consequence><br></br> //Maintainer code is optional and can be NULL<br></br> ruleMessage.markRuleAsValid("if_maintainer_code_then_checksum_digit_valid");<br></br> </java:consequence><br></br></rule>
这个例子中,保存某诊所数据的时候,服务类会调用 Drools 规则引擎检查和认证所有的规则,数据只有符合所有的规则才能被储存。这些认证规则随着政府制定的规章制度的修改而变化。由于修改经常发生,所以把它们和应用程序代码划分开来非常重要,这样才能在维护和修改程序的时候不需要重启整个应用系统。
教训与启发
代码生成在这个项目中是成功的关键,它不但提高了开发人员的生产率,同时也保证了代码风格一致。在 50 个开发人员在各个独立团队中各自工作且共享基础组件的情况下,使他们保持代码一致性非常困难。起初采用 XDoclet,然而好景不长,由于生成文件时间越来越长,对开发速度带来出乎意料的坏影响。之后,转而采用注释策略,才大大地减短了生成时间,真正体现了生成代码的优越性。
好的团队交流是必须的。特别是像这样的大项目,总会有些时候有些人发表不同的意见。让所有的人互相理解对方的观点实在很难。特别是在这样一群人中,交流真的是让人头疼的难题:
- 开发者 VS. 构架师——你不得不吃你自己的狗食。当构架师积极参与到开发工作中来的时候,他们可以提出更好的建议,提高生产率。同时团队的“开心指数”也相对得到提升,同样也影响到整体的开发能力。
- 需求分析者/客户 VS. 开发人员/构架师——这两组人交流的失败可以直接导致项目的推迟并让所有人灰心丧气。好的规格说明是必要的,但要这两组人理解双方的动机,仅靠一个好的用例说明来使他们融洽得像一个团队那样工作简直是不可能的。
- 出类拔萃的开发人员 VS. 其他出类拔萃的开发人员——这个项目中有个开发小组,拥有非常优秀的开发人员,他们中的每个人都有满脑子的好点子来开发软件。然而在短时开发周期中,这个组的组员每天都冒出一些无法验证可能性的改革性的主意,这个小组因此受到一些挫折。其实在像这样的项目中,重要的是坚持定义严谨的终极目标,并使每个人都能正确理解作出每个决定的理由。这个开发小组后来采取间断性的重构周 (refactor),在这些重构周里,组员提出系统中存在的问题,讨论如何解决这些问题并在这段时间里对系统进行重构。整个星期都只作重构,没有任何新代码的开发,只用来修改原有代码使之更便于实现未来将冒出来的新点子而提高工作效率。
如果你的构架定义确切,有规则、有代码生成、套用设计模式、构件的话,就比较容易进行小范围或中等程度的软件重构,大范围重构往往很麻烦。然而,现实系统的部分构架不够确切,严格的规则更使得 20%的应用程序那以实现。明确你究竟能够在加强构架标准这点上走多远至关重要。项目开发过程中,有时候就算一些解决方案显然不是最好的,开发人员也只是强行将它们向构架靠拢,而非重新探讨最佳方案。另一方面,系统中某些部分,规则要求并不严格,比如说用户界面,对于这些部分,开发人员则各取其爱,以至于造成系统这些部分质量较差、代码不易管理等预料未及的坏结果。
在庞大的应用程序中,面对繁杂的依赖性和代码,开发周期往往不得不延长。有时候,在某个界面上加上一个简单的域 (field) 就意味着系统的许多部分都随之需要修改,这很费时间。无论是开发人员还是客户都因此感到头痛,工具变得太过沉重以至于所有一切都比原本更花时间。把应用程序划分成许多构件,还是不足以彻底解决问题。尽管 J2EE 有很多优点,但同时代码也更加复杂,生产率还是受到影响,这些很难跟管理人员和客户解释清楚。
项目启动最初,很多人都说按照那样的短时开发周期,项目根本没有办法完成。在其他一些国家的公共医疗保健系统开发过程中,也都发生过太多太多令人汗颜的经验。所以,从这个项目中得到的最后一个启示是:当别人说某件事不可能的时候,千万不要让他们这样的泛泛之说影响了你去将这件事变得可能。
未来方向
容器外测试
无法进行容器外测试让开发团队很困扰,无法在初期就进行容器外测试的原因是他们被迫从合法的代码开始工作。政府制定的规则和规章条例模块是一个巨大的 EJB 1.0 系统。结合代码生成技术来处理所有的实例 bean 和其他容器假象 (container artifact),程序的新生部分变的越来越基于 POJO。但将他们部署到容器中以后,甚至只是一个渐进变化也缓慢到开发人员无法接受的地步。
迁移到基于 POJO 的构架
目前的系统明显有大量的容器依赖和 EJB 样板代码 (plumbing code)(尽管大部分都隐藏在生成的业务委托和会话外观中),但开发团队计划的迁移方向是完完全全基于 POJO,包括业务规则,他们计划将所有的会话 Bean 重构成一个单一的会话 bean interceptor,由这个单一的会话 bean interceptor 专门向服务层调用。构建这个单一的会话 bean interceptor 的目的在于提供像事务 (transaction) 和线程管理这样的中间件服务。他们原先设想在修改之后的构架上采用 Spring,但这注定开销太过昂贵。鉴于当时拥有的会话 bean 都已经全部生成,相比较从 EJB 中完全引身而退来说,修改代码生成逻辑来贯彻会话 bean interceptor 明显简单得多。将这样的重构变得可能的另一个元素是现存的业务委托层可以将这些大幅修改从 Web 端屏蔽。
为什么他们没有一并摆脱掉 EJB 呢?理由是最后他们必须将应用程序展示给在医疗卫生部门的另外的团队,这些团队需要访问该系统,并正准备简单地让工作人员(从圣保罗市)接手开始投入使用可行的业务委托。由于业务委托隐藏了远程的各方面,他们知道合作方将不会工作于 Java 上,所以预期系统集成会非常顺利。
不同的事务类型又将如何处理呢?不同的事务界限有不同的方法,目标方法和事务类型间有一个映射,所以他们知道该执行哪个方法来处理相应的事务。顺便提一句,这里的会话 Bean interceptor 和事务策略同 Floyd Marinescu 在 EJB 设计模式一书中描述的极为相近。 :)
最终,他们实在是想摆脱掉实例 Bean,也许甚至想直接只保存值对象和 Hibernate。面对这样修改的潜在可能性最大花销是测试,他们必须彻彻底底地测试系统的每个方面,单一的单元测试无法覆盖全部。
AJAX 简化 Web 用户界面
AJAX 被公认是为非技术终端用户简化用户界面工作流程的有效工具。就如在调度用例中,自动补全 (auto-completion) 可以帮助输入医生姓名,疗程名称,专科名称,实在没有理由去调用弹出窗口来做查询,从长长的列单中去寻找这些名字。另外,在使用大而冗长的表单时,使用 AJAX 一步一步将表单域的值保存到 HTTPSession 中去无疑会更好,还能防止一些用户因不小心错关页面而遗失所输入的信息。
最近 AJAX 也被部署项目中,被用来浏览一个需要读取数据的巨大的流程树,帮助我们在缩短了数据录入时间的同时也给终端用户提供更漂亮的界面。点击这里查看界面截图。
评论