Skip to content

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

17.1. Firebird 数据库

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

该数据库名为 [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] 表的结构旨在存储 [Person] 类型的对象,其结构反映了该对象的结构。 由于 Firebird 中不存在布尔类型,因此 [MARRIED] 字段(第 8 行)被声明为 [SMALLINT] 类型,即整数。其值将为 0(未婚)或 1(已婚)。
  • 第 13–16 行:这些完整性约束与 [ValidatePerson] 数据验证器的约束一致。
  • 第 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] 是该数据库管理系统中的系统表。

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

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

Image

该项目是一个简单的 Java 项目,而非 Tomcat Web 项目。请注意,应用程序的第 2 版将沿用第 1 版的 [web] 层。因此,该层无需重新编写。


[src] 文件夹


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

Image

其中包含多个包:

  • [istia.st.mvc.personnes.dao]:包含 [dao] 层
  • [istia.st.mvc.personnes.entites]:包含 [Person] 类
  • [istia.st.mvc.people.service]:包含 [service] 类
  • [istia.st.mvc.personnes.tests]:包含针对 [dao] 和 [service] 层的 JUnit 测试

以及必须位于应用程序 ClassPath 中的配置文件。


[database] 文件夹


该文件夹包含用于用户的 Firebird 数据库:

Image

  • [dbpersonnes.gdb] 是数据库文件。
  • [dbpersonnes.sql] 是用于生成该数据库的 SQL 脚本:
/******************************************************************************/
/*** Generated by IBExpert 2006.03.07 27/04/2006 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;



/******************************************************************************/
/*** Generators ***/
/******************************************************************************/

CREATE GENERATOR GEN_PERSONNES_ID;
SET GENERATOR GEN_PERSONNES_ID TO 787;



/******************************************************************************/
/*** Tables ***/
/******************************************************************************/



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;



/* Check constraints definition */

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);


/******************************************************************************/
/*** Primary Keys ***/
/******************************************************************************/

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

文件夹 [lib]


此文件夹包含应用程序所需的文件:

请注意,这里包含用于 Firebird 数据库管理系统(DBMS)的 JDBC 驱动程序 [firebirdsql-full.jar],以及若干 [spring-*.jar] 文件。我们本可以使用发行版 [dist] 文件夹中的单个 [spring.jar] 文件,该文件包含 Spring 的所有类。 我们也可以仅使用项目所需的归档文件。这就是我们在此采取的做法,依据是 Eclipse 报告的缺失类错误以及部分 Spring 归档文件的名称。来自 [lib] 文件夹的所有这些归档文件均已添加到项目的类路径中


[dist] 文件夹


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

Image

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

17.3. [dao] 层

17.3.1. [dao] 层的组件

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

Image

  • [IDao] 是 [dao] 层提供的接口
  • [DaoImplCommon] 是该接口的一个实现,其中人员组存储在数据库表中。[DaoImplCommon] 整合了与数据库管理系统无关的功能。
  • [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 {
    // list of all persons
    Collection getAll();
    // find a specific person
    Personne getOne(int id);
    // add/modify a person
    void saveOne(Personne personne);
    // delete a person
    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 {

    // list of persons
    public Collection getAll() {
...
    }

    // get a specific person
    public Personne getOne(int id) {
...
    }

    // deleting a person
    public void deleteOne(int id) {
...
    }

    // add or modify a person
    public void saveOne(Personne personne) {
        // is the person parameter valid?
        check(personne);
        // addition or modification?
        if (personne.getId() == -1) {
            // add
            insertPersonne(personne);
        } else {
            updatePersonne(personne);
        }
    }

    // add a person
    protected void insertPersonne(Personne personne) {
...
    }

    // edit a person
    protected void updatePersonne(Personne personne) {
...
    }

    // person validity check
    private void check(Personne p) {
...
    }

...
}
  • 第 8–9 行:[DaoImpl] 类实现了 [IDao] 接口,因此也实现了 [getAll]、[getOne]、[saveOne] 和 [deleteOne] 这四个方法。
  • 第 27–37 行:[saveOne] 方法根据需要添加还是修改人员,分别调用两个内部方法 [insertPerson] 和 [updatePerson]。
  • 第 50 行:私有方法 [check] 与上一版本相同。此处不再赘述。
  • 第 8 行:为了实现 [IDao] 接口,[DaoImpl] 类继承了 Spring 类 [SqlMapClientDaoSupport]。

17.3.2. 数据访问层 [iBATIS]

Spring类 [SqlMapClientDaoSupport] 使用了第三方框架 [Ibatis SqlMap],该框架可通过以下网址获取:[http://ibatis.apache.org/]:

Image

[iBATIS] 是一个 Apache 项目,旨在简化基于数据库的 [DAO] 层的构建。使用 [iBATIS] 时,数据访问层的架构如下:

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

Image

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

[SqlMapClientDaoSupport] 类封装了使用 [iBATIS] 框架的通用部分,即在使用 [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对象[SqlMapClient sqlMapClient]的引用,该对象将负责与数据库的通信。该对象要正常工作需要两项内容:

  • 一个已连接至数据库的 [DataSource] 对象,用于从中请求连接
  • 一个(或多个)配置文件,其中包含待执行的 SQL 语句。实际上,这些语句并不在 Java 代码中。它们通过配置文件中的代码标识,而 [SqlMapClient sqlMapClient] 对象使用该代码来执行特定的 SQL 语句。

反映上述架构的 [dao] 层的初步配置如下:


    <!-- the [dao] layer access classes -->
    <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] 类提供了设置方法来初始化这两个属性:

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>
    <!-- the [dao] layer access classes -->
    <bean id="dao" class="istia.st.mvc.personnes.dao.DaoImplCommon">
        <property name="sqlMapClient">
            <ref local="sqlMapClient"/>
        </property>
    </bean>
  • 第 2-3 行:"sqlMapClient" Bean 的类型为 [SqlMapClientFactoryBean]。根据前文所述,我们知道当向 Spring 请求该 Bean 的实例时,会获得一个实现 iBATIS [SqlMapClient] 接口的对象。因此,第 14 行获取的正是该对象。
  • 第 7-9 行:我们指定 iBATIS [SqlMapClient] 对象所需的配置文件名为 "sql-map-config-firebird.xml",且该文件必须位于应用程序的 ClassPath 中。此处使用了 [SqlMapClientFactoryBean].setConfigLocation 方法。
  • 第 4–6 行:我们使用 [SqlMapClientFactoryBean] 的 [setDataSource] 方法初始化其 [dataSource] 属性。

第 5 行:我们引用了一个名为“dataSource”的 Bean,该 Bean 尚未创建。如果查看 [SqlMapClientFactoryBean] 的 [setDataSource] 方法所期望的参数,我们会发现其类型为 [DataSource]:

Image

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

  • 建立连接
  • 启动事务
  • 执行 SQL 语句
  • 关闭事务
  • 关闭连接

反复打开和关闭连接非常耗时。为了解决这两个问题——既要限制任意时刻打开的连接数量,又要减少打开和关闭连接带来的开销——实现 [DataSource] 接口的类通常会采取以下做法:

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

目前有多种免费的 [DataSource] 接口实现。本文将使用 [commons DBCP] 实现,其地址为 [http://jakarta.apache.org/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. 连接所有者的用户名——通过 [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>
    <!-- data source DBCP -->
    <bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource" 
        destroy-method="close">
        <property name="driverClassName">
            <value>org.firebirdsql.jdbc.FBDriver</value>
        </property>
        <!-- warning: do not leave spaces between the two <value> tags in the url -->
        <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>
    <!-- the [dao] layer access classes -->
    <bean id="dao" class="istia.st.mvc.personnes.dao.DaoImplCommon">
        <property name="sqlMapClient">
            <ref local="sqlMapClient"/>
        </property>
    </bean>
</beans>
  • 第 7–9 行:Firebird 数据库管理系统 (DBMS) 的 JDBC 驱动程序名称
  • 第 11–13 行:Firebird 数据库 [dbpersonnes.gdb] 的 URL。请特别注意此处的书写方式。<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>
    <!-- alias class [Person] -->
    <typeAlias alias="Personne.classe" 
        type="istia.st.mvc.personnes.entites.Personne"/>
    <!-- mapping table [PERSONNES] - object [Person] -->
    <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>
    <!-- list of all persons -->
    <select id="Personne.getAll" resultMap="Personne.map" > select ID, VERSION, NOM, 
        PRENOM, DATENAISSANCE, MARIE, NBENFANTS FROM PERSONNES</select>
    <!-- get a specific person -->
        <select id="Personne.getOne" resultMap="Personne.map" >select ID, VERSION, NOM, 
        PRENOM, DATENAISSANCE, MARIE, NBENFANTS FROM PERSONNES WHERE ID=#value#</select>
    <!-- add a person -->
    <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#, #nom#, #prenom#, #dateNaissance#, #marie#, 
        #nbEnfants#) </insert>
    <!-- update a person -->
    <update id="Personne.updateOne" parameterClass="Personne.classe"> update 
        PERSONNES set VERSION=#version#+1, NOM=#nom#, PRENOM=#prenom#, DATENAISSANCE=#dateNaissance#, 
        MARIE=#marie#, NBENFANTS=#nbEnfants# WHERE ID=#id# and 
        VERSION=#version#</update>
    <!-- delete a person -->
    <delete id="Personne.deleteOne" parameterClass="int"> delete FROM PERSONNES WHERE 
        ID=#value# </delete>
</sqlMap>
  • 该文件必须以 <sqlMap> 作为根标签(第 7 行和第 45 行)
  • 第 9–10 行:为了便于编写文件,我们将别名 [Person.class] 赋予类 [istia.st.springmvc.personnes.entites.Person]。
  • 第 12–21 行:定义了 [PERSONNES] 表中的列与 [Personne] 对象中的字段之间的映射。
  • 第 23–24 行:用于从 [PERSONNES] 表中检索所有人员的 SQL [SELECT] 语句
  • 第 26–27 行:用于从 [PERSONNES] 表中检索特定人员的 SQL [SELECT] 语句
  • 第 29–36 行:用于将人员插入 [PERSONS] 表的 SQL [INSERT] 语句
  • 第 38–41 行:用于更新 [PERSONS] 表中某人的 SQL [UPDATE] 语句
  • 第 42–44 行:用于从 [PERSONS] 表中删除某人的 SQL [DELETE] 命令

我们将通过分析实现 [dao] 层的 [DaoImplCommon] 类,来解释 [people-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 {

    // list of persons
    public Collection getAll() {
...
    }

    // get a specific person
    public Personne getOne(int id) {
...
    }

    // deleting a person
    public void deleteOne(int id) {
...
    }

    // add or modify a person
    public void saveOne(Personne personne) {
        // is the person parameter valid?
        check(personne);
        // addition or modification?
        if (personne.getId() == -1) {
            // add
            insertPersonne(personne);
        } else {
            updatePersonne(personne);
        }
    }

    // add a person
    protected void insertPersonne(Personne personne) {
...
    }

    // edit a person
    protected void updatePersonne(Personne personne) {
...
    }

    // person validity check
    private void check(Personne p) {
...
    }

...
}

我们将逐一探讨这些方法。


getAll


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

1
2
3
4
    // list of persons
    public Collection getAll() {
        return getSqlMapClientTemplate().queryForList("Personne.getAll", null);
}

首先,让我们回顾一下,[DaoImplCommon] 类继承自 Spring 的 [SqlMapClientDaoSupport] 类。正是该类提供了上文第 3 行中使用的 [getSqlMapClientTemplate()] 方法。该方法的签名如下:

Image

[SqlMapClientTemplate] 类型封装了来自 [iBATIS] 层的 [SqlMapClient] 对象。我们将通过它访问数据库。由于 [SqlMapClientDaoSupport] 类可以访问 [iBATIS] 的 SqlMapClient 类型,因此也可以直接使用该类型:

Image

[iBATIS] SqlMapClient 类的缺点在于它会抛出 [SQLException] 异常,这是一种受控异常类型,即必须通过 try/catch 代码块进行处理,或在抛出该异常的方法签名中进行声明。但请注意,[dao] 层实现了 [IDao] 接口,而该接口的方法签名中不包含异常。 因此,实现 [IDao] 接口的类的方法在签名中也不能包含异常。因此,我们必须拦截 [iBATIS] 层抛出的每个 [SQLException],并将其封装为一个未检查异常。我们项目中的 [DaoException] 类型非常适合用于这种封装。

与其自行处理这些异常,我们将把它们委托给 Spring 的 [SqlMapClientTemplate] 类型,该类型封装了来自 [iBATIS] 层的 [SqlMapClient] 对象。 事实上,[SqlMapClientTemplate] 正是为了拦截 [SqlMapClient] 层抛出的 [SQLException] 异常,并将其封装为未处理的 [ DataAccessException] 类型而设计的。这种行为正合我们所需。我们只需记住,[dao] 层现在可能抛出两种类型的未处理异常:

  • 我们的自定义 [DaoException] 类型
  • Spring 的 [DataAccessException] 类型

[SqlMapClientTemplate] 类型的定义如下:

Image

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

Image

该接口定义了能够利用 [people-firebird.xml] 文件内容的方法:

[queryForList]

Image

此方法允许您执行 [SELECT] 语句,并将结果作为对象列表检索回来:

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

[queryForObject]

Image

该方法在概念上与前一个方法完全相同,但仅返回单个对象。如果 [SELECT] 未返回任何行,则结果为指针。

[insert]

Image

此方法执行由第二个参数配置的 SQL [insert] 语句。返回的对象是已插入行主键。无需强制使用此结果。

[update]

Image

此方法执行由第二个参数配置的 SQL [update] 语句。返回值是该 SQL [update] 语句修改的行数。

[delete]

Image

此方法执行由第二个参数配置的 SQL [delete] 语句。返回值是该 SQL [delete] 语句删除的行数。

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

1
2
3
4
    // list of persons
    public Collection getAll() {
        return getSqlMapClientTemplate().queryForList("Personne.getAll", null);
}
  • 第 4 行:执行名为“Person.getAll”的 [select] 语句。该语句没有参数,因此“parameter”对象为 null

在 [people-firebird.xml] 中,名为 "Person.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>
    <!-- alias class [Person] -->
    <typeAlias alias="Personne.classe" 
        type="istia.st.mvc.personnes.entites.Personne"/>
    <!-- mapping table [PERSONNES] - object [Person] -->
    <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>
    <!-- list of all persons -->
    <select id="Personne.getAll" resultMap="Personne.map" > select ID, VERSION, NOM, 
        PRENOM, DATENAISSANCE, MARIE, NBENFANTS FROM PERSONNES</select>
...
</sqlMap>
  • 第 23 行:SQL 语句“Person.getAll”未进行参数化(查询文本中没有参数)。
  • [getAll] 方法的第 3 行调用了名为 "Personne.getAll" 的 [select] 查询。该查询将被执行。[iBATIS] 依赖于 JDBC。 因此,我们知道查询结果将作为 [ResultSet] 对象返回。第 23 行:<select> 标签的 [resultMap] 属性告诉 [iBATIS] 应使用哪个“resultMap”将获得的 [ResultSet] 的每一行转换为一个对象。 正是第 12–21 行中定义的 "resultMap" [Person.map],指定了如何将 [PERSONNES] 表中的一行映射为 [Person] 类型的对象。[iBATIS] 将使用这些映射,根据 [ResultSet] 中的行返回一个 [Person] 对象列表。
  • 随后,[getAll] 方法的第 3 行返回了一个 [Person] 对象集合
  • [queryForList] 方法可能会抛出 Spring [DataAccessException]。我们允许该异常向上传播。

我们将简要说明 [AbstractDaoImpl] 类的其他方法,因为 [iBATIS] 的使用要点已在 [getAll] 方法的讨论中涵盖。


getOne


该方法用于根据 [id] 检索特定人员。其代码如下:

        // get a specific person
    public Personne getOne(int id) {
        // it is retrieved from the BD
        Personne personne = (Personne) getSqlMapClientTemplate()
                .queryForObject("Personne.getOne", new Integer(id));
        // did we recover anything?
        if (personne == null) {
            // throw an exception
            throw new DaoException(
                    "La personne d'id [" + id + "] n'existe pas", 2);
        }
        // we return the person
        return personne;
    }
  • 第 4 行:请求执行名为“Person.getOne”的 [select] 语句。这在 [people-firebird.xml] 文件中对应如下内容:

<!-- get a specific person -->
        <select id="Personne.getOne" resultMap="Personne.map" parameterClass="int">
            select ID, VERSION, NOM, PRENOM, DATENAISSANCE, MARIE, NBENFANTS FROM 
            PERSONNES WHERE ID=#value#</select>

SQL 查询由 #value# 参数配置(第 4 行)。当参数属于简单类型(如 Integer、Double、String 等)时,#value# 属性指定传递给 SQL 查询的参数值。在 <select> 标签的属性中,[parameterClass] 属性表明该参数的类型为 Integer(第 2 行)。 在 [getOne] 的第 5 行中,我们可以看到该参数是以 Integer 对象形式呈现的被搜索人员的 ID。这种类型转换是必需的,因为 [queryForList] 的第二个参数必须是 [Object] 类型。

[select] 查询的结果将通过 [resultMap="Personne.map"] 属性(第 2 行)转换为对象。因此,我们将获得 [Personne] 类型。

  • 第 7–11 行:如果 [select] 查询未返回任何行,则从第 4 行获取指针。这意味着未找到要查找的人员。在此情况下,抛出代码为 2 的 [DaoException](第 9–10 行)。
  • 第 13 行:如果未发生异常,则返回请求的 [Person] 对象。

deleteOne


此方法允许您删除通过 [id] 标识的人员。其代码如下:

    // deleting a person
    public void deleteOne(int id) {
        // we delete the person
        int n = getSqlMapClientTemplate().delete("Personne.deleteOne",
                new Integer(id));
        // have we succeeded
        if (n == 0) {
            throw new DaoException("Personne d'id [" + id + "] inconnue", 2);
        }
    }
  • 第 4-5 行:请求执行名为“Person.deleteOne”的 [delete] 命令。这在 [people-firebird.xml] 文件中的对应内容如下:

<!-- delete a person -->
    <delete id="Personne.deleteOne" parameterClass="int"> delete FROM PERSONNES WHERE 
        ID=#value# </delete>

该 SQL 命令由类型为 [parameterClass="int"](第 2 行)的 #value# 参数(第 3 行)配置。这将是待查询人员的 ID(deleteOne 中的第 5 行)

  • 第 4 行:[SqlMapClientTemplate].delete 方法的返回值是已删除的行数。
  • 第 7–8 行:如果 [delete] 查询未删除任何行,则表示该人员不存在。此时将抛出代码为 2 的 [DaoException](第 8 行)。

saveOne


此方法允许您添加新人员或修改现有人员。其代码如下:

        // add or modify a person
    public void saveOne(Personne personne) {
        // is the person parameter valid?
        check(personne);
        // addition or modification?
        if (personne.getId() == -1) {
            // add
            insertPersonne(personne);
        } else {
            updatePersonne(personne);
        }
    }
...
  • 第 4 行:我们使用 [check] 方法验证该人的有效性。该方法在上一版本中已存在,当时被注释掉了。如果该人不合法,它会抛出 [DaoException]。我们让此异常向上传播。
  • 第 6 行:如果执行到此处,说明未发生异常。因此该人员有效。
  • 第 6–11 行:根据人员的 ID,此处操作要么是新增(ID = -1),要么是更新(ID ≠ -1)。无论哪种情况,都会调用两个内部类方法:
    • insertPersonne:用于添加
    • updatePersonne:用于更新

insertPerson


此方法允许您添加新人员。其代码如下:

// add a person
    protected void insertPersonne(Personne personne) {
        // 1st version
        personne.setVersion(1);
        // wait 10 ms - for tests set true instead of false
        if (true)
            wait(10);
        // insert the new person in the BD table
        getSqlMapClientTemplate().insert("Personne.insertOne", personne);
    }
  • 第 4 行:将要创建的人员的版本号设置为 1
  • 第 9 行:使用名为“Person.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#, #nom#, #prenom#, #dateNaissance#, #marie#, 
    #nbEnfants#) </insert>

这是一个参数化查询,参数类型为 [Person](parameterClass="Person.class",第 1 行)。作为参数传递的 [Person] 对象的字段(insertPersonne 函数的第 9 行)用于填充将插入到 [PERSONS] 表中的行(第 5–8 行)。 我们需要解决一个问题。在插入操作期间,待插入的 [Person] 对象的 ID 值为 -1。必须将此值替换为有效的主键。为此,我们使用上述 <selectKey> 标签的第 2–4 行。它们指定:

  • (待续)
    • 用于获取主键值的 SQL 查询语句。此处展示的正是我们在 17.1 节中介绍过的那个。有两点值得注意:
      • "as 'value'" 是必需的。虽然也可以写成 "as value","value" 是 Firebird 的关键字,必须用引号括起来。
      • Firebird 表的实际名称为 [RDB$DATABASE]。但 [iBATIS] 会将 $ 字符解释为特殊字符,因此通过重复该字符进行了转义。
    • [Person] 对象中必须使用 [SELECT] 语句检索到的值进行初始化的字段,本例中为 [id] 字段。该字段由第 2 行中的 [keyProperty] 属性指定。
  • 第 6-7 行:出于测试目的,我们在执行插入操作前将等待 10 毫秒,以检查尝试同时进行插入操作的线程之间是否存在冲突。

updatePerson


此方法允许您修改 [PERSONNES] 表中已存在的人员记录。其代码如下:

// edit a person
    protected void updatePersonne(Personne personne) {
        // wait 10 ms - for tests set true instead of false
        if (true)
            wait(10);
        // change
        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 行:执行名为“Person.updateOne”的 SQL [update] 查询。具体内容如下:

    <!-- update a person -->
    <update id="Personne.updateOne" parameterClass="Personne.classe"> update 
        PERSONNES set VERSION=#version#+1, NOM=#nom#, PRENOM=#prenom#, DATENAISSANCE=#dateNaissance#, 
        MARIE=#marie#, NBENFANTS=#nbEnfants# WHERE ID=#id# and 
VERSION=#version#</update>
  • (续)
    • 第 2 行:该查询采用参数化形式,并接受 [Person] 类型作为参数(parameterClass="Person.class")。此参数即为待修改的对象(第 8 行 – updatePerson)。
    • 我们仅希望修改 [PERSONS] 表中与参数具有相同 ID 和版本的记录。因此设置了 [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");
        // current list
        Collection personnes = dao.getAll();
        // console display
        Iterator iter = personnes.iterator();
        while (iter.hasNext()) {
            System.out.println(iter.next());
        }
    }
}

第 13–14 行使用的 [dao] 层的配置文件 [spring-config-test-dao-firebird.xml] 如下:


<?xml version="1.0" encoding="ISO_8859-1"?>
<!DOCTYPE beans SYSTEM "http://www.springframework.org/dtd/spring-beans.dtd">
<beans>
    <!-- data source DBCP -->
    <bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource" 
        destroy-method="close">
        <property name="driverClassName">
            <value>org.firebirdsql.jdbc.FBDriver</value>
        </property>
        <!-- warning: do not leave spaces between the two <value> tags -->
        <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>
    <!-- the [dao] layer access classes -->
    <bean id="dao" class="istia.st.mvc.personnes.dao.DaoImplCommon">
        <property name="sqlMapClient">
            <ref local="sqlMapClient"/>
        </property>
    </bean>
</beans>

此文件即第 17.3.2 节中讨论的文件。

为了进行测试,已启动 Firebird 数据库管理系统。[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 {

    // layer [dao]
    private IDao dao;

    public IDao getDao() {
        return dao;
    }

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

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

    // list of persons
    private void doListe(Collection personnes) {
...
    }

    // test1
    public void test1() throws ParseException {
...
    }

    // modification-deletion of a non-existent element
    public void test2() throws ParseException {
..
    }

    // person version management
    public void test3() throws ParseException, InterruptedException {
...
    }

    // optimistic locking - multi-threaded access
    public void test4() throws Exception {
...
    }

    // validity tests for saveOne
    public void test5() throws ParseException {
....
    }

    // multi-threaded insertions
    public void test6() throws ParseException, InterruptedException{
...
}
  • 测试 [test1] 至 [test5] 与第 1 版相同,但 [test4] 略有改动。测试 [test6] 是新增的。我们将仅对这两个测试进行说明。

[test4]


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

// edit a person
    protected void updatePersonne(Personne personne) {
        // wait 10 ms - for tests set true instead of false
        if (true)
            wait(10);
        // change
        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 毫秒。这会迫使执行 [updatePerson] 的线程失去 CPU 控制权,从而可能增加我们观察到并发线程之间发生访问冲突的几率。

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

    public void test4() throws Exception {
        // add a person
        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();
        // creation of N child update threads
        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();
        }
        // we wait for the end of threads
        for (int i = 0; i < taches.length; i++) {
            taches[i].join();
        }
        // we pick up the person
        p1 = dao.getOne(id1);
        // she must have N children
        assertEquals(N, p1.getNbEnfants());
        // delete person p1
        dao.deleteOne(p1.getId());
        // check
        boolean erreur = false;
        int codeErreur = 0;
        try {
            p1 = dao.getOne(p1.getId());
        } catch (DaoException ex) {
            erreur = true;
            codeErreur = ex.getCode();
        }
        // we must have a code 2 error
        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 {
    // thread name
    private String name;

    // reference on the [dao] layer
    private IDao dao;

    // the id of the person we're going to work on
    private int idPersonne;

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

    // thread core
    public void run() {
        // follow-up
        suivi("lancé");
        // we loop until we have succeeded in incrementing by 1
        // person's number of children idPersonne
        boolean fini = false;
        int nbEnfants = 0;
        while (!fini) {
            // a copy of the idPersonne person is retrieved
            Personne personne = dao.getOne(idPersonne);
            nbEnfants = personne.getNbEnfants();
            // follow-up
            suivi("" + nbEnfants + " -> " + (nbEnfants + 1)
                    + " pour la version " + personne.getVersion());
            // 10 ms wait to abandon processor
            try {
                // follow-up
                suivi("début attente");
                // we pause to let the processor
                Thread.sleep(10);
                // follow-up
                suivi("fin attente");
            } catch (Exception ex) {
                throw new RuntimeException(ex.toString());
            }
            // waiting complete - try to validate the copy
            // in the meantime, other threads may have modified the original
            int codeErreur = 0;
            try {
                // increments by 1 the number of children in this copy
                personne.setNbEnfants(nbEnfants + 1);
                // we try to modify the original
                dao.saveOne(personne);
                // we passed - the original has been modified
                fini = true;
            } catch (DaoException ex) {
                // we retrieve the error code
                codeErreur = ex.getCode();
                // if a ID or error code 2 version error occurs, retry the update
                switch (codeErreur) {
                case 2:
                    suivi("version corrompue ou personne inexistante");
                    break;
                default:
                    // unhandled exception - left to rise
                    throw ex;
                }
            }
        }
        // follow-up
        suivi("a terminé et passé le nombre d'enfants à " + (nbEnfants + 1));
    }

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

人员更新可能会失败,原因可能是我们要修改的人员不存在,或者该人员已被另一个线程更新过。这两种情况在第 67–69 行中进行了处理。无论哪种情况,[updatePersonne] 方法都会抛出代码为 2 的 [DaoException]。随后,该线程将被迫从头开始重启更新过程(while 循环,第 34 行)。


[test6]


[test6] 旨在测试 [insertPersonne - DaoImplCommon] 方法。该方法的代码如下:

// add a person
    protected void insertPersonne(Personne personne) {
        // 1st version
        personne.setVersion(1);
        // wait 10 ms - for tests set true instead of false
        if (true)
            wait(10);
        // insert the new person in the BD table
        getSqlMapClientTemplate().insert("Personne.insertOne", personne);
    }
  • 第 6-7 行:我们等待 10 毫秒,以迫使执行 [insertPerson] 的线程失去 CPU 控制权,从而增加观察到因多个线程同时执行插入操作而引发冲突的可能性。

[test6] 的代码如下:

    // multi-threaded insertions
    public void test6() throws ParseException, InterruptedException{
        // creation of a person
        Personne p = new Personne(-1, "X", "X", new SimpleDateFormat(
                "dd/MM/yyyy").parse("01/02/2006"), true, 0);
        // duplicated N times in an array
        final int N = 100;
        Personne[] personnes=new Personne[N];
        for(int i=0;i<personnes.length;i++){
            personnes[i]=new Personne(p);
        }
        // creation of N insertion threads - each thread inserts 1 person
        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();
        }
        // we wait for the end of threads
        for (int i = 0; i < taches.length; i++) {
            // thread n° i
            taches[i].join();
            // supression personne
            dao.deleteOne(personnes[i].getId());
        }
}

我们创建 100 个线程,它们将同时插入 100 个不同的人。这 100 个线程都会为需要插入的人获取一个主键,然后暂停 10 毫秒(第 10 行 – insertPerson),之后才能执行插入操作。我们希望验证一切是否顺利,特别是它们确实获得了不同的主键值。

  • 第 7–11 行:创建了一个包含 100 个人的数组。这些人都是第 4–5 行创建的 person 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 {
    // thread name
    private String name;

    // reference on the [dao] layer
    private IDao dao;

    // the id of the person we're going to work on
    private Personne personne;

    // manufacturer
    public ThreadDaoInsertPersonne(String name, IDao dao, Personne personne) {
        this.name = name;
        this.dao = dao;
        this.personne = personne;
    }

    // thread core
    public void run() {
        // follow-up
        suivi("lancé");
        // insertion
        dao.saveOne(personne);
        // follow-up
        suivi("a terminé");
    }

    // follow-up
    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];   
--- The error occurred in personnes-firebird.xml.  
--- The error occurred while applying a parameter map.  
--- Check the Personne.updateOne-InlineParameterMap.  
--- Check the statement (update failed).  
--- Cause: org.firebirdsql.jdbc.FBSQLException: GDS Exception. 335544336. deadlock
update conflicts with concurrent update; nested exception is com.ibatis.common.jdbc.exception.NestedSQLException:   
--- The error occurred in personnes-firebird.xml.  
--- The error occurred while applying a parameter map.  
  • 第 1 行 – 发生了一个 Spring 异常 [org.springframework.jdbc.UncategorizedSQLException]。这是一个未捕获的异常,用于包装 Firebird JDBC 驱动程序抛出的异常,具体描述见第 6 行。
  • 第 6 行 – Firebird JDBC 驱动程序抛出了类型为 [org.firebirdsql.jdbc.FBSQLException] 的异常,错误代码为 335544336。
  • 第 7 行:表明有两个线程试图同时更新 [PERSONNES] 表中的同一行,导致并发冲突。

这并非致命错误。捕获此异常的线程可以重试更新操作。为此,请修改 [ThreadDaoMajEnfants] 中的代码:

            try {
                // incrémente de 1 le nbre d'enfants de cette copie
                personne.setNbEnfants(nbEnfants + 1);
                // on essaie de modifier l'original
                dao.saveOne(personne);
                // on est passé - l'original a été modifié
                fini = true;
            } catch (DaoException ex) {
                // on récupère le code erreur
                codeErreur = ex.getCode();
                // si une erreur d'ID ou de version de code ereur 2, on réessaie la mise à jour
                switch (codeErreur) {
                case 2:
                    suivi("version corrompue ou personne inexistante");
                    break;
                default:
                    // exception non gérée - on laisse remonter
                    throw ex;
                }
  • 第 8 行:我们处理类型为 [DaoException] 的异常。根据前文所述,我们应处理测试中出现的异常,即类型为 [org.springframework.jdbc.UncategorizedSQLException] 的异常。然而,我们无法直接处理该类型,因为它是一个通用的 Spring 类型,旨在封装 Spring 无法识别的异常。 Spring 能够识别由 Oracle、MySQL、Postgres、DB2、SQL Server 等多种数据库管理系统(DBMS)的 JDBC 驱动程序抛出的异常,但不包括 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] 异常,那么该线程将仅适用于 Firebird DBMS。使用该线程的 [test4] 测试也将受到同样的限制。 我们需要避免这种情况。事实上,我们希望 JUnit 测试无论使用何种 DBMS 都能保持有效。为实现这一目标,我们决定:每当检测到“更新冲突”异常时,[dao] 层将抛出代码为 4 的 [DaoException],且不依赖底层 DBMS。因此,[ThreadDaoMajEnfants] 线程可重写如下:

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

public class ThreadDaoMajEnfants extends Thread {
...

    // thread core
    public void run() {
...
        while (!fini) {
            // a copy of the idPersonne person is retrieved
            Personne personne = dao.getOne(idPersonne);
            nbEnfants = personne.getNbEnfants();
...
            // waiting complete - try to validate the copy
            // in the meantime, other threads may have modified the original
            int codeErreur = 0;
            try {
                // increments by 1 the number of children in this copy
                personne.setNbEnfants(nbEnfants + 1);
                // we try to modify the original
                dao.saveOne(personne);
                // we passed - the original has been modified
                fini = true;
            } catch (DaoException ex) {
                // we retrieve the error code
                codeErreur = ex.getCode();
                // if a ID or version 2 error or a deadlock 4 occurs, we
                // try the update again
                switch (codeErreur) {
                case 2:
                    suivi("version corrompue ou personne inexistante");
                    break;
                case 4:
                    suivi("conflit de mise à jour");
                    break;
                default:
                    // unhandled exception - left to rise
                    throw ex;
                }
            }
        }
        // follow-up
        suivi("a terminé et passé le nombre d'enfants à " + (nbEnfants + 1));
    }
...
}
  • 第 34-36 行:捕获了代码为 4 的 [DaoException] 异常。[ThreadDaoMajEnfants] 线程将被迫从头开始重启更新过程(第 10 行)

因此,我们的 [dao] 层必须能够识别“更新冲突”异常。该异常由 JDBC 驱动程序抛出,且仅适用于该驱动程序。必须在 [DaoImplCommon] 类的 [updatePerson] 方法中处理此异常:

// edit a person
    protected void updatePersonne(Personne personne) {
        // wait 10 ms - for tests set true instead of false
        if (true)
            wait(10);
        // change
        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 代码块中。对于 Firebird 数据库管理系统,我们需要验证导致更新失败的异常类型为 [org.firebirdsql.gds.GDSException],且错误代码为 335544336。 如果将此类测试放入 [DaoImplCommon] 中,将导致该类与 Firebird DBMS 绑定,这显然是不希望看到的。若要保持 [DaoImplCommon] 类的通用性,我们需要继承该类,并在一个 Firebird 专用的类中处理异常。这就是我们当前正在做的事情。

17.4.2. [DaoImplFirebird] 类

其代码如下:

package istia.st.mvc.personnes.dao;

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

public class DaoImplFirebird extends DaoImplCommon {

    // edit a person
    protected void updatePersonne(Personne personne) {
        // wait 10 ms - for tests set true instead of false
        if (true)
            wait(10);
        // change
        try {
            // we modify the person who has the correct version
            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;
            }
        }
    }

    // waiting
    private void wait(int N) {
        // we wait for N ms
        try {
            Thread.sleep(N);
        } catch (InterruptedException e) {
            // the exception trace is displayed
            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>
    <!-- data source DBCP -->
    <bean id="dataSource" class="org.apache.commons.dbcp.BasicDataSource" 
        destroy-method="close">
        <property name="driverClassName">
            <value>org.firebirdsql.jdbc.FBDriver</value>
        </property>
        <!-- warning: do not leave spaces between the two <value> tags -->
        <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>
    <!-- the [dao] layer access classes -->
    <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] 是 [服务] 层提供的接口
  • [ServiceImpl] 是该接口的实现

[IService] 接口如下:

package istia.st.mvc.personnes.service;

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

import java.util.Collection;

public interface IService {
    // list of all persons
    Collection getAll();

    // find a specific person
    Personne getOne(int id);

    // add/modify a person
    void saveOne(Personne personne);

    // delete a person
    void deleteOne(int id);

    // save multiple people
    void saveMany(Personne[] personnes);

    // delete several people
    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 {

    // the [dao] layer
    private IDao dao;

    public IDao getDao() {
        return dao;
    }

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

    // list of persons
    public Collection getAll() {
        return dao.getAll();
    }

    // get a specific person
    public Personne getOne(int id) {
        return dao.getOne(id);
    }

    // add or modify a person
    public void saveOne(Personne personne) {
        dao.saveOne(personne);
    }

    // deleting a person
    public void deleteOne(int id) {
        dao.deleteOne(id);
    }

    // save a collection of people
    public void saveMany(Personne[] personnes) {
        // we loop over the people table
        for (int i = 0; i < personnes.length; i++) {
            dao.saveOne(personnes[i]);
        }
    }

    // delete a collection of people
    public void deleteMany(int[] ids) {
        // ids: the ids of the people to be deleted
        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. [ 服务]层的配置

在上文第 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>
    <!-- data source 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">
            <!-- warning: do not leave spaces between the two <value> tags -->
            <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>
    <!-- the [dao] layer access class -->
    <bean id="dao" class="istia.st.mvc.personnes.dao.DaoImplFirebird">
        <property name="sqlMapClient">
            <ref local="sqlMapClient"/>
        </property>
    </bean>
    <!-- transaction manager -->
    <bean id="transactionManager" 
        class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
        <property name="dataSource">
            <ref local="dataSource"/>
        </property>
    </bean>
    <!-- access classes to the [service] layer -->
    <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] 已被编译为与 [C2] 所实现的接口 [I2] 协同工作。在运行时,AOP 将 [拦截器] 类置于 [C1] 和 [C2] 之间。要实现这一点,[拦截器] 类当然必须向 [C1] 展示与 [C2] 相同的 [I2] 接口。

这有什么用?Spring 文档提供了一些示例。例如,您可能希望记录对 [C2] 中特定方法 M 的调用,以便对该方法进行审计。在 [拦截器] 中,您需要编写一个方法 [M] 来执行这些日志记录。来自 [C1] 对 [C2].M 的调用将按以下方式进行(参见上图):

  1. [C1] 调用 [C2] 的方法 M。实际上,被调用的将是 [interceptor] 的方法 M。只要 [C1] 调用的是接口 [I2] 而非 [I2] 的具体实现,这种情况就可能发生。唯一的要求是 [interceptor] 必须实现 [I2]。
  2. [拦截器] 的方法 M 记录相关信息,并调用 [C1] 最初目标 [C2] 的方法 M。
  3. [C2] 的 M 方法执行并将其结果返回给 [interceptor] 的 M 方法,后者可选择性地对步骤 2 中执行的内容进行补充。
  4. [interceptor] 的方法 M 将结果返回给 [C1] 的调用方法

我们可以看到,[拦截器] 的 M 方法可以在调用 [C2] 的 M 方法之前和之后执行某些操作。因此,从 [C1] 的角度来看,它丰富了 [C2] 的 M 方法。因此,我们可以将 AOP 技术视为一种丰富类所呈现的接口的方式。

这一概念如何应用于我们的 [service] 层?如果我们直接使用 [ServiceImpl] 实例来实现 [service] 层,我们的 Web 应用程序将具有以下架构:

如果我们使用 [TransactionProxyFactoryBean] 实例来实现 [service] 层,我们将得到以下架构:

可以说,[服务]层是通过两个对象实例化的:

  • 上文中我们称之为[事务代理]的对象,它实际上是[TransactionProxyFactoryBean]的[getObject]方法返回的对象。该对象充当[服务]层与[Web]层之间的接口。按设计,它实现了[IService]接口。
  • 一个 [ServiceImpl] 实例,它同样实现了 [IService] 接口。只有它知道如何与 [dao] 层进行交互,因此它是必不可少的。

假设[web]层调用了[IService]接口的[saveMany]方法。我们知道,从功能上讲,该方法执行的插入/更新操作必须在事务内完成。要么全部成功,要么全部不执行。 我们之前介绍了 [ServiceImpl] 类的 [saveMany] 方法,并指出它缺乏事务概念。而 [事务代理] 的 [saveMany] 方法将通过引入事务概念来增强 [ServiceImpl] 类的 [saveMany] 方法。让我们参照上图:

  1. [Web] 层调用 [IService] 接口的 [saveMany] 方法。
  2. [transactional proxy] 的 [saveMany] 方法被执行。它会启动一个事务。为此,它必须拥有足够的信息,特别是用于建立与 DBMS 连接的 [DataSource] 对象。随后,它调用 [ServiceImpl] 的 [saveMany] 方法。
  3. 该方法开始执行。它会反复调用 [dao] 层来执行插入或更新操作。此时执行的 SQL 语句都在步骤 2 中启动的事务范围内执行。
  4. 假设其中某项操作失败,[dao]层将把异常向上传播至[service]层,具体而言是[ServiceImpl]实例的[saveMany]方法。
  5. 该方法不执行任何操作,允许异常向上传播至 [transactional proxy] 的 [saveMany] 方法。
  6. 收到异常后,拥有该事务的 [transactional proxy] 的 [saveMany] 方法将执行 [rollback] 以撤销所有更新,然后允许异常向上传播至 [web] 层,由该层负责处理。

在步骤 4 中,我们假设插入或更新操作中有一项失败。如果并非如此,则在 [5] 中不会传播任何异常。情况 [6] 也是如此。在此情况下,[transactional proxy] 的 [saveMany] 方法会提交事务以验证所有更新。

现在,我们对 [TransactionProxyFactoryBean] 实现的架构有了更清晰的认识。让我们重新审视其配置:


    <!-- transaction manager -->
    <bean id="transactionManager" 
        class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
        <property name="dataSource">
            <ref local="dataSource"/>
        </property>
    </bean>
    <!-- access classes to the [service] layer -->
    <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>

让我们结合已建立的架构来分析这一配置:

  • [事务代理] 将负责管理事务。Spring 提供了多种事务管理策略。[事务代理] 需要引用所选的事务管理器。
  • 第 11–13 行:为 [TransactionProxyFactoryBean] bean 定义 [transactionManager] 属性,并引用一个事务管理器。该事务管理器在第 2–7 行中定义。
  • 第 2–7 行:事务管理器的类型为 [DataSourceTransactionManager]:

Image

[DataSourceTransactionManager] 是一种适用于通过 [DataSource] 对象访问数据库管理系统 (DBMS) 的事务管理器。它只能管理单个 DBMS 上的事务,无法管理跨多个 DBMS 分布的事务。在此场景中,我们仅有一个 DBMS,因此该事务管理器是合适的。 当 [transactional proxy] 启动事务时,它是在与该线程关联的连接上进行的。该连接将用于通向数据库的所有层:[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

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

JUnit 测试 [TestServiceFirebird] 如下所示:

package istia.st.mvc.personnes.tests;

...

public class TestServiceFirebird extends TestCase {

    // service] layer
    private IService service;

    public IService getService() {
        return service;
    }

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

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

    // list of persons
    private void doListe(Collection personnes) {
...
    }

    // test1
    public void test1() throws ParseException {
...
    }

    // modification-deletion of a non-existent element
    public void test2() throws ParseException {
...
    }

    // person version management
    public void test3() throws ParseException, InterruptedException {
...
    }

    // optimistic locking - multi-threaded access
    public void test4() throws Exception {
...
    }

    // validity tests for saveOne
    public void test5() throws ParseException {
...
    }

        // multi-threaded insertions
    public void test6() throws ParseException, InterruptedException{
...
    }

    // tests of the deleteMany method
    public void test7() throws ParseException {
        // current list
        Collection personnes = service.getAll();
        int nbPersonnes1 = personnes.size();
        // display
        doListe(personnes);
        // creation of three people
        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);
        // add 3 people - person p3 with id -2 will cause
        // an exception
        boolean erreur = false;
        try {
            service.saveMany(new Personne[] { p1, p2, p3 });
        } catch (Exception ex) {
            erreur = true;
            System.out.println(ex.toString());
        }
        // check
        assertTrue(erreur);
        // new list - the number of elements must not have changed
        // because of automatic transaction rollback
        int nbPersonnes2 = service.getAll().size();
        assertEquals(nbPersonnes1, nbPersonnes2);
        // addition of two able-bodied people
        // reset their id to -1
        p1.setId(-1);
        p2.setId(-1);
        service.saveMany(new Personne[] { p1, p2 });
        // we retrieve their id
        int id1 = p1.getId();
        int id2 = p2.getId();
        // checks
        p1 = service.getOne(id1);
        assertEquals(p1.getNom(), "X");
        p2 = service.getOne(id2);
        assertEquals(p2.getNom(), "Y");
        // new list - must have 2 + elements
        int nbPersonnes3 = service.getAll().size();
        assertEquals(nbPersonnes1 + 2, nbPersonnes3);
        // deletion of p1 and p2 and a non-existent person
        // an exception must occur
        erreur = false;
        try {
            service.deleteMany(new int[] { id1, id2, -1 });
        } catch (Exception ex) {
            erreur = true;
            System.out.println(ex.toString());
        }
        // check
        assertTrue(erreur);
        // new list
        personnes = service.getAll();
        int nbPersonnes4 = personnes.size();
        // no person had to be deleted (rollback
        // automatic transaction)
        assertEquals(nbPersonnes4, nbPersonnes3);
        // we remove the two able-bodied people
        service.deleteMany(new int[] { id1, id2 });
        // checks
        // person p1
        erreur = false;
        int codeErreur = 0;
        try {
            p1 = service.getOne(id1);
        } catch (DaoException ex) {
            erreur = true;
            codeErreur = ex.getCode();
        }
        // we must have a code 2 error
        assertTrue(erreur);
        assertEquals(2, codeErreur);
        // person p2
        erreur = false;
        codeErreur = 0;
        try {
            p1 = service.getOne(id2);
        } catch (DaoException ex) {
            erreur = true;
            codeErreur = ex.getCode();
        }
        // we must have a code 2 error
        assertTrue(erreur);
        assertEquals(2, codeErreur);
        // new list
        personnes = service.getAll();
        int nbPersonnes5 = personnes.size();
        // verification - we must be back at the starting point
        assertEquals(nbPersonnes5, nbPersonnes1);
        // display
        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 行)被保存。前两个人 p1 和 p2 的 ID 均为 -1,将被添加到 [PERSONNES] 表中。 人员 p3 的 ID 为 -2。因此这不是插入操作,而是更新操作。由于 [PERSONS] 表中不存在 ID 为 -2 的人员,该更新将失败。因此 [dao] 层将抛出一个异常,该异常将向上传播至 [service] 层。第 83 行会检查该异常是否存在。
  • 由于之前的异常,[service]层应回滚[saveMany]方法执行期间发出的所有SQL语句,因为该方法是在事务中运行的。第86–87行:我们验证列表中的人数没有变化,这意味着p1和p2的插入操作并未发生。
  • 第 88–103 行:我们仅添加 p1 和 p2,并验证列表中现在增加了两个人。
  • 第 106–114 行:我们删除一组人员,该组包含刚刚添加的 p1 和 p2,以及一个不存在的用户(id = -1)。为此使用了第 108 行的 [deleteMany] 方法。由于 [PERSONNES] 表中不存在 id 等于 –1 的用户,该方法将失败。 因此,[dao] 层将抛出一个异常,该异常将向上传播至 [service] 层。第 114 行检查了该异常是否存在。
  • 由于之前抛出了异常,[service] 层应回滚 [deleteMany] 方法执行期间发出的所有 SQL 语句,因为该方法是在事务中运行的。第 116–117 行:我们验证列表中的人数没有变化,因此 p1 和 p2 并未被删除。
  • 第 122 行:我们删除一个仅包含 p1 和 p2 的组。此操作应成功。该方法的其余部分将验证结果确实如此。

运行测试得到以下结果:

Image

全部七个测试均成功通过。我们将认为我们的 [service] 层已可正常运行。

17.7. [w eb] 层

让我们回顾一下即将构建的 Web 应用程序的总体架构:

我们刚刚构建了用于操作 Firebird 数据库的 [dao] 和 [service] 层。我们编写了该应用程序的 1.0 版本,其中 [dao] 和 [service] 层操作的是内存中的人员列表。 当时编写的 [web] 层仍然有效。事实上,它与实现 [IService] 接口的 [service] 层进行了交互。由于新的 [service] 层实现了相同的接口,因此 [web] 层无需修改

在上一篇文章中,我们使用 Eclipse 项目 [mvc-personnes-02B] 对该应用程序的 1.0 版进行了测试,其中 [Web、Service、DAO、Entities] 各层被打包为 .jar 文件:

[src] 文件夹为空。各层类的文件位于 [people-*.jar] 归档文件中:

要测试版本 2,我们在 Eclipse 中将 [mvc-personnes-02B] 文件夹复制为 [mvc-personnes-03B](复制/粘贴):

Image

在 [mvc-personnes-03] 项目中,我们通过 [文件 / 导出 / Jar 文件] 分别将 [DAO] 和 [service] 层导出到项目 [dist] 文件夹中的 [personnes-dao.jar] 和 [personnes-service.jar] 存档中:

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] 文件夹中。这些 JAR 文件是新 [dao] 和 [service] 层所必需的。

完成此操作后,我们将这些新的 JAR 文件添加到项目的类路径中:[右键单击项目 -> 属性 -> Java 构建路径 -> 添加 JAR 文件]。

[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>
    <!-- data source 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">
            <!-- warning: do not leave spaces between the two <value> tags -->
            <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>
    <!-- the [dao] layer access class -->
    <bean id="dao" class="istia.st.mvc.personnes.dao.DaoImplFirebird">
        <property name="sqlMapClient">
            <ref local="sqlMapClient"/>
        </property>
    </bean>
    <!-- transaction manager -->
    <bean id="transactionManager" 
        class="org.springframework.jdbc.datasource.DataSourceTransactionManager">
        <property name="dataSource">
            <ref local="dataSource"/>
        </property>
    </bean>
    <!-- access classes to the [service] layer -->
    <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] 层的数据库

我们将 [mvc-personnes-03B] Web 项目部署到 Tomcat 中:

我们已准备好进行 的测试。Firebird 数据库管理系统正在运行。[PERSONNES] 表的内容如下:

Image

随后启动 Tomcat。使用浏览器访问 URL [http://localhost:8080/mvc-personnes-03B]:

Image

我们通过 [添加]链接添加一名新人员:

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

Image

欢迎读者进行其他测试 [编辑、删除]。

现在让我们进行版本 1 中进行的版本冲突测试。[Firefox] 将作为用户 U1 的浏览器。用户 U1 请求 URL [http://localhost:8080/mvc-personnes-03B]:

Image

[IE] 将作为用户 U2 的浏览器。用户 U2 请求相同的 URL:

Image

用户 U1 输入该人的详细信息 [Perrichon]:

Image

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

Image

用户 U1 进行修改并提交:

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

用户 U2 通过表单上的 [取消] 链接返回用户列表:

Image

他们发现用户 [Perrichon] 已被 U1 修改过(姓名首字母大写)。

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

Image

根据U1的修改,第899号人员的姓名确实已大写。

17.8. 结论

让我们回顾一下我们的目标。我们有一个采用以下三层架构的Web应用程序:

其中 [dao] 和 [service] 层使用内存中的数据列表,该列表在 Web 服务器关闭时会丢失。这就是版本 1。在版本 2 中,[service] 和 [dao] 层被重写,使得人员列表存储在数据库表中。现在它具有持久性。 现在,我们计划研究更换数据库管理系统(DBMS)对应用程序的影响。为此,我们将构建三个新版本的Web应用程序:

  • 版本 3:数据库管理系统为 Postgres
  • 版本 4:数据库管理系统为 MySQL
  • 版本 5:数据库管理系统为 SQL Server Express 2005

以下位置进行了更改:

  • [DaoImplFirebird] 类实现了与 Firebird 数据库管理系统相关的 [dao] 层功能。如果该需求仍然存在,它将分别被 [DaoImplPostgres]、[DaoImplMySQL] 和 [DaoImplSqlExpress] 类所取代。
  • 针对 Firebird 数据库管理系统(DBMS)的 iBATIS 映射文件 [personnes-firebird.xml] 将分别被映射文件 [personnes-postgres.xml]、[personnes-mysql.xml] 和 [personnes-sqlexpress.xml] 所取代。
  • [dao] 层中 [DataSource] 对象的配置是针对特定 DBMS 的。因此,它会随着每个版本而变化。
  • DBMS 的 JDBC 驱动程序也会随着每个版本而变化

除上述内容外,其余部分保持不变。在接下来的章节中,我们将重点介绍这些新版本,仅聚焦于每个版本所引入的新功能。