Skip to content

17. 基于三层架构的 Web 应用程序 MVC – 示例 3 – Firebird 数据库管理系统

17.1. Firebird 数据库

在此新版本中,我们将把人员列表存储在 Firebird 数据库的表中。文档 [http://tahe.developpez.com/divers/sql-firebird/] 中提供了安装和管理此 SGBD 的相关信息。 下文中的屏幕截图均来自 IBExpert,这是一个用于管理 Interbase 和 Firebird SGBD 的客户端。

该数据库名为 [dbpersonnes.gdb]。其中包含一个名为 [PERSONNES] 的表:

Image

表 [PERSONNES] 将包含由 Web 应用程序管理的人员列表。该表是通过以下 SQL 命令构建的:

CREATE TABLE PERSONNES (
    ID             INTEGER NOT NULL,
    "VERSION"      INTEGER NOT NULL,
    NOM            VARCHAR(30) NOT NULL,
    PRENOM         VARCHAR(30) NOT NULL,
    DATENAISSANCE  DATE NOT NULL,
    MARIE          SMALLINT NOT NULL,
    NBENFANTS      SMALLINT NOT NULL
);


ALTER TABLE PERSONNES ADD CONSTRAINT CHK_PRENOM_PERSONNES check (PRENOM<>'');
ALTER TABLE PERSONNES ADD CONSTRAINT CHK_MARIE_PERSONNES check (MARIE=0 OR MARIE=1);
ALTER TABLE PERSONNES ADD CONSTRAINT CHK_NOM_PERSONNES check (NOM<>'');
ALTER TABLE PERSONNES ADD CONSTRAINT CHK_ENFANTS_PERSONNES check (NBENFANTS>=0);


ALTER TABLE PERSONNES ADD CONSTRAINT PK_PERSONNES PRIMARY KEY (ID);
  • 第 2-10 行:表 [PERSONNES] 的结构用于存储 [Personne] 类型的对象,其结构反映了该对象的结构。 由于 Firebird 中不存在布尔类型,因此字段 [MARIE](第 8 行)被声明为整数类型 [SMALLINT]。其取值为 0(未婚)或 1(已婚)。
  • 第13-16行:这些完整性约束反映了数据验证器[ValidatePersonne]的约束。
  • 第 19 行:字段 ID 是表 [PERSONNES] 的主键

表 [PERSONNES] 的内容可能如下:

Image

数据库 [dbpersonnes.gdb] 除包含表 [PERSONNES] 外,还包含一个名为 [GEN_PERSONNES_ID] 的生成器对象。 该生成器会输出连续的整数,我们将利用这些整数为类 [PERSONNES] 的主键 [ID] 赋值。下面通过一个示例来说明其工作原理:

可以看到生成器 [GEN_PERSONNES_ID] 的值已发生变化(双击该项并输入 F5 即可刷新):

 

顺序为 SQL

SELECT GEN_ID ( GEN_PERSONNES_ID,1 ) FROM RDB$DATABASE

因此,生成器 [GEN_PERSONNES_ID] 的值如下。GEN_ID 是 Firebird 的内部函数,而 [RDB$DATABASE] 是 SGBD 的系统表。

17.2. [dao] 和 [service] 层的 Eclipse 项目

为了开发我们基于数据库的应用程序中的 [dao] 和 [service] 层,我们将使用以下 Eclipse 项目 [mvc-personnes-03]:

Image

该项目是一个简单的 Java 项目,而非 Tomcat Web 项目。需要提醒的是,应用程序的 2.0 版本将沿用 1.0 版本中的 [web] 层。因此,该层无需重新编写。


[src]文件夹


该文件夹包含 [dao] 和 [service] 层的源代码:

Image

其中包含以下不同包:

  • [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 数据库:

Image

  • [dbpersonnes.gdb] 是该数据库。
  • [dbpersonnes.sql] 是用于生成数据库的脚本 SQL:
/******************************************************************************/
/***          由 IBExpert 生成 2006.03.07 2006年4月27日 10:27:11          ***/
/******************************************************************************/

SET SQL DIALECT 3;

SET NAMES NONE;

CREATE DATABASE 'C:\data\2005-2006\webjava\dvp-spring-mvc\mvc-38\database\DBPERSONNES.GDB'
USER 'SYSDBA' PASSWORD 'masterkey'
PAGE_SIZE 16384
DEFAULT CHARACTER SET NONE;



/******************************************************************************/
/***                               生成器                               ***/
/******************************************************************************/

CREATE GENERATOR GEN_PERSONNES_ID;
SET GENERATOR GEN_PERSONNES_ID TO 787;



/******************************************************************************/
/***                                 表                                 ***/
/******************************************************************************/



CREATE TABLE PERSONNES (
    ID             INTEGER NOT NULL,
    "VERSION"      INTEGER NOT NULL,
    NOM            VARCHAR(30) NOT NULL,
    PRENOM         VARCHAR(30) NOT NULL,
    DATENAISSANCE  DATE NOT NULL,
    MARIE          SMALLINT NOT NULL,
    NBENFANTS      SMALLINT NOT NULL
);

INSERT INTO PERSONNES (ID, "VERSION", NOM, PRENOM, DATENAISSANCE, MARIE, NBENFANTS) VALUES (1, 1, 'Major', 'Joachim', '1984-11-13', 1, 2);
INSERT INTO PERSONNES (ID, "VERSION", NOM, PRENOM, DATENAISSANCE, MARIE, NBENFANTS) VALUES (2, 1, 'Humbort', 'Mélanie', '1985-02-12', 0, 1);
INSERT INTO PERSONNES (ID, "VERSION", NOM, PRENOM, DATENAISSANCE, MARIE, NBENFANTS) VALUES (3, 1, 'Lemarchand', 'Charles', '1986-03-01', 0, 0);

COMMIT WORK;



/* 检查约束定义 */

ALTER TABLE PERSONNES ADD CONSTRAINT CHK_PRENOM_PERSONNES check (PRENOM<>'');
ALTER TABLE PERSONNES ADD CONSTRAINT CHK_NOM_PERSONNES check (NOM<>'');
ALTER TABLE PERSONNES ADD CONSTRAINT CHK_MARIE_PERSONNES check (MARIE=0 OR MARIE=1);
ALTER TABLE PERSONNES ADD CONSTRAINT CHK_ENFANTS_PERSONNES check (NBENFANTS>=0);


/******************************************************************************/
/***                              主键                              ***/
/******************************************************************************/

ALTER TABLE PERSONNES ADD CONSTRAINT PK_PERSONNES PRIMARY KEY (ID);

文件夹 [lib]


该文件夹包含应用程序所需的存档文件:

值得注意的是,其中包含 Firebird 驱动程序 JDBC、[firebirdsql-full.jar] 和 SGBD,以及若干 [spring-*.jar] 压缩包。 我们本可以使用发行版中 [dist] 文件夹内唯一的 [spring.jar] 归档文件,该文件包含 Spring 的所有类。 我们也可以仅使用项目所需的特定压缩包。本次操作正是如此,我们依据Eclipse报告的缺失类错误提示,结合Spring部分压缩包的名称进行了筛选。[lib]文件夹中的所有压缩包均已移入项目的Classpath文件夹中。


[dist] 文件夹


该文件夹将包含应用程序类编译生成的归档文件:

Image

  • [personnes-dao.jar]:[dao] 层的归档文件
  • [personnes-service.jar]:[service] 层的归档文件

17.3. [dao] 层

17.3.1. [dao] 层的组成部分

[dao] 层由以下类和接口组成:

Image

  • [IDao] 是 [dao] 层提供的接口
  • [DaoImplCommon] 是该接口的实现,其中人员组位于数据库表中。[DaoImplCommon] 汇集了独立于 SGBD 的功能。
  • [DaoImplFirebird] 是从 [DaoImplCommon] 派生出的类,专门用于管理 Firebird 数据库。
  • [DaoException] 是 [dao] 层抛出的未捕获异常的类型。该类属于第 1 版。

[IDao] 的接口如下:

package istia.st.mvc.personnes.dao;

import istia.st.mvc.personnes.entites.Personne;

import java.util.Collection;

public interface IDao {
    // 所有人员的列表
    Collection getAll();
    // 查询特定人员
    Personne getOne(int id);
    // 添加/修改人员
    void saveOne(Personne personne);
    // 删除人员
    void deleteOne(int id);
}
  • 该接口与上一版本一样,包含四个方法。

实现该接口的 [DaoImplCommon] 类如下:

package istia.st.mvc.personnes.dao;

import istia.st.mvc.personnes.entites.Personne;
import org.springframework.orm.ibatis.support.SqlMapClientDaoSupport;

import java.util.Collection;

public class DaoImplCommon extends SqlMapClientDaoSupport implements
        IDao {

    // 人员列表
    public Collection getAll() {
...
    }

    // 获取特定人员
    public Personne getOne(int id) {
...
    }

    // 删除联系人
    public void deleteOne(int id) {
...
    }

    // 添加或修改人员
    public void saveOne(Personne personne) {
        // 人员参数是否有效?
        check(personne);
        // 是添加还是修改?
        if (personne.getId() == -1) {
            // 添加
            insertPersonne(personne);
        } else {
            updatePersonne(personne);
        }
    }

    // 添加人员
    protected void insertPersonne(Personne personne) {
...
    }

    // 修改人员
    protected void updatePersonne(Personne personne) {
...
    }

    // 验证人员有效性
    private void check(Personne p) {
...
    }

...
}
  • 第 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/]访问:

Image

[iBATIS] 是一个 Apache 项目,用于简化基于数据库的 [dao] 层的构建。结合 [iBATIS],数据访问层的架构如下:

[iBATIS] 位于应用程序的 [dao] 层与数据库驱动程序 JDBC 之间。 [iBATIS] 还有其他替代方案,例如 [Hibernate]:

Image

使用 [iBATIS] 框架需要两个 [ibatis-common, ibatis-sqlmap] 压缩包,这两个压缩包均已放置在项目的 [lib] 文件夹中:

类 [SqlMapClientDaoSupport] 封装了框架 [iBATIS] 和 c.a.d 的通用使用部分。 这些代码片段存在于所有使用工具 [iBATIS] 的 [dao] 层中。 要编写代码的非通用部分,即针对 [dao] 层特有的内容,只需继承 [SqlMapClientDaoSupport] 类即可。这就是我们在此处所做的。

类 [SqlMapClientDaoSupport] 定义如下:

Image

在该类的各种方法中,其中一个方法用于配置将用于访问数据库的客户端 [iBATIS]:

Image

对象 [SqlMapClient sqlMapClient] 即用于访问数据库的 [IBATIS] 对象。它单独实现了我们架构中的 [iBATIS] 层:

使用该对象的一般操作流程如下:

  1. 向连接池请求连接
  2. 开启事务
  3. 执行存储在配置文件中的一系列 SQL 命令
  4. 关闭事务
  5. 将连接归还给连接池

如果我们的 [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]类型:

Image

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

Image

需要提醒的是,我们正在尝试实例化一个实现 [SqlMapClient] 接口的对象。 显然,[SqlMapClientFactoryBean] 类并非如此。该类实现了 [FactoryBean] 接口(参见上文)。该接口具有以下 [getObject()] 方法:

Image

当向 Spring 请求一个实现 [FactoryBean] 接口的对象实例时,它会:

  • 创建该类的 [I] 实例——此处创建的是 [SqlMapClientFactoryBean] 类型的实例。
  • 将 [I] 方法的结果返回给调用方。getObject() - 方法 [SqlMapClientFactoryBean].getObject() 将在此返回一个实现接口 [SqlMapClient] 的对象。

为了能够返回一个实现 [SqlMapClient] 接口的对象,[SqlMapClientFactoryBean] 类需要该对象的两个必要信息:

  • 一个已连接数据库的 [DataSource] 对象,该对象将向其请求连接
  • 一个(或多个)配置文件,其中包含待执行的 SQL 命令

类 [SqlMapClientFactoryBean] 提供了用于初始化这两个属性的 set 方法:

Image

我们正在取得进展……我们的配置文件逐渐完善,最终变为:


<!-- 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]:

Image

我们再次面对一个需要为其寻找实现类的接口。此类的作用是高效地为应用程序提供与特定数据库的连接。 一个 SGBD 无法同时保持大量连接处于打开状态。为了减少某个时刻打开的连接数量,在每次与数据库交互时,我们需要:

  • 建立连接
  • 开始事务
  • 发出 SQL 命令
  • 结束事务
  • 关闭连接

反复打开和关闭连接会消耗大量时间。为了解决这两个问题(既要限制某个时刻打开的连接数,又要降低打开/关闭连接的成本),实现 [DataSource] 接口的类通常采用以下方式:

  • 在实例化时,它们会立即与目标数据库建立 N 个连接。N 通常有一个默认值,且大多可在配置文件中定义。这 N 个连接将始终保持打开状态,并构成一个可供应用程序线程使用的连接池。
  • 当应用程序的线程请求建立连接时,[DataSource] 对象会为其分配启动时打开的 N 个连接中的一个(如果仍有可用连接)。当应用程序关闭连接时,该连接实际上并未真正关闭,而是被放回可用连接池中。

目前有多种 [DataSource] 接口的实现可供免费使用。本文将使用位于 [http://jakarta.apache.org/commons/dbcp/] 网址处的 [commons DBCP] 实现:

Image

使用 [commons DBCP] 工具需要两个 [commons-dbcp, commons-pool] 压缩包,这两个文件均已放置在项目的 [lib] 文件夹中:

[commons DBCP] 中的 [BasicDataSource] 类提供了我们所需的 [DataSource] 实现:

Image

该类将为我们提供一个连接池,用于访问应用程序中的 Firebird 数据库 [dbpersonnes.gdb]。为此,需要向其提供创建连接池所需的以下信息:

  1. 要使用的驱动程序名称 JDBC – 初始化时使用 [setDriverClassName]
  2. 要使用的数据库 URL 名称——初始化为 [setUrl]
  3. 连接所有者的用户 ID – 初始化值为 [setUsername](而非如预期所示的 setUserName)
  4. 其密码 - 初始化为 [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 项目中的位置:

Image

  • [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] 如下所示:

package istia.st.mvc.personnes.dao;

import istia.st.mvc.personnes.entites.Personne;
import org.springframework.orm.ibatis.support.SqlMapClientDaoSupport;

import java.util.Collection;

public class DaoImplCommon extends SqlMapClientDaoSupport implements
        IDao {

    // 联系人列表
    public Collection getAll() {
...
    }

    // 获取特定人员
    public Personne getOne(int id) {
...
    }

    // 删除联系人
    public void deleteOne(int id) {
...
    }

    // 添加或修改人员
    public void saveOne(Personne personne) {
        // 人员参数是否有效?
        check(personne);
        // 是添加还是修改?
        if (personne.getId() == -1) {
            // 添加
            insertPersonne(personne);
        } else {
            updatePersonne(personne);
        }
    }

    // 添加人员
    protected void insertPersonne(Personne personne) {
...
    }

    // 修改人员
    protected void updatePersonne(Personne personne) {
...
    }

    // 验证人员有效性
    private void check(Personne p) {
...
    }

...
}

我们将依次研究这些方法。


getAll


该方法用于获取列表中的所有人员。其代码如下:

1
2
3
4
    // 人员列表
    public Collection getAll() {
        return getSqlMapClientTemplate().queryForList("Personne.getAll", null);
}

首先需要明确的是,类 [DaoImplCommon] 继承自 Spring 类 [SqlMapClientDaoSupport]。正是该类拥有上文第 3 行中使用的方法 [getSqlMapClientTemplate()]。该方法的签名如下:

Image

类型 [SqlMapClientTemplate] 封装了 [SqlMapClient] 层中的对象 [SqlMapClient]。正是通过它,我们才能访问数据库。 类型 [iBATIS] SqlMapClient 可以被直接使用,因为类 [SqlMapClientDaoSupport] 可以访问它:

Image

[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] 的定义如下:

Image

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

Image

该接口定义了能够处理 [personnes-firebird.xml] 文件内容的方法:

[queryForList]

Image

该方法用于发出 [SELECT] 指令,并以对象列表的形式获取其结果:

  • [statementName]:配置文件中 [select] 命令的标识符 (id)
  • [parameterObject]:针对已配置的 [select] 的“参数”对象。该“参数”对象可采用两种形式:
    • 符合 JavaBean 标准的对象:此时,[select] 命令的参数即为 JavaBean 字段的名称。在执行 [select] 命令时,这些参数将被替换为相应字段的值。
    • 字典:此时,命令 [select] 的参数即为字典的键。在执行命令 [select] 时,这些键将被替换为字典中与其关联的值。
  • 如果 [SELECT] 未返回任何行,则结果 [List] 是一个空对象,但 null 并非如此(待验证)。

[queryForObject]

Image

该方法的原理与前一个相同,但仅返回一个对象。如果 [SELECT] 未返回任何行,则结果为指针 null

[insert]

Image

此方法用于执行由第二个参数配置的 SQL [insert] 命令。返回的对象是已插入行的一主键。没有强制要求必须使用此结果。

[update]

Image

此方法用于执行由第二个参数设置的 SQL [update] 命令。结果是 SQL [update] 命令修改的行数。

[delete]

Image

此方法用于执行由第二个参数配置的命令 SQL [delete]。结果是命令 SQL [delete] 删除的行数。

让我们回到类 [DaoImplCommon] 中的方法 [getAll]:

1
2
3
4
    // 人员列表
    public Collection getAll() {
        return getSqlMapClientTemplate().queryForList("Personne.getAll", null);
}
  • 第 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] 标识的人员。其代码如下:

        // 获取特定人员
    public Personne getOne(int id) {
        // 从 BD 中检索
        Personne personne = (Personne) getSqlMapClientTemplate()
                .queryForObject("Personne.getOne", new Integer(id));
        // 是否检索到任何内容?
        if (personne == null) {
            // 抛出异常
            throw new DaoException(
                    "La personne d'id [" + id + "] n'existe pas", 2);
        }
        // 返回该人员
        return personne;
    }
  • 第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] 标识的人员。其代码如下:

    // 删除某人
    public void deleteOne(int id) {
        // 删除该人员
        int n = getSqlMapClientTemplate().delete("Personne.deleteOne",
                new Integer(id));
        // 是否成功
        if (n == 0) {
            throw new DaoException("Personne d'id [" + id + "] inconnue", 2);
        }
    }
  • 第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


此方法可用于添加新人员或修改现有人员。其代码如下:

        // 添加或修改人员
    public void saveOne(Personne personne) {
        // “person”参数是否有效?
        check(personne);
        // 是添加还是修改?
        if (personne.getId() == -1) {
            // 添加
            insertPersonne(personne);
        } else {
            updatePersonne(personne);
        }
    }
...
  • 第4行:使用[check]方法验证人员有效性。该方法在上一版本中已存在,当时被注释掉。若人员无效,则触发[DaoException]。允许该异常上报。
  • 第6行:若执行到此处,说明未发生异常。因此该人员信息有效。
  • 第 6-11 行:根据人员的 ID,处理的是新增(ID = -1)还是更新(ID ≠ -1)。两种情况下,都会调用该类内的两个内部方法:
    • insertPersonne:用于添加
    • updatePersonne:用于更新

insertPersonne


该方法用于添加新人员。其代码如下:

// 添加人员
    protected void insertPersonne(Personne personne) {
        // 第1版
        personne.setVersion(1);
        // 等待 10 毫秒 - 测试时请将 false 改为 true
        if (true)
            wait(10);
        // 将新人员插入 BD 表中
        getSqlMapClientTemplate().insert("Personne.insertOne", personne);
    }
  • 第 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] 属性指明了该字段。
  • 第6-7行:出于测试需要,我们将延迟10毫秒再进行插入操作,以此观察是否存在多个线程试图同时进行插入操作而引发的冲突。

updatePersonne


该方法用于修改表 [PERSONNES] 中已存在的个人记录。其代码如下:

// 修改人员信息
    protected void updatePersonne(Personne personne) {
        // 等待 10 毫秒 - 测试时请将 false 改为 true
        if (true)
            wait(10);
        // 修改
        int n = getSqlMapClientTemplate()
                .update("Personne.updateOne", personne);
        if (n == 0)
            throw new DaoException("La personne d'Id [" + personne.getId()
                    + "] n'existe pas ou bien a été modifiée", 2);
    }
  • 更新操作可能因至少两种原因失败:
    1. 待更新的记录不存在
    2. 待更新的个人记录已存在,但试图修改它的线程使用的不是正确版本
  • 第 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 测试用例对其进行测试:

Image

在进行全面测试之前,我们可以先从一个简单的 [main] 类型的程序开始,该程序将显示 [PERSONNES] 表中的内容。这就是 [MainTestDaoFirebird] 类:

package istia.st.mvc.personnes.tests;

import istia.st.mvc.personnes.dao.IDao;

import java.util.Collection;
import java.util.Iterator;

import org.springframework.beans.factory.xml.XmlBeanFactory;
import org.springframework.core.io.ClassPathResource;

public class MainTestDaoFirebird {
    public static void main(String[] args) {
        IDao dao = (IDao) (new XmlBeanFactory(new ClassPathResource(
                "spring-config-test-dao-firebird.xml"))).getBean("dao");
        // 当前列表
        Collection personnes = dao.getAll();
        // 控制台显示
        Iterator iter = personnes.iterator();
        while (iter.hasNext()) {
            System.out.println(iter.next());
        }
    }
}

[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] 的内容如下:

Image

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

Image

确实获取到了人员列表。现在可以进行 JUnit 测试。

测试 JUnit [TestDaoFirebird] 如下:

package istia.st.mvc.personnes.tests;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Collection;
import java.util.Iterator;
import org.springframework.beans.factory.xml.XmlBeanFactory;
import org.springframework.core.io.ClassPathResource;

import istia.st.mvc.personnes.dao.DaoException;
import istia.st.mvc.personnes.dao.IDao;
import istia.st.mvc.personnes.entites.Personne;
import junit.framework.TestCase;

public class TestDaoFirebird extends TestCase {

    // [dao] 层
    private IDao dao;

    public IDao getDao() {
        return dao;
    }

    public void setDao(IDao dao) {
        this.dao = dao;
    }

    // 制造商
    public void setUp() {
        dao = (IDao) (new XmlBeanFactory(new ClassPathResource(
                "spring-config-test-dao-firebird.xml"))).getBean("dao");
    }

    // 人员名单
    private void doListe(Collection personnes) {
...
    }

    // 测试1
    public void test1() throws ParseException {
...
    }

    // 修改-删除不存在的元素
    public void test2() throws ParseException {
..
    }

    // 人员版本管理
    public void test3() throws ParseException, InterruptedException {
...
    }

    // 乐观锁定 - 多线程访问
    public void test4() throws Exception {
...
    }

    // saveOne 的有效性测试
    public void test5() throws ParseException {
....
    }

    // 多线程插入
    public void test6() throws ParseException, InterruptedException{
...
}
  • 测试 [test1] 至 [test5] 与第 1 版相同,但 [test4] 略有调整。测试 [test6] 则是全新的。我们仅对这两个测试进行说明。

[test4]


[test4] 的目的是测试 [updatePersonne - DaoImplCommon] 方法。该方法的代码如下:

// 修改人员
    protected void updatePersonne(Personne personne) {
        // 等待 10 毫秒 - 测试时将 false 改为 true
        if (true)
            wait(10);
        // 修改
        int n = getSqlMapClientTemplate()
                .update("Personne.updateOne", personne);
        if (n == 0)
            throw new DaoException("La personne d'Id [" + personne.getId()
                    + "] n'existe pas ou bien a été modifiée", 2);
    }
  • 第4-5行:等待10毫秒。这样会迫使执行[updatePersonne]的线程失去处理器,从而增加观察到并发线程之间发生访问冲突的可能性。

[test4] 启动 N=100 个线程,负责同时将同一个人子女的数量增加 1。我们希望观察版本冲突和访问冲突是如何被处理的。

    public void test4() throws Exception {
        // 添加人员
        Personne p1 = new Personne(-1, "X", "X", new SimpleDateFormat(
                "dd/MM/yyyy").parse("01/02/2006"), true, 0);
        dao.saveOne(p1);
        int id1 = p1.getId();
        // 创建 N 个线程来更新子节点数量
        final int N = 100;
        Thread[] taches = new Thread[N];
        for (int i = 0; i < taches.length; i++) {
            taches[i] = new ThreadDaoMajEnfants("thread n° " + i, dao, id1);
            taches[i].start();
        }
        // 等待线程结束
        for (int i = 0; i < taches.length; i++) {
            taches[i].join();
        }
        // 获取该人员
        p1 = dao.getOne(id1);
        // 该人员应有 N 个子女
        assertEquals(N, p1.getNbEnfants());
        // 删除人员 p1
        dao.deleteOne(p1.getId());
        // 验证
        boolean erreur = false;
        int codeErreur = 0;
        try {
            p1 = dao.getOne(p1.getId());
        } catch (DaoException ex) {
            erreur = true;
            codeErreur = ex.getCode();
        }
        // 应出现代码 2 的错误
        assertTrue(erreur);
        assertEquals(2, codeErreur);
    }

线程在第 8-13 行创建。每个线程将把第 3-5 行创建的该人的子女数量增加 1。更新线程 [ThreadDaoMajEnfants ] 如下:

package istia.st.mvc.personnes.tests;

import java.util.Date;

import istia.st.mvc.personnes.dao.DaoException;
import istia.st.mvc.personnes.dao.IDao;
import istia.st.mvc.personnes.entites.Personne;

public class ThreadDaoMajEnfants extends Thread {
    // 线程名称
    private String name;

    // 关于 [dao] 层的参考
    private IDao dao;

    // 待处理人员的ID
    private int idPersonne;

    // 构建器
    public ThreadDaoMajEnfants(String name, IDao dao, int idPersonne) {
        this.name = name;
        this.dao = dao;
        this.idPersonne = idPersonne;
    }

    // 线程核心
    public void run() {
        // 跟踪
        suivi("lancé");
        // 在未能成功递增1之前持续循环
        // 该人的子女数量 idPersonne
        boolean fini = false;
        int nbEnfants = 0;
        while (!fini) {
            // 获取idPersonne中该人的副本
            Personne personne = dao.getOne(idPersonne);
            nbEnfants = personne.getNbEnfants();
            // 后续处理
            suivi("" + nbEnfants + " -> " + (nbEnfants + 1)
                    + " pour la version " + personne.getVersion());
            // 等待 10 毫秒后释放处理器
            try {
                // 跟进
                suivi("début attente");
                // 暂停以释放处理器
                Thread.sleep(10);
                // 跟进
                suivi("fin attente");
            } catch (Exception ex) {
                throw new RuntimeException(ex.toString());
            }
            // 等待结束 - 尝试验证副本
            // 在此期间其他线程可能已修改了原始数据
            int codeErreur = 0;
            try {
                // 将该副本的子节点数增加1
                personne.setNbEnfants(nbEnfants + 1);
                // 正在尝试修改原始文件
                dao.saveOne(personne);
                // 操作成功——原始数据已被修改
                fini = true;
            } catch (DaoException ex) {
                // 获取错误代码
                codeErreur = ex.getCode();
                // 若出现ID错误或错误代码2,则重试更新
                switch (codeErreur) {
                case 2:
                    suivi("version corrompue ou personne inexistante");
                    break;
                default:
                    // 未处理的异常 - 允许上报
                    throw ex;
                }
            }
        }
        // 跟踪
        suivi("a terminé et passé le nombre d'enfants à " + (nbEnfants + 1));
    }

    // 跟进
    private void suivi(String message) {
        System.out.println(name + " [" + new Date().getTime() + "] : "
                + message);
    }
}

人员更新可能会失败,因为要修改的人员不存在,或者该人员已被其他线程更新过。这两种情况在第67-69行中进行了处理。 在上述两种情况下,方法 [updatePersonne] 会触发代码为 2 的 [DaoException] 线程。随后该线程将返回并从头开始执行更新流程(第 34 行的 while 循环)。


[test6]


[test6] 的目的是测试 [insertPersonne - DaoImplCommon] 方法。重申该方法的代码如下:

// 添加人员
    protected void insertPersonne(Personne personne) {
        // 第1版
        personne.setVersion(1);
        // 等待 10 毫秒 - 测试时将 false 改为 true
        if (true)
            wait(10);
        // 将新人员插入 BD 表中
        getSqlMapClientTemplate().insert("Personne.insertOne", personne);
    }
  • 第6-7行:等待10毫秒,以迫使执行[insertPersonne]的线程失去处理器,从而增加因多个线程同时进行插入操作而引发冲突的可能性。

[test6]的代码如下:

    // 多线程插入
    public void test6() throws ParseException, InterruptedException{
        // 创建一个人员
        Personne p = new Personne(-1, "X", "X", new SimpleDateFormat(
                "dd/MM/yyyy").parse("01/02/2006"), true, 0);
        // 将其在数组中复制 N 次
        final int N = 100;
        Personne[] personnes=new Personne[N];
        for(int i=0;i<personnes.length;i++){
            personnes[i]=new Personne(p);
        }
        // 创建 N 个插入线程——每个线程插入 1 个人员
        Thread[] taches = new Thread[N];
        for (int i = 0; i < taches.length; i++) {
            taches[i] = new ThreadDaoInsertPersonne("thread n° " + i, dao, personnes[i]);
            taches[i].start();
        }
        // 等待线程结束
        for (int i = 0; i < taches.length; i++) {
            // 第 i 个线程
            taches[i].join();
            // 删除人员
            dao.deleteOne(personnes[i].getId());
        }
}

我们创建 100 个线程,它们将同时插入 100 个不同的人。这 100 个线程都会为需要插入的人获取一个主键,然后在能够进行插入之前被中断 10 毫秒(第 10 行 – insertPersonne)。 我们需要验证操作是否正常进行,特别是验证它们是否确实获得了不同的主键值。

  • 第7-11行:创建了一个包含100人的数组。这些人都是第4-5行创建的p的副本。
  • 第14-17行:启动100个插入线程。每个线程负责插入之前创建的100个人物之一。
  • 第19-23行:[test6]等待其启动的100个线程全部结束。当检测到第i个线程结束时,它会删除该线程刚刚插入的人员。

插入线程 [ThreadDaoInsertPersonne] 的代码如下:

package istia.st.mvc.personnes.tests;

import java.util.Date;

import istia.st.mvc.personnes.dao.IDao;
import istia.st.mvc.personnes.entites.Personne;

public class ThreadDaoInsertPersonne extends Thread {
    // 线程名称
    private String name;

    // [dao] 层上的引用
    private IDao dao;

    // 待处理人员的ID
    private Personne personne;

    // 构造函数
    public ThreadDaoInsertPersonne(String name, IDao dao, Personne personne) {
        this.name = name;
        this.dao = dao;
        this.personne = personne;
    }

    // 线程核心
    public void run() {
        // 跟踪
        suivi("lancé");
        // 插入
        dao.saveOne(personne);
        // 跟进
        suivi("a terminé");
    }

    // 跟踪
    private void suivi(String message) {
        System.out.println(name + " [" + new Date().getTime() + "] : "
                + message);
    }
}
  • 第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] 的代码:

            try {
                // 将此副本的子节点数增加 1
                personne.setNbEnfants(nbEnfants + 1);
                // 尝试修改原始文件
                dao.saveOne(personne);
                // 操作成功 - 原始文件已被修改
                fini = true;
            } catch (DaoException ex) {
                // 获取错误代码
                codeErreur = ex.getCode();
                // 若出现 ID 错误或代码版本错误 2则重试更新
                switch (codeErreur) {
                case 2:
                    suivi("version corrompue ou personne inexistante");
                    break;
                default:
                    // 未处理的异常 - 允许异常上报
                    throw ex;
                }
  • 第 8 行:处理了类型为 [DaoException] 的异常。根据上述情况,我们需要处理测试中出现的异常,即类型为 [org.springframework.jdbc.UncategorizedSQLException] 的异常。 然而,我们不能仅处理该类型,因为它只是 Spring 用于封装未知异常的通用类型。 Spring 识别由 JDBC 驱动程序抛出的异常,这些驱动程序对应于若干 SGBD 数据库(如 Oracle、MySQL、 Postgres、DB2、SQL Server 等驱动程序抛出的异常,但不包括 Firebird。 因此,Firebird 的 JDBC 驱动程序抛出的所有异常都会被封装在 Spring 的 [org.springframework.jdbc.UncategorizedSQLException] 类型中:

Image

从上文可以看出,[UncategorizedSQLException] 类继承自我们在第 17.3.3 节中提到的 [DataAccessException] 类。 通过其方法 [getSQLException],可以查明封装在 [UncategorizedSQLException] 中的异常:

Image

该 [SQLException] 类型的异常是由 [iBATIS] 层抛出的,而该层本身封装了由数据库驱动程序 JDBC 抛出的异常。 可以通过以下方法获取 [SQLException] 类型异常的确切原因:

Image

由此可获取由驱动程序 JDBC 抛出的类型为 [Throwable] 的对象:

Image

类型 [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] 可重写为:

package istia.st.mvc.personnes.tests;
...

public class ThreadDaoMajEnfants extends Thread {
...

    // 线程核心
    public void run() {
...
        while (!fini) {
            // 从 idPersonne 获取该用户的副本
            Personne personne = dao.getOne(idPersonne);
            nbEnfants = personne.getNbEnfants();
...
            // 等待结束 - 尝试验证副本
            // 在此期间,其他线程可能已修改了原始数据
            int codeErreur = 0;
            try {
                // 将该副本的子节点数增加1
                personne.setNbEnfants(nbEnfants + 1);
                // 正在尝试修改原始文件
                dao.saveOne(personne);
                // 操作成功——原始数据已被修改
                fini = true;
            } catch (DaoException ex) {
                // 获取错误代码
                codeErreur = ex.getCode();
                // 如果出现 ID 错误、版本 2 错误或死锁 4,则
                // 重试更新
                switch (codeErreur) {
                case 2:
                    suivi("version corrompue ou personne inexistante");
                    break;
                case 4:
                    suivi("conflit de mise à jour");
                    break;
                default:
                    // 未处理的异常 - 允许异常上报
                    throw ex;
                }
            }
        }
        // 跟踪
        suivi("a terminé et passé le nombre d'enfants à " + (nbEnfants + 1));
    }
...
}
  • 第 34-36 行:代码为 4 的 [DaoException] 类型异常被拦截。线程 [ThreadDaoMajEnfants] 将被迫从头(第 10 行)重新开始更新过程

因此,我们的 [dao] 层必须能够识别“更新冲突”类型的异常。该异常由 JDBC 驱动程序触发,且仅针对该驱动程序。 该异常应在 [DaoImplCommon] 类的 [updatePersonne] 方法中进行处理:

// 修改人员
    protected void updatePersonne(Personne personne) {
        // 等待 10 毫秒 - 测试时请将 false 改为 true
        if (true)
            wait(10);
        // 修改
        int n = getSqlMapClientTemplate()
                .update("Personne.updateOne", personne);
        if (n == 0)
            throw new DaoException("La personne d'Id [" + personne.getId()
                    + "] n'existe pas ou bien a été modifiée", 2);
    }

第 7-11 行必须用 try / catch 语句包围。对于 SGBD Firebird,我们需要验证导致更新失败的异常类型为 [org.firebirdsql.gds.GDSException],且错误代码为 335544336。 如果将此类测试放入 [DaoImplCommon] 中,将会将该类与 SGBD Firebird 关联起来,这显然是不希望看到的。 如果希望保持 [DaoImplCommon] 类的通用性,我们需要对其进行派生,并在一个专门针对 Firebird 的类中处理该异常。这就是我们接下来要做的。

17.4.2. 类 [DaoImplFirebird]

其代码如下:

package istia.st.mvc.personnes.dao;

import istia.st.mvc.personnes.entites.Personne;

public class DaoImplFirebird extends DaoImplCommon {

    // 修改人员
    protected void updatePersonne(Personne personne) {
        // 等待10毫秒——测试时请将false改为true
        if (true)
            wait(10);
        // 修改
        try {
            // 修改拥有正确版本的人员
            int n = getSqlMapClientTemplate().update("Personne.updateOne",
                    personne);
            if (n == 0)
                throw new DaoException("La personne d'Id [" + personne.getId()
                        + "] n'existe pas ou bien a été modifiée", 2);
        } catch (org.springframework.jdbc.UncategorizedSQLException ex) {
            if (ex.getSQLException().getCause().getClass().isAssignableFrom(
                    org.firebirdsql.jdbc.FBSQLException.class)) {
                org.firebirdsql.jdbc.FBSQLException cause = (org.firebirdsql.jdbc.FBSQLException) ex
                        .getSQLException().getCause();
                if (cause.getErrorCode() == 335544336) {
                    throw new DaoException(
                            "Conflit d'accès au même enregistrement", 4);
                }
            } else {
                throw ex;
            }
        }
    }

    // 等待
    private void wait(int N) {
        // 等待 N 毫秒
        try {
            Thread.sleep(N);
        } catch (InterruptedException e) {
            // 显示异常跟踪信息
            e.printStackTrace();
            return;
        }
    }

}
  • 第 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] 的结果如下:

Image

[test4] 已通过。屏幕日志的最后几行如下:

1
2
3
4
5
6
7
thread n° 36 [1145977145984] : fin attente
thread n° 75 [1145977145984] : a terminé et passé le nombre d'enfants à 99
thread n° 36 [1145977146000] : version corrompue ou personne inexistante
thread n° 36 [1145977146000] : 99 -> 100 pour la version 100
thread n° 36 [1145977146000] : début attente
thread n° 36 [1145977146015] : fin attente
thread n° 36 [1145977146031] : a terminé et passé le nombre d'enfants à 100

最后一行表明第 36 号线程最后完成。第 3 行显示了一个版本冲突,导致第 36 号线程不得不重启人员更新流程(第 4 行)。其他日志显示了更新过程中的访问冲突:

1
2
3
thread n° 52 [1145977145765] : version corrompue ou personne inexistante
thread n° 75 [1145977145765] : conflit de mise à jour
thread n° 36 [1145977145765] : version corrompue ou personne inexistante

第2行显示,线程75在更新时因更新冲突而失败: 当在表 [PERSONNES] 上发出命令 SQL [update] 时,需要更新的行已被另一个线程锁定。此访问冲突将迫使第 75 号线程重试更新。

最后关于 [test4],值得注意的是,其结果与版本 1 中同一测试的结果存在显著差异——在版本 1 中,该测试因同步问题而失败。 由于版本 1 中 [dao] 层的方法未进行同步,因此出现了访问冲突。在此,我们无需对 [dao] 层进行同步,只需处理 Firebird 报告的访问冲突即可。

现在,让我们执行 [dao] 层中的完整测试 JUnit:

Image

因此,该[dao]图层似乎是有效的。若要以极高的概率确认其有效性,我们需要进行更多测试。尽管如此,我们仍将其视为可用的。

17.5. [service]图层

17.5.1. [service] 层的组成部分

[service] 层由以下类和接口组成:

Image

  • [IService] 是 [service] 层提供的接口
  • [ServiceImpl] 是该接口的实现

[IService] 接口如下:

package istia.st.mvc.personnes.service;

import istia.st.mvc.personnes.entites.Personne;

import java.util.Collection;

public interface IService {
    // 所有人员的列表
    Collection getAll();

    // 查询特定人员
    Personne getOne(int id);

    // 添加/修改人员
    void saveOne(Personne personne);

    // 删除人员
    void deleteOne(int id);

    // 批量保存人员
    void saveMany(Personne[] personnes);

    // 删除多个人员
    void deleteMany(int ids[]);
}
  • 该接口与版本 1 中的接口具有相同的四个方法,但额外增加了两个方法:
    • saveMany:允许以原子方式同时保存多人。要么全部保存,要么一个都不保存。
    • deleteMany:允许以原子方式同时删除多个人员。要么全部删除,要么一个也不删除。

这两个方法不会被 Web 应用程序使用。我们添加它们是为了说明数据库中的事务概念。实际上,这两个方法必须在事务中执行,才能实现所需的原子性。

实现该接口的类 [ServiceImpl] 如下所示:

package istia.st.mvc.personnes.service;

import istia.st.mvc.personnes.entites.Personne;
import istia.st.mvc.personnes.dao.IDao;

import java.util.Collection;

public class ServiceImpl implements IService {

    // 图层 [dao]
    private IDao dao;

    public IDao getDao() {
        return dao;
    }

    public void setDao(IDao dao) {
        this.dao = dao;
    }

    // 人员列表
    public Collection getAll() {
        return dao.getAll();
    }

    // 获取特定人员
    public Personne getOne(int id) {
        return dao.getOne(id);
    }

    // 添加或修改人员
    public void saveOne(Personne personne) {
        dao.saveOne(personne);
    }

    // 删除人员
    public void deleteOne(int id) {
        dao.deleteOne(id);
    }

    // 保存人员集合
    public void saveMany(Personne[] personnes) {
        // 遍历人员数组
        for (int i = 0; i < personnes.length; i++) {
            dao.saveOne(personnes[i]);
        }
    }

    // 删除人员集合
    public void deleteMany(int[] ids) {
        // ids:待删除人员的ID
        for (int i = 0; i < ids.length; i++) {
            dao.deleteOne(ids[i]);
        }
    }
}
  • [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] 类:

Image

我们可以看到它实现了 [FactoryBean] 接口。我们之前已经遇到过这个接口。我们知道,当应用程序向 Spring 请求一个实现 [FactoryBean] 接口的类型的实例时, Spring 返回的并非该类型的 [I] 实例,而是由方法 [I].getObject() 返回的对象:

Image

在我们的案例中,[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 的调用将如下所示(参见上图):

  1. [C1] 调用 [C2] 的方法 M。 实际上被调用的将是 [intercepteur] 的 M 方法。只有当 [C1] 调用的是 [I2] 接口,而非 [I2] 的特定实现时,才可能发生这种情况。 此时,只需让 [intercepteur] 实现 [I2] 即可。
  2. [intercepteur] 的 M 方法负责记录日志,并调用 [C2] 的 M 方法(该方法最初是 [C1] 所指向的)。
  3. [C2] 的 M 方法执行并将其结果返回给 [intercepteur] 的 M 方法,后者可能会对步骤 2 中已完成的内容进行补充。
  4. [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] 添加这一事务概念。请参考上图:

  1. [web]层调用[IService]接口中的[saveMany]方法。
  2. [proxy transactionnel] 中的 [saveMany] 方法被执行。它启动了一个事务。 该方法必须具备足够的执行信息,特别是需要一个 [DataSource] 对象以建立与 SGBD 的连接。随后,它调用 [ServiceImpl] 中的 [saveMany] 方法。
  3. 该方法执行后,会反复调用 [dao] 层来执行插入或更新操作。此时执行的 SQL 命令均在步骤 2 中启动的事务中执行。
  4. 假设其中一项操作失败。 [dao] 层将向 [service] 层抛出异常,具体而言,即抛向 [ServiceImpl] 实例的 [saveMany] 方法。
  5. 该方法不执行任何操作,而是将异常向上传递至 [proxy transactionnel] 实例的 [saveMany] 方法。
  6. 接收到异常后,作为事务所有者的 [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]:

Image

[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 测试进行测试:

Image

[service] 层的配置文件 [spring-config-test-service-firebird.xml] 即第 17.5.2 节中所述的文件。

JUnit [TestServiceFirebird] 测试内容如下:

package istia.st.mvc.personnes.tests;

...

public class TestServiceFirebird extends TestCase {

    // [service] 层
    private IService service;

    public IService getService() {
        return service;
    }

    public void setService(IService service) {
        this.service = service;
    }

    // 设置
    public void setUp() {
        service = (IService) (new XmlBeanFactory(new ClassPathResource(
                "spring-config-test-service-firebird.xml"))).getBean("service");
    }

    // 人员名单
    private void doListe(Collection personnes) {
...
    }

    // 测试1
    public void test1() throws ParseException {
...
    }

    // 修改-删除不存在的元素
    public void test2() throws ParseException {
...
    }

    // 人员版本管理
    public void test3() throws ParseException, InterruptedException {
...
    }

    // 乐观锁定 - 多线程访问
    public void test4() throws Exception {
...
    }

    // saveOne 的有效性测试
    public void test5() throws ParseException {
...
    }

        // 多线程插入
    public void test6() throws ParseException, InterruptedException{
...
    }

    // deleteMany 方法测试
    public void test7() throws ParseException {
        // 当前列表
        Collection personnes = service.getAll();
        int nbPersonnes1 = personnes.size();
        // 显示
        doListe(personnes);
        // 创建三个人
        Personne p1 = new Personne(-1, "X", "X", new SimpleDateFormat(
                "dd/MM/yyyy").parse("01/02/2006"), true, 1);
        Personne p2 = new Personne(-1, "Y", "Y", new SimpleDateFormat(
                "dd/MM/yyyy").parse("01/03/2006"), false, 0);
        Personne p3 = new Personne(-2, "Z", "Z", new SimpleDateFormat(
                "dd/MM/yyyy").parse("01/04/2006"), true, 2);
        // 添加 3 个人 - ID 为 -2 的第 3 个人  将引发
        // 一个异常
        boolean erreur = false;
        try {
            service.saveMany(new Personne[] { p1, p2, p3 });
        } catch (Exception ex) {
            erreur = true;
            System.out.println(ex.toString());
        }
        // 验证
        assertTrue(erreur);
        // 新列表 - 元素数量不应发生变化
        // 因为事务自动回滚
        int nbPersonnes2 = service.getAll().size();
        assertEquals(nbPersonnes1, nbPersonnes2);
        // 添加了两名有效人员
        // 将他们的ID重置为-1
        p1.setId(-1);
        p2.setId(-1);
        service.saveMany(new Personne[] { p1, p2 });
        // 检索其ID
        int id1 = p1.getId();
        int id2 = p2.getId();
        // 进行验证
        p1 = service.getOne(id1);
        assertEquals(p1.getNom(), "X");
        p2 = service.getOne(id2);
        assertEquals(p2.getNom(), "Y");
        // 新列表 - 必须包含 2 个正数元素
        int nbPersonnes3 = service.getAll().size();
        assertEquals(nbPersonnes1 + 2, nbPersonnes3);
        // 删除 p1、p2 及一名不存在的人员
        // 应发生异常
        erreur = false;
        try {
            service.deleteMany(new int[] { id1, id2, -1 });
        } catch (Exception ex) {
            erreur = true;
            System.out.println(ex.toString());
        }
        // 验证
        assertTrue(erreur);
        // 新列表
        personnes = service.getAll();
        int nbPersonnes4 = personnes.size();
        // 未删除任何人(回滚
        // 自动回滚事务)
        assertEquals(nbPersonnes4, nbPersonnes3);
        // 删除两个有效人员
        service.deleteMany(new int[] { id1, id2 });
        // 验证
        // 人员 p1
        erreur = false;
        int codeErreur = 0;
        try {
            p1 = service.getOne(id1);
        } catch (DaoException ex) {
            erreur = true;
            codeErreur = ex.getCode();
        }
        // 应出现代码 2 的错误
        assertTrue(erreur);
        assertEquals(2, codeErreur);
        // 人员 p2
        erreur = false;
        codeErreur = 0;
        try {
            p1 = service.getOne(id2);
        } catch (DaoException ex) {
            erreur = true;
            codeErreur = ex.getCode();
        }
        // 应该有一个代码2的错误
        assertTrue(erreur);
        assertEquals(2, codeErreur);
        // 新列表
        personnes = service.getAll();
        int nbPersonnes5 = personnes.size();
        // 验证 - 应该已回到起点
        assertEquals(nbPersonnes5, nbPersonnes1);
        // 显示
        doListe(personnes);
    }

}
  • 第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组成的组。此操作应成功。方法的其余部分将验证结果是否正确。

测试执行结果如下:

Image

七项测试均已通过。我们将 [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](复制/粘贴):

Image

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

Image

我们将这两个文件复制出来,然后在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] 的配置文件:

Image

文件 [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]表的内容如下:

Image

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

Image

我们通过 通过链接 [Ajout] 添加一位新用户:

我们在数据库中验证新增记录:

Image

请读者进行其他测试:[modification, suppression]。

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

Image

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

Image

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

Image

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

Image

用户 U1 进行修改并提交:

用户 U2 也做了同样的操作:

用户 U2 通过表单中的链接 [Annuler] 返回人员列表:

Image

他找到了 [Perrichon] 这个人,该记录已被 U1 修改过(姓名已转换为大写)。

那么数据库的情况如何?让我们看看:

Image

经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 也会随着每个版本而变化

除上述内容外,其余部分保持不变。下文将针对这些新版本进行说明,重点仅介绍各版本带来的新功能。