Skip to content

14. 三层架构中的 MVC Web 应用程序 – 示例 1

14.1. 简介

到目前为止,我们仅限于介绍旨在教学目的的示例。因此,这些示例不得不保持简单。现在,我们将介绍一个基础应用程序,尽管如此,它比迄今为止介绍的任何示例都功能更丰富。该应用程序的独特之处在于它采用了三层架构的三个层:

Image

若读者已遗忘相关知识,建议重温第 4 节中关于三层架构下 MVC Web 应用程序的原则。

我们将要编写的 Web 应用程序将允许我们通过以下四种操作来管理一组人员:

  • 列出组内人员
  • 向组中添加人员
  • 修改组内成员
  • 从组中移除成员

这些是数据库表的四项基本操作。我们将编写该应用程序的两个版本:

  • 在版本 1 中,[DAO] 层将不使用数据库。组成员将存储在由 [DAO] 层内部管理的简单 [ArrayList] 对象中。这将使读者能够在不受数据库限制的情况下测试该应用程序。
  • 在版本 2 中,我们将把组成员存入数据库表。我们将演示如何在不影响版本 1 的 Web 层的情况下实现这一点,该 Web 层将保持不变。

以下截图 展示了应用程序与用户交互的页面。

Image

Image

Image

 

14.2. Eclipse 项目

该应用程序项目的名称为 [people-01]:

Image

该项目涵盖了应用程序三层架构的三个层级:

  • [DAO] 层位于 [istia.st.mvc.personnes.dao] 包中
  • [业务] 或 [服务] 层位于 [istia.st.mvc.personnes.service] 包中
  • [web] 或 [ui] 层位于 [istia.st.mvc.personnes.web] 包中
  • 包 [istia.st.mvc.personnes.entities] 包含不同层之间共享的对象
  • 包 [istia.st.mvc.people.tests] 包含针对 [DAO] 和 [service] 层的 JUnit 测试

我们将依次探讨 [dao]、[service] 和 [web] 这三个层。由于详细阐述会耗时过长,且阅读起来可能过于冗长,因此除涉及新内容外,我们有时会快速带过相关说明。

14.3. 人员的表示

该应用程序管理一组人员。第 14.1 节中的屏幕截图展示了人员的某些特征。从形式上讲,这些特征由 [Person] 类表示:

Image

[Person] 类定义如下:

package istia.st.springmvc.personnes.entites;

import java.text.SimpleDateFormat;
import java.util.Date;

public class Personne {

    // unique personal identifier
    private int id;
    // the current version
    private long version;
    // the name
    private String nom;
    // first name
    private String prenom;
    // date of birth
    private Date dateNaissance;
    // marital status
    private boolean marie = false;
    // number of children
    private int nbEnfants;

    // getters - setters
...

    // default builder
    public Personne() {

    }

    // constructor with initialization of person fields
    public Personne(int id, String prenom, String nom, Date dateNaissance,
            boolean marie, int nbEnfants) {
        setId(id);
        setNom(nom);
        setPrenom(prenom);
        setDateNaissance(dateNaissance);
        setMarie(marie);
        setNbEnfants(nbEnfants);
    }

    // builder of a person by copying another person
    public Personne(Personne p) {
        setId(p.getId());
        setVersion(p.getVersion());
        setNom(p.getNom());
        setPrenom(p.getPrenom());
        setDateNaissance(p.getDateNaissance());
        setMarie(p.getMarie());
        setNbEnfants(p.getNbEnfants());
    }


    // toString
    public String toString() {
        return "[" + id + "," + version + "," + prenom + "," + nom + ","
                + new SimpleDateFormat("dd/MM/yyyy").format(dateNaissance)
                + "," + marie + "," + nbEnfants + "]";
    }
}
  • 通过以下信息识别一个人:
    • id:个人的唯一标识符
    • last_name:该人的姓
    • firstName:该人的名字
    • dateOfBirth:出生日期
    • maritalStatus:婚姻状况
    • nbChildren:子女数量
  • [version] 属性是专为该应用程序而人工添加的属性。从面向对象的角度来看,将其添加到 [Person] 的派生类中可能更为理想。但在考虑 Web 应用程序的使用场景时,该属性的必要性便显而易见。其中一个使用场景如下:

在时间点 T1,用户 U1 开始编辑某人 P。此时,子女数为 0。U1 将该数值改为 1,但在提交更改前,用户 U2 开始编辑同一人 P。由于 U1 尚未提交更改,U2 看到的子女数仍是 0。U2 将人 P 的名字改为大写。 随后,U1和U2按此顺序保存了各自的修改。U2的修改将具有优先级:姓名将显示为大写,且子女数量仍保持为零,尽管U1认为自己已将其修改为1。

“人员版本”的概念有助于我们解决这个问题。让我们重新审视这个用例:

在时间点 T1,用户 U1 开始编辑人员 P。此时,子女数为 0,版本号为 V1。他们将子女数改为 1,但在提交编辑之前,用户 U2 进入了同一人员 P 的编辑模式。由于 U1 尚未提交编辑,U2 看到的子女数为 0,版本号为 V1。 U2 将人物 P 的名字改为大写。随后 U1 和 U2 按此顺序提交了各自的编辑。在提交更改之前,我们会验证修改人物 P 的用户所持有的版本是否与当前已保存的人物 P 版本一致。对于用户 U1 而言,情况确实如此。因此其更改被接受,随后我们将被修改人物的版本从 V1 更新为 V2,以表明该人物已发生变更。 在验证 U2 的修改时,我们会发现其持有的 P 用户版本为 V1,而当前版本为 V2。此时我们可以告知用户 U2:已有其他用户先于其进行操作,必须基于 P 用户的新版本开始修改。用户 U2 将照此操作,获取现已拥有子女的 P 用户版本 V2,将姓名首字母大写,并提交验证。若系统中 P 用户的版本仍为 V2,则其修改将被接受。 最终,U1和U2所做的修改都将被采纳,而在没有版本控制的用例中,其中一项修改会丢失。

  • 第 32–40 行:一个能够初始化 person 字段的构造函数。[version] 字段被省略。
  • 第 43–51 行:一个构造函数,用于创建作为参数传递给它的 person 的副本。现在我们有两个内容相同但由两个不同指针引用的对象。
  • 第 55 行:重新定义 [toString] 方法,使其返回一个字符串,该字符串表示该人的状态

14.4. [DAO] 层

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

Image

  • [IDao] 是 [DAO] 层提供的接口
  • [DaoImpl] 是该接口的实现,其中人员组被封装在一个 [ArrayList] 对象中
  • [DaoException] 是 [dao] 层抛出的未检查异常类型

[ IDao] 接口如下:

package istia.st.springmvc.personnes.dao;

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

import java.util.Collection;

public interface IDao {
    // list of all persons
    Collection getAll();
    // get a specific person
    Personne getOne(int id);
    // add/modify a person
    void saveOne(Personne personne);
    // delete a person
    void deleteOne(int id);
}
  • 该接口提供了四个方法,用于对人员组执行以下四种操作:
    • getAll:用于检索一组人员
    • getOne:获取具有特定 ID 的个人
    • saveOne:添加人员(id=-1)或修改现有人员(id ≠ -1)
    • deleteOne:删除具有特定 ID 的用户

[DAO] 层可能会抛出异常。这些异常的类型为 [ DaoException]:

package istia.st.springmvc.personnes.dao;

public class DaoException extends RuntimeException {

    // error code
    private int code;

    public int getCode() {
        return code;
    }

// manufacturer
    public DaoException(String message,int code) {
        super(message);
        this.code=code;
    }
}
  • 第 3 行:[DaoException] 类继承自 [RuntimeException],是一种未处理的异常类型:编译器并不要求我们在调用可能抛出此类异常的方法时,使用 try/catch 代码块来处理此类异常:
    • 在调用可能抛出该异常的方法时,使用 try/catch 代码块来处理此类异常
    • 在可能抛出该异常的方法签名中包含“throws DaoException”关键字

这种技术使我们无需为 [IDao] 接口的方法指定特定类型的异常。任何抛出未检查异常的实现都将被接受,从而为架构带来了灵活性。

  • 第 6 行:一个错误代码。[dao] 层将抛出由不同错误代码标识的各种异常。这将使负责处理异常的层能够确定错误的确切来源并采取适当行动。还有其他方法可以实现相同的结果。其中一种是为每种可能的错误类型创建一个异常类型,例如 MissingLastNameException、MissingFirstNameException、IncorrectAgeException 等。
  • 第 13–16 行:允许您通过错误代码和错误消息创建异常的构造函数。
  • 第 8–10 行:允许异常处理程序获取错误代码的方法。

类 [ DaoImpl] 实现了 [IDao] 接口:

package istia.st.springmvc.personnes.dao;

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

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collection;

public class DaoImpl implements IDao {

    // a list of people
    private ArrayList personnes = new ArrayList();

    // next person's no
    private int id = 0;

    // initializations
    public void init() {
        try {
            Personne p1 = new Personne(-1, "Joachim", "Major",
                    new SimpleDateFormat("dd/MM/yyyy").parse("13/11/1984"),
                    true, 2);
            saveOne(p1);
            Personne p2 = new Personne(-1, "Mélanie", "Humbort",
                    new SimpleDateFormat("dd/MM/yyyy").parse("12/02/1985"),
                    false, 1);
            saveOne(p2);
            Personne p3 = new Personne(-1, "Charles", "Lemarchand",
                    new SimpleDateFormat("dd/MM/yyyy").parse("01/03/1986"),
                    false, 0);
            saveOne(p3);
        } catch (ParseException ex) {
            throw new DaoException(
                    "Erreur d'initialisation de la couche [dao] : "
                            + ex.toString(), 1);
        }
    }

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

    // get a specific person
    public Personne getOne(int id) {
        // we're looking for the person
        int i = getPosition(id);
        // have we found?
        if (i != -1) {
            return new Personne(((Personne) personnes.get(i)));
        } else {
            throw new DaoException("Personne d'id [" + id + "] inconnue", 2);
        }
    }

    // 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
            personne.setId(getNextId());
            personne.setVersion(1);
            personnes.add(personne);
            return;
        }
        // modification - we're looking for the person
        int i = getPosition(personne.getId());
        // have we found?
        if (i == -1) {
            throw new DaoException("La personne d'Id [" + personne.getId()
                    + "] qu'on veut modifier n'existe pas", 2);
        }
        // do we have the right version of the original?
        Personne original = (Personne) personnes.get(i);
        if (original.getVersion() != personne.getVersion()) {
            throw new DaoException("L'original de la personne [" + personne
                    + "] a changé depuis sa lecture initiale", 3);
        }
        // wait 10 ms
        //wait(10);
        // that's it - make the change
        original.setVersion(original.getVersion()+1);
        original.setNom(personne.getNom());
        original.setPrenom(personne.getPrenom());
        original.setDateNaissance((personne.getDateNaissance()));
        original.setMarie(personne.getMarie());
        original.setNbEnfants(personne.getNbEnfants());
    }

    // deleting a person
    public void deleteOne(int id) {
        // we're looking for the person
        int i = getPosition(id);
        // have we found?
        if (i == -1) {
            throw new DaoException("Personne d'id [" + id + "] inconnue", 2);
        } else {
            // we delete the person
            personnes.remove(i);
        }
    }

    // id generator
    private int getNextId() {
        id++;
        return id;
    }

    // find a person
    private int getPosition(int id) {
        int i = 0;
        boolean trouvé = false;
        // browse the list of people
        while (i < personnes.size() && !trouvé) {
            if (id == ((Personne) personnes.get(i)).getId()) {
                trouvé = true;
            } else {
                i++;
            }
        }
        // result?
        return trouvé ? i : -1;
    }

    // person verification
    private void check(Personne p) {
        // person p
        if (p == null) {
            throw new DaoException("Personne null", 10);
        }
        // id
        if (p.getId() != -1 && p.getId() < 0) {
            throw new DaoException("Id [" + p.getId() + "] invalide", 11);
        }
        // date of birth
        if (p.getDateNaissance() == null) {
            throw new DaoException("Date de naissance manquante", 12);
        }
        // number of children
        if (p.getNbEnfants() < 0) {
            throw new DaoException("Nombre d'enfants [" + p.getNbEnfants()
                    + "] invalide", 13);
        }
        // name
        if (p.getNom() == null || p.getNom().trim().length() == 0) {
            throw new DaoException("Nom manquant", 14);
        }
        // first name
        if (p.getPrenom() == null || p.getPrenom().trim().length() == 0) {
            throw new DaoException("Prénom manquant", 15);
        }
    }

    // waiting
    private void wait(int N) {
        // we wait for N ms
        try {
            Thread.sleep(N);
        } catch (InterruptedException e) {
            // display the exception trace
            e.printStackTrace();
            return;
        }
    }
}

我们只对这段代码做个概述。不过,我们会花一点时间讲解其中比较棘手的部分。

  • 第 13 行:用于存储人员组的 [ArrayList] 对象
  • 第 16 行:最后添加的人员的 ID。每次添加新人员时,该 ID 将递增 1。

[DaoImpl] 类将作为单例进行实例化。这被称为单例模式。Web 应用程序会同时为多个用户提供服务。在任何给定时刻,Web 服务器上都有多个线程在运行。这些线程共享单例:

  • 来自 [dao] 层的单例
  • [service] 层中的单例
  • Web 层中各个控制器、数据验证器等的实例

如果某个单例拥有私有字段,你应立即自问:为何需要这些字段?它们是否合理?事实上,这些字段将在不同线程间共享。如果它们是只读的,且能在确信仅有一个活动线程时进行初始化,则不会有问题。 我们通常知道如何识别这个时刻。那就是 Web 应用程序启动但尚未开始服务客户端的时候。如果这些字段是读写型的,则必须实现对字段访问的同步;否则,灾难在所难免。我们在测试 [dao] 层时将对此问题进行说明。

  • [DaoImpl] 类没有构造函数。因此,将使用其默认构造函数。
  • 第 19–38 行:当 [dao] 层的单例被实例化时,将调用 [init] 方法。该方法创建了一个包含三人的列表。
  • 第 41–43 行:实现 [IDao] 接口的 [getAll] 方法。该方法返回人员列表的引用。
  • 第 46–55 行:实现 [IDao] 接口的 [getOne] 方法。其参数为要查找的人员的 ID。

为了检索该数据,我们在第 113–126 行调用了一个私有方法 [getPosition]。该方法返回被搜索人员在列表中的位置,若未找到该人员则返回 -1。

如果找到了该人员,[getOne] 方法返回的是该人员副本的引用(第 51 行),而不是该人员本身。 实际上,当用户想要编辑某人时,该人的信息会从 [dao] 层请求,并以 [Person] 对象的引用形式传递到 [web] 层进行修改。该引用在编辑表单中充当输入容器。当用户在 web 层提交更改时,输入容器中的内容将被修改。 如果该容器是对 [dao] 层 [ArrayList] 中实际人员对象的引用,那么即使更改尚未提交给 [service] 和 [dao] 层,该人员也会被修改。而 [dao] 层是唯一有权管理人员列表的层。因此,Web 层必须对待修改人员的副本进行操作。在此,[dao] 层提供了该副本。

如果未找到要搜索的人员,将抛出带有错误代码 2 的 [DaoException](第 53 行)。

  • 第 94–104 行:实现 [IDao] 接口的 [deleteOne] 方法。其参数为待删除人员的 ID。如果待删除人员不存在,则抛出错误代码为 2 的 [DaoException]。
  • 第 58–91 行:实现 [IDao] 接口的 [saveOne] 方法。其参数是一个 [Person] 对象。如果该对象的 id 为 -1,则表示正在添加新人员。否则,它将使用参数中的值修改列表中具有该 id 的人员。
    • 第 60 行:通过第 129–155 行定义的私有方法 [check] 检查 [Person] 参数的有效性。该方法对 [Person] 各字段的值进行基本检查。 一旦检测到异常,将抛出一个带有特定错误代码的 [DaoException]。由于 [saveOne] 方法未处理此异常,因此该异常将传播至调用方法。
    • 第 62 行:如果 [Person] 参数的 id 等于 -1,则表示要添加新记录。此时,[Person] 对象会被添加到内部人员列表中(第 66 行),并分配首个可用的 id(第 64 行)以及版本号 1(第 65 行)。
    • 如果 [Person] 参数的 [id] 不为 -1,则表示需要修改内部列表中具有该 [id] 的用户。首先,我们检查(第 70–75 行)待修改的用户是否存在。如果不存在,则抛出错误代码为 2 的 [DaoException]。
    • 如果该人员确实存在,我们会验证其当前版本是否与 [Person] 参数中的版本一致,该参数包含要应用到原始对象上的更改。如果不一致,则意味着尝试修改该人员的用户没有最新版本。我们会通过抛出错误代码为 3 的 [DaoException] 来通知用户(第 79–80 行)。
    • 如果一切顺利,则对原始人员记录进行修改(第 85–90 行)

显然,此方法必须进行同步。例如,在我们验证待修改的[Person]确实存在与实际执行修改操作之间,该[Person]可能已被其他人从列表中移除。因此,应将该方法声明为[synchronized],以确保每次仅有一个线程执行它。 [IDao] 接口的其他方法同样适用此原则。但我们并未在此处进行同步,而是选择将同步操作移至 [service] 层。为了突出同步问题,在测试 [dao] 层时,我们将在确认可以进行修改与实际执行修改之间,暂停 [saveOne] 的执行 10 毫秒(第 83 行)。 此时,执行 [saveOne] 的线程将把 CPU 控制权让给另一个线程。这会增加我们在人员列表中观察到访问冲突的概率。

14.5. [DAO] 层测试

为 [dao] 层编写了一个 JUnit 测试:

[TestDao] 是 JUnit 测试类。为突出人员列表的并发访问问题,创建了类型为 [ThreadDaoMajEnfants] 的线程。这些线程负责将指定人员的子女数量增加 1。

[TestDao] 包含五个测试用例,即 [test1] 至 [test5]。本文仅展示其中两个;欢迎读者查阅本文配套的源代码以探索其余测试用例。

package istia.st.springmvc.personnes.tests;

import java.text.ParseException;
...

public class TestDao extends TestCase {

    // layer [dao]
    private DaoImpl dao;

    // manufacturer
    public TestDao() {
        dao = new DaoImpl();
        dao.init();
    }

    // list of persons
    private void doListe(Collection personnes) {
        Iterator iter = personnes.iterator();
        while (iter.hasNext()) {
            System.out.println(iter.next());
        }
    }

    // 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 {
    ...
}
  • 第 9 行:引用待测试的 [dao] 层的实现
  • 第12–15行:JUnit测试构造函数。它从待测试的[dao]层创建一个[DaoImpl]类型的实例并对其进行初始化。

[test1] 方法按以下方式测试 [IDao] 接口的四个方法:

    public void test1() throws ParseException {
        // current list
        Collection personnes = dao.getAll();
        int nbPersonnes = personnes.size();
        // display
        doListe(personnes);
        // add a person
        Personne p1 = new Personne(-1, "X", "X", new SimpleDateFormat(
                "dd/MM/yyyy").parse("01/02/2006"), true, 1);
        dao.saveOne(p1);
        int id1 = p1.getId();
        // verification - a crash will occur if the person is not found
        p1 = dao.getOne(id1);
        assertEquals("X", p1.getNom());
        // modification
        p1.setNom("Y");
        dao.saveOne(p1);
        // verification - a crash will occur if the person is not found
        p1 = dao.getOne(id1);
        assertEquals("Y", p1.getNom());
        // delete
        dao.deleteOne(id1);
        // check
        int codeErreur = 0;
        boolean erreur = false;
        try {
            p1 = dao.getOne(id1);
        } catch (DaoException ex) {
            erreur = true;
            codeErreur = ex.getCode();
        }
        // we must have a code 2 error
        assertTrue(erreur);
        assertEquals(2, codeErreur);
        // list of persons
        personnes = dao.getAll();
        assertEquals(nbPersonnes, personnes.size());
    }
  • 第 3 行:请求人员列表
  • 第 6 行:显示该列表
[1,1,Joachim,Major,13/01/1984,true,2]
[2,1,Mélanie,Humbort,12/01/1985,false,1]
[3,1,Charles,Lemarchand,01/01/1986,false,0]

随后,该测试会添加、修改和删除一个人员。因此,[IDao] 接口的四个方法都被用到了。

  • 第 8–10 行:添加一个新人员(id=-1)。
  • 第11行:我们获取所添加人员的ID,因为添加操作为其分配了一个ID。在此之前,该人员还没有ID。
  • 第 13–14 行:我们向 [dao] 层请求刚刚添加的那个人的副本。请注意,如果找不到请求的人,[dao] 层会抛出异常。这将导致第 13 行发生崩溃。我们本可以更干净地处理这种情况。在第 14 行,我们检查了检索到的那个人的姓名。
  • 第 16–17 行:我们修改该姓名,并请求 [DAO] 层保存更改。
  • 第 19–20 行:我们向 [DAO] 层请求刚刚添加的该人的副本,并验证其新名称。
  • 第 22 行:删除测试开始时添加的人员。
  • 第 23–34 行:向 [DAO] 层请求刚刚被删除的那个人的副本。你应该会收到一个代码为 2 的 [DaoException]。
  • 第 36–37 行:再次请求人员列表。此时应得到与测试开始时相同的列表。

[test4] 方法旨在突出 [dao] 层方法在并发访问时存在的问题。请注意,这些方法尚未进行同步。测试代码如下:

    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 threads for updating the number of children
        final int N = 10;
        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);
    }
  • 第 3–6 行:我们将一个没有子女的人 P 添加到列表中。我们记录其 [id](第 6 行)。
  • 第 7–13 行:我们启动 N 个线程。每个线程将把人物 P 的子女数量增加 1。最终,人物 P 应该有 N 个子女。
  • 第15–17行:启动N个线程的[test4]方法会等待所有线程完成工作,然后才检查人物P的新子女数量。
  • 第 18–21 行:我们取出人员 P,并验证其子女数为 N。
  • 第 22–35 行:移除人物 P,并验证其已不再出现在列表中。

在第 11 行,我们可以看到线程的类型为 [ThreadDaoMajEnfants]。该类型的构造函数有三个参数:

  1. 线程的名称,用于通过日志追踪该线程
  2. 指向 [dao] 层的引用,以便线程能够访问该层
  3. 该线程应处理的用户的ID

[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();
                // must be a version 3 error - otherwise it will be re-run
                // the exception
                if (codeErreur != 3) {
                    throw ex;
                } else {
                    // follow-up
                    suivi(ex.getMessage());
                }
                // the original has changed - start all over again
            }
        }
        // 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);
    }
}
  • 第 9 行:[ThreadDaoMajEnfants] 确实是一个线程
  • 第 18–22 行:使用三项信息初始化线程的构造函数
    1. 赋予线程的名称 [name]
    2. 指向 [dao] 层的引用 [dao]。请注意,我们再次使用的是接口类型 [IDao],而非实现类型 [DaoImpl]。
    3. 线程将要处理的对象的标识符 [id]

当 [test4] 启动线程 [ThreadDaoMajEnfants](test4 的第 12 行)时,其 [run] 方法(第 25 行)会被执行:

  • 第 78–81 行:私有方法 [suivi] 用于屏幕日志记录。[run] 方法调用该方法来跟踪线程的执行。
  • 该线程尝试将标识符为 [id] 的用户 P 的子女数量增加 1。此更新可能需要多次尝试。让我们考虑两个线程 [TH1] 和 [TH2]。[TH1] 向 [dao] 层请求用户 P 的副本。它获取了该副本并记录其版本为 V1。[TH1] 被中断。 随后跟进的 [TH2] 执行了相同操作,并获取了同为 V1 版本的 P。[TH2] 被中断。[TH2] 恢复控制后,将 P 的子节点数量加 1,并保存了更改。我们知道这些更改现已保存,且 P 的版本将变为 V2。[TH1] 已完成其工作。 [TH2] 恢复控制并执行相同操作。其对 P 的更新将被拒绝,因为它持有的是版本 V1 的 P 副本,而原始的 P 现在已是版本 V2。[TH2] 必须重复整个循环 [读取 -> 更新 -> 保存]。这就是为什么我们在第 32–72 行中看到循环的原因。在此循环中,该线程:
  • 请求获取人员 P 的副本以进行修改(第 34 行)
  • 等待 10 毫秒(第 43 行)。此操作是人为设计的,旨在中断线程在读取人员 P 与实际将其更新到人员列表之间的过程,从而增加冲突发生的概率。
  • 递增 P 的子节点数量(第 54 行)并保存 P(第 56 行)。如果线程持有的 P 版本不正确,[dao] 层将抛出异常。随后我们获取异常代码(第 61 行)以验证其确实为代码 3(P 版本不正确)。 若非如此,则将异常重新抛回给调用方法,最终抛回给 [test4] 测试方法。若收到代码 3 的异常,则重新启动 [读取 -> 更新 -> 保存] 循环。若未抛出异常,则更新已完成,该线程的工作也随之结束。

测试结果如何?

在测试的首个配置中:

  • 我们将 [DaoImpl] 类中 [saveOne] 方法的 wait 语句注释掉(第 83 行,第 14.4 节)。
        // on attend 10 ms
        //wait(10);
  • [test4] 方法会创建 100 个线程(第 8 行,第 14.5 节)。
        // création de N threads de mise à jour du nombre d'enfants
        final int N = 100;

得到以下结果:

Image

全部五项测试均成功。

在测试的第二种配置中:

  • [DaoImpl] 类中 [saveOne] 方法的 wait 语句已被取消注释(第 83 行,第 14.4 节)。
        // on attend 10 ms
        wait(10);
  • [test4] 方法创建了 2 个线程(第 8 行,第 14.5 节)。
        // création de N threads de mise à jour du nombre d'enfants
        final int N = 2;

得到以下结果:

[test4] 测试失败。我们创建了两个线程,每个线程的任务是将最初为 0 的某人 P 的子女数增加 1。因此,我们预期两个线程运行后应有 2 个子女,但实际只有 1 个。

让我们查看 [test4] 的屏幕日志,以了解发生了什么:

thread n° 0 [1145536368171] : lancé
thread n° 0 [1145536368171] : 0 -> 1 pour la version 1
thread n° 0 [1145536368171] : début attente
thread n° 1 [1145536368171] : lancé
thread n° 1 [1145536368171] : 0 -> 1 pour la version 1
thread n° 1 [1145536368171] : début attente
thread n° 0 [1145536368187] : fin attente
thread n° 1 [1145536368187] : fin attente
thread n° 0 [1145536368187] : a terminé et passé le nombre d'enfants à 1
thread n° 1 [1145536368187] : a terminé et passé le nombre d'enfants à 1
  • 第 1 行:线程 #0 开始工作
  • 第 2 行:它已获取了人员 P 的副本,并发现子节点数量为 0
  • 第 3 行:它在 [run] 方法中遇到 [Thread.sleep(10)],因此于时间 [1145536368171] (毫秒) 暂停
  • 第 4 行:线程 #1 随后接管处理器并开始工作
  • 第 5 行:它已获取人员 P 的副本,并发现子女数量为 0
  • 第 6 行:它在 [run] 方法中遇到 [Thread.sleep(10)],因此暂停
  • 第 7 行:线程 0 在时间 [1145536368187] (毫秒) 重新获得 CPU 控制权,即在失去控制权 16 毫秒后。
  • 第 8 行:线程 #1 也是如此
  • 第 9 行:线程 #0 已更新自身,并将子节点数设置为 1
  • 第 10 行:线程 #1 也做了同样的操作

问题在于:通常情况下,线程 #1 已经不再持有线程 #0 刚刚更新的关于人物 P 的正确版本,那么它为何还能执行更新操作?

首先,我们可以观察到第7行和第8行之间存在异常:似乎在线程#0执行这两行代码之间,CPU被线程#1抢占了。当时它在做什么?它正在执行[dao]层的[saveOne]方法。该方法的骨架如下(参见第14.4节):

    public void saveOne(Personne personne) {
...
        // modification - we're looking for the person
....
        // do we have the right version of the original?
...
        // wait 10 ms
        wait(10);
        // that's it - make the change
    ...
}
  • 线程 #0 执行了 [saveOne] 并继续执行到第 8 行,在那里它被迫释放 CPU。与此同时,它读取了用户 P 的版本,该版本为 1,因为用户 P 尚未被更新。
  • 由于 CPU 空闲,线程 #1 接管了它。它随后执行了 [saveOne] 并到达第 8 行,在那里它被迫释放 CPU。与此同时,它读取了人物 P 的版本,该版本为 1,因为人物 P 仍未被更新。
  • 由于处理器已空闲,线程 #0 获取了它。从第 9 行开始,它执行了更新操作并将子节点数设为 1。随后线程 #0 的 [run] 方法结束,该线程显示了日志,表明它已将子节点数设为 1(第 9 行)。
  • 由于处理器已空闲,线程 #1 接管了它。 从第 9 行开始,它执行了更新操作,并将子节点数设置为 1。为什么是 1?因为它持有 P 的一个副本,其中子节点数被设置为 0。日志(第 5 行)显示了这一点。随后线程 #1 的 [run] 方法结束,该线程显示了日志,表明它已将子节点数设置为 1(第 10 行)。

问题出在哪里?问题源于线程 #0 没有时间提交其更改,因此未能更新人物 P 的版本,而线程 #1 却试图读取该版本以检查人物 P 是否已发生变化。这种情况虽然不太可能发生,但并非不可能。 我们不得不强制线程 #0 失去 CPU 控制权,才使其表现为仅有两个线程。若没有这一变通方案,之前的配置在 100 个线程下无法重现此场景。[test4] 测试此前已成功通过。

解决方案是什么?无疑有几种。其中一种简单易行的方法是让 [saveOne] 方法进行同步:


    public synchronized void saveOne(Personne personne)

[synchronized] 关键字确保每次只有一个线程可以执行该方法。因此,只有在线程 #0 退出 [saveOne] 方法后,线程 #1 才被允许执行 [saveOne]。这样,我们就能确保当线程 #1 进入 [saveOne] 时,person P 的版本已经发生过变更。随后,由于线程 #1 持有的 P 版本不正确,其更新操作将被拒绝。

以上是 [dao] 层中需要进行同步的四个方法。然而,我们决定保持该层的设计不变,并将同步操作移至 [service] 层。这样做有以下几个原因:

  • 我们假设对 [dao] 层的访问总是通过 [service] 层进行。在我们的 Web 应用程序中正是如此。
  • 此外,出于与 [dao] 层同步原因不同的其他因素,可能也需要对 [service] 层的方法访问进行同步。在这种情况下,就没有必要同步 [dao] 层的方法。如果我们确信:
  • 所有对 [DAO] 层的访问都通过 [service] 层进行
  • 每次仅有一个线程使用 [service] 层

那么我们可以确信,[DAO]层的方法不会被两个线程同时执行。

接下来我们将探讨 [service] 层。

14.6. [服务]层

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

Image

  • [IService] 是 [dao] 层暴露的接口
  • [ServiceImpl] 是该接口的实现

[IService] 接口如下:

package istia.st.springmvc.personnes.service;

import istia.st.springmvc.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);
}

它与 [IDao] 接口完全一致。

[IService] 接口的 [ServiceImpl] 实现如下:

package istia.st.springmvc.personnes.service;

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

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 synchronized Collection getAll() {
        return dao.getAll();
    }

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

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

    // deleting a person
    public synchronized void deleteOne(int id) {
        dao.deleteOne(id);
    }
}
  • 第 10–19 行:[IDao dao] 属性是对 [dao] 层的引用。它将由 Spring IoC 进行初始化。
  • 第 22–24 行:实现 [IService] 接口的 [getAll] 方法。该方法仅将请求委托给 [dao] 层。
  • 第 27–29 行:实现 [IService] 接口的 [getOne] 方法。该方法仅将请求委托给 [dao] 层。
  • 第 32–34 行:实现 [IService] 接口的 [saveOne] 方法。该方法仅将请求委托给 [dao] 层。
  • 第 37–39 行:[IService] 接口的 [deleteOne] 方法的实现。该方法仅将请求委托给 [dao] 层。
  • 所有方法均已同步(使用 `synchronized` 关键字),以确保每次仅有一个线程可以使用 [service] 层,进而使用 [dao] 层。

14.7. [service] 层的测试

为 [service] 层编写了一个 JUnit 测试:

[TestService] 是 JUnit 测试类。其执行的测试与针对 [dao] 层执行的测试完全相同。[TestService] 的框架如下:

package istia.st.springmvc.personnes.tests;

...

public class TestService extends TestCase {

    // service] layer
    private ServiceImpl service;

    // manufacturer
    public TestService() {
        service = new ServiceImpl();
        DaoImpl dao=new DaoImpl();
        service.setDao(dao);
    }

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

    // test1
    public void test1() throws ParseException {
        // current list
        Collection personnes = service.getAll();
        int nbPersonnes = personnes.size();
        // display
        doListe(personnes);
        // add a person
        Personne p1 = new Personne(-1, "X", "X", new SimpleDateFormat(
                "dd/MM/yyyy").parse("01/02/2006"), true, 1);
        service.saveOne(p1);
        int id1 = p1.getId();
        // verification - a crash will occur if the person is not found
        p1 = service.getOne(id1);
        assertEquals("X", p1.getNom());
...
    }

    // 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 {
        // add a person
        Personne p1 = new Personne(-1, "X", "X", new SimpleDateFormat(
                "dd/MM/yyyy").parse("01/02/2006"), true, 0);
        service.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 ThreadServiceMajEnfants("thread n° " + i, service,
                    id1);
            taches[i].start();
        }
...
    }

    // validity tests for saveOne
    public void test5() throws ParseException {
    ...
    }
}
  • 第 9 行:正在测试的 [service] 层类型为 [ServiceImpl]。
  • 第 11–15 行:JUnit 测试构造函数创建了一个待测试的 [service] 层实例(第 12 行),创建了一个 [dao] 层实例(第 13 行),并指示 [service] 层使用该 [dao] 层(第 14 行)。

[test1] 方法以与 [dao] 层同名测试方法相同的方式,测试 [IService] 接口的四个方法。唯一的区别在于,它访问的是 [service] 层(第 25、32、35 行),而不是 [dao] 层。

[test4]方法旨在突出[service]层方法并发访问时的问题。它再次与[dao]层的[test4]测试方法完全相同。不过,有几个细节有所不同:

  • 我们调用的是 [service] 层而非 [dao] 层(第 55 行)
  • 我们将 [service] 层的引用传递给线程,而非 [dao] 层(第 61 行)

[ThreadServiceMajEnfants] 类型也与 [ThreadDaoMajEnfants] 类型几乎完全相同,唯一的区别在于它操作的是 [service] 层而非 [dao] 层:

package istia.st.springmvc.personnes.tests;

import istia.st.mvc.personnes.dao.DaoException;
import istia.st.mvc.personnes.entites.Personne;
import istia.st.mvc.personnes.service.IService;

public class ThreadServiceMajEnfants extends Thread {

    // thread name
    private String name;
    // reference on the [service] layer
    private IService service;
    // the id of the person we're going to work on
    private int idPersonne;

    public ThreadServiceMajEnfants(String name, IService service, int idPersonne) {
        this.name = name;
        this.service = service;
        this.idPersonne = idPersonne;
    }

    public void run() {
...
    }

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

}
  • 第 12 行:该线程与 [service] 层协作

我们正在使用导致 [dao] 层出现问题的配置运行测试:

  • 我们在 [DaoImpl] 的 [saveOne] 方法中取消了 wait 语句的注释(第 83 行,第 14.4 节)。
        // on attend 10 ms
        wait(10);
  • [test4] 方法会创建 100 个线程(第 65 行,第 14.7 节)。
        // création de N threads de mise à jour du nombre d'enfants
        final int N = 100;

所得结果如下:

正是[service]层中各方法的同步,使得[test4]测试得以成功。

14.8. [Web]层

让我们回顾一下应用程序的三层架构:

[Web] 层将向用户提供界面,以便他们管理人员组:

  • 群组成员列表
  • 将人员添加到组中
  • 编辑组内成员
  • 从组中移除成员

为此,它将依赖于 [service] 层,而该层又会调用 [DAO] 层。我们已经介绍了由 [web] 层管理的界面(第 14.1 节)。为了描述 web 层,我们将依次介绍以下内容:

  • 其配置
  • 其视图
  • 其控制器
  • 部分测试

14.8.1. Web 应用程序配置

该应用程序的 Eclipse 项目如下:

Image

  • 在 [istia.st.mvc.personnes.web] 包中,您将找到 [Application] 控制器。
  • JSP/JSTL 页面位于 [WEB-INF/views] 目录下。
  • [lib] 文件夹包含应用程序所需的第三方库。这些库位于 [Web App Libraries] 文件夹中。

[web.xml]


[web.xml] 文件是 Web 服务器用于加载应用程序的文件。其内容如下:


<?xml version="1.0" encoding="UTF-8"?>
<web-app id="WebApp_ID" version="2.4"
    xmlns="http://java.sun.com/xml/ns/j2ee"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://java.sun.com/xml/ns/j2ee http://java.sun.com/xml/ns/j2ee/web-app_2_4.xsd">
    <display-name>mvc-personnes-01</display-name>
    <!--  ServletPersonne -->
    <servlet>
        <servlet-name>personnes</servlet-name>
        <servlet-class>
            istia.st.mvc.personnes.web.Application
        </servlet-class>
        <init-param>
            <param-name>urlEdit</param-name>
            <param-value>/WEB-INF/vues/edit.jsp</param-value>
        </init-param>
        <init-param>
            <param-name>urlErreurs</param-name>
            <param-value>/WEB-INF/vues/erreurs.jsp</param-value>
        </init-param>
        <init-param>
            <param-name>urlList</param-name>
            <param-value>/WEB-INF/vues/list.jsp</param-value>
        </init-param>
    </servlet>
    <!--  Mapping ServletPersonne-->
    <servlet-mapping>
        <servlet-name>personnes</servlet-name>
        <url-pattern>/do/*</url-pattern>
    </servlet-mapping>
    <!--  welcome files -->
    <welcome-file-list>
        <welcome-file>index.jsp</welcome-file>
    </welcome-file-list>
    <!--  Unexpected error page -->
    <error-page>
        <exception-type>java.lang.Exception</exception-type>
        <location>/WEB-INF/vues/exception.jsp</location>
    </error-page>
</web-app>
  • 第 27-30 行:URL [/do/*] 将由 [people] Servlet 处理
  • 第 9-12 行:[personnes] Servlet 是 [Application] 类的实例,该类我们将自行实现。
  • 第 13-24 行:定义三个参数 [urlList, urlEdit, urlErrors],用于标识 [list, edit, errors] 视图对应的 JSP 页面的 URL。
  • 第 32–34 行:该应用程序有一个默认入口页面 [index.jsp],位于 Web 应用程序文件夹的根目录下。
  • 第 36–39 行:该应用程序有一个默认错误页面,当 Web 服务器遇到应用程序未处理的异常时,将显示该页面。
    • 第 37 行:<exception-type> 标签指定了由 <error-page> 指令处理的异常类型;此处为 [java.lang.Exception] 类型及其子类型,即所有异常。
    • 第 38 行:<location> 标签指定了当发生 <exception-type> 定义的类型异常时要显示的 JSP 页面。如果该页面包含以下指令,则发生的异常可在该页面中通过名为 exception 的对象获取:

<%@ page isErrorPage="true" %>
  • (待续)
    • 如果 <exception-type> 指定了类型 T1,而类型为 T2(非 T1 的子类)的异常被向上传播至 Web 服务器,则服务器会向客户端发送一个专有异常页面,该页面通常用户体验不佳。因此,[web.xml] 文件中的 <error-page> 标签至关重要。

[index.jsp]


当用户未指定 URL(即此处的 [/personnes-01])而直接请求应用上下文时,将显示此页面。其内容如下:


<%@ page language="java" pageEncoding="ISO-8859-1" contentType="text/html;charset=ISO-8859-1"%>
<%@ taglib uri="/WEB-INF/c.tld" prefix="c" %>
 
<c:redirect url="/do/list"/>

[index.jsp] 将客户端重定向至 URL [/do/list]。该 URL 显示该组中的人员列表。

14.8.2. 应用程序的 JSP/JSTL 页面


[ list.jsp] 视图


该页面用于显示人员列表:

Image

其代码如下:


<%@ page language="java" pageEncoding="ISO-8859-1" contentType="text/html;charset=ISO-8859-1"%>
<%@ taglib uri="/WEB-INF/c.tld" prefix="c" %>
<%@ taglib uri="/WEB-INF/taglibs-datetime.tld" prefix="dt" %>
 
<html>
    <head>
        <title>MVC - Personnes</title>
    </head>
    <body background="<c:url value="/ressources/standard.jpg"/>">
        <h2>Liste des personnes</h2>
        <table border="1">
            <tr>
                <th>Id</th>
                <th>Version</th>
                <th>Pr&eacute;nom</th>
                <th>Nom</th>
                <th>Date de naissance</th>
                <th>Mari&eacute;</th>
                <th>Nombre d'enfants</th>
                <th></th>
            </tr>
            <c:forEach var="personne" items="${personnes}">
                <tr>
                    <td><c:out value="${personne.id}"/></td>
                    <td><c:out value="${personne.version}"/></td>
                    <td><c:out value="${personne.prenom}"/></td>
                    <td><c:out value="${personne.nom}"/></td>
                    <td><dt:format pattern="dd/MM/yyyy">${personne.dateNaissance.time}</dt:format></td>
                    <td><c:out value="${personne.marie}"/></td>
                    <td><c:out value="${personne.nbEnfants}"/></td>
                    <td><a href="<c:url value="/do/edit?id=${personne.id}"/>">Modifier</a></td>
                    <td><a href="<c:url value="/do/delete?id=${personne.id}"/>">Supprimer</a></td>
                </tr>
            </c:forEach>
        </table>
        <br>
        <a href="<c:url value="/do/edit?id=-1"/>">Ajout</a>
    </body>
</html>
 
  • 该视图在其模板中接收一个元素:
  • 与 [Person] 对象的 [ArrayList] 关联的 [people] 元素
  • 第 22–34 行:我们遍历 ${people} 列表,以显示一个包含该组成员的 HTML 表格。
  • 第 31 行:通过当前人员的 [id] 字段设置 [Edit] 链接指向的 URL,以便与 URL [/do/edit] 关联的控制器知道要编辑哪个人。
  • 第 32 行:[Delete] 链接也采用了同样的处理方式。
  • 第 28 行:为了以 DD/MM/YYYY 格式显示该人的出生日期,我们使用了 Apache [Jakarta Taglibs] 项目 [DateTime] 标签库中的 <dt> 标签:

Image

该标签库的描述文件在第 3 行中定义。

  • 第 37 行:用于添加新人员的 [Add] 链接指向 URL [/do/edit],与第 31 行的 [Edit] 链接相同。[id] 参数的值为 -1,表示这是添加操作而非编辑操作。

[ edit.jsp] 视图


该视图用于显示添加新人员或修改现有人员的表单:

[edit.jsp] 视图的代码如下:


<%@ page language="java" pageEncoding="ISO-8859-1" contentType="text/html;charset=ISO-8859-1"%>
<%@ taglib uri="/WEB-INF/c.tld" prefix="c" %>
<%@ taglib uri="/WEB-INF/taglibs-datetime.tld" prefix="dt" %>
 
<html>
    <head>
        <title>MVC - Personnes</title>
    </head>
    <body background="../ressources/standard.jpg">
        <h2>Ajout/Modification d'une personne</h2>
        <c:if test="${erreurEdit != ''}">
            <h3>Echec de la mise à jour :</h3>
          L'erreur suivante s'est produite : ${erreurEdit}
            <hr>
        </c:if>
        <form method="post" action="<c:url value="/do/validate"/>">
            <table border="1">
                <tr>
                    <td>Id</td>
                    <td>${id}</td>
                </tr>
                <tr>
                    <td>Version</td>
                    <td>${version}</td>
                </tr>
                <tr>
                    <td>Pr&eacute;nom</td>
                    <td>
                        <input type="text" value="${prenom}" name="prenom" size="20">
                    </td>
                    <td>${erreurPrenom}</td>
                </tr>
                <tr>
                    <td>Nom</td>
                    <td>
                        <input type="text" value="${nom}" name="nom" size="20">
                    </td>
                    <td>${erreurNom}</td>
                </tr>
                <tr>
                <td>Date de naissance (JJ/MM/AAAA)</td>
                    <td>
                        <input type="text" value="${dateNaissance}" name="dateNaissance">
                    </td>
                    <td>${erreurDateNaissance}</td>
                </tr>
                <tr>
                    <td>Mari&eacute;</td>
                    <td>
                        <c:choose>
                            <c:when test="${marie}">
                                <input type="radio" name="marie" value="true" checked>Oui
                                <input type="radio" name="marie" value="false">Non
                            </c:when>
                            <c:otherwise>
                                <input type="radio" name="marie" value="true">Oui
                                <input type="radio" name="marie" value="false" checked>Non
                            </c:otherwise>
                        </c:choose>
                    </td>
                </tr>
                <tr>
                    <td>Nombre d'enfants</td>
                    <td>
                        <input type="text" value="${nbEnfants}" name="nbEnfants">
                    </td>
                    <td>${erreurNbEnfants}</td>
                </tr>
            </table>
            <br>
            <input type="hidden" value="${id}" name="id">
      <input type="hidden" value="${version}" name="version">
            <input type="submit" value="Valider">
            <a href="<c:url value="/do/list"/>">Annuler</a>
        </form>
    </body>
</html>

此视图显示了一个用于添加新人员或更新现有人员的表单。从现在起,为简化表述,我们将统一使用术语 [更新]。 [提交]按钮(第73行)会向URL [/do/validate](第16行)发起POST请求。如果POST请求失败,则重新显示[edit.jsp]视图并附带发生的错误信息;否则,显示[list.jsp]视图。

  • [edit.jsp] 视图(无论是在 GET 请求还是 POST 请求失败时显示)在其模型中接收以下元素:
属性
GET
POST
id
要更新的用户的ID
相同
版本
其版本
相同
名字
名字
输入的姓名
他的/她的姓氏
已输入的姓氏
出生日期
他的/她的出生日期
已输入的出生日期
已婚
婚姻状况
已输入的婚姻状况
子女数
子女数
已输入子女数
错误编辑
一条错误消息,表示在点击[提交]按钮触发的POST请求过程中,添加或修改操作失败。若无错误,则为空。
errorFirstName
表示名字不正确——否则为空
errorName
为空
报告姓氏不正确——否则为空
birthDateError
表示出生日期不正确 – 否则为空
errorNumberOfChildren
为空
表示子女数量不正确——否则为空
  • 第 11-15 行:如果表单 POST 提交失败,将返回 [errorEdit!=''] 并显示错误信息。
  • 第 16 行:表单将提交至 URL [/do/validate]
  • 第 20 行:显示模板的 [id] 元素
  • 第 24 行:显示模板中的 [version] 元素
  • 第 26-32 行:输入人员的名字:
    • 当表单初次显示(GET)时,${firstName} 显示更新后的 [Person] 对象中 [firstName] 字段的当前值,而 ${firstNameError} 为空。
    • 若 POST 提交后出现错误,将再次显示已输入的值 ${firstName} 以及任何错误信息 ${firstNameError}
  • 第 33-39 行:输入人员的姓
  • 第 40–46 行:输入人员的出生日期
  • 第 47–61 行:使用单选按钮输入人员的婚姻状况。我们使用 [Person] 对象的 [married] 字段的值来确定应选中两个单选按钮中的哪一个。
  • 第 62-68 行:输入该人的子女数
  • 第 71 行:一个名为 [id] 的隐藏 HTML 字段,其值等于正在更新的人员的 [id] 字段,若为新增则设为 -1,若为修改则设为其他值。
  • 第 72 行:一个名为 [version] 的隐藏 HTML 字段,其值等于正在更新的该人的 [id] 字段。
  • 第 73 行:表单的 [提交] 按钮
  • 第 74 行:返回人员列表的链接。该链接标记为 [Cancel],因为它允许用户在不提交表单的情况下退出表单。

[ exception.jsp]视图


用于显示一个页面,提示应用程序未处理的异常已发生并已传播至 Web 服务器。

例如,让我们尝试删除一个在组中不存在的用户:

[exception.jsp] 视图的代码如下:


<%@ page language="java" pageEncoding="ISO-8859-1" contentType="text/html;charset=ISO-8859-1"%>
<%@ taglib uri="/WEB-INF/c.tld" prefix="c" %>
<%@ page isErrorPage="true" %>
 
<%
  response.setStatus(200);
%>
 
<html>
    <head>
        <title>MVC - Personnes</title>
    </head>
    <body background="<c:url value="/ressources/standard.jpg"/>">
        <h2>MVC - personnes</h2>
        L'exception suivante s'est produite :
        <%= exception.getMessage()%>
        <br><br>
        <a href="<c:url value="/do/list"/>">Retour &agrave; la liste</a>
    </body>
</html>
 
  • 该视图在其模板中接收一个键,即 [exception] 元素,该元素代表被 Web 服务器拦截的异常。为了让 Web 服务器将此元素包含到 JSP 页面模板中,页面必须在第 3 行定义了该标签。
  • 第 6 行:我们将响应的 HTTP 状态码设置为 200。这是响应中的第一个 HTTP 头。200 状态码向客户端表明其请求已成功。通常,服务器响应中会包含一个 HTML 文档。本例中正是如此。如果响应的 HTTP 状态码未设置为 200,则其值为 500,这意味着发生了错误。 实际上,当 Web 服务器捕获到未处理的异常时,会将此情况视为异常,并通过 500 状态码进行提示。不同浏览器对 HTTP 500 状态码的响应方式各异:Firefox 会显示响应中可能包含的 HTML 文档,而 IE 则会忽略该文档并显示其自身的页面。这就是我们用 200 状态码替换 500 状态码的原因。
  • 第 16 行:显示异常文本
  • 第 18 行:向用户提供返回人员列表的链接

[ -errors .jsp] 视图


该页面用于显示应用程序初始化错误报告,即在控制器Servlet的[init]方法执行过程中检测到的错误。例如,这可能是[web.xml]文件中缺少某个参数,如下例所示:

Image

[errors.jsp] 页面的代码如下:


<%@ page language="java" contentType="text/html; charset=ISO-8859-1"
    pageEncoding="ISO-8859-1"%>
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN">
<%@ taglib uri="/WEB-INF/c.tld" prefix="c" %>
 
<html>
    <head>
      <title>MVC - Personnes</title>
  </head>
  <body>
      <h2>Les erreurs suivantes se sont produites</h2>
    <ul>
            <c:forEach var="erreur" items="${erreurs}">
                <li>${erreur}</li>
            </c:forEach>
    </ul>
  </body>
</html>

该页面在其模板中接收一个 [errors] 元素,这是一个包含 [String] 对象的 [ArrayList];这些是错误消息。它们由第 13–15 行的循环显示出来。

14.8.3. 应用程序控制器

[Application] 控制器定义在 [istia.st.mvc.personnes.web] 包中:

Image


控制器的结构与初始化


[Application] 控制器的骨架如下:

package istia.st.mvc.personnes.web;

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

@SuppressWarnings("serial")
public class Application extends HttpServlet {
    // instance parameters
    private String urlErreurs = null;
    private ArrayList erreursInitialisation = new ArrayList<String>();
    private String[] paramètres = { "urlList", "urlEdit", "urlErreurs" };
    private Map params = new HashMap<String, String>();

    // service
    ServiceImpl service=null;

    // init
    @SuppressWarnings("unchecked")
    public void init() throws ServletException {
        // retrieve servlet initialization parameters
        ServletConfig config = getServletConfig();
        // other initialization parameters are processed
        String valeur = null;
        for (int i = 0; i < paramètres.length; i++) {
            // parameter value
            valeur = config.getInitParameter(paramètres[i]);
            // present parameter?
            if (valeur == null) {
                // we note the error
                erreursInitialisation.add("Le paramètre [" + paramètres[i]
                        + "] n'a pas été initialisé");
            } else {
                // parameter value is stored
                params.put(paramètres[i], valeur);
            }
        }
        // the [errors] view url has a special treatment
        urlErreurs = config.getInitParameter("urlErreurs");
        if (urlErreurs == null)
            throw new ServletException(
                    "Le paramètre [urlErreurs] n'a pas été initialisé");
        // dao] layer instantiation
        DaoImpl dao = new DaoImpl();
        dao.init();
        // instantiation of the [service] layer
        service = new ServiceImpl();
        service.setDao(dao);
    }

    // GET
    @SuppressWarnings("unchecked")
    public void doGet(HttpServletRequest request, HttpServletResponse response)
            throws IOException, ServletException {
....
    }

    // display list of persons
    private void doListPersonnes(HttpServletRequest request,
            HttpServletResponse response) throws ServletException, IOException {
...
    }

    // modify / add a person
    private void doEditPersonne(HttpServletRequest request,
            HttpServletResponse response) throws ServletException, IOException {
...
    }

    // validation modification / addition of a person
    private void doDeletePersonne(HttpServletRequest request,
            HttpServletResponse response) throws ServletException, IOException {
...
    }

    // validation modification / addition of a person
    public void doValidatePersonne(HttpServletRequest request,
            HttpServletResponse response) throws ServletException, IOException {
...
    }

    // display pre-filled form
    private void showFormulaire(HttpServletRequest request,
            HttpServletResponse response, String erreurEdit) throws ServletException, IOException{
...
    }

    // post
    public void doPost(HttpServletRequest request, HttpServletResponse response)
            throws IOException, ServletException {
        // we hand over to GET
        doGet(request, response);    }
}
  • 第 20–36 行:检索 [web.xml] 文件中指定的参数。
  • 第 39–41 行:必须存在 [urlErrors] 参数,因为它指定了 [errors] 视图的 URL,该视图用于显示任何初始化错误。如果不存在该参数,应用程序将通过抛出 [ServletException] 异常(第 40 行)而终止。该异常将传播到 Web 服务器,并由 [web.xml] 文件中的 <error-page> 标签进行处理。 因此将显示 [exception.jsp] 视图:

Image

上方的 [返回列表] 链接处于不可用状态。只要应用程序未被修改并重新加载,点击该链接将返回相同的响应。正如我们之前所见,这对其他类型的异常非常有用。

  • 第 43 行:创建一个实现 [dao] 层的 [DaoImpl] 实例
  • 第 44 行:初始化该实例(创建一个包含三人的初始列表)
  • 第 46 行:创建一个实现 [service] 层的 [ServiceImpl] 实例
  • 第 47 行:通过提供对 [dao] 层的引用来初始化 [service] 层

控制器初始化后,其方法将拥有指向 [service] 层的 [service] 引用(第 15 行),用于执行用户请求的操作。这些操作将被 [doGet] 方法拦截,并由控制器中的特定方法进行处理:

Url
HTTP 方法
控制器方法
/do/list
GET
doListPeople
/do/edit
GET
doEditPerson
/do/validate
POST
doValidatePerson
/do/delete
GET
doDeletePerson

[doGet] 方法


此方法的目的是将用户请求的操作处理路由到正确的方法。其代码如下:

// GET
    @SuppressWarnings("unchecked")
    public void doGet(HttpServletRequest request, HttpServletResponse response)
            throws IOException, ServletException {

        // check how the servlet was initialized
        if (erreursInitialisation.size() != 0) {
            // we hand over to the error page
            request.setAttribute("erreurs", erreursInitialisation);
            getServletContext().getRequestDispatcher(urlErreurs).forward(request, response);
            // end
            return;
        }
        // retrieve the request sending method
        String méthode = request.getMethod().toLowerCase();
        // retrieve the action to be executed
        String action = request.getPathInfo();
        // action?
        if (action == null) {
            action = "/list";
        }
        // execution action
        if (méthode.equals("get") && action.equals("/list")) {
            // list of persons
            doListPersonnes(request, response);
            return;
        }
        if (méthode.equals("get") && action.equals("/delete")) {
            // deleting a person
            doDeletePersonne(request, response);
            return;
        }
        if (méthode.equals("get") && action.equals("/edit")) {
            // presentation form add / modify a person
            doEditPersonne(request, response);
            return;
        }
        if (méthode.equals("post") && action.equals("/validate")) {
            // validation form add / modify a person
            doValidatePersonne(request, response);
            return;
        }
        // other cases
        doListPersonnes(request, response);
    }
  • 第 7–13 行:我们检查初始化错误列表是否为空。如果不是,则显示 [errors(errors)] 视图,该视图将报告错误。
  • 第 15 行:我们获取客户端用于发送请求的 [get] 或 [post] 方法。
  • 第 17 行:从请求中获取 [action] 参数的值。
  • 第 23–27 行:处理 [GET /do/list] 请求,该请求用于获取人员列表。
  • 第 28–32 行:处理 [GET /do/delete] 请求,该请求用于删除某人。
  • 第 33–37 行:处理 [GET /do/edit] 请求,该请求用于更新人员表单。
  • 第 38–42 行:处理 [POST /do/validate] 请求,该请求用于验证更新的用户。
  • 第 44 行:如果请求的操作不属于前五种,则将其视为 [GET /do/list] 请求。

[doListPersonnes] 方法


该方法处理 [GET /do/list] 请求,该请求用于获取人员列表:

Image

其代码如下:

1
2
3
4
5
6
7
8
9
    // display list of persons
    private void doListPersonnes(HttpServletRequest request,
            HttpServletResponse response) throws ServletException, IOException {
        // the [list] view model
        request.setAttribute("personnes", service.getAll());
        // list] view display
        getServletContext()
                .getRequestDispatcher((String) params.get("urlList")).forward(request, response);
    }
  • 第 5 行:我们从 [服务] 层请求该组中的人员列表,并将其存储在模型中,键名为 "people"。
  • 第 7 行:显示第 14.8.2 节中描述的 [list.jsp] 视图。

[doDeletePerson] 方法


该方法处理 [GET /do/delete?id=XX] 请求,该请求用于删除 id=XX 的用户。URL [/do/delete?id=XX] 即 [list.jsp] 视图中 [Delete] 链接的地址:

Image

其代码如下:

...
<html>
    <head>
        <title>MVC - personnes</title>
    </head>
    <body background="<c:url value="/ressources/standard.jpg"/>">
...
            <c:forEach var="personne" items="${personnes}">
                <tr>
...
                    <td><a href="<c:url value="/do/edit?id=${personne.id}"/>">Modifier</a></td>
                    <td><a href="<c:url value="/do/delete?id=${personne.id}"/>">Supprimer</a></td>
                </tr>
            </c:forEach>
        </table>
        <br>
        <a href="<c:url value="/do/edit?id=-1"/>">Ajout</a>
    </body>
</html>

第 12 行显示了 [删除] 链接的 URL [/do/delete?id=XX]。处理此 URL 的 [doDeletePerson] 方法必须删除 id=XX 的用户,然后显示该组中更新后的人员列表。其代码如下:

    // validation modification / addition of a person
    private void doDeletePersonne(HttpServletRequest request,
            HttpServletResponse response) throws ServletException, IOException {
        // retrieve the person's id
        int id = Integer.parseInt(request.getParameter("id"));
        // we delete the person
        service.deleteOne(id);
        // redirects to the list of persons
        response.sendRedirect("list");
    }
  • 第 5 行:正在处理的 URL 格式为 [/do/delete?id=XX]。我们从 [id] 参数中提取 [XX] 的值。
  • 第 7 行:我们请求 [service] 层根据获取的 ID 删除该用户。 我们不进行任何验证。如果要删除的用户不存在,[dao]层会抛出一个异常,该异常会向上传播至[service]层。我们在此处的控制器中也不进行处理。因此,该异常将向上传播至Web服务器,根据配置,Web服务器将显示第14.8.2节中描述的[exception.jsp]页面:

Image

  • 第 9 行:如果删除成功(未抛出异常),客户端将被重定向到相对 URL [list]。由于刚刚处理的 URL 是 [/do/delete],因此重定向 URL 将是 [/do/list]。浏览器将执行 [GET /do/list] 请求,从而显示人员列表。

[doEditPerson] 方法


该方法处理 [GET /do/edit?id=XX] 请求,该请求用于获取更新 id=XX 人员的表单。URL [/do/edit?id=XX] 即 [list.jsp] 视图中 [编辑] 和 [添加] 链接所使用的地址:

Image

其代码如下:

...
<html>
    <head>
        <title>MVC - personnes</title>
    </head>
    <body background="<c:url value="/ressources/standard.jpg"/>">
...
            <c:forEach var="personne" items="${personnes}">
                <tr>
...
                    <td><a href="<c:url value="/do/edit?id=${personne.id}"/>">Modifier</a></td>
                    <td><a href="<c:url value="/do/delete?id=${personne.id}"/>">Supprimer</a></td>
                </tr>
            </c:forEach>
        </table>
        <br>
        <a href="<c:url value="/do/edit?id=-1"/>">Ajout</a>
    </body>
</html>

在第 11 行,我们可以看到 [编辑] 链接的 URL [/do/edit?id=XX],而在第 17 行,可以看到 [添加] 链接的 URL [/do/edit?id=-1]。doEditPersonne 方法必须显示 id=XX 人员的编辑表单,如果是添加操作,则显示一个空表单。

[doEditPerson] 方法的代码如下:

    // modify / add a person
    private void doEditPersonne(HttpServletRequest request,
            HttpServletResponse response) throws ServletException, IOException {
        // retrieve the person's id
        int id = Integer.parseInt(request.getParameter("id"));
        // addition or modification?
        Personne personne = null;
        if (id != -1) {
            // modification - the person to be modified is retrieved
            personne = service.getOne(id);
        } else {
            // add - create an empty person
            personne = new Personne();
            personne.setId(-1);
        }
        // we put the [Person] object in the [edit] view model
        request.setAttribute("erreurEdit", "");
        request.setAttribute("id", personne.getId());
        request.setAttribute("version", personne.getVersion());
        request.setAttribute("prenom", personne.getPrenom());
        request.setAttribute("nom", personne.getNom());
        Date dateNaissance = personne.getDateNaissance();
        if (dateNaissance != null) {
            request.setAttribute("dateNaissance", new SimpleDateFormat(
                    "dd/MM/yyyy").format(dateNaissance));
        } else {
            request.setAttribute("dateNaissance", "");
        }
        request.setAttribute("marie", personne.getMarie());
        request.setAttribute("nbEnfants", personne.getNbEnfants());
        // view display [edit]
        getServletContext()
                .getRequestDispatcher((String) params.get("urlEdit")).forward(request, response);
    }
  • 该 GET 请求的目标 URL 格式为 [/do/edit?id=XX]。在第 5 行,我们获取 [id] 的值。随后有两种情况:
  1. 如果 id 不等于 -1,则表示更新操作,需要显示一个预先填入待编辑人员信息的表单。在第 10 行,从 [service] 层获取该人员信息。
  2. 如果 id 等于 -1,则为新增操作,必须显示一个空表单。为此,在第 13–14 行创建了一个空人员记录。
  • [Person] 对象被放置第 14.8.2 节所述的 [edit.jsp] 页面模板中。该模板包含以下元素:[errorEdit, id, version, firstName, errorFirstName, lastName, errorLastName, dateOfBirth, errorDateOfBirth, spouse, numberOfChildren, errorNumberOfChildren]。 这些元素在第 17–30 行中被初始化,但值设为空字符串的元素除外 [firstNameError, lastNameError, birthDateError, childrenCountError]。 我们知道,如果这些元素在模板中缺失,JSTL 库会将其值显示为空字符串。尽管 [errorEdit] 元素的值也是空字符串,但它仍会被初始化,因为 [edit.jsp] 页面中会对该值进行检查。
  • 模型准备就绪后,控制权将传递给 [edit.jsp] 页面(第 32–33 行),该页面将生成 [edit] 视图。

[doValidatePersonne] 方法


该方法处理 [POST /do/validate] 请求,用于验证更新表单。此 POST 请求由 [Validate] 按钮触发

Image

让我们回顾一下上文视图中 HTML 表单的输入元素:

<form method="post" action="<c:url value="/do/validate"/>">
....
        <input type="text" value="${prenom}" name="prenom" size="20">
....
        <input type="text" value="${nom}" name="nom" size="20">
....
        <input type="text" value="${dateNaissance}" name="dateNaissance">
...
        <input type="radio" name="marie" value="true" checked>Oui
....
        <input type="text" value="${nbEnfants}" name="nbEnfants">
....
            <input type="hidden" value="${id}" name="id">
     <input type="hidden" value="${version}" name="version">
            <input type="submit" value="Valider">
</form>

该 POST 请求包含参数 [firstName, lastName, dateOfBirth, spouse, numberOfChildren, id, version],并发送至 URL [/do/validate](第 1 行)。该请求由以下 [doValidatePerson] 方法处理:

// validation modification / addition of a person
    public void doValidatePersonne(HttpServletRequest request,
            HttpServletResponse response) throws ServletException, IOException {
        // retrieve posted items
        boolean formulaireErroné = false;
        boolean erreur;
        // first name
        String prenom = request.getParameter("prenom").trim();
        // valid first name?
        if (prenom.length() == 0) {
            // we note the error
            request.setAttribute("erreurPrenom", "Le prénom est obligatoire");
            formulaireErroné = true;
        }
        // the name
        String nom = request.getParameter("nom").trim();
        // valid first name?
        if (nom.length() == 0) {
            // we note the error
            request.setAttribute("erreurNom", "Le nom est obligatoire");
            formulaireErroné = true;
        }
        // date of birth
        Date dateNaissance = null;
        try {
            dateNaissance = new SimpleDateFormat("dd/MM/yyyy").parse(request
                    .getParameter("dateNaissance").trim());
        } catch (ParseException e) {
            // we note the error
            request.setAttribute("erreurDateNaissance", "Date incorrecte");
            formulaireErroné = true;
        }
        // marital status
        boolean marie = Boolean.parseBoolean(request.getParameter("marie"));
        // number of children
        int nbEnfants = 0;
        erreur = false;
        try {
            nbEnfants = Integer.parseInt(request.getParameter("nbEnfants")
                    .trim());
            if (nbEnfants < 0) {
                erreur = true;
            }
        } catch (NumberFormatException ex) {
            // we note the error
            erreur = true;
        }
        // wrong number of children?
        if (erreur) {
            // we report the error
            request.setAttribute("erreurNbEnfants",
                    "Nombre d'enfants incorrect");
            formulaireErroné = true;
        }
        // pERSON ID
        int id = Integer.parseInt(request.getParameter("id"));
        // version
        long version = Long.parseLong(request.getParameter("version"));
        // is the form incorrect?
        if (formulaireErroné) {
            // redisplay the form with error messages
            showFormulaire(request, response, "");
            // finish
            return;
        }
        // the form is correct - the person is registered
        Personne personne = new Personne(id, prenom, nom, dateNaissance, marie,
                nbEnfants);
        personne.setVersion(version);
        try {
            // registration
            service.saveOne(personne);
        } catch (DaoException ex) {
            // redisplay the form with the error message
            showFormulaire(request, response, ex.getMessage());
            // finish
            return;
        }
        // redirects to the list of persons
        response.sendRedirect("list");
    }

    // display pre-filled form
    private void showFormulaire(HttpServletRequest request,
            HttpServletResponse response, String erreurEdit)
            throws ServletException, IOException {
        // prepare the view model [edit]
        request.setAttribute("erreurEdit", erreurEdit);
        request.setAttribute("id", request.getParameter("id"));
        request.setAttribute("version", request.getParameter("version"));
        request.setAttribute("prenom", request.getParameter("prenom").trim());
        request.setAttribute("nom", request.getParameter("nom").trim());
        request.setAttribute("dateNaissance", request.getParameter(
                "dateNaissance").trim());
        request.setAttribute("marie", request.getParameter("marie"));
        request.setAttribute("nbEnfants", request.getParameter("nbEnfants")
                .trim());
        // view display [edit]
        getServletContext()
                .getRequestDispatcher((String) params.get("urlEdit")).forward(request, response);
    }
  • 第 8–14 行:从 POST 请求中获取 [firstName] 参数并检查其有效性。如果参数不正确,则将 [firstNameError] 元素初始化为一条错误消息,并将其放入请求属性中。
  • 第 16–22 行:对 [lastName] 参数执行相同的处理流程
  • 第 24–32 行:对 [dateOfBirth] 参数应用相同的处理流程
  • 第 34 行:获取 [spouse] 参数。我们不对其进行有效性检查,因为原则上该参数来自单选按钮的值。话虽如此,但没有任何机制能阻止程序发送一个带有虚假 [spouse] 参数的 [POST /people-01/do/validate] 请求。 因此,我们应当验证该参数的有效性。在此,我们依赖于异常处理机制:若控制器未自行处理异常,则会触发 [exception.jsp] 页面的显示。因此,若第 34 行将 [marie] 参数转换为布尔值失败,将抛出异常,导致 [exception.jsp] 页面被发送至客户端。这种行为符合我们的预期。
  • 第 34–54 行:我们获取 [nbEnfants] 参数并检查其值。
  • 第 56 行:我们获取 [id] 参数,但不检查其值
  • 第 58 行:对 [version] 参数执行同样的操作
  • 第 60–65 行:如果表单无效,则重新显示表单并附带之前生成的错误信息
  • 第 67–69 行:如果表单有效,则使用表单字段创建一个新的 [Person] 对象
  • 第 70–78 行:保存该人员。保存操作可能会失败。在多用户环境中,待修改的人员可能已被删除或已被他人修改。这种情况下,[dao] 层将抛出异常,我们在此处进行处理。
  • 第 80 行:若未发生异常,则将客户端重定向至 URL [/do/list] 以显示该组的新状态。
  • 第 75 行:若保存过程中发生异常,我们将请求重新显示初始表单,并向其传递异常的错误信息(第 3 个参数)。

[showFormulaire] 方法(第 84–101 行)使用输入的值(request.getParameter(" ... ")构建 [edit.jsp] 页面所需的模板。 请注意,[doValidatePersonne] 方法已将错误信息添加到模板中。第 99–100 行显示了 [edit.jsp] 页面。

14.9. Web 应用程序的测试

第 14.1 节中已介绍了一些测试。我们建议读者再次运行这些测试。在此,我们展示了一些额外的屏幕截图,以说明多用户环境中的数据访问冲突情况:

[Firefox] 将作为用户 U1 的浏览器。用户 U1 请求 URL [http://localhost:8080/personnes-01]:

Image

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

Image

用户 U1 开始编辑 [Lemarchand] 的记录:

Image

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

Image

用户 U1 进行修改并保存:

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

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

Image

他们找到了由 U1 修改过的 [Lemarchand]。现在 U2 删除了 [Lemarchand]:

U1 仍保留自己的列表,并希望再次编辑 [Lemarchand]:

U1点击[返回列表]链接查看情况:

Image

他发现[Lemarchand]确实已经不在名单上了……

14.10. 结论

我们通过一个管理人员列表的基本示例,在三层架构[Web、业务逻辑、DAO]中实现了MVC架构。这使我们能够应用前几节中介绍的概念。在我们探讨的版本中,人员列表保存在内存中。接下来我们将探讨将该列表存储在数据库表中的版本。

但在那之前,我们将先介绍一种名为 Spring IoC 的工具,它有助于实现多层应用程序中各层的集成。