J2EE开发全程实录


《J2EE 开发全程实录》 内容介绍: J2EE 是目前企业级软件开发的首选平台。本书从架构的角度讲解了一个完整的 J2EE 系统的搭建。内容包括:正则表达式、JSP、Swing、XML 等技术在实际中的应用;Spring、 Hibernate、Struts 等开源框架的实战性应用;MDA、敏捷开发等理念在实际开发中的应用; 如何搭建一个高度可扩展的系统。 作者简介: 杨中科,毕业于山东大学物流工程专业,曾任职于国内某 ERP 公司,现专注于开源技 术的研究与推广,是 CowNew 开源团队的发起人之一。作者 blog: http://www.blogjava.net/huanzhugege 《J2EE 开发全程实录》在线试读地址:http://book.csdn.net/bookfiles/427/ 《J2EE 开发全程实录》ChinaPub 订购地址: http://www.china-pub.com/computers/common/info.asp?id=35167 目录 第 1 章 正则表达式.... 1 1.1 为什么要用正则表达式... 1 1.2 正则表达式入门... 3 1.2.1 正则表达式中元字符的用法... 4 1.2.2 Java 中的正则表达式 API 5 1.2.3 java.util.regex 的使用... 6 1.3 实战正则表达式... 8 第 2 章 程序最优化.... 14 2.1 空间与时间... 14 2.1.1 空间与时间的概念和度量... 14 2.1.2 空间与时间的背反... 15 2.1.3 以空间换时间... 15 2.2 字典、哈希与 Map. 19 2.2.1 字典的定义... 19 2.2.2 哈希表与哈希方法... 19 2.2.3 冲突与冲突的解决... 20 2.2.4 Java 中的 Map 接口... 20 2.3 HashMap. 21 2.3.1 应用举例... 21 2.3.2 Map 与 HashCode. 26 2.4 使用缓存... 29 2.4.1 缓存的概念... 29 2.4.2 LRUMap 类... 30 第 3 章 AOP. 33 3.1 AOP 概论... 33 3.2 AspectJ. 35 3.3 Spring AOP. 36 3.3.1 实现 Advice. 36 3.3.2 编写业务代码... 37 3.3.3 装配 pointcut 和 advice. 38 3.3.4 运行主程序... 39 3.4 动态代理... 40 3.4.1 CGLib. 40 3.4.2 JDK Proxy. 42 第 4 章 Java 平台下的 Web 开发.... 48 4.1 标记语言... 48 4.2 自定义标记库的开发... 48 4.2.1 Tag 接口的生命周期... 49 4.2.2 hello 标记的开发... 50 4.2.3 flash 标记的开发... 52 第 5 章 案例系统需求.... 58 5.1 基础系统... 58 5.1.1 系统用户管理... 58 5.1.2 编码规则管理... 59 5.2 基础资料... 60 5.2.1 人员管理... 60 5.2.2 供应商管理... 61 5.2.3 客户管理... 62 5.2.4 计量单位管理... 62 5.2.5 物料管理... 63 5.3 业务单据... 64 5.3.1 入库单... 64 5.3.2 出库单... 66 5.3.3 盘点单... 68 第 6 章 基于 Spring 的多层分布式 应用.... 71 6.1 概述... 71 6.2 Spring Remoting. 72 6.2.1 Hessian 使用演示... 72 6.2.2 几种 Remoting 实现的比较... 75 6.3 改造 HttpInvoker 75 6.3.1 服务文件的分模块化... 82 6.3.2 本地服务加载器... 85 6.4 Remoting Session 实现... 87 6.4.1 实现思路... 88 6.4.2 Session Id 的生成... 88 6.4.3 用户信息的保存... 93 6.4.4 维护管理 Session. 95 6.4.5 Session 的注销... 97 6.4.6 安全问题... 100 第 7 章 元数据引擎.... 102 7.1 MDA 概述... 102 7.2 关于元数据... 104 7.2.1 元数据示例... 105 7.2.2 元元数据... 108 7.2.3 设计时与运行时... 108 7.2.4 元数据设计的基本原则... 109 7.2.5 此“元数据”非彼 “元数据”... 109 7.3 实体元数据... 110 7.3.1 实体元数据格式... 110 7.3.2 元数据编辑器... 113 7.4 元数据引擎设计... 118 7.4.1 实体元数据运行时模型... 118 7.4.2 分包及命名规范... 119 7.4.3 元数据加载器接口... 120 7.4.4 元数据热部署... 121 7.4.5 元数据部署方式... 121 7.5 元数据引擎实现... 122 7.5.1 根据元数据路径加载 元数据... 122 7.5.2 元数据枚举器... 122 7.5.3 元数据缓存... 125 7.5.4 元数据加载器... 126 7.5.5 工具类... 132 7.5.6 待改进问题... 133 第 8 章 基础类与基础接口.... 135 8.1 异常处理... 135 8.1.1 异常处理的方式... 135 8.1.2 为异常“脱皮”... 140 8.1.3 枚举异常... 141 8.1.4 异常处理器... 146 8.2 工具类... 147 8.2.1 枚举... 147 8.2.2 资源管理工具类... 149 8.2.3 DateUtils. 149 8.2.4 StringUtils. 150 第 9 章 数据访问基础服务.... 151 9.1 多账套的实现... 151 9.1.1 配置文件的支持... 151 9.1.2 账套管理器... 154 9.2 线程变量管理器... 157 9.2.1 ThreadLocal 类... 157 9.2.2 线程变量管理器的实现... 159 9.3 事务... 163 9.3.1 为什么需要事务... 163 9.3.2 什么是事务... 164 9.3.3 事务的边界划分... 164 9.3.4 声明型事务的属性... 166 9.3.5 事务的隔离... 168 9.3.6 事务的隔离级别... 168 9.3.7 不同隔离级别的差异... 169 9.3.8 Spring 的声明型事务... 169 9.3.9 改造 Spring 事务配置方式... 172 9.4 会话服务的生命周期管理... 175 9.5 IValueObject 接口... 178 第 10 章 层间数据传输.... 180 10.1 什么是 DTO.. 180 10.2 域 DTO.. 181 10.3 定制 DTO.. 186 10.4 数据传送哈希表... 188 10.5 数据传送行集... 189 10.6 案例系统的层间数据传输... 191 10.7 DTO 生成器... 192 10.7.1 生成器接口定义... 193 10.7.2 Hibernate 的元数据... 197 10.7.3 HibernateDTO 产生器... 200 10.7.4 通用 DTO 生成器... 207 第 11 章 基于 AOP 技术的日志系统 和权限系统.... 211 11.1 日志系统... 211 11.1.1 日志系统的设计目标... 211 11.1.2 日志记录元数据... 212 11.1.3 日志拦截器... 214 11.2 权限系统... 217 11.2.1 RBAC.. 218 11.2.2 用户模型... 219 11.2.3 权限拦截器... 222 11.2.4 取得系统中所有的权限项... 225 第 12 章 基于 Hibernate 和 JDBC 的 持久层.... 229 12.1 ServiceBean 基类... 229 12.1.1 IBizCtrl 与 BizCtrlImpl 229 12.1.2 IBaseDAO 与 BaseDAOImpl 230 12.2 SQL 翻译器... 238 12.2.1 数据库差异比较... 239 12.2.2 LDBC.. 240 12.2.3 SwisSQL. 240 12.2.4 CowNewSQL. 241 12.2.5 案例系统 SQL 翻译器的 选择... 243 12.2.6 SQL 语句的缓存... 243 12.2.7 LDBC 异常信息的序列化 问题... 243 12.3 SQL 执行器... 246 12.3.1 SQL 执行器服务接口... 246 12.3.2 CachedRowSet 248 12.3.3 直接执行 SQL 对 Hibernate 的 影响... 248 第 13 章 Swing 客户端主框架.... 253 13.1 登录服务与远程服务定位器... 253 13.1.1 登录接口... 253 13.1.2 密码的保存... 254 13.1.3 通用服务... 257 13.1.4 客户端配置... 259 13.1.5 远程服务定位器... 259 13.2 系统登录... 263 13.2.1 对话框信息的保存和加载... 263 13.2.2 未捕获异常的处理... 265 13.2.3 登录对话框... 266 13.2.4 客户端入口... 269 13.3 基于 Panel 的 UI 框架... 271 13.3.1 UIPanel 271 13.3.2 界面容器... 273 13.3.3 UI 工厂... 277 13.4 主界面与可配置式菜单... 279 13.4.1 主界面... 279 13.4.2 可配置式菜单... 281 13.4.3 主菜单管理器... 284 13.4.4 主界面菜单初始化... 287 第 14 章 Swing 客户端基础类.... 291 14.1 常用 Swing 控件... 291 14.1.1 JTextComponent 291 14.1.2 JTextField. 292 14.1.3 JFormattedTextField. 294 14.1.4 JPasswordField. 295 14.1.5 JScrollPane. 295 14.1.6 JProgressBar 296 14.1.7 JList 296 14.2 JTable 的使用及扩展... 301 14.2.1 基本用法... 301 14.2.2 隐藏表列... 304 14.2.3 单元格渲染器... 304 14.2.4 单元格编辑器... 308 14.2.5 导出到 Excel 312 14.3 数据选择器... 319 14.3.1 自定义布局管理器... 320 14.3.2 数据选择器视图... 322 14.3.3 文件选择器... 326 14.3.4 日期选择器... 328 14.3.5 数据库数据选择器设计... 330 14.3.6 数据选择对话框... 336 14.3.7 数据库数据选择器... 339 第 15 章 客户端数据维护框架.... 343 15.1 功能描述... 343 15.2 列表界面... 346 15.2.1 数据显示及分页支持... 347 15.2.2 增删改查... 351 15.3 编辑界面... 360 15.3.1 UIDataBinder 360 15.3.2 TableDataBinder 367 15.3.3 EditUI 371 15.4 过滤界面... 376 15.4.1 界面布局... 377 15.4.2 过滤方案持久化... 377 15.4.3 排序规则相关类... 380 15.4.4 系统预设条件面板接口... 384 15.4.5 FilterUI 实现... 386 第 16 章 Web 客户端框架.... 394 16.1 Web 端部署方式与相关辅助类... 394 16.1.1 SessionId 的存储... 394 16.1.2 Web 端应用服务定位器... 396 16.1.3 Web 端元数据加载器 工厂... 397 16.2 登录界面... 398 16.2.1 登出系统... 403 16.2.2 心跳页面... 404 16.3 主页面和主菜单... 405 16.3.1 菜单配置文件... 407 16.3.2 菜单控件... 412 16.4 数据选择器... 415 16.4.1 HTML 中的模态对话框... 416 16.4.2 表格的行选效果... 418 16.4.3 数据库数据对话框... 420 16.4.4 数据库数据选择器标记... 427 16.4.5 日期选择对话框... 430 第 17 章 应用系统开发.... 433 17.1 日志监控和权限管理... 433 17.1.1 日志监控界面... 433 17.1.2 用户管理接口... 435 17.1.3 用户管理列表界面... 439 17.1.4 用户新增界面... 444 17.1.5 Web 端修改密码... 449 17.2 用户自定义编码规则... 452 17.2.1 编码规则的持久化... 455 17.2.2 产生编码... 456 17.3 查询分析器... 460 17.3.1 生成建库 SQL. 461 17.3.2 实体检索... 465 17.3.3 客户端界面... 468 17.4 WebExcel 473 17.4.1 Excel 的解析... 473 17.4.2 处理文件上传... 474 17.5 客户基础资料开发... 478 17.5.1 数据校验器... 478 17.5.2 客户基础资料开发... 485 17.6 计量单位基础资料开发... 489 17.6.1 计量单位组的服务器端 实现... 492 17.6.2 计量单位列表界面... 496 17.7 库存业务单据... 502 17.7.1 入库单建模... 502 17.7.2 服务端接口及实现... 503 17.7.3 入库单编辑界面... 509 17.7.4 入库单列表界面... 513 17.7.5 入库单过滤界面... 517 17.8 库存 Web 报表... 523 17.8.1 报表服务接口及实现... 523 17.8.2 报表的编辑... 527 17.8.3 报表的打印... 530 17.8.4 打印控制按钮标记... 531 17.8.5 库存流水账... 533 17.8.6 销售排行榜... 538 前言 现在大部分软件开发书籍都是讲解某个技术如何使用,很少有讲实战的,即 使有实战案例的讲解,也是讲解网上购物、聊天室之类已经被人写烂了的系统的 开发,最可怕的是书中的实现代码惨不忍睹,使得读者很容易被误导,至于如何 进行合理的架构设计就更是无从谈起;少数从国外引进的高端技术书籍又大谈特 谈各种在天上飞来飞去的理论,“看的时候心潮澎湃,看完之后一脸茫然”,读 者不知道如何将这些理论应用到实际的开发过程当中。本书就尝试着打破这种局 面,把一个真实的案例系统搭建从头讲起,不仅包含具体的实现技术,也包含一 些架构方面的设计思想。 这是一本以 Java 开发语言为载体来讲解企业级信息系统开发的书,其中涉 及到了 Hibernate、Struts、Spring、JSP、Swing、JDBC 等很多技术,而且案例 系统在搭建过程中也较合理地使用了面向对象理念进行系统设计,但是书中不可 能全面讲解这些技术的细节知识,读者可以根据需要参考与这些技术相关的资 料。 在本书的序言中介绍开发框架等的概念;第 1、2、3、4 章介绍正则表达式、 AOP、自定义 JSP 标记等基础知识;第 5 章给出案例系统的需求文档;第 6 章基 于 Spring 技术搭建案例系统的 Remoting 部分;第 7 章构建一个基于 MDA 理念的 元数据引擎;第 8 章对案例系统中用到的枚举异常类、工 具 类 等 进 行 介绍;第 9、 10、11、12 章基于 Spring、Hibernate 等技术搭建事务、DTO 生成器、权限控制、 日志记录、多数据库支持等基础模块;第 13、14 章开发登录服务、Swing 客户 端基础模块以及数据选择器等自定义 Swing 控件;第 15 章实现列表界面、编辑 界面和编辑界面的基类;第 16 章搭建 Web 客户端的登录界面、主菜单等基础模 块,并开发 JSP 用的数据选择器等自定义标记;第 17 章则以前面章节搭建出的 框架为基础实现第 5 章中的需求文档所要求的功能。 在此,我要感谢为这本书的诞生给予我帮助的所有人。首先要感谢父母对我 的养育之恩,他们在我辞职写书的过程中对我无微不至的帮助更是让我永远不能 忘记;其次要感谢冯仁飞、刘培德、杨勇、戴敬、张洌生等同事对我的帮助和指 导;此外还要感谢 CowNew 开源团队的朋友们(特别是 KingChou 的执着精神很值 得我学习);最后我要感谢清华大学出版社的彭欣编辑,她给我的帮助使得我们 的合作非常圆满,使得本书能够顺利地完成创作和出版。 相对于业界很多高手来说,我的水平是很有限的,无论在实战方面还是在理 论知识方面都还有不少差距,希望读者不吝指教,以便再版时改进,您可以给我 发送邮件:about521@163.com,与本书相关的后续资料将会发布到 CowNew 开源 团队的网站(http://www.cownew.com)中。 《J2EE 开发全程实录》序 JDK 中很多类的用法我都烂熟于胸了;我已经能够使用 Struts+Hibernate 做出一个像样的论坛,公司很多人都称我是 Hibernate 高手;我做过很多上千万 的大项目;我有多年的编程经验,我写的代码很多人看了都叫好;我曾经用过 D elphi 三年,写过很多小程序,什么远程监控呀、API 劫持呀、木马呀,Window s 的 API 里边藏着不少好东西呀,Delphi 的控件也真是很丰富;我还研究了 C#, 用 C#的 WebForm 做东西真是方便呀;对了,我目前正在研究很火的 AJAX 技术! 可是……我是软件工程师吗? 1. 重剑无锋、大巧不工 很多开发人员做了一段时间开发以后经常琢磨着怎么写出精彩、巧妙的程序 来,所以在程序中使用了大量的技巧,并引以为自豪,还被同事夸奖:“真是高 手呀,人家独辟蹊径用这种方法解决的问题,咱都看不懂,惭愧!” 可是这样的技巧真的很好吗? 我曾经接手过一段代码,这段代码据说是一位前辈高人留下的,这段代码经 常出现 Bug,多少人接手过,每次试图修改 Bug 时就会引来更多的 Bug,被人称 为一块硬石头。我读过了这段代码以后倒吸了一口凉气,这确实是高人写的代码, 里边嵌套了 5 层循环,数据在界面和数据库之间加载来保存去很多次,并且用了 巧妙的方式拦截了一个框架 API,实现了用很复杂的代码才能实现的功能,我读 了一上午,并且用了两天时间去改 Bug,可谁知仍然是越改 Bug 越多。一气之下 我把原来的代码都删掉了,然后用最普通但是比较笨的实现方式重新实现,从 此 以 后这个功能很少出现 Bug,接手的人也再没有抱怨过代码难懂。 这件事让我想起我上学时候的一件事。大四的时候我在一个小软件公司兼 职,当时做的是一个呼叫中心系统。我用了很多控件的高级特性进行开发,开发 速度之快让项目经理惊讶不已,一个劲地夸我是高手,所以当功能做得差不多的 时候他就放我去北京求职了。晚上 12 点,我正在北京的一个旅馆中整理第二天 去招聘会所需要的资料的时候,项目经理的电话打了过来:“兄弟,这边你做的 程序有一个间歇性 Bug,现在程序跑不起来了。我让另外一个兄弟帮着改一下你 的程序,他说看了半天也不知道你是怎么实现的。”我当时心里听了特别自豪, 于是乎在电话中很骄傲地给改我程序的那个兄弟讲我是怎么实现的。后来这个程 序又出现了一些问题,都是只有我才能搞定,以至于当我毕业要离开那个城市的 时候,项目经理苦着脸对我说:“兄弟,你走了以后谁来维护你的程序呀,当初 真应该让你用简单一点的技术做呀。”当 时 听 了 他这句话我心里仍然是美滋滋的, 可现在想起来却好难过。 在软件开发领域中,技巧的优点在于能独辟蹊径地解决一些问题,缺点是技 巧并不为大众所熟知。若在程序中使用太多的技巧,可能会造成别人难以理解。 一个技巧造就的一个局部的优点对整个系统而言是微不足道的,而一个别人看不 懂、改不了的 Bug 则可能是致命的。在团队协作开发的情况下,开发时应该强调 的一个重要方面是程序的易读性,在 能 够 保 证 软件的性能指标且能够满足用户需 求的情况下,必须让其他程序员容易读懂你的程序。 编程时不可滥用技巧,应该用自然的方式编程,简洁是一种美。不会解决问 题的人尽管是“菜鸟”,但是这种人破坏力很小;而把简单的问题用复杂的方式 解决的人是“半瓶子醋”,这种人破坏力极强,就像当年的我一样!只有能够把 复杂问题用简洁而又直接的方式解决的人,才有望成为高手;所以现在我们更愿 意看到写得朴实无华,没有什么闪亮词汇的代码。 2. 框架与工具箱 经常听到有人说“我用某某框架做了一个东西”、“我开发了一个实现某某 功能的框架”,那么到底什么是框架呢? 用《设计模式》一书中的定义来说就是:“框架(Framework)是构成一类特 定软件可复用设计的一组相互协作的类。……框架规定了你的应用程序的体系结 构。它定义了整体结构,类和对象的分割,各部分的主要责任,类和对象怎么协 作,以及控制流程。”框架实现了对具体实现细节的反向控制(IOC),实现者无 需考虑框架层已经实现好的设计,只要按照框架的要求开发就可以了,然后把开 发好的东西放到框架中就可以运行。 以 Java Web 开发而言,任何人都知道 MVC 的架构方式比传统的 Model1 方式 更容易管理和维护,可是在实际开发中很多人又禁不住 Model 这种简便开发方式 的诱惑。那么如何强迫自己使用 MVC 模式呢?当然是使用 MVC 的开发框架了,比 如 Struts。采用 Struts 后必须写一个 JSP 作为 View,必须从 Action 继承来实 现 Control,必须从 ActionForm 继承来实现 Model,框架约束住了开发人员那充 满幻想的大脑,让我们以最佳的设计策略进行开发。 尽管框架中经常包含具体可用的子类或者工具包,但是使用框架的好处是可 复用设计而非复用实现,使用框架时,我们无需考虑一些设计策略问题,因为我 们已经无形中使用了框架设计好了的“最佳实践”。 与框架相对应的是工具箱(Toolkit)。工具箱是预定义在类库中的类,它是 一组相关的、可 复 用的、提供了通用功能类的集合。我们经常使用的 ArrayList、 FileOutputStream、CGLib、Dom4j 等都是工具箱。这些工具箱不强迫我们采用 某个特定的设计,它们只是提供了功能上的帮助,所以说工具箱的作用是实现复 用(或者称代码复用)。 在 Java 中有一种特殊的类叫做工具类(Utils),比如 java.lang 包中的 Mat h、Commons- BeanUtils 中的 BeanUtils。这些类并没有封装任何状态在里边, 也不允许调用者实例化它们,它们只是暴露了一些静态方法供其他类调用。这种 类在其他支持函数库的语言里(比如 C++、Delphi)被称为“伪类”,因为这些类 没有封装任何东西,没有自己的状态,只是一堆函数的集合而已,原本是应该被 坚决杜绝的东西,但是由于 Java 不支持函数库,所以才允许它们的存在。很多 人都批评说这些类是面向过程的东西,不是真正的面向对象。但是不得不承认的 是,Java 这样的面向过程与面向对象的混合产品正是工业级开发所需要的,面 向过程与面向对象并不冲突,否则那些真正的面向对象语言为什么一直还生活在 学院派的温室里面呢? 3. 再次框架 “再次框架”是我想使用的一个词汇,意思是在现有的框架基础上为了实现 更多应用层次的设计复用而进行的框架设计。 很多人认为使用 Struts、Spring 这样的框架开发就是基于框架开发了,就 是最好的设计了,岂不知这些框架只是解决了大部分通用的问题,很多具体实现 上的问题还需要进行框架设计,否则做出来的东西仍然是难以理解、难以复用、 难以扩展的。很多人基于某些著名框架写出来的论坛、网站的源代码里实际上充 斥着大量重复的代码、糟糕的设计,这些东西确实应该被好好地“再次框架”了。 4. 企业框架 很多企业都建立了自己的一套或自用或开放的框架,比如 SAP 的 Netweaver、 用友的 UAP、金蝶的 BOS、浪潮的 Loushang、上海普元的 EOS 等。开发这些框架 是需要大量的资金和人力资源的投入的,但是带来的如下好处也是非常明显的。 (1) 模块复用 在企业信息系统中很多模块是通用的,比如权限管理、组织架构管理,企业 框架把这些模块抽取出来,这样具体业务系统的开发者就可以专心于具体业务功 能的实现。 (2) 提高产品开发效率 企业框架通常都提供了很多通用的工具箱或者辅助开发工具,业务系统的开 发者使用这些工具箱或者辅助开发工具可以用最少的工作量在最短的时间内完 成功能开发。试想如果做第 1 个功能和做第 1000 个功能耗时耗力一样多的话, 那要这个框架有什么用呢? (3) 保证业务系统使用了最佳实践 企业框架常常采用了非常合理的设计,如何取得远程接口、在哪个地方进行 数据校验、如何储存数据等一系列问题都已经被设计好了,开发者只要按照框架 的要求进行设计,就可以保证开发出来的产品是设计合理的。 (4) 对知识管理很重要 企业框架集中了公司所有软件项目的共同点,集中了对于公司最重要的知识 的精华,随着框架的应用,框架本身也会随之升级优化。一个新加入企业的员工 只要理解并掌握了这个框架,就可以很好地融入到团队中来;而离职的人员也已 经把自己的知识留在了这个框架中。 (5) 提高产品的一致性 此处的一致性包含两个方面,一个是产品功能的一致性,另一个是产品实现 的一致性。功能的一致性主要指产品的界面、操作方式等的一致性,这保证了用 户可以很容易地学习和使用系统的所有模块;实现的一致性主要就是规范产品的 实现方式。 开发人员是聪明且富于想象力的,不同的开发人员写出来的程序具有个体差 异性。这里举一个例子。 有一个界面的功能是:用 户 可 以 往 这 个 界 面 中 输 入人员的姓名、年龄、性 别 、 地址等信息,然后单击“保存”按钮保存到数据库中。 如果没有一个统一的框架,那么开发人员就可以随意发挥了:有人会在单击 “保存”按钮的时候去逐个读取控件的值,然后通过 JDBC 执行一个 Insert 语句 把数据保存到数据库中;有人会使用开源的数据绑定框架把控件与数据库的字段 绑定起来,然后让数据绑定框架处理数据的保存;有人会先为人员建立一个 hib ernate 配置文件,然后逐个读取控件的值填充到值对象中并调用 session.save ()方法把数据保存到数据库。 开发人员的这种随意发挥是项目非常大的一个风险,如何保证离职人员的代 码能迅速地被接手人读懂是非常重要的一个问题,如 果 这 么 一个小小的功能就有 这么多种实现方式的话,较大规模功能聚合中将面临的问题就更是可想而知了。 而使用特定的开发框架以后呢?对于上述界面功能而言,系统规定只要覆盖 父类的 initDataBind 方法,并在方法里注册界面控件与数据库字段的绑定关系 就可以了,其余的如何连接数据库、如何保存都由框架处理了。相信这样做的话 接手的人员几乎不用看代码就能知道程序是如何实现的。 采用企业框架带来的一个主要变化就是开发人员可随意发挥的余地小了,必 须在框架的约束下进行开发,无法在开发过程中体现自己的“高超本领”。从提 高管理效率角度来说,软件企业应当欢迎这种变化;而另一方面,企业中具有挑 战新技术激情的优秀开发人员尚可针对企业框架不断地实施改进和完善工作,为 企业的技术路线注入新的活力。 编辑推荐序言 CowNew 的指导思想是为软件公司的 J2EE 开发提供性能优良的框架方案。他 (杨中科)在书序中写道:“再次框架”是我想使用的一个词汇,意思是在现有的 框架基础上为了实现更多应用层次的设计复用而进行的框架设计。 很多人认为使用 Struts、Spring 这样的框架开发就是基于框架开发了,就 是最好的设计了,岂不知这些框架只是解决了大部分通用的问题,很多具体实现 上的问题还需要进行框架设计,否则做出来的东西仍然是难以理解、难以复用、 难以扩展的。很多人基于某些著名框架写出来的论坛、网站的源代码里实际上充 斥着大量重复的代码、糟糕的设计,这些东西确实应该被好好地“再次框架”了。 杨中科的“再次框架”思想是针对当前大量 Java 图书中的低水平开发方式 而提出的。从软件工程的角度出发,像 Java 这样的完全面向对象的编程语言, 最适合以可积累的方式来从事任何开发工作。无论是水平高超的个人还是企业都 有必要以比较稳妥的方式来保存前期的工作成果。此外任何普通程序员若想从蓝 领地位跃进到设计师的高度,他就必须转换思维方式,从 习 惯 性的蓝领思维模式 变化到经常能够思考设计师所关注的问题。所以仅仅学会使用现有的各种框架是 不够的,那些是包装严密的“别人的框架”。还应该学会“再次框架”——即一 切从企业的需要出发,以各种已有的框架为工具,把许多复杂的工作、频繁重复 的简单工作统统地包装起来,构建出企业自己的框架系统,这种“再次框架”所 产生的系统无疑对于提高企业的管理水平是最有帮助的。 具体地展示企业开发过程中的实战技术 可以这样说:《J2EE 全程开发实录》是国内 Java 人士对 J2EE 研究的具有里 程碑意义的一部作品。 此书的写作根由作者总结得最为确切: 现在大部分软件开发书籍都是讲解某个技术如何使用,很少有讲实战的,即 使有实战案例的讲解,也是讲解网上购物、聊天室之类已经被人写烂了的系统的 开发,最可怕的是书中的实现代码惨不忍睹,使得读者很容易被误导,至于如何 进行合理的架构设计就更是无从谈起;少数从国外引进的高端技术书籍又大谈特 谈各种在天上飞来飞去的理论,“看的时候心潮澎湃,看完之后一脸茫然”,读 者不知道如何将这些理论应用到实际的开发过程当中。本书就尝试着打破这种局 面,把一个真实的案例系统搭建从头讲起,不仅包含具体的实现技术,也包含一 些架构方面的设计思想。 通过阅读此书可以发现,原来架构设计并非高不可攀,一些极为普通的、琐 碎和细微的工作均可借助于框架设计思想而获得简化。得益于作者在企业中已有 的实践经历,在此方面该书对案例系统搭建过程的描述的确令人大开眼界。 本书所讲述的实战技术并非关注于技巧(这与许多书不同),而是以分析框架 需求为核心通过 CowNew 的设计来体现提高开发效率的过程。从这一点来看,Co wNew 所提供的框架不是封闭的、简单地供一般程序员编程时引用的,而是完全 敞开,供与广大读者共同研究的,CowNew 的框架是以培养框架思维为目的的、 特殊的开放式框架。 在开发中以企业框架来实现再次框架的工作本书的重要价值是向广大 Java 学习者传达了关于企业真实需求的信息。企业开发是一种高度有组织化的劳动, 要求以很高的效率来完成,并不懈地追求程序代码的可重用性,要求以简单、规 范、易于管理的方式开展工作。而企业框架恰好体现了这种要求,CowNew 开源 团队站在全局的高度审视国内软件开发企业,承诺联手共建优秀企业框架的责 任。 关于依照企业框架思路开展工作时的特征,作者写道: 采用企业框架带来的一个主要变化就是开发人员可随意发挥的余地小了,必 须在框架的约束下进行开发,无法在开发过程中体现自己的“高超本领”。从提 高管理效率角度来说,软件企业应当欢迎这种变化;而另一方面,企业中具有挑 战新技术激情的优秀开发人员尚可针对企业框架不断地实施改进和完善工作,为 企业的技术路线注入新的活力。 由此可见企业框架是软件企业核心竞争力的体现,是优秀的开发人员着手开 展工作的基础和前提。这种认识不但对广大 Java 学习者具有指导意义,而且对 于很多企业培训内部人才和提高管理水平也是十分重要的。基于这样的成熟认 识,中国的企业有理由通过不断地积累精华资源,从积累中发挥出自己的优势。 杨中科的《J2EE 全程开发实录》配书光盘也令我们开眼界。 他把 CowNew 框架下的企业开发活动,即蓝领的实际劳动过程用录像加解说 的形式表现出来。整整一个多小时。他用 Eclips 为工具,先做服务器端,然后 做 Swing 客户端,然后做 Web 客户实现。一边说,一边敲代码,喔快捷提示与 J Builder 同样丰富,而且也能以可视化方式布局控件,如按钮、文本框、图片框 等。给我们印象最深的,是他不时地发现自己编码有错误、未完善。然后他就从 error 输出中实时地判断问题的根源,马上进行完善。再运行。通了。之所以在 编程的过程中感到得心应手,是因为本书提供了丰富的避免重复开发的技巧(做 成 CowNew 的包)。然后蓝领每用一个企业功能,就把 CowNew 的包引用一下。有 趣的是,本书从头到尾都在演示 CowNew 的框架设计思想(是设计师的工作),然 后作者又在光盘里充当蓝领现身说法。光盘中已经囊括了书中引述的全部 CowNe w 框架包,以及全部相关源代码。当然读者还可以直接访问 CowNew 的网站(http: //www.cownew.com)与作者本人直接交流。 本书由清华大学出版社资深责任编辑组稿、加工。编辑本身对 Java 等编程 语言有很好的了解和必要的实践经验,在 加 工、校对过程中不敢有一丝一毫的疏 忽(此书的创作成果最初经过与以往大量 Java 图书比较,已认定为精品)。任何 编辑、修改之处均已反馈给作者杨中科本人亲自进行了核实。编辑对作者十分认 真的工作态度深感敬佩。 衷心希望本书的出版能够进一步促进国内的 Java 学习向更高水平迈进。期 待未来国产好书更加精彩,使读者倍加欢迎。期待我国的软件开发竞争力登上新 的台阶。 第 6 章 基于 Spring 的多层分布式应用 对于本书之中的案例系统来说,前面提到的业务需求比较简单,使用 PB、Delphi、JBu ilder 等提供的数据敏感组件可以在很短时间内开发出一个可用的系统来。但是本书不会采 用这种简单的开发方式,而是要构建一个多层的分布式系统框架,主要基于如下两点考虑: l 本书是讲解 J2EE 开发的,所以必须以一个集成了先进设计思想的系统为案例讲解才可以使读者得到尽 可能多的知识,这是最大的动因。 l 目前的案例系统的需求非常简单,但是如果架构设计合理,以后完全可以基于这个技术架构进行更多的 业务扩展,最终发展成为一个集生产管理、供应链管理、财务管理、客户管理、HR 管理等多业务模块,并 能处理跨地区、跨组织的大型企业信息系统。 6.1 概 述 分布式技术是处理客户端与服务器之间资源分配的技术,它解决的问题包括失败转发、 负载平衡、分布式事务、Session 共享等。 分布式系统通常是由多台实现相同功能的服务器同时提供服务,客户端的请求可以根据 一定的负载平衡算法被转发到负载较轻的服务器上去,这样就提高了各个服务器的利用率和 系统的整体吞吐量;当一台服务器发生故障时,其他服务器会接管这个服务器正在执行的操 作,继续为客户端提供服务。使用分布式技术带来的好处主要是提高了系统的稳定性和吞吐 量。 多层架构把系统分成数据访问层,业 务 规 则 层 等 多 个 层 次 。每 个 层 次都向其他层次提供 服务,服务使用者无须关心服务的实现,这样各个层次之间可以责任明确地、相互协调地完 成系统任务。各个层之间只通过约定的接口提供服务,服务的实现方式是调用者无须关心的, 这样任何一层内部的修改不会蔓延到其他模块,从 而 最 大 限 度 地 减 少 了 需求变化时对系统的 影响。多层架构带来的好处就是解耦了系统,使各个模块之间的依赖变小,系统更容易理解、 修改和扩展。 在 Java 中,分布式技术常常是和多层架构同时出现的,分布式技术的使用会自然而然 地导致系统的分层设计。比如使用 EJB 后会很自然地将系统分成三层甚至更多层。分布式 系统开发中的经验也被借鉴到分层开发中来,从 而 使 得 分层更加合理,以至于很多没有采用 分布式技术的多层架构系统中也可以找到分布式技术的影子。采用了分层结构设计的分布式 系统就被称为多层分布式系统,在 Java 世界中又经常被简称为分布式系统。本书将会沿用 这种说法。 经过多年的发展,分布式技术已经日趋成熟,在各个平台都有了成熟的实现,比如 Wi ndows 平台下的 DCom、Com+、.NET 技术体系,Java 平台下的 EJB,跨平台的 Corba、We bservice 等。 在 Java 平台下,EJB 无疑是最成熟的分布式技术,它解决了安全性、负载平衡、分布 式事务、数据持久化等很多核心问题。但 EJB 的缺点是应用程序的运行必须依靠 EJB 服务 器,需要编写部署描述符,并且每次修改都要重新编写部署描述符并部署到应用服务器上去, 必要的时候还要重启服务器;EJB 服务器运行时启动很多开发时无用的组件,占用了大量的 系统内存,这给系统的开发人员带来了诸多的不便,开发效率变得非常低。 Rod Johnson 在使用 EJB 进行开发过程中逐渐认识到了 EJB 的这些缺点,在《Expert One-on-One J2EE Design and Development》一书中从实用的角度重新认识了 EJB 等技术在 J2EE 开发中的作用,也催生出了优秀的 J2EE 框架 SpringFramework。SpringFramework、Hi bernate 等“草根”框架的流行给了 EJB 这个“皇家规范”以很大的压力,EJB 也在向这些框架学 习,甚至邀请这些框架的作者参与 EJB 的改进,从 EJB3 的规范中就可以看到这一点。 6.2 Spring Remoting Spring 目前提供了对 RMI、HttpInvoker、Hessian、Burlap 及 WebService 等 Remoting 技术的集成。Spring 屏蔽了这些实现技术的差异,用户只需开发简 单的 Java 对象(Plain Old Java Objects,POJO)然后按照 Spring 规定的格式进 行配置文件的编写即可。 6.2.1 Hessian 使用演示 【例 6.1】在 Spring 中使用 Hessian Remoting 技术。 下面就来演示一下在 Spring 中是如何使用 Hessian Remoting 技术的。Hess ian、Burlap、HttpInvoker 等是要运行在支持 Servlet 的 Web 服务器中的,因 此在运行例子之前要安装配置好 Web 服务器。Web 服务器配置完毕以后按照下面 的步骤编写代码。 (1) 编写业务接口: // IWordProcessor 业务接口 public interface IWordProcessor { /** * 抽取 value 中的中文 * @param value * @return */ public String extractChinese(String value); } (2) 编写实现类: // 实现类 public class WordProcessorImpl implements IWordProcessor { public String extractChinese(String value) { Pattern p = Pattern.compile("[\\u4E00-\\u9FFF]+"); Matcher matcher = p.matcher(value); StringBuffer sb = new StringBuffer(); while (matcher.find()) { sb.append(matcher.group()); } return sb.toString(); } } (3) 修改 Web 工程中的 web.xml 文件: remote org.springframework.web.servlet.DispatcherServlet< /servlet-class> 1 remote /remote/* (4) 在 Web 工程中添加 remote-servlet.xml 文件: com.cownew.Char11.Sec02.IWordProcessor (5) 编写客户端测试代码: // 测试代码 package com.cownew.Char11.Sec02; import java.net.MalformedURLException; import com.caucho.hessian.client.HessianProxyFactory; public class MainApp { public static void main(String[] args) { HessianProxyFactory proxyFactory = new HessianProxyFactory(); try { IWordProcessor service = (IWordProcessor) proxyFactory.create( IWordProcessor.class, "http://localhost:8080/ RemoteCall/remote/WordProcessorService"); System.out.println( service.extractChinese("人来的不少,I'm very 欣慰")); } catch (MalformedURLException e) { e.printStackTrace(); } } } 运行结果: 人来的不少欣慰 用 Web 服务器来实现 Remoting,确实很神奇! 如果需要改用 Burlap,则将上面的 HessianServiceExporter 改成 BurlapSe rviceExporter,HessianProxyFactory 改成 BurlapProxyFactory 就可以,接口 和实现类的代码均不需要修改;同样如果要改用 HttpInoker,只要将上面的 He ssianServiceExporter 改成 HttpInvokerService- Exporter,将 HessianProxy Factory 改成 HttpInvokerProxyFactoryBean 就可以了。 在案例系统开发的最初阶段曾经使用 Hessian 实现 Remoting,后来逐渐发现 Hessian 不能传递复杂对象的缺点,因此决定切换到 Http Invoker,没想到从看 资料到最终修改完毕竟然用了不到 1 分钟时间,其他部分完全不用修改,不得不 为 Spring 折服。 6.2.2 几种 Remoting 实现的比较 Spring 支持的 Remoting 实现技术是非常多的,虽然 Spring 屏蔽了这些技术 使用上的差异,但是选择一个合适的 Remoting 技术仍然对系统有非常积极的作 用,下面就来讲述这些实现技术的优缺点。 (1) RMI:RMI 使用 Java 的序列化机制实现调用及返回值的编组(marshal) 与反编组(unmarshal),可以使用任何可序列化的对象作为参数和返回值。其缺 点是 RMI 只能通过 RMI 协议来进行访问,无法通过 HTTP 协议访问,无法穿透防 火墙。 (2) Hessian:Hessian 也是将网络传输的对象转换为二进制流通过 Http 进 行传递,不过它是使用自己的序列化机制实现的编组与反编组,其支持的数据类 型是有限制的,不支持复杂的对象。Hessian 的优点是可以透过防火墙。 (3) Burlap:Burlap 是将网络传输的对象转换为 XML 文本格式通过 Http 进 行传递,支持的对象与 Hessian 相比更少。XML 一般比二进制流占用空间大,在 网络上传递所需要的时间比二进制流长,XML 的解析过程也会耗用更多的内存。 Burlap 可以穿透防火墙,而且由于传输的格式是 XML 文本,可以与其他系统(比 如.NET)集成,从某种程度来讲,Burlap 是一种不标准的 WebService。 (4) HttpInvoker:HttpInvoker 将参数和返回值通过 Java 的序列化机制进 行编组和反编组,它具有 RMI 的支持所有可序列化对象的优点。Http Invoker 是使用 Http 协议传输二进制流的,而同时又具有 Hessian、Burlap 的优点。 经过比较,并结合案例系统的特点,HttpInvoker 在众多实现技术中脱颖而 出,因此案例系统的 Remoting 部分将使用 HttpInvoker 实现。 6.3 改造 HttpInvoker HttpInvoker 提供了 HessianServlet 和 HessianServiceExporter 两种发布服务的方式,Hess ianServiceExporter 比 HessianServlet 简单一些,只要配置一个 Spring IoC 风格的配置文件即 可: com.cownew.Char11.Sec02.IWordProcessor 这是 Spring 官方文档中提到的使用方法,可是这是最好的使用方法吗?想一想我们配 置这个文件无非是要告诉容器 3 件事:向外提供的服务名字叫做 WordProcessorService;服 务实现了接口 com.cownew.Char11.Sec02.IWordProcessor;服务的实现类是 com.cownew. Ch ar11.Sec02.WordProcessorImpl。为了实现这 3 件事竟然要去写 13 行配置文件,如果要给服 务对象添加 AOP 代理,那么还要再添加一个标记;如果要对外暴露 100 个 服务,就要去写(13+n)*100 行配置文件! 长篇大论、没完没了的配置文件不是 Spring 的本意,把本应该写在代码里的依赖关系 写到配置文件中是对 Spring 的最大滥用,甚至 Rod Johnson 本人也犯这样的错误。Java 是 强类型语言,这也是为什么 Java 成为工业级语言的重要原因,强类型可以尽早发现代码的 错误,借助 IDE 使用强类型可以提高开发效率。通过使用配置文件,可以将组件之间的依 赖延迟到运行阶段,但是并不是任何依赖都需要延迟到运行阶段的。把本应该在开发阶段就 组装完毕且在运行时不会轻易改变的依赖放到配置文件中,不仅会导致代码难读、编写困难, 也会降低系统的运行效率。 初学 Spring 的人往往喜欢把本来能在代码里完成的功能都改成配置文件方式,甚至 per sonInfo.setParent(new PersonInfo(“Smith”))这样的代码也要配置到 XML 文件中。因为这样看 起来很酷,因为把代码写到了配置文件中,这样会与众不同!但是软件开发不是玩玩具,“酷” 不是选择一个技术的理由,这个技术必须解决实际问题才可以。 Spring 只是提供了一个解决问题的思路,Spring 的 IOC 思想是非常简单易懂的,一个 熟练的开发人员可以在很短的时间内重写 Spring 的核心。但是 Spring 能够发展至今,靠的 不是这个核心!试想如果没有 Spring MVC、Spring AOP、Spring Remoting,没有 Spring ORM,没有 Spring JMS,我们还会如此痴迷 Spring 吗? 不要滥用 Spring 配置文件,不要把本应该在代码中注入的依赖放到配置文件中去,Spr ing 的本意是简化,而不是复杂化! 那么下面看一下我们想要的配置文件是什么样的: 在这里指定了输出服务的标识为“WordProcessorService”、实现该服务的类为“com.cowne w.Char11.Sec02.WordProcessorImpl”,该服务对应的接口为“com.cownew. Char11.Sec02.IWord Processor”。 这个配置文件还可以进一步简化。真实的系统中会存在大量的远程服务对象,如 果 每 个 对 象都采取“WordProcessorService”这样的命名方式的话很容易重复,而且不容易管理。最好 的命名方式就是模仿 Java 的包机制,比如“com.cownew.Char11.Sec02.WordProcessorService”。 既然此服务的调用者知道此服务实现了“com.cownew.Char11.Sec02.IWordProcessor”接口,而 且“com.cownew.Char11.Sec02.IWordProcessor”这个名字不会重复,服务标识为什么不直接命 名为“com.cownew.Char11.Sec02.IWordProcessor”呢?这是个好注意!这样配置文件就可以被 简化为: 这就是我想要的!那么我们就来向着这个目标迈进吧。 HttpInvoker 是如何在服务器端响应客户端的调用请求,然后把调用的结果返回给客户 端的呢?HttpInvoker 与客户端交互的组件是 DispatcherServlet,当 Web 服务器接收到“http:/ /localhost:8080/RemoteCall/remote”这个请求的时候就会将请求派发给 DispatcherServlet 处 理。通过阅读 DispatcherServlet 的代码可以得知,DispatcherServlet 从请求中分辨出客户端 要调用的服务是“/WordProcessorService”,它就会到 remote-servlet.xml 中查找名称为“/WordPr ocessorService”的 Bean,最终查找到下面的配置文件声明了“/WordProcessorService”这个服 务: com.cownew.Char11.Sec02.IWordProcessor DispatcherServlet 调用 IOC 容器的方法得到这个服务。IOC 容器发现这个 Bean 还引用 了另外一个 Bean: IOC 容器首先实例化“WordProcessorImpl”为名称为“wordProcessorBean”的 Bean,然后实 例化“HessianServiceExporter”,设置“service”属性为“wordProcessorBean”对象,设置“serviceI nterface”属性为“com.cownew.Char11.Sec02.IWordProcessor”。IOC 容器将实例化完毕的“/Wor dProcessorService”对象返回给 DispatcherServlet,DispatcherServlet 把 Web 请求再次派发给“/ WordProcessorService”。 那么“/WordProcessorService”(即 HttpInvokerServiceExporter 类的对象)是如何响应 Web 请求的呢?打开 HttpInvokerServiceExporter 的源码,查看其实现代码,下面的公共方法引起 我们的注意: public void handleRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { Assert.notNull(this.proxy, "HttpInvokerServiceExporter has not been initialized"); try { RemoteInvocation invocation = readRemoteInvocation(request); RemoteInvocationResult result = invokeAndCreateResult(invocation, this.proxy); writeRemoteInvocationResult(request, response, result); } catch (ClassNotFoundException ex) { throw new NestedServletException("Class not found during deserialization", ex); } } 方法的名字和其参数以及方法内部的实现都暗示了它就是响应 Web 请求的核心方法, 看到它的 JavaDoc 就更加表明我们的猜测是完全正确的: Read a remote invocation from the request, execute it, and write the remote invoc ation result to the response.(从请求中读取远程调用,执行调用,然后将调用结果写到响应 中去。) handleRequest 方法是 HttpRequestHandler 接口中定义的响应 Http 请求的接口,BurlapS erviceExporter、HessianServiceExporter、HttpInvokerServiceExporter 都实现了这个接口。req uest 是客户端的请求,客户端的方法调用全部在 request 中;response 是返回给客户端的响应 对象,我们要把调用结果(包括返回值、异常等)通过 response 返回给客户端。 弄懂了 HttpInvokerServiceExporter 的实现原理,下面就来实现要解析的配置文件: 由于上面的这个配置文件格式是自定义的,DispatcherServlet 和 HttpInvokerServiceExpo rter 都无法识别它,必须写一个 Servlet 来处理调用请求。 【例 6.2】提供 Remoting 服务的 Servlet。 编写一个从 HttpServlet 继承的 RemotingCallServlet: // 提供 Remoting 服务的 Servlet public class RemotingCallServlet extends HttpServlet { private static Logger logger = Logger.getLogger(RemotingCallServlet.class); private static BeanFactory appContext = null; protected void doPost(HttpServletRequest httpRequest, HttpServletResponse httpResponse) throws ServletException, IOException { try { invokeService(httpResponse, httpRequest); }catch (Throwable e) { // 注意,bean 运行过程中的异常并不是通过此处抛出的 //而是通过 remoting 机制传递到客户端再抛出的 // 此处抛出的是非 bean 的异常 //由于这里的异常不会抛出到客户端,因此把异常打印出来,方便开发调试 //使用 log4j 把异常打印出来是一种好习惯! logger.error(e.getMessage(), e); throw new ServletException(e); } } private void invokeService(HttpServletResponse response, HttpServletRequest request) throws ServletException, PISException { String reqPath = request.getPathInfo(); String serviceId = getServiceId(reqPath); invokeBean(request, response, serviceId); } private void invokeBean(HttpServletRequest request, HttpServletResponse response, String serviceId) throws ServletException, PISException { Object _service = appContext.getBean(serviceId); //因为所有的服务都是无状态的服务,所以此处的_service 无须进行同步, //可以同时为多个调用服务 try { HttpInvokerServiceExporter exporter = new HttpInvokerServiceExporter(); exporter.setService(_service); exporter.setServiceInterface(Class.forName(serviceId)); exporter.afterPropertiesSet(); exporter.handleRequest(request, response); } catch (ClassNotFoundException e) { throw new ServletException(e); } catch (IOException e) { throw new ServletException(e); } } //用正则表达式将 Path 中的服务 id 提取出来,比如“/com.cownew.demo.IService” //将“com.cownew.demo.IService”解析出来 private static String getServiceId(String reqPath) { Pattern pattern = Pattern.compile("/(.+)"); Matcher match = pattern.matcher(reqPath); match.matches(); match.group(); String serviceId = match.group(1); return serviceId; } static { appContext = new ClassPathXmlApplicationContext( "com/cownew/PIS/framework/server/springBeans.xml"); } } 编写配置文件 springBeans.xml 放到和 RemotingCallServlet 同一级的包下: 修改 Web 工程中的 web.xml 文件,将 DispatcherServlet 替换成 RemotingCallServlet,删 除 remote-servlet.xml 文件,然后重启服务器。 编写测试客户端: public class MainApp { public static void main(String[] args) { HttpInvokerProxyFactoryBean proxyFactory = new HttpInvokerProxyFactoryBean(); try { proxyFactory.setServiceUrl( "http://localhost:8080/RemoteCall/remote/" +IWordProcessor.class.getName()); proxyFactory.setServiceInterface(IWordProcessor.class); proxyFactory.setHttpInvokerRequestExecutor(new CommonsHttpInvokerRequestExecutor()); proxyFactory.afterPropertiesSet(); IWordProcessor service = (IWordProcessor)proxyFactory.getObject(); System.out.println(service.extractChinese( "人来的不少,I'm very 欣慰")); } catch (MalformedURLException e) { e.printStackTrace(); } } } 运行结果: 人来的不少欣慰 RemotingCallServlet 的核心代码在 invokeBean 中。首 先 使 用 Spring 的 ClassPathXml- A pplicationContext 的 getBean 方法得到服务,然后实例化 HttpInvokerServiceExporter,把通过 getBean 方法得到的服务 Bean 对象赋值给 setService 方法。这里规定服务的 id 和服务的接口 类名一致,所以调用 Class.forName(serviceId)即可反射得到接口类名,把类名赋值给 setSer viceInterface 方法。 在例子中通过 Spring 的配置文件来为 HttpInvokerServiceExporter 设置 service、serviceI nterface 属性,而此处是直接在代码中完成的注入。Spring 中大部分类都实现了 InitializingB ean 接口,Spring 在为 Bean 设置完属性后会调用 InitializingBean 接口的 afterPropertiesSet 方 法来标识属性设置完毕,实现类常常在 afterPropertiesSet 方法中做属性合法性检验、数据初 始化等操作,因此在这种代码注入的情况下要手动调用 afterPropertiesSet 方法,以防出错。 代码最后调用了 handleRequest 来响应客户端请求。 不按照 Spring 推荐的配置文件的方式来使用 Spring 的类初看好像是对 Spring 的错误使 用,实则是一种最佳的使用方式。此处由于服务接口和实现的动态性,在 Spring 中用配置 文件实现起来是非常困难的,即使能够实现配置文件看起来也是非常难懂的,而通过这种代 码注入的方式看起来却更简单明了。在使用 Spring 的时候,使用配置文件注入一定要有充 分的理由,不能人云亦云。 客户端测试代码中 HttpInvokerProxyFactoryBean 的初始化方式也是从 Spring 的 HttpInv oker 使用手册的 XML 文件配置方式翻译过来的,此处由于只是得到 IWordProcessor 服务, 我们完全可以按照配置文件的方式进行注入,但是我们后边将会将这种调用方式封装成一个 能承担各种服务调用工作的 RemoteServiceLocator,所以此处仍然使用代码方式进行注入。 案例系统中使用 Spring 的配置文件方式注入的地方是非常少的,所以在后边的代码分析中 再见到类似的“反 Spring”的使用方式的时候就无须大惊小怪了。 经过上边的改造,实现一个新的服务所需要的工作已经减少很多了,只需完成下面的工 作就可以实现一个 Remoting 服务:编写服务接口;编写实现类;在 springBeans.xml 加入。 这个框架还有以下两点可以进一步改进的: l 服务文件的分模块化。每增加一个服务都要向 springBeans.xml 中加入一个服务的定义,如果整个系统 的服务都定义在这一个文件中,当多人甚至多项目组协同开发的时候这个文件的修改将成为一个灾难,会 经常出现多个人同时修改这一个文件造成冲突的问题。要对此处做改进,使得可以支持多 springBeans.xm l 文件,每个项目组都定义自己的 springBeans.xml 文件。 l 抽象出本地服务加载器。在 invokeBean 方法中使用 appContext.getBean(serviceId)方法来取得本地服 务 Bean,这暗示了系统是使用 Spring IOC 来管理服务的,但这个事实是无须 RemotingCallServlet 知道的, RemotingCallServlet 只是想通过 serviceId 来得到服务实现,至于服务的加载方式 RemotingCallServlet 无 须关心。再者在服务器端,各个模块之间也要相互协作,模块之间无须知道具体的实现类是什么而是通过 接口直接调用的。如果调用其他模块的时候都要使用 appContext.getBean(serviceId)来加载服务的话,这 无疑使得 Spring IOC 的使用蔓延到了整个系统。基于以上两点考虑,系统需要一个本地服务加载器。 6.3.1 服务文件的分模块化 由于每个模块都定义一个 springBeans.xml 文件,所以系统内部的各个包中会散布着这 些文件,要通过它们加载服务的话,必须首先加载它们,要加载它们就首先要知道它们的位 置。得到所有 springBeans.xml 文件有两种实现方式。 (1) 遍历系统每个包,发现名字为 springBeans.xml 的配置文件就加载进来; (2) 在系统的一个配置文件中保存这些 springBeans.xml 文件的位置,只要读取这个配置 文件就可以知道所有 springBeans.xml 文件的位置了。 第一种方式简单灵活,开发人员可以在任意位置编写 springBeans.xml 文件,系统都可 以加载到它们。缺点就是遍历所有的包需要一定的时间,会降低系统的初始化速度;文件名 只能为 springBeans.xml,而且系统中不能有用作其他用途的名称为“springBeans.xml”的文件。 第二种方式比较严谨,各个模块必须严格遵守在配置文件中声明的位置建立 springBea ns.xml 文件,文件的名字也可以改变成其他的;配置文件的加载速度也会有所提高,从 而 加 快 系统的初始化速度;缺点就是每个模块都必须到统一的配置文件中注册,加大了工作量。 在大型的团队开发中,第二种方式比第一种方式拥有更多的优势,所以此处按照第二种 思路实现。 在包“/com/cownew/PIS/framework/server/”下建立文件 ServerConfig.xml: /com/cownew/PIS/framework/server/springBeans.xml /com/cownew/PIS/framework/server/springBeans2.xml 在 BeanFiles 标记中定义的就是所有的 springBeans.xml 文件。 【例 6.3】建立一个配置文件读取类。 为了读取这个文件,下面建立一个应用服务器端配置文件读取类: // 应用服务器端配置文件读取器 package com.cownew.PIS.framework.server.helper; import java.io.InputStream; import java.io.InputStreamReader; import java.util.List; import org.dom4j.Document; import org.dom4j.io.SAXReader; import org.dom4j.tree.DefaultElement; import com.cownew.ctk.common.ExceptionUtils; import com.cownew.ctk.common.StringUtils; import com.cownew.ctk.constant.StringConst; import com.cownew.ctk.io.ResourceUtils; public class ServerConfig { private String[] beanFiles; private static ServerConfig instance = null; private ServerConfig() { super(); }; public static ServerConfig getInstance() { if (instance == null) { instance = new ServerConfig(); try { instance.initConfig(); } catch (Exception e) { ExceptionUtils.toRuntimeException(e); } } return instance; } protected void initConfig() throws Exception { InputStream beansXFStream = null; try { beansXFStream = getClass().getResourceAsStream( "/com/cownew/PIS/framework/server/ServerConfig.xml"); SAXReader reader = new SAXReader(); reader.setValidation(false); Document doc = reader.read(new InputStreamReader(beansXFStream, StringConst.UTF8)); loadBeanFilesDef(doc); } finally { ResourceUtils.close(beansXFStream); } } /** * Remoting 定义文件 */ public String[] getBeanFiles() { return beanFiles; } /** * 加载 remoting 配置文件 */ private void loadBeanFilesDef(Document doc) { List beanList = doc.selectNodes("//Config/BeanFiles/File"); beanFiles = new String[beanList.size()]; for (int i = 0, n = beanList.size(); i < n; i++) { DefaultElement beanElement = (DefaultElement) beanList.get(i); beanFiles[i] = beanElement.getText(); } } } 配置文件的解析是一个比较费时的过程,所以在这里只是在 ServerConfig 实例化的时候 调用 initConfig 进行配置文件的解析,并把解析后的结果保存在 beanFiles 数组中。为了防止 调用者实例化此读取类,所以将此类设计成单例的,并且实现为惰性加载,通过 getInstanc e 返回这个单例。这样通过 ServerConfig.getInstance().getBeanFiles()就可以得到所有配置文件 的位置了。 在这里规定在 ServerConfig.xml 定义的配置文件位置必须是以类路径形式表示的,Clas sPathXmlApplicationContext 有一个支持字符串数组的构造函数,所以只要修改 Bean 工厂的 实例化方式为: appContext = new ClassPathXmlApplicationContext(ServerConfig .getInstance().getBeanFiles()); 就可以一次性加载所有的配置文件了。 6.3.2 本地服务加载器 目前阶段的本地服务加载器的实现是非常简单的,只要在适当的时候创建 Bean 工厂, 并调用 appContext 的相应方法来取得相应的服务对象即可。 【例 6.4】本地服务加载器。 代码如下: // 本地服务加载器 public class LocalServiceLocator { private static LocalServiceLocator instance; private LocalServiceLocator() { super(); }; private static BeanFactory appContext = null; public static LocalServiceLocator getInstance() { if (instance == null) { instance = new LocalServiceLocator(); } return instance; } public Object getService(Class serviceIntfClass) throws PISException { String serviceId = serviceIntfClass.getName(); Object bean = appContext.getBean(serviceId); return bean; } static { appContext = new ClassPathXmlApplicationContext(ServerConfig .getInstance().getBeanFiles()); } } 将 RemotingCallServlet 的 invokeBean 方法中根据 serviceId 得到服务的代码替换为下面 的方式: Class serviceIntfClass = Class.forName(serviceId); Object _service = LocalServiceLocator.getInstance() .getService(serviceIntfClass); 6.4 Remoting Session 实现 由于 Http 协议是不保持连接、无状态的,所以 HttpInvoker、Hessian、Burlap、WebSer vice 等都是无状态的,系统无法分辨本次调用者是否是上次调用的那个客户端;EJB、Com +等支持状态的 Remoting 实现本质上也是无状态的,只是它们内置了 Remoting Session 机制 而已。 无状态的 Remoting 服务从严格意义上来说是回归到了面向过程的时代,我们面对的是 服务器提供的没有任何状态的“伪类”,业界 专 家 之所 以推荐使用无状态的 Remoting 服务,是 考 虑 到 如 果服 务 是 有状 态 的,那么状态信息就会迅速地将服务器内存占满,将会降低系统的 吞吐量;无状态的 Remoting 服务更体现了服务的概念,超市收银员只是提供收银服务,无 须在服务完成后记住每个客户买了多少东西、买了什么东西,否则收银员的脑子会爆炸掉的。 不过在某些时候调用者的状态还是有用的,超市的购物积分就是一个例子。收银员无须 记忆客户的购买历史,也无须客户出示所有的购物小票,这些购买历史全部记录在 POS 机 中,标识这个客户唯一性的就是会员卡的卡号。当用户付款的时候,POS 机首先读取会员 卡号,根据此卡号找到对应的客户记录,然后将本次的购买情况记录到此客户的名下。 在信息系统中同样需要类似的功能,比如在业务处理过程中服务常常需要知道当前所服 务的客户端的操作人员是谁、用户名是什么、密码对不对、它是否有执行此操作的权限、它 要连接哪个数据库。如果这些信息全部都在每次提供服务的时候要求客户端提供(比如每个 服务端方法都增加传递这些信息的参数),那么这对于双方都会很麻烦,并且也是不安全的 行为,让调用者无法理解:“不是上次告诉你了吗?怎么还问?”。 解决这个问题就需要借助于 Session 技术了。Session 中文翻译为“会话”,其本来的含义 是指有始有终的一系列交互动作。随着 Web 技术的发展,Session 已经变成在无状态的协议 中在服务器端保存客户端状态的解决方案的代名词了。 当客户端第一次登录的时候,服务器分配给此客户端一个标识其唯一性的 Id 号码,并 且询问客户端“你是谁、用户名是什么、密码是什么、要连接哪个数据库”,根据这些信息服 务器再到数据库中查询这个客户端有哪些权限、密码是否正确,然后将“它是谁、用户名是 什么、要连接哪个数据库、有哪些权限”等信息以唯一性的 Id 号码为主键保存到特定的位置。 以后客户端再向服务器请求服务的时候,只要提供此 Id 号码即可(类似于购物时提供会员 卡),服务端就可以根据这个 Id 号码来取它需要的信息。 要注意此 Remoting Session 和 Web 中的 Session 的区别,它们的作用是类似的,不过这 里的 Remoting Session 是不在乎调用的客户端是 Swing GUI 程序还是 Web 应用的。它是一 个应用服务器端技术,在 Web 调用的时候常常需要把应用服务器分配的唯一的 Id 保存在 W eb Session 中,这个问题在后边会有专门的论述。 6.4.1 实现思路 Session 的实现方式如下:在用户第一次登录的时候,系统为它分配一个唯一 Id(被称为 Session Id)作为标识,并且记录下这个用户的用户名、要登录的账套名、用 户 拥 有 的 权限等, 以 Id 为键,用户名、账套名等信息为值保存到一张 Session 哈希表中。以后客户端登录的时 候只要提供此 Id 即可,应用服务器可以通过此 Id 到 Session 哈希表中查询到所需要的一切 信息。因为 Session 哈希表是保存在存储器中的(通常是内存),存储过多的 Session 信息将会 占用内存空间,所以客户端退出的时候要通知应用服务器注销此 Id。 具体到细节还有一些问题需要处理: l 如何生成唯一的 Id。 l 如何保存用户名、账套名等信息,如何能在 Session 中放入自定义的信息。 l 如何维护管理 Session。 l 如何清除 Session。当系统非正常退出的时候,比如客户端机器故障,客户端是无法通知应用服务器注 销 Id 的,这会造成应用服务器中存在垃圾 Session。 l 如何防止此 Id 被恶意程序截获,从而冒充合法客户端登录系统。 6.4.2 Session Id 的生成 客户端 Session Id 的生成与数据库中的主键生成面对的问题是类似的。以可移植、高效 率 、可 靠 的 方 式 来 生 成 主 键 是一个非常重要的问题。可 移植指的是主键生成策略不能依赖于 服务器、操作系统、数据库等;高效率是生成主键的过程必须足够快,不能让生成主键的算 法成为系统的瓶颈;可 靠 指 的是生成的主键必须保证唯一性。主键生成方式可以分为数据库 相关方式和数据库无关方式两种。 数据库相关方式是通过数据库的帮助来生成主键。对于支持自增字段的数据库,可 以 借 助 其 序 列 号 发 生 器 来 产 生 唯 一的主键;对于不支持自增字段的数据库,可 以 在系统中放置一 张表,采用此表记录本次生成的主键,这样就保证了生成的主键与以前的不冲突。数据库相 关方式的优点是实现简单,而且可以完全保证生成主键的唯一性;不过由于需要数据库来维 护主键的状态和同步对主键生成器的访问,所以对数据库有依赖性,而且由于需要访问数据 库,其生成速度较慢。 数据无关方式是无须依靠数据库而生成主键的方式。最典型的算法就是 UUID 算法。U UID 是一个字符串,它被编码为包含了使生成的 UUID 在整个空间和时间上都完全唯一的所 必需的系统信息集,不管这个 UUID 是何时何地被生成的。原始的 UUID 规范是由 Paul Le ach 和 Rich Salz 在网络工作组因特网草案中定义的。 UUID 字符串一般由下面信息构成: l 系统时钟的毫秒值。这是通过 System.currentTimeMillis()方法得到的。这保证了在时间维度上产生主键 的唯一性。 l 网络 IP 或者网卡标识。这保证了在集群环境中产生主键的唯一性。 l 精确到在一个 JVM 内部的对象的唯一。通常是 System.identityHashCode(this)所调用产生的编码,这个 方法调用保证对 JVM 中不同对象返回不同的整数。即使在同一台机器上存在多个 JVM,两个 UUID 生成器 返回相同的 UUID 的情况也极不可能发生。 l 在一个对象内的毫秒级的唯一。这是由与每一个方法调用所对应的随机整数,这个随机整数是通过使用 java.security.SecureRandom 类生成的。这可以保证在同一毫秒内对同一个方法的多个调用都是唯一的。 上述这些部分组合在一起,就可以保证生成的 UUID 在所有机器中(IP 不重复或者网卡 地址不重复),以及在同一台机器上的 JVM 内部的所有 UUID 生成器的实例中都保持唯一, 并且能精确到毫秒级甚至是一个毫秒内的单个方法调用的级别。 流行的 UUID 算法有很多,这些算法有的不能完全保证生成的 UUID 的唯一性,必须根 据情况选用。下面推荐两种 UUID 实现算法。 【例 6.5】UUID.Hex 算法。 这个算法是 Hibernate 中主键策略为“uuid.hex”时所使用的算法,代码位于包 org.hiberna te.id 下的 UUIDHexGenerator.java 文件中。 调用方法: IdentifierGenerator gen = new UUIDHexGenerator(); for (int i = 0; i < 10; i++) { String id = (String) gen.generate(null, null); System.out.println(id); } 运行结果(UUID 的生成是不重复的,每次的运行结果都会不同): ff8080810ef0779f010ef0779f500000 ff8080810ef0779f010ef0779f500001 ff8080810ef0779f010ef0779f500002 ff8080810ef0779f010ef0779f500003 ff8080810ef0779f010ef0779f500004 ff8080810ef0779f010ef0779f500005 ff8080810ef0779f010ef0779f500006 ff8080810ef0779f010ef0779f500007 ff8080810ef0779f010ef0779f500008 ff8080810ef0779f010ef0779f500009 这个算法的特点是生成的 UUID 序列具有顺序性,因此生成的 UUID 具有一定的可预测 性。前 边 的 部分采用的是系统时钟、网络地址等拼凑的,而最后的有序部分采用的是内部维 持一个同步了的计数器,每次生成 UUID 此计数器增加 1,所以并发性能稍差。 【例 6.6】Marc A. Mnich 的算法。 代码如下: // 随机 GUID 生成器 public class RandomGUID { public String valueBeforeMD5 = ""; public String valueAfterMD5 = ""; private static Random myRand; private static SecureRandom mySecureRand; private static String s_id; static { mySecureRand = new SecureRandom(); long secureInitializer = mySecureRand.nextLong(); myRand = new Random(secureInitializer); try { s_id = InetAddress.getLocalHost().toString(); } catch (UnknownHostException e) { e.printStackTrace(); } } public RandomGUID() { getRandomGUID(false); } public RandomGUID(boolean secure) { getRandomGUID(secure); } private void getRandomGUID(boolean secure) { MessageDigest md5 = null; StringBuffer sbValueBeforeMD5 = new StringBuffer(); try { md5 = MessageDigest.getInstance("MD5"); } catch (NoSuchAlgorithmException e) { throw ExceptionUtils.toRuntimeException(e); } try { long time = System.currentTimeMillis(); long rand = 0; if (secure) { rand = mySecureRand.nextLong(); } else { rand = myRand.nextLong(); } sbValueBeforeMD5.append(s_id); sbValueBeforeMD5.append(":"); sbValueBeforeMD5.append(Long.toString(time)); sbValueBeforeMD5.append(":"); sbValueBeforeMD5.append(Long.toString(rand)); valueBeforeMD5 = sbValueBeforeMD5.toString(); md5.update(valueBeforeMD5.getBytes()); byte[] array = md5.digest(); StringBuffer sb = new StringBuffer(); for (int j = 0; j < array.length; ++j) { int b = array[j] & 0xFF; if (b < 0x10) sb.append('0'); sb.append(Integer.toHexString(b)); } valueAfterMD5 = sb.toString(); } catch (Exception e) { e.printStackTrace(); } } public String toString() { String raw = valueAfterMD5.toUpperCase(); StringBuffer sb = new StringBuffer(); sb.append(raw.substring(0, 8)); sb.append("-"); sb.append(raw.substring(8, 12)); sb.append("-"); sb.append(raw.substring(12, 16)); sb.append("-"); sb.append(raw.substring(16, 20)); sb.append("-"); sb.append(raw.substring(20)); return sb.toString().trim(); } } 测试代码: for(int i=0;i<10;i++) { System.out.println(new RandomGUID().toString()); } 运行结果: B2FAA7E0-5E46-40D5-4757-C4C91A6A8F8E 7B8E3A34-B173-AC54-8F3D-8CAF48CECD13 62380599-EFA4-0AEF-8E03-A49018308D92 F781C6B5-55ED-D553-D1F7-43C573A00AB4 19FE1D7F-41A0-EB71-E149-9FEAD2B746C7 A0C334EA-0C31-E4C8-B7B6-64F2A4A9C35A ED9329E2-64D2-E3D7-88FC-7EC03FA0AA54 7285B963-2BBE-45FE-A074-7CA20B496D04 F0085927-12B2-BE6C-1217-281B470E1282 9AA63E7A-61C9-2CB9-46C1-3E07B60952D1 RandomGUID 算法和其他算法一样采用系统时钟、网络地址等来产生唯一编码,唯一 不同的地方就是 RandomGUID 算法生成的 UUID 是随机的,由于使用 SecureRandom 产生随 机数,所以其安全性非常高。此算法除了能快速、可移植地生成可靠的 UUID 之外,其最大 的优势就是无法根据以前生成的 UUID 来推算后边要生成什么样的 UUID。这在有的场合是 非常有用的,比如在生成对安全性要求比较高的数据表的主键的时候,不希望有恶意企图的 人能够猜测出后续要生成的 UUID。这对这里要生成的客户端唯一标识也是有意义的,这个 客户端唯一标识是应该只有应用服务器和相应的客户端才需要知道的,如 果 这 个 唯 一 标 识 能 被 猜测的话,就会对系统安全造成隐患(比如恶意清除 Session 等)。 基于安全和效率的考虑,我们选择 RandomGUID 算法作为客户唯一标识生成算法,同 时案例系统中其他需要生成主键的地方也全部使用 RandomGUID 算法。 6.4.3 用户信息的保存 目前要保存在应用服务器端 Session 的信息有登录用户 Id、账套名、sessionId 等,并且 允许把自定义的一些信息放入 Session 中,以实现一些特殊的功能。 【例 6.7】用户信息的保存。 写一个简单的服务器端用户上下文 JavaBean 即可储存这些信息。代码如下: // 服务器端用户上下文 public class ServerUserContext implements Serializable { private String sessionId; //账套名 private String acName; //登录用户 id private String curUserId; //存储用户自定义信息用的哈希表 private Hashtable userDefAttributes = new Hashtable(); public String getACName() { return acName; } public void setACName(String acName) { this.acName = acName; } public String getSessionId() { return sessionId; } public void setSessionId(String sessionId) { this.sessionId = sessionId; } public String getCurUserId() { return curUserId; } public void setCurUserId(String curUserId) { this.curUserId = curUserId; } //得到名称为 name 的用户自定义信息 public Object getUserDefAttribute(String name) { return userDefAttributes.get(name); } //得到所有的用户自定义信息 public Enumeration getUserDefAttributeNames() { return userDefAttributes.keys(); } //移除名称为 name 的用户自定义信息 public void removeUserDefAttribute(String name) { userDefAttributes.remove(name); } //在用户自定义信息中加入名称为 name,值为 value 的用户自定义信息 public void setUserDefAttribute(String name, Object value) { userDefAttributes.put(name, value); } } 和 Web 中的 Session 一样,此处的 Session 中的自定义用户信息功能只是供一些极特殊 的用途使用的,不应该使用此功能使得 Remoting 服务变成了有状态的。比如,在 A 服务中 将一些逻辑信息存入 Session,然后到 B 服务中取出使用,这违反了分布式开发的无状态原 则,很容易导致数据混乱并使系统状态控制变得复杂。即使确实有特殊需要向 Session 中放 置自定义信息的,也要尽量避免放置大对象或者过多的对象进去,以免过多地占用宝贵的应 用服务器内存资源。当某个问题要通过向 Session 中放置自定义信息的时候,要首先思考能 否改用其他更好的方式解决,向 Session 中放置自定义信息是下下策。 6.4.4 维护管理 Session 在 Session 的原理中曾经提到,Session 通常是应用服务器中的一张哈希表。在系统中增 加一个 Session 哈希表,这个表以 Session 的 Id(即前边说的客户端唯一标识)为键,以 Server UserContext 的对象为值。其他的管理操作自然而然也就全部围绕此哈希表来完成, 【例 6.8】Session 的维护管理。 编写如下的 Session 管理器来进行 Session 的维护管理: // Session 管理器 public class SessionManager { private static SessionManager instance = null; //sessionId 为 key,ServerUserContext 为 value private Map sessionMap = Collections.synchronizedMap(new HashMap()); private SessionManager() { super(); } public static SessionManager getInstance() { if (instance == null) { instance = new SessionManager(); } return instance; } /** * 根据会话 id 得到用户上下文 * @param sessionId * @return */ public ServerUserContext getServerUserContext(String sessionId) { return (ServerUserContext) sessionMap.get(sessionId); } /** * 得到所有会话 id 的集合 * @return */ public Set getSessionIdSet() { return Collections.unmodifiableSet(sessionMap.entrySet()); } /** * sessionId 是否合法 * @param sessionId * @return */ public boolean isValid(String sessionId) { return sessionMap.containsKey(sessionId); } /** * 清除 session * @param sessionId */ public void removeSession(String sessionId) { sessionMap.remove(sessionId); } /** * 清除所有 session * */ public void removeAll() { sessionMap.clear(); } /** * 根据账套名请求一个会话 Id * @param acName * @return */ public String requestSessionId(String acName) { String sessionId = new RandomGUID().toString(); ServerUserContext ctx = new ServerUserContext(); ctx.setACName(acName); ctx.setSessionId(sessionId); sessionMap.put(sessionId, ctx); return sessionId; } } Session 管理器在一个应用服务器内只存在一个实例,所以采用单例模式。客户端第一 次登录的时候以要登录的账套名为参数调用 requestSessionId 方法得到唯一的 Session Id;以 后可以 SessionId 为参数调用 getServerUserContext 方法,这样就可以得到此 Session 的 Serv erUserContext 信息;退出的时候以 SessionId 为参数调用 removeSession 方法以清除对应的 S ession。 6.4.5 Session 的注销 由于 Session 中保存着用户名、权限、账套名称等信息,占据一定的应用服务器内存, 随着登录系统的人次的增多,Session 占用的存储空间也会变大。为了及时清除无用的 Sessi on,可以要求调用者在退出时调用 SessionManager 的 removeSession 方法来清除其对应的 S ession。 客户端有可能意外终止或者客户端和应用服务器的连接意外断开,这时客户端在应用服 务器中的 Session 就会永远无法清除了。解决这个问题最直接的办法就是建立 Session 超时 机制:某个 Session 对应的客户端如果在一定时间后还没有活动的话,就将此客户端对应的 Session 从应用服务器清除,这也是 Web Session 处理这类问题的策略。 超时时长的设置最好能够配置,这样方便实施人员或者客户根据情况进行修改以便调 优,为此在 ServerConfig.xml 中增加下面的配置项: 3 这个配置表示当客户端 3 分钟之后还没有活动的话就将其对应的 Session 清除。在 Serv erConfig.java 中增加读取此配置项的代码,并增加 getSessionTimeOut()方法以读取配置项的 值。 接着在 SessionManager 中增加一个私有变量 Map,用来记录 Session 的活动信息: private Map sessionActiveMap = Collections.synchronizedMap(new HashMap()); sessionActiveMap 以 sessionId 为键,以会话自上次活动以来的时间(分钟)为值。 然后为 SessionManager 增加一个公共方法用来供外界调用,表 示 某 Session 产生活动了: public void sessionVisit(String sessionId) { if (!sessionMap.containsKey(sessionId)) { return; } sessionActiveMap.put(sessionId, new Integer(0)); } 当sessionVisit 被调用以后,就重置此 Session 的未活动时间为 0。那么谁来调用此方法 呢?我们将会在讲解 SessionServiceLifeListener 类的时候介绍,此处可以认为只要客户端调 用应用服务器的方法的时候 sessionVisit 方法就会被自动调用。 剩下的问题就是实现定时清除超时 Session 了。要实现这个功能,首 先 要 设置定时任务, 以使定时清除超时 Session 的任务每隔一段时间运行一次。设置定时任务的方式有很多种, 比如 Quartz 就是一个非常优秀的定时任务工具。对于这个应用,使用 Quartz 就有点大材小 用了,最方便、高效的实现方式就是使用 java.util.Timer 类。 Timer 类的使用是非常简单的,比如下面的代码就实现了每两秒钟打印一次的功能: package com.cownew.Char11.Sec04; import java.util.Timer; public class TimerTest { public static void main(String[] args) { Timer timer = new Timer(false); timer.schedule(new java.util.TimerTask() { public void run() { System.out.println("hello"); } }, 0, 2 * 1000); System.out.println("program end!"); } } 使用 Timer 类的时候有如下几点需要特别注意: l Timer 类有一个参数为“boolean isDaemon”的构造函数,此处的 isDaemon 表示执行定时器任务的线程 是否是后台线程。如果是后台线程,则主程序终止的时候后台线程也就终止了;如果不是后台线程,除非 这个 Timer 任务停止,否则主程序无法停止。可以看到 TimerTest 类运行的时候“program end!”已经打印 出来了,可程序仍然没有终止,这是因为 timer 已经被设置为后台线程,而 timer 是无限次循环执行的,所 以程序就无法正常终止了。 l schedule 中的时间参数是以毫秒为单位的。 l Timer 中是采用 Object.wait(long time)来实现定时的,由于 Object.wait()不保证精确计时,所以 Timer 也不是一个精确的时钟。如果是实时系统,不能依赖 Timer。 【例 6.9】Session 超时清理任务。 编写一个从 TimerTask 继承的 SessionCleanerTimerTask 类作为 SessionManager 的内部 类,实现 TimerTask 的 run 方法,在 run 方法中进行 Session 超时的检测及处理: // Session 超时清理任务 protected class SessionCleanerTimerTask extends TimerTask { private int timeOut = ServerConfig.getInstance().getSessionTimeOut(); public void run() { Set idSet = sessionActiveMap.keySet(); Iterator idIt = idSet.iterator(); // 已经失效的 Session 的 Id 列表 List invalidIdList = new ArrayList(); while (idIt.hasNext()) { String id = (String) idIt.next(); // 自上次访问以来的时长,即未活动时间 Integer lastSpan = (Integer) sessionActiveMap.get(id); if (lastSpan.intValue() > timeOut) { invalidIdList.add(id); } //Session 的未活动增加一分钟 sessionActiveMap.put(id, new Integer(lastSpan.intValue() + 1)); } //清除超时的 Session for (int i = 0, n = invalidIdList.size(); i < n; i++) { String id = (String) invalidIdList.get(i); removeSession(id); sessionActiveMap.remove(id); } } } SessionCleanerTimerTask 是 SessionManager 的内部类,能够访问 sessionActiveMap 并调 用 removeSession 等方法。此任务每隔一段时间对所有的 Session 进行扫描,拣出超时的 Ses sion,然后把所有 Session 的未活动时间增加一分钟,处理完毕后统一清除超时的 Session。 为了使此任务与 SessionManager 一起启动,需要把任务的部署(schedule)工作放到 Sessi onManager 的构造函数中: private SessionManager() { super(); //要设置成后台线程,否则会造成服务器无法正常关闭 Timer sessionClearTimer = new Timer(true); //ONE_MINUTE 是 CTK 的 DateUtils 中定义的常量,表示一分钟 //ONE_MINUTE = 60000; int oneMin = DateUtils.ONE_MINUTE; //1 分钟以后开始,每隔一分钟探测一次 sessionClearTimer.schedule(new SessionCleanerTimerTask(),oneMin,oneMin); } 6.4.6 安全问题 Session 机制可以防止恶意攻击者跳过登录模块直接调用服务端方法,因为对系统安全 有影响的操作(查询数据、修改删除数据)都必须通过 SessionId 才能得到数据库连接,由于 恶意攻击者得不到一个正确的 SessionId,所以就无法正确调用这些方法。 Session 采用了 RandomGUID 来防止恶意攻击者通过猜测 SessionId 的方式来冒充合法 的用户进入系统,但这不足以防范恶意攻击者。恶意攻击者可以截获客户端发往应用服务器 的数据包并从数据包中分析出 SessionId,这样恶意攻击者就可以采用此 SessionId 来冒充合 法的用户进行系统的操作了。对此进行防范的比较好的方法就是采用 SSL 连接,SSL 连接 会对客户端和应用服务器之间的数据交换过程进行加密及数字签名,恶意攻击者根本无法正 确地截获数据,商业系统目前大都采用此种方式保证数据的安全,比如网上银行、银企平台 等。 为了在不安全的网络上安全保密地传输关键信息,Netscape 公司开发了 SSL 协议,后 来 IETF(Internet Engineering Task Force)把它标准化了,并且取名为 TLS,目前 TLS 的版本 为 1.0,TLS 1.0 的完整版本请参考 rfc2246(www.ietf.org)。 基于 TLS 协议的通信双方的应用数据是经过加密后传输的,应用数据的加密采用了对 称密钥加密方式,通信双方通过 TLS 握手协议来获得对称密钥。为了不让攻击者偷听、篡 改或者伪造消息,通信的双方需要互相认证,来确认对方确实是其所声称的主体。TLS 握手 协议通过互相发送证书来认证对方,一 般 来 说 只 需要单向认证,即客户端能确认服务器便可。 但是对于对安全性要求很高的应用往往需要双向认证,以获得更高的安全性。 可以向可信的第三方认证机构(CA)申请证书,也可以自己做 CA,由自己来颁发证书。 如果自己做证书颁发机构,可以使用 Openssl,Openssl 是能用来产生 CA 证书、证书签名的 软件,可以在其官方网站 http://www.openssl.org 下载最新版本。使用的时候要同时生成服务 器端证书和颁发并发布个人证书。服务器端证书用来向客户端证明服务器的身份,也就是说 在 SSL 协议握手的时候,服务器发给客户端的证书。个人证书用来向服务器证明个人的身 份,即在 SSL 协议握手的时候,客户端发给服务器端的证书。 第 7 章 元数据引擎 MDA(Model Driven Architecture)是由 OMG 定义的一个软件开发框架。它是一种基于 UML 以及其他工业标准的框架,支持软件设计和模型的可视化、存储和交换。MDA 能够 创建出机器可读和高度抽象的模型,这些模型独立于实现技术,以标准化的方式储存。MD A 提供了一种途径来规范化一个平台独立的系统,为系统选择一个特定的实现平台,并且把 系统规范转换到特定的实现平台。本章将对 MDA 的主要概念做一下介绍,然后开发一个体 现 MDA 思想的元数据引擎。 7.1 MDA 概述 随着信息技术的发展,一大批信息系统,如客户关系管理系统、自动办公系统、资金管 理系统等被开发出来,对提高管理效率起到了重要的作用。但是在这些系统的开发和后续的 扩展过程中存在很多长期无法解决的难题,最突出的问题就是设计与实现不一致。 大多数的信息系统没有分离的定义模型。软件开发虽然有建模过程,但是很多模型仅仅 仅在开发者脑中闪现,然后就消失了。开发人员经常使用单独代码的方法,依靠他们编写的 代码表示他们正在建立的系统模型。他们所做的任何“建模”都是以嵌入在代码中的编程的抽 象形式进行的,这些方式是通过程序库和对象层次的机制进行管理的,系统的可重用性差。 随着软件工程学的发展,越来越多的系统在开发的时候开始注意开发前的系统建模过 程,而且开发人员会按着最初的设计模型进行开发。软件的需求是一直在变的,客户会源源 不断地要求提供各种新的功能,开发人员对付这些新需求的手段就是修改代码。随着时间的 推移,系统不断地被修改,设计模型和代码之间的距离就越来越远。修改设计模型并不会对 系统有任何影响,直接修改代码就可以达到目的,所以很少有人去做设计模型与代码的同步 工作。即使我们修改了设计模型,这样的工作是否有效也值得怀疑,因为我们还会不断地修 改代码,难道我们要花更多的时间去不断修改设计模型吗?算了吧,设计模型不改也罢,有 代码就行了。 当开发团队发生人事变动的时候,来维护这个系统的人可能是一个新人,那么他面对的 就只有一堆已经过时的设计文档和天书一般的代码,这使得系统维护极其困难。 在很多团队中,详细设计是由经验丰富的开发人员完成的,他们会根据客户的需求设计 出有哪些实体对象、实 体 对 象 有 哪 些 字 段 、字 段 的 类 型 是 什么、实 体 对 象 之间的关系是什么、 实体对象对应的数据库表是什么等。开发人员拿到这个设计文档以后开始按着设计文档一步 步机械地实现:设计文档说有 Employee 这个对象代表员工,因此我就在数据库中创建一个 员工表 T_Employee,建立一个 EmployeeInfo JavaBean,并创建 EmployeeInfo.hbm.xml 配置 文件;设计文档说 Employee 有一个类型为 int、名称为 age 的字段代表年龄,因此我就在 T _Employee 表中增加一个名称为 FAge 的 int 类型字段,在 EmployeeInfo 中增加 age 属性, 并在 EmployeeInfo.hbm.xml 中加上 age 属性的映射配置……,最后还要同样机械地创建“员 工”的管理界面。开发人员心里一定在抱怨:我真的是软件蓝领呀,只能做这些机械的工作! 既然设计人员已经设计出来了这些模型,为什么不能把模型直接转换为数据库表、JavaBea n、配置文件、维护界面呢? 为了解决这个机械性地将设计文档转化成代码的重复性劳动问题,出现了很多代码生成 器。代码生成器分为两类: l 无源的代码生成器。这些生成器一般提供了一些向导,只要在它的向导页中设置相应的参数,比如实体 对象的名字是什么、有哪些字段、生成的 ORM 目标产品是什么,填写完毕以后就可以生成需要的代码了。 这类代码生成器的优点是非常简便,使用之前无须做准备工作。缺点是过程数据无法重复利用,如果想增 加一个字段的话,就必须重复上次的工作,一个字段一个字段地重新添加,如果改动不大的话大部分开发 人员都是直接去修改生成的代码。这样的生成器仅仅是一个一次性用品。 l 有源的代码生成器。这些代码生成器能够利用现有的一些信息来加速代码生成的过程。比如 Hibernate Tools、Middlegen 就可以根据现有数据库表来生成代码和配置文件,Rose 可以根据 UML 图生成 Java 代码。 这类代码生成器能够重复利用原有的工作成果,缺点是指导思想不明确,仅仅是一个代码生成器而已。比 如 HibernateTool 暗示开发人员要先建立数据库表,这其实是一种数据驱动的开发模式,使得开发人员先 要去思考数据是如何存储的、有哪些字段,而不是先思考系统对象之间的关系是怎么样的。这类代码生成 器的“源”具有随意性,可以是一张数据库表、可以是代码中的 JavaDoc,“源”只是为代码生成存在的,代码 生成以后就没有了任何作用。 OMG 在 2001 年 7 月发布了模型驱动体系结构(Model-Driven Architecture,MDA),确 定了以模型驱动体系结构代替对象管理体系结构(OMA)作为对象管理联盟未来的发展方向。 MDA 通过使用软件工程方法和工具,为分析、理解、设计、实现、维护、发展以及集 成原有信息系统提供了方法。在 MDA 中,模型不再仅仅是描述系统和辅助沟通的工具,而 是软件开发的核心和主要媒介。MDA 采用标准模型表述方法和标准建模方法来详细描述信 息系统,从业务需求描述、系统功能和体系结构设计、包含平台技术细节的系统实现等 3 个层次,MDA 都给出了相应的描述模型。 (1) 计算无关模型(Computation Independent Model,CIM),在系统需求分析阶段从纯业 务角度描述系统要完成的工作。 (2) 平台无关模型(Platform Independent Model,PIM),从功能设计角度描述系统的体 系结构,与技术细节无关。 (3) 平台特定模型(Platform Specific Model,PSM),描述基于特定平台的解决方案。 (4) 实现相关模型(Implementation Specific Model,ISM),面向最后的编程描述系统的 实现细节。 MDA 是一种用于构建系统应用架构的新方法,它的最大特点是通过定义一种与具体实 现技术或平台无关的应用系统规范,将系统的功能描述与基于具体平台的实现描述分离开 来。建模和模型映射技术是 MDA 的核心,对系统的不同方面进行不同抽象水平的建模,模 型之间通过模型映射机制实现模型映射,保证了模型的可追溯性。 运用 MDA 开发系统,开发人员可以获得最大限度的灵活性。当底层基础设施随时间发 生变化或系统功能需要扩展时,开发人员能够从稳定的、平台独立的模型重新生成代码,而 不必重新构建系统。模型的一些信息可以通过运行时取得,这样使得模型在系统开发中发挥 了更大的作用。 MDA 解决的是从需求收集到系统设计、从详细设计到系统开发、从产品测试到产品实 施等产品全生命周期的问题。本书无法去探讨 MDA 在整个生命周期中发挥的作用,案例中 最能体现 MDA 思想的就是元数据机制。 7.2 关于元数据 系统开发中存在各种各样的数据,比如 Tom 是一个年龄为 30 岁的男性员工、Liliy 是一 个 21 岁的女性员工、这张报表是今年第三季度的利润表、那张报表是今年上半年的销售波 动图、对话框上有三个按钮控件、窗口上有一个多行文本控件和一个保存按钮、这个 WebS ervice 提供了股票实时情况查询的服务、那个 WebService 提供了查询天气预报的服务。 以上数据存在很多共性的特征,这些特性都可以通过某种形式进行抽象。 对于“Tom 是一个年龄为 30 岁的男性员工”、“Liliy 是一个 21 岁的女性员工”,在数据库级 别就会抽象成含有 FId Varchar(50)、FName Varchar(50)、FAge(int)、FSex(int)四个字段的数 据库表 T_Employee,在 Hibernate 中就被抽象成含有 id、name、age、sex 四个字段的 JavaB ean 以及对应的 hbm 配置文件。 这些数据是平台无关的,在 描 述 “ Tom 是一个年龄为 30 岁的男性员工”这条数据的时候, 它即可以是保存在数据库中的,也可以是保存在 XML 配置文件中的,甚至有可能只是写在 一张便条上的。与此相反的是,对这些数据的抽象方式大都是与特定平台相关的,是 无法 移 植 的。比如要把数据的存储方式由数据库改为 XML 文档,那么就必须针对 XML 文件的存 取特点重新进行抽象。由于抽象方式是平台相关的,这些抽象出来的模型就不具有通用性, 无法通过统一的方式来读取它们。比如要读懂 T_Employee 这张表中的字段的含义就要去查 阅数据字典,要读懂便条上的“Tom 30 m”就要去询问写便条的人。 元数据(MetaData)是 MDA 中非常重要的概念。它通过统一的、平台无关的、规范的方 式对数据的模式特征进行描述,通过一个模型结构来表达通用的信息,它集设计模型、开发 模型与运行模型为一体。元数据具有如下几个作用。 (1) 元数据是独立于平台的,无论使用什么技术平台,元数据本身是不受影响的,这保 证了先期工作成果的效用最大化。 (2) 元数据是生成平台相关模型的基础,可以使用代码生成器等工具将元数据转换成平 台相关代码。 (3) 元数据为运行时系统提供了统一的可读的系统模型,系统运行时可以使得实体对象 通过运行时元数据模型来得知自身的结构、自身的特征、在系统模型中的位置以及与其他对 象之间的关系等。这样就可以从一个新的角度来观察、设计、开发系统。 (4) 元数据模型是系统运行不可或缺的部分,如果直接修改平台相关代码而不修改元数 据,就会造成系统运行异常,这就强迫保证元数据模型与代码同步,保证了设计模型和实现 代码的一致性。 (5) 元数据本身就是一个设计模型。系统设计人员可以使用元数据进行系统建模,在某 种程度上元数据可以取代 UML 图等传统的设计模型。设计人员将设计完成的元数据模型交 给开发人员,开发人员使用代码生成器将元数据转换成平台相关代码,然后就可以基于这些 平台相关代码进行开发了。元数据起到了设计人员和开发人员沟通桥梁的作用,设计人员的 工作立即就可以转换为可以运行的平台相关代码。 7.2.1 元数据示例 枚举类型在不同的系统中有不同的表示方式,而且有不同的模型描述方式(即枚举有哪 些项、项的值是多少等信息),有的平台还没有提供足够的模型描述方式。客户类型包括: 普通客户、会员客户、VIP 客户。 在 JDK 1.5 中可以表示为 enum CustomerTypeEnum{Normal, Member, VIP},取得 Cus tomerTypeEnum 枚举类型中定义的所有枚举项的方法为 CustomerTypeEnum.values(),取得“N ormal”这个字符串对应的枚举项的方法为 Enum.valueOf(CustomerTypeEnum.class, "Normal ")。 在 JDK 1.4 中使用 Apache Commons 包提供的 Enum 类可以表示为: public class CustomerTypeEnum extends org.apache.commons.lang.enums.Enum { public static DataTypeEnum Normal= new DataTypeEnum("Normal"); public static DataTypeEnum Member= new DataTypeEnum("Member"); public static DataTypeEnum VIP= new DataTypeEnum("VIP"); private DataTypeEnum(String name) { super(name); } } 取得 CustomerTypeEnum 枚举类型中定义的所有枚举项的方法为 EnumUtils.get- EnumL ist(CustomerTypeEnum.class),取得“Normal”这个字符串对应的枚举项的方法为 EnumUtils.ge tEnum(CustomerTypeEnum.class, "Normal")。 在 C#中,可以表示为 enum CustomerTypeEnum{Normal, Member, VIP},取得 Custome r- TypeEnum 枚举类型中定义的所有枚举项的方法为 Enum.GetNames(typeof(CustomerTypeE num)),取得“Normal”这个字符串对应的枚举项的方法为 Enum.Parse(typeof(CustomerTypeEn um), "Normal")。 在 Delphi 中,可以表示为 type CustomerTypeEnum=(Normal, Member, VIP);没有提供 取得 CustomerTypeEnum 枚举类型中定义的所有枚举项的方法,取得“Normal”这个字符串对 应的枚举项的方法也没有直接提供,必须借助 RTTI。 要将一个平台上的 CustomerTypeEnum 移植到另一个平台,必须用目标平台的枚举语法 重新改写,而且使用的取得枚举类描述信息的方式也要发生变化,这都给系统的移植带来了 很大的工作量。 【例 7.1】元数据示例。 为了解决这个问题,我们设计一个元数据模型: CustomerTypeEnum 提供一个描述这个元数据模型的描述类: //枚举描述类 public class EnumInfo { … //得到所有的枚举项 public EnumItemInfo[] getEnumItems(); //得到名字为 name 的枚举项的信息 public EnumItemInfo getEnumItem(String name); } //枚举项描述类 public class EnumItemInfo() { … //枚举项的名字 public String getName(); //枚举项的显示信息 public String getDisplayName(); } 提供一个读取元数据模型的 API: public class EnumMetaDataLoader { … //加载元数据类型 enumTypeName 对应的元数据模型 public EnumInfo loadEnum(String enumTypeName) { … } } 枚举元数据模型的描述类和读取元数据模型的 API 的实现代码仍然是平台相关的,因 为这些类都是要被特定平台使用的。因为 XML 解析在各个平台是大同小异的,所以这些描 述类和 API 的实现方式的移植是非常简单的。 使用这样的元数据模型我们还可以定义其他的枚举类型,比如: SexEnum 在 JDK 1.4 平台下,使用代码生成器将 SexEnum 的元数据模型转换成 JDK 1.4 下的枚 举代码: public class SexEnum extends org.apache.commons.lang.enums.Enum { public static SexEnum Male= new DataTypeEnum("Male"); public static SexEnum Female= new DataTypeEnum("Female"); private SexEnum String name) { super(name); } } 当要得到所有 SexEnum 定义的枚举项的时候,按如下方式调用: EnumInfo enumInfo = EnumMetaDataLoader.getInstance().loadEnum("SexEnum"); EnumItemInfo[] itemInfos = enumInfo.getgetEnumItems(); for(int i=0,n=itemInfos.length;i 枚举名称 可以定义一种模型来描述所有枚举元数据的共性特征,也就是枚举元数据的元数据(Me tadata of metadata)。这种对元数据进行抽象描述的形式被称为元元数据(MetaMetaData)。 7.2.3 设计时与运行时 元数据的直接表示形式被称为设计时元数据,而在运行的时候能被系统读取的形式(比 如上边的 EnumInfo)被称为运行时元数据。通常,运行时元数据描述的特性是设计时元数据 的特性的子集。 系统承担着设计模型与运行时模型的多重责任,而且元数据还作为代码生成器的“源”, 承载着描述目标代码的作用。这些责任之间有相交的部分,也有自己独特的部分。举例来说, 一个描述实体对象的元数据,它描述这个实体对象有哪些字段、字段的类型是什么、和其他 实体对象之间有什么关系等信息,而作为代码生成器的“源”,它还要描述一些目标平台特有 的东西,比如当目标平台为 Hibernate 的时候,就需要指定主键字段的生成策略、关联字段 的 LazyLoad 策略、Casade 策略等。从严格意义上来讲,为了维持元数据的平台无关性,这 些平台相关的特性是不能放在元数据中的,而应该放在一个描述平台相关属性的地方,不过 这样就使得元数据模型过于复杂。一个较好的策略是在元数据中增加一个专门存放这些平台 相关属性的区域。 运行时的元数据是要被平台相关代码访问的,如 果 运行时元数据中包含平台相关特性的 话,就会导致以后平台移植难度加大,而且也混淆了设计时语义与运行时语义之间的界限。 所以运行时的元数据中一定不能包含平台相关特性。 7.2.4 元数据设计的基本原则 除了上边提到的运行时的元数据中一定不能包含平台相关特性之外,在元数据的设计 中,“适可而止”也是需要铭记在心的核心原则。对元数据描述的范围要适可而止,不要试图 包罗万象。运行时元数据是能够给运行时的系统提供元数据的信息的,这在一定程度上简化 了系统的开发,但是切不可把应该写在代码中或者写到配置文件中的信息写到元数据中。比 如在实体对象元数据中,给字段增加了“allowNull”特性来表示此字段是否允许为空。系统保 存实体对象的时候,可 以读取此实体对象对应的元数据,进而取得所有字段的是否为空的特 性,从 而 对 数据进行校验。这是对运行时元数据非常合理的运用。但是如果试图把字段为空 时提示什么样的信息、字段最大长度是多少、字段是否进行加密操作等特性加入元数据的话 就会使得元数据模型过于庞大,这也违反了“适可而止”这一基本原则。如 果 元数据直接驱动 系统的运行过程,并且有取代程序代码的趋势的话,就说明设计人员对元数据概念理解错误 了,用 元 数据驱动系统运行虽然减少了代码的编写,但是这些本不应该放在元数据中的特性 是不完备的,一旦需要扩展就会遇到难以逾越的鸿沟。 由于客户需求的复杂性,模型结构不能表达出所有业务的处理过程,仍然存在需要利用 编程语言才能完成的业务功能。元数据模型解决大多数通用的问题,而对于具有差异性的问 题还是要通过编码来完成的,不应该让运行时元数据承担过多的运行时语义。 7.2.5 此“元数据”非彼“元数据” 元数据这个词汇并不是 MDA 发明的,在其他领域“元数据”早已经被使用了,在软件开 发领域,“元数据”也不是 MDA 中才有的。 JDK 5.0 的 annotation 机制也被称为元数据,它为属性的物理驻留位置提供了新的选择。 annotation 使得代码具有自解释的能力,代码变成能同时提供行为及自我描述能力的实体, 也就是说代码从一维变成二维的了。使用 JDK 提供的 API 就可以从代码中读取到这些描述 信息。 /** *@ filedName = "FName" type = "Varchar(44)" */ public String getName(){…} 这段代码中类似 JavaDoc 的东西就是 annotation,它描述了 name 这个属性对应着数据 库中的类型为“Varchar(44)”名称为“FName”的字段。Hibernate3、EJB3 都推荐并且支持这种 方式。 在 Hibernate 本身也有元数据机制,在 Hibernate 的包 org.hibernate.metadata 中的类就是 提供元数据支持的 API,通过它们能读取到一个实体对象有哪些字段、字段的类型是什么、 是否允许为空、是否关联其他的实体对象等。 JDK 的 annotation、Hibernate 中的元数据都符合元数据的定义,它们也是真正的元数据。 它们与 MDA 中的元数据的最主要区别就是是否具有平台无关性。很显然 JDK 的 annotation、 Hibernate 中的元数据都不能脱离它们所依赖的平台,元数据中有很多描述平台专有属性的 东西,无法作为一个跨平台的元数据引擎使用。不过这些元数据能反应平台的更多的细节, 如果合理利用将极大地提高开发效率。在后边关于 HibernateDTOGenerator 的分析中读者将 会看到我们是如何使用 Hibernate 的元数据来实现 DTO 产生器的。 7.3 实体元数据 为了使用 MDA 思想进行系统的设计开发,在 案例系统中为在系统中处于核心的数据实 体引入了元数据机制,系统建模、代码生成、系统开发、系统运行全部基于此元数据机制。 7.3.1 实体元数据格式 实体元数据中定义了实体的别名、对应的表名、实体的字段列表、字段的名称、字段的 别名、字段类型等,基本包含了数据实体的公共特征,实体元数据文件的扩展名为“.emf”。 下面是人员元数据的内容,各个标记的含义见注释: Person 人员 com.cownew.PIS.basedata T_BD_Person id id id FId STRING false 50 false age 年龄 FAge INTEGER false false name 姓名 FName STRING false 100 false number 编码 FNumber STRING false 50 false 实体元数据不仅能定义简单的字段,而且能定义实体之间的关联关系,下面是一个定义 了关联类型的系统操作员数据实体: User 系统用户 com.cownew.PIS.base.permission T_BS_User id id 主键 FId STRING false 50 false number 账号 FNumber STRING false 50 false password 密码 FPassword STRING false 50 false person 对应人 FPersonId STRING false 50 true MANYTOONE /com/cownew/PIS/basedata/Person.emf none false isFreezed 是否被冻结 FIsFreezed BOOLEAN false 这个元数据的定义和 Person 类似,唯一的区别在于这里定义了一个“person”字段关联到 “Person”实体: person 对应人 FPersonId STRING false 50 true MANYTOONE /com/cownew/PIS/basedata/Person.emf none false 对于关联字段只要设置 IsLinkProperty 为 true,在 LinkType 标记内指定关联的类型,在 LinkEntity 中指定关联的实体路径(注意实体路径以“/”分割,并且全部是相对于根包的相对 路径)即可。对于“一对多(ONETOMANY)”类型的字段还需要添加“***”标记表示被关联实体通过哪个字段反向关联本实体。 能够定义实体、定义字段、字段类型、实体关联、并定义了一些平台特有属性,这就是 一个比较完备的实体元数据模型了。 7.3.2 元数据编辑器 虽然元数据模型是比较简单易懂的,但是手工编写这样的元数据文件仍然是低效且易出 错的,直接查看元数据源文件也是非常烦琐的,为此我们开发了一个元数据文件的编辑器, 使用此编辑器就可以通过可视化的界面编辑和查看实体元数据文件。编辑器还内置了代码生 成功能,可以根据实体元数据文件生成 JavaBean 文件和 ORM 配置文件,目前仅支持 Hiber nate,不过由于设计时考虑到了可扩展问题,所以可以很轻松地支持其他 ORM 工具的代码 和配置文件的生成。 这个元数据文件的编辑器是基于 Eclipse 的插件机制进行开发的。本书不假定也不强迫 用户使用任何 IDE,所以这里不介绍这个插件的实现原理。这里只简单介绍一下这个插件的 使用,读者可以将此插件移植到当前使用的 IDE 上,当然也可以将其开发成一个独立的应 用程序。 【例 7.2】一个销售小票的建模过程(元数据编辑器的使用)。 下面以一个销售小票的建模过程来演示一下元数据编辑器的使用,图 7.1 是销售小票的 类图。 图 7.1 销售小票类图 (1) 安装 Eclipse,安装 CowNewStudio 插件。 在工程根目录下创建一个名字为“metadata”的文件夹,也可以直接打开案例工程,这个 工程已经建立好了“metadata”文件夹以及常用的实体元数据。本例子中假定您使用的是案例 工程。 (2) 在 metadata/com/cownew/目录下创建文件夹 demo。 在 demo 文件夹上右击,在弹出的快捷菜单中选择【新建】|【其他】命令,弹出向导对 话框,如图 7.2 所示,选中 CownewStudio 节点下的 Entity Model File creation wizard,单 击【下一步】按钮。 (3) 进入如图 7.3 所示的新建界面,在【文件名】文本框中输入 Goods.emf,然后单击 【完成】按钮。 图 7.2 选择向导 图 7.3 选择所在文件夹 (4) 然后系统会自动用实体元数据编辑器打开此元数据文件,如图 7.4 所示。 编辑器的主要选项卡有两个,其中 config 选项卡为元数据文件的可视化编辑界面,而 G oods.emf 选项卡为元数据文件的源码编辑器,可以直接在此处编辑元数据文件的源码。 在可视化编辑选项卡中,Name、PackageName 因为是系统预设的,所以是不可编辑的。 在 Alias 中输入“商品”,在 DBTableName 中输入“T_Demo_Goods”。 编辑器左下方的空白区域是字段列表区,实 体 定义的字段在此展示,可 以 单 击 add 按钮 新增字段,单击 remove 按钮删除选定的字段。字段属性的编辑在 eclipse 的属性视图中进行, 可以通过选择【窗口】|【显示视图】|【属性】命令打开此视图,可以通过单击编辑器中的 快捷按钮 open properties views 来打开属性视图。 图 7.4 元数据编辑器 (5) 单击 add 按钮增加 id 字段,在如图 7.5 所示的属性视图中编辑字段属性。 图 7.5 属性视图 (6) 按照同样方式增加 number、name 字段。 在 PrimaryKey 下拉列表框中选择 id 作为主键。然后单击 Eclipse 的保存图标完成商品 元数据的建模。 按照同样的步骤建立 SaleBill 实体元数据,增加 id、number、saleDate 属性。在增加 sa ler 属性的时候,此属性关联着系统中已经建立的 Person 元数据,因此设置 isLinkProperty 为 true,设置完毕后属性视图中的属性比普通属性多了一些内容,主要是 linkEntity、linkTy pe、casadeType 等。单击 linkEntity 属性右边的浏览 按钮,如图 7.6 所示,选择系统中已 经定义好的“Person 元数据”。 选择 linkType 属性为 MANYTOONE。SaleBillDetail 元数据没有建立,所以暂时不增加 details 属性。 (7) 按照同样步骤增加“SaleBillDetail”实体元数据。 回到 SaleBill 实体元数据编辑界面,增加 details 属性,设置 linkedEntity 指向 SaleBillD etail 实体,设定 linkType 属性为 OneToMany,从 keycolumn 属性的下拉列表框中选择 FHea dId 属性,表示 SaleBillDetail 实体通过 FHeadId 字段指向 SaleBill 实体。 图 7.6 选择关联元数据 (8) 建模完毕,下面开始生成代码和配置文件。同时选中 Goods.emf、SaleBill.emf、Sal eBillDetail.emf 三个文件,右击,在弹出的快捷菜单中选择 CownewStudio∣Generate Code from Model File 命令,弹出如图 7.7 所示的界面。 图 7.7 代码生成选项 (9) Target ORM 为生成的文件对应的 ORM 类型,目前支持 Hibernate2 和 Hibernate3。 按照图 7.7 进行设置,单击【完成】按钮,然后在 Eclipse 中就可以看到生成的文件了,如 图 7.8 所示。hbm 配置文件生成在 bizLayer 包下,JavaBean 生成在 common 包下。 图 7.8 生成的代码和配置文件 7.4 元数据引擎设计 前边介绍了元数据在系统设计和平台相关代码生成中的应用,本 节 介绍元数据的运行时 模型,这部分也是整个元数据中最复杂的部分。 7.4.1 实体元数据运行时模型 元数据中定义了很多丰富的属性,但并不是元数据所有的属性都对运行时系统有用,而 且有的属性也不应该放到运行时模型中,图 7.9 是运行时的元数据模型。 图 7.9 运行时的元数据模型 DataTypeEnum 是数据类型枚举,LinkTypeEnum 是关联类型枚举,EntityFieldModelInf o 为实体字段模型,EntityModelInfo 为整体的实体元数据模型。可以看到实体字段模型中去 掉了实体元数据中的“CascadeType”、“Constrained”、“Inverse”等属性,这些字段是 Hibernate 平 台特有的,所以是不能放到运行时模型中的。 为了解析元数据文件以生成运行时元数据模型,我们开发了元数据解析器类 EntityMet aDataParser(在包 com.cownew.PIS.framework.common.metaDataMgr 下),此类对外提供了一 个静态方法:public static EntityModelInfo xmlToBean(Document doc)。将元数据 XML 文件 的 Dom4j 对象 Document 作为参数调用,返回值就是这个实体元数据的模型 EntityModelInf o。因为 XML 文件中包含运行时元数据模型不需要的东西,所以此处不能使用 XStream 等 OXMapping 工具,而是使用 Dom4j 完成 XML 的解析。 7.4.2 分包及命名规范 在多层架构中,各个层之间有自己独立的不应被其他层访问的类,也有在层之间共享的 类。对这些不同共享层次的类进行分包可以保证清晰的系统分层,也可以简化各层的配置安 装。在案例系统中分为 4 种包:应用服务器包、Swing Client 端包,Web Client 端包,公共 包。比如,有一个提供天气预报服务的 Remoting Service,定义了服务接口 IWeatherForecas t,此服务接口是应用服务器、Swing Client、Web Client 都要访问的,所以要将它放到公共 包中;此接口的实现类 WeatherForecastImpl 只有应用服务器需要,因此定义在应用服务器 包中;如果编写了一个 Swing 客户端 WeatherForecastUI 访问此服务,WeatherForecastUI 就 要定义在 Swing Client 端包中;如果编写一个 Web 页面,此页面有 WeatherForecastForm 和 WeatherForecastAction 两个类,这两个类就要放到 Web Client 端包中。在案例系统中,应用 服务器包命名为 bizLayer、 为了简化系统的开发及规范文件管理,案例系统对文件目录结构做了如下约定,如果 实体元数据 DemoEntity 的名称(name)为 DemoEntity,包名(packageName)为 com.cownew. D emo,那么: l 生成的 hbm 配置文件 DemoEntity.hbm.xml 保存在 com.cownew.Demo.bizLayer 目录下。 l 生成的 JavaBean 文件必须以实体名加“Info”命名,即 DemoEntityInfo.java,它保存在 com.cownew.De mo.common 目录下。 l 此实体对应的 DAO 实现接口 IDemoEntityDAO 保存在 com.cownew.Demo.bizLayer 目录下。 l 此实体对应的 DAO 实现类 DemoEntityDAOImpl 保存在 com.cownew.Demo.bizLayer 目录下。 l 访问此接口的 Swing 客户端文件保存在 com.cownew.Demo.client 目录下。 l 访问此接口的 Web 端源码保存在 com.cownew.Demo.web 目录下。 前三条是必须遵守的规范,其他是建议遵守的规范。 系统运行时可能需要根据实体的 JavaBean 得到其对应的接口或者根据元数据的位置得 知接口的位置,只要根据上述规则就可以在 JavaBean、接口、元数据之间互相转换。为了 简化此操作,系统提供了一个工具类 NameUtils,位于包 com.cownew.PIS. framework.comm on.metaDataMgr 下,各个方法的作用如下。 l public static String getVOClassName(String entityPath):将实体路径名转化为 VO 类名,比如 getVO ClassName("/com/cownew/Person.emf")将返回“com.cownew.common. PersonInfo ”。 l public static String getPackageName(String entityPath):从实体路径名得到包路径名,比如 getPackag eName("/com/cownew/Person.emf")将返回“com.cownew”。 l public static String getEntityName(String entityPath):从实体路径名得到实体名,比如 getEntityNam e("/com/cownew/Person.emf")将返回“Person”。 l public static String getEntityPath(String infoClassName):将 VO 类名转化为实体路径名,比如 getEn tityPath("com.cownew.common.PersonInfo")将会返回“/com/cownew/ Person.emf”。 7.4.3 元数据加载器接口 元数据加载器最基本的功能就是根据给出的元数据路径返回对应的元数据运行时模型, 比如调用者想得到“人员”元数据运行时模型,只要将“/com/cownew/PIS/basedata/ Person.em f”作为参数传递给元数据加载器,加载器就可以返回对应于“人员”的 EntityModelInfo 类的实 例。 为了方便调用,元数据加载器还提供一些辅助功能,比如根据 VO 类加载元数据、根据 VO 类名加载元数据、一次性加载系统中所有的元数据模型。元数据的解析是非常耗时且耗 用内存的,最好提供元数据缓存以及缓存的持久化功能。 【例 7.3】设计元数据加载器接口。 代码如下: // 元数据加载器接口 public interface IMetaDataLoader { /** * 根据元数据路径加载元数据运行时模型 */ public EntityModelInfo loadEntityByEntityPath(String path) throws MetaDataException; /** * 根据 VO 类加载元数据运行时模型 */ public EntityModelInfo loadEntityByVOClass(Class voClass) throws MetaDataException; /** * 根据 VO 类名加载元数据运行时模型 */ public EntityModelInfo loadEntityByVOClass(String voClass) throws MetaDataException; /** * 加载所有的实体元数据的 path,List 中元素的类型为 String */ public List loadAllEntityPath() throws MetaDataException; /** * 保存元数据缓存 */ public void saveCache(); } 7.4.4 元数据热部署 元数据的热部署指的是当对元数据进行修改以后,无须重启服务器就能在系统中得到修 改以后的运行时元数据模型。元数据的热部署功能主要是考虑到应用服务器的启动过程以及 Spring、Hibernate 等的初始化过程都是需要一定的时间的,如 果 开发人员每次对元数据进行 修改都需要重启服务器就会降低开发效率。 7.4.5 元数据部署方式 元数据在系统中存在的方式可以有 3 种:①元数据和.class 二进制字节码文件放到一起; ②把 metadata 文件夹直接放到系统的目录下;③把所有元数据单独打包成一个 jar 文件。 元数据是和二进制字节码文件用途不同的文件,如 果 将 它 们 混 放 则 不 利 于 管 理,第一种 方式很自然地被否决了。 第二种方式将元数据单独管理,比第一种方式具有优势。当元数据数量变多以后,met adata 文件夹占据的空间也会变大,从文件夹中检索文件的速度也会较慢。 第三种方式将元数据打包,减小了元数据占据的空间并且加快了文件的检索速度。但是 在开发环境中采用此方式的话,开发人员每次新增、删除或者修改元数据以后都要对元数据 重新打包,这是非常低效和不现实的。 为了同时兼顾开发环境和运行环境的要求,元数据引擎将同时支持第二、第三种方式。 当处于开发环境时就采用第三种方式,当处于正式运行环境的时候则采用第二种方式。具体 采用哪种方式可以在配置文件中配置。 为了提高元数据的加载速度,元数据在客户端和服务器端各存在一份,并且客户端和服 务器端都有自己的元数据加载器实例。 7.5 元数据引擎实现 前面我们设计了元数据引擎,这一节将看一下元数据引擎的具体实现。 7.5.1 根据元数据路径加载元数据 IMetaDataLoader 接口中定义了根据元数据路径加载元数据运行时模型的方法 public En tityModelInfo loadEntityByEntityPath(String path),实现此方法有两种思路: l 编写一个专用的文件加载器,可以 按照 元数据路径从文件夹(开发环境部署模式)或者 Jar 文件(正式运行 环境部署模式)中加载元数据文件,然后再对元数据文件进行解析。 l 将元数据所在的文件夹(开发环境部署模式)或者 jar 文件(正式运行环境部署模式)放入类路径(ClassPath) 中,这样在程序中就可以通过 getClass().getResource- AsStream("/com/cownew/PIS/demo/Person.emf") 这样的方式来加载元数据文件了。 很显然,第二种方式实现起来最简单,所以在系统中将元数据所在的文件夹或者 jar 文 件放入类路径(ClassPath)中。 7.5.2 元数据枚举器 IMetaDataLoader 接口中定义了加载所有的实体元数据的 path 的方法 public List loadAl lEntityPath()。JDK 没有提供枚举类路径中某一类文件的方法,我们必须开发这样的功能, 我们称其为元数据枚举器。其接口定义如下: public interface IMetaDataEnumerator { public List listAll(); } 接口只定义了一个 listAll 方法,它返回所有的元数据的路径列表。 元数据有文件夹和 Jar 文件两种部署模式,为了同时支持这两种方式的元数据枚举,可 以编写一个元数据枚举器,在 这 个 枚 举 器 的 listAll 方法中判断是哪种部署模型,然后进行不 同的处理。这样实现违背了面向对象开发的基本原则,应 该 将这两种不同的行为分别都定义 在不同的枚举器中:DirectoryMetaDataEnumerator 类负责在文件夹中进行元数据枚举,而 J arFileMetaDataEnumerator 类负责在 Jar 文件中进行元数据枚举。 【例 7.4】元数据枚举器抽象类。 为了抽象出这两种枚举器的公共行为,首先编写一个实现了 IMetaDataEnumerator 接口 的抽象类 AbstractMetaDataEnumerator: // 元数据枚举器抽象类 public abstract class AbstractMetaDataEnumerator implements IMetaDataEnumerator { protected List list; public List listAll() { // 进行惰性初始化处理,子类是要实现 fillList 方法, // 在这个方法中向 list 中填充元数据路径即可,不用管惰性初始化问题 if (list == null) { fillList(); } return list; } protected abstract void fillList(); } 抽象类中进行了惰性初始化处理,子类只要实现 fillList 方法即可。 【例 7.5】Jar 文件元数据枚举器。 接着编写从 AbstractMetaDataEnumerator 继承的 JarFileMetaDataEnumerator 类: // Jar 文件元数据枚举器 import java.util.ArrayList; import java.util.Enumeration; import java.util.jar.JarFile; public class JarFileMetaDataEnumerator extends AbstractMetaDataEnumerator { protected JarFile jarFile; protected String suffix; public JarFileMetaDataEnumerator(JarFile jarFile, String suffix) { super(); this.jarFile = jarFile; this.suffix = suffix; } public void fillList() { list = new ArrayList(); Enumeration entries = jarFile.entries(); while (entries.hasMoreElements()) { Object obj = entries.nextElement(); String e = obj.toString(); if (e.endsWith(suffix)) { list.add("/" + e); } } } } JarFileMetaDataEnumerator 的构造函数接受两个参数 jarFile 为元数据所在的 Jar 包,su ffix 为待匹配文件的扩展名,一般传递“.emf”。 通过 JarFile 类的 public Enumeration entries()方法即可得到遍历 Jar 文件中所有文件的 枚举器,在遍历文件过程中判断文件扩展名,若等于 suffix 的话,就认为它是元数据文件。 【例 7.6】目录元数据枚举器。 接着编写目录元数据枚举器,代码如下: // 目录元数据枚举器 public class DirectoryMetaDataEnumerator extends AbstractMetaDataEnumerator { private File dir; private String suffix; public DirectoryMetaDataEnumerator(File dir, String suffix) { super(); this.dir = dir; this.suffix = suffix; } public void fillList() { list = new ArrayList(); list.addAll(getChidren(dir, suffix)); } private List getChidren(File f, String suffix) { List list = new ArrayList(); File[] listFiles = f.listFiles(); if (listFiles == null) { return list; } for (int i = 0, n = listFiles.length; i < n; i++) { File file = listFiles[i]; if (file.isFile()) { if (file.getPath().endsWith(suffix)) { String path = file.getPath().substring( dir.toString().length()); path = path.replace(File.separatorChar, '/'); if (!path.startsWith("/")) { path = "/" + path; } list.add(path); } } else { list.addAll(getChidren(file, suffix)); } } return list; } } 此类的核心代码就是 getChidren 方法,此方法采用递归的方式遍历一个目录下的所有文 件。当 File 的实例调用一个目录的时候,就调用其 listFiles()方法得到其下的所有直接子文 件(或者目录)。使用 File 类的 isFile()方法判断子文件是文件还是文件夹,如果 是 文 件夹 则 继 续 递 归 。文件的分隔符在不同的操作系统中有不同的形式,在 UNIX 系统中为“/”而在 Wind ows 中则为“\”,元数据路径遵守 Java 中的跨平台的要求,分隔符使用“/”,使用 path = path.r eplace(File.separatorChar, '/')方法将路径中的平台相关的文件分隔符替换成“/”。 7.5.3 元数据缓存 元数据的解析是非常耗时且耗用内存的,如 果 每 次 加载元数据运行时模型都要去解析 X ML 文件的话,会大大降低系统的运行效率,因此需要建立元数据的缓存机制。在 loadEntit yByEntityPath 方法中首先判断要加载的元数据是否已经解析过并放在缓存中了,如果已经 放在缓存中则只需到缓存中去取就可以;如果 没 有 在缓存中,则解析元数据,然后将解析结 果放入缓存。 由于缓存是建立在内存哈希表中的,当系统重启(包括客户端重启或者服务器端重启)以 后,缓存就消失了,元数据必须被再次加载才能放到缓存中。既然上次运行的时候已经解析 过元数据了,为什么不把上次的缓存保存下来呢?由于缓存是以哈希表的形式存在的,而哈 希表是可以序列化的,所以在系统即将关闭的时候将缓存保存到硬盘中,下次系统重启的时 候只要读取这个缓存文件并重建缓存即可。这项技术被称为“元数据延迟预编译”。更近一步, 在系统正式安装运行的之前就将所有元数据解析一遍然后保存到缓存中,进而将缓存保存到 文件中,系统运行的时候根本不用再去解析元数据,只要从缓存中读取就行了,这种技术被 称为“元数据预编译”,和 JSP 页面预编译技术类似。这种技术在处理大数据量的不可变 XML 文件时很有用处。 元数据缓存在加快系统运行的同时也给开发人员带来了麻烦。举例来说:开发人员开发 了实体元数据 Person.emf,然后在系统中运行并加载了此元数据,这样元数据的运行时模型 就保存到缓存中了。测试 Person.emf 的时候,开发人员发现要对 Person.emf 做一下修改, 于是他修改了 Person.emf,并保存了修改。当他再次调用元数据加载器加载此元数据的时候, 由于 Person.emf 的元数据模型已经在缓存中存在了,所以他得到的是未修改之前的元数据模 型。这种情况下,必须重启服务器和客户端,删除缓存文件。可 以 想象这是多么烦琐的过程, 为了解决这个问题,我们在配置文件中增加一个缓存开关,在开发环境下关闭缓存开关,在 正式运行时则打开缓存开关。 在 ClientConfig.xml 和 ServerConfig.xml 文件中同时增加下面针对元数据的配置项: false F:\我的程序\java\CowNewPIS\metadata C:\cownew\entity.cache 并在 ClientConfig.java 和 ServerConfig.java 文件中增加读取这些配置项的代码。 7.5.4 元数据加载器 【例 7.7】实现元数据加载器。 有了上面的这些类作为基础,下面就来看一下最核心的元数据加载器的实现代码: // 元数据加载器 package com.cownew.PIS.framework.common.metaDataMgr; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.jar.JarFile; import org.apache.log4j.Logger; import org.dom4j.Document; import org.dom4j.DocumentException; import org.dom4j.io.SAXReader; import com.cownew.ctk.common.ExceptionUtils; import com.cownew.ctk.io.ResourceUtils; public class MetaDataLoader implements IMetaDataLoader { //实体路径到元数据模型的缓存 private static Map pathEntityMap; //Vo 类名到实体路径的缓存 private static Map voClassPathEntityMap; //元数据的位置 private File metaDataPathFile; //元数据缓存文件的位置 private File entityCacheFile; //元数据枚举器 private IMetaDataEnumerator metaDataScanner; //元数据缓存开关 private boolean cacheEnable; /** * @param metaDataPath 元数据所在的路径 * @param entityCacheFile 元数据缓存文件位置 */ public MetaDataLoader(String metaDataPath, String entityCacheFile) { super(); metaDataPathFile = new File(metaDataPath); if (!metaDataPathFile.exists()) { throw new IllegalArgumentException("path:" + metaDataPath + " not found!"); } //如果元数据所在的路径是目录,则使用目录元数据扫描器 //否则使用 Jar 文件元数据扫描器 if (metaDataPathFile.isDirectory()) { metaDataScanner = new DirectoryMetaDataEnumerator(metaDataPathFile, "." + NameUtils.EMFEXT); } else { try { metaDataScanner = new JarFileMetaDataEnumerator(new JarFile( metaDataPathFile), "." + NameUtils.EMFEXT); } catch (IOException e) { throw ExceptionUtils.toRuntimeException(e); } } voClassPathEntityMap = new HashMap(); this.entityCacheFile = new File(entityCacheFile); cacheEnable = false; loadMetaCache(); } public void setCacheEnable(boolean cacheEnable) { this.cacheEnable = cacheEnable; } //从元数据缓存文件中加载缓存 private void loadMetaCache() { if (!this.entityCacheFile.exists()) { pathEntityMap = new HashMap(); } ObjectInputStream ois = null; try { ois = new ObjectInputStream(new FileInputStream( this.entityCacheFile)); pathEntityMap = (Map) ois.readObject(); } catch (Exception e) { //只要发生异常就认为元数据缓存不可用,因此就不加载缓存 //发生异常一般是由于类版本不一致造成的 //这种异常一般只在开发环境或者正式运行环境的系统 //升级过程中出现 pathEntityMap = null; Logger.getLogger(MetaDataLoader.class).error(e); } finally { ResourceUtils.close(ois); } if (pathEntityMap == null) { pathEntityMap = new HashMap(); } } public EntityModelInfo loadEntityByEntityPath(String path) throws MetaDataException { // 只有元数据缓存打开的时候才从缓存中读取 if (cacheEnable) { EntityModelInfo eInfo = (EntityModelInfo) pathEntityMap.get(path); if (eInfo != null) { return eInfo; } } InputStream inStream = null; try { inStream = getClass().getResourceAsStream(path); Document doc = new SAXReader().read(inStream); EntityModelInfo info = EntityMetaDataParser.xmlToBean(doc); pathEntityMap.put(path, info); return info; } catch (DocumentException e) { throw new MetaDataException( MetaDataException.LOADENTITYMETADATAERROR, e); } finally { ResourceUtils.close(inStream); } } public EntityModelInfo loadEntityByVOClass(Class voClass) throws MetaDataException { return loadEntityByVOClass(voClass.getName()); } public EntityModelInfo loadEntityByVOClass(String voClass) throws MetaDataException { String entityPath = (String) voClassPathEntityMap.get(voClass); if (entityPath == null) { entityPath = NameUtils.getEntityPath(voClass); voClassPathEntityMap.put(voClass, entityPath); } EntityModelInfo info = loadEntityByEntityPath(entityPath); return info; } public List loadAllEntityPath() throws MetaDataException { return metaDataScanner.listAll(); } //保存缓存 public void saveCache() { ObjectOutputStream oos = null; try { //无论缓存文件是否存在,重新创建文件 entityCacheFile.createNewFile(); oos = new ObjectOutputStream(new FileOutputStream(entityCacheFile,false)); oos.writeObject(pathEntityMap); } catch (Exception e) { Logger.getLogger(MetaDataLoader.class).error(e); } finally { ResourceUtils.close(oos); } } } 在初始化元数据枚举器的时候体现了基于接口编程的好处: if (metaDataPathFile.isDirectory()) { metaDataScanner = new DirectoryMetaDataEnumerator(metaDataPathFile, "." + NameUtils.EMFEXT); } else { metaDataScanner = new JarFileMetaDataEnumerator( new JarFile(metaDataPathFile), "." + NameUtils.EMFEXT); } 如果元数据文件是目录,则将 metaDataScanner 变量初始化为 DirectoryMetaDataEnume rator 的实例;如果元数据文件是文件,则将 metaDataScanner 变量初始化为 JarFileMetaData Enumerator 的实例。后面使用 metaDataScanner 的时候都是使用 IMetaDataEnumerator 接口声 明的方法,而不管是哪个实现类的。 这里保存缓存的方式是直接将 pathEntityMap 对象序列化到文件中,这样做的优点是简 单,缺点是当缓存中的对象对应类版本发生变化的时候(在开发环境中对类进行修改或者正 式运行环境进行版本升级),反序列化就会失败。只要从缓存文件中反序列化 pathEntityMap 的时候发生任何异常,就重建缓存: try { ois = new ObjectInputStream(new FileInputStream( this.entityCacheFile)); pathEntityMap = (Map) ois.readObject(); } catch (Exception e) { pathEntityMap = null; } … if (pathEntityMap == null) { pathEntityMap = new HashMap(); } 7.5.5 工具类 为了方便使用元数据引擎,系统中还内置了方便客户端和服务器端访问元数据的工具 类。 【例 7.8】内置方便客户端和服务器端访问元数据的工具类。 ClientMetaDataLoaderFactory 是客户端元数据加载器工厂,它位于 com.cownew. PIS.fr amework.client 包中。 具体代码如下: // 客户端元数据加载器工厂 public class ClientMetaDataLoaderFactory { private static MetaDataLoader loader; public static IMetaDataLoader getLoader() { if (loader != null) { return loader; } ClientConfig config = ClientConfig.getInstance(); String entityCacheFile = config.getEntityCacheFile(); String metaDataPath = config.getMetaDataPath(); loader = new MetaDataLoader(metaDataPath, entityCacheFile); loader.setCacheEnable ( ClientConfig.getInstance().isMetaCacheEnabled()); return loader; } } ServerMetaDataLoaderFactory 是服务器端元数据加载器工厂,它位于 com.cownew. PIS. framework.server.helper 包中。 具体代码如下: // 服务器端元数据加载器工厂 public class ServerMetaDataLoaderFactory { private static MetaDataLoader loader; public static IMetaDataLoader getLoader() { if (loader != null) { return loader; } ServerConfig config = ServerConfig.getInstance(); String entityCacheFile = config.getEntityCacheFile(); String metaDataPath = config.getMetaDataPath(); loader = new MetaDataLoader(metaDataPath, entityCacheFile); loader.setCacheEnable( ServerConfig.getInstance().isMetaCacheEnabled()); return loader; } static { Runtime.getRuntime().addShutdownHook(new Thread() { public void run() { super.run(); ServerMetaDataLoaderFactory.getLoader().saveCache(); } }); } } 客户端的元数据缓存的保存动作是放到注销方法中的。与客户端不同的是,服务器关闭 的时候没有一个准确的响应服务器关闭的入口,必须借助其他手段来实现服务器关闭时保存 元数据缓存的功能。 java.lang.Runtime 类有一个 addShutdownHook 方法,使用它我们可以把一个线程对象注 册到虚拟机中。当虚拟机正常关闭时,虚拟机会调用注册的所有线程对象,并运行它们。这 样把保存元数据缓存的方法写到一个线程对象中,然后调用 addShutdownHook 将其注册到 J VM 即可。 MetaDataHelper 位于 com.cownew.PIS.framework.common.metaDataMgr 包中,这个助手 类的构造函数要求一个 IMetaDataLoader 接口的实例。类中有一个方法:public String getPr opertyAlias(Class voClass, String property),通过这个方法可以得知一个实体值对象的某个 字段的别名。 例如调用 metaDataHelper.getPropertyAlias(PersonInfo.class, "age")就会返回“年龄”。通过 元数据引擎就能够得到数据的一些模型信息,这就是元数据的神奇之处。 MetaDataHelper 类目前只有这一个方法,读者可以根据需要增加更多的方法,比如得到 实体值对象的所有关联实体、得到实体值对象的某个字段的类型等。 7.5.6 待改进问题 到了这里,元数据引擎已经基本可用了。任何事情都不可能是完美的,这个元数据引擎 还可以在如下几方面优化: l 实现“元数据预编译”。目前实现的是“元数据延迟预编译”,在系统第一次运行的时候要进行元数据的解 析,所以运行速度会比较慢,采用“元数据预编译”以后就可以避免此问题。 l 优化元数据缓存策略。现阶段系统中业务模块较少,元数据量较少,所以对所有访问过的元数据都进行 了缓存。当系统的业务模块发展到一定规模以后,系统中会存在大量元数据,如果把这些元数据都加载到 缓存中必将大量地占用内存。说到这里,第一个反应就是将目前的缓存改造成固定大小的采用 LRU 淘汰算 法的缓存,但是这种淘汰算法只能解决客户端的问题。在真实的业务系统中,一个登录的客户端通常大部 分时间只运行一部分业务功能,比如会计登录系统的时候只会登录财务模块、库管只会登录仓库管理模块, 即使会访问其他模块也是暂时和短暂的;而应用服务器端则不同,应用服务器是为所有客户端提供服务的, 它会几率均等地访问系统的各个模块。在客户端只有部分元数据会被频繁访问,而在应用服务器端大部分 元数据都会被频繁地访问,所以说客户端的元数据访问具有局部性,而应用服务器端元数据访问的局部性 则不明显。对访问局部性很强的客户端采用 LRU 淘汰算法能够起到非常良好的作用,而如果对应用服务器 端元数据采用 LRU 淘汰算法则会导致缓存的抖动。基于此,我们建议对客户端元数据采用 LRU 淘汰算法, 而对应用服务器端则采取增加内存容量的方式来解决问题。 l 采用非递归算法改造 DirectoryMetaDataEnumerator 类。当目录结构过深或者目录数量过多的话,此实 现算法会导致系统性能急剧下降,甚至使机器发生故障。 第 10 章 层间数据传输 层间数据传输的过程就是服务的执行者将数据返回给服务的调用者的过程。在 非分布式 系统中由于有类似 Open session in view 这样的“怪胎解决方案”的存在,所以层间数据传输 的问题并没有充分暴露出来,但是在分布式系统中我们就能清楚地意识到层间数据传输的问 题,从 而 能 够 更合理的进行设计。为了暴露更多问题,本 章 讨 论 的 层间 数据传输假定的场景 是“服务器将执行的数据结果如何传递给远程客户端”,尽管在实际场景中服务的提供者和服 务的调用者有可能处于同一虚拟机中(比如 Web 端与应用服务部署在同一服务器中)。 10.1 什么是 DTO 在分布式系统中,客户端和服务器端交互有两种情形:第一个是客户端从服务器端读取 数据;第二个是客户端将本身的数据传递给服务器端。 当有客户端要向服务器端传输大量数据的时候,可 以 通过一个包含要传输的所有数据的 方法调用来完成。这在小数据量的时候缺点并不明显,但是如果要传递包含有大量信息的数 据的时候,这将变得难以忍受。下面的方法是任何人看了都会害怕的: public void save(String id,String number,String name,int type,int height, int width,BigDecimal weight,BigDecimal price,String description) 这种接口也是非常的脆弱,一旦需要添加或者删除某个属性,方法的签名就要改变。 当客户端要从服务器端取得大量数据的时候,可 以使用多个细粒度的对服务器端的调用 来获取数据。比如: ISomeInterface intf = RemoteService.getSomeInterface(); System.out.println("您要查询的商品的资料为:"); System.out.println("编号:"+intf.getNumber(id)); System.out.println("姓名:"+intf.getName(id)); System.out.println("类型:"+intf.getType(id)); System.out.println("高度:"+intf.getHeight(id)); System.out.println("宽度:"+intf.getWidth(id)); System.out.println("价格:"+intf.getPrice(id)); System.out.println("描述信息:"+intf.getDescription(id)); 这种方式中每一个 get***方法都是一个对服务器的远程调用,都需要对参数和返回值进 行序列化和反序列化,而且服务器进行这些调用的时候还需要进行事务、权限、日志的处理, 这会造成性能的大幅下降。如 果 没 有 使 用 客 户 端 事 务 的 话 还 会导致这些调用不在一个事务中 从而导致数据错误。 系统需要一种在客户端和服务器端之间高效、安全地进行数据传输的技术。DTO(Data Transfer Object,数据传送对象)是解决这个问题的比较好的方式。DTO 是一个普通的 Java 类,它封装了要传送的批量的数据。当客户端需要读取服务器端的数据的时候,服务器端将 数据封装在 DTO 中,这样客户端就可以在一个网络调用中获得它需要的所有数据。 还是上面的例子,服务器端的服务将创建一个 DTO 并封装客户端所需要的属性,然后 返回给客户端: ISomeInterface intf = RemoteService.getSomeInterface(); SomeDTOInfo info = intf.getSomeData(id); System.out.println("您要查询的商品的资料为:"); System.out.println("编号:"+info.getNumber()); System.out.println("姓名:"+info.getName()); System.out.println("类型:"+info.getType()); System.out.println("高度:"+info.getHeight()); System.out.println("宽度:"+info.getWidth()); System.out.println("价格:"+info.getPrice()); System.out.println("描述信息:"+info.getDescription()); 使用 DTO 的时候,一个主要问题是选择什么样的 DTO:这个 DTO 能够容纳哪些数据, DTO 的结构是什么,这个 DTO 是如何产生的。DTO 是服务器端和客户端进行通信的一个 协议格式,合理的 DTO 设计将会使得服务器和客户端的通信更加顺畅。在水 平开发模式(即 每个开发人员负责系统的不同层,A 专门负责 Web 表现层的开发,B 专门负责服务层的开 发)中,在项目初期合理的 DTO 设计会减少各层开发人员之间的纠纷;在垂直开发模式(即 每个开发人员负责不同模块的所有层,A 专门负责库存管理模块的开发,B 专门负责固定资 产模块的开发)中,虽然开发人员可以自由地调整 DTO 的结构,但是合理的 DTO 设计仍然 会减少返工的可能性。 实现 DTO 最简单的方法是将服务端的域对象(比如 Hibernate 中的 PO、EJB 中的实体 B ean)进行拷贝然后作为 DTO 传递。采用域对象做 DTO 比较简单和清晰,因为 DTO 与域模 型一致,所以了解一个结构就够了。这样做也免去了 DTO 的设计,使得开发工作变得更快。 这种做法的缺点是域 DTO 的粒度太大以至于难以满足客户端的细粒度的要求,客户端可能 不需要访问那些域中的所有属性,也可能需要不是简单地被封装在域中的数据,当域 DTO 不能满足要求的时候就需要更加细粒度的 DTO 方案。目前主流的 DTO 解决方案有定制 DT O、数据传送哈希表、数据传送行集。 10.2 域 DTO 域模型是指从业务模型中抽取出来的对象模型,比如商品、仓库。在 J2EE 中,最常见 的域模型就是可持久化对象,比如 Hibernate 中的 PO、EJB 中的实体 Bean。 在分布式系统中,域模型完全位于服务器端。根据持久化对象可否直接传递到客户端, 域对象可以分为两种类型:一 种是 服 务 器端 的 持 久化对 象 不 可 以直 接 传 递到 客 户 端 ,比 如 E JB 中的实体 Bean 是不能被传递到客户端的;一 种是持 久 化 对 象可 以 直 接传 递 到 客户 端 ,比 如 Hibernate 中的 PO 变为 detached object 以后就可以传递到客户端。 EJB 中的实体 Bean 不能直接传递到客户端,而且实体 Bean 不是一个简单的 JavaBean, 所以也不能通过深度克隆(deep clone)创造一个新的可传递 Bean 的方式产生 DTO。针对这 种情况,必须编写一个简单的 JavaBean 来作为 DTO。 下面是一个系统用户的实体 Bean 的代码: abstract public class SystemUserBean implements EntityBean { EntityContext entityContext; public java.lang.String ejbCreate(java.lang.String userId) throws CreateException { setUserId(userId); return null; } public void ejbPostCreate(java.lang.String userId) throws CreateException { } public void ejbRemove() throws RemoveException { } public abstract void setUserId(java.lang.String userId); public abstract void setName(java.lang.String name); public abstract void setPassword(java.lang.String password); public abstract void setRole(java.lang.Integer role); public abstract java.lang.String getUserId(); public abstract java.lang.String getName(); public abstract java.lang.String getPassword(); public abstract java.lang.Integer getRole(); public void ejbLoad() { } public void ejbStore() { } public void ejbActivate() { } public void ejbPassivate() { } public void unsetEntityContext() { this.entityContext = null; } public void setEntityContext(EntityContext entityContext) { this.entityContext = entityContext; } } 根据需要我们设计了如下的 DTO: public class SystemUserDto implements Serializable { private String userId; private String name; private String password; private Integer role; public void setUserId(String userId) { this.userId = userId; } public String getUserId() { return userId; } public void setName(String name) { this.name = name; } public String getName() { return name; } public void setPassword(String password) { this.password = password; } public String getPassword() { return password; } public void setRole(Integer role) { this.role = role; } public Integer getRole() { return role; } } 为了实现 DTO 的生成,这里还需要一个将实体 Bean 转换为一个 DTO 的工具,我们称 其为 DTOAssembler: public class SystemUserDtoAssembler { public static SystemUserDto createDto(SystemUser systemUser) { SystemUserDto systemUserDto = new SystemUserDto(); if (systemUser != null) { systemUserDto.setUserId(systemUser.getUserId()); systemUserDto.setName(systemUser.getName()); systemUserDto.setPassword(systemUser.getPassword()); systemUserDto.setRole(systemUser.getRole()); } return systemUserDto; } public static SystemUserDto[] createDtos(Collection systemUsers) { List list = new ArrayList(); if (systemUsers != null) { Iterator iterator = systemUsers.iterator(); while (iterator.hasNext()) { list.add(createDto((SystemUser) iterator.next())); } } SystemUserDto[] returnArray = new SystemUserDto[list.size()]; return (SystemUserDto[]) list.toArray(returnArray); } } 为一个实体 Bean 产生 DTO 是非常麻烦的事情,所以像 JBuilder 这样的 IDE 都提供了 根据实体 Bean 直接生成 DTO 类和 DTOAssembler 的代码生成器。 相对于重量级的实体 Bean 来说,使用 Hibernate 的开发人员则轻松多了,因为 Hibernat e 中的 PO 就是一个普通的 JavaBean 对象,而且 PO 可以随时脱离 Hibernate 被传递到客户端, 不用进行复杂的 DTO 和 DTOAssembler 的开发。不过缺点也是有的,当一个 PO 脱离 Hiber nate 以后如果客户端访问其并没有在服务器端加载的属性的时候就会抛出惰性加载的异常, 而如果对 PO 不采用惰性加载的话则会导致 Hibernate 将此 PO 直接或者间接关联的对象都取 出来的问题,在有的情况下这是灾难性的。在案例系统中是使用 DTOGenerator 的方式来解 决这种问题的。 无论是哪种方式,客户端都不能直接访问服务器端的域模型,但是客户端却希望能和域 模型进行协作,因此需要一种机制来允许客户端像操纵域模型一样操作 DTO,这样客户端 可以对 DTO 进行读取、更新的操作,就好像对域模型做了同样的操作一样。客户端对 DTO 进行新增、修改、删除等操作,然后将修改后的 DTO 传回服务器端由服务器对其进行处理。 对于实体 Bean 来讲,如果要处理从客户端传递过来的 DTO,就必须编写一个 DTODisasse mbler 来将 DTO 解析为实体 Bean: public class SystemUserDtoDisassembler { public static SystemUser fromDto(SystemUserDto aDto) throws ServiceLocatorException, CreateException, FinderException { SystemUser systemUser = null; ServiceLocator serviceLoc = ServiceLocator.getInstance(); SystemUserHome systemUserHome = (SystemUserHome) serviceLoc .getEjbLocalHome("SystemUserHome"); boolean bFind = false; try { systemUser = systemUserHome.findByPrimaryKey(aDto.getPkId()); bFind = (systemUser != null); } catch (FinderException fe) { bFind = false; } if (bFind != true) systemUser = systemUserHome.create(aDto.getPkId()); systemUser.setName(aDto.getName()); systemUser.setPassword(aDto.getPassword()); systemUser.setRole(aDto.getRole()); return systemUser; } } Hibernate 在这方面的处理就又比实体 Bean 简单了,主要把从客户端传来的 DTO 重新 纳入 Hibernate 的管理即可,唯一需要注意的就是版本问题。 (1) 使用域 DTO 会有如下好处: l 域模型结构可以在一次网络调用中复制到客户端,客户端可以读取、更新这个 DTO 而不需要额外的网 络调用开销,而且客户端还可以通过将更新后的 DTO 回传到服务器端以更新数据。 l 易于实现快速开发。通过使用域 DTO 可以直接将域模型在层间传输,减少了工作量,可以快速地构建 出一个应用。 (2) 但它也有如下的缺点: l 将客户端和服务器端域对象耦合在一起。如果域模型变了,那么相应的 DTO 也会改变,即使对于 Hibe rnate 这种 PO、DTO 一体的系统来说也会同样导致客户端的代码要重新编译或者修改。 l 不能很好地满足客户端的要求。客户端可能只需要域对象的 20 个属性中的一两个,采用域 DTO 则会将 20 个属性都传递到客户端,浪费了网络资源。 l 更新域对象很烦琐。客户端对 DTO 可能做了很多更新或者很深层次的更新,要探查这些更新然后更新 域对象是很麻烦的事情。 10.3 定制 DTO 域 DTO 解决了在客户端和服务器端之间传递大量数据的问题,但是客户端往往需要更 细粒度的数据访问。 例如,一件商品可能有很多属性:名称、编码、重量、型号、大小、颜色、生产日期、 生产厂家、批次、保质期等。而客户端只对其中一部分属性有要求,如果将包含所有属性的 商品对象到客户端的话,将会即浪费时间又浪费网络带宽,并对系统的性能有不同程度的影 响。 我们需要一种可定制的 DTO,使它仅封装客户端需要的数据的任意组合,完全与服务 器端的域模型相分离。定制 DTO 与域 DTO 的区别就是它不映射到任何服务器端的域模型。 从上述的商品例子,设想客户端只需要一些与产品质量有关的属性,在 这 种 情况下,应 该 创 造 一个封装了这些特定属性的 DTO 并传送给客户端。这个 DTO 是商品属性的一个子 集: public class GoodsCustomDTO implements Serializable { private Date productDate; private Date expireDate; private String batchNumber; public GoodsCustomDTO(Date productDate, Date expireDate, String batchNumber) { super(); this.productDate = productDate; this.expireDate = expireDate; this.batchNumber = batchNumber; } public String getBatchNumber() { return batchNumber; } public Date getExpireDate() { return expireDate; } public Date getProductDate() { return productDate; } } 一般来说,如 果 客 户 端 需要 n 个属性,那么应该创造一个包含且仅包含这 n 个属性的 D TO。使用这种方法,域模型的细节被隐藏在服务器中。这样开发人员把 DTO 仅当做普通的 数据,而不是任何像 PO 那样的服务端的业务数据。当然采用定制 DTO 系统中会有越来越 多的 DTO,所以很多开发者情愿使用粗糙一些的 DTO(即包含比需要的属性多的属性),而 不是重新编写一个新的 DTO,只要是返回的冗余数据不是太多,还是可以接受的。毕竟对 于任何一种技术,都需要寻求一个兼顾方便和性能的折衷点。 定制 DTO 主要用于只读操作,也就是 DTO 只能用来显示,而不能接受改变。既然定 制 DTO 对象仅仅是一个数据的集合,和任何服务端对象没有必然的关系,那么对定制 DTO 进行更新就是没有意义的了。 定制 DTO 的缺点如下: l 需要创建大量的 DTO。使用定制 DTO 会爆炸式地产生大量的对象。 l 客户端 DTO 的版本必须和服务器端的版本一致。由于客户端和服务器端都通过定制 DTO 通信,所以一 旦服务器端的 DTO 增加了字段,那么客户端的代码也必须重新编译,否则会产生类版本不一致的问题。 10.4 数据传送哈希表 使用定制 DTO 可以解决域 DTO 的数据冗余等问题,但是我们需要编写大量的 DTO 以 便返回给客户端它们所需要的数据,但是仍然有对象骤增、代码版本等问题。解 决这 一 问题 的方法就是使用数据传送哈希表。 JDK 中的哈希表(HashMap、HashTable 等)提供了一种通用的、可序列化的、可容纳任 意数据集合的容器。若使用哈希表作为 DTO 客户端和服务器端代码之间数据传送载体的话, 唯一的依赖关系就是置于键中用于表示属性的命名。 比如: ISomeInterface intf = RemoteService.getSomeInterface(); Map info = intf.getSomeData(id); System.out.println("您要查询的商品的资料为:"); System.out.println("编号:"+info.get("Number")); System.out.println("姓名:"+info.get("Name")); System.out.println("类型:"+info.get("Type")); System.out.println("高度:"+info.get("Height")); System.out.println("宽度:"+info.get("Width")); System.out.println("价格:"+info.get("Price")); 使用数据传送哈希表而不是域 DTO 或者定制 DTO 意味着增加了额外的实现复杂性, 因为客户端需要知道作为键的字符串,以便在哈希表中取得感兴趣的属性。 (1) 使用数据传送哈希表来进行数据传递的好处在于: l 有很好的可维护性。不必像定制 DTO 那样需要额外的类和重复的逻辑,取而代之的是通用的哈希表访 问。 l 维护代价低。无须任何服务器端编程就可以创建新的服务器端数据的视图,这样客户端可以动态地决定 需要哪些数据。 (2) 当然它也是有缺点的: l 需要服务器和客户端就键的命名达成一个约定。 l 无法使用强类型的编译时检查。当使用定制 DTO 或者域 DTO 的时候,传递给 set 的值或者从 get 方法 得到的值总是正确的,任何错误都能在编译时被发现。而使用数据传送哈希表时,属性访问的问题只有运 行时才能发现,而且读取数据的时候也要进行类型转换,这使得系统性能降低。 l 需要对基本类型进行封装。Java 中的基本数据类型,比如 int、double、boolean 等不能保存在哈希表中, 因为它们不是对象,所以在放入哈希表之前需要采用 Wrapper 类封装,不过在 JDK 1.5 以后的版本中不再 存在此问题。 10.5 数据传送行集 当开发报表或者开发大数据量的客户端的时候,直接用 JDBC 访问数据库是更好的方 式,但是如何将查询结果传递给客户端呢?最普通的解决方法是使用 DTO。例如,用 JDB C 查询每种商品的销售总量: select sum(saleBillDetail.FQty) as FTotalQty,saleBillDetail.FGoodsName,saleBillDetail.FGoodsNumber as FGoodsName from T_SaleBillDetail as saleBillDetail group by saleBillDetail.FgoodsId 我们可以创建一个定制 DTO 来传送这个查询的结果集: public class SomeDTO implements Serializable { private BigDecimal totalQty; private String goodsNumber; private String goodsName; public SomeDTO (BigDecimal totalQty,String goodsNumber,String goodsName) { super(); this.totalQty = totalQty; this.goodsNumber = goodsNumber; this.goodsName = goodsName; } public BigDecimal getTotalQty { return totalQty; } public String getGoodsNumber() { return goodsNumber; } public String getGoodsName() { return goodsName; } } 服务器会执行报表 SQL 语句得到一个包含每种商品销量的结果集,然后服务器将结果 集填装 DTO,结果集中的每一行都被转换成 DTO 并加入一个集合中,填装完毕,这个 DT O 集合就被传递到客户端供客户端显示报表用。 SQL 查询语句是千变万化的,因此对于每种不同的查询结果都要创建不同的 DTO。而 且数据已经表示在结果集的数据表的行中,将数据转换到一个对象集合中,然后在客户端又 将对象集合转换回由行和列组成的数据表显然是多余的。使用行集将原始的 SQL 查询结果 从服务器端直接返回给客户端是更好的做法。 javax.sql.RowSet 是 java.sql.ResultSet 的子接口,并且在 JDBC 3.0 中它被作为核心接口 取代 ResultSet。使用 RowSet 可以将结果集封装并传递到客户端,由于 RowSet 是 ResultSet 的子接口,所以客户端可以像操纵结果集一样对 RowSet 进行操作。这允许开发人员将查询 结果与数据库相分离,这样就无须手工将结果集转换成 DTO 然后又在客户端重新转换为表 格形式。 要将行集传递到客户端,那么这种行集必须是非连接的行集,也就是行集无须保持与数 据库的连接,完全可以脱离数据库环境。Sun 提供了一个实现如此功能的缓冲行集(Cached RowSet),这个实现在 Sun JDK 1.5 以后的版本中是包含在安装包中的,如果使用其他公司 的 JDK 或者 Sun JDK 1.4,则需要单独到 Sun 的网站上去下载对应的 Jar 包。 在商品销售总量报表的例子中,可 以 用 行 集获得查询的整个结果集,并将其传递到客户 端。为了创建这个行集,可以在服务端编写如下的代码: ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery(); RowSet crs = new CachedRowSet(); crs.populate(rs); return crs; 这样客户端就可以得到这个 RowSet 了。 (1) 用行集作为跨层数据传输的方法的好处是: l 行集对所有查询操作都提供了统一的接口。使用行集,所有的客户端都可以使用相同的接口满足所有的 数据查询需要。当客户端要访问的数据发生改变时行集接口是不变的。 l 消除了无谓的转换。行集可以直接从 SQL 执行的结果集中创建,而不用从结果集转换为 DTO,再由 DT O 转换为表格。 (2) 使用行集的缺点是: l 客户端必须知道查询结果集中列的名字。如 果查询 SQL 是隐藏在服务器端的话,表名 、表 之间的关系等 对客户端是透明的,但是客户端仍然需要知道结果集中列的名字,这样才能获得相关的值。 l 直接跳过了域模型。这是一种非面向对象的方式,有悖于基本的 J2EE 架构。这和 Delphi 中的“ClientDa taSet 伪三层”、.Net 中的“WebService 返回 DataSet”一样,当使用行集的时候并没有反映出来任何业务的 概念,它们只是一堆数据而已。Scott Hanselman 说:“从 WebService 返回 DataSet,是撒旦的产物,代表 了世界上一切真正邪恶的东西”。采用行集使得客户端与服务器端的域模型绑定得更加紧密,当需要对系统 重构的时候增加了工作量。 l 无法使用强类型的编译检查。客户端必须调用行集上的 getString、getBoolean、getBigDecimal 等方法 来获取数据,而不是调用 DTO 上的 getName,getNumber。这使得客户端的开发容易出现在运行时才能发 现的错误。 l 行集接口定义了可以修改行集数据并与数据库同步的机制,但是开发人员应该避免使用这种手段在客户 端更新数据。为了从根本上杜绝这种情况的发生。可以编写一个子集的行集实现类(或者简单地封装一个 C achedRowSet 实现)把所有的与数据更新相关的行集操作通过异常等方式屏蔽。 10.6 案例系统的层间数据传输 上面几节比较了常见的层间数据传输模式,这些模式都有各自的优缺点,必须根据实际 情况选择合适的模式,绝对不能生搬硬套、人云亦云。 考虑到系统架构的合理性,很多人都是强调避免将域对象直接传递到客户端的,因为这 样服务端的域模型就暴露给了客户端,造成客户端与服务器端的高度耦合。当域模型修改的 时候,就要造成客户端代码的修改或者重新编写。建议重新建立一个定制 DTO 类来传输必 要的数据,这样 DTO 与域模型就可以独立变化。 在大部分业务系统中,很多情况下 DTO 与域模型是无法独立变化的,比如客户要求为 一个商品增加一个“跟货员”的属性,并且要能在客户端显示、编辑这个属性。这种情况下我 们能做到只修改域模型而不修改 DTO 吗?如果客户想去掉“批次”属性,那么如果只从域模 型中去掉这个属性的话,客户端保留编辑这个属性的控件还有什么意义吗? 在大部分业务系统的普通逻辑中客户端界面通常反映的就是域模型,所以没必要进行屏 蔽,这样做只能增加无谓的工作量,降低开发效率。案例系统中在大部分情况下可以直接将 域模型当做 DTO 直接传递给客户端,只有在特殊的逻辑中才采用其他的层间数据传输模式。 前面提到对于 EJB 我们只能编写一个和实体 Bean 含有相同属性的 JavaBean 作为 DTO, 而由于 Hibernate 的强大功能,PO 的状态管理可以脱离 Session。问题的关键是我们不能把 一个脱了 Session 管理的 PO 直接传递到客户端,因为如果不采取 LazyLoad 的话,我们会把 服务器端所有与此 PO 相关联的对象都传递到客户端,这是任何人都无法忍受的。而如果采 用 LazyLoad 的话如何取得客户端要的所有数据呢?一个方法是在服务器端把客户端需要的 所有数据采用 BeanUtils 之类的工具一次性都装载好,然后传递给客户端: PersonInfo p = intf.getPersonByPK(id); BeanUtils.getProperty(p,"age"); BeanUtils.getProperty(p,"parent.name"); BeanUtils.getProperty(p,"parent.company.name"); return p; 采用 LazyLoad 以后,对象的类型其实是域对象的子类,其中包含了 CGLib、Hibernate 为实现 LazyLoad而添加的代码(也就是上边的 p 其实是类似于 PersonInfo$CGLib$Proxy的类 型)。如果使用 Hessian、Burlap 等传递的话会导致序列化问题,因为它们没有能力序列化如 此复杂的对象;如果使用 RMI、HttpInvoker 虽然可以将对象传递到客户端,但是由于反序 列化的需要,CGLib、Hibernate 的包是需要安装在客户端的,而且客户端的代码中一旦访问 了没有在服务端加载到的属性就会发生“Session 已关闭”的异常。那么采用一种更合理的形 式把 PO 传递给客户端就成为一个必须解决的问题。 10.7 DTO 生成器 将 PO 经过一定形式的转换,传递给客户端,使得客户端能够方便地使用传过来的 DT O,这就是 DTO 生成器要解决的问题。把问题具体分解,我们发现 DTO 生成器的功能如下: l 允许客户端指定加载哪些属性,这样 DTO 生成器就只加载客户端指定的属性,其他属性不予以加载, 这减小了网络流量。 l 屏蔽 CGLib、Hibernate 等的影响,客户端可以把 DTO 当成一个没有任何副作用的普通 JavaBean 使用。 l 允许客户端将修改后的 DTO 传递回服务器端进行更新。 采用简单的对象克隆方法无法得到满足要求的 DTO,因为克隆以后的对象仍然是和 PO 一样的被代理对象。更好的解决方法就是重新生成一个与 PO 的原有类型(比如 PersonInfo, 而非 PersonInfo$CGLib$Proxy)一致的 JavaBean 作为 DTO,然后将客户端需要的 PO 中的属 性赋值到 DTO 中。在 复 制过程中,因为 PO 以及关联的对象的信息已经被 LazyLoad 破坏得 乱七八糟了,所以我们必须要通过一种机制知道对象的字段有哪些、字段的类型是什么、字 段是否是关联对象、关联的类型是什么。了解这些信息的最好方式就是通过元数据,案例系 统的元数据机制就可以满足这个要求,而且 Hibernate 也有元数据机制能提供类似的信息, 下面就分别介绍通过这两种元数据机制实现 DTO 生成器的方法。 10.7.1 生成器接口定义 DTO 生成器要允许用户指定转换哪些属性,指定的属性的粒度精确到关联属性。下面 假定有如下的员工域模型:员工有自己的上司(manager)、部门(department)、电脑设备(comp uter),本身还有工号、姓名等属性。类图如图 10.1 所示。 图 10.1 员工类图 类图中的两个“0..*—1”的关联关系分别表示:一个部门可以有 0 到多个员工,一个员工 只属于一个部门;一台电脑可以被 0 到多个员工同时占用,但一个员工必须有且只有一台电 脑(这个假设比较特殊)。 假如客户端想获得员工的所有属性、所属部门、间接上级、间接上级的上级,那么只要 指定类似于下面的格式就可以了:department、manager.manager、manager.managermanager。 【例 10.1】定义一个 Selectors。 定义一个 Selectors 类来表示这些格式,代码如下: // 关联字段选择器 package com.cownew.PIS.framework.common.db; import java.io.Serializable; import java.util.HashSet; import java.util.Iterator; import java.util.Set; public class Selectors implements Serializable { private Set set; public Selectors() { set = new HashSet(); } public Selectors(int capacity) { set = new HashSet(capacity); } public boolean add(String string) { return set.add(string); } public boolean remove(String string) { return set.remove(string); } public Iterator iterator() { return set.iterator(); } public String toString() { return set.toString(); } /** * 产生以 property 为根的新的 Selectors */ public Selectors generateSubSelectors(String property) { property = property+"."; Selectors newSelector = new Selectors(); Iterator it = this.iterator(); while(it.hasNext()) { String item = it.next().toString(); if(item.startsWith(property)) { String subItem = item.substring(property.length()); newSelector.add(subItem); } } return newSelector; } /** * property 属性是否被定义在 Seletors 中了 */ public boolean contains(String property) { Iterator it = this.iterator(); while(it.hasNext()) { String item = it.next().toString(); if (item.startsWith(property)) { return true; } } return false; } } 调用 add 方法向 Selectors 中添加要取得的属性,支持级联方式,比如 manager.departme nt;调用 generateSubSelectors 方法产生以 property 为根的新的 Selectors,比如 Selectors 中有 manager.department、manager.manager、computer 三项,调用 generateSub- Selectors("manage r")以后就产生了 department、manager 两项;调用 contains 判断一个 property 属性是否被定 义在 Seletors 中了,比如 Selectors 中有 manager.department、manager.manager、computer 三 项,那么调用 contains("manager")返回 true,调用 contains("manager.computer")返回 false。 代码示例: Selectors s = new Selectors(); s.add("department"); s.add("manager.manager"); s.add("manager.manager.manager"); System.out.println(s.generateSubSelectors("manager")); System.out.println(s.contains("computer")); System.out.println(s.contains("manager.manager")); 运行结果: [manager.manager, manager] false true 接下来我们来定义 DTO 生成器的接口,这个接口将能够转换单个 PO 为 DTO,也可以 批量转换多个 PO 为 DTO,而且这个接口还应该允许用户指定转换哪些属性。 【例 10.2】定义 DTO 生成器的接口。 代码如下: // DTO 生成器接口 public interface IDTOGenerator { /** * 为多个 PO 产生 DTO * @param list DTO 列表 * @param selectors 哪些复合属性需要转换 */ public List generateDTOList(List list, Selectors selectors); /** * @see List generateDTOList(List list, Selectors selectors) * @param list DTO 列表 */ public List generateDTOList(List list); /** * 为单个 PO 产生 DTO * @param srcBean * @param selectors 哪些复合属性需要转换 */ public Object generateDTO(Object srcBean, Selectors selectors); public Object generateDTO(Object srcBean); } 对于没指定 Selectors 参数的 generateDTO、generateDTOList 方法则不返回关联属性的 值,只返回根一级的属性。 大部分 DTOGenerator 的子类都将会直接循环调用 generateDTO 来完成 generateDTOList 方法,所以定义一个抽象基类来抽象出这个行为。 【例 10.3】DTO 生成器抽象基类。 代码如下: // DTO 生成器抽象基类 package com.cownew.PIS.framework.bizLayer; import java.util.ArrayList; import java.util.List; import com.cownew.PIS.framework.common.db.Selectors; abstract public class AbstractDTOGenerator implements IDTOGenerator { public List generateDTOList(List list, Selectors selectors) { List retList = new ArrayList(list.size()); for (int i = 0, n = list.size(); i < n; i++) { Object srcOV = list.get(i); retList.add(generateDTO(srcOV, selectors)); } return retList; } public List generateDTOList(List list) { List retList = new ArrayList(list.size()); for (int i = 0, n = list.size(); i < n; i++) { Object srcOV = list.get(i); retList.add(generateDTO(srcOV)); } return retList; } } 10.7.2 Hibernate 的元数据 Hibernate 中有一个非常丰富的元数据模型,含有所有的实体和值类型数据的元数据。 Hibernate 提供了 ClassMetadata 接口、CollectionMetadata 接口和 Type 层次体系来访问 元数据。可以通过 SessionFactory 获取元数据接口的实例。 ClassMetadata catMeta = sessionfactory.getClassMetadata(Cat.class); Object[] propertyValues = catMeta.getPropertyValues(fritz); String[] propertyNames = catMeta.getPropertyNames(); Type[] propertyTypes = catMeta.getPropertyTypes(); Map namedValues = new HashMap(); for (int i = 0; i < propertyNames.length; i++) { if (!propertyTypes[i].isEntityType() && !propertyTypes[i].isCollectionType()) { namedValues.put(propertyNames[i], propertyValues[i]); } } 通过将持久化对象的类作为参数调用 SessionFactory 的 getClassMetadata 方法就可以得 到关于此对象的所有元数据信息的接口 ClassMetadata。下面是 ClassMetadata 接口的主要方 法说明。 l public String getEntityName():获取实体名称。 l public String getIdentifierPropertyName():得到主键的名称。 l public String[] getPropertyNames():得到所有属性名称(不包括主键)。 l public Type getIdentifierType():得到主键的类型。 l public Type[] getPropertyTypes():得到所有属性的类型(不包括主键)。 l public Type getPropertyType(String propertyName):得到指定属性的类型。 l public boolean isVersioned():实体是否是版本化的。 l public int getVersionProperty():得到版本属性。 l public boolean[] getPropertyNullability():得到所有属性的“是否允许为空”属性。 l public boolean[] getPropertyLaziness():得到所有属性的“是否 LazyLoad”属性。 l public boolean hasIdentifierProperty():实体是否有主键字段。 l public boolean hasSubclasses():是否有子类。 l public boolean isInherited():是否是子类。 ClassMetadata 接口有 getPropertyTypes()、getPropertyNullability()这样平面化的访问所有 字段属性的方法,这些方法是供 Hibernate 内部实现用的,在外部使用的时候我们常常需要 深入每个属性的内部,这样借助于 getPropertyNames()、getPropertyType(String propertyNam e)两个方法就可以满足要求了。 ClassMetadata entityMetaInfo = sessionFactory .getClassMetadata(destClass); String[] propertyNames = entityMetaInfo.getPropertyNames(); for (int i = 0, n = propertyNames.length; i < n; i++) { String propertyName = propertyNames[i]; Type propType = entityMetaInfo.getPropertyType(propertyName); … } getPropertyType(String propertyName)方法返回的类型为 Type,这个类型包含了字段的 元数据信息。Type 接口只是一个父接口,它有很多子接口和实现类,图 10.2 是它的主要的 子接口和实现类的结构图。 图 10.2 Type 接口层次图 Hibernate 中的集合类型的基类是 CollectionType,其子类分别对应着数组类型(ArrayTyp e)、Bag 类型(BagType)、List 类型(ListType)、Map 类型(MapType)、Set 类型(SetType)。而“多 对一”和“一对一”类型分别为 ManyToOneType 和 OneToOneType,它们的基类为 EntityType。 BigDecimal、Boolean、String、Date 等类型则属于 NullableType 的直接或者间接子类。 Type 接口的主要方法列举如下。 l public boolean isAssociationType():此类型是否可以转型为 AssociationType,并不表示此属性是关联 属性。 l public boolean isCollectionType():是否是集合类型。 l public boolean isComponentType():是否是 Component 类型,如果是的话必须能转型为 AbstractCo mponentType 类型。 l public boolean isEntityType():是否是实体类型。 l public boolean isAnyType():是否是 Any 类型。 l public int[] sqlTypes(Mapping mapping):取得实体各个字段的 SQL 类型,返回值的类型遵守 java.sql. Types 中的定义。 l public Class getReturnedClass():返回值类型。 l public String getName():返回类型名称。 【例 10.4】Hibernate 元数据接口调用。 示例代码如下: package com.cownew.Char15; import org.hibernate.SessionFactory; import org.hibernate.metadata.ClassMetadata; import org.hibernate.type.Type; import com.cownew.PIS.base.permission.common.UserInfo; import com.cownew.PIS.framework.bizLayer.hibernate.HibernateConfig; public class HibernateMetaTest { public static void main(String[] args) { SessionFactory sessionFactory = HibernateConfig.getSessionFactory(); ClassMetadata entityMetaInfo = sessionFactory .getClassMetadata(UserInfo.class); String[] propertyNames = entityMetaInfo.getPropertyNames(); for (int i = 0, n = propertyNames.length; i < n; i++) { String propertyName = propertyNames[i]; Type propType = entityMetaInfo.getPropertyType(propertyName); System.out.println(propertyName + "字段类型为" + propType.getReturnedClass().getName()); } if (entityMetaInfo.hasIdentifierProperty()) { String idPropName = entityMetaInfo.getIdentifierPropertyName(); Type idPropType = entityMetaInfo.getIdentifierType(); System.out.println("主键字段为:" + idPropName + "类型为" + idPropType.getReturnedClass().getName()); } else { System.out.println("此实体无主键"); } } } 运行结果: number 字段类型为 java.lang.String password 字段类型为 java.lang.String person 字段类型为 com.cownew.PIS.basedata.common.PersonInfo permissions 字段类型为 java.util.Set isSuperAdmin 字段类型为 java.lang.Boolean isFreezed 字段类型为 java.lang.Boolean 主键字段为:id 类型为 java.lang.String 10.7.3 HibernateDTO 产生器 【例 10.5】HibernateDTO 产生器示例。 代码如下: // HibernateDTO 产生器 package com.cownew.PIS.framework.bizLayer.hibernate; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; import org.hibernate.SessionFactory; import org.hibernate.metadata.ClassMetadata; import org.hibernate.proxy.HibernateProxyHelper; import org.hibernate.type.ArrayType; import org.hibernate.type.CollectionType; import org.hibernate.type.EntityType; import org.hibernate.type.ListType; import org.hibernate.type.MapType; import org.hibernate.type.SetType; import org.hibernate.type.Type; import com.cownew.PIS.framework.bizLayer.AbstractDTOGenerator; import com.cownew.PIS.framework.common.db.Selectors; import com.cownew.ctk.common.PropertyUtils; import com.cownew.ctk.common.ExceptionUtils; public class HibernateDTOGenerator extends AbstractDTOGenerator { private SessionFactory sessionFactory; public HibernateDTOGenerator(SessionFactory sessionFactory) { super(); this.sessionFactory = sessionFactory; } public Object generateDTO(Object srcBean, Selectors selectors) { try { return copyValueObject(srcBean, selectors); } catch (InstantiationException e) { throw ExceptionUtils.toRuntimeException(e); } catch (IllegalAccessException e) { throw ExceptionUtils.toRuntimeException(e); } } private Object copyValueObject(Object srcVO, Selectors selectors) throws InstantiationException, IllegalAccessException { // 取得被代理之前的类型 Class destClass = HibernateProxyHelper .getClassWithoutInitializingProxy(srcVO); Object newBean = destClass.newInstance(); ClassMetadata entityMetaInfo = sessionFactory .getClassMetadata(destClass); String[] propertyNames = entityMetaInfo.getPropertyNames(); for (int i = 0, n = propertyNames.length; i < n; i++) { String propertyName = propertyNames[i]; Type propType = entityMetaInfo.getPropertyType(propertyName); // 如果不是实体类型也不是集合类型,即普通类型,则直接拷贝这些属性 if (!(propType instanceof EntityType) && !(propType instanceof CollectionType)) { Object value = PropertyUtils.getProperty(srcVO, propertyName); PropertyUtils.setProperty(newBean, propertyName, value); } else if (selectors != null) { Selectors subSelector = selectors .generateSubSelectors(propertyName); // 如果是集合属性,并且用户在 selectors 中声明要求此属性, // 则复制这些属性 if (propType instanceof CollectionType && selectors.contains(propertyName)) { Object collValue = generateCollectionValue(srcVO, (CollectionType) propType, propertyName, subSelector); PropertyUtils.setProperty(newBean, propertyName, collValue); } // 如果是实体属性,并且用户在 selectors 中声明要求此属性 // 则复制这些属性 else if (selectors.contains(propertyName)) { Object oldVO = PropertyUtils.getProperty(srcVO, propertyName); if (oldVO != null) { Object obj = copyValueObject(oldVO, subSelector); PropertyUtils.setProperty(newBean, propertyName, obj); } } } } // 由于主键字段没有在 getPropertyNames 中,所以要复制主键 String idPropName = entityMetaInfo.getIdentifierPropertyName(); Object value = PropertyUtils.getProperty(srcVO, idPropName); PropertyUtils.setProperty(newBean, idPropName, value); return newBean; } /** * 生成 srcVO 的副本,关联属性由 subSelector 指定 */ private Object generateCollectionValue(Object srcVO, CollectionType type,String propertyName, Selectors subSelector) throws InstantiationException, IllegalAccessException { if (type instanceof SetType) { Set valueSet = new HashSet(); Set oldSet = (Set) PropertyUtils.getProperty(srcVO, propertyName); Iterator oldIt = oldSet.iterator(); while (oldIt.hasNext()) { Object oldValue = oldIt.next(); if (oldValue != null) { Object obj = copyValueObject(oldValue, subSelector); valueSet.add(obj); } } return valueSet; } else if (type instanceof ArrayType) { Object[] oldArray = (Object[]) PropertyUtils.getProperty(srcVO, propertyName); Object[] valueArray = new Object[oldArray.length]; for (int i = 0, n = oldArray.length; i < n; i++) { Object oldValue = oldArray[i]; if (oldValue != null) { valueArray[i] = copyValueObject(oldValue, subSelector); } } return valueArray; } else if (type instanceof ListType) { List oldList = (List) PropertyUtils .getProperty(srcVO, propertyName); List valueList = new ArrayList(oldList.size()); for (int i = 0, n = oldList.size(); i < n; i++) { Object oldValue = oldList.get(i); if (oldValue != null) { valueList.add(copyValueObject(oldValue, subSelector)); } } return valueList; } else if (type instanceof MapType) { Map oldMap = (Map) PropertyUtils.getProperty(srcVO, propertyName); Map valueMap = new HashMap(oldMap.size()); Set keySet = oldMap.keySet(); Iterator keyIt = keySet.iterator(); while (keyIt.hasNext()) { Object key = keyIt.next(); Object oldValue = oldMap.get(key); if (oldValue != null) { valueMap.put(key, copyValueObject(oldValue, subSelector)); } } return valueMap; } else if (type instanceof SetType) { Set oldSet = (Set) PropertyUtils.getProperty(srcVO, propertyName); Set valueSet = new HashSet(oldSet.size()); Iterator it = oldSet.iterator(); while (it.hasNext()) { Object oldValue = it.next(); if (oldValue != null) { Object copyValue = copyValueObject(oldValue, subSelector); valueSet.add(copyValue); } } return valueSet; } throw new IllegalArgumentException("unsupport Type:" + type.getClass().getName()); } public Object generateDTO(Object srcBean) { try { return copyValueObject(srcBean); } catch (InstantiationException e) { throw ExceptionUtils.toRuntimeException(e); } catch (IllegalAccessException e) { throw ExceptionUtils.toRuntimeException(e); } } /** * 得到 srcVO 的副本 */ private Object copyValueObject(Object srcVO) throws InstantiationException,IllegalAccessException { Class destClass = HibernateProxyHelper .getClassWithoutInitializingProxy(srcVO); Object newBean = destClass.newInstance(); ClassMetadata entityMetaInfo = sessionFactory .getClassMetadata(destClass); String[] propNames = entityMetaInfo.getPropertyNames(); for (int i = 0, n = propNames.length; i < n; i++) { String propName = propNames[i]; Type fType = entityMetaInfo.getPropertyType(propName); if (!(fType instanceof EntityType) && !(fType instanceof CollectionType)) { Object value = PropertyUtils.getProperty(srcVO, propName); PropertyUtils.setProperty(newBean, propName, value); } } String idPropName = entityMetaInfo.getIdentifierPropertyName(); Object value = PropertyUtils.getProperty(srcVO, idPropName); PropertyUtils.setProperty(newBean, idPropName, value); return newBean; } } 类的核心方法就是 copyValueObject、generateCollectionValue,它们分别负责生成关联实 体和集合属性。 在 copyValueObject 中首先调用 Hibernate 的工具类 HibernateProxyHelper 提供的 getClas sWithoutInitializingProxy 方法来得到被 LazyLoad 代理之前的类名,比如: getClassWithoutInitializingProxy(session.load(PersonInfo.class, id))返回 PersonInfo.class。 getClassWithoutInitializingProxy(new PersonInfo())也将返回 PersonInfo.class。 这是去掉 LazyLoad 这个包袱的最重要的一步。 接着用反射的方法得到 getClassWithoutInitializingProxy 方法返回的类型的实例。 最后使用 Hibernate 的元数据 API 逐个判断实体的各个字段的属性,如果字段是普通字 段(既不是实体类型也不是集合类型)则直接使用 PropertyUtils 来拷贝字段属性;如果字段是 集合属性,并且用户在 selectors 中声明要求此属性,则调用 generateCollectionValue 方法来 生成新的集合属性;如 果是实体属性,并且用户在 selectors 中声明要求此属性,则 递 归 调用 copyValueObject 方法来取得这个实体属性。需要注意的是在字段是非普通属性的时候,需 要调用 Selectors 的 generateSubSelectors 方法来更换 Selectors 的相对根,这就达到了从左到 右的逐级深入地取得关联属性值的目的。 generateCollectionValue 方法用来根据源 bean 生成新的集合属性。因为 Hibernate 中集合 字段的类型都是基于接口的,所以此处我们使用这些接口的任意实现类就可以。 调用代码示例: SessionFactory sessionFactory = HibernateConfig.getSessionFactory(); Session session = sessionFactory.openSession(); UserInfo userInfo = (UserInfo) session.load(UserInfo.class, "1111111111111111111-88888888"); HibernateDTOGenerator dtoGenerator = new HibernateDTOGenerator( sessionFactory); Selectors selectors = new Selectors(); selectors.add("person"); UserInfo newUser1 = (UserInfo) dtoGenerator.generateDTO(userInfo); System.out.println(newUser1.getNumber()); UserInfo newUser2 = (UserInfo) dtoGenerator.generateDTO(userInfo, selectors); System.out.println(newUser2.getPerson().getName()); 10.7.4 通用 DTO 生成器 HibernateDTOGenerator 比较完美地解决了 DTO 的产生的问题,由于使用 Hibernate 本 身的元数据机制,所以这个 DTOGenerator 可以脱离案例系统使用。并不是所有的 ORM 工 具都提供了像 Hibernate 一样的元数据机制,所以对于这样的 ORM 就必须使用案例系统的 元数据机制。代码的实现和 HibernateDTOGenerator 非常类似,不过由于根据 PO 得到 DTO 的方式在各个 ORM 之间的差异非常大,比如在 Hibernate 中 PO 的类名就是 DTO 的类名, 而在 EJB 的实体 Bean 中 PO 和 DTO 的类名没有直接关系,这就需要使用某种命名约定来决 定 DTO 的类名(比如 DTO 类名为实体 Bean 类名加“DTO”)。CommonDTOGenerator 只能是 一个抽象类,把根据 PO 得到 DTO 等不能确定的逻辑留到具体的子类中实现。 【例 10.6】通用 DTO 生成器示例。 通用 DTO 生成器的代码如下: // 通用 DTO 生成器 abstract public class CommonDTOGenerator extends AbstractDTOGenerator { public Object generateDTO(Object srcBean, Selectors selectors) { try { return copyValueObject((IValueObject) srcBean, selectors); } catch (InstantiationException e) { throw ExceptionUtils.toRuntimeException(e); } catch (IllegalAccessException e) { throw ExceptionUtils.toRuntimeException(e); } } public Object generateDTO(Object srcBean) { try { return copyValueObject((IValueObject) srcBean); } catch (InstantiationException e) { throw ExceptionUtils.toRuntimeException(e); } catch (IllegalAccessException e) { throw ExceptionUtils.toRuntimeException(e); } } /** * 得到 bean 的真实类,也就是剥离了 lazyload 等 AOP 方面以后的类, * 比如在 hibernate 中就是: * return HibernateProxyHelper * .getClassWithoutInitializingProxy(bean) */ protected abstract Class getRealClass(Object bean); private IValueObject copyValueObject(IValueObject srcVO, Selectors selectors)throws InstantiationException, IllegalAccessException { Class destClass = getRealClass(srcVO); IValueObject newBean = (IValueObject) destClass.newInstance(); EntityModelInfo eInfo = ServerMetaDataLoaderFactory.getLoader() .loadEntityByVOClass(destClass); List fields = eInfo.getFields(); for (int i = 0, n = fields.size(); i < n; i++) { EntityFieldModelInfo fInfo = (EntityFieldModelInfo) fields.get(i); if (!fInfo.isLinkProperty()) { Object value = PropertyUtils.getProperty(srcVO, fInfo.getName()); PropertyUtils.setProperty(newBean, fInfo.getName(), value); } else if (selectors != null) { Selectors subSelector = selectors.generateSubSelectors (fInfo.getName()); if (fInfo.getLinkType().equals(LinkTypeEnum.ONETOMANY) && selectors.contains(fInfo.getName())) { //TODO:支持其他集合属性,比如 List Set valueSet = new HashSet(); Set oldSet = (Set) PropertyUtils.getProperty(srcVO, fInfo .getName()); Iterator oldIt = oldSet.iterator(); while (oldIt.hasNext()) { IValueObject oldValue = (IValueObject) oldIt.next(); if (oldValue != null) { IValueObject obj = copyValueObject(oldValue, subSelector); valueSet.add(obj); } } PropertyUtils.setProperty(newBean, fInfo.getName(), valueSet); } else if (selectors.contains(fInfo.getName())) { Object oldVO = PropertyUtils .getProperty(srcVO, fInfo.getName()); if (oldVO != null) { IValueObject obj = copyValueObject( (IValueObject) oldVO, subSelector); PropertyUtils.setProperty(newBean, fInfo.getName(), obj); } } } } return newBean; } private IValueObject copyValueObject(IValueObject srcVO) throws InstantiationException, IllegalAccessException { Class destClass = getRealClass(srcVO); IValueObject newBean = (IValueObject) destClass.newInstance(); EntityModelInfo eInfo = ServerMetaDataLoaderFactory.getLoader() .loadEntityByVOClass(destClass); List fields = eInfo.getFields(); for (int i = 0, n = fields.size(); i < n; i++) { EntityFieldModelInfo fInfo = (EntityFieldModelInfo) fields.get(i); if (!fInfo.isLinkProperty()) { Object value = PropertyUtils.getProperty(srcVO, fInfo.getName()); PropertyUtils.setProperty(newBean, fInfo.getName(), value); } } return newBean; } } 在 CommonDTOGenerator 中将 getRealClass 方法设为抽象方法等待子类实现。在 copyV alueObject 方法中目前支持的集合类型仅支持 Set 类型的属性,以后可以增加对 List、Map、 数组等类型的支持。 如果规定 DTO 类名为实体 Bean 类名加“DTO”,就可以编写下面的 EJBDTOGenerator: public class EJBDTOGenerator extends CommonDTOGenerator { protected Class getRealClass(Object bean) { String entityBeanClassName = bean.getClass().getName(); String dtoClassName = entityBeanClassName + "DTO"; try { return Class.forName(dtoClassName); } catch (ClassNotFoundException e) { throw ExceptionUtils.toRuntimeException(e); } } } 采用案例系统的元数据来实现 DTOGenerator 就可以保证不依赖于具体 ORM,这就是 元数据的好处,坏处就是这个 EJBDTOGenerator 是无法将案例系统的元数据机制剥离的。 第 16 章 Web 客户端框架 虽然 Swing 客户端的表现力非常强,但是由于 Swing 客户端的部署安装非常麻烦,所 以在需要无固定地点登录系统进行数据查询的场合,必须采用 Web 方式来实现。本章就来 讨论使用 Web 实现客户端的一些实现技术。 16.1 Web 端部署方式与相关辅助类 根据分层开发的思想,Web 端通常需要使用应用服务器提供的服务,因此像 Swing 客 户端一样需要一个部署方式以及调用应用服务器服务的方式。和 Swing 客户端方式不同的 是运行 Web 端的服务器有可能和应用服务器是运行在同一个服务器的同一个 JVM 下的,这 种方式被称为“并置应用”部署模式。当处于公网的时候这种方式是不被推荐的,因为应用服 务器的所有服务接口暴露给了外部访问者,这样增加了系统被攻击的概率,比较好的方式是 将应用服务器和数据库等都放在防火墙内部,对外只暴露 Web 服务器。不过如果应用只是 供内网使用的,采用并置的方式则提高了系统的运行速度,因为同一 JVM 内的调用会比跨 JVM 的调用速度更快。 无论那种部署方式,Web 端都是需要访问元数据以及应用服务器的,所以同样需要元 数据加载器、远程服务定位器等,而且通常需要一个允许配置应用服务器地址、元数据地址 等的配置文件。 Web 端的配置文件的结构目前和 ClientConfig 是一致的,因此直接拷贝一份 ClientConf ig.xml 重命名为 WebConfig.xml 即可,读取 WebConfig.xml 的 WebConfig.java 也是和 Client Config.java 相似的,所以同样拷贝一份 ClientConfig.java 并稍作修改以后保存为 WebConfig. java。虽然 ClientConfig.java 的实现和 WebConfig.java 是非常相似的,但是不能通过继承等 方式来实现代码重用,因为这种相似是临时的、不稳定的,在这种情况下采用“Ctrl+C”、“Ctr l+V”的“代码重用”方式是一种比较合理的选择。 16.1.1 SessionId 的存储 和 Swing 一样,Web 端在进入系统之前也首先要登录,登录成功以后服务器会分配一 个 SessionId,在 Swing 客户端中将 SessionId 保存到 RemoteServiceLocator 的一个静态变量 中,但是由于 HTTP 服务的无状态特性并且一个 Web 服务器是同时为多个登录客户服务的, 所以不能将这个 SessionId 保存到一个普通静态变量中。必须采取其他的机制将这个 Session Id 保存到能唯一标识这个登录用户的地方。 在 Web 中有如下几种方式用来存储 SessionId。 (1) 隐藏字段 通过 HTML 的 Hidden 标记来保存信息: 这种方式需要在每个页面中都增加这个隐藏字段,在 服 务 器 端 处 理表单的时候也要及时 读取这个字段,在每次将响应发送回客户端的时候也要将 SessionId 写到这个字段中。而且 这种方式有保密性问题,恶意攻击者可以很容易地从 HTML 源代码中找到 SessionId。 (2) URL 重写技术 把 SessionId 写到页面链接的尾部,例如: 库存报表 改为: 库存报表 这种方式同样存在安全问题。 (3) Cookie 可以把一些小的信息片段保存在客户端,在 以 后 的 使 用 过 程中,服务器端可以去读取客 户端的 Cookie。这种方式无须在每个页面中加入处理代码,用的时候直接调用服务端的方 法就可以读取 Cookie,安全性也比较好,但是由于早期用户对 Cookie 的误解造成很多用户 将浏览器的 Cookie 功能关闭,这样系统就不能正常运行了。 (4) Session 这里的 Session 和应用服务器中的 Session 原理是一致的,不过是两个不同的东西,为 了避免混淆,我们称这里的 Session 为 WebSession。WebSession 是 Web 服务器为每个登录 客户端在服务器中设立的一个数据区,客户端只要将数据放入 WebSession 中,以后的调用 中就可以直接从 WebSession 中取得这些数据了。通过这种方式分配的 SessionId 是保存在 W eb 服务器端的,系统的安全性得到了提高,因此我们决定将分配的 SessionId 保存在 WebSe ssion 中。 客户端浏览器登录 Web 服务器的时候,Web 服务器同样会为此客户端分配一个 Session Id 以唯一标识此用户,不过这个 WebSessionId 是不用去关心的,Web 服务器会正确处理 We bSessionId 的保存。如果浏览器支持 Cookie 的话,Web 服务器就会将 WebSessionId 保存在 Cookie 中;如 果 浏览器不支持 Cookie 的话,就采用 URL 重写技术将 SessionId 通过 URL 传 递。 和 WebSession 对应的是 HttpSession 接口,我们只要将应用服务器分配的 SessionId 作 为一个 Attribute 保存到 WebSession 中即可,WebSession 中每个 Attribute 必须有一个名字作 为键,此处将保存 SessionId 的键命名为“SessionId”并定义在 WebConstant 类中作为常量: public final static String SESSIONID = "SessionId"; 为了简化调用,创建一个 WebContextHelper 类,并增加一个 getSessionId 静态方法用来 从 WebSession 中取得 SessionId: public static String getSessionId(HttpServletRequest request) { HttpSession session = request.getSession(); return (String) session.getAttribute(WebConstant.SESSIONID); } 16.1.2 Web 端应用服务定位器 与 Swing 方式的远程服务定位器一样,Web 端应用服务定位器使得取得应用服务器服 务的过程透明化,无须考虑服务器的位置、如何连接服务器等问题,实现代码和 RemoteSer viceLocator 非常相似。 【例 11.1】在 Web 端定位服务的定位器。 代码如下: // Web 端服务定位器 public class WebEndServiceLocator { public static Object getService(HttpServletRequest request, Class serviceIntfClass) { HttpSession webSession = request.getSession(); String sessionId = (String) webSession .getAttribute(WebConstant.SESSIONID); return getService(sessionId, serviceIntfClass); } public static Object getService(String sessionId, Class serviceIntfClass) { WebConfig webConfig = WebConfig.getInstance(); HttpInvokerProxyFactoryBean proxyFactory = new HttpInvokerProxyFactoryBean(); CommonsHttpInvokerRequestExecutor reqExecutor = new CommonsHttpInvokerRequestExecutor(); proxyFactory.setHttpInvokerRequestExecutor(reqExecutor); try { String serviceIntfName = serviceIntfClass.getName(); String serviceURL = webConfig.getServerURL().toString() + serviceIntfName; if (!StringUtils.isEmpty(sessionId)) { StringBuffer sb = new StringBuffer(); sb.append(serviceURL).append("?") .append(SysConstants.SESSIONID).append("=") .append(sessionId); proxyFactory.setServiceUrl(sb.toString()); } else { proxyFactory.setServiceUrl(serviceURL); } proxyFactory.setServiceInterface(serviceIntfClass); proxyFactory.afterPropertiesSet(); Object service = proxyFactory.getObject(); return service; } catch (MalformedURLException e) { throw new RemoteServerException( RemoteServerException.CONNECTSERVERERROR, e); } } } 这里假设 Web 服务器和应用服务器处于不同的 JVM 中,所以和 RemoteServiceLocator 一样采用 Spring 提供的 Remoting 访问类来跨 JVM 进行方法调用。如 果 采 用 “ 并 置 应用”部署 模式的话这种方式就会造成参数的返回结果的无谓的序列化与反序列化,这种情况下可以改 由直接调用 LocalServiceLocator 来取得服务,这样的调用消耗就变成了 JVM 内的普通方法 调用,提高了响应速度。 16.1.3 Web 端元数据加载器工厂 在 Web 端同样需要去读取元数据,因此必须为 Web 端编写一个元数据加载器工厂,用 来生成元数据加载器。 【例 16.2】在 Web 端加载元数据的加载器工厂。 // Web 端元数据加载器工厂 public class WebMetaDataLoaderFactory { private static MetaDataLoader loader; public static IMetaDataLoader getLoader() { if (loader != null) { return loader; } WebConfig config = WebConfig.getInstance(); String entityCacheFile = config.getEntityCacheFile(); String metaDataPath = config.getMetaDataPath(); loader = new MetaDataLoader(metaDataPath, entityCacheFile); loader.setCacheEnable(config.isMetaCacheEnabled()); return loader; } } 16.2 登 录 界 面 登录界面是系统的入口,在 登 录 界 面 中用户可以选择要登录的账套以及使用的账号,登 录成功以后应用服务器会分配一个 SessionId,必须将此 SessionId 保存到 WebSession 中。完 成 登 录 界 面需要三个文件:作 为视图的 index.jsp 用来显示登录界面,LoginForm.java 作为用 户提交表单的模型,LoginAction.java 则执行实际的登录动作。 登录界面由三个表单项构成:账套列表、用户名、密码。在用户单击【登录】按钮的时 候要在客户端校验用户名是否为空,如果校验通过则提示“正在登录请稍候……”。 【例 16.3】登录界面的 JSP 代码(index.jsp)。 代码如下: <%@ page contentType="text/html; charset=UTF-8"%> <%@ taglib uri="/WEB-INF/struts-bean.tld" prefix="bean"%> <%@ taglib uri="/WEB-INF/struts-html.tld" prefix="html"%> <%@ taglib uri="/WEB-INF/struts-logic.tld" prefix="logic"%> <%@page import="java.util.List"%> <%@page import="java.util.ArrayList"%> <%@page errorPage="/errorPage.jsp" %> 登录
账  套:
账户名:
密  码:
登录
在表单上设置了 onSubmit 事件监听器,这样当用户单击“登录”按钮的时候会首先执行 J avaScript 代码 checkForm,只有 checkForm 方法返回 true,表单才被提交。在 checkForm 方 法中校验用户名是否为空,如果为空则提示用户“请输入用户名”,否则将初始状态为隐藏的 包含文字“正在登录请稍候……”的层 loadingDiv 显示出来,然后返回 true。 得到账套列表的方法 getACInfos 定义在 LoginForm 中,当 index.jsp 初始化的时候会调 用 LoginForm 的 getACInfos 方法得到账套列表,并使用标记将列表 显示到下拉列表中,label="displayName"、 value="name" 表示显示 ACInfo 的 displayName 属性,而内部保存的属性则为 name。 图 16.1 是 Web 端的登录界面。 图 16.1 登录界面 LoginForm 有三个作用:一是保存 index.jsp 提交过来的表单中的账套名 acName、用户 名 userName 及密码 password 三个字段;二是提供得到账套列表的 getACInfos 方法;三是对 提交的表单进行校验。 【例 16.4】登录界面的 Form 类。 代码如下: // LoginForm.java public class LoginForm extends ActionForm { private String acName; private String userName; private String password; public String getAcName() { return acName; } public void setAcName(String acName) { this.acName = acName; } public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } public String getUserName() { return userName; } public void setUserName(String userName) { this.userName = userName; } /** * 得到账套列表 */ public ACInfo[] getACInfos() { ILoginService loginService = (ILoginService) WebEndServiceLocator .getService((String)null,ILoginService.class); return loginService.getAllACInfo(); } public ActionErrors validate(ActionMapping mapping, HttpServletRequest request) { ActionErrors errors = new ActionErrors(); if(StringUtils.isEmpty(acName)) { errors.add("acName",new ActionMessage("请选择账套",false)); } if(StringUtils.isEmpty(userName)) { errors.add("userName",new ActionMessage( "用户名不能为空",false)); } return errors; } } getACInfos 方法直接将 ILoginService 接口的 getAllACInfo 方法的返回值作为自身的返 回值。在登录之前还没有 SessionId,所以在此处将 SessionId 参数设定为 null。 在 validate 方法中校验用户提交的表单中账套名和用户名是否为空,虽然在客户端的 J avaScript 代码中校验用户名不为空了,但是客户端永远是不可信的,必须在服务端重新校验。 案例系统没有多语言支持的要求,因此对于校验信息简化处理,即不定义在配置文件中,而 是直接写在代码中,ActionMessage 构造函数的第二个参数表示第一个参数是配置文件的键 还是一个单纯的消息字符串,此处设置为 false,"用户名不能为空"这样的字符串表示单纯的 消息字符串,无须到配置文件中去进行消息的转换。 【例 16.5】进行实际登录处理的 Action 类。 代码如下: // LoginAction.java public class LoginAction extends Action { public ActionForward execute(ActionMapping mapping, ActionForm form, HttpServletRequest request, HttpServletResponse response) throws Exception { LoginForm loginForm = (LoginForm) form; String userName = loginForm.getUserName(); String password = loginForm.getPassword(); String acName = loginForm.getAcName(); ILoginService loginService = (ILoginService) WebEndServiceLocator .getService((String)null,ILoginService.class); try { String sessionId = loginService.login(userName, password, acName); HttpSession session = request.getSession(); // 将应用 sessionId 放入 WebSession session.setAttribute(WebConstant.SESSIONID, sessionId); ICommonService cs = (ICommonService) WebEndServiceLocator .getService(sessionId, ICommonService.class); // 将当前用户值对象放入 WebSession session.setAttribute(WebConstant.CURUSERINFO, cs.getCurrentUser()); return mapping.findForward("mainPage"); } catch (LoginServiceException lse) { saveErrors(request, WebExceptionUtils.toActionMessages(lse)); return mapping.getInputForward(); } } } 这里调用 ILoginService 接口的 login 实现登录操作,如 果登录正确则将应用服务器分配 的 SessionId 保存到 WebSession 中,并调用 ICommonService 的 getCurrentUser 方法将当前 用户的信息也保存到 WebSession 中,最后跳转到主页面。 如果出现密码错误、用户已被冻结等问题,则 login 方法中会抛出 LoginServiceExcepti on 异常,所以在这里捕捉 LoginServiceException 异常,并将异常消息显示给用户。WebEx ceptionUtils 中的 toActionMessages 方法用来将异常对象中的异常消息转换成 ActionMessage s 对象,其实现如下: // Web 端异常工具类 package com.cownew.PIS.framework.web.helper; import org.apache.struts.action.ActionErrors; import org.apache.struts.action.ActionMessage; import org.apache.struts.action.ActionMessages; public class WebExceptionUtils { public static ActionMessages toActionMessages(Exception e) { ActionMessages errors = new ActionMessages(); errors.add(ActionErrors.GLOBAL_MESSAGE, new ActionMessage(e .getMessage(), false)); return errors; } } index.jsp、LoginForm.java 和 LoginActon.java 这 3 个文件编写完毕后,在 struts-config. xml 中将三者的对应关系配置起来即可。 16.2.1 登出系统 当用户退出系统的时候如果执行退出操作就能够及早释放服务器端资源。因为登录操作 涉及不到表单等问题,所以不需要编写 JSP 页面作为视图,也不需要 Form 作为模型,只要 开发一个登出 Action 即可。Struts 支持没有 JSP 和 Form 的 Action,直接调用 Action 即可执 行 Action 的 execute 方法,这时候的 Action 相当于一个 Servlet,不过与 Servlet 相比能更方 便地使用 Struts 提供的一些功能。 【例 16.6】登出操作对应的 Action。 代码如下: // LogoutAction.java public class LogoutAction extends Action { public ActionForward execute(ActionMapping mapping, ActionForm form, HttpServletRequest request, HttpServletResponse response) throws Exception { String sessionid = WebContextHelper.getSessionId(request); ILoginService loginService = (ILoginService) WebEndServiceLocator .getService(request, ILoginService.class); loginService.logout(sessionid); request.getSession().invalidate(); return mapping.findForward("loginPage"); } } 在 execute 方法中首先调用 ILoginService 接口的 logout 方法登出应用服务器,然后调用 HttpSession 的 invalidate 方法将 WebSession 销毁,最后转向登录界面。 16.2.2 心跳页面 Web 客户端同样存在 Swing 客户端的长时间无操作造成的 Session 失效问题,必须定时 调用 ICommonService 接口提供的心跳操作。 可以使用 HTML 的元信息头实现刷新,HTML 提供了一种要求浏览器定时刷新页面的 方式,也就是在 HTML 的 head 标记内加入,这样 浏览器就可以每分钟刷新本页面了。 【例 16.7】心跳页面(heartBeat.jsp)。 代码如下: <%@ page language="java" contentType="text/html; charset=GB18030" pageEncoding="GB18030"%> <%@page import="com.cownew.PIS.framework.common.services.ICommonService"%> <%@page import="com.cownew.PIS.framework.web.helper.WebEndServiceLocator"%> <%@page import="com.cownew.ctk.common.StringUtils"%> HeartBeat <% ICommonService cs = (ICommonService)WebEndServiceLocator .getService(request,ICommonService.class); try { cs.nop(); } catch(Throwable t) { //心跳操作不能停 out.println(StringUtils.stackToString(t)); } %> 为了防止 nop 方法调用出现异常造成“心跳停止”,这里将所有可能的异常全部截获并且 打印出来。 这个心跳页面必须放到用户永远都会打开的页面上,后边将会讲到的主菜单页面是用户 在运行期间一直打开的页面,所以将心跳页面嵌入到主菜单页面中是比较好的选择。 16.3 主页面和主菜单 主页面框架定义在页面 mainPage.jsp 中,这个页面由左侧的菜单框架和右侧的主界面框 架组成。 <%@ page contentType="text/html; charset=UTF-8"%> <%@ taglib uri="/WEB-INF/struts-bean.tld" prefix="bean"%> <%@ taglib uri="/WEB-INF/struts-html.tld" prefix="html"%> <%@ taglib uri="/WEB-INF/struts-logic.tld" prefix="logic"%> CowNew 进销存 <body> </body> 文件 head 部分的代码: 表示页面进入时的渐进显示特效,这使得从登录界面到主界面的切换过程更圆滑。在 页 面 中可以用类似的方式指定页面进入和页面退出时的各种特效: 进入页面 退出页面 duration 表示特效的持续时间,以秒为单位。transition 表示使用哪种特效,取值为 1~2 3,各个值的意义如下: 0 矩形缩小 1 矩形扩大 2 圆形缩小 3 圆形扩大 4 下到上刷新 5 上到下刷新 6 左到右刷新 7 右到左刷新 8 竖百叶窗 9 横百叶窗 10 错位横百叶窗 11 错位竖百叶窗 12 点扩散 13 左右到中间刷新 14 中间到左右刷新 15 中间到上下 16 上下到中间 17 右下到左上 18 右上到左下 19 左上到右下 20 左下到右上 21 横条 22 竖条 23 以上 22 种随机选择一种 16.3.1 菜单配置文件 与 Swing 方式一样,在 Web 客户端同样采用可配置的菜单来组织 Web 客户端的菜单, 这样开发人员无须在意菜单的绘制方式,只要了解菜单 XML 文件的配置方式即可。 Web 客户端的业务并不是很复杂,而且功能点比较少,所以没有必要采用多级的菜单 结构,只要支持两级即可,也就是整个菜单分为多个栏目,每个栏目下又有数个平行关系的 菜单项。菜单配置文件 WebMainMenu.xml 的格式如下: 系统工具 库存管理 WebExcel 修改密码 库存流水账 即时库存 销售排行榜 Columns 标记中定义的是各个栏目,MenuItems 标记定义的是各个菜单项,column 属性 表示菜单项所属的栏目,link 属性表示此菜单项对应的超链接地址。 为了比较清晰地描述菜单栏目和菜单项,我们需要定义 WebMenuColumnInfo 和 WebMe nuItemInfo 两个类分别表示栏目和菜单项的属性信息。 【例 16.8】菜单属性定义。 Web 菜单栏目属性代码如下: package com.cownew.PIS.framework.web.menu; public class WebMenuColumnInfo { private String name; private String text; public WebMenuColumnInfo(String name, String text) { super(); this.name = name; this.text = text; } public String getName() { return name; } public String getText() { return text; } } Web 菜单项属性代码如下: package com.cownew.PIS.framework.web.menu; public class WebMenuItemInfo { private String text; private String link; private String column; public WebMenuItemInfo(String text, String link, String column) { super(); this.text = text; this.link = link; this.column = column; } /** * 得到菜单项的显示名称 */ public String getText() { return text; } /** * 得到菜单项的连接地址 */ public String getLink() { return link; } /** * 得到菜单项的所属栏目 */ public String getColumn() { return column; } } 为了读取 WebMainMenu.xml 中的菜单信息,需要开发一个与 MainMenuManager 类似 的 WebMenuManager。 【例 16.9】负责 Web 主菜单管理的类。 代码如下: // Web 主菜单管理器 package com.cownew.PIS.framework.web.menu; import java.io.InputStream; import java.io.InputStreamReader; import java.io.UnsupportedEncodingException; import java.util.ArrayList; import java.util.List; import org.dom4j.Document; import org.dom4j.DocumentException; import org.dom4j.io.SAXReader; import org.dom4j.tree.DefaultElement; import com.cownew.ctk.common.ExceptionUtils; import com.cownew.ctk.constant.StringConst; import com.cownew.ctk.io.ResourceUtils; public class WebMenuManager { private static WebMenuManager instance; private WebMenuColumnInfo[] columnInfos; private WebMenuItemInfo[] menuItems; private WebMenuManager() { super(); } public static WebMenuManager getManager() { if (instance == null) { instance = new WebMenuManager(); try { instance.init(); } catch (UnsupportedEncodingException e) { throw ExceptionUtils.toRuntimeException(e); } catch (DocumentException e) { throw ExceptionUtils.toRuntimeException(e); } } return instance; } private void init() throws UnsupportedEncodingException, DocumentException { InputStream beansXFStream = null; try { beansXFStream = getClass().getResourceAsStream( "/com/cownew/PIS/framework/web/menu/WebMainMenu.xml"); Document doc = new SAXReader().read(new InputStreamReader( beansXFStream, StringConst.UTF8)); List colList = doc.selectNodes("//Config/Columns/Column"); columnInfos = new WebMenuColumnInfo[colList.size()]; for (int i = 0, n = colList.size(); i < n; i++) { DefaultElement beanElement = (DefaultElement) colList.get(i); String name = beanElement.attribute("name").getText(); String text = beanElement.getText(); columnInfos[i] = new WebMenuColumnInfo(name,text); } List itemList = doc.selectNodes("//Config/MenuItems/Item"); menuItems = new WebMenuItemInfo[itemList.size()]; for (int i = 0, n = itemList.size(); i < n; i++) { DefaultElement beanElement = (DefaultElement) itemList.get(i); String column = beanElement.attribute("column").getText(); String link = beanElement.attribute("link").getText(); String text = beanElement.getText(); menuItems[i] = new WebMenuItemInfo(text,link,column); } }finally { ResourceUtils.close(beansXFStream); } } /** * 获取配置文件的所有栏目定义 */ public WebMenuColumnInfo[] getColumnInfos() { return columnInfos; } /** * 获取配置文件的所有菜单项定义 */ public WebMenuItemInfo[] getMenuItems() { return menuItems; } /** * 得到栏目 columnName 下的所有菜单项 */ public List getMenuItems(String columnName) { List list = new ArrayList(); for(int i=0,n=menuItems.length;i 在浏览器中打开上面的 HTML 文件,在浏览器中就可以看到如图 16.2 所示的效果。 图 16.2 树状菜单演示 treeItem 的构造函数方法签名如下: function treeItem(text,action,target,title,Icon) 5 个参数的含义分别为菜单项显示文字、对应的链接地址、连接打开的 target、超链接 的 title 属性、此节点的图标。 由于 XTree 是基于层技术的,所以在树构造完毕以后要调用根节点的 setup 方法将树创 建到一个层中。 有了 WebMenuManager 和 XTree 控件,我们就能很容易地实现主菜单页面了。 【例 16.11】显示主菜单的 JSP 页面。 <%@ page contentType="text/html; charset=UTF-8"%> <%@ taglib uri="/WEB-INF/struts-bean.tld" prefix="bean"%> <%@ taglib uri="/WEB-INF/struts-html.tld" prefix="html"%> <%@ taglib uri="/WEB-INF/struts-logic.tld" prefix="logic"%> <%@page import="com.cownew.PIS.framework.web.menu.WebMenuManager"%> <%@page import="com.cownew.PIS.framework.web.menu.WebMenuColumnInfo"%> <%@page import="java.util.List"%> <%@page import="com.cownew.PIS.framework.web.menu.WebMenuItemInfo"%> 首先创建栏目项,然后创建菜单项,并将菜单项加入栏目项,由于规定了栏目项和菜单 项的命名规则,所以此处很容易地就拼凑出变量名。在脚本最后是预定义的“退出”菜单。 注意到在页面中使用