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

若读者已遗忘相关知识,建议重温第 4 节中关于三层架构下 MVC Web 应用程序的原则。
我们将要编写的 Web 应用程序将允许我们通过以下四种操作来管理一组人员:
- 列出组内人员
- 向组中添加人员
- 修改组内成员
- 从组中移除成员
这些是数据库表的四项基本操作。我们将编写该应用程序的两个版本:
- 在版本 1 中,[DAO] 层将不使用数据库。组成员将存储在由 [DAO] 层内部管理的简单 [ArrayList] 对象中。这将使读者能够在不受数据库限制的情况下测试该应用程序。
- 在版本 2 中,我们将把组成员存入数据库表。我们将演示如何在不影响版本 1 的 Web 层的情况下实现这一点,该 Web 层将保持不变。



![]() |
![]() |
14.2. Eclipse 项目
该应用程序项目的名称为 [people-01]:

该项目涵盖了应用程序三层架构的三个层级:
![]() |
- [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] 类表示:
![]()
[Person] 类定义如下:
- 通过以下信息识别一个人:
- 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] 层由以下类和接口组成:
![]()
- [IDao] 是 [DAO] 层提供的接口
- [DaoImpl] 是该接口的实现,其中人员组被封装在一个 [ArrayList] 对象中
- [DaoException] 是 [dao] 层抛出的未检查异常类型
- 该接口提供了四个方法,用于对人员组执行以下四种操作:
- getAll:用于检索一组人员
- getOne:获取具有特定 ID 的个人
- saveOne:添加人员(id=-1)或修改现有人员(id ≠ -1)
- deleteOne:删除具有特定 ID 的用户
[DAO] 层可能会抛出异常。这些异常的类型为 [ DaoException]:
- 第 3 行:[DaoException] 类继承自 [RuntimeException],是一种未处理的异常类型:编译器并不要求我们在调用可能抛出此类异常的方法时,使用 try/catch 代码块来处理此类异常:
- 在调用可能抛出该异常的方法时,使用 try/catch 代码块来处理此类异常
- 在可能抛出该异常的方法签名中包含“throws DaoException”关键字
这种技术使我们无需为 [IDao] 接口的方法指定特定类型的异常。任何抛出未检查异常的实现都将被接受,从而为架构带来了灵活性。
- 第 6 行:一个错误代码。[dao] 层将抛出由不同错误代码标识的各种异常。这将使负责处理异常的层能够确定错误的确切来源并采取适当行动。还有其他方法可以实现相同的结果。其中一种是为每种可能的错误类型创建一个异常类型,例如 MissingLastNameException、MissingFirstNameException、IncorrectAgeException 等。
- 第 13–16 行:允许您通过错误代码和错误消息创建异常的构造函数。
- 第 8–10 行:允许异常处理程序获取错误代码的方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 | |
我们只对这段代码做个概述。不过,我们会花一点时间讲解其中比较棘手的部分。
- 第 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]。本文仅展示其中两个;欢迎读者查阅本文配套的源代码以探索其余测试用例。
- 第 9 行:引用待测试的 [dao] 层的实现
- 第12–15行:JUnit测试构造函数。它从待测试的[dao]层创建一个[DaoImpl]类型的实例并对其进行初始化。
[test1] 方法按以下方式测试 [IDao] 接口的四个方法:
- 第 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] 层方法在并发访问时存在的问题。请注意,这些方法尚未进行同步。测试代码如下:
- 第 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]。该类型的构造函数有三个参数:
- 线程的名称,用于通过日志追踪该线程
- 指向 [dao] 层的引用,以便线程能够访问该层
- 该线程应处理的用户的ID
[ThreadDaoMajEnfants] 类型的定义如下:
- 第 9 行:[ThreadDaoMajEnfants] 确实是一个线程
- 第 18–22 行:使用三项信息初始化线程的构造函数
- 赋予线程的名称 [name]
- 指向 [dao] 层的引用 [dao]。请注意,我们再次使用的是接口类型 [IDao],而非实现类型 [DaoImpl]。
- 线程将要处理的对象的标识符 [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 节)。
- [test4] 方法会创建 100 个线程(第 8 行,第 14.5 节)。
得到以下结果:

全部五项测试均成功。
在测试的第二种配置中:
- [DaoImpl] 类中 [saveOne] 方法的 wait 语句已被取消注释(第 83 行,第 14.4 节)。
- [test4] 方法创建了 2 个线程(第 8 行,第 14.5 节)。
得到以下结果:
![]() | ![]() |
[test4] 测试失败。我们创建了两个线程,每个线程的任务是将最初为 0 的某人 P 的子女数增加 1。因此,我们预期两个线程运行后应有 2 个子女,但实际只有 1 个。
让我们查看 [test4] 的屏幕日志,以了解发生了什么:
- 第 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节):
- 线程 #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] 层由以下类和接口组成:
![]()
- [IService] 是 [dao] 层暴露的接口
- [ServiceImpl] 是该接口的实现
[IService] 接口如下:
它与 [IDao] 接口完全一致。
[IService] 接口的 [ServiceImpl] 实现如下:
- 第 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] 的框架如下:
- 第 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] 层:
- 第 12 行:该线程与 [service] 层协作
我们正在使用导致 [dao] 层出现问题的配置运行测试:
- 我们在 [DaoImpl] 的 [saveOne] 方法中取消了 wait 语句的注释(第 83 行,第 14.4 节)。
- [test4] 方法会创建 100 个线程(第 65 行,第 14.7 节)。
所得结果如下:
![]() |
正是[service]层中各方法的同步,使得[test4]测试得以成功。
14.8. [Web]层
让我们回顾一下应用程序的三层架构:
![]() |
[Web] 层将向用户提供界面,以便他们管理人员组:
- 群组成员列表
- 将人员添加到组中
- 编辑组内成员
- 从组中移除成员
为此,它将依赖于 [service] 层,而该层又会调用 [DAO] 层。我们已经介绍了由 [web] 层管理的界面(第 14.1 节)。为了描述 web 层,我们将依次介绍以下内容:
- 其配置
- 其视图
- 其控制器
- 部分测试
14.8.1. Web 应用程序配置
该应用程序的 Eclipse 项目如下:

- 在 [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 页面
该页面用于显示人员列表:

其代码如下:
<%@ 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énom</th>
<th>Nom</th>
<th>Date de naissance</th>
<th>Marié</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> 标签:

该标签库的描述文件在第 3 行中定义。
- 第 37 行:用于添加新人员的 [Add] 链接指向 URL [/do/edit],与第 31 行的 [Edit] 链接相同。[id] 参数的值为 -1,表示这是添加操作而非编辑操作。
该视图用于显示添加新人员或修改现有人员的表单:
![]() |
[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é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é</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 | 相同 | |
其版本 | 相同 | |
名字 | 输入的姓名 | |
他的/她的姓氏 | 已输入的姓氏 | |
他的/她的出生日期 | 已输入的出生日期 | |
婚姻状况 | 已输入的婚姻状况 | |
子女数 | 已输入子女数 | |
空 | 一条错误消息,表示在点击[提交]按钮触发的POST请求过程中,添加或修改操作失败。若无错误,则为空。 | |
空 | 表示名字不正确——否则为空 | |
为空 | 报告姓氏不正确——否则为空 | |
空 | 表示出生日期不正确 – 否则为空 | |
为空 | 表示子女数量不正确——否则为空 |
- 第 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],因为它允许用户在不提交表单的情况下退出表单。
用于显示一个页面,提示应用程序未处理的异常已发生并已传播至 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 à 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 行:向用户提供返回人员列表的链接
该页面用于显示应用程序初始化错误报告,即在控制器Servlet的[init]方法执行过程中检测到的错误。例如,这可能是[web.xml]文件中缺少某个参数,如下例所示:

[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] 包中:
![]()
[Application] 控制器的骨架如下:
- 第 20–36 行:检索 [web.xml] 文件中指定的参数。
- 第 39–41 行:必须存在 [urlErrors] 参数,因为它指定了 [errors] 视图的 URL,该视图用于显示任何初始化错误。如果不存在该参数,应用程序将通过抛出 [ServletException] 异常(第 40 行)而终止。该异常将传播到 Web 服务器,并由 [web.xml] 文件中的 <error-page> 标签进行处理。 因此将显示 [exception.jsp] 视图:

上方的 [返回列表] 链接处于不可用状态。只要应用程序未被修改并重新加载,点击该链接将返回相同的响应。正如我们之前所见,这对其他类型的异常非常有用。
- 第 43 行:创建一个实现 [dao] 层的 [DaoImpl] 实例
- 第 44 行:初始化该实例(创建一个包含三人的初始列表)
- 第 46 行:创建一个实现 [service] 层的 [ServiceImpl] 实例
- 第 47 行:通过提供对 [dao] 层的引用来初始化 [service] 层
控制器初始化后,其方法将拥有指向 [service] 层的 [service] 引用(第 15 行),用于执行用户请求的操作。这些操作将被 [doGet] 方法拦截,并由控制器中的特定方法进行处理:
Url | HTTP 方法 | 控制器方法 |
GET | doListPeople | |
GET | doEditPerson | |
POST | doValidatePerson | |
GET | doDeletePerson |
[doGet] 方法
此方法的目的是将用户请求的操作处理路由到正确的方法。其代码如下:
- 第 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] 请求,该请求用于获取人员列表:

其代码如下:
- 第 5 行:我们从 [服务] 层请求该组中的人员列表,并将其存储在模型中,键名为 "people"。
- 第 7 行:显示第 14.8.2 节中描述的 [list.jsp] 视图。
[doDeletePerson] 方法
该方法处理 [GET /do/delete?id=XX] 请求,该请求用于删除 id=XX 的用户。URL [/do/delete?id=XX] 即 [list.jsp] 视图中 [Delete] 链接的地址:

其代码如下:
第 12 行显示了 [删除] 链接的 URL [/do/delete?id=XX]。处理此 URL 的 [doDeletePerson] 方法必须删除 id=XX 的用户,然后显示该组中更新后的人员列表。其代码如下:
- 第 5 行:正在处理的 URL 格式为 [/do/delete?id=XX]。我们从 [id] 参数中提取 [XX] 的值。
- 第 7 行:我们请求 [service] 层根据获取的 ID 删除该用户。 我们不进行任何验证。如果要删除的用户不存在,[dao]层会抛出一个异常,该异常会向上传播至[service]层。我们在此处的控制器中也不进行处理。因此,该异常将向上传播至Web服务器,根据配置,Web服务器将显示第14.8.2节中描述的[exception.jsp]页面:

- 第 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] 视图中 [编辑] 和 [添加] 链接所使用的地址:

其代码如下:
在第 11 行,我们可以看到 [编辑] 链接的 URL [/do/edit?id=XX],而在第 17 行,可以看到 [添加] 链接的 URL [/do/edit?id=-1]。doEditPersonne 方法必须显示 id=XX 人员的编辑表单,如果是添加操作,则显示一个空表单。
![]() | ![]() |
[doEditPerson] 方法的代码如下:
- 该 GET 请求的目标 URL 格式为 [/do/edit?id=XX]。在第 5 行,我们获取 [id] 的值。随后有两种情况:
- 如果 id 不等于 -1,则表示更新操作,需要显示一个预先填入待编辑人员信息的表单。在第 10 行,从 [service] 层获取该人员信息。
- 如果 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] 按钮触发:

让我们回顾一下上文视图中 HTML 表单的输入元素:
该 POST 请求包含参数 [firstName, lastName, dateOfBirth, spouse, numberOfChildren, id, version],并发送至 URL [/do/validate](第 1 行)。该请求由以下 [doValidatePerson] 方法处理:
- 第 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]:

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

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

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

用户 U1 进行修改并保存:
![]() |
用户 U2 也进行了同样的操作:
![]() |
用户 U2 通过表单上的 [取消] 链接返回用户列表:

他们找到了由 U1 修改过的 [Lemarchand]。现在 U2 删除了 [Lemarchand]:
![]() |
U1 仍保留自己的列表,并希望再次编辑 [Lemarchand]:
![]() |
U1点击[返回列表]链接查看情况:

他发现[Lemarchand]确实已经不在名单上了……
14.10. 结论
我们通过一个管理人员列表的基本示例,在三层架构[Web、业务逻辑、DAO]中实现了MVC架构。这使我们能够应用前几节中介绍的概念。在我们探讨的版本中,人员列表保存在内存中。接下来我们将探讨将该列表存储在数据库表中的版本。
但在那之前,我们将先介绍一种名为 Spring IoC 的工具,它有助于实现多层应用程序中各层的集成。

















