17. 基于三层架构的 Web 应用程序 MVC – 示例 3 – Firebird 数据库管理系统
17.1. Firebird 数据库
在此新版本中,我们将把人员列表存储在 Firebird 数据库的表中。文档 [http://tahe.developpez.com/divers/sql-firebird/] 中提供了安装和管理此 SGBD 的相关信息。 下文中的屏幕截图均来自 IBExpert,这是一个用于管理 Interbase 和 Firebird SGBD 的客户端。
该数据库名为 [dbpersonnes.gdb]。其中包含一个名为 [PERSONNES] 的表:

表 [PERSONNES] 将包含由 Web 应用程序管理的人员列表。该表是通过以下 SQL 命令构建的:
- 第 2-10 行:表 [PERSONNES] 的结构用于存储 [Personne] 类型的对象,其结构反映了该对象的结构。 由于 Firebird 中不存在布尔类型,因此字段 [MARIE](第 8 行)被声明为整数类型 [SMALLINT]。其取值为 0(未婚)或 1(已婚)。
- 第13-16行:这些完整性约束反映了数据验证器[ValidatePersonne]的约束。
- 第 19 行:字段 ID 是表 [PERSONNES] 的主键
表 [PERSONNES] 的内容可能如下:

数据库 [dbpersonnes.gdb] 除包含表 [PERSONNES] 外,还包含一个名为 [GEN_PERSONNES_ID] 的生成器对象。 该生成器会输出连续的整数,我们将利用这些整数为类 [PERSONNES] 的主键 [ID] 赋值。下面通过一个示例来说明其工作原理:
![]() |
![]() |
可以看到生成器 [GEN_PERSONNES_ID] 的值已发生变化(双击该项并输入 F5 即可刷新):
顺序为 SQL
因此,生成器 [GEN_PERSONNES_ID] 的值如下。GEN_ID 是 Firebird 的内部函数,而 [RDB$DATABASE] 是 SGBD 的系统表。
17.2. [dao] 和 [service] 层的 Eclipse 项目
为了开发我们基于数据库的应用程序中的 [dao] 和 [service] 层,我们将使用以下 Eclipse 项目 [mvc-personnes-03]:

该项目是一个简单的 Java 项目,而非 Tomcat Web 项目。需要提醒的是,应用程序的 2.0 版本将沿用 1.0 版本中的 [web] 层。因此,该层无需重新编写。
[src]文件夹
该文件夹包含 [dao] 和 [service] 层的源代码:

其中包含以下不同包:
- [istia.st.mvc.personnes.dao]:包含 [dao] 层
- [istia.st.mvc.personnes.entites]:包含类 [Personne]
- [istia.st.mvc.personnes.service]:包含类 [service]
- [istia.st.mvc.personnes.tests]:包含针对 [dao] 和 [service] 层的 JUnit 测试
以及必须位于应用程序的 ClassPath 中的配置文件。
[database] 文件夹
该文件夹包含人员 Firebird 数据库:
![]()
- [dbpersonnes.gdb] 是该数据库。
- [dbpersonnes.sql] 是用于生成数据库的脚本 SQL:
文件夹 [lib]
该文件夹包含应用程序所需的存档文件:
![]() |
值得注意的是,其中包含 Firebird 驱动程序 JDBC、[firebirdsql-full.jar] 和 SGBD,以及若干 [spring-*.jar] 压缩包。 我们本可以使用发行版中 [dist] 文件夹内唯一的 [spring.jar] 归档文件,该文件包含 Spring 的所有类。 我们也可以仅使用项目所需的特定压缩包。本次操作正是如此,我们依据Eclipse报告的缺失类错误提示,结合Spring部分压缩包的名称进行了筛选。[lib]文件夹中的所有压缩包均已移入项目的Classpath文件夹中。
[dist] 文件夹
该文件夹将包含应用程序类编译生成的归档文件:
![]()
- [personnes-dao.jar]:[dao] 层的归档文件
- [personnes-service.jar]:[service] 层的归档文件
17.3. [dao] 层
17.3.1. [dao] 层的组成部分
[dao] 层由以下类和接口组成:

- [IDao] 是 [dao] 层提供的接口
- [DaoImplCommon] 是该接口的实现,其中人员组位于数据库表中。[DaoImplCommon] 汇集了独立于 SGBD 的功能。
- [DaoImplFirebird] 是从 [DaoImplCommon] 派生出的类,专门用于管理 Firebird 数据库。
- [DaoException] 是 [dao] 层抛出的未捕获异常的类型。该类属于第 1 版。
[IDao] 的接口如下:
- 该接口与上一版本一样,包含四个方法。
实现该接口的 [DaoImplCommon] 类如下:
- 第 8-9 行:类 [DaoImpl] 实现了接口 [IDao],因此也实现了四个方法 [getAll, getOne, saveOne, deleteOne]。
- 第 27-37 行:方法 [saveOne] 根据需要添加或修改人员,分别调用两个内部方法 [insertPersonne] 和 [updatePersonne]。
- 第 50 行:私有方法 [check] 属于上一版本,此处不再赘述。
- 第 8 行:为了实现 [IDao] 接口,[DaoImpl] 类继承自 Spring 的 [SqlMapClientDaoSupport] 类。
17.3.2. 数据访问层 [iBATIS]
Spring类[SqlMapClientDaoSupport]使用了一个第三方框架[Ibatis SqlMap],该框架可通过URL [http://ibatis.apache.org/]访问:

[iBATIS] 是一个 Apache 项目,用于简化基于数据库的 [dao] 层的构建。结合 [iBATIS],数据访问层的架构如下:
![]() |
[iBATIS] 位于应用程序的 [dao] 层与数据库驱动程序 JDBC 之间。 [iBATIS] 还有其他替代方案,例如 [Hibernate]:

![]() |
使用 [iBATIS] 框架需要两个 [ibatis-common, ibatis-sqlmap] 压缩包,这两个压缩包均已放置在项目的 [lib] 文件夹中:
![]() |
类 [SqlMapClientDaoSupport] 封装了框架 [iBATIS] 和 c.a.d 的通用使用部分。 这些代码片段存在于所有使用工具 [iBATIS] 的 [dao] 层中。 要编写代码的非通用部分,即针对 [dao] 层特有的内容,只需继承 [SqlMapClientDaoSupport] 类即可。这就是我们在此处所做的。
类 [SqlMapClientDaoSupport] 定义如下:

在该类的各种方法中,其中一个方法用于配置将用于访问数据库的客户端 [iBATIS]:
![]()
对象 [SqlMapClient sqlMapClient] 即用于访问数据库的 [IBATIS] 对象。它单独实现了我们架构中的 [iBATIS] 层:
![]() |
使用该对象的一般操作流程如下:
- 向连接池请求连接
- 开启事务
- 执行存储在配置文件中的一系列 SQL 命令
- 关闭事务
- 将连接归还给连接池
如果我们的 [DaoImplCommon] 实现直接与 [iBATIS] 交互,则必须反复执行这一系列操作。只有操作 3 是 [dao] 层特有的,其余操作均为通用操作。 Spring类[SqlMapClientDaoSupport]将自行处理操作1、2、4和5,并将操作3委托给其派生类,即本例中的[DaoImplCommon]类。
为了正常运行,类 [SqlMapClientDaoSupport] 需要一个指向对象 iBATIS 的引用,该对象将负责与数据库进行交互。该对象运行需要两项内容:
- 一个已连接数据库的 [DataSource] 对象,该对象将向其请求连接
- 一个(或多个)配置文件,其中包含待执行的 SQL 命令。 实际上,这些命令并未包含在 Java 代码中。它们通过配置文件中的代码标识,而 [SqlMapClient sqlMapClient] 对象会利用该代码来执行特定的 SQL 命令。
我们[dao]层的初步配置(反映上述架构)如下:
<!-- [dao] 层的访问权限 -->
<bean id="dao" class="istia.st.mvc.personnes.dao.DaoImplCommon">
<property name="sqlMapClient">
<ref local="sqlMapClient"/>
</property>
</bean>
在此,[DaoImplCommon]类(第2行)的属性[sqlMapClient](第3行)被初始化。该初始化操作由[DaoImpl]类的[setSqlMapClient]方法执行。 该类本身并不包含此方法,而是由其父类 [SqlMapClientDaoSupport] 提供。因此,实际上在此处被初始化的正是该父类。
现在看第4行,这里引用了一个名为“sqlMapClient”的对象,该对象尚未创建。如前所述,该对象的类型为[SqlMapClient],即[iBATIS]类型:

[SqlMapClient] 是一个接口。Spring 提供了 [SqlMapClientFactoryBean] 类,用于获取实现该接口的对象:

需要提醒的是,我们正在尝试实例化一个实现 [SqlMapClient] 接口的对象。 显然,[SqlMapClientFactoryBean] 类并非如此。该类实现了 [FactoryBean] 接口(参见上文)。该接口具有以下 [getObject()] 方法:
![]()
当向 Spring 请求一个实现 [FactoryBean] 接口的对象实例时,它会:
- 创建该类的 [I] 实例——此处创建的是 [SqlMapClientFactoryBean] 类型的实例。
- 将 [I] 方法的结果返回给调用方。getObject() - 方法 [SqlMapClientFactoryBean].getObject() 将在此返回一个实现接口 [SqlMapClient] 的对象。
为了能够返回一个实现 [SqlMapClient] 接口的对象,[SqlMapClientFactoryBean] 类需要该对象的两个必要信息:
- 一个已连接数据库的 [DataSource] 对象,该对象将向其请求连接
- 一个(或多个)配置文件,其中包含待执行的 SQL 命令
类 [SqlMapClientFactoryBean] 提供了用于初始化这两个属性的 set 方法:

我们正在取得进展……我们的配置文件逐渐完善,最终变为:
<!-- SqlMapCllient -->
<bean id="sqlMapClient"
class="org.springframework.orm.ibatis.SqlMapClientFactoryBean">
<property name="dataSource">
<ref local="dataSource"/>
</property>
<property name="configLocation">
<value>classpath:sql-map-config-firebird.xml</value>
</property>
</bean>
<!-- [dao] 层的访问类 -->
<bean id="dao" class="istia.st.mvc.personnes.dao.DaoImplCommon">
<property name="sqlMapClient">
<ref local="sqlMapClient"/>
</property>
</bean>
- 第 2-3 行:bean "sqlMapClient" 的类型为 [SqlMapClientFactoryBean]。 根据前文所述,我们知道当向 Spring 请求该 Bean 的实例时,会获得一个实现 iBATIS [SqlMapClient] 接口的对象。因此,第 14 行获取的正是该对象。
- 第7-9行: 我们指定对象 iBATIS [SqlMapClient] 所需的配置文件名为 "sql-map-config-firebird.xml",且该文件应从应用程序的 ClassPath 中查找。 此处使用了方法 [SqlMapClientFactoryBean].setConfigLocation。
- 第 4-6 行:我们使用 [setDataSource] 方法初始化 [SqlMapClientFactoryBean] 的 [dataSource] 属性。
第 5 行,我们引用了一个名为“dataSource”的 Bean,该 Bean 尚未构建。 如果查看 [SqlMapClientFactoryBean] 的 [setDataSource] 方法所期望的参数,可以看到其类型为 [DataSource]:

我们再次面对一个需要为其寻找实现类的接口。此类的作用是高效地为应用程序提供与特定数据库的连接。 一个 SGBD 无法同时保持大量连接处于打开状态。为了减少某个时刻打开的连接数量,在每次与数据库交互时,我们需要:
- 建立连接
- 开始事务
- 发出 SQL 命令
- 结束事务
- 关闭连接
反复打开和关闭连接会消耗大量时间。为了解决这两个问题(既要限制某个时刻打开的连接数,又要降低打开/关闭连接的成本),实现 [DataSource] 接口的类通常采用以下方式:
- 在实例化时,它们会立即与目标数据库建立 N 个连接。N 通常有一个默认值,且大多可在配置文件中定义。这 N 个连接将始终保持打开状态,并构成一个可供应用程序线程使用的连接池。
- 当应用程序的线程请求建立连接时,[DataSource] 对象会为其分配启动时打开的 N 个连接中的一个(如果仍有可用连接)。当应用程序关闭连接时,该连接实际上并未真正关闭,而是被放回可用连接池中。
目前有多种 [DataSource] 接口的实现可供免费使用。本文将使用位于 [http://jakarta.apache.org/commons/dbcp/] 网址处的 [commons DBCP] 实现:

使用 [commons DBCP] 工具需要两个 [commons-dbcp, commons-pool] 压缩包,这两个文件均已放置在项目的 [lib] 文件夹中:
![]() |
[commons DBCP] 中的 [BasicDataSource] 类提供了我们所需的 [DataSource] 实现:

该类将为我们提供一个连接池,用于访问应用程序中的 Firebird 数据库 [dbpersonnes.gdb]。为此,需要向其提供创建连接池所需的以下信息:
- 要使用的驱动程序名称 JDBC – 初始化时使用 [setDriverClassName]
- 要使用的数据库 URL 名称——初始化为 [setUrl]
- 连接所有者的用户 ID – 初始化值为 [setUsername](而非如预期所示的 setUserName)
- 其密码 - 初始化为 [setPassword]
我们的 [dao] 配置文件可以如下所示:
<?xml version="1.0" encoding="ISO_8859-1"?>
<!DOCTYPE beans SYSTEM "http://www.springframework.org/dtd/spring-beans.dtd">
<beans>
<!-- 数据源 DBCP -->
<bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource"
destroy-method="close">
<property name="driverClassName">
<value>org.firebirdsql.jdbc.FBDriver</value>
</property>
<!-- 注意:URL中的两个<value>标签之间不要留空格 -->
<property name="url">
<value>jdbc:firebirdsql:localhost/3050:C:/data/2005-2006/eclipse/dvp-eclipse-tomcat/mvc-personnes-03/database/dbpersonnes.gdb</value>
</property>
<property name="username">
<value>sysdba</value>
</property>
<property name="password">
<value>masterkey</value>
</property>
</bean>
<!-- SqlMapCllient -->
<bean id="sqlMapClient"
class="org.springframework.orm.ibatis.SqlMapClientFactoryBean">
<property name="dataSource">
<ref local="dataSource"/>
</property>
<property name="configLocation">
<value>classpath:sql-map-config-firebird.xml</value>
</property>
</bean>
<!-- 访问 [dao] 层的类 -->
<bean id="dao" class="istia.st.mvc.personnes.dao.DaoImplCommon">
<property name="sqlMapClient">
<ref local="sqlMapClient"/>
</property>
</bean>
</beans>
- 第 7-9 行:JDBC 的驱动程序名称,对应 SGBD Firebird
- 第 11-13 行:Firebird 数据库的 URL [dbpersonnes.gdb]。请特别注意其书写格式。<value> 标签与 URL 之间不得有任何空格。
- 第 14-16 行:连接的所有者——此处为 [sysdba],即 Firebird 发行版的默认管理员
- 第 17-19 行:其密码 [masterkey]——同样是默认值
虽然进展显著,但仍有待澄清的配置细节:第 28 行引用了文件 [sql-map-config-firebird.xml],该文件用于配置 iBATIS 的客户端 [SqlMapClient]。 在研究其内容之前,先展示一下这些配置文件在我们的 Eclipse 项目中的位置:

- [spring-config-test-dao-firebird.xml] 是我们刚刚研究过的 [dao] 层的配置文件
- [sql-map-config-firebird.xml] 由 [spring-config-test-dao-firebird.xml] 引用。我们将对其进行研究。
- [personnes-firebird.xml] 由 [sql-map-config-firebird.xml] 引用。我们将对其进行研究。
前三个文件位于 [src] 文件夹中。在 Eclipse 中,这意味着在运行时,它们将位于项目中的 [bin] 文件夹内(上图未显示)。该文件夹属于应用程序的 ClassPath 文件夹。 最终,上述三个文件将确实存在于应用程序的 ClassPath 文件夹中。这是必要的。
文件 [sql-map-config-firebird.xml] 内容如下:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE sqlMapConfig
PUBLIC "-//iBATIS.com//DTD SQL Map Config 2.0//EN"
"http://www.ibatis.com/dtd/sql-map-config-2.dtd">
<sqlMapConfig>
<sqlMap resource="personnes-firebird.xml"/>
</sqlMapConfig>
- 该文件必须以 <sqlMapConfig> 作为根标签(第 6 行和第 8 行)
- 第 7 行:标签 <sqlMap> 用于指定包含待执行命令 SQL 的文件。 通常(但并非强制要求)每个表对应一个文件。这样可以将针对特定表的 SQL 命令集中到同一个文件中。但经常会遇到涉及多个表的 SQL 命令。在这种情况下,上述分解方式就不适用。 只需记住,所有由 <sqlMap> 标签指定的文件都将被合并。这些文件会在应用程序的 ClassPath 中进行查找。
文件 [personnes-firebird.xml] 描述了将发送到 Firebird 数据库 [dbpersonnes.gdb] 中表 [PERSONNES] 的 SQL 命令。其内容如下:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE sqlMap
PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN"
"http://www.ibatis.com/dtd/sql-map-2.dtd">
<sqlMap>
<!-- 别名类 [Personne] -->
<typeAlias alias="Personne.classe"
type="istia.st.mvc.personnes.entites.Personne"/>
<!-- 映射表 [PERSONNES] - 对象 [Personne] -->
<resultMap id="Personne.map"
class="Personne.classe">
<result property="id" column="ID" />
<result property="version" column="VERSION" />
<result property="nom" column="NOM"/>
<result property="prenom" column="PRENOM"/>
<result property="dateNaissance" column="DATENAISSANCE"/>
<result property="marie" column="MARIE"/>
<result property="nbEnfants" column="NBENFANTS"/>
</resultMap>
<!-- 所有人员的列表 -->
<select id="Personne.getAll" resultMap="Personne.map" > select ID, VERSION, NOM,
PRENOM, DATENAISSANCE, MARIE, NBENFANTS FROM PERSONNES</select>
<!-- 获取特定人员 -->
<select id="Personne.getOne" resultMap="Personne.map" >select ID, VERSION, NOM,
PRENOM, DATENAISSANCE, MARIE, NBENFANTS FROM PERSONNES WHERE ID=#值#</select>
<!-- 添加人员 -->
<insert id="Personne.insertOne" parameterClass="Personne.classe">
<selectKey keyProperty="id">
SELECT GEN_ID(GEN_PERSONNES_ID,1) as "value" FROM RDB$$DATABASE
</selectKey>
insert into
PERSONNES(ID, VERSION, NOM, PRENOM, DATENAISSANCE, MARIE, NBENFANTS)
VALUES(#id#, #version#, #姓#, #名#, #dateNaissance#, #已婚#,
#nbEnfants#) </insert>
<!-- 更新人员信息 -->
<update id="Personne.updateOne" parameterClass="Personne.classe"> update
PERSONNES set VERSION=#版本#+1, NOM=#姓#, PRENOM=#名#, DATENAISSANCE=#dateNaissance#,
MARIE=#玛丽#, NBENFANTS=#nbEnfants# WHERE ID=#id# 以及
VERSION=#版本#</update>
<!-- 删除人员 -->
<delete id="Personne.deleteOne" parameterClass="int"> delete FROM PERSONNES WHERE
ID=#值# </delete>
</sqlMap>
- 该文件必须以 <sqlMap> 作为根标签(第 7 行和第 45 行)
- 第 9-10 行:为便于编写文件,为类 [istia.st.springmvc.personnes.entites.Personne] 赋予别名(同义词)[Personne.classe]。
- 第12-21行:设定表[PERSONNES]的列与对象[Personne]的字段之间的映射关系。
- 第23-24行:执行查询 SQL [select],以获取表 [PERSONNES] 中的所有人员
- 第26-27行:命令 SQL [select],用于从表 [PERSONNES] 中获取特定人员
- 第 29-36 行:命令 SQL [insert],用于将某人插入到表 [PERSONNES] 中
- 第 38-41 行:命令 SQL [update],用于更新表 [PERSONNES] 中的某个人
- 第 42-44 行:命令 SQL [delete],用于从表 [PERSONNES] 中删除一个人
将通过研究实现 [dao] 层的类 [DaoImplCommon],来解释文件 [personnes-firebird.xml] 内容的用途和含义。
17.3.3. 类 [DaoImplCommon]
让我们回顾一下数据访问架构:
![]() |
类 [DaoImplCommon] 如下所示:
我们将依次研究这些方法。
getAll
该方法用于获取列表中的所有人员。其代码如下:
首先需要明确的是,类 [DaoImplCommon] 继承自 Spring 类 [SqlMapClientDaoSupport]。正是该类拥有上文第 3 行中使用的方法 [getSqlMapClientTemplate()]。该方法的签名如下:
![]()
类型 [SqlMapClientTemplate] 封装了 [SqlMapClient] 层中的对象 [SqlMapClient]。正是通过它,我们才能访问数据库。 类型 [iBATIS] SqlMapClient 可以被直接使用,因为类 [SqlMapClientDaoSupport] 可以访问它:
![]()
[iBATIS] SqlMapClient 类的缺点在于,它会抛出 [SQLException] 类型的异常,这属于受控异常类型 c.a.d。 该异常必须通过 try/catch 进行处理,或在抛出该异常的方法签名中进行声明。但请注意,[dao] 层实现了 [IDao] 接口,而该接口的方法签名中不包含异常。 因此,实现 [IDao] 接口的类中的方法,其签名中同样不能包含异常。 因此,我们需要拦截 [iBATIS] 层抛出的每个 [SQLException] 异常,并将其封装为一个未受控异常。我们项目中的 [DaoException] 类型可用于此封装。
与其自行处理这些异常,不如将其交由 Spring 类型 [SqlMapClientTemplate] 处理,该类型封装了 [iBATIS] 层中的 [SqlMapClient] 对象。 实际上,[SqlMapClientTemplate] 正是为了拦截由 [SqlMapClient] 层抛出的 [SQLException] 异常,并将其封装为非受控的 [DataAccessException] 类型( )而设计的。这种行为正符合我们的需求。 只需记住,[dao] 层现在可能会抛出两种类型的未受控异常:
- 我们的专有类型 [DaoException]
- Spring 类型 [DataAccessException]
类型 [SqlMapClientTemplate] 的定义如下:

它实现了以下 [SqlMapClientOperations] 接口:

该接口定义了能够处理 [personnes-firebird.xml] 文件内容的方法:
[queryForList]
![]()
该方法用于发出 [SELECT] 指令,并以对象列表的形式获取其结果:
- [statementName]:配置文件中 [select] 命令的标识符 (id)
- [parameterObject]:针对已配置的 [select] 的“参数”对象。该“参数”对象可采用两种形式:
- 符合 JavaBean 标准的对象:此时,[select] 命令的参数即为 JavaBean 字段的名称。在执行 [select] 命令时,这些参数将被替换为相应字段的值。
- 字典:此时,命令 [select] 的参数即为字典的键。在执行命令 [select] 时,这些键将被替换为字典中与其关联的值。
- 如果 [SELECT] 未返回任何行,则结果 [List] 是一个空对象,但 null 并非如此(待验证)。
[queryForObject]
![]()
该方法的原理与前一个相同,但仅返回一个对象。如果 [SELECT] 未返回任何行,则结果为指针 null。
[insert]
![]()
此方法用于执行由第二个参数配置的 SQL [insert] 命令。返回的对象是已插入行的一主键。没有强制要求必须使用此结果。
[update]
![]()
此方法用于执行由第二个参数设置的 SQL [update] 命令。结果是 SQL [update] 命令修改的行数。
[delete]
![]()
此方法用于执行由第二个参数配置的命令 SQL [delete]。结果是命令 SQL [delete] 删除的行数。
让我们回到类 [DaoImplCommon] 中的方法 [getAll]:
- 第 4 行:名为“Personne.getAll”的命令 [select] 被执行。该命令未设置参数,因此“参数”对象为 null。
在 [personnes-firebird.xml] 中,名为“Personne.getAll”的命令 [select] 如下:
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE sqlMap
PUBLIC "-//iBATIS.com//DTD SQL Map 2.0//EN"
"http://www.ibatis.com/dtd/sql-map-2.dtd">
<sqlMap>
<!-- 别名类 [Personne] -->
<typeAlias alias="Personne.classe"
type="istia.st.mvc.personnes.entites.Personne"/>
<!-- 映射表 [PERSONNES] - 对象 [Personne] -->
<resultMap id="Personne.map"
class="Personne.classe">
<result property="id" column="ID" />
<result property="version" column="VERSION" />
<result property="nom" column="NOM"/>
<result property="prenom" column="PRENOM"/>
<result property="dateNaissance" column="DATENAISSANCE"/>
<result property="marie" column="MARIE"/>
<result property="nbEnfants" column="NBENFANTS"/>
</resultMap>
<!-- 所有人员的列表 -->
<select id="Personne.getAll" resultMap="Personne.map" > select ID, VERSION, NOM,
PRENOM, DATENAISSANCE, MARIE, NBENFANTS FROM PERSONNES</select>
...
</sqlMap>
- 第 23 行:名为“Personne.getAll”的命令 SQL 未设置参数(请求文本中没有参数)。
- 方法 [getAll] 的第 3 行要求执行名为 "Personne.getAll" 的查询 [select]。该查询将被执行。 [iBATIS] 基于 JDBC。因此,我们知道查询结果将以 [ResultSet] 对象的形式返回。 第23行,<select>标签的[resultMap]属性指示[iBATIS]应使用哪个"resultMap " 来将获得的 [ResultSet] 对象的每一行进行转换。 即第12-21行定义的“resultMap”[Personne.map],它规定了如何将[PERSONNES]表中的一行转换为[Personne]类型的对象。 [iBATIS] 将利用这些映射关系,根据 [ResultSet] 对象的行数据生成 [Personne] 对象列表。
- 随后,方法 [getAll] 的第 3 行将返回一个 [Personne] 对象集合
- 方法 [queryForList] 可能会抛出 Spring 异常 [DataAccessException]。我们允许该异常向上传播。
我们将快速说明 [AbstractDaoImpl] 类的其他方法,因为关于 [iBATIS] 的使用要点已在 [getAll] 方法的讲解中阐述。
getOne
该方法可用于获取通过 [id] 标识的人员。其代码如下:
- 第4行:请求执行名为“Personne.getOne”的命令[select]。该命令在文件[personnes-firebird.xml]中的内容如下:
<!-- 获取特定人员 -->
<select id="Personne.getOne" resultMap="Personne.map" parameterClass="int">
select ID, VERSION, NOM, PRENOM, DATENAISSANCE, MARIE, NBENFANTS FROM
PERSONNES WHERE ID=#值#</select>
命令 SQL 由参数 #value#(第 4 行)进行配置。 当参数类型为简单类型(如 Integer、Double、String 等)时,#value# 属性表示传递给 SQL 命令的参数值。在 <select> 标签的属性中,[parameterClass] 属性表明该参数为整数类型 (第 2 行)。在 [getOne] 的第 5 行,可以看到该参数是以 Integer 对象形式表示的被查找人员的标识符。 此类型转换是必需的,因为 [queryForList] 的第二个参数必须为 [Object] 类型。
[select]查询的结果将通过属性[resultMap="Personne.map"](第2行)转换为对象。因此将得到类型[Personne]。
- 第7-11行:如果查询[select]未返回任何记录,则获取第4行中的指针null。这意味着未找到所查找的人员。 此时,将发起代码为 2 的 [DaoException] 查询(第 9-10 行)。
- 第13行:若未发生异常,则返回请求的对象 [Personne]。
deleteOne
该方法用于删除通过 [id] 标识的人员。其代码如下:
- 第4-5行:请求执行名为“Personne.deleteOne”的命令[delete]。该命令在文件[personnes-firebird.xml]中的内容如下:
<!-- 删除某人 -->
<delete id="Personne.deleteOne" parameterClass="int"> delete FROM PERSONNES WHERE
ID=#值# </delete>
命令 SQL 由类型为 [parameterClass="int"](第 2 行)的参数 #value#(第 3 行)进行配置。该参数将作为被查询人员的标识符(deleteOne 的第 5 行)
- 第4行:方法[SqlMapClientTemplate].delete的结果是已删除的行数。
- 第7-8行:如果查询 [delete] 未删除任何行,则表示该人员不存在。此时执行代码为2的 [DaoException](第8行)。
saveOne
此方法可用于添加新人员或修改现有人员。其代码如下:
- 第4行:使用[check]方法验证人员有效性。该方法在上一版本中已存在,当时被注释掉。若人员无效,则触发[DaoException]。允许该异常上报。
- 第6行:若执行到此处,说明未发生异常。因此该人员信息有效。
- 第 6-11 行:根据人员的 ID,处理的是新增(ID = -1)还是更新(ID ≠ -1)。两种情况下,都会调用该类内的两个内部方法:
- insertPersonne:用于添加
- updatePersonne:用于更新
insertPersonne
该方法用于添加新人员。其代码如下:
- 第 4 行:将当前正在创建的人员的版本号设为 1
- 第9行:通过名为“Personne.insertOne”的查询进行插入,该查询内容如下:
<insert id="Personne.insertOne" parameterClass="Personne.classe">
<selectKey keyProperty="id">
SELECT GEN_ID(GEN_PERSONNES_ID,1) as "value" FROM RDB$$DATABASE
</selectKey>
insert into
PERSONNES(ID, VERSION, NOM, PRENOM, DATENAISSANCE, MARIE, NBENFANTS)
VALUES(#id#, #version#, #姓#, #名#, #dateNaissance#, #玛丽#,
#nbEnfants#) </insert>
这是一个带参数的查询,其参数类型为 [Personne](parameterClass="Personne.classe",第 1 行)。 作为参数传递的 [Personne] 对象的字段(insertPersonne 的第 9 行)用于填充即将插入到 [PERSONNES] 表中的行(第 5-8 行)。 这里有一个问题需要解决。在插入操作中,待插入的对象 [Personne] 的 id 值为 -1。必须将该值替换为有效的主键。为此,我们使用上述 <selectKey> 标签的第 2-4 行。它们指定:
- (待续)
- 用于获取主键值的查询语句为 SQL。此处给出的正是我们在第 17.1 节中介绍过的那个。有两点需要注意:
- as " value " 是必需的。虽然也可以写成 as value,但 value 是 Firebird 的关键字,因此必须用引号进行保护。
- Firebird 表的实际名称为 [RDB$DATABASE]。但字符 $ 会被 [iBATIS] 解释。通过将其复制一次来对其进行保护。
- 需要使用命令 [SELECT] 获取的值来初始化对象 [Personne] 的字段,此处即字段 [id]。 第 2 行中的 [keyProperty] 属性指明了该字段。
- 用于获取主键值的查询语句为 SQL。此处给出的正是我们在第 17.1 节中介绍过的那个。有两点需要注意:
- 第6-7行:出于测试需要,我们将延迟10毫秒再进行插入操作,以此观察是否存在多个线程试图同时进行插入操作而引发的冲突。
updatePersonne
该方法用于修改表 [PERSONNES] 中已存在的个人记录。其代码如下:
- 更新操作可能因至少两种原因失败:
- 待更新的记录不存在
- 待更新的个人记录已存在,但试图修改它的线程使用的不是正确版本
- 第 7-8 行:执行名为“Personne.updateOne”的查询 SQL [update]。具体内容如下:
<!-- 更新人员 -->
<update id="Personne.updateOne" parameterClass="Personne.classe"> update
PERSONNES set VERSION=#版本号#+1, NOM=#姓#, PRENOM=#名#, DATENAISSANCE=#dateNaissance#,
MARIE=#玛丽#, NBENFANTS=#nbEnfants# WHERE ID=#id# 以及
VERSION=#版本#</update>
- (续)
- 第2行:该请求已配置,其参数类型为[Personne](parameterClass="Personne.classe")。 该参数对应待修改的对象(第8行 – updatePersonne)。
- 我们只想修改 [PERSONNES] 表中与参数具有相同编号 [id] 和相同版本 [version] 的记录。 因此,我们设置了约束条件 [WHERE ID=#id# and VERSION=#version#]。若找到该人员,则将其更新为参数人员,并将其版本号增加 1(见上文第 3 行)。
- 第 9 行:获取已更新的行数。
- 第 10-11 行:如果该数量为零,则触发代码为 2 的 [DaoException] 操作,这表明待更新的人员要么不存在,要么其版本在此期间已发生变更。
17.4. [dao] 层的测试
17.4.1. [DaoImplCommon] 实现的测试
现在我们已经编写了 [dao] 层,接下来将使用 JUnit 测试用例对其进行测试:

在进行全面测试之前,我们可以先从一个简单的 [main] 类型的程序开始,该程序将显示 [PERSONNES] 表中的内容。这就是 [MainTestDaoFirebird] 类:
[dao] 层中的配置文件 [spring-config-test-dao-firebird.xml](用于第 13-14 行)如下:
<?xml version="1.0" encoding="ISO_8859-1"?>
<!DOCTYPE beans SYSTEM "http://www.springframework.org/dtd/spring-beans.dtd">
<beans>
<!-- 数据源DBCP -->
<bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource"
destroy-method="close">
<property name="driverClassName">
<value>org.firebirdsql.jdbc.FBDriver</value>
</property>
<!-- 注意:两个 <value> 标签之间不要留空格 -->
<property name="url">
<value>jdbc:firebirdsql:localhost/3050:C:/data/2005-2006/eclipse/dvp-eclipse-tomcat/mvc-personnes-03/database/dbpersonnes.gdb</value>
</property>
<property name="username">
<value>sysdba</value>
</property>
<property name="password">
<value>masterkey</value>
</property>
</bean>
<!-- SqlMapCllient -->
<bean id="sqlMapClient"
class="org.springframework.orm.ibatis.SqlMapClientFactoryBean">
<property name="dataSource">
<ref local="dataSource"/>
</property>
<property name="configLocation">
<value>classpath:sql-map-config-firebird.xml</value>
</property>
</bean>
<!-- 图层访问类 [dao] -->
<bean id="dao" class="istia.st.mvc.personnes.dao.DaoImplCommon">
<property name="sqlMapClient">
<ref local="sqlMapClient"/>
</property>
</bean>
</beans>
该文件即第 17.3.2 节中讨论的文件。
为进行测试,启动了 Firebird 实例 SGBD。表 [PERSONNES] 的内容如下:

运行程序 [MainTestDaoFirebird] 后,屏幕显示如下结果:

确实获取到了人员列表。现在可以进行 JUnit 测试。
测试 JUnit [TestDaoFirebird] 如下:
- 测试 [test1] 至 [test5] 与第 1 版相同,但 [test4] 略有调整。测试 [test6] 则是全新的。我们仅对这两个测试进行说明。
[test4]
[test4] 的目的是测试 [updatePersonne - DaoImplCommon] 方法。该方法的代码如下:
- 第4-5行:等待10毫秒。这样会迫使执行[updatePersonne]的线程失去处理器,从而增加观察到并发线程之间发生访问冲突的可能性。
[test4] 启动 N=100 个线程,负责同时将同一个人子女的数量增加 1。我们希望观察版本冲突和访问冲突是如何被处理的。
线程在第 8-13 行创建。每个线程将把第 3-5 行创建的该人的子女数量增加 1。更新线程 [ThreadDaoMajEnfants ] 如下:
人员更新可能会失败,因为要修改的人员不存在,或者该人员已被其他线程更新过。这两种情况在第67-69行中进行了处理。 在上述两种情况下,方法 [updatePersonne] 会触发代码为 2 的 [DaoException] 线程。随后该线程将返回并从头开始执行更新流程(第 34 行的 while 循环)。
[test6]
[test6] 的目的是测试 [insertPersonne - DaoImplCommon] 方法。重申该方法的代码如下:
- 第6-7行:等待10毫秒,以迫使执行[insertPersonne]的线程失去处理器,从而增加因多个线程同时进行插入操作而引发冲突的可能性。
[test6]的代码如下:
我们创建 100 个线程,它们将同时插入 100 个不同的人。这 100 个线程都会为需要插入的人获取一个主键,然后在能够进行插入之前被中断 10 毫秒(第 10 行 – insertPersonne)。 我们需要验证操作是否正常进行,特别是验证它们是否确实获得了不同的主键值。
- 第7-11行:创建了一个包含100人的数组。这些人都是第4-5行创建的p的副本。
- 第14-17行:启动100个插入线程。每个线程负责插入之前创建的100个人物之一。
- 第19-23行:[test6]等待其启动的100个线程全部结束。当检测到第i个线程结束时,它会删除该线程刚刚插入的人员。
插入线程 [ThreadDaoInsertPersonne] 的代码如下:
- 第19-22行:线程构造函数将待插入的人员及用于执行此插入操作的[dao]层存储在内存中。
- 第 30 行:插入该人员。如果发生异常,则将异常上报至 [test6]。
测试
测试结果如下:
![]() |
因此,测试 [test4] 失败。子节点数量变为 69 个,而非预期的 100 个。发生了什么?让我们查看屏幕日志。日志显示 Firebird 抛出了异常:
Exception in thread "Thread-62" org.springframework.jdbc.UncategorizedSQLException: SqlMapClient operation; uncategorized SQLException for SQL []; SQL state [HY000]; error code [335544336];
--- 错误发生在 personnes-firebird.xml 中。
--- 应用参数映射时发生错误。
--- 请检查 Personne.updateOne-InlineParameterMap。
--- 请检查语句(更新失败)。
--- 原因:org.firebirdsql.jdbc.FBSQLException:GDS 异常。335544336。死锁
update conflicts with concurrent update; nested exception is com.ibatis.common.jdbc.exception.NestedSQLException:
--- 错误发生在 personnes-firebird.xml 中。
--- 应用参数映射时发生错误。
- 第 1 行——发生了 Spring 异常 [org.springframework.jdbc.UncategorizedSQLException]。这是一个用于封装 Firebird 驱动程序 JDBC 抛出的异常(详见第 6 行)的未捕获异常。
- 第 6 行 – Firebird 的 JDBC 驱动程序抛出了类型为 [org.firebirdsql.jdbc.FBSQLException]、错误代码为 335544336 的异常。
- 第 7 行:表明两个线程在同时更新 [PERSONNES] 表中的同一行时发生了访问冲突。
这并非不可恢复的错误。捕获该异常的线程可以重试更新操作。为此,需要修改 [ThreadDaoMajEnfants] 的代码:
- 第 8 行:处理了类型为 [DaoException] 的异常。根据上述情况,我们需要处理测试中出现的异常,即类型为 [org.springframework.jdbc.UncategorizedSQLException] 的异常。 然而,我们不能仅处理该类型,因为它只是 Spring 用于封装未知异常的通用类型。 Spring 识别由 JDBC 驱动程序抛出的异常,这些驱动程序对应于若干 SGBD 数据库(如 Oracle、MySQL、 Postgres、DB2、SQL Server 等驱动程序抛出的异常,但不包括 Firebird。 因此,Firebird 的 JDBC 驱动程序抛出的所有异常都会被封装在 Spring 的 [org.springframework.jdbc.UncategorizedSQLException] 类型中:

从上文可以看出,[UncategorizedSQLException] 类继承自我们在第 17.3.3 节中提到的 [DataAccessException] 类。 通过其方法 [getSQLException],可以查明封装在 [UncategorizedSQLException] 中的异常:
![]()
该 [SQLException] 类型的异常是由 [iBATIS] 层抛出的,而该层本身封装了由数据库驱动程序 JDBC 抛出的异常。 可以通过以下方法获取 [SQLException] 类型异常的确切原因:
![]()
由此可获取由驱动程序 JDBC 抛出的类型为 [Throwable] 的对象:

类型 [Throwable] 是 [Exception] 的父类。
在此,我们需要验证由 Firebird 驱动程序 JDBC 抛出的类型为 [Throwable] 的对象——该对象正是导致由 [iBATIS] 层抛出的 [SQLException] 异常,确实是类型为 [org.firebirdsql.gds.GDSException]、错误代码为 335544336 的异常。 要获取错误代码,我们可以使用 [org.firebirdsql.gds.GDSException] 类中的 [getErrorCode()] 方法。
如果我们在 [ThreadDaoMajEnfants] 的代码中使用了 [org.firebirdsql.gds.GDSException] 异常,那么该线程将只能与 SGBD Firebird 配合工作。 使用该线程的 [test4] 测试也将面临同样的情况。我们需要避免这种情况。实际上,我们希望 JUnit 测试在任何 SGBD 实例下都保持有效。为实现这一目标, 我们决定:当检测到“更新冲突”类型的异常时,[dao]层将启动代码为4的[DaoException],且无论底层的SGBD为何。 因此,线程 [ThreadDaoMajEnfants] 可重写为:
- 第 34-36 行:代码为 4 的 [DaoException] 类型异常被拦截。线程 [ThreadDaoMajEnfants] 将被迫从头(第 10 行)重新开始更新过程
因此,我们的 [dao] 层必须能够识别“更新冲突”类型的异常。该异常由 JDBC 驱动程序触发,且仅针对该驱动程序。 该异常应在 [DaoImplCommon] 类的 [updatePersonne] 方法中进行处理:
第 7-11 行必须用 try / catch 语句包围。对于 SGBD Firebird,我们需要验证导致更新失败的异常类型为 [org.firebirdsql.gds.GDSException],且错误代码为 335544336。 如果将此类测试放入 [DaoImplCommon] 中,将会将该类与 SGBD Firebird 关联起来,这显然是不希望看到的。 如果希望保持 [DaoImplCommon] 类的通用性,我们需要对其进行派生,并在一个专门针对 Firebird 的类中处理该异常。这就是我们接下来要做的。
17.4.2. 类 [DaoImplFirebird]
其代码如下:
- 第 5 行:类 [DaoImplFirebird] 继承自 [DaoImplCommon](即我们刚刚分析过的类)。它在第 8-33 行重定义了引发问题的 [updatePersonne] 方法。
- 第 20 行:我们拦截了类型为 [UncategorizedSQLException] 的 Spring 异常
- 第 21-22 行:我们验证了由 [iBATIS] 层抛出的、类型为 [SQLException] 的底层异常,其原因是一条类型为 [org.firebirdsql.jdbc.FBSQLException] 的异常
- 第 25 行:此外,我们还验证该 Firebird 异常的错误代码为 335544336,即“死锁”的错误代码。
- 第26-27行:若所有条件均满足,则抛出代码为4的[DaoException]异常。
- 第36-44行:[wait]方法可将当前线程暂停N毫秒。该方法仅用于测试。
现在我们可以开始测试新的 [dao] 层了。
17.4.3. [DaoImplFirebird] 实现的测试
已修改测试配置文件 [spring-config-test-dao-firebird.xml] 以使用实现 [DaoImplFirebird]:
<?xml version="1.0" encoding="ISO_8859-1"?>
<!DOCTYPE beans SYSTEM "http://www.springframework.org/dtd/spring-beans.dtd">
<beans>
<!-- 数据源DBCP -->
<bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource"
destroy-method="close">
<property name="driverClassName">
<value>org.firebirdsql.jdbc.FBDriver</value>
</property>
<!-- 注意:两个 <value> 标签之间不要留空格 -->
<property name="url">
<value>jdbc:firebirdsql:localhost/3050:C:/data/2005-2006/eclipse/dvp-eclipse-tomcat/mvc-personnes-03/database/dbpersonnes.gdb</value>
</property>
<property name="username">
<value>sysdba</value>
</property>
<property name="password">
<value>masterkey</value>
</property>
</bean>
<!-- SqlMapCllient -->
<bean id="sqlMapClient"
class="org.springframework.orm.ibatis.SqlMapClientFactoryBean">
<property name="dataSource">
<ref local="dataSource"/>
</property>
<property name="configLocation">
<value>classpath:sql-map-config-firebird.xml</value>
</property>
</bean>
<!-- 访问 [dao] 层的权限类 -->
<bean id="dao" class="istia.st.mvc.personnes.dao.DaoImplFirebird">
<property name="sqlMapClient">
<ref local="sqlMapClient"/>
</property>
</bean>
</beans>
- 第 32 行:[dao] 层的新实现 [DaoImplFirebird]。
先前测试失败的 [test4] 的结果如下:

[test4] 已通过。屏幕日志的最后几行如下:
最后一行表明第 36 号线程最后完成。第 3 行显示了一个版本冲突,导致第 36 号线程不得不重启人员更新流程(第 4 行)。其他日志显示了更新过程中的访问冲突:
第2行显示,线程75在更新时因更新冲突而失败: 当在表 [PERSONNES] 上发出命令 SQL [update] 时,需要更新的行已被另一个线程锁定。此访问冲突将迫使第 75 号线程重试更新。
最后关于 [test4],值得注意的是,其结果与版本 1 中同一测试的结果存在显著差异——在版本 1 中,该测试因同步问题而失败。 由于版本 1 中 [dao] 层的方法未进行同步,因此出现了访问冲突。在此,我们无需对 [dao] 层进行同步,只需处理 Firebird 报告的访问冲突即可。
现在,让我们执行 [dao] 层中的完整测试 JUnit:

因此,该[dao]图层似乎是有效的。若要以极高的概率确认其有效性,我们需要进行更多测试。尽管如此,我们仍将其视为可用的。
17.5. [service]图层
17.5.1. [service] 层的组成部分
[service] 层由以下类和接口组成:
![]()
- [IService] 是 [service] 层提供的接口
- [ServiceImpl] 是该接口的实现
[IService] 接口如下:
- 该接口与版本 1 中的接口具有相同的四个方法,但额外增加了两个方法:
- saveMany:允许以原子方式同时保存多人。要么全部保存,要么一个都不保存。
- deleteMany:允许以原子方式同时删除多个人员。要么全部删除,要么一个也不删除。
这两个方法不会被 Web 应用程序使用。我们添加它们是为了说明数据库中的事务概念。实际上,这两个方法必须在事务中执行,才能实现所需的原子性。
实现该接口的类 [ServiceImpl] 如下所示:
- [getAll, getOne, insertOne, saveOne] 方法调用了 [dao] 层中同名的方法。
- 第 42-47 行:方法 [saveMany] 将作为参数传递的数组中的人员逐一保存。
- 第 50-55 行:方法 [deleteMany] 逐个删除由 id 作为参数传递过来的表中的人员
我们曾提到,方法 [saveMany] 和 [deleteMany] 必须在事务内执行,以确保这些方法的“全有或全无”特性。 我们可以看到,上述代码完全忽略了事务的概念。该概念仅会在 [service] 层的配置文件中出现。
17.5.2. [service] 层的配置
在上文第 11 行中,我们可以看到 [ServiceImpl] 实现持有对 [dao] 层的引用。 该层与版本 1 一样,将在 [service - ServiceImpl] 层实例化时由 Spring 进行初始化。用于实例化 [service] 层的配置文件如下:
<?xml version="1.0" encoding="ISO_8859-1"?>
<!DOCTYPE beans SYSTEM "http://www.springframework.org/dtd/spring-beans.dtd">
<beans>
<!-- 数据源 DBCP -->
<bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource"
destroy-method="close">
<property name="driverClassName">
<value>org.firebirdsql.jdbc.FBDriver</value>
</property>
<property name="url">
<!-- 注意:两个 <value> 标签之间不要留空格 -->
<value>jdbc:firebirdsql:localhost/3050:C:/data/2005-2006/eclipse/dvp-eclipse-tomcat/mvc-personnes-03/database/dbpersonnes.gdb</value>
</property>
<property name="username">
<value>sysdba</value>
</property>
<property name="password">
<value>masterkey</value>
</property>
</bean>
<!-- SqlMapCllient -->
<bean id="sqlMapClient"
class="org.springframework.orm.ibatis.SqlMapClientFactoryBean">
<property name="dataSource">
<ref local="dataSource"/>
</property>
<property name="configLocation">
<value>classpath:sql-map-config-firebird.xml</value>
</property>
</bean>
<!-- [dao] 层访问类 -->
<bean id="dao" class="istia.st.mvc.personnes.dao.DaoImplFirebird">
<property name="sqlMapClient">
<ref local="sqlMapClient"/>
</property>
</bean>
<!-- 事务管理器 -->
<bean id="transactionManager"
class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource">
<ref local="dataSource"/>
</property>
</bean>
<!-- [service] 层的访问类 -->
<bean id="service"
class="org.springframework.transaction.interceptor.TransactionProxyFactoryBean">
<property name="transactionManager">
<ref local="transactionManager"/>
</property>
<property name="target">
<bean class="istia.st.mvc.personnes.service.ServiceImpl">
<property name="dao">
<ref local="dao"/>
</property>
</bean>
</property>
<property name="transactionAttributes">
<props>
<prop key="get*">PROPAGATION_SUPPORTS,readOnly</prop>
<prop key="save*">PROPAGATION_REQUIRED</prop>
<prop key="delete*">PROPAGATION_REQUIRED</prop>
</props>
</property>
</bean>
</beans>
- 第 1-36 行:[dao] 层的配置。该配置已在第 17.3.2 节研究 [dao] 层时进行过说明。
- 第38-64行:配置[service]层
第 46 行可见,[service] 层的实现由类型 [TransactionProxyFactoryBean] 完成。 我们原本预期会看到类型 [ServiceImpl]。[TransactionProxyFactoryBean] 是 Spring 的预定义类型。一个预定义类型怎么可能实现 [IService] 接口,而该接口是专属于我们应用程序的呢?
首先让我们来了解 [TransactionProxyFactoryBean] 类:

我们可以看到它实现了 [FactoryBean] 接口。我们之前已经遇到过这个接口。我们知道,当应用程序向 Spring 请求一个实现 [FactoryBean] 接口的类型的实例时, Spring 返回的并非该类型的 [I] 实例,而是由方法 [I].getObject() 返回的对象:
![]()
在我们的案例中,[service] 层将由 [TransactionProxyFactoryBean].getObject() 方法返回的对象来实现。该对象的性质是什么? 我们不会深入探讨细节,因为它们相当复杂。这些细节涉及所谓的 Spring AOP(面向切面编程)。我们将尝试通过简单的示意图来阐明问题。AOP 实现了以下功能:
- 我们有两个类 C1 和 C2,其中 C1 使用了由 C2 提供的 [I2] 接口:
![]() |
- 借助 AOP,可以在 C1 和 C2 类之间插入一个拦截器,且对这两个类完全透明:
![]() |
类 [C1] 已编译为与接口 [I2] 配合使用,而 [C2] 实现了该接口。 在运行时,AOP 会将类 [intercepteur] 放置在 [C1] 和 [C2] 之间。 要实现这一点,当然需要 [intercepteur] 类向 [C1] 展示与 [C2] 相同的 [I2] 接口。
这有什么用处?Spring文档中给出了几个示例。例如,我们可能希望在调用[C2]的某个特定方法M时进行日志记录,以便对该方法进行审计。 因此,在 [intercepteur] 中,我们将编写一个名为 [M] 的方法来执行这些日志记录。[C1] 对 [C2].M 的调用将如下所示(参见上图):
- [C1] 调用 [C2] 的方法 M。 实际上被调用的将是 [intercepteur] 的 M 方法。只有当 [C1] 调用的是 [I2] 接口,而非 [I2] 的特定实现时,才可能发生这种情况。 此时,只需让 [intercepteur] 实现 [I2] 即可。
- [intercepteur] 的 M 方法负责记录日志,并调用 [C2] 的 M 方法(该方法最初是 [C1] 所指向的)。
- [C2] 的 M 方法执行并将其结果返回给 [intercepteur] 的 M 方法,后者可能会对步骤 2 中已完成的内容进行补充。
- [intercepteur] 的 M 方法将结果返回给调用方 [C1]
可以看出,[intercepteur] 的 M 方法可以在调用 [C2] 的 M 方法之前和之后执行某些操作。 相对于 [C1],它因此扩展了 [C2] 的 M 方法。因此,我们可以将 AOP 技术视为一种扩展类所呈现接口的方式。
该概念如何应用于我们的 [service] 层?如果直接使用 [ServiceImpl] 实例来实现 [service] 层,我们的 Web 应用程序将具有以下架构:
![]() |
如果通过 [TransactionProxyFactoryBean] 实例来实现 [service] 层,则架构如下:
![]() |
可以说,[service]层通过两个对象实例化:
- 上文中我们称之为 [proxy transactionnel] 的对象,实际上是由 [TransactionProxyFactoryBean] 的 [getObject] 方法返回的对象。 正是该对象将作为 [service] 层与 [web] 层之间的接口。它通过构造实现了 [IService] 接口。
- 一个名为 [ServiceImpl] 的实例,它同样实现了 [IService] 接口。只有它知道如何与 [dao] 层进行交互,因此它是必不可少的。
假设 [web] 层调用 [IService] 接口中的 [saveMany] 方法。 我们知道,从功能上讲,该方法执行的插入/更新操作必须在事务中进行。要么全部成功,要么全部不执行。我们之前介绍了类 [ServiceImpl] 中的方法 [saveMany],并指出它不支持事务概念。 [proxy transactionnel] 中的方法 [saveMany] 将为 [ServiceImpl] 类中的方法 [saveMany] 添加这一事务概念。请参考上图:
- [web]层调用[IService]接口中的[saveMany]方法。
- [proxy transactionnel] 中的 [saveMany] 方法被执行。它启动了一个事务。 该方法必须具备足够的执行信息,特别是需要一个 [DataSource] 对象以建立与 SGBD 的连接。随后,它调用 [ServiceImpl] 中的 [saveMany] 方法。
- 该方法执行后,会反复调用 [dao] 层来执行插入或更新操作。此时执行的 SQL 命令均在步骤 2 中启动的事务中执行。
- 假设其中一项操作失败。 [dao] 层将向 [service] 层抛出异常,具体而言,即抛向 [ServiceImpl] 实例的 [saveMany] 方法。
- 该方法不执行任何操作,而是将异常向上传递至 [proxy transactionnel] 实例的 [saveMany] 方法。
- 接收到异常后,作为事务所有者的 [proxy transactionnel] 中的 [saveMany] 方法会调用 [rollback] 方法来撤销所有更新, 随后将异常向上传递至 [web] 层,由该层负责处理。
在步骤4中,我们假设其中一次插入或更新操作失败。如果未发生失败,则在[5]中不会上报任何异常。[6]的情况亦是如此。 在此情况下,[proxy transactionnel] 中的 [saveMany] 方法会对事务执行 [commit] 操作,以提交所有更新。
现在,我们对 [TransactionProxyFactoryBean] Bean 所实现的架构有了更清晰的认识。让我们回顾一下其配置:
<!-- 事务管理器 -->
<bean id="transactionManager"
class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource">
<ref local="dataSource"/>
</property>
</bean>
<!-- [service] 层的访问类 -->
<bean id="service"
class="org.springframework.transaction.interceptor.TransactionProxyFactoryBean">
<property name="transactionManager">
<ref local="transactionManager"/>
</property>
<property name="target">
<bean class="istia.st.mvc.personnes.service.ServiceImpl">
<property name="dao">
<ref local="dao"/>
</property>
</bean>
</property>
<property name="transactionAttributes">
<props>
<prop key="get*">PROPAGATION_REQUIRED,readOnly</prop>
<prop key="save*">PROPAGATION_REQUIRED</prop>
<prop key="delete*">PROPAGATION_REQUIRED</prop>
</props>
</property>
</bean>
让我们结合已配置的架构来分析这一配置:
![]() |
- [proxy transactionnel] 将负责事务管理。Spring 提供了多种事务管理策略。[proxy transactionnel] 需要引用所选的事务管理器。
- 第 11–13 行:定义 Bean [TransactionProxyFactoryBean] 的属性 [transactionManager],并将其引用指向一个事务管理器。该事务管理器在第 2–7 行中定义。
- 第 2–7 行:事务管理器的类型为 [DataSourceTransactionManager]:

[DataSourceTransactionManager] 是一个适用于通过 [DataSource] 对象访问的 SGBD 的事务管理器。它只能管理单个 SGBD 上的事务。 它无法管理分布在多个 SGBD 上的事务。在此,我们只有一个 SGBD。 因此,该事务管理器是合适的。当 [proxy transactionnel] 启动事务时,它将通过一个与线程关联的连接进行。所有通向数据库的层级(即 [ServiceImpl, DaoImplCommon, SqlMapClientTemplate, JDBC])都将使用该连接。
类 [DataSourceTransactionManager] 需要知道应向哪个数据源请求连接以将其绑定到线程上。该数据源在第 4-6 行中定义:它与 [dao] 层使用的数据源相同(参见第 17.5.2 节)。
- 第14-19行:"target"属性指定了需要拦截的类,此处为[ServiceImpl]类。需要此信息有两个原因:
- [ServiceImpl]类必须被实例化,因为它负责与[dao]层进行交互
- [TransactionProxyFactoryBean] 必须生成一个代理,该代理向 [web] 层展示与 [ServiceImpl] 相同的接口。
- 第 21-27 行:指定代理应拦截 [ServiceImpl] 的哪些方法。第 21 行的 [transactionAttributes] 属性指定了 [ServiceImpl] 的哪些方法需要事务,以及该事务的属性:
- 第23行:名称以get [getOne, getAll]开头的方法将在[PROPAGATION_REQUIRED,readOnly]属性事务中执行:
- PROPAGATION_REQUIRED:如果线程已关联事务,则该方法在该事务中执行;否则将创建新事务并在其中执行。
- readOnly:只读事务
在此,[ServiceImpl] 中的方法 [getOne] 和 [getAll] 将在事务中执行,而实际上这并非必要。 每次操作仅包含一个 SELECT 命令。我们看不出将 SELECT 放入事务中的必要性。
- 第 24 行:名称以 save 开头的方法 [saveOne, saveMany] 在属性事务 [PROPAGATION_REQUIRED] 中执行。
- 第 25 行:[ServiceImpl] 中的方法 [deleteOne] 和 [deleteMany] 的配置与方法 [saveOne, saveMany] 完全相同。
在我们的 [service] 层中,仅 [saveMany] 和 [deleteMany] 方法需要在事务中执行。配置可简化为以下几行:
<property name="transactionAttributes">
<props>
<prop key="saveMany">PROPAGATION_REQUIRED</prop>
<prop key="deleteMany">PROPAGATION_REQUIRED</prop>
</props>
</property>
17.6. [service] 层的测试
现在我们已经编写并配置了 [service] 层,接下来将使用 JUnit 测试进行测试:

[service] 层的配置文件 [spring-config-test-service-firebird.xml] 即第 17.5.2 节中所述的文件。
JUnit [TestServiceFirebird] 测试内容如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 | |
- 第19-22行:该程序测试由文件[spring-config-test-service-firebird.xml]配置的图层[dao]和[service],该文件已在上一节中进行过探讨。
- [test1] 至 [test6] 的测试在设计理念上与 [dao] 层中 [TestDaoFirebird] 测试类中同名的测试完全一致。 唯一的区别在于,根据配置,方法 [saveOne] 和 [deleteOne] 现在将在事务中执行。
- 方法 [test7] 的目的是测试方法 [saveMany] 和 [deleteMany]。我们需要验证它们是否确实在事务中执行。让我们对该方法的代码进行注释:
- 第 62-63 行:统计当前列表中 [nbPersonnes1] 方法处理的人员数量
- 第67-72行:创建三名人员
- 第 73-83 行:通过方法 [saveMany](第 77 行)保存这三个人。前两个人的 ID 均为 -1,将被添加到表 [PERSONNES] 中。 而人员 p3 的 id 为 -2。因此这不是插入操作,而是更新操作。该操作将失败,因为表 [PERSONNES] 中不存在 id 为 -2 的人员。 因此,[dao]层将抛出一个异常,该异常将向上传播至[service]层。第83行对该异常的存在进行了检测。
- 由于之前的异常,[service] 层应将 SQL 方法执行期间生成的所有 SQL 订单转换为 [rollback], 这是因为该方法是在事务中执行的。第86-87行,验证列表中的人数没有变化,因此p1和p2的插入操作并未发生。
- 第88-103行:仅添加p1和p2,并验证列表中随后增加了两人。
- 第106-114行:删除一个由刚添加的p1和p2以及一个不存在的用户(id= -1)组成的用户组。 为此使用了方法 [deleteMany](第 108 行)。该方法将失败,因为表 [PERSONNES] 中不存在 ID 为 -1 的用户。 因此,[dao]层将抛出一个异常,该异常将向上传播至[service]层。第114行对该异常的存在进行了检测。
- 由于前面的异常,[service]层应针对在[deleteMany]方法执行期间发出的所有SQL命令生成一个[rollback], 这是因为该方法是在事务中执行的。第116-117行,验证列表中的人数未发生变化,因此p1和p2并未被删除。
- 第122行:删除仅由p1和p2组成的组。此操作应成功。方法的其余部分将验证结果是否正确。
测试执行结果如下:

七项测试均已通过。我们将 [service] 层视为已投入运行。
17.7. [web]层
回顾一下待构建的Web应用程序的总体架构:
![]() |
我们刚刚构建了 [dao] 和 [service] 层,用于与 Firebird 数据库进行交互。 我们编写了该应用程序的第 1 版,其中 [dao] 和 [service] 层处理内存中的人员列表。 当时编写的 [web] 层仍然有效。因为它面向的是实现 [IService] 接口的 [service] 层。 由于新的 [service] 层实现了该相同接口,因此无需修改 [web] 层。
在上一篇文章中,应用程序的第 1 版已通过 Eclipse 项目 [mvc-personnes-02B] 进行了测试,其中 [web, service, dao, entites] 层已被打包为 .jar 文件:
![]() |
[src]文件夹为空。图层的类位于[personnes-*.jar ]归档文件中:
![]() |
为测试第 2 版,我们在 Eclipse 中将文件夹 [mvc-personnes-02B] 复制为 [mvc-personnes-03B](复制/粘贴):

在项目 [mvc-personnes-03] 中, 我们将 [File / Export / Jar file] 以及图层 [dao] 和 [service] 分别导出到项目 [mvc-personnes-03] 中的 [personnes-dao.jar] 和 [personnes-service.jar] 存档文件中,这两个文件位于项目 [dist]文件夹中:

我们将这两个文件复制出来,然后在Eclipse中将其粘贴到项目[mvc-personnes-03B]的[WEB-INF/lib]文件夹中,它们将替换该文件夹中同名的旧版本存档文件。
![]() |
我们还需将项目 [mvc-personnes-03] 中 [lib] 文件夹内的 [commons-dbcp-*.jar, commons-pool-*.jar, firebirdsql-full.jar, ibatis-common-2.jar, ibatis-sqlmap-2.jar] 归档文件复制并粘贴到项目 [mvc-personnes-03B] 的 [WEB-INF/lib] 文件夹中。 这些归档文件是新层 [dao] 和 [service] 所必需的。
完成上述操作后,我们将新归档文件添加到项目 [clic droit sur projet -> Properties -> Java Build Path -> Add Jars] 的类路径中。
文件夹 [src] 包含图层 [dao] 和 [service] 的配置文件:

文件 [spring-config.xml] 用于配置 Web 应用程序的 [dao] 和 [service] 层。 在新版本中,该文件与用于配置项目 [mvc-personnes-03] 中服务层测试的文件 [spring-config-test-service-firebird.xml] 完全相同。因此,我们将其中一个文件的内容复制并粘贴到另一个文件中:
<?xml version="1.0" encoding="ISO_8859-1"?>
<!DOCTYPE beans SYSTEM "http://www.springframework.org/dtd/spring-beans.dtd">
<beans>
<!-- 数据源DBCP -->
<bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource"
destroy-method="close">
<property name="driverClassName">
<value>org.firebirdsql.jdbc.FBDriver</value>
</property>
<property name="url">
<!-- 注意:两个 <value> 标签之间不要留空格 -->
<value>jdbc:firebirdsql:localhost/3050:C:/data/2005-2006/eclipse/dvp-eclipse-tomcat/mvc-personnes-03/database/dbpersonnes.gdb</value>
</property>
<property name="username">
<value>sysdba</value>
</property>
<property name="password">
<value>masterkey</value>
</property>
</bean>
<!-- SqlMapCllient -->
<bean id="sqlMapClient"
class="org.springframework.orm.ibatis.SqlMapClientFactoryBean">
<property name="dataSource">
<ref local="dataSource"/>
</property>
<property name="configLocation">
<value>classpath:sql-map-config-firebird.xml</value>
</property>
</bean>
<!-- 访问 [dao] 层的类 -->
<bean id="dao" class="istia.st.mvc.personnes.dao.DaoImplFirebird">
<property name="sqlMapClient">
<ref local="sqlMapClient"/>
</property>
</bean>
<!-- 事务管理器 -->
<bean id="transactionManager"
class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
<property name="dataSource">
<ref local="dataSource"/>
</property>
</bean>
<!-- [service] 层的访问类 -->
<bean id="service"
class="org.springframework.transaction.interceptor.TransactionProxyFactoryBean">
<property name="transactionManager">
<ref local="transactionManager"/>
</property>
<property name="target">
<bean class="istia.st.mvc.personnes.service.ServiceImpl">
<property name="dao">
<ref local="dao"/>
</property>
</bean>
</property>
<property name="transactionAttributes">
<props>
<prop key="get*">PROPAGATION_SUPPORTS,readOnly</prop>
<prop key="save*">PROPAGATION_REQUIRED</prop>
<prop key="delete*">PROPAGATION_REQUIRED</prop>
</props>
</property>
</bean>
</beans>
- 第 12 行:Firebird 数据库的 URL。我们继续使用之前用于测试 [dao] 和 [service] 层的数据库
我们将 Web 项目 [mvc-personnes-03B] 部署到 Tomcat 中:
![]() | ![]() |
我们已准备好进行 测试。启动了SGBD Firebird。此时,[PERSONNES]表的内容如下:

随后启动了Tomcat。通过浏览器,我们访问网址[http://localhost:8080/mvc-personnes-03B]:

我们通过 通过链接 [Ajout] 添加一位新用户:
![]() | ![]() |
我们在数据库中验证新增记录:

请读者进行其他测试:[modification, suppression]。
现在进行版本冲突测试,该测试在第1版中已进行过。[Firefox]将是用户U1的浏览器。该浏览器请求URL [http://localhost:8080/mvc-personnes-03B]:

[IE] 是用户 U2 的浏览器。该用户请求相同的 URL:

用户 U1 进入对用户 [Perrichon] 的编辑界面:

用户 U2 也进行了同样的操作:

用户 U1 进行修改并提交:
![]() |
用户 U2 也做了同样的操作:
![]() |
用户 U2 通过表单中的链接 [Annuler] 返回人员列表:

他找到了 [Perrichon] 这个人,该记录已被 U1 修改过(姓名已转换为大写)。
那么数据库的情况如何?让我们看看:

经U1修改后,第899号人员的姓名已正确显示为大写。
17.8. 结论
让我们回顾一下我们的目标。我们有一个采用以下三层架构的Web应用程序:
其中 [dao] 和 [service] 层处理内存中的数据列表,因此当 Web 服务器停止运行时,这些数据会丢失。 这是第 1 版。在第 2 版中,[service] 和 [dao] 层已重写,将人员列表存入数据库表中。因此,该列表现在具有持久性。 接下来,我们将探讨 SGBD 的变更对应用程序产生的影响。为此,我们将构建三个新版本的 Web 应用程序:
![]() |
- 版本 3:SGBD 采用 Postgres
- 版本 4:SGBD 基于 MySQL
- 版本 5:SGBD 即 SQL Server Express 2005
更改内容如下:
- 类 [DaoImplFirebird] 实现了 [dao] 层中与 SGBD Firebird 相关联的功能。 如果该需求仍然存在,它将分别被类 [DaoImplPostgres]、[DaoImplMySQL] 和 [DaoImplSqlExpress] 所取代。
- iBATIS 的映射文件 [personnes-firebird.xml](用于 SGBD Firebird)将分别被映射文件 [personnes-postgres.xml]、 [personnes-mysql.xml] 和 [personnes-sqlexpress.xml] 映射文件。
- [dao] 层中 [DataSource] 对象的配置是针对 SGBD 的。因此,它将在每个版本中发生变化。
- SGBD 驱动程序 JDBC 也会随着每个版本而变化
除上述内容外,其余部分保持不变。下文将针对这些新版本进行说明,重点仅介绍各版本带来的新功能。

























