8. 案例研究
8.1. 简介
我们计划开发一个用于医疗诊所预约排程的Web应用程序。该问题已在文档《AngularJS / Spring 4 教程》(网址:[http://tahe.developpez.com/angularjs-spring4/])中有所探讨。该应用程序的架构如下:
![]() |
- 在[1]中,Web服务器向浏览器提供静态页面。这些页面包含一个基于MVC(模型-视图-控制器)模式构建的AngularJS应用程序。这里的模型同时涵盖视图和业务领域,由[服务]层表示;
- 用户通过浏览器与呈现的视图进行交互。用户的操作有时需要向 Spring 4 服务器 [2] 发起查询。服务器将处理该请求并返回 JSON(JavaScript 对象表示法)响应 [3]。该响应将用于更新呈现给用户的视图。
我们建议采用 Spring MVC 实现该应用程序的端到端开发。此时,架构如下所示:
![]() |
浏览器将连接到一个基于 Spring MVC 实现的 [Web 1] 应用程序,该应用程序将从同样基于 Spring MVC 实现的 [Web 2] Web 服务中获取数据。
8.2. 应用程序功能
欢迎读者通过测试来探索该应用程序的功能。我们将 [case-study] 文件夹中的 Maven 项目加载到 STS 中:
![]() | ![]() |
首先,我们将使用 [Wamp Server] 工具创建 MySQL 5 数据库 [dbrdvmedecins](参见第 9.5 节):
![]() |
- 在 [1] 中,从 WampServer 中选择 [phpMyAdmin] 工具;
- 在 [2] 中,选择 [导入] 选项;
![]() |
- 在 [3] 中,选择文件 [database/dbrdvmedecins.sql];
- 在 [4] 中,运行该文件;
- 在 [5] 中,数据库已创建。
接下来,我们需要启动连接到该数据库的服务器。这就是 [rdvmedecins-webjson-server] 项目
![]() |
该服务器可通过 URL [http://localhost:8080] 访问。该地址可在项目的 [application.properties] 文件中进行修改:
![]() |
server.port=8080
数据库访问凭据存储在 [rdvmedecins-metier-dao] 项目的 [DomainAndPersistenceConfig] 类中:
![]() |
// the MySQL data source
@Bean
public DataSource dataSource() {
BasicDataSource dataSource = new BasicDataSource();
dataSource.setDriverClassName("com.mysql.jdbc.Driver");
dataSource.setUrl("jdbc:mysql://localhost:3306/dbrdvmedecins");
dataSource.setUsername("root");
dataSource.setPassword("");
return dataSource;
}
如果您使用不同的凭据访问 MySQL 数据库,请在此处进行修改。
接下来,与之前的服务器一样,我们启动 [rdvmedecins-springthymeleaf-server] 服务器:
![]() | ![]() |
该服务器默认可通过 URL [http://localhost:8081] 访问。同样,这可以在项目的 [application.properties] 文件中进行配置:
server.port=8081
此外,该服务器必须知道连接数据库的服务器 URL。此配置位于上文的 [AppConfig] 类中:
// admin / admin
private final String USER_INIT = "admin";
private final String MDP_USER_INIT = "admin";
// racine service web / json
private final String WEBJSON_ROOT = "http://localhost:8080";
// timeout en millisecondes
private final int TIMEOUT = 5000;
// CORS
private final boolean CORS_ALLOWED=true;
如果第一个服务器是在 8080 以外的端口上启动的,则必须修改第 5 行。
然后,使用浏览器访问 URL [http://localhost:8081/boot.html]:
![]() |
- 在 [1] 中,即应用程序的登录页面;
- 在 [2] 和 [3] 中,填写希望使用该应用程序的用户名和密码。共有两个用户:admin/admin(用户名/密码),其角色为 (ADMIN);以及 user/user,其角色为 (USER)。只有 ADMIN 角色具有使用该应用程序的权限。USER 角色仅用于在此用例中演示服务器的响应;
- 在 [4] 中,用于连接服务器的按钮;
- 在 [5] 中,应用程序语言。有两个选项:法语(默认)和英语;
- 在 [6] 处,服务器 URL [rdvmedecins-springthymeleaf-server];
![]() |
- 在 [1] 处,您登录;
![]() |
- 登录后,您可以选择想就诊的医生[2]和预约日期[3]。一旦选定医生和日期,日历就会自动显示:
![]() |
- 医生日程表显示后,您可以预约具体时段 [5];
![]() |
- 在[6]中,选择就诊患者,并在[7]中确认选择;
![]() |
预约确认后,系统将自动返回日历页面,新预约已显示在日历中。该预约稍后可删除 [8]。
主要功能已介绍完毕。这些功能非常简单。最后我们来看看语言设置:

- 在[1]处,您可以切换语言,从法语切换为英语;
![]() |
- 在[2]中,界面切换为英文,包括日历;
8.3. 数据库
![]() |
该数据库(以下简称 [dbrdvmedecins])是一个 MySQL5 数据库,包含以下表:
![]() |
预约由以下表管理:
- [doctors]:包含该诊所的医生列表;
- [clients]:包含该诊所的患者列表;
- [slots]:包含每位医生的可用时段;
- [rv]:包含医生预约的列表。
[roles]、[users] 和 [users_roles] 表与身份验证相关。目前,我们暂不涉及这些表。管理预约的各表之间的关系如下:
![]() |
- 一个时段属于一位医生——一位医生拥有 0 个或多个时段;
- 一个预约通过医生的时段将客户和医生联系在一起;
- 每位客户拥有0个或多个预约;
- 一个时间段关联着0个或多个预约(在不同的日期)。
8.3.1. [DOCTORS] 表
该表包含由 [RdvMedecins] 应用程序管理的医生相关信息。
![]() | ![]() |
- ID:用于标识医生的编号——该表的主键
- VERSION:标识表中该行版本的数字。每次对该行进行修改时,该数字会递增 1。
- LAST_NAME:医生的姓
- FIRST_NAME:医生的名字
- TITLE:称谓(Ms.、Mrs.、Mr.)
8.3.2. [CLIENTS] 表
各医生的患者信息存储在 [CLIENTS] 表中:
![]() | ![]() |
- ID:用于标识客户的ID号——该表的主键
- VERSION:标识该表中该行版本的编号。每次对该行进行修改时,该编号会递增1。
- LAST NAME:客户的姓
- 名字:客户的名字
- 称谓:称谓(Ms.、Mrs.、Mr.)
8.3.3. [SLOTS] 表
该表列出了可预约的时间段:
![]() |
![]() |
- ID:时间段的ID号——该表的主键(第8行)
- VERSION:标识表中该行版本的编号。每次对该行进行修改时,该编号会递增1。
- DOCTOR_ID:标识该时段所属医生的ID号——作为DOCTORS表中ID列的外键。
- START_TIME:时间段的开始时间
- MSTART:时间段的开始分钟
- HFIN:时段结束时间
- MFIN:该时段的结束分钟
例如,[SLOTS] 表(参见上文 [1])的第二行表明,第 2 号时段于上午 8:20 开始,上午 8:40 结束,属于第 1 号医生(Marie PELISSIER 女士)。
8.3.4. [RV] 表
该表列出了每位医生已预约的就诊时间:
![]() |
- ID:预约的唯一标识符——主键
- DAY:预约日期
- SLOT_ID:预约时段——作为外键关联至 [SLOTS] 表的 [ID] 字段——同时确定时段及负责医生。
- CLIENT_ID:被预约客户的ID——作为[CLIENTS]表中[ID]字段的外键
该表对关联列(DAY、SLOT_ID)的值设置了唯一性约束:
如果 [RV] 表中某行 (DAY, SLOT_ID) 列的值为 (DAY1, SLOT_ID1),则该值不能出现在其他任何地方。否则,这意味着同一医生在同一时间被预约了两次。从 Java 编程的角度来看,发生这种情况时,数据库的 JDBC 驱动程序会抛出一个 SQLException。
ID 为 3 的行(参见上文 [1])表示,2006 年 8 月 23 日为第 20 个时段和第 4 号客户预订了一次预约。[SLOTS] 表告诉我们,第 20 个时段对应于下午 4:20 至 4:40,并属于第 1 号医生(Marie PELISSIER 女士)。 [CLIENTS] 表显示,客户 #4 是 Brigitte BISTROU 女士。
8.3.5. 创建数据库
要创建 [dbrdvmedecins] 数据库,本文档 [1-3] 的示例中提供了脚本 [dbrdvmedecins.sql]:
![]() |
我们使用 WampServer 提供的 [PhpMyAdmin] 工具:
![]() |
- 在 [1] 中,从 WampServer 中选择 [phpMyAdmin] 工具;
- 在 [2] 中,选择 [导入] 选项;
![]() |
- 在 [3] 中,选择 [database/dbrdvmedecins.sql] 文件;
- 在 [4] 中,运行该文件;
- 在 [5] 中,数据库已创建。
8.4. Web 服务 / JSON
![]() |
在上述架构中,我们将着手构建基于 Spring MVC 框架的 Web 服务 / JSON。我们将分几个步骤进行:
- 首先是 [业务] 和 [DAO](数据访问对象)层。此处我们将使用 Spring Data;
- 接下来,实现不带身份验证的 JSON Web 服务。此处将使用 Spring MVC;
- 然后,我们将使用 Spring Security 添加身份验证组件。
以下内容转载自文档 [http://tahe.developpez.com/angularjs-spring4/],并进行了少量修改。
8.4.1. Spring Data 简介
我们将使用 Spring 生态系统中的组件 Spring Data 来实现项目的 [DAO] 层。
![]() |
Spring 官网提供了大量入门教程 [http://spring.io/guides]。我们将利用其中一篇教程来介绍 Spring Data。为此,我们将使用 Spring Tool Suite (STS)。
![]() |
- 在 [1] 中,我们从 [spring.io/guides] 导入了一个教程;
![]() |
- 在 [2] 中,我们选择了 [Accessing Data JPA] 教程,该教程演示了如何使用 Spring Data 访问数据库;
- 在[3]中,我们选择了一个由Maven配置的项目;
- 在[4]中,该教程提供两种形式:[initial](初始版),这是一个空模板,需根据教程逐步填充;以及[complete](完整版),即教程的最终版本。我们选择后者;
- 在[5]中,您可以选择在浏览器中查看教程;
- 在 [6] 中,是最终项目。
8.4.1.1. 项目的 Maven 配置
该项目的 Maven 依赖项在 [pom.xml] 文件中进行配置:
<groupId>org.springframework</groupId>
<artifactId>gs-accessing-data-jpa</artifactId>
<version>0.1.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.1.10.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
</dependencies>
<properties>
<!-- use UTF-8 for everything -->
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<start-class>hello.Application</start-class>
</properties>
- 第 5–9 行:定义一个父 Maven 项目。该项目定义了项目的大部分依赖项。这些依赖项可能已经足够,此时无需添加额外依赖;也可能不够,此时会自动添加缺失的依赖;
- 第 12–15 行:定义对 [spring-boot-starter-data-jpa] 的依赖。该工件包含 Spring Data 类;
- 第 16–19 行:定义对 H2 数据库管理系统 (DBMS) 的依赖,它允许您创建和管理内存数据库。
让我们来看看这些依赖项提供的类:
![]() | ![]() | ![]() |
数量众多:
- 其中一些属于 Spring 生态系统(以 spring 开头的);
- 还有一些属于 Hibernate 生态系统(Hibernate、JBoss),我们在此使用其 JPA 实现;
- 还有一些是测试库(JUnit、Hamcrest);
- 还有的是日志库(log4j、logback、slf4j);
我们将保留所有这些。对于生产环境中的应用程序,应仅保留必要的组件。
在 [pom.xml] 文件的第 26 行,我们发现以下内容:
<start-class>hello.Application</start-class>
该行与以下几行相关联:
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
第 6–9 行:[spring-boot-maven-plugin] 允许您生成应用程序的可执行 JAR 文件。随后,[pom.xml] 文件的第 26 行指定了该 JAR 文件中的可执行类。
8.4.1.2. [JPA] 层
数据库访问通过 [JPA] 层(Java Persistence API)进行处理:
![]() |
![]() |
该应用程序功能基础,用于管理 [Customer] 实体。 [Customer] 类属于 [JPA] 层,其定义如下:
package hello;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@Entity
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private long id;
private String firstName;
private String lastName;
protected Customer() {
}
public Customer(String firstName, String lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
@Override
public String toString() {
return String.format("Customer[id=%d, firstName='%s', lastName='%s']", id, firstName, lastName);
}
}
客户拥有一个 ID [id]、一个名字 [firstName] 和一个姓氏 [lastName]。每个 [Customer] 实例代表数据库表中的一行。
- 第 8 行:JPA 注解,确保 [Customer] 实例的持久化操作(创建、读取、更新、删除)将由 JPA 实现管理。根据 Maven 依赖项,我们可以看出正在使用 JPA/Hibernate 实现;
- 第 11–12 行:JPA 注解,用于将 [id] 字段与 [Customer] 表的主键关联。第 12 行表明 JPA 实现将使用所用数据库管理系统(此处为 H2)特有的主键生成方法;
没有其他 JPA 注解。因此将使用默认值:
- [Customer] 表将以类名命名,即 [Customer];
- 该表的列名将采用类字段的名称:[id, firstName, lastName],需注意表列名不区分大小写;
请注意,此处未提及所使用的 JPA 实现。
8.4.1.3. [DAO] 层
![]() |
![]() |
[CustomerRepository] 类实现了 [DAO] 层。其代码如下:
package hello;
import java.util.List;
import org.springframework.data.repository.CrudRepository;
public interface CustomerRepository extends CrudRepository<Customer, Long> {
List<Customer> findByLastName(String lastName);
}
因此,这是一个接口而非类(第 7 行)。它继承了 [CrudRepository] 接口,这是一个 Spring Data 接口(第 5 行)。该接口由两种类型参数化:第一种是受管元素的类型,此处为 [Customer] 类型;第二种是受管元素的主键类型,此处为 [Long] 类型。 [CrudRepository] 接口如下所示:
package org.springframework.data.repository;
import java.io.Serializable;
@NoRepositoryBean
public interface CrudRepository<T, ID extends Serializable> extends Repository<T, ID> {
<S extends T> S save(S entity);
<S extends T> Iterable<S> save(Iterable<S> entities);
T findOne(ID id);
boolean exists(ID id);
Iterable<T> findAll();
Iterable<T> findAll(Iterable<ID> ids);
long count();
void delete(ID id);
void delete(T entity);
void delete(Iterable<? extends T> entities);
void deleteAll();
}
此接口定义了可在 JPA T 类型上执行的 CRUD(创建 – 读取 – 更新 – 删除)操作:
- 第 8 行:save 方法允许您将 T 实体持久化到数据库中。它使用 DBMS 分配的主键将实体持久化。该方法还允许您更新通过主键 id 标识的 T 实体。这两种操作的选择取决于主键 id 的值:如果为 null,则执行持久化操作;否则,执行更新操作;
- 第 10 行:与上文相同,但针对实体列表;
- 第 12 行:findOne 方法根据主键 id 检索实体 T;
- 第 22 行:delete 方法允许您删除一个通过主键 id 标识的实体 T;
- 第 24–28 行:[delete] 方法的变体;
- 第 16 行:[findAll] 方法检索所有已持久化的 T 实体;
- 第 18 行:与上文相同,但仅限于已提供标识符列表的实体;
让我们回到 [CustomerRepository] 接口:
package hello;
import java.util.List;
import org.springframework.data.repository.CrudRepository;
public interface CustomerRepository extends CrudRepository<Customer, Long> {
List<Customer> findByLastName(String lastName);
}
- 第 9 行允许您根据 [lastName] 检索 [Customer];
关于 [DAO] 层的内容就到这里。上述接口没有实现类,它由 [Spring Data] 在运行时自动生成。[CrudRepository] 接口的方法会自动实现。至于添加到 [CustomerRepository] 接口中的方法,则视情况而定。让我们回到 [Customer] 的定义:
private long id;
private String firstName;
private String lastName;
第 9 行中的方法由 [Spring Data] 自动实现,因为它引用了 [Customer] 类的 [lastName] 字段(第 3 行)。当遇到待实现接口中的 [findBySomething] 方法时,Spring Data 会使用以下 JPQL(Java 持久化查询语言)查询来实现它:
因此,类型 T 必须有一个名为 [something] 的字段。因此,该方法
将通过类似于以下内容的代码实现:
return [em].createQuery("select c from Customer c where c.lastName=:value").setParameter("value",lastName).getResultList()
其中 [em] 指的是 JPA 持久化上下文。只有当 [Customer] 类包含一个名为 [lastName] 的字段时,才可能实现这一点,而该类确实包含该字段。
综上所述,在简单场景下,Spring Data 允许我们通过一个简单的接口来实现 [DAO] 层。
8.4.1.4. [控制台] 层
![]() |
![]() |
[Application] 类的定义如下:
package hello;
import java.util.List;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.Configuration;
@Configuration
@EnableAutoConfiguration
public class Application {
public static void main(String[] args) {
ConfigurableApplicationContext context = SpringApplication.run(Application.class);
CustomerRepository repository = context.getBean(CustomerRepository.class);
// save a couple of customers
repository.save(new Customer("Jack", "Bauer"));
repository.save(new Customer("Chloe", "O'Brian"));
repository.save(new Customer("Kim", "Bauer"));
repository.save(new Customer("David", "Palmer"));
repository.save(new Customer("Michelle", "Dessler"));
// fetch all customers
Iterable<Customer> customers = repository.findAll();
System.out.println("Customers found with findAll():");
System.out.println("-------------------------------");
for (Customer customer : customers) {
System.out.println(customer);
}
System.out.println();
// fetch an individual customer by ID
Customer customer = repository.findOne(1L);
System.out.println("Customer found with findOne(1L):");
System.out.println("--------------------------------");
System.out.println(customer);
System.out.println();
// fetch customers by last name
List<Customer> bauers = repository.findByLastName("Bauer");
System.out.println("Customer found with findByLastName('Bauer'):");
System.out.println("--------------------------------------------");
for (Customer bauer : bauers) {
System.out.println(bauer);
}
context.close();
}
}
- 第 10 行:表明该类用于配置 Spring。Spring 的最新版本确实可以在 Java 中进行配置,而无需使用 XML。这两种方法可以同时使用。在带有 [Configuration] 注解的类代码中,通常会看到 Spring Bean,即待实例化的类定义。此处未定义任何 Bean。需要特别注意的是,在使用数据库管理系统 (DBMS) 时,必须定义各种 Spring Bean:
- 一个 [EntityManagerFactory],用于定义要使用的 JPA 实现,
- 一个 [DataSource],用于定义要使用的数据源,
- 一个 [TransactionManager],用于定义要使用的事务管理器;
而在此处,这些 Bean 均未被定义。
- 第 11 行:[EnableAutoConfiguration] 注解是来自 [Spring Boot] 项目的注解(第 5–6 行)。 该注解通过 [SpringApplication] 类(第 16 行)指示 Spring Boot 根据其类路径中找到的库来配置应用程序。由于 Hibernate 库位于类路径中,因此 [entityManagerFactory] Bean 将使用 Hibernate 实现。由于 H2 DBMS 库位于类路径中,因此 [dataSource] Bean 将使用 H2 实现。 在 [dataSource] Bean 中,我们还必须定义用户名和密码。在此,Spring Boot 将使用默认的 H2 管理员,该管理员没有密码。由于 [spring-tx] 库位于类路径中,因此将使用 Spring 的事务管理器。
此外,系统会扫描包含 [Application] 类的目录,查找 Spring 隐式识别的 Bean 或通过 Spring 注解显式定义的 Bean。 因此,系统将检查 [Customer] 和 [CustomerRepository] 类。由于前者带有 [@Entity] 注解,它将被归类为实体并由 Hibernate 管理;而后者继承了 [CrudRepository] 接口,因此将被注册为 Spring Bean。
让我们来分析代码的第 16–17 行:
ConfigurableApplicationContext context = SpringApplication.run(Application.class);
CustomerRepository repository = context.getBean(CustomerRepository.class);
- 第 16 行:执行 Spring Boot 项目中 [SpringApplication] 类的静态 [run] 方法。其参数是带有 [Configuration] 或 [EnableAutoConfiguration] 注解的类。随后将执行之前所述的所有操作。结果是一个 Spring 应用上下文,即一组由 Spring 管理的 Bean;
- 第 17 行:我们从该 Spring 上下文中请求一个实现 [CustomerRepository] 接口的 Bean。此处,我们获取由 Spring Data 生成的、用于实现该接口的类。
后续操作仅需调用实现 [CustomerRepository] 接口的 Bean 的方法。请注意第 50 行中已关闭上下文。控制台输出如下:
- 第 1-8 行:Spring Boot 项目徽标;
- 第 9 行:执行 [hello.Application] 类;
- 第 10 行:[AnnotationConfigApplicationContext] 是一个实现 Spring [ApplicationContext] 接口的类。它是一个 Bean 容器;
- 第 11 行:[entityManagerFactory] Bean 通过 Spring 类 [LocalContainerEntityManagerFactory] 实现;
- 第 15 行:出现了 [Hibernate]。这是所选的 JPA 实现;
- 第 19 行:Hibernate 方言是与数据库管理系统 (DBMS) 配合使用的 SQL 变体。此处的 [H2Dialect] 方言表明 Hibernate 将与 H2 数据库管理系统配合使用;
- 第 21–22 行:创建数据库。创建了 [CUSTOMER] 表。这意味着 Hibernate 已被配置为根据 JPA 定义生成表,本例中即 [Customer] 类的 JPA 定义;
- 第 27–31 行:插入五位客户;
- 第 33–35 行:接口 [findOne] 方法的执行结果;
- 第 37–40 行:[findByLastName] 方法的结果;
- 第 41 行及后续:来自 Spring 上下文闭包的日志。
8.4.1.5. Spring Data 项目的手动配置
我们将前一个项目复制为 [gs-accessing-data-jpa-2] 项目:
![]() |
在这个新项目中,我们将不依赖 Spring Boot 提供的自动配置,而是手动进行配置。如果默认配置不符合我们的需求,这种做法会很有用。
首先,我们将在 [pom.xml] 文件中指定必要的依赖项:
...
<dependencies>
<!-- Spring Core -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<!-- Spring transactions -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-orm</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aop</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<!-- Spring ORM -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
<version>4.1.2.RELEASE</version>
</dependency>
<!-- Spring Data -->
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-jpa</artifactId>
<version>1.7.1.RELEASE</version>
</dependency>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot</artifactId>
<version>1.1.10.RELEASE</version>
</dependency>
<!-- Hibernate -->
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-entitymanager</artifactId>
<version>4.3.4.Final</version>
</dependency>
<!-- H2 Database -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.178</version>
</dependency>
<!-- Commons DBCP -->
<dependency>
<groupId>commons-dbcp</groupId>
<artifactId>commons-dbcp</artifactId>
<version>1.4</version>
</dependency>
<dependency>
<groupId>commons-pool</groupId>
<artifactId>commons-pool</artifactId>
<version>1.6</version>
</dependency>
</dependencies>
...
</project>
- 第 2–18 行:Spring 核心库;
- 第 19–29 行:用于管理数据库事务的 Spring 库;
- 第 30–35 行:用于操作 ORM(对象关系映射器)的 Spring 库;
- 第 36–41 行:用于访问数据库的 Spring Data;
- 第 42–47 行:用于启动应用程序的 Spring Boot;
- 第 54–59 行:H2 数据库管理系统;
- 第 60–70 行:数据库通常与连接池配合使用,以避免反复打开和关闭连接。此处采用的实现是 [commons-dbcp];
仍在 [pom.xml] 中,我们将可执行类的名称更改为:
<properties>
...
<start-class>demo.console.Main</start-class>
</properties>
在新项目中,[Customer] 实体和 [CustomerRepository] 接口保持不变。我们将修改 [Application] 类,将其拆分为两个类:
- [Config],作为配置类:
- [Main],作为可执行类;
![]() |
可执行类 [Main] 与之前相同,不包含配置注解:
package demo.console;
import java.util.List;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext;
import demo.config.Config;
import demo.entities.Customer;
import demo.repositories.CustomerRepository;
public class Main {
public static void main(String[] args) {
ConfigurableApplicationContext context = SpringApplication.run(Config.class);
CustomerRepository repository = context.getBean(CustomerRepository.class);
...
context.close();
}
}
- 第 12 行:[Main] 类不再包含任何配置注解;
- 第 16 行:应用程序通过 Spring Boot 启动。[Config.class] 参数是项目的新配置类;
用于配置该项目的 [Config] 类如下:
package demo.config;
import javax.persistence.EntityManagerFactory;
import javax.sql.DataSource;
import org.apache.commons.dbcp.BasicDataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.Database;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
//@ComponentScan(basePackages = { "demo" })
//@EntityScan(basePackages = { "demo.entities" })
@EnableTransactionManagement
@EnableJpaRepositories(basePackages = { "demo.repositories" })
@Configuration
public class Config {
// h2 data source
@Bean
public DataSource dataSource() {
BasicDataSource dataSource = new BasicDataSource();
dataSource.setDriverClassName("org.h2.Driver");
dataSource.setUrl("jdbc:h2:./demo");
dataSource.setUsername("sa");
dataSource.setPassword("");
return dataSource;
}
// the provider JPA
@Bean
public JpaVendorAdapter jpaVendorAdapter() {
HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
hibernateJpaVendorAdapter.setShowSql(false);
hibernateJpaVendorAdapter.setGenerateDdl(true);
hibernateJpaVendorAdapter.setDatabase(Database.H2);
return hibernateJpaVendorAdapter;
}
// EntityManagerFactory
@Bean
public EntityManagerFactory entityManagerFactory(JpaVendorAdapter jpaVendorAdapter, DataSource dataSource) {
LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
factory.setJpaVendorAdapter(jpaVendorAdapter);
factory.setPackagesToScan("demo.entities");
factory.setDataSource(dataSource);
factory.afterPropertiesSet();
return factory.getObject();
}
// Transaction manager
@Bean
public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
JpaTransactionManager txManager = new JpaTransactionManager();
txManager.setEntityManagerFactory(entityManagerFactory);
return txManager;
}
}
- 第 22 行:[@Configuration] 注解将 [Config] 类定义为 Spring 配置类;
- 第 21 行:[@EnableJpaRepositories] 注解指定了 Spring Data [CrudRepository] 接口所在的目录。这些接口将作为 Spring 组件存在,并在其上下文中可用;
- 第 20 行:[@EnableTransactionManagement] 注解表明 [CrudRepository] 接口的方法必须在事务内执行;
- 第 19 行:[@EntityScan] 注解指定了应搜索 JPA 实体的目录。此处已将其注释掉,因为第 50 行已显式提供了该信息。若使用 [@EnableAutoConfiguration] 模式且 JPA 实体与配置类不在同一目录下,则应保留此注解;
- 第 18 行:[@ComponentScan] 注解允许您列出 Spring 组件应被搜索的目录。Spring 组件是指带有 Spring 注解(如 @Service、@Component、@Controller 等)的类。此处除了 [Config] 类中定义的组件外没有其他组件,因此该注解已被注释掉;
- 第 25–33 行:定义数据源,即 H2 数据库。正是第 25 行的 @Bean 注解,使得该方法创建的对象成为 Spring 管理的组件。此处的方法名可以是任意名称。但是,如果第 47 行的 EntityManagerFactory 不存在且通过自动配置定义,则该方法必须命名为 [dataSource];
- 第 29 行:数据库将命名为 [demo],并生成在项目文件夹中;
- 第 36–43 行:定义所使用的 JPA 实现,本例中为 Hibernate 实现。此处的方法名可以是任意名称;
- 第 39 行:不记录 SQL 日志;
- 第 30 行:若数据库不存在,则创建数据库;
- 第 46–54 行:定义将管理 JPA 持久化的 EntityManagerFactory。该方法必须命名为 [entityManagerFactory];
- 第 47 行:该方法接收两个参数,其类型分别为之前定义的两个 Bean。Spring 将构建这些 Bean 并作为方法参数进行注入;
- 第 49 行:设置要使用的 JPA 实现;
- 第 50 行:指定 JPA 实体的存储目录;
- 第 51 行:设置待管理的数据源;
- 第 57–62 行:事务管理器。该方法必须命名为 [transactionManager]。它接收第 46–54 行定义的 Bean 作为参数;
- 第 60 行:将事务管理器与 EntityManagerFactory 关联;
上述方法可以按任意顺序定义。
运行该项目将得到相同的结果。项目文件夹中会出现一个新文件,即 H2 数据库文件:
![]() |
最后,我们可以不用 Spring Boot 了。我们创建第二个可执行类 [Main2]:
![]() |
[Main2] 类的代码如下:
package demo.console;
import java.util.List;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import demo.config.Config;
import demo.entities.Customer;
import demo.repositories.CustomerRepository;
public class Main2 {
public static void main(String[] args) {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(Config.class);
CustomerRepository repository = context.getBean(CustomerRepository.class);
....
context.close();
}
}
- 第 15 行:Spring 类 [AnnotationConfigApplicationContext] 现在使用配置类 [Config]。如第 5 行所示,不再依赖 Spring Boot。
执行结果与之前相同。
8.4.1.6. 创建可执行归档文件
![]() |
- 在 [1] 中:创建一个运行时配置;
- 在 [2] 中:类型为 [Java 应用程序]
- 在 [3] 中:指定要运行的项目(使用“浏览”按钮);
- 在 [4] 中:指定要运行的类;
- 在 [5] 中:运行配置的名称——可以是任意名称;
![]() |
- 在 [6] 中:导出项目;
- 在 [7] 中:作为可执行 JAR 归档文件;
- 在 [8] 中:指定要创建的可执行文件的路径和名称;
- 在 [9] 中:在 [5] 中创建的运行配置的名称;
完成上述操作后,在包含可执行归档文件的文件夹中打开一个控制台:
该归档文件的运行方式如下:
.....\dist>java -jar gs-accessing-data-jpa-2.jar
控制台显示的结果如下:
8.4.1.7. 创建一个新的 Spring Data 项目
要创建 Spring Data 项目模板,请按照以下步骤操作:
![]() |
- 在 [1] 中,创建一个新项目;
- 在 [2] 中:选择 [Spring Starter Project];
- 生成的项目将是一个 Maven 项目。在 [3] 中,指定项目组名称;
- 在 [4] 中,指定项目构建时将生成的工件(此处为 JAR 文件)的名称;
- 在 [5] 中:指定项目中将生成的可执行类的包名;
- 在 [6] 中:项目的 Eclipse 名称——可以是任意名称(不必与 [4] 相同);
- 在 [7] 中:指定您正在创建一个包含 [JPA] 层的项目。此类项目所需的依赖项随后将被包含在 [pom.xml] 文件中;
![]() |
- 在 [8] 中:生成的项目;
[pom.xml] 文件包含 JPA 项目所需的依赖项:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.0.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
- 第 9–12 行:JPA 所需的依赖项——将包含 [Spring Data];
- 第 13–17 行:集成 Spring 的 JUnit 测试所需的依赖项;
可执行类 [Application] 本身不执行任何操作,但已预先配置:
package istia.st;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@Configuration
@ComponentScan
@EnableAutoConfiguration
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
测试类 [ApplicationTests] 没有任何具体功能,但已预先配置好:
package istia.st;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = Application.class)
public class ApplicationTests {
@Test
public void contextLoads() {
}
}
- 第 9 行:[@SpringApplicationConfiguration] 注解允许使用 [Application] 配置文件。因此,测试类将能够使用该文件中定义的所有 Bean;
- 第 8 行:[@RunWith] 注解实现了 Spring 与 JUnit 的集成:该类将能够作为 JUnit 测试执行。[@RunWith] 是 JUnit 注解(第 4 行),而 [SpringJUnit4ClassRunner] 类是 Spring 类(第 6 行);
现在我们已经有了一个 JPA 应用程序的骨架,可以继续完善它,编写我们的预约管理应用程序的服务器端持久化层。
8.4.2. Eclipse 服务器项目
![]() |
![]() |
该项目的主要组成部分如下:
- [pom.xml]:项目的 Maven 配置文件;
- [rdvmedecins.entities]:JPA 实体;
- [rdvmedecins.repositories]:用于访问 JPA 实体的 Spring Data 接口;
- [rdvmedecins.metier]:[业务]层;
- [rdvmedecins.domain]:由[业务]层处理的实体;
- [rdvmdecins.config]:持久化层的配置类;
- [rdvmedecins.boot]:一个基本的控制台应用程序;
8.4.3. Maven 配置
![]() | ![]() | ![]() |
该项目的 [pom.xml] 文件如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st.spring4.rdvmedecins</groupId>
<artifactId>rdvmedecins-metier-dao</artifactId>
<version>0.0.1-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.6.RELEASE</version>
</parent>
<dependencies>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Spring test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- driver JDBC / MySQL -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- Tomcat JDBC -->
<dependency>
<groupId>org.apache.tomcat</groupId>
<artifactId>tomcat-jdbc</artifactId>
</dependency>
<!-- mapper jSON -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Googe Guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>16.0.1</version>
</dependency>
</dependencies>
<properties>
<!-- use UTF-8 for everything -->
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<start-class>rdvmedecins.boot.Boot</start-class>
<java.version>1.8</java.version>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>http://repo.spring.io/libs-milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
<repository>
<id>org.jboss.repository.releases</id>
<name>JBoss Maven Release Repository</name>
<url>https://repository.jboss.org/nexus/content/repositories/releases</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>http://repo.spring.io/libs-milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</pluginRepository>
</pluginRepositories>
</project>
- 第 8–12 行:该项目依赖于父项目 [spring-boot-starter-parent]。对于父项目中已存在的依赖项,不指定版本。将使用父项目中定义的版本。其他依赖项按常规方式声明;
- 第 15–18 行:用于 Spring Data;
- 第 20–24 行:用于 JUnit 测试;
- 第 26–29 行:用于 Spring Security 库,其 [DAO] 层使用其中一个密码加密类;
- 第 31–34 行:用于 MySQL5 数据库管理系统(DBMS)的 JDBC 驱动程序;
- 第 36–39 行:Tomcat JDBC 连接池。连接池用于收集与数据库建立的开放连接。当代码需要打开连接时,会向连接池请求一个连接;当代码关闭连接时,该连接不会被关闭,而是返回给连接池。 所有这些操作在代码层面上都是透明进行的。性能因此得到提升,因为反复打开和关闭连接会消耗时间。在此,连接池在实例化时会建立一定数量的数据库连接。此后,除非池中存储的连接数量不足,否则不再进行连接的打开或关闭操作。在这种情况下,连接池会自动创建新的连接;
- 第 41–44 行:用于处理 JSON 的 Jackson 库;
- 第 46–50 行:Google Collections 库;
8.4.4. JPA 实体
![]() |
JPA 实体是封装数据库表中各行的对象。
![]() |
[AbstractEntity] 类是 [Person、Slot、Appointment] 实体的父类。其定义如下:
package rdvmedecins.entities;
import java.io.Serializable;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.MappedSuperclass;
import javax.persistence.Version;
@MappedSuperclass
public class AbstractEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
protected Long id;
@Version
protected Long version;
@Override
public int hashCode() {
int hash = 0;
hash += (id != null ? id.hashCode() : 0);
return hash;
}
// initialization
public AbstractEntity build(Long id, Long version) {
this.id = id;
this.version = version;
return this;
}
@Override
public boolean equals(Object entity) {
String class1 = this.getClass().getName();
String class2 = entity.getClass().getName();
if (!class2.equals(class1) || entity==null) {
return false;
}
AbstractEntity other = (AbstractEntity) entity;
return this.id.longValue() == other.id.longValue();
}
// getters and setters
..
}
- 第 11 行:[@MappedSuperclass] 注解表示被注解的类是 JPA [@Entity] 实体的父类;
- 第 15–17 行:为每个实体定义主键 [id]。正是 [@Id] 注解使 [id] 字段成为主键。[@GeneratedValue(strategy = GenerationType.IDENTITY)] 注解表示该主键的值由 DBMS 生成,并强制采用 [IDENTITY] 生成模式。 对于 MySQL 数据库管理系统,这意味着主键将由数据库管理系统使用 [AUTO_INCREMENT] 属性生成
- 第 18–19 行:定义每个实体的版本号。JPA 实现会在每次修改实体时递增该版本号。该数字用于防止两个不同用户同时更新同一实体:假设用户 U1 和 U2 读取版本号为 V1 的实体 E。U1 修改 E 并将该变更持久化到数据库中,此时版本号变为 V1+1。 随后 U2 修改 E 并将此更改持久化到数据库:由于其版本(V1)与数据库中的版本(V1+1)不一致,因此会抛出异常;
- 第 29–33 行:[build] 方法初始化 [AbstractEntity] 的两个字段。该方法返回对如此初始化的 [AbstractEntity] 实例的引用;
- 第 36–44 行:重写了该类的 [equals] 方法:如果两个实体具有相同的类名和相同的 id 标识符,则被视为相等;
- 第 21–26 行:重写类的 [equals] 方法时,必须同时重写其 [hashCode] 方法(第 21–26 行)。规则是:被 [equals] 方法判定为相等的两个实体,其 [hashCode] 也必须相同。在此,实体的 [hashCode] 等于其主键 [id]。 类中的 [hashCode] 方法特别用于管理其值为该类实例的字典;
[Person] 实体是 [Doctor] 和 [Client] 实体的父类:
package rdvmedecins.entities;
import javax.persistence.Column;
import javax.persistence.MappedSuperclass;
@MappedSuperclass
public class Personne extends AbstractEntity {
private static final long serialVersionUID = 1L;
// attributes of a person
@Column(length = 5)
private String titre;
@Column(length = 20)
private String nom;
@Column(length = 20)
private String prenom;
// default builder
public Personne() {
}
// builder with parameters
public Personne(String titre, String nom, String prenom) {
this.titre = titre;
this.nom = nom;
this.prenom = prenom;
}
// toString
public String toString() {
return String.format("Personne[%s, %s, %s, %s, %s]", id, version, titre, nom, prenom);
}
// getters and setters
...
}
- 第 6 行:[@MappedSuperclass] 注解表明被注解的类是 JPA 实体 [@Entity] 的父类;
- 第 10–15 行:一个人有一个头衔(Ms.)、一个名字(Jacqueline)和一个姓氏(Tatou)。未提供关于表列的信息。因此,默认情况下,它们将与字段具有相同的名称;
[Medecin] 实体如下:
package rdvmedecins.entities;
import javax.persistence.Entity;
import javax.persistence.Table;
@Entity
@Table(name = "medecins")
public class Medecin extends Personne {
private static final long serialVersionUID = 1L;
// default builder
public Medecin() {
}
// builder with parameters
public Medecin(String titre, String nom, String prenom) {
super(titre, nom, prenom);
}
public String toString() {
return String.format("Medecin[%s]", super.toString());
}
}
- 第 6 行:该类是一个 JPA 实体;
- 第 7 行:与数据库中的 [DOCTORS] 表相关联;
- 第 8 行:[Doctor] 实体继承自 [Person] 实体;
医生可以按以下方式初始化:
如果还想为其分配一个ID和版本号,可以这样写:
其中 [build] 方法是在 [AbstractEntity] 中定义的。
[Client] 实体如下:
package rdvmedecins.entities;
import javax.persistence.Entity;
import javax.persistence.Table;
@Entity
@Table(name = "clients")
public class Client extends Personne {
private static final long serialVersionUID = 1L;
// default builder
public Client() {
}
// builder with parameters
public Client(String titre, String nom, String prenom) {
super(titre, nom, prenom);
}
// identity
public String toString() {
return String.format("Client[%s]", super.toString());
}
}
- 第 6 行:该类是一个 JPA 实体;
- 第 7 行:与数据库中的 [CLIENTS] 表相关联;
- 第 8 行:[Client] 实体继承自 [Person] 实体;
[TimeSlot] 实体如下:
package rdvmedecins.entities;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
@Entity
@Table(name = "creneaux")
public class Creneau extends AbstractEntity {
private static final long serialVersionUID = 1L;
// characteristics of a RV slot
private int hdebut;
private int mdebut;
private int hfin;
private int mfin;
// a slot is linked to a doctor
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_medecin")
private Medecin medecin;
// foreign key
@Column(name = "id_medecin", insertable = false, updatable = false)
private long idMedecin;
// default builder
public Creneau() {
}
// builder with parameters
public Creneau(Medecin medecin, int hdebut, int mdebut, int hfin, int mfin) {
this.medecin = medecin;
this.hdebut = hdebut;
this.mdebut = mdebut;
this.hfin = hfin;
this.mfin = mfin;
}
// toString
public String toString() {
return String.format("Créneau[%d, %d, %d, %d:%d, %d:%d]", id, version, idMedecin, hdebut, mdebut, hfin, mfin);
}
// foreign key
public long getIdMedecin() {
return idMedecin;
}
// setters - getters
...
}
- 第 10 行:该类是一个 JPA 实体;
- 第 11 行:与数据库中的 [CRENEAUX] 表相关联;
- 第 12 行:[Creneau] 实体继承自 [AbstractEntity] 实体,因此继承了 [id] 和 [version] 字段;
- 第 16 行:时段开始时间 (14);
- 第 17 行:时段的开始分钟(20);
- 第 18 行:时段结束小时(14);
- 第 19 行:时段结束分钟(40);
- 第 22–24 行:拥有该时段的医生。[CRENEAUX] 表与 [MEDECINS] 表之间存在外键关系。第 22–24 行表示了这一关系;
- 第 22 行:[@ManyToOne] 注解表示多对一关系(时段与医生)。属性 [fetch=FetchType.LAZY] 表示当从持久化上下文中请求 [Slot] 实体且必须从数据库中检索时,不会同时返回 [Doctor] 实体。 此模式的优势在于,只有当开发者请求时才会检索 [Doctor] 实体。这既节省了内存,又提升了性能;
- 第 23 行:指定 [CRENEAUX] 表中外键列的名称;
- 第 27–28 行:[MEDECINS] 表的外键;
- 第 27 行:[ID_MEDECIN] 列已在第 23 行被使用。这意味着该列可能被以两种不同方式修改,而 JPA 标准不允许这种情况。因此,我们添加属性 [insertable = false, updatable = false],以确保该列为只读;
[Rv] 实体如下:
package rdvmedecins.entities;
import java.util.Date;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
import javax.persistence.Temporal;
import javax.persistence.TemporalType;
@Entity
@Table(name = "rv")
public class Rv extends AbstractEntity {
private static final long serialVersionUID = 1L;
// characteristics of an Rv
@Temporal(TemporalType.DATE)
private Date jour;
// an appointment is linked to a customer
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_client")
private Client client;
// an appointment is linked to a time slot
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_creneau")
private Creneau creneau;
// foreign keys
@Column(name = "id_client", insertable = false, updatable = false)
private long idClient;
@Column(name = "id_creneau", insertable = false, updatable = false)
private long idCreneau;
// default builder
public Rv() {
}
// with parameters
public Rv(Date jour, Client client, Creneau creneau) {
this.jour = jour;
this.client = client;
this.creneau = creneau;
}
// toString
public String toString() {
return String.format("Rv[%d, %s, %d, %d]", id, jour, client.id, creneau.id);
}
// foreign keys
public long getIdCreneau() {
return idCreneau;
}
public long getIdClient() {
return idClient;
}
// getters and setters
...
}
- 第 14 行:该类是一个 JPA 实体;
- 第 15 行:与数据库中的 [RV] 表相关联;
- 第 16 行:[Rv] 实体继承自 [AbstractEntity] 实体,因此继承了 [id] 和 [version] 字段;
- 第 21 行:预约日期;
- 第 20 行:Java 类型 [Date] 同时包含日期和时间。此处我们指定仅使用日期;
- 第 24–26 行:本次预约的客户。表 [RV] 通过外键关联表 [CLIENTS]。第 24–26 行表示这一关系;
- 第 29–31 行:预约时段。表 [RV] 与表 [CRENEAUX] 之间存在外键关系。第 29–31 行表示了这一关系;
- 第 34–35 行:外键 [idClient];
- 第 36–37 行:外键 [idClient];
8.4.5. [DAO] 层
![]() |
我们将使用 Spring Data 实现 [DAO] 层:
![]() |
[DAO] 层通过四个 Spring Data 接口实现:
- [ClientRepository]:提供对 [Client] JPA 实体的访问;
- [CreneauRepository]:提供对 [Creneau] JPA 实体的访问;
- [MedecinRepository]:提供对 [Medecin] JPA 实体的访问;
- [RvRepository]:提供对 [Rv] JPA 实体的访问;
[MedecinRepository] 接口如下:
package rdvmedecins.repositories;
import org.springframework.data.repository.CrudRepository;
import rdvmedecins.entities.Medecin;
public interface MedecinRepository extends CrudRepository<Medecin, Long> {
}
- 第 7 行:[MedecinRepository] 接口仅继承了 [CrudRepository] 接口中的方法,未添加任何其他方法;
[ClientRepository] 接口如下:
package rdvmedecins.repositories;
import org.springframework.data.repository.CrudRepository;
import rdvmedecins.entities.Client;
public interface ClientRepository extends CrudRepository<Client, Long> {
}
- 第 7 行:[ClientRepository] 接口仅继承了 [CrudRepository] 接口中的方法,未添加任何其他方法;
[CreneauRepository] 接口如下:
package rdvmedecins.repositories;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import rdvmedecins.entities.Creneau;
public interface CreneauRepository extends CrudRepository<Creneau, Long> {
// liste des créneaux horaires d'un médecin
@Query("select c from Creneau c where c.medecin.id=?1")
Iterable<Creneau> getAllCreneaux(long idMedecin);
}
- 第 8 行:[CreneauRepository] 接口继承了 [CrudRepository] 接口的方法;
- 第 10-11 行:[getAllCreneaux] 方法用于获取医生的可用时段;
- 第 11 行:参数是医生的 ID。结果是一个以 [Iterable<Creneau>] 对象形式表示的时间段列表;
- 第 10 行:[@Query] 注解用于指定实现该方法的 JPQL(Java 持久化查询语言)查询。参数 [?1] 将被替换为该方法的 [idMedecin] 参数;
[RvRepository] 接口如下:
package rdvmedecins.repositories;
import java.util.Date;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import rdvmedecins.entities.Rv;
public interface RvRepository extends CrudRepository<Rv, Long> {
@Query("select rv from Rv rv left join fetch rv.client c left join fetch rv.creneau cr where cr.medecin.id=?1 and rv.jour=?2")
Iterable<Rv> getRvMedecinJour(long idMedecin, Date jour);
}
- 第 10 行:[RvRepository] 接口继承了 [CrudRepository] 接口的方法;
- 第 12–13 行:[getRvMedecinJour] 方法用于获取指定日期某位医生的预约信息;
- 第 13 行:参数为医生的 ID 和日期。结果是一个以 [Iterable<Rv>] 对象形式呈现的预约列表;
- 第 12 行:[@Query] 注解允许您指定实现该方法的 JPQL 查询。参数 [?1] 将被替换为方法的 [idMedecin] 参数,参数 [?2] 将被替换为方法的 [jour] 参数。以下 JPQL 查询是不够的:
因为 Rv 类的字段(类型分别为 [Client] 和 [Creneau])是在 [FetchType.LAZY] 模式下检索的,这意味着必须显式请求才能获取它们。在 JPQL 查询中,这通过 [left join fetch entity] 语法实现,该语法要求与外键引用的表执行连接操作,以便检索被引用的实体;
8.4.6. [业务]层
![]() |
![]() |
- [IMetier] 是 [业务] 层的接口,而 [Metier] 是其实现;
- [Doctor'sDailySchedule] 和 [Doctor'sDailyTimeSlot] 是两个业务实体;
8.4.6.1. 实体
[CreneauMedecinJour] 实体将时间段与该时间段内预订的任何预约相关联:
package rdvmedecins.domain;
import java.io.Serializable;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Rv;
public class CreneauMedecinJour implements Serializable {
private static final long serialVersionUID = 1L;
// fields
private Creneau creneau;
private Rv rv;
// manufacturers
public CreneauMedecinJour() {
}
public CreneauMedecinJour(Creneau creneau, Rv rv) {
this.creneau=creneau;
this.rv=rv;
}
// toString
@Override
public String toString() {
return String.format("[%s %s]", creneau, rv);
}
// getters and setters
...
}
- 第 12 行:时间段;
- 第13行:预约(如有)——否则为null;
[AgendaMedecinJour] 实体表示医生某一天的日程安排,即其预约列表:
package rdvmedecins.domain;
import java.io.Serializable;
import java.text.SimpleDateFormat;
import java.util.Date;
import rdvmedecins.entities.Medecin;
public class AgendaMedecinJour implements Serializable {
private static final long serialVersionUID = 1L;
// fields
private Medecin medecin;
private Date jour;
private CreneauMedecinJour[] creneauxMedecinJour;
// manufacturers
public AgendaMedecinJour() {
}
public AgendaMedecinJour(Medecin medecin, Date jour, CreneauMedecinJour[] creneauxMedecinJour) {
this.medecin = medecin;
this.jour = jour;
this.creneauxMedecinJour = creneauxMedecinJour;
}
public String toString() {
StringBuffer str = new StringBuffer("");
for (CreneauMedecinJour cr : creneauxMedecinJour) {
str.append(" ");
str.append(cr.toString());
}
return String.format("Agenda[%s,%s,%s]", medecin, new SimpleDateFormat("dd/MM/yyyy").format(jour), str.toString());
}
// getters and setters
...
}
- 第 13 行:医生;
- 第14行:日历中的日期;
- 第15行:他们的可用时段,无论是否预约;
8.4.6.2. 该服务
package rdvmedecins.metier;
import java.util.Date;
import java.util.List;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Rv;
public interface IMetier {
// customer list
public List<Client> getAllClients();
// list of doctors
public List<Medecin> getAllMedecins();
// list of physician slots
public List<Creneau> getAllCreneaux(long idMedecin);
// list of doctor's appointments on a given day
public List<Rv> getRvMedecinJour(long idMedecin, Date jour);
// find a customer identified by its id
public Client getClientById(long id);
// find a customer identified by its id
public Medecin getMedecinById(long id);
// find an Rv identified by its id
public Rv getRvById(long id);
// find a time slot identified by its id
public Creneau getCreneauById(long id);
// add a RV
public Rv ajouterRv(Date jour, Creneau créneau, Client client);
// delete a RV
public void supprimerRv(Rv rv);
// job
public AgendaMedecinJour getAgendaMedecinJour(long idMedecin, Date jour);
}
注释说明了每个方法的作用。
[IMetier] 接口的实现是以下 [Metier] 类:
package rdvmedecins.metier;
import java.util.Date;
import java.util.Hashtable;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.domain.CreneauMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Rv;
import rdvmedecins.repositories.ClientRepository;
import rdvmedecins.repositories.CreneauRepository;
import rdvmedecins.repositories.MedecinRepository;
import rdvmedecins.repositories.RvRepository;
import com.google.common.collect.Lists;
@Service("métier")
public class Metier implements IMetier {
// repositories
@Autowired
private MedecinRepository medecinRepository;
@Autowired
private ClientRepository clientRepository;
@Autowired
private CreneauRepository creneauRepository;
@Autowired
private RvRepository rvRepository;
// interface implementation
@Override
public List<Client> getAllClients() {
return Lists.newArrayList(clientRepository.findAll());
}
@Override
public List<Medecin> getAllMedecins() {
return Lists.newArrayList(medecinRepository.findAll());
}
@Override
public List<Creneau> getAllCreneaux(long idMedecin) {
return Lists.newArrayList(creneauRepository.getAllCreneaux(idMedecin));
}
@Override
public List<Rv> getRvMedecinJour(long idMedecin, Date jour) {
return Lists.newArrayList(rvRepository.getRvMedecinJour(idMedecin, jour));
}
@Override
public Client getClientById(long id) {
return clientRepository.findOne(id);
}
@Override
public Medecin getMedecinById(long id) {
return medecinRepository.findOne(id);
}
@Override
public Rv getRvById(long id) {
return rvRepository.findOne(id);
}
@Override
public Creneau getCreneauById(long id) {
return creneauRepository.findOne(id);
}
@Override
public Rv ajouterRv(Date jour, Creneau créneau, Client client) {
return rvRepository.save(new Rv(jour, client, créneau));
}
@Override
public void supprimerRv(Rv rv) {
rvRepository.delete(rv.getId());
}
public AgendaMedecinJour getAgendaMedecinJour(long idMedecin, Date jour) {
...
}
}
- 第 24 行:[@Service] 注解是 Spring 注解,它将被注解的类设为 Spring 管理的组件。您可以为组件命名,也可以不命名。此处的组件命名为 [business];
- 第 25 行:[Metier] 类实现了 [IMetier] 接口;
- 第 28 行:[@Autowired] 注解是 Spring 注解。被此注解标注的字段值将由 Spring 通过注入指定类型或名称的 Spring 组件引用来初始化。此处 [@Autowired] 注解未指定名称,因此将执行基于类型的注入;
- 第 29 行:[medecinRepository] 字段将通过 [MedecinRepository] 类型的 Spring 组件引用进行初始化。该引用指向 Spring Data 生成的、用于实现我们之前介绍的 [MedecinRepository] 接口的类;
- 第 30–35 行:此过程将针对前面讨论的其他三个接口重复进行;
- 第 39–41 行:实现 [getAllClients] 方法;
- 第 40 行:我们使用 [ClientRepository] 接口的 [findAll] 方法。该方法返回 [Iterable<Client>] 类型,我们使用静态方法 [Lists.newArrayList] 将其转换为 [List<Client>]。[Lists] 类定义在 Google Guava 库中。在 [pom.xml] 中,已引入此依赖项:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>16.0.1</version>
</dependency>
- 第 38–86 行:[IMetier] 接口的方法使用 [DAO] 层的类进行实现;
只有第88行的方法是[business]层特有的。将其放置在此处是因为它执行的业务逻辑超出了简单数据访问的范畴。如果没有这个方法,就没有理由创建[business]层。[getAgendaMedecinJour]方法如下:
public AgendaMedecinJour getAgendaMedecinJour(long idMedecin, Date jour) {
// list of doctor's time slots
List<Creneau> creneauxHoraires = getAllCreneaux(idMedecin);
// list of bookings for the same doctor on the same day
List<Rv> reservations = getRvMedecinJour(idMedecin, jour);
// a dictionary is created from the Rvs taken
Map<Long, Rv> hReservations = new Hashtable<Long, Rv>();
for (Rv resa : reservations) {
hReservations.put(resa.getCreneau().getId(), resa);
}
// create the agenda for the requested day
AgendaMedecinJour agenda = new AgendaMedecinJour();
// the doctor
agenda.setMedecin(getMedecinById(idMedecin));
// the day
agenda.setJour(jour);
// reservation slots
CreneauMedecinJour[] creneauxMedecinJour = new CreneauMedecinJour[creneauxHoraires.size()];
agenda.setCreneauxMedecinJour(creneauxMedecinJour);
// filling reservation slots
for (int i = 0; i < creneauxHoraires.size(); i++) {
// line i agenda
creneauxMedecinJour[i] = new CreneauMedecinJour();
// time slot
Creneau créneau = creneauxHoraires.get(i);
long idCreneau = créneau.getId();
creneauxMedecinJour[i].setCreneau(créneau);
// is the slot free or reserved?
if (hReservations.containsKey(idCreneau)) {
// the slot is occupied - we note the resa
Rv resa = hReservations.get(idCreneau);
creneauxMedecinJour[i].setRv(resa);
}
}
// we return the result
return agenda;
}
建议读者阅读注释。算法如下:
- 检索指定医生的所有时段;
- 检索指定日期内该医生的所有预约;
- 利用这两项信息,我们可以判断某个时段是空闲还是已被预约;
8.4.7. Spring 项目配置
![]() |
[DomainAndPersistenceConfig] 类用于配置整个项目:
package rdvmedecins.config;
import javax.persistence.EntityManagerFactory;
import org.apache.tomcat.jdbc.pool.DataSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.Database;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
@EnableJpaRepositories(basePackages = { "rdvmedecins.repositories", "rdvmedecins.security" })
@ComponentScan(basePackages = { "rdvmedecins" })
public class DomainAndPersistenceConfig {
// JPA entity packages
public final static String[] ENTITIES_PACKAGES = { "rdvmedecins.entities", "rdvmedecins.security" };
// the MySQL data source
@Bean
public DataSource dataSource() {
// data source TomcatJdbc
DataSource dataSource = new DataSource();
// configuration JDBC
dataSource.setDriverClassName("com.mysql.jdbc.Driver");
dataSource.setUrl("jdbc:mysql://localhost:3306/dbrdvmedecins");
dataSource.setUsername("root");
dataSource.setPassword("");
// initially open connections
dataSource.setInitialSize(5);
// result
return dataSource;
}
// provider JPA is Hibernate
@Bean
public JpaVendorAdapter jpaVendorAdapter() {
HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
hibernateJpaVendorAdapter.setShowSql(false);
hibernateJpaVendorAdapter.setGenerateDdl(false);
hibernateJpaVendorAdapter.setDatabase(Database.MYSQL);
return hibernateJpaVendorAdapter;
}
// EntityManagerFactory
@Bean
public EntityManagerFactory entityManagerFactory(JpaVendorAdapter jpaVendorAdapter, DataSource dataSource) {
LocalContainerEntityManagerFactoryBean factory = new LocalContainerEntityManagerFactoryBean();
factory.setJpaVendorAdapter(jpaVendorAdapter);
factory.setPackagesToScan(ENTITIES_PACKAGES);
factory.setDataSource(dataSource);
factory.afterPropertiesSet();
return factory.getObject();
}
// Transaction manager
@Bean
public PlatformTransactionManager transactionManager(EntityManagerFactory entityManagerFactory) {
JpaTransactionManager txManager = new JpaTransactionManager();
txManager.setEntityManagerFactory(entityManagerFactory);
return txManager;
}
}
- 第 17 行:该类是一个 Spring 配置类;
- 第 18 行:包含 Spring Data [CrudRepository] 接口的包。这些接口将被添加到 Spring 上下文中;
- 第 19 行:将 [rdvmedecins] 包中所有带有 Spring 注解的类及其子类添加到 Spring 上下文中。在 [rdvmedecins.metier] 包中,带有 [@Service] 注解的 [Metier] 类将被查找并添加到 Spring 上下文中;
- 第 26–39 行:配置 Tomcat JDBC 连接池(第 5 行);
- 第 36 行:连接池默认将保持 5 个打开的连接。此行仅为说明之用。在我们的场景中,1 个连接就足够了。如果 [DAO] 层将被多个线程使用,则此行必不可少。这种情况将在后续出现,届时 [DAO] 层将作为 Web 应用程序的基础,而该应用程序本质上支持同时为多个用户提供服务;
- 第 42–49 行:所使用的 JPA 实现是 Hibernate 实现;
- 第 45 行:不记录 SQL 日志;
- 第 46 行:不进行表重建;
- 第 47 行:使用的数据库管理系统(DBMS)是 MySQL;
- 第 53–61 行:定义 JPA 层的 EntityManagerFactory。通过该对象,我们可以获取 [EntityManager] 对象,用于执行 JPA 操作;
- 第 57 行:指定 JPA 实体所在的包;
- 第 58 行:指定要连接到 JPA 层的数据源;
- 第 64–69 行:与前面的 EntityManagerFactory 关联的事务管理器。默认情况下,Spring Data 的 [CrudRepository] 接口的方法会在事务中运行。事务在进入方法前启动,并在退出方法后(通过提交或回滚)完成;
8.4.8. [业务]层的测试
[rdvmedecins.tests.Metier] 类是一个 Spring/JUnit 4 测试类:
package rdvmedecins.tests;
import java.text.ParseException;
import java.util.Date;
import java.util.List;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import rdvmedecins.config.DomainAndPersistenceConfig;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Rv;
import rdvmedecins.metier.IMetier;
@SpringApplicationConfiguration(classes = DomainAndPersistenceConfig.class)
@RunWith(SpringJUnit4ClassRunner.class)
public class Metier {
@Autowired
private IMetier métier;
@Test
public void test1(){
// customer display
List<Client> clients = métier.getAllClients();
display("Liste des clients :", clients);
// physician display
List<Medecin> medecins = métier.getAllMedecins();
display("Liste des médecins :", medecins);
// display doctor's slots
Medecin médecin = medecins.get(0);
List<Creneau> creneaux = métier.getAllCreneaux(médecin.getId());
display(String.format("Liste des créneaux du médecin %s", médecin), creneaux);
// list of doctor's appointments on a given day
Date jour = new Date();
display(String.format("Liste des rv du médecin %s, le [%s]", médecin, jour), métier.getRvMedecinJour(médecin.getId(), jour));
// add a RV to the list
Rv rv = null;
Creneau créneau = creneaux.get(2);
Client client = clients.get(0);
System.out.println(String.format("Ajout d'un Rv le [%s] dans le créneau %s pour le client %s", jour, créneau,
client));
rv = métier.ajouterRv(jour, créneau, client);
// check
Rv rv2 = métier.getRvById(rv.getId());
Assert.assertEquals(rv, rv2);
display(String.format("Liste des Rv du médecin %s, le [%s]", médecin, jour), métier.getRvMedecinJour(médecin.getId(), jour));
// add a RV in the same slot on the same day
// must trigger an exception
System.out.println(String.format("Ajout d'un Rv le [%s] dans le créneau %s pour le client %s", jour, créneau,
client));
Boolean erreur = false;
try {
rv = métier.ajouterRv(jour, créneau, client);
System.out.println("Rv ajouté");
} catch (Exception ex) {
Throwable th = ex;
while (th != null) {
System.out.println(ex.getMessage());
th = th.getCause();
}
// we note the error
erreur = true;
}
// check for errors
Assert.assertTrue(erreur);
// RV list
display(String.format("Liste des Rv du médecin %s, le [%s]", médecin, jour), métier.getRvMedecinJour(médecin.getId(), jour));
// calendar display
AgendaMedecinJour agenda = métier.getAgendaMedecinJour(médecin.getId(), jour);
System.out.println(agenda);
Assert.assertEquals(rv, agenda.getCreneauxMedecinJour()[2].getRv());
// delete a RV
System.out.println("Suppression du Rv ajouté");
métier.supprimerRv(rv);
// check
rv2 = métier.getRvById(rv.getId());
Assert.assertNull(rv2);
display(String.format("Liste des Rv du médecin %s, le [%s]", médecin, jour), métier.getRvMedecinJour(médecin.getId(), jour));
}
// utility method - displays items in a collection
private void display(String message, Iterable<?> elements) {
System.out.println(message);
for (Object element : elements) {
System.out.println(element);
}
}
}
- 第 22 行:[@SpringApplicationConfiguration] 注解允许使用前面讨论过的 [DomainAndPersistenceConfig] 配置文件。因此,测试类可以利用该文件中定义的所有 Bean;
- 第 23 行:[@RunWith] 注解实现了 Spring 与 JUnit 的集成:该类可作为 JUnit 测试执行。[@RunWith] 是 JUnit 注解(第 9 行),而 [SpringJUnit4ClassRunner] 类是 Spring 类(第 12 行);
- 第 26–27 行:将 [business] 层的引用注入到测试类中;
- 许多测试仅是视觉测试:
- 第 32–33 行:客户端列表;
- 第 35–36 行:医生列表;
- 第 39–40 行:医生的时间段列表;
- 第 43 行:某位医生的预约列表;
- 第 50 行:添加新预约。[addAppt] 方法返回包含附加信息(即主键 id)的预约;
- 第 53 行:使用该主键在数据库中搜索预约;
- 第 54 行:我们验证正在搜索的预约与找到的预约是否相同。请注意,[Rv] 实体的 [equals] 方法已被重新定义:如果两个预约的 id 相同,则它们相等。这里,这表明添加的预约确实已插入数据库;
- 第 61–73 行:我们尝试第二次添加同一条预约。由于存在唯一性约束,此操作应被数据库管理系统(DBMS)拒绝:
CREATE TABLE IF NOT EXISTS `rv` (
`ID` bigint(20) NOT NULL AUTO_INCREMENT,
`JOUR` date NOT NULL,
`ID_CLIENT` bigint(20) NOT NULL,
`ID_CRENEAU` bigint(20) NOT NULL,
`VERSION` int(11) NOT NULL DEFAULT '0',
PRIMARY KEY (`ID`),
UNIQUE KEY `UNQ1_RV` (`JOUR`,`ID_CRENEAU`),
KEY `FK_RV_ID_CRENEAU` (`ID_CRENEAU`),
KEY `FK_RV_ID_CLIENT` (`ID_CLIENT`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_swedish_ci AUTO_INCREMENT=60 ;
上文第 8 行规定 [DAY, SLOT_ID] 的组合必须唯一,这可防止在同一天的同一时间段内安排两个预约。
- 第 73 行:我们验证是否确实发生了异常;
- 第 77 行:我们检索刚刚为其添加了预约的医生的日历;
- 第 79 行:我们验证所添加的预约是否确实出现在其日程中;
- 第 82 行:删除已添加的预约;
- 第 84 行:从数据库中检索已删除的预约;
- 第 85 行:检查是否获取到了空指针,这表明所查找的约会不存在;
测试运行成功:
![]() |
8.4.9. 控制台程序
![]() |
该控制台程序非常简单。它演示了如何检索外键:
package rdvmedecins.boot;
import java.text.SimpleDateFormat;
import java.util.Date;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ConfigurableApplicationContext;
import rdvmedecins.config.DomainAndPersistenceConfig;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Rv;
import rdvmedecins.metier.IMetier;
public class Boot {
// the boot
public static void main(String[] args) {
// prepare the configuration
SpringApplication app = new SpringApplication(DomainAndPersistenceConfig.class);
app.setLogStartupInfo(false);
// launch it
ConfigurableApplicationContext context = app.run(args);
// business
IMetier métier = context.getBean(IMetier.class);
try {
// add a RV to the list
Date jour = new Date();
System.out.println(String.format("Ajout d'un Rv le [%s] dans le créneau 1 pour le client 1", new SimpleDateFormat("dd/MM/yyyy").format(jour)));
Client client = (Client) new Client().build(1L, 1L);
Creneau créneau = (Creneau) new Creneau().build(1L, 1L);
Rv rv = métier.ajouterRv(jour, créneau, client);
System.out.println(String.format("Rv ajouté = %s", rv));
// check
créneau = métier.getCreneauById(1L);
long idMedecin = créneau.getIdMedecin();
display("Liste des rendez-vous", métier.getRvMedecinJour(idMedecin, jour));
} catch (Exception ex) {
System.out.println("Exception : " + ex.getCause());
}
// closing the Spring context
context.close();
}
// utility method - displays items in a collection
private static <T> void display(String message, Iterable<T> elements) {
System.out.println(message);
for (T element : elements) {
System.out.println(element);
}
}
}
该程序添加一个预约,然后验证该预约是否已成功添加。
- 第 19 行:[SpringApplication] 类将使用 [DomainAndPersistenceConfig] 配置类;
- 第 20 行:抑制应用程序启动日志;
- 第 22 行:执行 [SpringApplication] 类。它返回一个 Spring 上下文,即已注册 Bean 的列表;
- 第 24 行:获取实现 [IMetier] 接口的 Bean 的引用。因此,这是对 [业务] 层的引用;
- 第 27–31 行:为今天添加一个新预约,客户 #1 安排在时段 #1。客户和时段是从零开始创建的,以演示仅使用标识符。我们在此初始化了版本号,但也可以使用任何值。此处并未使用该值;
- 第 34 行:我们需要知道哪个医生负责第 1 个时段。为此,我们需要查询数据库中的第 1 个时段。由于当前处于 [FetchType.LAZY] 模式,系统不会随时段一并返回医生。不过,我们在 [Creneau] 实体中特意添加了 [idMedecin] 字段,用于获取医生的主键;
- 第 35 行:我们获取医生的主键;
- 第 36 行:我们显示该医生的预约列表;
控制台输出如下:
8.4.10. 日志管理
控制台日志通过两个文件进行配置:[application.properties] 和 [logback.xml] [1]:
![]() |
[application.properties] 文件由 Spring Boot 框架使用。它允许您定义各种设置,以覆盖 Spring Boot 使用的默认值(http://docs.spring.io/spring-boot/docs/current/reference/html/common-application-properties.html)。以下是其内容:
logging.level.org.hibernate=OFF
spring.main.show-banner=false
- 第 1 行:控制 Hibernate 的日志级别——此处不记录日志
- 第 2 行:控制 Spring Boot 欢迎信息的显示——此处不显示欢迎信息
[logback.xml] 文件是 [logback] 日志框架的配置文件 [2]:
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- encoders are by default assigned the type ch.qos.logback.classic.encoder.PatternLayoutEncoder -->
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- log level control -->
<root level="info"> <!-- off, info, debug, warn -->
<appender-ref ref="STDOUT" />
</root>
</configuration>
- 第 9 行控制着日志的总体级别——此处记录 [info] 级别的日志;
这将产生以下结果:
如果我们将 Hibernate 的日志级别设置为 [info](且不更改其他任何设置):
logging.level.org.hibernate=INFO
spring.main.show-banner=false
这将产生以下结果:
如果我们将日志级别设置为 [debug](且不更改其他任何内容):
logging.level.org.hibernate=DEBUG
spring.main.show-banner=false
这将产生以下结果:
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Eagerly caching bean 'clientRepository' to allow for resolving potential circular references
10:35:13.522 [main] DEBUG o.s.b.f.annotation.InjectionMetadata - Processing injected element of bean 'clientRepository': PersistenceElement for public void org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean.setEntityManager(javax.persistence.EntityManager)
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Creating instance of bean '(inner bean)#6a2eea2a'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Creating instance of bean '(inner bean)#b967222'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Invoking afterPropertiesSet() on bean with name '(inner bean)#b967222'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Finished creating instance of bean '(inner bean)#b967222'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Finished creating instance of bean '(inner bean)#6a2eea2a'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Creating instance of bean '(inner bean)#1ba05e38'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Finished creating instance of bean '(inner bean)#1ba05e38'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Creating instance of bean '(inner bean)#6c298dc'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Returning cached instance of singleton bean 'entityManagerFactory'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Finished creating instance of bean '(inner bean)#6c298dc'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Returning cached instance of singleton bean 'jpaMappingContext'
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Invoking afterPropertiesSet() on bean with name 'clientRepository'
10:35:13.522 [main] DEBUG o.s.o.j.SharedEntityManagerCreator$SharedEntityManagerInvocationHandler - Creating new EntityManager for shared EntityManager invocation
10:35:13.522 [main] DEBUG o.s.o.jpa.EntityManagerFactoryUtils - Closing JPA EntityManager
10:35:13.522 [main] DEBUG o.s.o.j.SharedEntityManagerCreator$SharedEntityManagerInvocationHandler - Creating new EntityManager for shared EntityManager invocation
10:35:13.522 [main] DEBUG o.s.o.jpa.EntityManagerFactoryUtils - Closing JPA EntityManager
10:35:13.522 [main] DEBUG o.s.aop.framework.JdkDynamicAopProxy - Creating JDK dynamic proxy: target source is org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$ThreadBoundTargetSource@723ed581
10:35:13.522 [main] DEBUG o.s.aop.framework.JdkDynamicAopProxy - Creating JDK dynamic proxy: target source is SingletonTargetSource for target object [org.springframework.data.jpa.repository.support.SimpleJpaRepository@796065aa]
10:35:13.522 [main] DEBUG o.s.b.f.s.DefaultListableBeanFactory - Finished creating instance of bean 'clientRepository'
10:35:13.522 [main] DEBUG o.s.b.f.a.AutowiredAnnotationBeanPostProcessor - Autowiring by type from bean name 'métier' to bean named 'clientRepository'
...
8.4.11. [Web / JSON] 层
![]() |
![]() |
我们将分几个步骤构建 [Web / JSON] 层:
- 步骤 1:构建一个不包含身份验证功能的运行中 Web 层;
- 步骤 2:使用 Spring Security 实现身份验证;
- 步骤 3:实现 CORS [跨源资源共享(CORS)是一种机制,允许网页上的许多资源(例如字体、JavaScript 等)从资源源域之外的另一个域进行请求。 (维基百科)]。我们 Web 服务的客户端将是一个 Angular Web 客户端,它未必与我们的 Web 服务位于同一域名下。默认情况下,除非 Web 服务授权,否则它无法访问该服务。我们将了解具体实现方法;
8.4.11.1. Maven 配置
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st.spring4.mvc</groupId>
<artifactId>rdvmedecins-webjson-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>rdvmedecins-webjson-server</name>
<description>Gestion de RV Médecins</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.6.RELEASE</version>
</parent>
<dependencies>
<!-- spring mvc web layer -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- test layer -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- layer DAO -->
<dependency>
<groupId>istia.st.spring4.rdvmedecins</groupId>
<artifactId>rdvmedecins-metier-dao</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
...
</project>
- 第 12–15 行:父级 Maven 项目;
- 第 19–22 行:Spring MVC 项目的依赖项;
- 第 24–28 行:JUnit/Spring 测试的依赖项;
- 第 30–34 行:项目各层的依赖项 [业务逻辑、DAO、JPA];
8.4.11.2. Web 服务接口
![]() |
- 在上述[1]中,浏览器只能请求数量有限且具有特定语法的URL;
- 在[4]中,它会收到一个JSON响应;
我们 Web 服务返回的响应都将采用相同的格式,对应 [Response] 类型的对象的 JSON 表示形式如下:
package rdvmedecins.web.models;
import java.util.List;
public class Response<T> {
// ----------------- properties
// operation status
private int status;
// any error messages
private List<String> messages;
// the body of the reply
private T body;
// manufacturers
public Response() {
}
public Response(int status, List<String> messages, T body) {
this.status = status;
this.messages = messages;
this.body = body;
}
// getters and setters
...
}
- 第 7 行:响应错误代码 0 表示成功,其他情况表示失败;
- 第 11 行:错误消息列表(如有错误);
- 第13行:响应正文;
下面展示的是说明 Web 服务 / JSON 接口的屏幕截图:
该诊所的所有患者列表 [/getAllClients]
![]() |
该诊所所有医生的列表 [/getAllMedecins]
![]() |
某位医生的可用时段列表 [/getAllCreneaux/{idMedecin}]
![]() |
某位医生的预约列表 [/getRvMedecinJour/{idMedecin}/{yyyy-mm-dd}
![]() |
医生的每日日程 [/getAgendaMedecinJour/{idMedecin}/{yyyy-mm-dd}]
![]() |
要添加或删除预约,我们使用 Chrome 扩展程序 [Advanced Rest Client],因为这些操作是通过 POST 请求实现的。
添加预约 [/addAppointment]
![]() |
- 在 [0] 中,填写 Web 服务 URL;
- 在 [1] 中,使用 POST 方法;
- 在 [2] 中,发送给 Web 服务的 JSON 文本采用 {day, clientId, slotId} 格式;
- 在 [3] 中,客户端向 Web 服务指定其发送的信息采用 JSON 格式;
响应如下:
![]() |
- 在 [4] 中:客户端发送标头,表明其发送的数据采用 JSON 格式;
- 在 [5] 中:Web 服务响应称其同样发送的是 JSON;
- 在 [6] 中:Web 服务的 JSON 响应。其中 [body] 字段包含已添加约会的 JSON 表示形式;
可以验证新预约是否已存在:
![]() |
请注意该预约的ID为[50]。我们将删除该预约。
删除预约 [/deleteApp]
![]() |
- 在 [1] 中,Web 服务 URL;
- 在 [2] 中,使用 POST 方法;
- 在 [3] 中,发送给 Web 服务的 JSON 文本采用 {idRv} 格式;
- 在 [4] 中,客户端向 Web 服务指定其正在发送 JSON 数据;
响应如下:
![]() |
- 在 [5] 中:[status] 字段被设置为 0,表示操作成功;
可以验证该约会的删除情况:
![]() |
上方的患者[GERMAIN女士]的预约已不复存在。
该网络服务还支持通过实体ID检索实体:
![]() |
![]() |
![]() |
![]() |
所有这些 URL 均由 [RdvMedecinsController] 控制器处理,我们稍后将对此进行介绍。
8.4.11.3. Web 服务配置
![]() |
配置类 [AppConfig] 如下所示:
package rdvmedecins.web.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import rdvmedecins.config.DomainAndPersistenceConfig;
@Configuration
@ComponentScan(basePackages = { "rdvmedecins.web" })
@Import({ DomainAndPersistenceConfig.class, SecurityConfig.class, WebConfig.class })
public class AppConfig {
}
- 第 12 行:[AppConfig] 类用于配置整个应用程序;
- 第 9 行:[AppConfig] 类是一个 Spring 配置类;
- 第 10 行:我们指定应在 [rdvmedecins.web] 包及其子包中搜索 Spring 组件。以下组件将通过此方式被发现:
- 位于 [rdvmedecins.web.controllers] 包中的 [@RestController RdvMedecinsController];
- 位于 [rdvmedecins.web.models] 包中的 [@Component ApplicationModel];
- 第 11 行:我们导入 [DomainAndPersistenceConfig] 类,该类配置 [rdvmedecins-metier-dao] 项目以提供对该项目 Bean 的访问;
- 第 11 行:[SecurityConfig] 类用于配置 Web 应用程序的安全性。目前我们暂不关注它;
- 第 11 行:[WebConfig] 类用于配置 [Web / JSON] 层;
[WebConfig] 类如下所示:
package rdvmedecins.web.config;
import org.springframework.boot.context.embedded.EmbeddedServletContainerFactory;
import org.springframework.boot.context.embedded.ServletRegistrationBean;
import org.springframework.boot.context.embedded.tomcat.TomcatEmbeddedServletContainerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
@Configuration
@EnableWebMvc
public class WebConfig {
// dispatcherservlet configuration for CORS headers
@Bean
public DispatcherServlet dispatcherServlet() {
DispatcherServlet servlet = new DispatcherServlet();
servlet.setDispatchOptionsRequest(true);
return servlet;
}
@Bean
public ServletRegistrationBean servletRegistrationBean(DispatcherServlet dispatcherServlet) {
return new ServletRegistrationBean(dispatcherServlet, "/*");
}
@Bean
public EmbeddedServletContainerFactory embeddedServletContainerFactory() {
return new TomcatEmbeddedServletContainerFactory("", 8080);
}
// mappers jSON
@Bean
public ObjectMapper jsonMapper() {
return new ObjectMapper();
}
@Bean
public ObjectMapper jsonMapperShortCreneau() {
ObjectMapper jsonMapperShortCreneau = new ObjectMapper();
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperShortCreneau.setFilters(new SimpleFilterProvider().addFilter("creneauFilter", creneauFilter));
return jsonMapperShortCreneau;
}
@Bean
public ObjectMapper jsonMapperLongRv() {
ObjectMapper jsonMapperLongRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("");
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperLongRv.setFilters(
new SimpleFilterProvider().addFilter("rvFilter", rvFilter).addFilter("creneauFilter", creneauFilter));
return jsonMapperLongRv;
}
@Bean
public ObjectMapper jsonMapperShortRv() {
ObjectMapper jsonMapperShortRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("client", "creneau");
jsonMapperShortRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter));
return jsonMapperShortRv;
}
}
- 第 20–25 行:定义 [DispatcherServlet] Bean。[DispatcherServlet] 类是 Spring MVC 框架的 Servlet。它充当 [FrontController]:拦截发送到 Spring MVC 站点的请求,并将它们路由到该站点的某个控制器;
- 第 22 行:实例化该类;
- 第 23 行:此行目前可以忽略;
- 第 27–30 行:[dispatcherServlet] Servlet 处理所有 URL;
- 第 27–30 行:启动项目依赖中嵌入的 Tomcat 服务器。它将在 8080 端口上运行;
- 第 38–67 行:配置了四个带有不同 JSON 过滤器的 JSON 映射器;
- 第 38–41 行:一个未配置过滤器的 JSON 映射器;
- 第 43–49 行:JSON 映射器 [jsonMapperShortCreneau] 在序列化/反序列化 [Creneau] 对象时忽略 [Creneau.medecin] 字段;
- 第 51–59 行:JSON 映射器 [jsonMapperLongRv] 在序列化/反序列化 [Rv] 对象时忽略 [Rv.creneau.medecin] 字段;
- 第 61–67 行:JSON 映射器 [jsonMapperShortRv] 在序列化/反序列化 [Rv] 对象时,会忽略 [Rv.creneau] 和 [Rv.client] 字段;
8.4.11.4. [ApplicationModel] 类
![]() |
[ApplicationModel] 类将实现两个目的:
package rdvmedecins.web.models;
import java.util.Date;
import java.util.List;
import javax.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Rv;
import rdvmedecins.metier.IMetier;
import rdvmedecins.web.helpers.Static;
@Component
public class ApplicationModel implements IMetier {
// the [business] layer
@Autowired
private IMetier métier;
// data from the [business] layer
private List<Medecin> médecins;
private List<Client> clients;
private List<String> messages;
// configuration data
private boolean CORSneeded = false;
private boolean secured = false;
@PostConstruct
public void init() {
// we get the doctors and the customers
try {
médecins = métier.getAllMedecins();
clients = métier.getAllClients();
} catch (Exception ex) {
messages = Static.getErreursForException(ex);
}
}
// getter
public List<String> getMessages() {
return messages;
}
// ------------------------- [business] layer interface
@Override
public List<Client> getAllClients() {
return clients;
}
@Override
public List<Medecin> getAllMedecins() {
return médecins;
}
@Override
public List<Creneau> getAllCreneaux(long idMedecin) {
return métier.getAllCreneaux(idMedecin);
}
@Override
public List<Rv> getRvMedecinJour(long idMedecin, Date jour) {
return métier.getRvMedecinJour(idMedecin, jour);
}
@Override
public Client getClientById(long id) {
return métier.getClientById(id);
}
@Override
public Medecin getMedecinById(long id) {
return métier.getMedecinById(id);
}
@Override
public Rv getRvById(long id) {
return métier.getRvById(id);
}
@Override
public Creneau getCreneauById(long id) {
return métier.getCreneauById(id);
}
@Override
public Rv ajouterRv(Date jour, Creneau creneau, Client client) {
return métier.ajouterRv(jour, creneau, client);
}
@Override
public void supprimerRv(long idRv) {
métier.supprimerRv(idRv);
}
@Override
public AgendaMedecinJour getAgendaMedecinJour(long idMedecin, Date jour) {
return métier.getAgendaMedecinJour(idMedecin, jour);
}
// getters and setters
public boolean isCORSneeded() {
return CORSneeded;
}
public boolean isSecured() {
return secured;
}
}
- 第 19 行:[@Component] 注解将 [ApplicationModel] 类定义为 Spring 组件。与迄今为止所见的所有 Spring 组件(@Controller 除外)一样,该类型的对象仅会实例化一个(单例);
- 第 20 行:[ApplicationModel] 类实现了 [IMetier] 接口;
- 第 23–24 行:Spring 注入了对 [business] 层的引用;
- 第 34 行:[@PostConstruct] 注解确保 [init] 方法将在 [ApplicationModel] 类实例化后立即执行;
- 第 38–39 行:从 [business] 层获取医生和客户列表;
- 第 41 行:如果发生异常,将异常堆栈中的消息存储在第 17 行的字段中;
Web层的架构演变如下:
![]() |
- 在[2b]中,控制器(们)的方法与[ApplicationModel]单例进行通信;
这种策略为缓存管理提供了灵活性。目前,医生的预约时段并未被缓存。若要缓存它们,只需修改 [ApplicationModel] 类即可。这不会对控制器产生任何影响,控制器将一如既往地使用 [List<Creneau> getAllCreneaux(long idMedecin)] 方法。需要更改的是 [ApplicationModel] 中该方法的实现。
8.4.11.5. 静态类
[Static] 类包含一组静态辅助方法,这些方法不涉及任何“业务”或“Web”方面的内容:
![]() |
其代码如下:
package rdvmedecins.web.helpers;
import java.util.ArrayList;
import java.util.List;
public class Static {
public Static() {
}
// list of exception error messages
public static List<String> getErreursForException(Exception exception) {
// retrieve the list of exception error messages
Throwable cause = exception;
List<String> erreurs = new ArrayList<String>();
while (cause != null) {
erreurs.add(cause.getMessage());
cause = cause.getCause();
}
return erreurs;
}
}
- 第 12 行:在 [ApplicationModel] 类的 [init] 方法中(见下文第 8 行)使用的 [Static.getErrorsForException] 方法:
@PostConstruct
public void init() {
// we get the doctors and the customers
try {
médecins = métier.getAllMedecins();
clients = métier.getAllClients();
} catch (Exception ex) {
messages = Static.getErreursForException(ex);
}
}
该方法构建一个 [List<String>] 对象,其中包含异常 [exception] 的错误消息 [exception.getMessage()] 及其内部异常 [exception.getCause()] 的错误消息。
8.4.11.6. 控制器骨架 [RdvMedecinsController]
![]() |
接下来我们将详细说明 Web 服务的 URL 处理机制。该过程涉及三个主要类:
- 控制器 [RdvMedecinsController];
- 工具方法类 [Static];
- 缓存类 [ApplicationModel];
![]() |
[RdvMedecinsController] 控制器如下所示:
package rdvmedecins.web.controllers;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.ResponseBody;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import rdvmedecins.domain.AgendaMedecinJour;
import rdvmedecins.entities.Client;
import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Medecin;
import rdvmedecins.entities.Rv;
import rdvmedecins.web.helpers.Static;
import rdvmedecins.web.models.ApplicationModel;
import rdvmedecins.web.models.PostAjouterRv;
import rdvmedecins.web.models.PostSupprimerRv;
import rdvmedecins.web.models.Response;
@Controller
public class RdvMedecinsController {
@Autowired
private ApplicationModel application;
@Autowired
private RdvMedecinsCorsController rdvMedecinsCorsController;
// message list
private List<String> messages;
// mappers jSON
@Autowired
private ObjectMapper jsonMapper;
@Autowired
private ObjectMapper jsonMapperShortCreneau;
@Autowired
private ObjectMapper jsonMapperLongRv;
@Autowired
private ObjectMapper jsonMapperShortRv;
@PostConstruct
public void init() {
// application error messages
messages = application.getMessages();
}
// list of doctors
@RequestMapping(value = "/getAllMedecins", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllMedecins() throws JsonProcessingException {...}
// customer list
@RequestMapping(value = "/getAllClients", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllClients() throws JsonProcessingException {...}
// list of physician slots
@RequestMapping(value = "/getAllCreneaux/{idMedecin}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllCreneaux(@PathVariable("idMedecin") long idMedecin) throws JsonProcessingException {...}
// list of doctor's appointments
@RequestMapping(value = "/getRvMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getRvMedecinJour(@PathVariable("idMedecin") long idMedecin, @PathVariable("jour") String jour)
throws JsonProcessingException {...}
@RequestMapping(value = "/getClientById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getClientById(@PathVariable("id") long id) throws JsonProcessingException {...}
@RequestMapping(value = "/getMedecinById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getMedecinById(@PathVariable("id") long id) String origin) throws JsonProcessingException {...}
@RequestMapping(value = "/getRvById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getRvById(@PathVariable("id") long id) throws JsonProcessingException {...}
@RequestMapping(value = "/getCreneauById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getCreneauById(@PathVariable("id") long id) throws JsonProcessingException {...}
@RequestMapping(value = "/ajouterRv", method = RequestMethod.POST, produces = "application/json; charset=UTF-8", consumes = "application/json; charset=UTF-8")
@ResponseBody
public String ajouterRv(@RequestBody PostAjouterRv post) throws JsonProcessingException {...}
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, produces = "application/json; charset=UTF-8", consumes = "application/json; charset=UTF-8")
@ResponseBody
public String supprimerRv(@RequestBody PostSupprimerRv post) throws JsonProcessingException {...}
@RequestMapping(value = "/getAgendaMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAgendaMedecinJour(@PathVariable("idMedecin") long idMedecin, @PathVariable("jour") String jour)
throws JsonProcessingException {...}
@RequestMapping(value = "/authenticate", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String authenticate() throws JsonProcessingException {...}
}
- 第 35 行:[@Controller] 注解将 [RdvMedecinsController] 类定义为 Spring 控制器,即 MVC 中的 C;
- 第 38–39 行:Spring 将在此处注入一个 [ApplicationModel] 类型的对象。我们之前已经介绍过它;
- 第 41–42 行:Spring 将在此处注入一个 [RdvMedecinsCorsController] 类型的对象。我们稍后将介绍该对象;
- 第 48–58 行:在 [WebConfig] 配置类中定义的 JSON 映射器;
- 第 60 行:[@PostConstruct] 注解标记了一个方法,该方法将在类实例化后立即执行。当此方法运行时,Spring 注入的对象已可用;
- 第 63 行:我们从 [ApplicationModel] 对象中检索任何错误消息。该对象在应用程序启动并尝试缓存医生和客户时被实例化。如果缓存失败,则 [messages!=null]。这将使控制器的方法能够判断应用程序是否初始化成功;
- 第 67–118 行:[web/jSON] 服务公开的 URL。所有方法均返回以下 [Response<T>] 类型的 JSON 字符串:
![]() |
package rdvmedecins.web.models;
import java.util.List;
public class Response<T> {
// ----------------- properties
// operation status
private int status;
// any error messages
private List<String> messages;
// the body of the reply
private T body;
// manufacturers
public Response() {
}
public Response(int status, List<String> messages, T body) {
this.status = status;
this.messages = messages;
this.body = body;
}
// getters and setters
...
}
- 第 9 行:一个错误代码:0 表示没有错误;
- 第 11 行:如果 [status!=0],则 [messages] 是一个错误消息列表;
- 第 13 行:响应中封装的 T 对象。若发生错误,T 为空;
该对象在发送至客户端浏览器前会被序列化为 JSON;
- 第 67 行:公开的 URL 为 [/getAllDoctors]。客户端必须使用 [GET] 方法发起请求(method = RequestMethod.GET)。如果通过 POST 方法请求此 URL,请求将被拒绝,Spring MVC 会向 Web 客户端发送 HTTP 错误代码。该方法本身会将响应返回给客户端(第 68 行)。该响应将是一个字符串(第 67 行)。 将向客户端发送 HTTP 头部 [Content-type: application/json; charset=UTF-8],以表明客户端将接收一个 JSON 字符串(第 67 行);
- 第 77 行:URL 配置了 {idMedecin}。该参数通过第 79 行的 [@PathVariable] 注解进行获取;
- 第 79 行:参数 [long idMedecin] 的值来自 URL 中的 {idMedecin} 参数 [@PathVariable("idMedecin")]。URL 中的参数与方法中的参数可以使用不同的名称( )。 请注意,[@PathVariable("idMedecin")] 的类型为 String(整个 URL 是一个字符串),而参数 [long idMedecin] 的类型为 [long]。类型转换会自动进行。如果类型转换失败,将返回一个 HTTP 错误代码;
- 第 105 行:[@RequestBody] 注解指代请求正文。在 GET 请求中,几乎不会包含正文(但可以包含);在 POST 请求中,通常会包含正文(但也可以省略)。对于 URL [ajouterRv],Web 客户端会在其 POST 请求中发送以下 JSON 字符串:
语法 [@RequestBody PostAjouterRv post](第 105 行),结合该方法期望接收 JSON [consumes = "application/json; charset=UTF-8"](第 103 行)这一事实,意味着 Web 客户端发送的 JSON 字符串将被反序列化为类型为 [PostAjouterRv] 的对象。具体过程如下:
package rdvmedecins.web.models;
public class PostAjouterRv {
// pOST DATA
private String jour;
private long idClient;
private long idCreneau;
// getters and setters
...
}
在此处,必要的类型转换也会自动进行;
- 第 107–109 行包含针对 URL [/supprimerRv] 的类似机制。提交的 JSON 字符串如下:
而 [PostSupprimerRv] 类型定义如下:
package rdvmedecins.web.models;
public class PostSupprimerRv {
// pOST DATA
private long idRv;
// getters and setters
...
}
8.4.11.7. URL [/getAllDoctors]
URL [/getAllMedecins] 由控制器 [RdvMedecinsController] 中的以下方法处理:
// list of doctors
@RequestMapping(value = "/getAllMedecins", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllMedecins() throws JsonProcessingException {
// the answer
Response<List<Medecin>> response;
// application status
if (messages != null) {
response = new Response<>(-1, messages, null);
} else {
// list of doctors
try {
response = new Response<>(0, null, application.getAllMedecins());
} catch (RuntimeException e) {
response = new Response<>(1, Static.getErreursForException(e), null);
}
}
// answer
return jsonMapper.writeValueAsString(response);
}
- 第 9-10 行:我们检查应用程序是否已正确初始化(messages==null)。如果未初始化,则返回状态为 -1、正文为 messages 的响应;
- 第 13 行:否则,我们向 [ApplicationModel] 类请求医生列表;
- 第 19 行:由于 [Medecin] 类没有 JSON 过滤器,因此我们使用 JSON 映射器 [jsonMapper] 发送响应的 JSON 字符串。响应可能没有错误(第 14 行),也可能包含错误(第 16 行)。 方法 [application.getAllMedecins()] 不会抛出异常,因为它只是返回一个缓存列表。尽管如此,我们仍保留此异常处理,以防医生数据已不再缓存;
我们尚未演示应用程序初始化失败的情况。现在,让我们停止 MySQL5 数据库管理系统,启动 Web 服务,然后请求 URL [/getAllMedecins]:

我们确实遇到了错误。在正常情况下,我们会看到以下视图:
![]() |
8.4.11.8. URL [/getAllClients]
URL [/getAllClients] 由 [RdvMedecinsController] 中的以下方法处理:
// customer list
@RequestMapping(value = "/getAllClients", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllClients() throws JsonProcessingException {
// the answer
Response<List<Client>> response;
// application status
if (messages != null) {
response = new Response<>(-1, messages, null);
}
// customer list
try {
response = new Response<>(0, null, application.getAllClients());
} catch (RuntimeException e) {
response = new Response<>(1, Static.getErreursForException(e), null);
}
// answer
return jsonMapper.writeValueAsString(response);
}
这与我们之前介绍过的 [getAllMedecins] 方法类似。得到的结果如下:
![]() |
8.4.11.9. URL [/getAllSlots/{doctorId}]
URL [/getAllSlots/{doctorId}] 由 [RdvMedecinsController] 控制器中的以下方法处理:
// list of physician slots
@RequestMapping(value = "/getAllCreneaux/{idMedecin}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllCreneaux(@PathVariable("idMedecin") long idMedecin) throws JsonProcessingException {
// the answer
Response<List<Creneau>> response;
// application status
if (messages != null) {
response = new Response<>(-1, messages, null);
}
// we get the doctor back
Response<Medecin> responseMedecin = getMedecin(idMedecin);
if (responseMedecin.getStatus() != 0) {
response = new Response<>(responseMedecin.getStatus(), responseMedecin.getMessages(), null);
} else {
Medecin médecin = responseMedecin.getBody();
// doctor's slots
try {
response = new Response<>(0, null, application.getAllCreneaux(médecin.getId()));
} catch (RuntimeException e1) {
response = new Response<>(3, Static.getErreursForException(e1), null);
}
}
// answer
return jsonMapperShortCreneau.writeValueAsString(response);
}
- 第 12 行:通过 [id] 参数标识的医生是从本地方法中请求的:
private Response<Medecin> getMedecin(long id) {
// we get the doctor back
Medecin médecin = null;
try {
médecin = application.getMedecinById(id);
} catch (RuntimeException e1) {
return new Response<Medecin>(1, Static.getErreursForException(e1), null);
}
// existing doctor?
if (médecin == null) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("Le médecin d'id [%s] n'existe pas", id));
return new Response<Medecin>(2, messages, null);
}
// ok
return new Response<Medecin>(0, null, médecin);
}
该方法返回一个状态值,取值范围为 [0,1,2]。让我们回到 [getAllCreneaux] 方法的代码:
- 第 13-14 行:如果 status ≠ 0,则构建一个包含错误的响应;
- 第 16 行:我们获取该医生;
- 第 19 行:获取该医生的时段;
- 第 25 行:将 [List<Creneau>] 对象作为响应返回。让我们回顾一下 [Creneau] 类的定义:
@Entity
@Table(name = "creneaux")
public class Creneau extends AbstractEntity {
private static final long serialVersionUID = 1L;
// characteristics of a RV slot
private int hdebut;
private int mdebut;
private int hfin;
private int mfin;
// a slot is linked to a doctor
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_medecin")
private Medecin medecin;
// foreign key
@Column(name = "id_medecin", insertable = false, updatable = false)
private long idMedecin;
...
}
- 第 13 行:医生是在 [FetchType.LAZY] 模式下获取的;
回顾 [DAO] 层中实现 [getAllCreneaux] 方法的 JPQL 查询:
@Query("select c from Creneau c where c.medecin.id=?1")
符号 [c.medecin.id] 强制 [CRENEAUX] 和 [MEDECINS] 表之间进行连接。因此,该查询会返回该医生的所有时段,且每个时段中都包含该医生。当我们将这些时段序列化为 JSON 时,医生的 JSON 字符串会出现在每个时段中。这是不必要的。要控制序列化过程,我们需要两点:
- 访问待序列化的对象;
- 配置待序列化的对象;
第一点可通过将适用于该对象的 JSON 转换器注入控制器来实现:
@Autowired
private ObjectMapper jsonMapperShortCreneau;
通过在 [rdvmedecins-metier-dao] 项目中定义的 [Creneau] 类上添加注解,即可实现要点 2:
![]() |
@Entity
@Table(name = "creneaux")
@JsonFilter("creneauFilter")
public class Creneau extends AbstractEntity {
...
- 第 3 行:来自 Jackson JSON 库的一个注解。它创建了一个名为 [creneauFilter] 的过滤器。使用此过滤器,我们将能够通过编程方式定义哪些字段应被序列化,哪些不应被序列化;
[Creneau] 对象的序列化发生在 [getAllCreneaux] 方法的以下行中:
// réponse
return jsonMapperShortCreneau.writeValueAsString(response);
JSON 映射器 [jsonMapperShortCreneau] 在 [WebConfig] 类中定义如下:
@Bean
public ObjectMapper jsonMapperShortCreneau() {
ObjectMapper jsonMapperShortCreneau = new ObjectMapper();
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperShortCreneau.setFilters(new SimpleFilterProvider().addFilter("creneauFilter", creneauFilter));
return jsonMapperShortCreneau;
}
- 第 5 行:名为 [creneauFilter] 的过滤器与第 4 行中的 [creneauFilter] 过滤器相关联。该过滤器在序列化 [Creneau] 对象时会省略其 [medecin] 字段;
[getAllCreneaux] 方法返回的结果是一个类型为 [Response<List<Creneau>>] 的 JSON 字符串。
获得的结果如下:
![]() |
如果该槽位不存在,则返回以下结果:
![]() |
从这个例子中,我们可以得出以下规则:
- Web 服务器/JSON 方法返回一个类型为 [Response<T>] 的对象,该对象会被序列化为 JSON;
- 如果类型 T 具有一个或多个 JSON 过滤器,则会使用具有相同过滤器的映射器对其进行序列化;
8.4.11.10. URL [/getRvMedecinJour/{idMedecin}/{jour}]
URL [/getRvMedecinJour/{idMedecin}/{jour}] 由 [RdvMedecinsController] 控制器中的以下方法处理:
// list of doctor's appointments
@RequestMapping(value = "/getRvMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getRvMedecinJour(@PathVariable("idMedecin") long idMedecin)
throws JsonProcessingException {
// the answer
Response<List<Rv>> response=null;
boolean erreur = false;
// application status
if (messages != null) {
response = new Response<>(-1, messages, null);
erreur = true;
}
// check the date
Date jourAgenda = null;
if (!erreur) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.setLenient(false);
try {
jourAgenda = sdf.parse(jour);
} catch (ParseException e) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("La date [%s] est invalide", jour));
response = new Response<List<Rv>>(3, messages, null);
erreur = true;
}
}
Response<Medecin> responseMedecin = null;
if (!erreur) {
// we get the doctor back
responseMedecin = getMedecin(idMedecin);
if (responseMedecin.getStatus() != 0) {
response = new Response<>(responseMedecin.getStatus(), responseMedecin.getMessages(), null);
erreur = true;
}
}
if (!erreur) {
Medecin médecin = responseMedecin.getBody();
// list of appointments
try {
response = new Response<>(0, null, application.getRvMedecinJour(médecin.getId(), jourAgenda));
} catch (RuntimeException e1) {
response = new Response<>(4, Static.getErreursForException(e1), null);
}
}
// answer
return jsonMapperLongRv.writeValueAsString(response);
}
- 我们必须返回类型为 [Response<List<Rv>>] 的 JSON 字符串。类 [Rv] 有一个字段 [Rv.creneau]。如果该字段被序列化,我们将遇到 JSON 过滤器 [creneauFilter];
- 第 47 行:第 7 行中的 [Response<List<Rv>>] 类型的对象被序列化为 JSON;
让我们分析第 42 行获取预约列表的情况。在 [rdvmedecins-metier-dao] 项目中,[Rv] 类的定义如下:
@Entity
@Table(name = "rv")
public class Rv extends AbstractEntity {
private static final long serialVersionUID = 1L;
// characteristics of an Rv
@Temporal(TemporalType.DATE)
private Date jour;
// an appointment is linked to a customer
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_client")
private Client client;
// an appointment is linked to a time slot
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_creneau")
private Creneau creneau;
// foreign keys
@Column(name = "id_client", insertable = false, updatable = false)
private long idClient;
@Column(name = "id_creneau", insertable = false, updatable = false)
private long idCreneau;
...
}
- 第 11 行:使用 [FetchType.LAZY] 模式检索客户端;
- 第 18 行:使用 [FetchType.LAZY] 模式检索槽位;
让我们回顾一下用于检索约会的 JPQL 查询:
@Query("select rv from Rv rv left join fetch rv.client c left join fetch rv.creneau cr where cr.medecin.id=?1 and rv.jour=?2")
为了检索 [client] 和 [slot] 字段,我们显式地执行了连接操作。此外,由于 [cr.doctor.id=?1] 这一连接条件,我们还会获取医生信息。因此,每位医生的信息都会出现在每个预约的 JSON 字符串中。然而,这些重复的信息是多余的。 我们已经了解如何通过在 [Creneau] 对象上应用 JSON 过滤器来解决此问题。由于 [Rv] 类中 [client] 和 [slot] 字段采用 [FetchType.LAZY] 模式,我们很快会发现有必要在 [rdvmedecins-metier-dao] 项目中对 [Rv] 类应用 JSON 过滤器:
@Entity
@Table(name = "rv")
@JsonFilter("rvFilter")
public class Rv extends AbstractEntity {
...
我们将使用 [rvFilter] 过滤器来控制 [Rv] 对象的序列化。 显然,在此情况下,我们无需进行过滤,因为我们需要 [Rv] 对象的所有字段。然而,由于我们指定了该类具有 JSON 过滤器,因此对于任何 [Rv] 类型的对象序列化,我们都必须定义该过滤器;否则,将会引发异常。为此,我们使用 [rdvMedecinsController] 类中定义的以下 JSON 映射器:
@Autowired
private ObjectMapper jsonMapperLongRv;
该映射器在 [WebConfig] 配置类中定义如下:
@Bean
public ObjectMapper jsonMapperLongRv() {
ObjectMapper jsonMapperLongRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("");
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperLongRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter).addFilter("creneauFilter",creneauFilter));
return jsonMapperLongRv;
}
- 第 4 行:我们指定 [Rv] 对象的所有字段都必须序列化;
- 第 5 行:我们指定在 [Creneau] 对象中,[medecin] 字段不应被序列化;
- 第 6 行:我们将两个过滤器 [rvFilter] 和 [creneauFilter] 添加到 [jsonMapperLongRv] 对象的 JSON 过滤器中;
所得结果如下:
![]() |
或者这些没有预约的日子:
![]() |
或者这些日期有误的:
![]() |
或者这些日期有误的:
![]() |
8.4.11.11. URL [/getAgendaMedecinJour/{idMedecin}/{jour}]
URL [/getAgendaMedecinJour/{idMedecin}/{jour}] 由 [RdvMedecinsController] 控制器中的以下方法处理:
@RequestMapping(value = "/getAgendaMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAgendaMedecinJour(@PathVariable("idMedecin") long idMedecin)
throws JsonProcessingException {
// the answer
Response<AgendaMedecinJour> response = null;
boolean erreur = false;
// application status
if (messages != null) {
response = new Response<>(-1, messages, null);
erreur = true;
}
// check the date
Date jourAgenda = null;
if (!erreur) {
// check the date
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.setLenient(false);
try {
jourAgenda = sdf.parse(jour);
} catch (ParseException e) {
erreur = true;
List<String> messages = new ArrayList<String>();
messages.add(String.format("La date [%s] est invalide", jour));
response = new Response<>(3, messages, null);
}
}
// we get the doctor back
Medecin médecin = null;
if (!erreur) {
// we get the doctor back
Response<Medecin> responseMedecin = getMedecin(idMedecin);
if (responseMedecin.getStatus() != 0) {
response = new Response<>(responseMedecin.getStatus(), responseMedecin.getMessages(), null);
} else {
médecin = responseMedecin.getBody();
}
}
// get your diary back
if (!erreur) {
try {
response = new Response<>(0, null, application.getAgendaMedecinJour(médecin.getId(), jourAgenda));
} catch (RuntimeException e1) {
erreur = true;
response = new Response<>(4, Static.getErreursForException(e1), null);
}
}
// answer
return jsonMapperLongRv.writeValueAsString(response);
}
- 第 6 行和第 49 行:我们返回一个封装在 [Response] 对象中的 [AgendaMedecinJour] 类型的 JSON 字符串;
[AgendaMedecinJour] 类型如下:
public class AgendaMedecinJour implements Serializable {
// fields
private Medecin medecin;
private Date jour;
private CreneauMedecinJour[] creneauxMedecinJour;
[CreneauMedecinJour] 类型的定义如下:
public class CreneauMedecinJour implements Serializable {
private static final long serialVersionUID = 1L;
// fields
private Creneau creneau;
private Rv rv;
[creneau] 和 [rv] 字段拥有需要配置的 JSON 过滤器。这就是 [getAgendaMedecinJour] 方法第 49 行所做的工作,它使用了我们之前提到的 [jsonMapperLongRv] JSON 映射器:
@Bean
public ObjectMapper jsonMapperLongRv() {
ObjectMapper jsonMapperLongRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("");
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperLongRv.setFilters(
new SimpleFilterProvider().addFilter("rvFilter", rvFilter).addFilter("creneauFilter", creneauFilter));
return jsonMapperLongRv;
}
所得结果如下:
![]() |
上文显示,2015年1月28日上午8点20分,佩利西耶博士与布里吉特·比斯特鲁女士有约;
如果日期有误,请参考以下信息:
![]() |
如果医生ID无效,请尝试以下选项:
![]() |
8.4.11.12. URL [/getMedecinById/{id}]
URL [/getMedecinById/{id}] 由 [RdvMedecinsController] 中的以下方法处理:
@RequestMapping(value = "/getMedecinById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getMedecinById(@PathVariable("id") long id) throws JsonProcessingException {
// the answer
Response<Medecin> response;
// application status
if (messages != null) {
response = new Response<Medecin>(-1, messages, null);
} else {
response = getMedecin(id);
}
// answer
return jsonMapper.writeValueAsString(response);
}
- 第 5 行、第 13 行:该方法返回一个类型为 [Doctor] 的 JSON 字符串。该类型没有 JSON 过滤器注解。因此,在第 14 行中,JSON 映射器是在没有过滤器的情况下使用的;
第 10 行:[getMedecin] 方法如下:
private Response<Medecin> getMedecin(long id) {
// we get the doctor back
Medecin médecin = null;
try {
médecin = application.getMedecinById(id);
} catch (RuntimeException e1) {
return new Response<Medecin>(1, Static.getErreursForException(e1), null);
}
// existing doctor?
if (médecin == null) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("Le médecin d'id [%s] n'existe pas", id));
return new Response<Medecin>(2, messages, null);
}
// ok
return new Response<Medecin>(0, null, médecin);
}
结果如下:
![]() |
如果医生的ID不正确,请使用以下信息:
![]() |
8.4.11.13. URL [/getClientById/{id}]
URL [/getClientById/{id}] 由控制器 [RdvMedecinsController] 中的以下方法处理:
@RequestMapping(value = "/getClientById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getClientById(@PathVariable("id") long id) throws JsonProcessingException {
// the answer
Response<Client> response;
// application status
if (messages != null) {
response = new Response<>(-1, messages, null);
} else {
response = getClient(id);
}
// answer
return jsonMapper.writeValueAsString(response);
}
- 第 5 行、第 13 行:该方法返回一个类型为 [Client] 的 JSON 字符串。该类型没有 JSON 过滤器注解。因此,在第 13 行中,JSON 映射器是在没有过滤器的情况下使用的;
第 11 行:[getClient] 方法如下:
private Response<Client> getClient(long id) {
// we get the customer back
Client client = null;
try {
client = application.getClientById(id);
} catch (RuntimeException e1) {
return new Response<Client>(1, Static.getErreursForException(e1), null);
}
// existing customer?
if (client == null) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("Le client d'id [%s] n'existe pas", id));
return new Response<Client>(2, messages, null);
}
// ok
return new Response<Client>(0, null, client);
}
结果如下:
![]() |
或者,如果客户 ID 不正确,请使用以下内容:
![]() |
8.4.11.14. URL [/getCreneauById/{id}]
URL [/getCreneauById/{id}] 由控制器 [RdvMedecinsController] 中的以下方法处理:
@RequestMapping(value = "/getCreneauById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getCreneauById(@PathVariable("id") long id) throws JsonProcessingException {
// the answer
Response<Creneau> response;
// application status
if (messages != null) {
response = new Response<>(-1, messages, null);
} else {
// we give back the slot
response = getCreneau(id);
}
// answer
return jsonMapperShortCreneau.writeValueAsString(response);
}
- 第 5 行、第 14 行:该方法返回类型为 [Response<Creneau>] 的 JSON 字符串;
第 8 行:[getCreneau] 方法如下:
private Response<Creneau> getCreneau(long id) {
// we get the slot back
Creneau créneau = null;
try {
créneau = application.getCreneauById(id);
} catch (RuntimeException e1) {
return new Response<Creneau>(1, Static.getErreursForException(e1), null);
}
// existing niche?
if (créneau == null) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("Le créneau d'id [%s] n'existe pas", id));
return new Response<Creneau>(2, messages, null);
}
// ok
return new Response<Creneau>(0, null, créneau);
}
让我们回顾一下 [Creneau] 实体的代码:
@Entity
@Table(name = "creneaux")
@JsonFilter("creneauFilter")
public class Creneau extends AbstractEntity {
private static final long serialVersionUID = 1L;
// characteristics of a RV slot
private int hdebut;
private int mdebut;
private int hfin;
private int mfin;
// a slot is linked to a doctor
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "id_medecin")
private Medecin medecin;
// foreign key
@Column(name = "id_medecin", insertable = false, updatable = false)
private long idMedecin;
- 第 14-16 行:由于 [doctor] 字段处于 [fetch = FetchType.LAZY] 模式,因此在通过其 [id] 获取槽时,该字段不会被检索。 因此必须将其从序列化中排除。如果不进行此排除,将会引发异常。这是因为序列化对象 [mapper] 会调用 [getMedecin] 方法来获取 [medecin] 字段。 然而,在 JPA/Hibernate 实现中,[medecin] 字段的 [fetch = FetchType.LAZY] 模式会返回一个 [Creneau] 对象,其 [getMedecin] 方法被编程为从 JPA 上下文中获取医生。这被称为 [代理] 对象。现在,让我们回顾一下 Web 应用程序的架构:
![]() |
控制器位于 [Controllers / Actions] 模块中。一旦进入该模块,JPA 上下文的概念便不再适用。 JPA 上下文是在 [DAO] 层执行操作时创建的,且仅在此范围内存在。因此,当控制器尝试访问 JPA 上下文时,会抛出异常,表明该上下文已关闭。为避免此异常,必须阻止 [Rv] 类中 [medecin] 字段的序列化。这就是 JSON 映射器 [jsonMapperShortCreneau] 所做的工作:
@Bean
public ObjectMapper jsonMapperShortCreneau() {
ObjectMapper jsonMapperShortCreneau = new ObjectMapper();
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperShortCreneau.setFilters(new SimpleFilterProvider().addFilter("creneauFilter", creneauFilter));
return jsonMapperShortCreneau;
}
所得结果如下:
![]() |
如果槽位编号不正确,则显示以下内容:
![]() |
8.4.11.15. URL [/getRvById/{id}]
URL [/getRvById/{id}] 由控制器 [RdvMedecinsController] 中的以下方法处理:
@RequestMapping(value = "/getRvById/{id}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getRvById(@PathVariable("id") long id) throws JsonProcessingException {
// the answer
Response<Rv> response;
// application status
if (messages != null) {
response = new Response<>(-1, messages, null);
} else {
// we recover rv
response = getRv(id);
}
// answer
return jsonMapperShortRv.writeValueAsString(response);
}
- 第 5 行、第 14 行:该方法返回一个类型为 [Response<Rv>] 的 JSON 字符串;
第 11 行:[getRv] 方法如下:
private Response<Rv> getRv(long id) {
// we recover the Rv
Rv rv = null;
try {
rv = application.getRvById(id);
} catch (RuntimeException e1) {
return new Response<Rv>(1, Static.getErreursForException(e1), null);
}
// Existing Rv?
if (rv == null) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("Le rendez-vous d'id [%s] n'existe pas", id));
return new Response<Rv>(2, messages, null);
}
// ok
return new Response<Rv>(0, null, rv);
}
[Rv] 类有两个标注了 [fetch = FetchType.LAZY] 的字段:[creneau] 和 [client]。因此,当通过主键获取 [Rv] 时,这些字段不会被检索。 基于与之前相同的理由,因此必须将它们从序列化中排除。这就是在 [WebConfig] 类中定义的以下 [jsonMapperShortRv] 映射器所做的工作:
@Bean
public ObjectMapper jsonMapperShortRv() {
ObjectMapper jsonMapperShortRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("client", "creneau");
jsonMapperShortRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter));
return jsonMapperShortRv;
}
所得结果如下:
![]() |
如果预约编号有误,则显示以下内容:
![]() |
8.4.11.16. URL [/ajouterRv]
URL [/ajouterRv] 由 [RdvMedecinsController] 控制器中的以下方法处理:
@RequestMapping(value = "/ajouterRv", method = RequestMethod.POST, produces = "application/json; charset=UTF-8", consumes = "application/json; charset=UTF-8")
@ResponseBody
public String ajouterRv(@RequestBody PostAjouterRv post) throws JsonProcessingException {
// the answer
Response<Rv> response = null;
boolean erreur = false;
// application status
if (messages != null) {
response = new Response<>(-1, messages, null);
erreur = true;
}
// retrieve posted values
String jour;
long idCreneau = -1;
long idClient = -1;
Date jourAgenda = null;
if (!erreur) {
// retrieve posted values
jour = post.getJour();
idCreneau = post.getIdCreneau();
idClient = post.getIdClient();
// check the date
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.setLenient(false);
try {
jourAgenda = sdf.parse(jour);
} catch (ParseException e) {
List<String> messages = new ArrayList<String>();
messages.add(String.format("La date [%s] est invalide", jour));
response = new Response<>(6, messages, null);
erreur = true;
}
}
// we get the slot back
Response<Creneau> responseCréneau = null;
if (!erreur) {
// we get the slot back
responseCréneau = getCreneau(idCreneau);
if (responseCréneau.getStatus() != 0) {
erreur = true;
response = new Response<>(responseCréneau.getStatus(), responseCréneau.getMessages(), null);
}
}
// we get the customer back
Response<Client> responseClient = null;
Creneau créneau = null;
if (!erreur) {
créneau = (Creneau) responseCréneau.getBody();
// we get the customer back
responseClient = getClient(idClient);
if (responseClient.getStatus() != 0) {
erreur = true;
response = new Response<>(responseClient.getStatus() + 2, responseClient.getMessages(), null);
}
}
if (!erreur) {
Client client = responseClient.getBody();
// we add the Rv
try {
response = new Response<>(0, null, application.ajouterRv(jourAgenda, créneau, client));
} catch (RuntimeException e1) {
erreur = true;
response = new Response<>(5, Static.getErreursForException(e1), null);
}
}
// answer
return jsonMapperLongRv.writeValueAsString(response);
}
- 第 5 行、第 67 行:该方法必须返回类型为 [Response<Rv>] 的 JSON 字符串;
- 第 3 行:注解 [@RequestBody PostAjouterRv post] 用于获取 POST 请求体,并将其赋值给 [PostAjouterRv post] 参数。该请求体为 JSON 格式 [consumes = "application/json; charset=UTF-8"],将被自动反序列化为以下 [PostAjouterRv] 类型:
public class PostAjouterRv {
// pOST DATA
private String jour;
private long idClient;
private long idCreneau;
...
- 接下来是一段以某种形式已经出现过的代码;
- 第 67 行:设置 JSON 过滤器 [creneauFilter] 和 [rvFilter]。该方法返回一个类型为 [Response<Rv>] 的 JSON 字符串,其中 Rv 在第 61 行获取。该 [Rv] 对象封装了一个 [Creneau] 对象以及一个 [Client] 对象。 [Creneau] 对象对 [Medecin] 对象具有 [FetchType.LAZY] 依赖关系,并在第 36–44 行中被检索。它是通过主键从 JPA 上下文中获取的,且在检索时未包含其 [FetchType.LAZY] 依赖关系。最终,
- [Rv] 对象拥有其所有依赖关系。这些依赖关系可以被序列化;
- 而 [Creneau] 对象不包含其 [medecin] 依赖关系。因此,该依赖关系不得被序列化;
在 [WebConfig] 类中定义的 JSON 映射器 [jsonMapperLongRv] 满足这些约束:
@Bean
public ObjectMapper jsonMapperLongRv() {
ObjectMapper jsonMapperLongRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("");
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperLongRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter).addFilter("creneauFilter",creneauFilter));
return jsonMapperLongRv;
}
使用 [Advanced Rest Client] 客户端获取的结果如下所示:
![]() |
- 在 [1] 中,POST URL;
- 在 [2] 中,POST 请求;
- 在 [3] 中,提交的值;
- 在 [4a] 中,该提交的值是 JSON;
![]() |
- 在 [4b] 中,客户端表明其发送的是 JSON;
- 在 [5] 中,服务器表明其返回的是 JSON;
![]() |
- 在 [6] 中,服务器返回的 JSON 响应表示已添加的预约。其中显示了该预约的 ID [id];
若使用不存在的时段编号,我们将得到以下结果:
![]() |
8.4.11.17. URL [/deleteAppointment]
URL [/deleteAppointment] 由 [RdvMedecinsController] 控制器中的以下方法处理:
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, produces = "application/json; charset=UTF-8", consumes = "application/json; charset=UTF-8")
@ResponseBody
public String supprimerRv(@RequestBody PostSupprimerRv post) throws JsonProcessingException {
// the answer
Response<Void> response = null;
boolean erreur = false;
// headers CORS
rdvMedecinsCorsController.sendOptions(origin, httpServletResponse);
// application status
if (messages != null) {
response = new Response<>(-1, messages, null);
erreur = true;
}
// retrieve posted values
long idRv = post.getIdRv();
// recovering the rv
if (!erreur) {
Response<Rv> responseRv = getRv(idRv);
if (responseRv.getStatus() != 0) {
response = new Response<>(responseRv.getStatus(), responseRv.getMessages(), null);
erreur = true;
}
}
if (!erreur) {
// rv deletion
try {
application.supprimerRv(idRv);
response = new Response<Void>(0, null, null);
} catch (RuntimeException e1) {
response = new Response<>(3, Static.getErreursForException(e1), null);
}
}
// answer
return jsonMapper.writeValueAsString(response);
}
- 第 5 行:类型 [Void] 是对应于基本类型 [void] 的类;
- 第 5 行、第 34 行:该方法返回一个类型为 [Response<Void>] 的 JSON 字符串,且不包含任何 JSON 过滤器。因此,在第 34 行中,我们使用不带过滤器的 JSON 映射器;
- 第 3 行:该方法将 POST 请求体作为参数,即提交的值。该值以 JSON 格式 [content-type="application/json; charset=UTF-8"] 接收,并自动反序列化为以下 [PostSupprimerRv] 类型:
public class PostSupprimerRv {
// pOST DATA
private long idRv;
- 第 28 行:删除成功时,会发送一条响应,其中 [status=0];
所得结果如下:
![]() |
![]() |
- 在[5]中,[status=0]字段表示删除操作已成功;
当预约 ID 不存在时,我们会得到以下结果:
![]() |
控制器部分到此结束。现在让我们看看如何运行该项目。
8.4.11.18. Web 服务的可执行类
![]() |
[Boot] [1] 类如下所示:
package rdvmedecins.web.boot;
import org.springframework.boot.SpringApplication;
import rdvmedecins.web.config.AppConfig;
public class Boot {
public static void main(String[] args) {
SpringApplication.run(AppConfig.class, args);
}
}
第 10 行:静态方法 [SpringApplication.run] 被调用,其第一个参数是项目配置类 [AppConfig]。该方法将自动配置项目,启动依赖项中嵌入的 Tomcat 服务器,并将 [RdvMedecinsController] 控制器部署到该服务器上。
日志由以下文件控制 [2]:
[logback.xml]
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- encoders are by default assigned the type ch.qos.logback.classic.encoder.PatternLayoutEncoder -->
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- log level control -->
<root level="info"> <!-- off, info, debug, warn -->
<appender-ref ref="STDOUT" />
</root>
</configuration>
- 第 9 行:将通用日志级别设置为 [info];
[application.properties]
logging.level.org.springframework.web=INFO
logging.level.org.hibernate=OFF
spring.main.show-banner=false
第 1-2 行设置了应用程序某些部分的特定日志级别:
- 第 1 行:我们希望获取来自 [web] 层的日志;
- 第 2 行:我们不希望获取来自 [JPA] 层的日志;
- 第 3 行:不显示 Spring Boot 标题;
执行过程中的日志如下:
11:06:04,279 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Could NOT find resource [logback.groovy]
11:06:04,279 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Could NOT find resource [logback-test.xml]
11:06:04,279 |-INFO in ch.qos.logback.classic.LoggerContext[default] - Found resource [logback.xml] at [file:/D:/data/istia-1516/projets/springmvc-thymeleaf/dvp-final/etude-de-cas/rdvmedecins-webjson-server/target/classes/logback.xml]
11:06:04,279 |-WARN in ch.qos.logback.classic.LoggerContext[default] - Resource [logback.xml] occurs multiple times on the classpath.
11:06:04,279 |-WARN in ch.qos.logback.classic.LoggerContext[default] - Resource [logback.xml] occurs at [file:/D:/data/istia-1516/projets/springmvc-thymeleaf/dvp-final/etude-de-cas/rdvmedecins-metier-dao/target/classes/logback.xml]
11:06:04,279 |-WARN in ch.qos.logback.classic.LoggerContext[default] - Resource [logback.xml] occurs at [file:/D:/data/istia-1516/projets/springmvc-thymeleaf/dvp-final/etude-de-cas/rdvmedecins-webjson-server/target/classes/logback.xml]
11:06:04,342 |-INFO in ch.qos.logback.classic.joran.action.ConfigurationAction - debug attribute not set
11:06:04,342 |-INFO in ch.qos.logback.core.joran.action.AppenderAction - About to instantiate appender of type [ch.qos.logback.core.ConsoleAppender]
11:06:04,342 |-INFO in ch.qos.logback.core.joran.action.AppenderAction - Naming appender as [STDOUT]
11:06:04,357 |-INFO in ch.qos.logback.core.joran.action.NestedComplexPropertyIA - Assuming default type [ch.qos.logback.classic.encoder.PatternLayoutEncoder] for [encoder] property
11:06:04,404 |-INFO in ch.qos.logback.classic.joran.action.RootLoggerAction - Setting level of ROOT logger to INFO
11:06:04,404 |-INFO in ch.qos.logback.core.joran.action.AppenderRefAction - Attaching appender named [STDOUT] to Logger[ROOT]
11:06:04,404 |-INFO in ch.qos.logback.classic.joran.action.ConfigurationAction - End of configuration.
11:06:04,420 |-INFO in ch.qos.logback.classic.joran.JoranConfigurator@56f4468b - Registering current configuration as safe fallback point
11:06:04.732 [main] INFO rdvmedecins.web.boot.Boot - Starting Boot on Gportpers3 with PID 420 (D:\data\istia-1516\projets\springmvc-thymeleaf\dvp-final\etude-de-cas\rdvmedecins-webjson-server\target\classes started by usrlocal in D:\data\istia-1516\projets\springmvc-thymeleaf\dvp-final\etude-de-cas\rdvmedecins-webjson-server)
11:06:04.775 [main] INFO o.s.b.c.e.AnnotationConfigEmbeddedWebApplicationContext - Refreshing org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@2ea6137: startup date [Wed Oct 14 11:06:04 CEST 2015]; root of context hierarchy
11:06:05.538 [main] INFO o.s.b.c.e.t.TomcatEmbeddedServletContainer - Tomcat initialized with port(s): 8080 (http)
11:06:05.688 [main] INFO o.a.catalina.core.StandardService - Starting service Tomcat
11:06:05.689 [main] INFO o.a.catalina.core.StandardEngine - Starting Servlet Engine: Apache Tomcat/8.0.26
11:06:05.833 [localhost-startStop-1] INFO o.a.c.c.C.[Tomcat].[localhost].[/] - Initializing Spring embedded WebApplicationContext
11:06:05.833 [localhost-startStop-1] INFO o.s.web.context.ContextLoader - Root WebApplicationContext: initialization completed in 1061 ms
11:06:06.231 [localhost-startStop-1] INFO o.s.o.j.LocalContainerEntityManagerFactoryBean - Building JPA container EntityManagerFactory for persistence unit 'default'
11:06:09.234 [localhost-startStop-1] INFO o.s.s.web.DefaultSecurityFilterChain - Creating filter chain: org.springframework.security.web.util.matcher.AnyRequestMatcher@1, [org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@12d14fa, org.springframework.security.web.context.SecurityContextPersistenceFilter@29823fb6, org.springframework.security.web.header.HeaderWriterFilter@662d93b2, org.springframework.security.web.authentication.logout.LogoutFilter@2d81ee0, org.springframework.security.web.authentication.www.BasicAuthenticationFilter@52aa47ad, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@60bd7a74, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@5a374232, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@7ddb4452, org.springframework.security.web.session.SessionManagementFilter@2cd9855f, org.springframework.security.web.access.ExceptionTranslationFilter@2263f0a2, org.springframework.security.web.access.intercept.FilterSecurityInterceptor@192ce7f6]
11:06:09.255 [localhost-startStop-1] INFO o.s.b.c.e.ServletRegistrationBean - Mapping servlet: 'dispatcherServlet' to [/*]
11:06:09.255 [localhost-startStop-1] INFO o.s.b.c.e.FilterRegistrationBean - Mapping filter: 'springSecurityFilterChain' to: [/*]
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/authenticate],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.Void> rdvmedecins.web.controllers.RdvMedecinsController.authenticate(javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAgendaMedecinJour/{idMedecin}/{jour}],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.getAgendaMedecinJour(long,java.lang.String,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAllCreneaux/{idMedecin}],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.getAllCreneaux(long,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getRvMedecinJour/{idMedecin}/{jour}],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.getRvMedecinJour(long,java.lang.String,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getMedecinById/{id}],methods=[GET]}" onto public rdvmedecins.web.models.Response<rdvmedecins.entities.Medecin> rdvmedecins.web.controllers.RdvMedecinsController.getMedecinById(long,javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getClientById/{id}],methods=[GET]}" onto public rdvmedecins.web.models.Response<rdvmedecins.entities.Client> rdvmedecins.web.controllers.RdvMedecinsController.getClientById(long,javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/supprimerRv],methods=[POST],consumes=[application/json;charset=UTF-8]}" onto public rdvmedecins.web.models.Response<java.lang.Void> rdvmedecins.web.controllers.RdvMedecinsController.supprimerRv(rdvmedecins.web.models.PostSupprimerRv,javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAllClients],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.util.List<rdvmedecins.entities.Client>> rdvmedecins.web.controllers.RdvMedecinsController.getAllClients(javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/ajouterRv],methods=[POST],consumes=[application/json;charset=UTF-8]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.ajouterRv(rdvmedecins.web.models.PostAjouterRv,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getCreneauById/{id}],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.getCreneauById(long,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getAllMedecins],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.util.List<rdvmedecins.entities.Medecin>> rdvmedecins.web.controllers.RdvMedecinsController.getAllMedecins(javax.servlet.http.HttpServletResponse,java.lang.String)
11:06:09.536 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerMapping - Mapped "{[/getRvById/{id}],methods=[GET]}" onto public rdvmedecins.web.models.Response<java.lang.String> rdvmedecins.web.controllers.RdvMedecinsController.getRvById(long,javax.servlet.http.HttpServletResponse,java.lang.String) throws com.fasterxml.jackson.core.JsonProcessingException
...
11:06:09.677 [main] INFO o.s.w.s.m.m.a.RequestMappingHandlerAdapter - Looking for @ControllerAdvice: org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@2ea6137: startup date [Wed Oct 14 11:06:04 CEST 2015]; root of context hierarchy
11:06:09.770 [main] INFO o.a.coyote.http11.Http11NioProtocol - Initializing ProtocolHandler ["http-nio-8080"]
11:06:09.786 [main] INFO o.a.coyote.http11.Http11NioProtocol - Starting ProtocolHandler ["http-nio-8080"]
11:06:09.802 [main] INFO o.a.tomcat.util.net.NioSelectorPool - Using a shared selector for servlet write/read
11:06:09.817 [main] INFO o.s.b.c.e.t.TomcatEmbeddedServletContainer - Tomcat started on port(s): 8080 (http)
11:06:09.817 [main] INFO rdvmedecins.web.boot.Boot - Started Boot in 5.319 seconds (JVM running for 6.053)
- 第 18 行:Tomcat 服务器已启动;
- 第 21 行:正在初始化 Spring 上下文;
- 第 27–38 行:正在发现 Web 服务暴露的 URL;
- 第 44 行:Tomcat 服务器已就绪,正在 8080 端口等待请求;
如果我们将 [application.properties] 文件修改如下:
logging.level.org.springframework.web: OFF
logging.level.org.hibernate:OFF
spring.main.show-banner=false
我们得到以下日志:
此外,如果我们将 [logback.xml] 文件修改如下:
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- encoders are by default assigned the type ch.qos.logback.classic.encoder.PatternLayoutEncoder -->
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- log level control -->
<root level="off"> <!-- off, info, debug, warn -->
<appender-ref ref="STDOUT" />
</root>
</configuration>
获得以下日志:
由此可见,我们可以对控制台中显示的日志进行一定程度的控制。[info] 级别通常是合适的日志级别。
现在,我们已拥有一个可通过 Web 客户端进行查询的可运行 Web 服务。接下来我们将探讨如何保障该服务的安全:我们希望仅允许特定人员管理医生的预约。为此,我们将使用 Spring 生态系统中的一个组件——Spring Security 框架。
8.4.12. Spring Security 简介
![]() |
![]() |
该项目包括以下内容:
- 在 [templates] 文件夹中,您将找到该项目的 HTML 页面;
- [Application]:是项目的可执行类;
- [MvcConfig]:是 Spring MVC 的配置类;
- [WebSecurityConfig]:是 Spring Security 的配置类;
8.4.12.1. Maven 配置
项目 [3] 是一个 Maven 项目。让我们查看其 [pom.xml] 文件,了解其依赖项:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.springframework</groupId>
<artifactId>gs-securing-web</artifactId>
<version>0.1.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.1.10.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- tag::security[] -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- end::security[] -->
</dependencies>
<properties>
<start-class>hello.Application</start-class>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
- 第 10–14 行:该项目是一个 Spring Boot 项目;
- 第 17–20 行:依赖 [Thymeleaf] 框架;
- 第 22–25 行:依赖 Spring Security 框架;
8.4.12.2. Thymeleaf 视图
![]() |
[home.html] 视图如下:
![]() |
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
<h1>Welcome!</h1>
<p>
Click <a th:href="@{/hello}">here</a> to see a greeting.
</p>
</body>
</html>
- 第 12 行:属性 [th:href="@{/hello}"] 将生成 <a> 标签的 [href] 属性。值 [@{/hello}] 将生成路径 [<context>/hello],其中 [context] 是 Web 应用程序的上下文;
生成的 HTML 代码如下:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
<h1>Welcome!</h1>
<p>
Click
<a href="/hello">here</a>
to see a greeting.
</p>
</body>
</html>
[hello.html] 视图如下:
![]() |
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Hello World!</title>
</head>
<body>
<h1 th:inline="text">Hello [[${#httpServletRequest.remoteUser}]]!</h1>
<form th:action="@{/logout}" method="post">
<input type="submit" value="Sign Out" />
</form>
</body>
</html>
- 第 9 行:[th:inline="text"] 属性将生成 <h1> 标签的文本内容。该文本包含一个必须进行求值的 $ 表达式。元素 [[${#httpServletRequest.remoteUser}]] 是当前 HTTP 请求中 [RemoteUser] 属性的值。这是已登录用户的名称;
- 第 10 行:一个 HTML 表单。[th:action="@{/logout}"] 属性将生成 [form] 标签的 [action] 属性。值 [@{/logout}] 将生成路径 [<context>/logout],其中 [context] 是 Web 应用程序上下文;
生成的 HTML 代码如下:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Hello World!</title>
</head>
<body>
<h1>Hello user!</h1>
<form method="post" action="/logout">
<input type="submit" value="Sign Out" />
<input type="hidden" name="_csrf" value="b152e5b9-d1a4-4492-b89d-b733fe521c91" />
</form>
</body>
</html>
- 第 8 行:Hello [[${#httpServletRequest.remoteUser}]]! 的翻译;
- 第 9 行:@{/logout} 的翻译;
- 第 11 行:一个名为 _csrf 的隐藏字段(name 属性);
最终的视图 [login.html] 如下:
![]() |
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
<div th:if="${param.error}">Invalid username and password.</div>
<div th:if="${param.logout}">You have been logged out.</div>
<form th:action="@{/login}" method="post">
<div>
<label> User Name : <input type="text" name="username" />
</label>
</div>
<div>
<label> Password: <input type="password" name="password" />
</label>
</div>
<div>
<input type="submit" value="Sign In" />
</div>
</form>
</body>
</html>
- 第 9 行:属性 [th:if="${param.error}"] 确保只有当显示登录页面的 URL 包含 [error] 参数(http://context/login?error)时,才会生成 <div> 标签;
- 第 10 行:属性 [th:if="${param.logout}"] 确保只有当显示登录页面的 URL 包含 [logout] 参数(http://context/login?logout)时,才会生成 <div> 标签;
- 第 11–23 行:一个 HTML 表单;
- 第 11 行:表单将提交至 URL [<context>/login],其中 <context> 是 Web 应用程序上下文;
- 第 13 行:一个名为 [username] 的输入字段;
- 第 17 行:一个名为 [password] 的输入字段;
生成的 HTML 代码如下:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example </title>
</head>
<body>
<div>
You have been logged out.
</div>
<form method="post" action="/login">
<div>
<label>
User Name :
<input type="text" name="username" />
</label>
</div>
<div>
<label>
Password:
<input type="password" name="password" />
</label>
</div>
<div>
<input type="submit" value="Sign In" />
</div>
<input type="hidden" name="_csrf" value="ef809b0a-88b4-4db9-bc53-342216b77632" />
</form>
</body>
</html>
请注意第 28 行,Thymeleaf 添加了一个名为 [_csrf] 的隐藏字段。
8.4.12.3. Spring MVC 配置
![]() |
[MvcConfig] 类用于配置 Spring MVC 框架:
package hello;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
@Configuration
public class MvcConfig extends WebMvcConfigurerAdapter {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/home").setViewName("home");
registry.addViewController("/").setViewName("home");
registry.addViewController("/hello").setViewName("hello");
registry.addViewController("/login").setViewName("login");
}
}
- 第 7 行:[@Configuration] 注解将 [MvcConfig] 类定义为配置类;
- 第 8 行:[MvcConfig] 类继承自 [WebMvcConfigurerAdapter] 类,以重写某些方法;
- 第 10 行:重定义了父类中的一个方法;
- 第 11–16 行:[addViewControllers] 方法允许将 URL 与 HTML 视图关联。其中建立了以下关联:
URL | 视图 |
/templates/home.html | |
/templates/hello.html | |
/templates/login.html |
后缀 [html] 和 [templates] 文件夹是 Thymeleaf 使用的默认值。它们可以通过配置进行更改。 [templates] 文件夹必须位于项目类路径的根目录下:
![]() |
在上文的 [1] 中,[java] 和 [resources] 文件夹均为源文件夹。这意味着它们的内容将位于项目类路径的根目录下。因此,在 [2] 中,[hello] 和 [templates] 文件夹将位于类路径的根目录下。
8.4.12.4. Spring Security 配置
![]() |
[WebSecurityConfig] 类用于配置 Spring Security 框架:
package hello;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.servlet.configuration.EnableWebMvcSecurity;
@Configuration
@EnableWebMvcSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/", "/home").permitAll().anyRequest().authenticated();
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser("user").password("password").roles("USER");
}
}
- 第 9 行:[@Configuration] 注解将 [WebSecurityConfig] 类定义为配置类;
- 第 10 行:[@EnableWebSecurity] 注解将 [WebSecurityConfig] 类定义为 Spring Security 配置类;
- 第 11 行:[WebSecurity] 类继承自 [WebSecurityConfigurerAdapter] 类,以重写某些方法;
- 第 12 行:重写父类中的一个方法;
- 第 13–16 行:重写 [configure(HttpSecurity http)] 方法,用于定义应用程序各 URL 的访问权限;
- 第 14 行:[http.authorizeRequests()] 方法允许将 URL 与访问权限关联。其中建立了以下关联:
URL | 规则 | 代码 |
无需身份验证的访问 | | |
仅限经过身份验证的访问 |
- 第 15 行:定义身份验证方法。身份验证通过一个对所有人开放的 URL 表单 [/login] 进行 [http.formLogin().loginPage("/login").permitAll()]。注销功能也对所有人开放;
- 第 19–21 行:重新定义管理用户的 [configure(AuthenticationManagerBuilder auth)] 方法;
- 第 20 行:使用硬编码的用户进行身份验证 [auth.inMemoryAuthentication()]。此处通过登录名 [user]、密码 [password] 和角色 [USER] 定义用户。具有相同角色的用户可被授予相同的权限;
8.4.12.5. 可执行类
![]() |
[Application] 类的定义如下:
package hello;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
@EnableAutoConfiguration
@Configuration
@ComponentScan
public class Application {
public static void main(String[] args) throws Throwable {
SpringApplication.run(Application.class, args);
}
}
- 第 8 行:[@EnableAutoConfiguration] 注解指示 Spring Boot(第 3 行)执行开发者未显式配置的配置;
- 第 9 行:将 [Application] 类设为 Spring 配置类;
- 第 10 行:指示系统扫描包含 [Application] 类的目录以查找 Spring 组件。因此,[MvcConfig] 和 [WebSecurityConfig] 这两个类会被发现,因为它们带有 [@Configuration] 注解;
- 第 13 行:可执行类的 [main] 方法;
- 第 14 行:以 [Application] 配置类作为参数执行静态方法 [SpringApplication.run]。我们之前已经遇到过这个过程,知道项目 Maven 依赖中嵌入的 Tomcat 服务器将被启动,项目也将部署到该服务器上。我们看到有四个 URL 被处理 [/, /home, /login, /hello],其中部分 URL 受访问权限保护。
8.4.12.6. 测试应用程序
让我们先请求 URL [/],这是四个被接受的 URL 之一。它关联的视图是 [/templates/home.html]:
![]() |
请求的 URL [/] 对所有人开放。这就是我们能够获取它的原因。链接 [此处] 如下:
点击该链接时,系统将请求 URL [/hello]。该 URL 受保护:
URL | 规则 | 代码 |
无需身份验证的访问 | | |
仅限经过身份验证的访问 |
您必须经过身份验证才能访问该页面。随后,Spring Security 将把客户端浏览器重定向至身份验证页面。根据所示配置,该页面的 URL 为 [/login]。该页面对所有人开放:
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
因此我们得到 [1]:
![]() |
生成的页面的源代码如下:
- 第 7 行出现了一个隐藏字段,该字段在原始的 [login.html] 页面中并不存在。这是 Thymeleaf 添加的。这段代码被称为 CSRF(跨站请求伪造),旨在消除一个安全漏洞。该令牌必须与身份验证信息一起发回给 Spring Security,才能被接受;
我们回顾一下,Spring Security 仅识别用户名/密码对。如果我们在 [2] 中输入其他内容,将看到 [3] 处带有错误信息的同一页面。Spring Security 已将浏览器重定向至 URL [http://localhost:8080/login?error]。由于存在 [error] 参数,触发了以下标签的显示:
<div th:if="${param.error}">Invalid username and password.</div>
现在,让我们输入预期的用户名/密码值 [4]:
![]() |
- 在 [4] 中,我们登录;
- 在[5]中,Spring Security 将我们重定向到了 URL [/hello],因为这是我们在被重定向到登录页面时请求的 URL。用户的身份信息通过 [hello.html] 中的以下代码行显示出来:
<h1 th:inline="text">Hello [[${#httpServletRequest.remoteUser}]]!</h1>
页面 [5] 显示如下表单:
<form th:action="@{/logout}" method="post">
<input type="submit" value="Sign Out" />
</form>
当您点击 [注销] 按钮时,系统会向 URL [/logout] 发送一个 POST 请求。与 URL [/login] 一样,该 URL 对所有人开放:
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
在我们的 URL/视图映射中,我们尚未为 URL [/logout] 定义任何内容。会发生什么?让我们试一试:
![]() |
- 在 [6] 中,我们点击了 [Sign Out] 按钮;
- 在 [7] 中,我们可以看到已被重定向到 URL [http://localhost:8080/login?logout]。这是 Spring Security 发起的重定向。URL 中 [logout] 参数的存在导致视图中显示了以下内容:
<div th:if="${param.logout}">You have been logged out.</div>
8.4.12.7. 结论
在上一个示例中,我们本可以先编写 Web 应用程序,然后在后续阶段再对其进行安全加固。Spring Security 具有非侵入性。您可以为已经编写好的 Web 应用程序实现安全功能。此外,我们还发现了以下几点:
- 可以定义身份验证页面;
- 身份验证必须伴随 Spring Security 生成的 CSRF 令牌;
- 如果身份验证失败,系统会将您重定向至身份验证页面,且 URL 中会附加一个错误参数;
- 若认证成功,系统将重定向至认证时请求的页面。若直接请求认证页面而不经过中间页面,Spring Security 会将您重定向至 URL [/](此情况未在演示中展示);
- 您可通过向 URL [/logout] 发送 POST 请求来注销。随后 Spring Security 会将您重定向至认证页面,并在 URL 中包含 "logout" 参数;
以上结论均基于 Spring Security 的默认行为。通过重写 [WebSecurityConfigurerAdapter] 类的某些方法,可通过配置更改此行为。
之前的教程对我们后续的工作帮助不大。实际上,我们将使用:
- 一个数据库来存储用户、密码及其角色;
- 基于 HTTP 头部的身份验证;
关于我们要实现的功能,现有的教程相对较少。我们将提出的解决方案是整合了从各处收集的代码片段。
8.4.13. 在预约 Web 服务中实现安全功能
8.4.13.1. 数据库
[rdvmedecins] 数据库正在更新,以包含用户、其密码及其角色。正在添加三个新表:

表 [USERS]:用户
- ID:主键;
- VERSION:行版本控制列;
- IDENTITY:用户的描述性标识符;
- LOGIN:用户的登录名;
- PASSWORD:用户的密码;
在 USERS 表中,密码不会以明文形式存储:
![]() |
用于加密密码的算法是 BCRYPT 算法。
[ROLES] 表:角色
- ID:主键;
- VERSION:该行的版本控制列;
- NAME:角色名称。默认情况下,Spring Security 期望名称采用 ROLE_XX 的格式,例如 ROLE_ADMIN 或 ROLE_GUEST;
![]() |
表 [USERS_ROLES]:USERS/ROLES 关联表
一个用户可以拥有多个角色,一个角色也可以包含多个用户。这是一种多对多关系,由 [USERS_ROLES] 表表示。
- ID:主键;
- VERSION:行版本控制列;
- USER_ID:用户标识符;
- ROLE_ID:角色的标识符;
![]() |
由于我们正在修改数据库,因此项目中的所有层 [业务逻辑、DAO、JPA] 都必须进行修改:
![]() |
8.4.13.2. 针对 [业务逻辑、DAO、JPA] 的新 STS 项目
[rdvmedecins-business-dao] 项目的演变如下:
![]() |
- 在 [1] 中:新项目;
- 在 [2] 中:安全功能实现所引入的变更已被归入一个单独的包 [rdvmedecins.security]。这些新元素属于 [JPA] 和 [DAO] 层,但为简化起见,已将其归入同一个包。
8.4.13.3. 新的 [JPA] 实体
![]() |
JPA 层定义了三个新实体:
![]() |
[User] 类表示 [USERS] 表:
package rdvmedecins.entities;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
@Entity
@Table(name = "USERS")
public class User extends AbstractEntity {
private static final long serialVersionUID = 1L;
// properties
private String identity;
private String login;
private String password;
// manufacturer
public User() {
}
public User(String identity, String login, String password) {
this.identity = identity;
this.login = login;
this.password = password;
}
// identity
@Override
public String toString() {
return String.format("User[%s,%s,%s]", identity, login, password);
}
// getters and setters
....
}
- 第 9 行:该类继承了其他实体已使用的 [AbstractEntity] 类;
- 第 13–15 行:未指定列名,因为它们与关联字段的名称相同;
[Role] 类映射了 [ROLES] 表:
package rdvmedecins.entities;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
@Entity
@Table(name = "ROLES")
public class Role extends AbstractEntity {
private static final long serialVersionUID = 1L;
// properties
private String name;
// manufacturers
public Role() {
}
public Role(String name) {
this.name = name;
}
// identity
@Override
public String toString() {
return String.format("Role[%s]", name);
}
// getters and setters
...
}
[UserRole] 类表示 [USERS_ROLES] 表:
package rdvmedecins.entities;
import javax.persistence.Entity;
import javax.persistence.JoinColumn;
import javax.persistence.ManyToOne;
import javax.persistence.Table;
@Entity
@Table(name = "USERS_ROLES")
public class UserRole extends AbstractEntity {
private static final long serialVersionUID = 1L;
// a UserRole refers to a User
@ManyToOne
@JoinColumn(name = "USER_ID")
private User user;
// a UserRole refers to a Role
@ManyToOne
@JoinColumn(name = "ROLE_ID")
private Role role;
// getters and setters
...
}
- 第 15-17 行:定义从 [USERS_ROLES] 表到 [USERS] 表的外键;
- 第 19-21 行:实现从 [USERS_ROLES] 表到 [ROLES] 表的外键;
8.4.13.4. [DAO] 层的变更
![]() |
[DAO] 层新增了三个 [Repository]:
![]() |
[UserRepository] 接口负责管理对 [User] 实体的访问:
package rdvmedecins.repositories;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import rdvmedecins.entities.Role;
import rdvmedecins.entities.User;
public interface UserRepository extends CrudRepository<User, Long> {
// liste des rôles d'un utilisateur identifié par son id
@Query("select ur.role from UserRole ur where ur.user.id=?1")
Iterable<Role> getRoles(long id);
// liste des rôles d'un utilisateur identifié par son login et son mot de passe
@Query("select ur.role from UserRole ur where ur.user.login=?1 and ur.user.password=?2")
Iterable<Role> getRoles(String login, String password);
// recherche d'un utilisateur via son login
User findUserByLogin(String login);
}
- 第 9 行:[UserRepository] 接口继承了 Spring Data 的 [CrudRepository] 接口(第 4 行);
- 第 12-13 行:[getRoles(User user)] 方法根据 [id] 检索指定用户的全部角色
- 第 16-17 行:与上文相同,但针对通过登录名和密码标识的用户;
- 第 20 行:根据用户登录名查找用户;
[RoleRepository] 接口管理对 [Role] 实体的访问:
package rdvmedecins.security;
import org.springframework.data.repository.CrudRepository;
public interface RoleRepository extends CrudRepository<Role, Long> {
// search for a role by name
Role findRoleByName(String name);
}
- 第 5 行:[RoleRepository] 接口继承自 [CrudRepository] 接口;
- 第 8 行:您可以按名称搜索角色;
[userRoleRepository] 接口管理对 [UserRole] 实体的访问:
package rdvmedecins.security;
import org.springframework.data.repository.CrudRepository;
public interface UserRoleRepository extends CrudRepository<UserRole, Long> {
}
- 第 5 行:[UserRoleRepository] 接口只是继承了 [CrudRepository] 接口,并未添加任何新方法;
8.4.13.5. 用户和角色管理类
![]() |
Spring Security 要求创建一个实现以下 [UsersDetail] 接口的类:
![]() |
此接口在此由 [AppUserDetails] 类实现:
package rdvmedecins.security;
import java.util.ArrayList;
import java.util.Collection;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
public class AppUserDetails implements UserDetails {
private static final long serialVersionUID = 1L;
// properties
private User user;
private UserRepository userRepository;
// manufacturers
public AppUserDetails() {
}
public AppUserDetails(User user, UserRepository userRepository) {
this.user = user;
this.userRepository = userRepository;
}
// -------------------------interface
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
for (Role role : userRepository.getRoles(user.getId())) {
authorities.add(new SimpleGrantedAuthority(role.getName()));
}
return authorities;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getLogin();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
// getters and setters
...
}
- 第 10 行:[AppUserDetails] 类实现了 [UserDetails] 接口;
- 第 15–16 行:该类封装了一个用户(第 15 行)以及提供该用户详细信息的存储库(第 16 行);
- 第 22–25 行:构造函数,用于通过用户及其存储库实例化该类;
- 第 28–35 行:实现 [UserDetails] 接口的 [getAuthorities] 方法。该方法必须构建一个由 [GrantedAuthority] 类型或其派生类型元素组成的集合。此处我们使用派生类型 [SimpleGrantedAuthority](第 32 行),该类型封装了第 15 行中用户的某个角色的名称;
- 第 31–33 行:遍历第 15 行中用户的角色列表,以构建一个 [SimpleGrantedAuthority] 类型的元素列表;
- 第 38–40 行:实现 [UserDetails] 接口的 [getPassword] 方法。返回第 15 行用户的密码;
- 第 38–40 行:实现 [UserDetails] 接口的 [getUserName] 方法。返回第 15 行中用户的登录名;
- 第 47–50 行:用户的账户永不过期;
- 第 52–55 行:用户的账户永不过期;
- 第 57–60 行:用户的凭据永不过期;
- 第 62–65 行:用户的账户始终处于活动状态;
Spring Security 还要求存在一个实现 [AppUserDetailsService] 接口的类:
![]() |
该接口由以下 [AppUserDetailsService] 类实现:
package rdvmedecins.security;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
public class AppUserDetailsService implements UserDetailsService {
@Autowired
private UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String login) throws UsernameNotFoundException {
// search for user via login
User user = userRepository.findUserByLogin(login);
// found?
if (user == null) {
throw new UsernameNotFoundException(String.format("login [%s] inexistant", login));
}
// render user details
return new AppUserDetails(user, userRepository);
}
}
- 第 9 行:该类将作为 Spring 组件,因此可在其上下文中使用;
- 第 12–13 行:[UserRepository] 组件将在此处被注入;
- 第 16–25 行:实现 [UserDetailsService] 接口(第 10 行)的 [loadUserByUsername] 方法。参数是用户的登录名;
- 第 18 行:使用用户的登录名进行搜索;
- 第 20–22 行:若未找到用户,则抛出异常;
- 第 24 行:构造并返回一个 [AppUserDetails] 对象。它确实是 [UserDetails] 类型(第 16 行);
8.4.13.6. [DAO] 层测试
![]() |
首先,我们创建一个可执行类 [CreateUser],该类能够创建具有特定角色的用户:
package rdvmedecins.security;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.security.crypto.bcrypt.BCrypt;
import rdvmedecins.config.DomainAndPersistenceConfig;
import rdvmedecins.security.Role;
import rdvmedecins.security.RoleRepository;
import rdvmedecins.security.User;
import rdvmedecins.security.UserRepository;
import rdvmedecins.security.UserRole;
import rdvmedecins.security.UserRoleRepository;
public class CreateUser {
public static void main(String[] args) {
// syntax: login password roleName
// three parameters are required
if (args.length != 3) {
System.out.println("Syntaxe : [pg] user password role");
System.exit(0);
}
// parameters are retrieved
String login = args[0];
String password = args[1];
String roleName = String.format("ROLE_%s", args[2].toUpperCase());
// spring context
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(DomainAndPersistenceConfig.class);
UserRepository userRepository = context.getBean(UserRepository.class);
RoleRepository roleRepository = context.getBean(RoleRepository.class);
UserRoleRepository userRoleRepository = context.getBean(UserRoleRepository.class);
// does the role already exist?
Role role = roleRepository.findRoleByName(roleName);
// if it doesn't exist, we create it
if (role == null) {
role = roleRepository.save(new Role(roleName));
}
// does the user already exist?
User user = userRepository.findUserByLogin(login);
// if it doesn't exist, we create it
if (user == null) {
// hash the password with bcrypt
String crypt = BCrypt.hashpw(password, BCrypt.gensalt());
// save user
user = userRepository.save(new User(login, login, crypt));
// we create the relationship with the role
userRoleRepository.save(new UserRole(user, role));
} else {
// the user already exists - does he/she have the required role?
boolean trouvé = false;
for (Role r : userRepository.getRoles(user.getId())) {
if (r.getName().equals(roleName)) {
trouvé = true;
break;
}
}
// if not found, we create the relationship with the role
if (!trouvé) {
userRoleRepository.save(new UserRole(user, role));
}
}
// closing Spring context
context.close();
}
}
- 第 17 行:该类期望接收三个定义用户的参数:登录名、密码和角色;
- 第 25–27 行:获取这三个参数;
- 第 29 行:从配置类 [DomainAndPersistenceConfig] 构建 Spring 上下文。该类在初始项目中已存在。必须按以下方式对其进行更新:
@EnableJpaRepositories(basePackages = { "rdvmedecins.repositories", "rdvmedecins.security" })
@EnableAutoConfiguration
@ComponentScan(basePackages = { "rdvmedecins" })
@EntityScan(basePackages = { "rdvmedecins.entities", "rdvmedecins.security" })
@EnableTransactionManagement
public class DomainAndPersistenceConfig {
....
}
- 第 1 行:您必须指定 [rdvmedecins.security] 包中现在包含 [Repository] 组件;
- 第 4 行:必须指定 [rdvmedecins.security] 包中现在包含 JPA 实体;
让我们回到创建用户的代码:
- 第 30–32 行:我们获取三个 [Repository] 对象的引用,这些引用在创建用户时可能派上用场;
- 第 34 行:检查该角色是否已存在;
- 第 36–38 行:如果不存在,则在数据库中创建该角色。其名称将采用 [ROLE_XX] 的格式;
- 第 40 行:检查登录名是否已存在;
- 第 42–49 行:如果用户名不存在,则在数据库中创建它;
- 第 44 行:对密码进行加密。此处使用 Spring Security 中的 [BCrypt] 类(第 4 行)。因此我们需要该框架的依赖包。[pom.xml] 文件中新增了一项依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
- 第 46 行:用户被持久化到数据库中;
- 第 48 行:以及将其与角色关联的关系;
- 第 51–57 行:如果登录用户已存在,则检查我们要分配给该用户的角色是否已在其角色列表中;
- 第 59–61 行:如果未找到目标角色,则在 [USERS_ROLES] 表中创建一行,将用户与其角色关联起来;
- 我们尚未对潜在的异常进行处理。这是一个用于快速创建带角色的用户的辅助类。
当该类以参数 [x x guest] 执行时,数据库中将生成以下结果:
表 [USERS]
![]() |
表 [ROLES]
![]() |
表 [USERS_ROLES]
![]() |
现在我们来看看第二个类 [UsersTest],这是一个 JUnit 测试:
![]() |
package rdvmedecins.security;
import java.util.List;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.crypto.bcrypt.BCrypt;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import rdvmedecins.config.DomainAndPersistenceConfig;
import com.google.common.collect.Lists;
@SpringApplicationConfiguration(classes = DomainAndPersistenceConfig.class)
@RunWith(SpringJUnit4ClassRunner.class)
public class UsersTest {
@Autowired
private UserRepository userRepository;
@Autowired
private AppUserDetailsService appUserDetailsService;
@Test
public void findAllUsersWithTheirRoles() {
Iterable<User> users = userRepository.findAll();
for (User user : users) {
System.out.println(user);
display("Roles :", userRepository.getRoles(user.getId()));
}
}
@Test
public void findUserByLogin() {
// user [admin] is retrieved
User user = userRepository.findUserByLogin("admin");
// we check that his password is [admin]
Assert.assertTrue(BCrypt.checkpw("admin", user.getPassword()));
// check admin / admin role
List<Role> roles = Lists.newArrayList(userRepository.getRoles("admin", user.getPassword()));
Assert.assertEquals(1L, roles.size());
Assert.assertEquals("ROLE_ADMIN", roles.get(0).getName());
}
@Test
public void loadUserByUsername() {
// user [admin] is retrieved
AppUserDetails userDetails = (AppUserDetails) appUserDetailsService.loadUserByUsername("admin");
// we check that his password is [admin]
Assert.assertTrue(BCrypt.checkpw("admin", userDetails.getPassword()));
// check admin / admin role
@SuppressWarnings("unchecked")
List<SimpleGrantedAuthority> authorities = (List<SimpleGrantedAuthority>) userDetails.getAuthorities();
Assert.assertEquals(1L, authorities.size());
Assert.assertEquals("ROLE_ADMIN", authorities.get(0).getAuthority());
}
// utility method - displays items in a collection
private void display(String message, Iterable<?> elements) {
System.out.println(message);
for (Object element : elements) {
System.out.println(element);
}
}
}
- 第 27–34 行:视觉测试。我们显示所有用户及其角色;
- 第 36–46 行:我们使用 [UserRepository] 验证用户 [admin] 的密码为 [admin] 且角色为 [ROLE_ADMIN];
- 第 41 行:[admin] 是明文密码。在数据库中,它使用 BCrypt 算法进行加密。[BCrypt.checkpw] 方法用于验证加密后的明文密码是否与数据库中的密码匹配;
- 第 48–59 行:我们使用 [appUserDetailsService] 验证用户 [admin] 的密码为 [admin] 且角色为 [ROLE_ADMIN];
测试运行成功,日志如下:
8.4.13.7. 阶段性结论
在几乎不改变原始项目的情况下,已添加了 Spring Security 所需的类。总结如下:
- 在 [pom.xml] 文件中添加对 Spring Security 的依赖;
- 在数据库中创建了三个额外表;
- 在 [rdvmedecins.security] 包中创建了 JPA 实体和 Spring 组件;
这种非常理想的方案源于一个事实:即添加到数据库中的这三个表与现有表是相互独立的。 我们甚至可以将它们放置在独立的数据库中。之所以能够这样做,是因为我们认定用户与医生及客户是相互独立的。如果后者被视为潜在用户,我们就必须在 [USERS] 表与 [MEDECINS] 及 [CLIENTS] 表之间建立关联。这将对现有项目产生重大影响。
8.4.13.8. [web]层的STS项目
![]() |
[rdvmedecins-webjson] 项目的演进情况如下[1]:
![]() |
主要更改需要在 [rdvmedecins.web.config] 文件中进行,其中必须配置 Spring Security。此外,[AppConfig] 和 [ApplicationModel] 类中还有其他一些细微的更改。我们已经遇到过一个 Spring Security 配置类:
package hello;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.servlet.configuration.EnableWebMvcSecurity;
@Configuration
@EnableWebMvcSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/", "/home").permitAll().anyRequest().authenticated();
http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication().withUser("user").password("password").roles("USER");
}
}
我们将遵循相同的步骤:
- 第 11 行:定义一个继承自 [WebSecurityConfigurerAdapter] 类的类;
- 第 13 行:定义一个方法 [configure(HttpSecurity http)],用于定义 Web 服务各 URL 的访问权限;
- 第 19 行:定义一个方法 [configure(AuthenticationManagerBuilder auth)],用于定义用户及其角色;
Spring Security 的配置由 [SecurityConfig] 类负责:
package rdvmedecins.web.config;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import rdvmedecins.security.AppUserDetailsService;
import rdvmedecins.web.models.ApplicationModel;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AppUserDetailsService appUserDetailsService;
@Autowired
private ApplicationModel application;
@Override
protected void configure(AuthenticationManagerBuilder registry) throws Exception {
// authentication is performed by bean [appUserDetailsService]
// the password is encrypted using the BCrypt hash algorithm
registry.userDetailsService(appUserDetailsService).passwordEncoder(new BCryptPasswordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// CSRF
http.csrf().disable();
// secure application?
if (application.isSecured()) {
// the password is transmitted by the header Authorization: Basic xxxx
http.httpBasic();
// the HTTP OPTIONS method must be authorized for all
http.authorizeRequests() //
.antMatchers(HttpMethod.OPTIONS, "/", "/**").permitAll();
// only the ADMIN role can use the application
http.authorizeRequests() //
.antMatchers("/", "/**") // all URL
.hasRole("ADMIN");
// no session
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}
}
- 第 15 行:[SecurityConfig] 类是一个 Spring 配置类;
- 第 16 行:用于配置项目安全;
- 第 19–20 行:注入 [AppUserDetails] 类,该类提供对应用程序用户的访问;
- 第 21–22 行:注入了 [ApplicationModel] 类,该类作为 Web 应用程序的缓存。我们在此处也选择使用它,以便在单一位置配置 Web 应用程序。它在第 36 行定义了 [isSecured] 布尔值。该布尔值用于控制 Web 应用程序是否启用安全保护(true)或不启用(false);
- 第 25–29 行:[configure(HttpSecurity http)] 方法用于定义用户及其角色。它将 [AuthenticationManagerBuilder] 类型作为参数。该参数被补充了两项信息(第 28 行):
- 来自第 20 行的 [appUserDetailsService] 引用,该服务提供对已注册用户的访问。请注意,此处并未明确说明用户数据存储在数据库中。因此,它们也可能存储在缓存中,或由 Web 服务提供等。
- 密码所使用的加密类型。回顾一下,我们使用了 BCrypt 算法;
- 第 38–47 行:[configure(HttpSecurity http)] 方法定义了对 Web 服务 URL 的访问权限;
- 第 34 行:我们在入门项目中看到,默认情况下 Spring Security 会管理一个 CSRF(跨站请求伪造)令牌,尝试认证的用户必须将该令牌发回给服务器。此处已禁用该机制。结合布尔值(isSecured=false),这使得 Web 应用程序可在无安全保护的情况下使用;
- 第 38 行:我们启用了通过 HTTP 头进行身份验证。客户端必须发送以下 HTTP 头:
其中 code 是登录名:密码字符串的 Base64 编码。例如,字符串 admin:admin 的 Base64 编码为 YWRtaW46YWRtaW4=。因此,登录名为 [admin]、密码为 [admin] 的用户将发送以下 HTTP 头进行身份验证:
- 第 40–42 行:表示具有 [ROLE_ADMIN] 角色的用户可以访问 Web 服务的所有 URL。这意味着没有此角色的用户无法访问该 Web 服务;
- 第 47 行:用户的密码可能存储在会话中,也可能不存储。如果存储了,用户只需在首次登录时进行身份验证。后续请求中将不再要求提供凭据。在此,我们选择了无会话模式。每次请求都必须附带安全凭据;
用于配置整个应用程序的 [AppConfig] 类更新如下:
![]() |
package rdvmedecins.web.config;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import rdvmedecins.config.DomainAndPersistenceConfig;
@Configuration
@ComponentScan(basePackages = { "rdvmedecins.web" })
@Import({ DomainAndPersistenceConfig.class, SecurityConfig.class, WebConfig.class })
public class AppConfig {
}
- 更改发生在第 11 行:添加了 [SecurityConfig] 配置类;
最后,[ApplicationModel] 类新增了一个布尔值:
@Component
public class ApplicationModel implements IMetier {
...
// configuration data
private boolean secured = false;
public boolean isSecured() {
return secured;
}
- 第 6 行:根据是否需要启用安全功能,将布尔变量 [secured] 设置为 [true / false]。
8.4.13.9. Web 服务测试
我们将使用 Chrome 客户端 [Advanced Rest Client] 测试 Web 服务。我们需要指定 HTTP 身份验证头:
其中 [code] 是 Base64 编码的字符串 [登录名:密码]。要生成此代码,您可以使用以下程序:
![]() |
package rdvmedecins.helpers;
import org.springframework.security.crypto.codec.Base64;
public class Base64Encoder {
public static void main(String[] args) {
// we expect two arguments: login password
if (args.length != 2) {
System.out.println("Syntaxe : login password");
System.exit(0);
}
// we retrieve the two arguments
String chaîne = String.format("%s:%s", args[0], args[1]);
// encode the string
byte[] data = Base64.encode(chaîne.getBytes());
// displays its Base64 encoding
System.out.println(new String(data));
}
}
如果我们使用两个参数 [admin admin] 运行此程序:
![]() |
我们得到以下结果:
既然我们已经知道如何生成 HTTP 身份验证头,现在就来启动这个已加固的 Web 服务:
@Component
public class ApplicationModel implements IMetier {
...
private boolean secured = true;
然后,使用 Chrome 客户端 [Advanced Rest Client],我们请求所有医生的列表:
![]() |
- 在 [1] 中,我们通过 GET 方法请求医生列表的 URL;
- 在[2]中,使用GET方法;
- 在 [3] 中,我们提供了 HTTP 身份验证头。代码 [YWRtaW46YWRtaW4=] 是字符串 [admin:admin] 的 Base64 编码;
- 在 [4] 中,我们发送 HTTP 请求;
服务器的响应如下:
![]() |
- 在 [1] 中,HTTP 身份验证标头;
- 在 [2] 中,服务器返回一个 JSON 响应;
- 在 [3] 中,与 Web 应用程序安全相关的 HTTP 标头列表;
我们成功获取了医生列表:
![]() |
现在,让我们尝试发送一个包含错误身份验证标头的 HTTP 请求。此时响应如下:
![]() |
- 在 [1] 和 [3] 中:HTTP 身份验证标头;
- 在 [2] 中:Web 服务响应;
现在,我们来试一下用户“user / user”。该用户存在,但无权访问该 Web 服务。如果我们使用两个参数 [user user] 运行 Base64 编码程序:
![]() |
我们得到以下结果:
![]() |
- 在 [1] 和 [3] 中:HTTP 身份验证标头;
- 在 [2] 中:Web 服务响应。它与之前的 [401 未授权] 不同。这次,用户认证成功,但没有足够的权限访问该 URL;
一个安全的 Web 服务现已投入运行。我们将对其进行扩展,以支持跨域请求。这一需求曾在文档 [AngularJS / Spring 4 教程] 中提及,尽管它不适用于此处,但我们仍将予以处理。
8.4.14. 实现跨域请求
让我们来探讨跨域请求的问题。在文档 [AngularJS / Spring 4 教程] 中,我们正在开发一个客户端/服务器应用程序,其中客户端是一个 AngularJS 应用程序:
![]() |
- Angular 应用程序的 HTML/CSS/JS 页面来自服务器 [1];
- 在 [2] 中,[dao] 服务向另一台服务器(服务器 [2])发起请求。然而,运行 Angular 应用程序的浏览器会禁止此操作,因为这存在安全漏洞。该应用程序只能向其来源服务器(即服务器 [1])发起查询;
实际上,说浏览器阻止 Angular 应用程序查询服务器 [2] 并不准确。它实际上是向服务器 [2] 发送请求,询问其是否允许非同源客户端进行查询。这种共享技术被称为 CORS(跨源资源共享)。服务器 [2] 通过发送特定的 HTTP 头部来授予权限。
为演示可能出现的问题,我们将创建一个客户端/服务器应用程序,其中:
- 服务器将是我们自己的 web/JSON 服务器;
- 客户端将是一个简单的 HTML 页面,其中包含用于向 Web/JSON 服务器发送请求的 JavaScript 代码;
8.4.14.1. 客户端项目
![]() |
该项目是一个 Maven 项目,包含以下 [pom.xml] 文件:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st</groupId>
<artifactId>rdvmedecins-webjson-client-cors</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>rdvmedecins-webjson-client-cors</name>
<description>Client for webjson server</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.6.RELEASE</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<start-class>istia.st.rdvmedecins.Client</start-class>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!-- spring MVC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
</project>
- 第 14–19 行:这是一个 Spring Boot 项目;
- 第 29–32 行:我们使用了 [spring-boot-starter-web] 依赖项,其中包含 Tomcat 服务器和 Spring MVC;
HTML 页面如下:
![]() |
它是通过以下代码生成的:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Spring MVC</title>
<script type="text/javascript" src="/js/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="/js/client.js"></script>
</head>
<body>
<h2>Client du service web / jSON</h2>
<form id="formulaire">
<!-- method HTTP -->
Méthode HTTP :
<!-- -->
<input type="radio" id="get" name="method" value="get" checked="checked" />GET
<!-- -->
<input type="radio" id="post" name="method" value="post" />POST
<!-- URL -->
<br /> <br />URL cible : <input type="text" id="url" size="30"><br />
<!-- posted value -->
<br /> Chaîne jSON à poster : <input type="text" id="posted" size="50" />
<!-- validation button -->
<br /> <br /> <input type="submit" value="Valider" onclick="javascript:requestServer(); return false;"></input>
</form>
<hr />
<h2>Réponse du serveur</h2>
<div id="response"></div>
</body>
</html>
- 第 6 行:我们导入 jQuery 库;
- 第 7 行:我们导入即将编写的代码;
[client.js] 的代码如下:
// global data
var url;
var posted;
var response;
var method;
function requestServer() {
// retrieve information from the form
var urlValue = url.val();
var postedValue = posted.val();
method = document.forms[0].elements['method'].value;
// make a manual Ajax call
if (method === "get") {
doGet(urlValue);
} else {
doPost(urlValue, postedValue);
}
}
function doGet(url) {
// make a manual Ajax call
$.ajax({
headers : {
'Authorization' : 'Basic YWRtaW46YWRtaW4='
},
url : 'http://localhost:8080' + url,
type : 'GET',
dataType : 'tex/plain',
beforeSend : function() {
},
success : function(data) {
// text result
response.text(data);
},
complete : function() {
},
error : function(jqXHR) {
// system error
response.text(jqXHR.responseText);
}
})
}
function doPost(url, posted) {
// make a manual Ajax call
$.ajax({
headers : {
'Authorization' : 'Basic YWRtaW46YWRtaW4='
},
url : 'http://localhost:8080' + url,
type : 'POST',
contentType : 'application/json',
data : posted,
dataType : 'tex/plain',
beforeSend : function() {
},
success : function(data) {
// text result
response.text(data);
},
complete : function() {
},
error : function(jqXHR) {
// system error
response.text(jqXHR.responseText);
}
})
}
// document loading
$(document).ready(function() {
// retrieve page component references
url = $("#url");
posted = $("#posted");
response = $("#response");
});
我们留给读者自行理解这段代码。所有内容都曾在不同场合讲解过。不过,其中几行值得说明:
- 第 11 行:
- [document] 指由浏览器加载的文档,即所谓的 DOM(文档对象模型),
- [document.forms[0]] 指文档中的第一个表单;一个文档可能包含多个表单。此处仅有一个,
- [document.forms[0].elements['method']] 指代具有 [name='method'] 属性的表单元素。此类元素共有两个:
<input type="radio" id="get" name="method" value="get" checked="checked" />GET
<input type="radio" id="post" name="method" value="post" />POST
- 第 11 行:
- [document.forms[0].elements['method'].value] 是将发送给具有 [name='method'] 属性的组件的值。我们知道,发送的值即为被选中单选按钮的 [value] 属性的值。因此,此处该值将是字符串 ['get', 'post'] 中的一个;
- 第 23–25 行:我们正在与一个要求 HTTP 头部 [Authorization: Basic 代码] 的服务器通信。我们为用户 [admin / admin] 创建此头部,该用户是唯一有权查询服务器的用户;
- 第 26 行:用户将输入 [/getAllDoctors, /deleteAppointment, ...] 形式的 URL。因此这些 URL 必须完整填写;
- 第 28 行:服务器返回 JSON,这是一种文本格式。我们将响应类型指定为 [text/plain],以便其显示效果与接收时完全一致;
- 第 33 行:显示服务器的文本响应;
- 第 39 行:以文本格式显示任何错误信息;
- 第 52 行:用于指示客户端正在发送 JSON;
在我们正在构建的客户端/服务器应用程序中:
- 客户端是一个可通过 URL [http://localhost:8081] 访问的 Web 应用程序。这就是我们目前正在构建的应用程序;
- 服务器是一个可通过 URL [http://localhost:8080] 访问的 Web 应用程序。这就是我们的 Web/JSON 服务器;
由于客户端和服务器运行在不同的端口上,因此会产生跨域请求的问题。[http://localhost:8080] 和 [http://localhost:8081] 属于两个不同的域名。
Spring Boot 应用程序是一个由以下可执行类 [Client] 启动的控制台应用程序:
package istia.st.rdvmedecins;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.context.embedded.EmbeddedServletContainerFactory;
import org.springframework.boot.context.embedded.ServletRegistrationBean;
import org.springframework.boot.context.embedded.tomcat.TomcatEmbeddedServletContainerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
@Configuration
@EnableWebMvc
public class Client extends WebMvcConfigurerAdapter {
public static void main(String[] args) {
SpringApplication.run(Client.class, args);
}
// static pages
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**").addResourceLocations(new String[] { "classpath:/static/" });
}
// configuration dispatcherServlet
@Bean
public DispatcherServlet dispatcherServlet() {
return new DispatcherServlet();
}
@Bean
public ServletRegistrationBean servletRegistrationBean(DispatcherServlet dispatcherServlet) {
return new ServletRegistrationBean(dispatcherServlet, "/*");
}
// embedded Tomcat server
@Bean
public EmbeddedServletContainerFactory embeddedServletContainerFactory() {
return new TomcatEmbeddedServletContainerFactory("", 8081);
}
}
- 第 14 行:[Client] 类是一个 Spring 配置类;
- 第 15 行:配置了一个 Spring MVC 应用程序。该注解会触发一系列自动配置;
- 第 16 行:若要覆盖 Spring MVC 框架的某些默认值,必须继承 [WebMvcConfigurerAdapter] 类;
- 第 23–26 行:[addResourceHandlers] 方法允许您指定应用程序静态资源(HTML、CSS、JS 等)所在的目录。在此,我们指定了位于项目类路径中的 [static] 目录:
![]() |
- 第 29–37 行:[dispatcherServlet] Bean 的配置,该 Bean 指定了 Spring MVC Servlet;
- 第 40–43 行:嵌入式 Tomcat 服务器将在 8081 端口上运行;
8.4.14.2. URL [/getAllMedecins]
我们启动:
- 在 8080 端口上启动 Web/JSON 服务器;
- 该服务器的客户端,运行在8081端口上;
然后我们请求 URL [http://localhost:8081/client.html] [1]:
![]() |
- 在 [2] 中,我们对 URL [http://localhost:8080/getAllMedecins] 执行 GET 请求;
我们没有收到服务器的响应。当我们查看开发者控制台(Ctrl-Shift-I)时,看到一条错误:
![]() |
- 在[1]中,我们位于[网络]选项卡;
- 在[2]中,我们可以看到发出的 HTTP 请求不是 [GET] 而是 [OPTIONS]。对于跨域请求,浏览器会通过发送 HTTP [OPTIONS] 请求向服务器进行验证,以确保满足特定条件。在此示例中,相关请求即图中圆圈标记的 [5-6];
- 在[5]处,浏览器询问目标URL是否可通过GET方法访问。[Access-Control-Request-Method]请求头要求服务器返回包含[Access-Control-Allow-Methods] HTTP头的响应,以表明所请求的方法被接受;
- 在 [5] 中,浏览器发送 HTTP 标头 [Origin: http://localhost:8081]。该标头要求响应包含 [Access-Control-Allow-Origin] HTTP 标头,以表明接受指定的源;
- 在 [6] 中,浏览器询问是否接受 [Accept] 和 [Authorization] 这两个 HTTP 头。请求头 [Access-Control-Request-Headers] 期望响应包含 [Access-Control-Allow-Headers] HTTP 头,以表明接受所请求的头部;
- 在 [3] 中发生错误。点击图标会导致错误 [4];
- 在 [4] 中,提示信息表明服务器未发送 HTTP 标头 [Access-Control-Allow-Origin],该标头用于指定是否接受请求的源;
- 在 [7] 中,我们可以看到服务器确实未发送此标头。因此,浏览器拒绝执行最初请求的 HTTP GET 请求;
我们需要修改 Web 服务器/JSON。首先在 [ApplicationModel] 中进行修改,这是 Web 服务配置元素之一:
![]() |
@Component
public class ApplicationModel implements IMetier {
...
// configuration data
private boolean corsAllowed = true;
private boolean secured = true;
...
public boolean isCorsAllowed() {
return corsAllowed;
}
- 第 6 行:我们创建了一个布尔变量,用于指示是否接受服务器域名之外的客户端;
- 第10–12行:访问此信息的方法;
然后,我们创建一个新的 Spring MVC 控制器:
![]() |
[RdvMedecinsCorsController] 类的定义如下:
package rdvmedecins.web.controllers;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import rdvmedecins.web.models.ApplicationModel;
@Controller
public class RdvMedecinsCorsController {
@Autowired
private ApplicationModel application;
// sending options to the customer
public void sendOptions(String origin, HttpServletResponse response) {
// Cors allowed ?
if (!application.isCorsAllowed() || origin==null || !origin.startsWith("http://localhost")) {
return;
}
// set header CORS
response.addHeader("Access-Control-Allow-Origin", origin);
// certain headers are allowed
response.addHeader("Access-Control-Allow-Headers", "accept, authorization");
// we authorize GET
response.addHeader("Access-Control-Allow-Methods", "GET");
}
// list of doctors
@RequestMapping(value = "/getAllMedecins", method = RequestMethod.OPTIONS)
public void getAllMedecins(@RequestHeader(value = "Origin", required = false) String origin, HttpServletResponse response) {
sendOptions(origin, response);
}
}
- 第 12–13 行:[RdvMedecinsCorsController] 类是一个 Spring 控制器;
- 第 33–36 行:定义了一个操作,用于处理通过 HTTP [OPTIONS] 方法请求 URL [/getAllMedecins] 时的情况;
- 第 34 行:[getAllMedecins] 方法接受以下参数:
- 该对象 [@RequestHeader(value = "Origin", required = false)] 用于从请求中获取 HTTP [Origin] 标头。该标头由请求发起者发送:
我们指定 HTTP 标头 [Origin] 为可选 [required = false]。在此情况下,如果该标头缺失,参数 [String origin] 的值将为 null。若设置为 [required = true](即默认值),则标头缺失时会抛出异常。我们希望避免这种情况;
- 第 34 行:
- 将发送给发起请求的客户端的 [HttpServletResponse response] 对象;
这两个参数由 Spring 注入;
- 第 35 行:我们将请求的处理委托给第 19–30 行中的方法;
- 第 15–16 行:注入 [ApplicationModel] 对象;
- 第 21–23 行:如果应用程序配置为接受跨域请求,且发送方已发送 [Origin] HTTP 头,且该源以 [http://localhost] 开头,则接受跨域请求;否则,拒绝该请求;
- 第 25 行:如果客户端位于 [http://localhost:port] 域中,则发送 HTTP 头:
Access-Control-Allow-Origin: http://localhost:port
这意味着服务器接受该客户端的源;
- 第 25 行:我们在 [OPTIONS] HTTP 请求中指定了两个特定的 HTTP 头部:
针对 [Access-Control-Request-X] HTTP 头,服务器将返回一个 [Access-Control-Allow-X] HTTP 头,其中指定了允许的操作。第 23–26 行只是重复了客户端的请求,以表明该请求已被接受;
现在我们可以进行进一步测试了。我们启动新版本的 Web 服务,发现问题依然存在。没有任何变化。如果我们在上面的第 35 行添加控制台输出,它将永远不会显示,这表明第 34 行的 [getAllMedecins] 方法从未被调用。
经过一番研究,我们发现 Spring MVC 会通过其默认处理机制自行处理 [OPTIONS] HTTP 请求。因此,响应始终由 Spring 发出,而非第 34 行中的 [getAllMedecins] 方法。Spring MVC 的这一默认行为是可以更改的。我们修改现有的 [WebConfig] 类:
![]() |
package rdvmedecins.web.config;
...
import org.springframework.web.servlet.DispatcherServlet;
@Configuration
public class WebConfig {
// dispatcherservlet configuration for CORS headers
@Bean
public DispatcherServlet dispatcherServlet() {
DispatcherServlet servlet = new DispatcherServlet();
servlet.setDispatchOptionsRequest(true);
return servlet;
}
// mapping jSON
...
- 第 10-11 行:使用 [dispatcherServlet] Bean 来定义处理客户端请求的 Servlet。此处,其类型为 [DispatcherServlet],即 Spring MVC 框架中的 Servlet;
- 第 12 行:我们创建了一个 [DispatcherServlet] 类型的实例;
- 第 13 行:我们指示该 Servlet 将 [OPTIONS] HTTP 请求转发给应用程序;
- 第 14 行:我们渲染按此方式配置的 Servlet;
我们使用此新配置重新运行测试。结果如下:
![]() |
- 在 [1] 中,我们可以看到有两个发往 URL [http://localhost:8080/getAllMedecins] 的 HTTP 请求;
- 在 [2] 中,是 [OPTIONS] 请求;
- 在 [3] 中,服务器响应中包含我们刚刚配置的三个 HTTP 头部;
现在让我们来分析第二个请求:
![]() |
- 在 [1] 中,正在分析的请求;
- 在 [2] 中,这是 GET 请求。得益于第一个 [OPTIONS] 请求,浏览器已获取所需信息。现在,它正在执行最初请求的 [GET] 请求;
- 在 [3] 中,是服务器的响应;
- 在 [4] 中,服务器发送 JSON;
- 在 [5] 中,发生了错误;
- 在 [6] 中,显示错误信息;
这里的情况更难解释。服务器的响应 [3] 正常 [HTTP/1.1 200 OK]。 因此,我们本应已获取到所请求的文档。可能的情况是,服务器确实发送了该文档,但浏览器阻止了其使用,因为浏览器要求对于 GET 请求,响应中也必须包含 HTTP 头部 [Access-Control-Allow-Origin:http://localhost:8081]。
我们将控制器 [RdvMedecinsController] 修改如下:
@Autowired
private RdvMedecinsCorsController rdvMedecinsCorsController;
...
// list of doctors
@RequestMapping(value = "/getAllMedecins", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getAllMedecins(HttpServletResponse httpServletResponse,
@RequestHeader(value = "Origin", required = false) String origin) throws JsonProcessingException {
// the answer
Response<List<Medecin>> response;
// headers CORS
rdvMedecinsCorsController.sendOptions(origin, httpServletResponse);
// application status
...
- 第 1-2 行:注入控制器 [RdvMedecinsCorsController];
- 第 7-8 行:封装了将发送给客户端的响应的 HttpServletResponse 对象,以及 HTTP 头部 [Origin] 被注入到 [getAllMedecins] 方法的参数中;
- 第 12 行:调用了 [RdvMedecinsCorsController] 控制器的 [sendOptions] 方法——这正是用于处理 [OPTIONS] HTTP 请求时调用的方法。因此,它将发送与该请求相同的 HTTP 头部;
进行此修改后,结果如下:
![]() |
我们已成功获取医生列表。
8.4.14.3. 其他 [GET] URL
接下来我们将探讨通过 GET 请求查询的其他 URL。在控制器中,处理这些 URL 的操作代码与之前处理 [/getAllMedecins] URL 的操作遵循相同的模式。读者可以查阅本文档提供的示例代码进行验证。以下是一个示例:
在 [RdvMedecinsCorsController] 中
// list of doctor's appointments
@RequestMapping(value = "/getRvMedecinJour/{idMedecin}/{jour}", method = RequestMethod.OPTIONS)
public void getRvMedecinJour(@RequestHeader(value = "Origin", required = false) String origin, HttpServletResponse response) {
sendOptions(origin, response);
}
在 [RdvMedecinsController] 中
// list of doctor's appointments
@RequestMapping(value = "/getRvMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET, produces = "application/json; charset=UTF-8")
@ResponseBody
public String getRvMedecinJour(@PathVariable("idMedecin") long idMedecin, @PathVariable("jour") String jour,
HttpServletResponse httpServletResponse, @RequestHeader(value = "Origin", required = false) String origin)
throws JsonProcessingException {
// the answer
Response<List<Rv>> response = null;
boolean erreur = false;
// headers CORS
rdvMedecinsCorsController.sendOptions(origin, httpServletResponse);
// application status
...
以下是执行过程的截图:
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
8.4.14.4. [POST] URL
让我们来看看以下情况:
![]() |
- 我们向 URL [2] 发送一个 POST [1] 请求;
- 在 [3] 中,是提交的值。这是一个 JSON 字符串;
- 总体而言,我们试图删除 [id] 为 100 的预约;
目前我们并未修改任何代码。获得的结果如下:
![]() |
- 在[1]中,与[GET]请求类似,浏览器会发出一个[OPTIONS]请求;
- 在[2]中,它请求[POST]请求的访问授权。此前,请求类型为[GET];
- 在[3]中,它请求发送HTTP头部[accept, authorization, content-type]的授权。此前,我们仅有前两个头部;
我们将 [RdvMedecinsCorsController.sendOptions] 方法修改如下:
public void sendOptions(String origin, HttpServletResponse response) {
// Cors allowed ?
if (!application.isCorsAllowed() || origin==null || !origin.startsWith("http://localhost")) {
return;
}
// set header CORS
response.addHeader("Access-Control-Allow-Origin", origin);
// certain headers are allowed
response.addHeader("Access-Control-Allow-Headers", "accept, authorization, content-type");
// we authorize GET
response.addHeader("Access-Control-Allow-Methods", "GET, POST");
}
- 第 9 行:我们添加了 HTTP 头部 [Content-Type](不区分大小写);
- 第 11 行:我们添加了 HTTP 方法 [POST];
这意味着 [POST] 方法与 [GET] 请求的处理方式相同。以下是 URL [/deleteAppointment] 的示例:
在 [RdvMedecinsController] 中
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, produces = "application/json; charset=UTF-8", consumes = "application/json; charset=UTF-8")
@ResponseBody
public String supprimerRv(@RequestBody PostSupprimerRv post, HttpServletResponse httpServletResponse,
@RequestHeader(value = "Origin", required = false) String origin) throws JsonProcessingException {
// the answer
Response<Void> response = null;
boolean erreur = false;
// headers CORS
rdvMedecinsCorsController.sendOptions(origin, httpServletResponse);
// application status
if (messages != null) {
...
在 [RdvMedecinsCorsController] 中
@RequestMapping(value = "/supprimerRv", method = RequestMethod.OPTIONS)
public void supprimerRv(@RequestHeader(value = "Origin", required = false) String origin, HttpServletResponse response) {
sendOptions(origin, response);
}
结果如下:
![]() |
对于 URL [/addRv],得到以下结果:
![]() |
8.4.14.5. 结论
我们的应用程序现已支持跨域请求。可通过在 [ApplicationModel] 类中进行配置来启用或禁用此功能:
// données de configuration
private boolean corsAllowed = false;
8.5. Web 服务客户端 / JSON
让我们回到我们要构建的应用程序的整体架构:
![]() |
图的上半部分已经编写完毕。这是 Web/JSON 服务器。现在我们将着手处理下半部分,首先从其 [DAO] 层开始。我们将编写该层,然后使用控制台客户端进行测试。测试架构如下:
![]() |
8.5.1. 控制台客户端项目
控制台客户端的 STS 项目结构如下:
![]() |
8.5.2. Maven 配置
控制台客户端的 [pom.xml] 文件如下:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st.rdvmedecins</groupId>
<artifactId>rdvmedecins-webjson-client-console</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>rdvmedecins-webjson-client-console</name>
<description>Client console du serveur web / jSON</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<java.version>1.8</java.version>
</properties>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.6.RELEASE</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<dependencies>
<!-- Spring -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId>
</dependency>
<!-- jSON library used by Spring -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- component used by Spring RestTemplate -->
<dependency>
<groupId>org.apache.httpcomponents</groupId>
<artifactId>httpclient</artifactId>
</dependency>
</dependencies>
</project>
- 第 15–20 行:父 Spring Boot 项目;
- 第 24–27 行:Web 服务器/JSON 控制台客户端基于 [spring-web] 依赖项提供的名为 [RestTemplate] 的组件;
- 第 29–36 行:序列化和反序列化 JSON 对象需要一个 JSON 库。我们使用的是 Spring Web 所用的 Jackson 库的一个变体;
- 第 38–41 行:在最底层,[RestTemplate] 组件通过 TCP/IP 套接字与服务器通信。我们需要为这些套接字设置 [timeout],即等待服务器响应 的最大时长。 [RestTemplate] 组件不允许我们直接设置此参数。为此,我们将把 [org.apache.httpcomponents.httpclient] 依赖项提供的底层组件传递给 [RestTemplate] 构造函数。正是这个依赖项使我们能够设置通信 [timeout];
8.5.3. [rdvmedecins.client.entities] 包
![]() |
[rdvmedecins.client.entities] 包包含 Web 服务/JSON 通过其各种 URL 发送的所有实体。我们不再赘述这些实体。只需说明,JPA 实体 [Client、Slot、Doctor、Appointment、Person] 已移除了所有 JPA 注解以及 JSON 注解。例如,以下是 [Appointment] 类:
package rdvmedecins.client.entities;
import java.util.Date;
public class Rv extends AbstractEntity {
private static final long serialVersionUID = 1L;
// day of appointment
private Date jour;
// an appointment is linked to a customer
private Client client;
// an appointment is linked to a time slot
private Creneau creneau;
// foreign keys
private long idClient;
private long idCreneau;
// default builder
public Rv() {
}
// with parameters
public Rv(Date jour, Client client, Creneau creneau) {
this.jour = jour;
this.client = client;
this.creneau = creneau;
}
// toString
public String toString() {
return String.format("Rv[%d, %s, %d, %d]", id, jour, client.id, creneau.id);
}
// getters and setters
...
}
8.5.4. [rdvmedecins.client.requests] 包
![]() |
[rdvmedecins.client.requests] 包包含两个类,其 JSON 值将分别发送至 URL [/ajouterRv] 和 [supprimerRv]。它们与服务器端的对应类完全一致。
8.5.5. [rdvmedecins.client.responses] 包
![]() |
[Response] 是所有 Web 服务/JSON 响应的类型。它是一个泛型类型:
package rdvmedecins.client.responses;
import java.util.List;
public class Response<T> {
// ----------------- properties
// operation status
private int status;
// any error messages
private List<String> messages;
// the body of the reply
private T body;
// manufacturers
public Response() {
}
public Response(int status, List<String> messages, T body) {
this.status = status;
this.messages = messages;
this.body = body;
}
// getters and setters
...
}
- 第 5 行:类型 [T] 取决于 Web 服务 URL / JSON;
8.5.6. [rdvmedecins.client.dao] 包
![]() |
- [IDao] 是 [DAO] 层的接口,而 [Dao] 是其实现。我们稍后会再回到这个实现;
8.5.7. [rdvmedecins.client.config] 包
![]() |
[DaoConfig] 类用于配置应用程序。其代码如下:
package rdvmedecins.client.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter;
import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider;
@Configuration
@ComponentScan({ "rdvmedecins.client.dao" })
public class DaoConfig {
@Bean
public RestTemplate restTemplate() {
// creation of the RestTemplate component
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
RestTemplate restTemplate = new RestTemplate(factory);
// result
return restTemplate;
}
// mappers jSON
@Bean
public ObjectMapper jsonMapper(){
return new ObjectMapper();
}
@Bean
public ObjectMapper jsonMapperShortCreneau() {
ObjectMapper jsonMapperShortCreneau = new ObjectMapper();
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperShortCreneau.setFilters(new SimpleFilterProvider().addFilter("creneauFilter", creneauFilter));
return jsonMapperShortCreneau;
}
@Bean
public ObjectMapper jsonMapperLongRv() {
ObjectMapper jsonMapperLongRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("");
SimpleBeanPropertyFilter creneauFilter = SimpleBeanPropertyFilter.serializeAllExcept("medecin");
jsonMapperLongRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter).addFilter("creneauFilter",
creneauFilter));
return jsonMapperLongRv;
}
@Bean
public ObjectMapper jsonMapperShortRv() {
ObjectMapper jsonMapperShortRv = new ObjectMapper();
SimpleBeanPropertyFilter rvFilter = SimpleBeanPropertyFilter.serializeAllExcept("client", "creneau");
jsonMapperShortRv.setFilters(new SimpleFilterProvider().addFilter("rvFilter", rvFilter));
return jsonMapperShortRv;
}
}
- 第 13 行:[DaoConfig] 类是一个 Spring 配置类;
- 第 14 行:系统将在 [rdvmedecins.client.dao] 包中搜索 Spring 组件。该包中将找到 [Dao] 组件;
- 第 17–24 行:定义了一个名为 [restTemplate](方法名)的 Spring 单例。该方法返回一个 [RestTemplate] 实例,这是 Spring 用于与 Web 服务或 JSON 通信的基本工具;
- 第 21 行:我们可以写成 [RestTemplate restTemplate = new RestTemplate();]。在大多数情况下,这样就足够了。 但在此处,我们需要设置客户端的 [timeouts]。为此,我们将类型为 [HttpComponentsClientHttpRequestFactory] 的低级组件(第 20 行)注入到 [RestTemplate] 组件中,这将允许我们设置这些 [timeouts]。所需的 Maven 依赖项已提供;
- 第 28–57 行:定义 JSON 映射器。这些是服务器端(参见第 8.4.11.3 节)用于序列化 [Response<T>] 响应中类型 T 的 JSON 映射器。现在,客户端将使用相同的转换器来反序列化类型 T;
8.5.8. [IDao] 接口
让我们回到应用程序架构:
![]() |
[DAO] 层充当 [console] 层与 Web 服务 / JSON 公开的 URL 之间的适配器。其 [IDao] 接口如下所示:
package rdvmedecins.client.dao;
import java.util.List;
import rdvmedecins.client.entities.AgendaMedecinJour;
import rdvmedecins.client.entities.Client;
import rdvmedecins.client.entities.Creneau;
import rdvmedecins.client.entities.Medecin;
import rdvmedecins.client.entities.Rv;
import rdvmedecins.client.entities.User;
public interface IDao {
// Web service url
public void setUrlServiceWebJson(String url);
// timeout
public void setTimeout(int timeout);
// authentication
public void authenticate(User user);
// customer list
public List<Client> getAllClients(User user);
// list of doctors
public List<Medecin> getAllMedecins(User user);
// list of physician slots
public List<Creneau> getAllCreneaux(User user, long idMedecin);
// find a customer identified by its id
public Client getClientById(User user, long id);
// find a customer identified by its id
public Medecin getMedecinById(User user, long id);
// find an Rv identified by its id
public Rv getRvById(User user, long id);
// find a time slot identified by its id
public Creneau getCreneauById(User user, long id);
// add a RV to the list
public Rv ajouterRv(User user, String jour, long idCreneau, long idClient);
// delete a RV
public void supprimerRv(User user, long idRv);
// list of doctor's appointments on a given day
public List<Rv> getRvMedecinJour(User user, long idMedecin, String jour);
// agenda
public AgendaMedecinJour getAgendaMedecinJour(User user, long idMedecin, String jour);
}
- 第 14 行:用于设置 Web 服务 / JSON 根 URL 的方法,例如 [http://localhost:8080];
- 第 17 行:用于设置客户端 [超时] 的方法。我们需要控制此参数,因为某些 HTTP 客户端可能会花费很长时间等待一个永远不会到来的响应;
- 第 20 行:用于验证用户身份的方法 [login, passwd]。若未识别用户,则抛出异常;
- 第 22–53 行:Web 服务 / JSON 暴露的每个 URL 都与接口中的一个方法相关联,该方法的签名源自处理该暴露 URL 的服务器端方法的签名。以以下服务器 URL 为例:
@RequestMapping(value = "/getAgendaMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET)
public Response<String> getAgendaMedecinJour(@PathVariable("idMedecin") long idMedecin, @PathVariable("jour") String jour, HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
- 第 1 行:我们可以看到 [idMedecin] 和 [jour] 是 URL 参数。这些将作为客户端与该 URL 关联的方法的输入参数;
- 第 2 行:我们看到服务器方法返回类型为 [Response<String>]。该 [String] 类型对应的是 [AgendaMedecinJour] 类型的 JSON 值。客户端与该 URL 关联的方法的返回类型将是 [AgendaMedecinJour];
在客户端,我们声明以下方法:
public AgendaMedecinJour getAgendaMedecinJour(User user, long idMedecin, String jour);
当服务器返回类型为 [int status, List<String> messages, String body] 且 [status==0] 的响应时,此签名有效。此时,我们有 [messages==null && body!=null]。当 [status!=0] 时,此签名无效。 此时,我们有 [messages!=null && body==null]。我们需要以某种方式提示已发生错误。为此,我们将按如下方式抛出类型为 [RdvMedecinsException] 的异常:
package rdvmedecins.client.dao;
import java.util.List;
public class RdvMedecinsException extends RuntimeException {
private static final long serialVersionUID = 1L;
// error code
private int status;
// list of error messages
private List<String> messages;
public RdvMedecinsException() {
}
public RdvMedecinsException(int code, List<String> messages) {
super();
this.status = code;
this.messages = messages;
}
// getters and setters
...
}
- 第 9 行和第 11 行:该异常将从服务器发送的 [Response<T>] 对象中获取 [status, messages] 字段的值;
- 第 5 行:[RdvMedecinsException] 类继承自 [RuntimeException] 类。因此它属于未处理的异常,这意味着无需使用 try/catch 代码块进行处理,也不需要在接口的方法签名中声明它;
此外,[IDao] 接口中所有查询 Web 服务/JSON 的方法都具有以下 [User] 类型的参数:
package rdvmedecins.client.entities;
public class User {
// data
private String login;
private String passwd;
// manufacturers
public User() {
}
public User(String login, String passwd) {
this.login = login;
this.passwd = passwd;
}
// getters and setters
...
}
事实上,与 Web 服务 / JSON 的每次交互都必须附带一个 HTTP 身份验证头。
8.5.9. [rdvmedecins.clients.console] 包
既然我们已经熟悉了 [DAO] 层的接口,现在可以介绍控制台应用程序了。
![]() |
[Main] 类的定义如下:
package rdvmedecins.clients.console;
import java.io.IOException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import rdvmedecins.client.config.DaoConfig;
import rdvmedecins.client.dao.IDao;
import rdvmedecins.client.dao.RdvMedecinsException;
import rdvmedecins.client.entities.Rv;
import rdvmedecins.client.entities.User;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
public class Main {
// serializer jSON
static private ObjectMapper mapper = new ObjectMapper();
// connection timeout in milliseconds
static private int TIMEOUT = 1000;
public static void main(String[] args) throws IOException {
// we retrieve a reference on the [DAO] layer
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(DaoConfig.class);
IDao dao = context.getBean(IDao.class);
// set the URL of the web/json service
dao.setUrlServiceWebJson("http://localhost:8080");
// set timeouts in milliseconds
dao.setTimeout(TIMEOUT);
// Authentication
String message = "/authenticate [admin,admin]";
try {
dao.authenticate(new User("admin", "admin"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
message = "/authenticate [user,user]";
try {
dao.authenticate(new User("user", "user"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
message = "/authenticate [user,x]";
try {
dao.authenticate(new User("user", "x"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
message = "/authenticate [x,x]";
try {
dao.authenticate(new User("x", "x"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
message = "/authenticate [admin,x]";
try {
dao.authenticate(new User("admin", "x"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// customer list
message = "/getAllClients";
try {
showResponse(message, dao.getAllClients(new User("admin", "admin")));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// list of doctors
message = "/getAllMedecins";
try {
showResponse(message, dao.getAllMedecins(new User("admin", "admin")));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// list of slots for doctor 2
message = "/getAllCreneaux/2";
try {
showResponse(message, dao.getAllCreneaux(new User("admin", "admin"), 2L));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// customer no. 1
message = "/getClientById/1";
try {
showResponse(message, dao.getClientById(new User("admin", "admin"), 1L));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// doctor no. 2
message = "/getMedecinById/2";
try {
showResponse(message, dao.getMedecinById(new User("admin", "admin"), 2L));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// slot no. 3
message = "/getCreneauById/3";
try {
showResponse(message, dao.getCreneauById(new User("admin", "admin"), 3L));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// rv n° 4
message = "/getRvById/4";
try {
showResponse(message, dao.getRvById(new User("admin", "admin"), 4L));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// adding an appointment
message = "/AjouterRv [idClient=4,idCreneau=8,jour=2015-01-08]";
long idRv = 0;
try {
Rv response = dao.ajouterRv(new User("admin", "admin"), "2015-01-08", 8L, 4L);
idRv = response.getId();
showResponse(message, response);
} catch (RdvMedecinsException e) {
showException(message, e);
}
// doctor's appointment list 1 on 2015-01-08
message = "/getRvMedecinJour/1/2015-01-08";
try {
showResponse(message, dao.getRvMedecinJour(new User("admin", "admin"), 1L, "2015-01-08"));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// doctor's agenda 1 on 2015-01-08
message = "/getAgendaMedecinJour/1/2015-01-08";
try {
showResponse(message, dao.getAgendaMedecinJour(new User("admin", "admin"), 1L, "2015-01-08"));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// delete added rv
message = String.format("/supprimerRv [idRv=%s]", idRv);
try {
dao.supprimerRv(new User("admin", "admin"), idRv);
} catch (RdvMedecinsException e) {
showException(message, e);
}
// doctor's appointment list 1 on 2015-01-08
message = "/getRvMedecinJour/1/2015-01-08";
try {
showResponse(message, dao.getRvMedecinJour(new User("admin", "admin"), 1L, "2015-01-08"));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// closing context
context.close();
}
private static void showException(String message, RdvMedecinsException e) {
System.out.println(String.format("URL [%s]", message));
System.out.println(String.format("L'erreur n° [%s] s'est produite :", e.getStatus()));
for (String msg : e.getMessages()) {
System.out.println(msg);
}
}
private static <T> void showResponse(String message, T response) throws JsonProcessingException {
System.out.println(String.format("URL [%s]", message));
System.out.println(mapper.writeValueAsString(response));
}
}
- 第 19 行:用于显示服务器响应的 JSON 序列化器,第 184 行;
- 第 25 行:[AnnotationConfigApplicationContext] 组件是一个 Spring 组件,能够利用 Spring 应用程序中的配置注解。我们将用于配置应用程序的 [AppConfig] 类传递给其构造函数;
- 第 26 行:我们获取 [DAO] 层的引用;
- 第 27–30 行:对其进行配置;
- 第 32–169 行:我们测试 [IDao] 接口的所有方法;
所得结果如下:
09:20:56.935 [main] INFO o.s.c.a.AnnotationConfigApplicationContext - Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@52feb982: startup date [Wed Oct 14 09:20:56 CEST 2015]; root of context hierarchy
/authenticate [admin,admin] : OK
URL [/authenticate [user,user]]
L'erreur n° [111] s'est produite :
403 Forbidden
URL [/authenticate [user,x]]
L'erreur n° [111] s'est produite :
401 Unauthorized
URL [/authenticate [x,x]]
L'erreur n° [111] s'est produite :
403 Forbidden
URL [/authenticate [admin,x]]
L'erreur n° [111] s'est produite :
401 Unauthorized
URL [/getAllClients]
[{"id":1,"version":1,"titre":"Mr","nom":"MARTIN","prenom":"Jules"},{"id":2,"version":1,"titre":"Mme","nom":"GERMAN","prenom":"Christine"},{"id":3,"version":1,"titre":"Mr","nom":"JACQUARD","prenom":"Jules"},{"id":4,"version":1,"titre":"Melle","nom":"BISTROU","prenom":"Brigitte"}]
URL [/getAllMedecins]
[{"id":1,"version":1,"titre":"Mme","nom":"PELISSIER","prenom":"Marie"},{"id":2,"version":1,"titre":"Mr","nom":"BROMARD","prenom":"Jacques"},{"id":3,"version":1,"titre":"Mr","nom":"JANDOT","prenom":"Philippe"},{"id":4,"version":1,"titre":"Melle","nom":"JACQUEMOT","prenom":"Justine"}]
URL [/getAllCreneaux/2]
[{"id":25,"version":1,"hdebut":8,"mdebut":0,"hfin":8,"mfin":20,"medecin":null,"idMedecin":2},{"id":26,"version":1,"hdebut":8,"mdebut":20,"hfin":8,"mfin":40,"medecin":null,"idMedecin":2},{"id":27,"version":1,"hdebut":8,"mdebut":40,"hfin":9,"mfin":0,"medecin":null,"idMedecin":2},{"id":28,"version":1,"hdebut":9,"mdebut":0,"hfin":9,"mfin":20,"medecin":null,"idMedecin":2},{"id":29,"version":1,"hdebut":9,"mdebut":20,"hfin":9,"mfin":40,"medecin":null,"idMedecin":2},{"id":30,"version":1,"hdebut":9,"mdebut":40,"hfin":10,"mfin":0,"medecin":null,"idMedecin":2},{"id":31,"version":1,"hdebut":10,"mdebut":0,"hfin":10,"mfin":20,"medecin":null,"idMedecin":2},{"id":32,"version":1,"hdebut":10,"mdebut":20,"hfin":10,"mfin":40,"medecin":null,"idMedecin":2},{"id":33,"version":1,"hdebut":10,"mdebut":40,"hfin":11,"mfin":0,"medecin":null,"idMedecin":2},{"id":34,"version":1,"hdebut":11,"mdebut":0,"hfin":11,"mfin":20,"medecin":null,"idMedecin":2},{"id":35,"version":1,"hdebut":11,"mdebut":20,"hfin":11,"mfin":40,"medecin":null,"idMedecin":2},{"id":36,"version":1,"hdebut":11,"mdebut":40,"hfin":12,"mfin":0,"medecin":null,"idMedecin":2}]
URL [/getClientById/1]
{"id":1,"version":1,"titre":"Mr","nom":"MARTIN","prenom":"Jules"}
URL [/getMedecinById/2]
{"id":2,"version":1,"titre":"Mr","nom":"BROMARD","prenom":"Jacques"}
URL [/getCreneauById/3]
{"id":3,"version":1,"hdebut":8,"mdebut":40,"hfin":9,"mfin":0,"medecin":null,"idMedecin":1}
URL [/getRvById/4]
L'erreur n° [2] s'est produite :
Le rendez-vous d'id [4] n'existe pas
URL [/ajouterRv [idClient=4,idCreneau=8,jour=2015-01-08]]
{"id":144,"version":0,"jour":1420671600000,"client":{"id":4,"version":1,"titre":"Melle","nom":"BISTROU","prenom":"Brigitte"},"creneau":{"id":8,"version":1,"hdebut":10,"mdebut":20,"hfin":10,"mfin":40,"medecin":null,"idMedecin":1},"idClient":0,"idCreneau":0}
URL [/getRvMedecinJour/1/2015-01-08]
[{"id":144,"version":0,"jour":1420675200000,"client":{"id":4,"version":1,"titre":"Melle","nom":"BISTROU","prenom":"Brigitte"},"creneau":{"id":8,"version":1,"hdebut":10,"mdebut":20,"hfin":10,"mfin":40,"medecin":null,"idMedecin":1},"idClient":4,"idCreneau":8}]
URL [/getAgendaMedecinJour/1/2015-01-08]
{"medecin":{"id":1,"version":1,"titre":"Mme","nom":"PELISSIER","prenom":"Marie"},"jour":1420671600000,"creneauxMedecinJour":[{"creneau":{"id":1,"version":1,"hdebut":8,"mdebut":0,"hfin":8,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":2,"version":1,"hdebut":8,"mdebut":20,"hfin":8,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":3,"version":1,"hdebut":8,"mdebut":40,"hfin":9,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":4,"version":1,"hdebut":9,"mdebut":0,"hfin":9,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":5,"version":1,"hdebut":9,"mdebut":20,"hfin":9,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":6,"version":1,"hdebut":9,"mdebut":40,"hfin":10,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":7,"version":1,"hdebut":10,"mdebut":0,"hfin":10,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":8,"version":1,"hdebut":10,"mdebut":20,"hfin":10,"mfin":40,"medecin":null,"idMedecin":1},"rv":{"id":144,"version":0,"jour":1420675200000,"client":{"id":4,"version":1,"titre":"Melle","nom":"BISTROU","prenom":"Brigitte"},"creneau":{"id":8,"version":1,"hdebut":10,"mdebut":20,"hfin":10,"mfin":40,"medecin":null,"idMedecin":1},"idClient":4,"idCreneau":8}},{"creneau":{"id":9,"version":1,"hdebut":10,"mdebut":40,"hfin":11,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":10,"version":1,"hdebut":11,"mdebut":0,"hfin":11,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":11,"version":1,"hdebut":11,"mdebut":20,"hfin":11,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":12,"version":1,"hdebut":11,"mdebut":40,"hfin":12,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":13,"version":1,"hdebut":14,"mdebut":0,"hfin":14,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":14,"version":1,"hdebut":14,"mdebut":20,"hfin":14,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":15,"version":1,"hdebut":14,"mdebut":40,"hfin":15,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":16,"version":1,"hdebut":15,"mdebut":0,"hfin":15,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":17,"version":1,"hdebut":15,"mdebut":20,"hfin":15,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":18,"version":1,"hdebut":15,"mdebut":40,"hfin":16,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":19,"version":1,"hdebut":16,"mdebut":0,"hfin":16,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":20,"version":1,"hdebut":16,"mdebut":20,"hfin":16,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":21,"version":1,"hdebut":16,"mdebut":40,"hfin":17,"mfin":0,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":22,"version":1,"hdebut":17,"mdebut":0,"hfin":17,"mfin":20,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":23,"version":1,"hdebut":17,"mdebut":20,"hfin":17,"mfin":40,"medecin":null,"idMedecin":1},"rv":null},{"creneau":{"id":24,"version":1,"hdebut":17,"mdebut":40,"hfin":18,"mfin":0,"medecin":null,"idMedecin":1},"rv":null}]}
URL [/getRvMedecinJour/1/2015-01-08]
[]
09:21:00.258 [main] INFO o.s.c.a.AnnotationConfigApplicationContext - Closing org.springframework.context.annotation.AnnotationConfigApplicationContext@52feb982: startup date [Wed Oct 14 09:20:56 CEST 2015]; root of context hierarchy
我们将结果与代码的对应关系留给读者自行推敲。代码展示了如何调用 [DAO] 层的每个方法。这里仅需注意几点:
- 第 2–14 行:表明在发生身份验证错误时,服务器会根据具体情况返回 HTTP 状态码 [403 Forbidden] 或 [401 Unauthorized];
- 第30–31行:为“1号医生”新增了一项预约;
- 第32–33行:我们看到这个预约。这是当天唯一的预约;
- 第34–35行:该预约在医生的日历中也能看到;
- 第36–37行:该预约已消失。在此期间,系统代码已将其删除;
控制台日志由以下文件控制:
![]() |
[application.properties]
logging.level.org.springframework.web=OFF
logging.level.org.hibernate=OFF
spring.main.show-banner=false
logging.level.httpclient.wire=OFF
[logback.xml]
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<!-- encoders are by default assigned the type ch.qos.logback.classic.encoder.PatternLayoutEncoder -->
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- log level control -->
<root level="info"> <!-- off, info, debug, warn -->
<appender-ref ref="STDOUT" />
</root>
</configuration>
8.5.10. [DAO] 层的实现
现在我们需要介绍 [DAO] 层的核心:其 [IDao] 接口的实现。我们将分步进行。
![]() |
[IDao] 接口由抽象类 [AbstractDao] 及其子类 [Dao] 实现。
父类 [AbstractDao] 如下所示:
package rdvmedecins.client.dao;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.http.RequestEntity;
import org.springframework.http.RequestEntity.BodyBuilder;
import org.springframework.http.RequestEntity.HeadersBuilder;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
import rdvmedecins.client.entities.User;
public abstract class AbstractDao implements IDao {
// data
@Autowired
protected RestTemplate restTemplate;
protected String urlServiceWebJson;
// URL web service / jSON
public void setUrlServiceWebJson(String url) {
this.urlServiceWebJson = url;
}
public void setTimeout(int timeout) {
// set the timeout for web client requests
HttpComponentsClientHttpRequestFactory factory = (HttpComponentsClientHttpRequestFactory) restTemplate
.getRequestFactory();
factory.setConnectTimeout(timeout);
factory.setReadTimeout(timeout);
}
private String getBase64(User user) {
// encodes user and password in base 64 - requires
// java 8
String chaîne = String.format("%s:%s", user.getLogin(), user.getPasswd());
return String.format("Basic %s", new String(Base64.getEncoder().encode(chaîne.getBytes())));
}
// generic request
protected String getResponse(User user, String url, String jsonPost) {
...
}
}
- 第 20 行:该类是抽象类,因此无法将其指定为 Spring 组件。其子类将被指定为 Spring 组件;
- 第 23–24 行:我们注入了在 [AppConfig] 配置类中定义的 [restTemplate] Bean;
- 第 25 行:Web 服务 / JSON 的根 URL;
- 第 32–38 行:设置客户端超时,用于等待服务器的响应;
- 第 34 行:我们获取在 [restTemplate] Bean 创建时注入其中的 [HttpComponentsClientHttpRequestFactory] 组件(参见 [AppConfig]);
- 第 36 行:设置客户端与服务器建立连接时的最大等待时间;
- 第 37 行:设置客户端在等待其请求响应时的最大等待时间;
与服务器通信的方法实现将被整合到以下通用方法中:
// generic request
protected String getResponse(User user, String url, String jsonPost) {
...
}
- 第 2 行:[getResponse] 的参数如下:
- [User user]:建立连接的用户;
- [String url]:要查询的 URL。这是 URL 的后半部分;前半部分由该类的 [urlServiceWebJson] 字段提供,
- [String jsonPost]:要提交的 JSON 字符串。如果存在此值,则将使用 POST 方法向 URL 发送请求;否则,将使用 GET 方法;
让我们继续:
// generic request
protected String getResponse(User user, String url, String jsonPost) {
// url : URL to contact
// jsonPost: the jSON value to be posted
try {
// request execution
RequestEntity<?> request;
if (jsonPost == null) {
HeadersBuilder<?> headersBuilder = RequestEntity.get(new URI(String.format("%s%s", urlServiceWebJson, url))).accept(MediaType.APPLICATION_JSON);
if (user != null) {
headersBuilder = headersBuilder.header("Authorization", getBase64(user));
}
request = headersBuilder.build();
} else {
BodyBuilder bodyBuilder = RequestEntity.post(new URI(String.format("%s%s", urlServiceWebJson, url)))
.header("Content-Type", "application/json").accept(MediaType.APPLICATION_JSON);
if (user != null) {
bodyBuilder = bodyBuilder.header("Authorization", getBase64(user));
}
request = bodyBuilder.body(jsonPost);
}
// execute the query
return restTemplate.exchange(request, new ParameterizedTypeReference<String>() {
}).getBody();
} catch (URISyntaxException e) {
throw new RdvMedecinsException(20, getMessagesForException(e));
} catch (RuntimeException e) {
throw new RdvMedecinsException(21, getMessagesForException(e));
}
}
- 第 23–24 行:该语句用于向服务器发送请求并接收其响应。[RestTemplate] 组件提供了多种与服务器交互的方法。我们本可以选择 [exchange] 以外的其他方法。调用的第二个参数指定了预期响应的类型,在本例中为 JSON 字符串。第一个参数是 [RequestEntity] 请求(第 7 行)。 [exchange]方法的返回类型为[ResponseEntity<String>]。[ResponseEntity]类型封装了服务器的完整响应,包括HTTP头以及服务器发送的 文档。同样,[RequestEntity]类型封装了客户端的完整请求,包括HTTP头和任何提交的数据;
- 第 23 行:这是返回给调用方法的 [ResponseEntity<String>] 对象的正文,即服务器发送的 JSON 字符串;
- 第 9–21 行:我们需要构建 [RequestEntity] 请求。具体实现取决于我们使用的是 GET 请求还是 POST 请求;
- 第 9 行:GET 请求。`[RequestEntity]` 类提供了静态方法来创建 GET、POST、HEAD 及其他请求。通过链式调用构建请求的各种方法,`[RequestEntity.get]` 方法允许您创建 GET 请求:
- [RequestEntity.get] 方法将目标 URL 作为 URI 实例形式的参数;
- [accept] 方法允许您定义 HTTP [Accept] 头部的元素。在此,我们指定接受服务器将发送的 [application/json] 类型;
- 此方法链的结果是一个 [HeadersBuilder] 类型;
- 第 10–12 行:如果 [User user] 参数不为空,则在请求中包含 [Authorization] HTTP 头;
- 第 13 行:[HeadersBuilder.build] 方法利用这些信息构建请求的 [RequestEntity] 类型;
- 第 15 行:该请求为 POST 请求。[RequestEntity.post] 方法允许您通过链式调用各种构建方法来创建 POST 请求:
- [RequestEntity.post] 方法将目标 URL 作为 URI 实例形式的参数接收,
- [header] 方法允许您定义要使用的 HTTP 头部,本例中即授权头部,
- 接下来的 [header] 方法在请求中添加了 [Content-Type: application/json] 头部,以表明提交的数据将以 JSON 字符串的形式到达;
- [accept] 方法表明我们接受服务器将发送的 [application/json] 类型;
- 第 17–19 行:如果 [User user] 参数不为空,则请求中将包含 [Authorization] HTTP 头;
- 第 20 行:[BodyBuilder.body] 方法设置提交的值。这是泛型 [getResponse] 方法(第 2 行)的第二个参数;
- 第 25–28 行:如果发生任何错误,将抛出 [RdvMedecinsException];
第 26 行和第 28 行中的 [getMessagesForException] 方法如下:
// list of exception error messages
protected static List<String> getMessagesForException(Exception exception) {
// retrieve the list of exception error messages
Throwable cause = exception;
List<String> erreurs = new ArrayList<String>();
while (cause != null) {
// the message is retrieved only if it is !=null and not blank
String message = cause.getMessage();
if (message != null) {
message = message.trim();
if (message.length() != 0) {
erreurs.add(message);
}
}
// next cause
cause = cause.getCause();
}
return erreurs;
}
私有方法 [getBase64] 返回字符串 'login:passwd' 的 Base64 编码,用于 HTTP 身份验证头:
private String getBase64(User user) {
// encodes user and password in base 64 - requires java 8
String chaîne = String.format("%s:%s", user.getLogin(), user.getPasswd());
return String.format("Basic %s", new String(Base64.getEncoder().encode(chaîne.getBytes())));
}
[Dao] 类继承自 [AbstractDao] 类,具体如下:
package rdvmedecins.client.dao;
import java.io.IOException;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import rdvmedecins.client.entities.AgendaMedecinJour;
import rdvmedecins.client.entities.Client;
import rdvmedecins.client.entities.Creneau;
import rdvmedecins.client.entities.Medecin;
import rdvmedecins.client.entities.Rv;
import rdvmedecins.client.entities.User;
import rdvmedecins.client.requests.PostAjouterRv;
import rdvmedecins.client.requests.PostSupprimerRv;
import rdvmedecins.client.responses.Response;
@Service
public class Dao extends AbstractDao implements IDao {
// mappers jSON
@Autowired
ObjectMapper jsonMapper;
@Autowired
private ObjectMapper jsonMapperShortCreneau;
@Autowired
private ObjectMapper jsonMapperLongRv;
@Autowired
private ObjectMapper jsonMapperShortRv;
public List<Client> getAllClients(User user) {
...
}
public List<Medecin> getAllMedecins(User user) {
...
}
...
}
- 第 22 行:[Dao] 类是 Spring 组件。此处使用了 [@Service] 注解。我们本可以继续使用此前一直使用的 [@Component] 注解;
- 第 26–36 行:注入在 [DaoConfig] 配置类中定义的四个 JSON 映射器;
[Dao] 类的所有方法都遵循相同的模式。我们将详细说明一个 GET 操作和一个 POST 操作。
首先,一个 [GET] 请求:
public AgendaMedecinJour getAgendaMedecinJour(User user, long idMedecin, String jour) {
// the answer
Response<AgendaMedecinJour> response;
// the diary
String jsonResponse = getResponse(user, String.format("%s/%s/%s", "/getAgendaMedecinJour", idMedecin, jour), null);
try {
// diary AgendaMedecinJour
response = jsonMapperLongRv.readValue(jsonResponse, new TypeReference<Response<AgendaMedecinJour>>() {
});
} catch (IOException e) {
throw new RdvMedecinsException(401, getMessagesForException(e));
} catch (RuntimeException e) {
throw new RdvMedecinsException(402, getMessagesForException(e));
}
// response analysis
int status = response.getStatus();
if (status != 0) {
throw new RdvMedecinsException(status, response.getMessages());
} else {
return response.getBody();
}
}
- 第 5 行:调用泛型方法 [getResponse]。实际使用的参数如下:
- 1:用户;
- 2:目标 URL;
- 3:待提交的值。在此情况下,无值;
- 第 5 行:该调用未被 try/catch 代码块包裹。getResponse 方法可能会抛出 RdvMedecinsException。若抛出该异常,它将传播至上文调用 getAgendaMedecinJour 方法的那个方法;
- 第 8 行:URL [/getAgendaMedecinJour] 返回一个 [Response<AgendaMedecinJour>] 对象,该对象已在服务器端由 JSON 映射器 [jsonMapperLongRv] 序列化为 JSON。我们使用相同的映射器对接收到的 JSON 字符串进行反序列化;
- 第 10–13 行:如果第 9 行发生错误,则抛出 [RdvMedecinsException];
- 第 16–21 行:解析服务器发送的响应;
- 第 17–18 行:如果服务器报告了错误,则抛出一个包含服务器提供的信息的异常;
- 第 19–21 行:否则,返回医生的日程安排;
待分析的 POST 请求如下:
public Rv ajouterRv(User user, String jour, long idCreneau, long idClient) {
// the answer
Response<Rv> response;
try {
// the Rv
String jsonResponse = getResponse(user, "/ajouterRv",
jsonMapper.writeValueAsString(new PostAjouterRv(idClient, idCreneau, jour)));
// the Rv Rv
response = jsonMapperLongRv.readValue(jsonResponse, new TypeReference<Response<Rv>>() {
});
} catch (RdvMedecinsException e) {
throw e;
} catch (IOException e) {
throw new RdvMedecinsException(381, getMessagesForException(e));
} catch (RuntimeException e) {
throw new RdvMedecinsException(382, getMessagesForException(e));
}
// response analysis
int status = response.getStatus();
if (status != 0) {
throw new RdvMedecinsException(status, response.getMessages());
} else {
return response.getBody();
}
}
- 第 6 行:调用 [getResponse] 方法时,传入以下参数:
- 1:用户;
- 2:目标 URL;
- 3:提交的值:我们传递一个类型为 [PostAjouter] 的 JSON 值,该值由方法接收的参数信息构建而成。我们使用不带过滤器的 JSON 映射器;
- 第 9 行:在服务器端,JSON 映射器 [jsonMapperLongRv] 对服务器响应进行了序列化。在客户端,我们使用同一个映射器对其进行反序列化;
- 第 6 行:URL [/ajouterRv] 返回类型为 [Response<Rv>] 的 JSON 值;
- 第 4–11 行:此处将 [getResponse] 方法置于 try/catch 块中,因为序列化提交的值可能会抛出异常。[getResponse] 方法很可能抛出 [RdvMedecinsException]。此时,我们只需重试该操作(第 11–12 行);
下面的代码(第 13–24 行)与刚才讨论的类似。因此,与 GET 操作的唯一区别在于 [getResponse] 方法的第二个参数,该参数必须是待提交值的 JSON 表示形式。
其他方法均基于相同的模式构建。
8.5.11. 异常
在运行各种测试时,我们遇到了一种异常情况,该情况总结在以下 [Anomalie] 类中:
package rdvmedecins.clients.console;
import java.io.IOException;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import rdvmedecins.client.config.DaoConfig;
import rdvmedecins.client.dao.IDao;
import rdvmedecins.client.dao.RdvMedecinsException;
import rdvmedecins.client.entities.User;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
public class Anomalie {
// serializer jSON
static private ObjectMapper mapper = new ObjectMapper();
// connection timeout in milliseconds
static private int TIMEOUT = 1000;
public static void main(String[] args) throws IOException {
// we retrieve a reference on the [DAO] layer
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(DaoConfig.class);
IDao dao = context.getBean(IDao.class);
// set the URL of the web/json service
dao.setUrlServiceWebJson("http://localhost:8080");
// set timeouts in milliseconds
dao.setTimeout(TIMEOUT);
// Authentication
String message = "/authenticate [admin,admin]";
try {
dao.authenticate(new User("admin", "admin"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// Authentication
message = "/authenticate [admin,x]";
try {
dao.authenticate(new User("admin", "x"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// Authentication
message = "/authenticate [user,user]";
try {
dao.authenticate(new User("user", "user"));
System.out.println(String.format("%s : OK", message));
} catch (RdvMedecinsException e) {
showException(message, e);
}
// closing context
context.close();
}
private static void showException(String message, RdvMedecinsException e) {
System.out.println(String.format("URL [%s]", message));
System.out.println(String.format("L'erreur n° [%s] s'est produite :", e.getStatus()));
for (String msg : e.getMessages()) {
System.out.println(msg);
}
}
}
- 第 31–38 行:用户 [admin, admin] 已通过身份验证;
- 第40-47行:对用户 [admin, x] 进行身份验证,该用户的密码不正确;
- 第 49-56 行:用户 [user, user] 通过身份验证;该用户存在但未获授权;
以下是结果:
- 第 2 行:与预期相反,用户 [admin, x] 被接受;
如果我们将代码的第 33–38 行注释掉,会得到以下结果:
这是预期的结果。看起来,一旦用户 [admin, admin] 首次成功登录,后续登录时就不再需要输入密码。情况确实如此。默认情况下,Spring Security 使用会话机制,确保用户经过身份验证后,在后续请求中无需再次验证。 您可以在 Web 服务器 / JSON 中的 [Spring Security] 配置中进行修改,以取消此行为:
![]() |
必须按以下方式修改 [SecurityConfig] 文件:
@Override
protected void configure(HttpSecurity http) throws Exception {
...
// no session
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
- 第 5 行指定不应建立安全会话;
这解决了问题。
8.6. Spring / Thymeleaf 服务器端渲染
8.6.1. 简介
让我们回到即将构建的客户端/服务器应用程序的架构:
![]() |
- [Web2] Web/JSON 服务器已构建完成;
- [Web1] 客户端的 [DAO] 层已构建完成;
[Web1] 服务器与客户端浏览器之间的关系是一种客户端/服务器关系,其中服务器是一个 Web/JSON 服务器。实际上,[Web1] 将传输封装在 JSON 字符串中的 HTML 流。客户端/服务器架构如下:
![]() |
- 我们采用客户端 [2] / 服务器 [1] 架构,其中客户端与服务器通过 JSON 进行通信;
- 在[1]中,Spring MVC/Thymeleaf Web层以JSON格式提供视图、视图片段和数据。因此,该服务器是一个类似于服务器[Web1]的Web/JSON服务器。它也是无状态的;
- 在[2]中:应用程序启动时加载的视图中嵌入的JavaScript代码采用分层结构:
- [展示]层处理用户交互,
- [DAO] 层通过 [Web2] 服务器处理数据访问;
- 客户端 [2] 将缓存某些视图以减轻服务器负载;
我们将分几个步骤构建基于 Spring MVC/Thymeleaf 实现的 Web/JSON 服务器 [Web1]:
- 探索 Bootstrap CSS 框架;
- 编写视图;
- 编写控制器;
然后,我们将单独为服务器 [Web1] 构建 JS 客户端。为了清楚地展示该客户端与服务器 [Web1] 具有一定程度的独立性,我们将使用 [WebStorm] 工具而非 STS 来构建它。
接下来,我们将省略某些细节,因为这些细节可能会分散我们对主要重点——即代码组织——的注意力。感兴趣的读者可以在本文档的网站上找到完整的代码。
8.6.2. STS 项目
![]() |
- 在[1]中,Java代码;
- 在 [2] 中,视图;
[pom.xml] 中的 Maven 配置如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st.rdvmedecins</groupId>
<artifactId>rdvmedecins-springthymeleaf-server</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>rdvmedecins-springthymeleaf-server</name>
<description>Gestion de RV Médecins</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.0.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>istia.st.rdvmedecins</groupId>
<artifactId>rdvmedecins-webjson-client-console</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
<properties>
<start-class>rdvmedecins.springthymeleaf.server.boot.Boot</start-class>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.7</java.version>
</properties>
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>1.7</source>
<target>1.7</target>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
...
</project>
- 第 16–19 行:该项目是一个 Thymeleaf 项目;
- 第 20–24 行:该项目依赖于我们刚刚构建的 [DAO] 层;
Java 配置由两个文件负责:
![]() |
[web] 层由以下 [WebConfig] 文件进行配置:
package rdvmedecins.springthymeleaf.server.config;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.support.ResourceBundleMessageSource;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import org.thymeleaf.spring4.SpringTemplateEngine;
import org.thymeleaf.spring4.templateresolver.SpringResourceTemplateResolver;
@EnableAutoConfiguration
public class WebConfig extends WebMvcConfigurerAdapter {
// ----------------- layer configuration [web]
@Bean
public MessageSource messageSource() {
ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
messageSource.setBasename("i18n/messages");
return messageSource;
}
@Bean
public SpringResourceTemplateResolver templateResolver() {
SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver();
templateResolver.setPrefix("classpath:/templates/");
templateResolver.setSuffix(".xml");
templateResolver.setTemplateMode("HTML5");
templateResolver.setCacheable(true);
templateResolver.setCharacterEncoding("UTF-8");
return templateResolver;
}
@Bean
SpringTemplateEngine templateEngine(SpringResourceTemplateResolver templateResolver) {
SpringTemplateEngine templateEngine = new SpringTemplateEngine();
templateEngine.setTemplateResolver(templateResolver);
return templateEngine;
}
// dispatcherservlet configuration for CORS headers
@Bean
public DispatcherServlet dispatcherServlet() {
DispatcherServlet servlet = new DispatcherServlet();
servlet.setDispatchOptionsRequest(true);
return servlet;
}
}
我们或多或少都接触过此配置中的所有元素。仅提醒一下,当您希望通过跨源请求(CORS)查询服务器时,第 42–47 行是必不可少的。本例中正是如此。
[AppConfig] 类用于配置整个应用程序:
package rdvmedecins.springthymeleaf.server.config;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Import;
import rdvmedecins.client.config.DaoConfig;
@EnableAutoConfiguration
@ComponentScan(basePackages = { "rdvmedecins.springthymeleaf.server" })
@Import({ WebConfig.class, DaoConfig.class })
public class AppConfig {
// admin / admin
private final String USER_INIT = "admin";
private final String MDP_USER_INIT = "admin";
// root web service / json
private final String WEBJSON_ROOT = "http://localhost:8080";
// timeout in milliseconds
private final int TIMEOUT = 5000;
// CORS
private final boolean CORS_ALLOWED=true;
...
}
- 第 11 行:[AppConfig] 导入 [DAO] 层和 [web] 层的配置;
- 第 15-16 行:允许应用程序访问应用程序启动进程以缓存医生和客户所需的凭据;
- 第 18 行:[Web1] Web 服务 / JSON 的 URL;
- 第 20 行:应用程序 HTTP 调用的超时时间;
- 第 22 行:一个布尔值,用于启用或禁用跨域调用;
最后,在 [application.properties] 文件中,将 Tomcat 服务器配置为在 8081 端口上运行:
![]() |
server.port=8081
8.6.3. 应用程序功能
这些功能已在第8.2节中描述。现在我们将回顾这些功能。使用浏览器,我们请求URL [http://localhost:8081/boot.html]:
![]() |
- 在[1]中,即应用程序的登录页面;
- 在 [2] 和 [3] 中,分别填写希望使用该应用程序的用户名和密码。共有两个用户:admin/admin(用户名/密码),其角色为 (ADMIN);以及 user/user,其角色为 (USER)。只有 ADMIN 角色具有使用该应用程序的权限。USER 角色仅用于在此用例中演示服务器的响应;
- 在 [4] 中,用于连接服务器的按钮;
- 在 [5] 中,应用程序的语言。共有两种:法语(默认)和英语;
- 在 [6] 处,服务器 URL [rdvmedecins-springthymeleaf-server];
![]() |
- 在 [1] 中,您登录;
![]() |
- 登录后,您可以选择想就诊的医生[2]和预约日期[3]。一旦选定医生和日期,日历就会自动显示:
![]() |
- 医生日历显示后,您可以预约具体时段 [5];
![]() |
- 在[6]中,选择就诊患者,并在[7]中确认选择;
![]() |
预约确认后,系统将自动返回日历页面,新预约已显示在日历中。该预约稍后可删除 [8]。
主要功能已介绍完毕。这些功能非常简单。最后我们来看看语言设置:
![]() |
- 在 [1] 中,您可以将语言从法语切换为英语;
![]() |
- 在[2]中,界面切换为英文,包括日历;
8.6.4. 步骤 1:Bootstrap CSS 框架简介
![]() |
在上述 Web 客户端中,HTML 页面将使用 Bootstrap CSS 框架 [http://getbootstrap.com/],下面我们将对此进行介绍。
8.6.4.1. 示例项目
示例项目结构如下:
![]() |
- 在 [1] 中:整个项目;
- 在 [2] 中:Java 代码;
- 在 [3] 中:JavaScript 脚本;
![]() |
- 在 [4] 中:JavaScript 库;
- 在 [5] 中:Thymeleaf 视图;
- 在 [6] 中:样式表;
8.6.4.1.1. Maven 配置
[pom.xml] 文件适用于 Thymeleaf Maven 项目:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st</groupId>
<artifactId>rdvmedecins-webjson-client-bootstrap</artifactId>
<version>0.0.1-SNAPSHOT</version>
<packaging>jar</packaging>
<name>rdvmedecins-webjson-client-bootstrap</name>
<description>Démos Bootstrap</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.2.0.RELEASE</version>
<relativePath /> <!-- lookup parent from repository -->
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<start-class>istia.st.rdvmedecins.BootstrapDemo</start-class>
<java.version>1.7</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
8.6.4.1.2. Java 配置
![]() |
[BootstrapDemo] 类用于配置 Spring/Thymeleaf 应用程序:
package istia.st.rdvmedecins;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import org.thymeleaf.spring4.templateresolver.SpringResourceTemplateResolver;
@EnableAutoConfiguration
@ComponentScan({ "istia.st.rdvmedecins" })
public class BootstrapDemo extends WebMvcConfigurerAdapter {
public static void main(String[] args) {
SpringApplication.run(BootstrapDemo.class, args);
}
@Bean
public SpringResourceTemplateResolver templateResolver() {
SpringResourceTemplateResolver templateResolver = new SpringResourceTemplateResolver();
templateResolver.setPrefix("classpath:/templates/");
templateResolver.setSuffix(".xml");
templateResolver.setTemplateMode("HTML5");
templateResolver.setCacheable(true);
templateResolver.setCharacterEncoding("UTF-8");
return templateResolver;
}
}
我们已经遇到过这种类型的代码。
8.6.4.1.3. Spring 控制器
![]() |
[BootstrapController] 如下所示:
package istia.st.rdvmedecins;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
@Controller
public class BootstrapController {
@RequestMapping(value = "/bs-01", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bso1() {
return "bs-01";
}
@RequestMapping(value = "/bs-02", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs02() {
return "bs-02";
}
@RequestMapping(value = "/bs-03", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs03() {
return "bs-03";
}
@RequestMapping(value = "/bs-04", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs04() {
return "bs-04";
}
@RequestMapping(value = "/bs-05", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs05() {
return "bs-05";
}
@RequestMapping(value = "/bs-06", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs06() {
return "bs-06";
}
@RequestMapping(value = "/bs-07", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs07() {
return "bs-07";
}
@RequestMapping(value = "/bs-08", method = RequestMethod.GET, produces = "text/html; charset=UTF-8")
public String bs08() {
return "bs-08";
}
}
这些操作仅用于显示由 Thymeleaf 处理的视图。
8.6.4.1.4. [application.properties] 文件
[application.properties] 文件用于配置嵌入式 Tomcat 服务器:
server.port=8082
8.6.4.2. 示例 #1:巨型显示屏
[/bs-01] 操作将显示以下 [bs-01.xml] 视图:
![]() |
[bs-01.xml] 视图如下:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
</head>
<body id="body">
<div class="container">
<!-- Bootstrap Jumbotron -->
<div th:include="jumbotron"></div>
<!-- content -->
<div id="content">
<h1>Ici un contenu</h1>
</div>
<!-- error -->
<div id="erreur" class="alert alert-danger">
<span>Ici, un texte d'erreur</span>
</div>
</div>
</body>
</html>
- 第 7 行:Bootstrap 框架的 CSS 文件;
- 第 8 行:一个本地 CSS 文件;
- 第 13 行:display [1];
- 第 19–21 行:display [2];
- 第 11 行:CSS 类 [container] 定义了浏览器内的显示区域;
- 第 19 行:CSS 类 [alert] 显示一个彩色区域。类 [alert-danger] 使用预定义的颜色。此类还有多个变体 [alert-info、alert-warning 等];
巨型显示屏 [1] 由以下 [jumbotron.xml] 视图生成:
<!DOCTYPE html>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<!-- Bootstrap Jumbotron -->
<div class="jumbotron">
<div class="row">
<div class="col-md-2">
<img src="resources/images/caduceus.jpg" alt="RvMedecins" />
</div>
<div class="col-md-10">
<h1>
Les Médecins
<br />
associés
</h1>
</div>
</div>
</div>
</section>
- 第 4 行:该区域具有 CSS 类 [jumbotron];
- 第 5 行:[row] 类定义了一个包含 12 个列的行;
- 第 6 行:[col-md-2] 类定义了该行内的一个两列区域;
- 第 7 行:在这两列中放置了一张图片;
- 第 9–15 行:文本被放置在剩余的 10 个列中;
8.6.4.3. 示例 #2:导航栏
操作 [/bs-02] 显示以下视图 [bs-02.xml]:
![]() |
新功能是导航栏 [1],其中包含输入表单和按钮:
[bs-02.xml] 视图如下:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- scripts JS -->
<script src="resources/vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="resources/js/bs-02.js"></script>
</head>
<body id="body">
<div class="container">
<!-- navigation bar -->
<div th:include="navbar1"></div>
<!-- Bootstrap Jumbotron -->
<div th:include="jumbotron"></div>
<!-- content -->
<div id="content">
<h1>Ici un contenu</h1>
</div>
<!-- info -->
<div class="alert alert-warning">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- 第 10 行:我们引入 jQuery;
- 第 11 行:一个本地 JS 脚本;
- 第 16 行:导航栏;
导航栏由以下 [navbar1.xml] 视图生成:
<!DOCTYPE HTML>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="navbar-collapse collapse">
<img id="loading" src="resources/images/loading.gif" alt="waiting..." style="display: none" />
<!-- identification form -->
<div class="navbar-form navbar-right" role="form" id="formulaire" method="post">
<div class="form-group">
<input type="text" placeholder="Utilisateur" class="form-control" />
</div>
<div class="form-group">
<input type="password" placeholder="Mot de passe" class="form-control" />
</div>
<button type="button" class="btn btn-success" onclick="javascript:connecter()">Connexion</button>
</div>
</div>
</div>
</div>
</section>
![]() |
- 第 3 行:[navbar] 类用于设置导航栏的样式。[navbar-inverse] 类为其赋予黑色背景。[navbar-fixed-top] 类确保当您滚动浏览器显示的页面时,导航栏始终位于屏幕顶部;
- 第 5–13 行:定义区域 [1]。这通常是一系列我不理解的类。我直接使用该组件;
- 第 14–26 行:定义导航栏的“响应式”区域。在智能手机上,该区域会折叠为菜单区域;
- 第 15 行:一张当前处于隐藏状态的图片;
- 第 17–25 行:[navbar-form] 类用于设置导航栏中表单的样式。[navbar-right] 类将其定位在导航栏右侧;
- 第 21–23 行:第 17 行表单中的两个输入字段 [2]。它们位于包裹表单元素的 [form-group] 类中,且各自带有 [form-control] 类;
- 第 24 行:[btn] 类定义了一个按钮,并通过 [btn-success] 类进行增强,使其呈现绿色;
- 第 24 行:当点击 [Login] 按钮时,将执行以下 JS 函数:
function connecter() {
showInfo("Connexion demandée...");
}
function showInfo(message) {
$("#info").text(message);
}
以下是一个示例:

8.6.4.4. 示例 #3:列表按钮
操作 [/bs-03] 会显示以下视图 [bs-03.xml]:
![]() |
- 新功能是列表按钮 [1],也称为“下拉菜单”;
[bs-03.xml] 视图的代码如下:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- Bootstrap core JavaScript ================================================== -->
<script src="resources/vendor/jquery-2.1.1.min.js"></script>
<script src="resources/vendor/bootstrap.js"></script>
<!-- local script -->
<script type="text/javascript" src="resources/js/bs-03.js"></script>
</head>
<body id="body">
<div class="container">
<!-- navigation bar -->
<div th:include="navbar2"></div>
<!-- Bootstrap Jumbotron -->
<div th:include="jumbotron"></div>
<!-- content -->
<div id="content">
<h1>Ici un contenu</h1>
</div>
<!-- info -->
<div class="alert alert-warning">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- 第 11 行:下拉按钮需要 Bootstrap JS 文件;
- 第 18 行:新的导航栏;
[navbar2.xml] 视图如下:
<!DOCTYPE HTML>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="navbar-collapse collapse">
<img id="loading" src="resources/images/loading.gif" alt="waiting..." style="display: none" />
<!-- identification form -->
<div class="navbar-form navbar-right" role="form" id="formulaire" method="post">
<div class="form-group">
<input type="text" placeholder="Utilisateur" class="form-control" />
</div>
<div class="form-group">
<input type="password" placeholder="Mot de passe" class="form-control" />
</div>
<button type="button" class="btn btn-success" onclick="javascript:connecter()">Connexion</button>
<!-- languages -->
<div class="btn-group">
<button type="button" class="btn btn-danger">Langues</button>
<button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown">
<span class="caret"></span>
<span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu" role="menu">
<li>
<a href="javascript:setLang('fr')">Français</a>
</li>
<li>
<a href="javascript:setLang('en')">English</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- init page -->
<script th:inline="javascript">
/*<![CDATA[*/
// on initialise la page
initNavBar2();
/*]]>*/
</script>
</section>
- 第 25–40 行:定义下拉按钮;
- 第 27 行:[btn-danger] 类使其呈现红色;
- 第 32–39 行:列表项。每个列表项都是一个关联了 JavaScript 函数的链接;
- 第 46–51 行:文档加载后执行的 JavaScript 脚本;
JS脚本 [bs-03.js] 内容如下:
function initNavBar2() {
// dropdown des langues
$('.dropdown-toggle').dropdown();
}
function connecter() {
showInfo("Connexion demandée...");
}
function setLang(lang) {
var msg;
switch (lang) {
case 'fr':
msg = "Vous avez choisi la langue française...";
break;
case 'en':
msg = "You have selected english language...";
break;
}
showInfo(msg);
}
function showInfo(message) {
$("#info").text(message);
}
- 第 1-4 行:初始化 [dropdown] 的函数。[$('.dropdown-toggle')] 定位具有 [dropdown-toggle] 类的元素。这就是下拉按钮(视图中的第 28 行)。 将 JS 文件 [bootstrap.js] 中定义的 JS 函数 [dropdown()] 应用于该按钮。只有完成此操作后,该按钮才会作为下拉按钮工作;
- 第 10–21 行:选择语言时执行的函数;
以下是一个示例:

8.6.4.5. 示例 #4:一个菜单
操作 [/bs-04] 会显示以下视图 [bs-04.xml]:
![]() |
已添加一个菜单 [1]。
[bs-04.xml] 视图如下:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- Bootstrap core JavaScript ================================================== -->
<script src="resources/vendor/jquery-2.1.1.min.js"></script>
<script src="resources/vendor/bootstrap.js"></script>
<!-- local script -->
<script type="text/javascript" src="resources/js/bs-04.js"></script>
</head>
<body id="body">
<div class="container">
<!-- navigation bar -->
<div th:include="navbar3"></div>
<!-- Bootstrap Jumbotron -->
<div th:include="jumbotron"></div>
<!-- content -->
<div id="content">
<h1>Ici un contenu</h1>
</div>
<!-- info -->
<div class="alert alert-warning">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- 第 18 行:插入一个新的导航栏;
[navbar3.xml] 的视图如下:
<!DOCTYPE HTML>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="collapse navbar-collapse">
<img id="loading" src="resources/images/loading.gif" alt="waiting..." style="display: none" />
<ul class="nav navbar-nav">
<li class="active" id="lnkAfficherAgenda">
<a href="javascript:afficherAgenda()">Agenda </a>
</li>
<li class="active" id="lnkAccueil">
<a href="javascript:retourAccueil()">Retour Accueil </a>
</li>
<li class="active" id="lnkRetourAgenda">
<a href="javascript:retourAgenda()">Retour Agenda </a>
</li>
<li class="active" id="lnkValiderRv">
<a href="javascript:validerRv()">Valider </a>
</li>
</ul>
<!-- right-hand buttons -->
<div class="navbar-form navbar-right" role="form">
<!-- disconnect -->
<button type="button" class="btn btn-success" onclick="javascript:deconnecter()">Déconnexion</button>
<!-- languages -->
<div class="btn-group">
<button type="button" class="btn btn-danger">Langues</button>
<button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown">
<span class="caret"></span>
<span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu" role="menu">
<li>
<a href="javascript:setLang('fr')">Français</a>
</li>
<li>
<a href="javascript:setLang('en')">English</a>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- init page -->
<script th:inline="javascript">
/*<![CDATA[*/
// on initialise la page
initNavBar3();
/*]]>*/
</script>
</section>
- 第16–29行:创建包含四个选项的菜单,每个选项都链接到一个JS脚本;
- 第55–60行:页面加载时执行的脚本;
JS脚本 [bs-04.js] 内容如下:
...
function initNavBar3() {
// dropdown des langues
$('.dropdown-toggle').dropdown();
// l'moving image
loading = $("#loading");
loading.hide();
}
function afficherAgenda() {
showInfo("option [Agenda] cliquée...");
}
function retourAccueil() {
showInfo("option [Retour accueil] cliquée...");
}
function retourAgenda() {
showInfo("option [Retour agenda] cliquée...");
}
function validerRv() {
showInfo("option [Valider] cliquée...");
}
function setMenu(show) {
// les liens du menu
var lnkAfficherAgenda = $("#lnkAfficherAgenda");
var lnkAccueil = $("#lnkAccueil");
var lnkValiderRv = $("#lnkValiderRv");
var lnkRetourAgenda = $("#lnkRetourAgenda");
// on les met dans un dictionnaire
var options = {
"lnkAccueil" : lnkAccueil,
"lnkAfficherAgenda" : lnkAfficherAgenda,
"lnkValiderRv" : lnkValiderRv,
"lnkRetourAgenda" : lnkRetourAgenda
}
// on cache tous les liens
for ( var key in options) {
options[key].hide();
}
// on affiche ceux qui sont demandés
for (var i = 0; i < show.length; i++) {
var option = show[i];
options[option].show();
}
}
- 第 2–18 行:页面初始化函数;
- 第4行:显示语言选择按钮;
- 第6–7行:隐藏动画图像;
- 第26–48行:一个[setMenu]函数,允许您指定应显示哪些选项;
现在打开开发者控制台(Ctrl-Shift-I),并输入以下代码 [1]:
![]() |
然后返回浏览器。菜单已发生变化 [2]:
8.6.4.6. 示例 #5:下拉列表
[/bs-05] 操作将显示以下 [bs-05.xml] 视图:
![]() |
新功能位于 [1] 处。这里我们使用了一个来自 Bootstrap 外部的组件,即 [bootstrap-select] [http://silviomoreto.github.io/bootstrap-select/]。
[bs-05.xml] 视图的代码如下:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-select.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- Bootstrap core JavaScript ================================================== -->
<script type="text/javascript" src="resources/vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-select.js"></script>
<!-- local script -->
<script type="text/javascript" src="resources/js/bs-05.js"></script>
</head>
<body id="body">
<div class="container">
<!-- navigation bar -->
<div th:include="navbar3"></div>
<!-- Bootstrap Jumbotron -->
<div th:include="jumbotron"></div>
<!-- content -->
<div id="content" th:include="choixmedecin">
</div>
<!-- info -->
<div class="alert alert-warning">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- 第 8 行:下拉列表所需的 CSS;
- 第 13 行:下拉列表所需的 JS 文件;
- 第24行:下拉列表;
[choixmedecin.xml] 视图如下:
<!DOCTYPE html>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="alert alert-info">Veuillez choisir un médecin</div>
<div class="row">
<div class="col-md-3">
<h2>Médecin</h2>
<select id="idMedecin" class="combobox" data-style="btn-primary">
<option value="1">Mme Marie Pélissier</option>
<option value="2">Mr Jean Pardon</option>
<option value="3">Mlle Jeanne Jirou</option>
<option value="4">Mr Paul Macou</option>
</select>
</div>
</div>
<!-- local script -->
<script th:inline="javascript">
/*<![CDATA[*/
// on initialise la page
initChoixMedecin();
/*]]>*/
</script>
</section>
- 第 7–12 行:这是一个标准的 [select] 元素,但带有特定的类 [combobox]。属性 [data-style="btn-primary"] 为该组件赋予了蓝色;
- 第 16–21 行:页面加载时执行的脚本;
JS 文件 [bs-05.js] 内容如下:
...
function afficherAgenda() {
var idMedecin = $('#idMedecin option:selected').val();
showInfo("Vous avez sélectionné le médecin d'id=" + idMedecin);
}
function initChoixMedecin() {
// le select des médecins
$('#idMedecin').selectpicker();
// le menu
setMenu([ "lnkAfficherAgenda" ]);
}
- 第 7–12 行:页面加载时执行的函数;
- 第 9 行:将页面中的 [select] 转换为 Bootstrap 下拉列表的指令。[$('#idMedecin')] 引用了 [select](位于 [choixmedecin] 视图的第 7 行),而 JS 函数 [selectpicker] 来自 JS 文件 [bootstrap-select.js];
- 第 11 行:仅显示其中一个菜单选项;
- 第 2–5 行:点击 [Agenda] 菜单选项时执行的 JavaScript 函数;
- 第 3 行:我们获取下拉列表中选中选项的值:[$('#idMedecin option:selected')] 首先查找 [id=idMedecin] 的组件,然后在该组件内查找选中的选项。随后 [..].val() 操作获取所查找元素的值,即选中选项的 [value] 属性;
以下是一个选择医生的示例:
![]() |
8.6.4.7. 示例 #6:日历
操作 [/bs-06] 显示以下视图 [bs-06.xml]:

选择医生或日期会触发一个 JS 函数,该函数会同时显示所选医生和所选日期。以下是一个示例:
![]() |
使用语言列表按钮,您可以将日历(仅限日历)切换为英文:

这是本系列中最复杂的示例。日历是一个 [bootstrap-datepicker] 组件 [http://eternicode.github.io/bootstrap-datepicker]。
[bs-06.xml] 视图如下:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-select.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/datepicker3.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- Bootstrap core JavaScript ================================================== -->
<script type="text/javascript" src="resources/vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-select.js"></script>
<script type="text/javascript" src="resources/vendor/moment-with-locales.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.fr.js"></script>
<!-- local script -->
<script type="text/javascript" src="resources/js/bs-06.js"></script>
</head>
<body id="body">
<div class="container">
<!-- navigation bar -->
<div th:include="navbar3"></div>
<!-- Bootstrap Jumbotron -->
<div th:include="jumbotron"></div>
<!-- content -->
<div id="content" th:include="choixmedecinjour">
</div>
<!-- info -->
<div class="alert alert-warning">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- 第 8 行:[bootstrap-datepicker] 组件的 CSS 文件;
- 第 16 行:[bootstrap-datepicker] 组件的 JS 文件;
- 第 17 行:用于管理法语日历的 JS 文件。默认情况下,日历为英语;
- 第 15 行:名为 [moment] 的库的 JS 文件,该库提供了多种时间计算函数 [http://momentjs.com/];
- 第 28 行:日历视图;
[choixmedecinjour.xml] 视图如下:
<!DOCTYPE html>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="alert alert-info">Veuillez choisir un médecin et une date</div>
<div class="row">
<div class="col-md-3">
<h2>Médecin</h2>
<select id="idMedecin" class="combobox" data-style="btn-primary">
<option value="1">Mme Marie Pélissier</option>
<option value="2">Mr Jean Pardon</option>
<option value="3">Mlle Jeanne Jirou</option>
<option value="4">Mr Paul Macou</option>
</select>
</div>
<div class="col-md-3">
<h2>Date</h2>
<section id="calendar_container">
<div id="calendar" class="input-group date">
<input id="displayjour" type="text" class="form-control btn-primary" disabled="true">
<span class="input-group-addon">
<i class="glyphicon glyphicon-th"></i>
</span>
</input>
</div>
</section>
</div>
</div>
<!-- local script -->
<script th:inline="javascript">
/*<![CDATA[*/
// on initialise la page
initChoixMedecinJour();
/*]]>*/
</script>
</section>
- 第17-23行:日历;
- 第 18 行:[btn-primary] 类为其赋予蓝色;
- 第 18 行:[disabled="true"] 属性禁止手动输入日期。您必须使用日历;
- 第 16 行:日历已放置在 [id="calendar_container"] 部分中。若要更改日历的语言,必须先将其删除再重新生成。因此,请删除 [id="calendar_container"] 组件中的内容,然后将带有新语言的日历放置于此;
- 第 28–33 行:页面初始化代码;
JS 文件 [bs-06.js] 内容如下:
...
var calendar_infos = {};
function initChoixMedecinJour() {
// calendrier
var calendar_container = $("#calendar_container");
calendar_infos = {
"container" : calendar_container,
"html" : calendar_container.html(),
"today" : moment().format('YYYY-MM-DD'),
"langue" : "fr"
}
// création calendrier
updateCalendar();
// le select des médecins
$('#idMedecin').selectpicker();
$('#idMedecin').change(function(e) {
afficherAgenda();
})
// le menu
setMenu([]);
}
- 第 2 行:日历由多个 JS 函数管理。变量 [calendar_infos] 将收集日历的相关信息。该变量是全局变量,以便各个函数都能访问它;
- 第 6 行:我们标识日历容器;
- 第7–12行:日历存储的信息;
- 第 8 行:对其容器的引用,
- 第 9 行:日历的 HTML 代码。借助这两项信息,我们可以移除日历并重新生成它;
- 第 10 行:以 [yyyy-mm-dd] 格式表示的今日日期,
- 第 11 行:日历的语言;
- 第 14 行:日历的创建;
- 第 16 行:医生下拉菜单;
- 第17–19行:每当此下拉菜单中选定的值发生变化时,[displayCalendar]方法将被执行;
- 第 21 行:导航栏中不显示菜单;
[updateCalendar] 函数如下:
function updateCalendar(renew) {
if (renew) {
// régénération du calendrier actuel
calendar_infos.container.html(calendar_infos.html);
}
// initialisation du calendrier
var calendar = $("#calendar");
var settings = {
format : "yyyy-mm-dd",
startDate : calendar_infos.today,
language : calendar_infos.langue,
};
calendar.datepicker(settings);
// sélection de la date courante
if (calendar_infos.date) {
calendar.datepicker('setDate', calendar_infos.date)
}
// évts
calendar.datepicker().on('hide', function(e) {
// affichage jour sélectionné
displayJour();
});
calendar.datepicker().on('changeDate', function(e) {
// on note la nouvelle date
calendar_infos.date = moment(calendar.datepicker('getDate')).format("YYYY-MM-DD");
// affichage infos agenda
afficherAgenda();
// affichage jour sélectionné
displayJour();
});
// affichage jour sélectionné
displayJour();
}
- 第 1 行:[updateCalendar] 函数接受一个可选参数。如果该参数存在,则根据 [calendar_infos] 中包含的信息重新生成日历(第 4 行);
- 第 7 行:引用日历;
- 第 8–12 行:其初始化参数;
- 第 9 行:处理日期的格式 [yyyy-mm-dd],
- 第 10 行:日历中可选的最早日期。此处为今日日期。早于该日期的日期不可选;
- 第 11 行:日历的语言。共有两种:['en'] 和 ['fr'];
- 第 13 行:配置日历;
- 第 15–17 行:如果 [calendar_infos] 中的日期已初始化,则将该日期设为当前日历日期;
- 第19–22行:每次日历关闭时,将显示所选日期;
- 第23–30行:每次日历中的日期发生变化时:
- 第 25 行:将选定日期记录到 [calendar_infos] 中,
- 第 27 行:显示日历相关信息,
- 第 29 行:显示选中的日期;
- 第 32 行:如果存在选定日期,则显示该日期;
用于显示所选日期的 [displayJour] 方法如下:
// affiche le jour sélectionné
function displayJour() {
if (calendar_infos.date) {
var displayjour = $("#displayjour");
moment.locale(calendar_infos.langue);
jour = moment(calendar_infos.date).format('LL');
displayjour.val(jour);
}
}
- 第 3 行:如果已经选定了日期(初始时,日历中没有选定日期);
- 第4行:定位用于输入日期的组件;
- 第5行:该日期可以英文或法文显示。我们设置库的语言 [moment];
- 第6行:以所选语言和长格式显示选定的日期;
- 第7行:显示该日期;
以下是两个示例:
![]() | ![]() |
当日期或时间发生变化时,会执行 [displayCalendar] 方法:
function afficherAgenda() {
// on affiche médecin et date
var idMedecin = $('#idMedecin option:selected').val();
if (calendar_infos.date) {
showInfo("Vous avez sélectionné le médecin d'id=" + idMedecin + " et le jour " + calendar_infos.date);
}
}
8.6.4.8. 示例 #7:一个“响应式”HTML 表格
注:"响应式"是指组件能够根据显示屏幕的大小进行自适应调整。下面我们将展示一个示例。
[/bs-07] 操作将显示以下 [bs-07.xml] 视图(全屏):
![]() |
新功能是 HTML 表格 [1]。该表格由 JS 库 [footable] 管理:[https://github.com/fooplugins/FooTable]。
若调整浏览器窗口大小,将显示如下效果:
![]() |
- HTML表格已适应屏幕尺寸;
- 在[1]中,要查看[Book]链接,必须点击[+]号;
- 在[2]中,点击[+]号后显示的内容;
[bs-07.xml] 的显示效果如下:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-select.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/datepicker3.css" />
<link rel="stylesheet" type="text/css" href="resources/css/footable.core.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- Bootstrap core JavaScript ================================================== -->
<script type="text/javascript" src="resources/vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-select.js"></script>
<script type="text/javascript" src="resources/vendor/moment-with-locales.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.fr.js"></script>
<script type="text/javascript" src="resources/vendor/footable.js"></script>
<!-- local script -->
<script type="text/javascript" src="resources/js/bs-07.js"></script>
</head>
<body id="body">
<div class="container">
<!-- navigation bar -->
<div th:include="navbar3" />
<!-- Bootstrap Jumbotron -->
<div th:include="jumbotron" />
<!-- content -->
<div id="content" th:include="choixmedecinjour" />
<div id="agenda" th:include="agenda" />
<!-- info -->
<div class="alert alert-success">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- 第 10 行:[footable] 库的 CSS;
- 第 19 行:[footable] 库的 JavaScript 代码;
- 第 31 行:日历的 HTML 表格;
[agenda.xml] 视图如下:
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div class="row alert alert-danger">
<div class="col-md-6">
<table id="creneaux" class="table">
<thead>
<tr>
<th data-toggle="true">
<span>Créneau horaire</span>
</th>
<th>
<span>Client</span>
</th>
<th data-hide="phone">
<span>Action</span>
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
<span class='status-metro status-active'>
9h00-9h20
</span>
</td>
<td>
<span></span>
</td>
<td>
<a href="javascript:reserver(14)" class="status-metro status-active">
Réserver
</a>
</td>
</tr>
<tr>
<td>
<span class='status-metro status-suspended'>
9h20-9h40
</span>
</td>
<td>
<span>Mme Paule MARTIN</span>
</td>
<td>
<a href="javascript:supprimer(17)" class="status-metro status-suspended">
Supprimer
</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- init page -->
<script th:inline="javascript">
/*<![CDATA[*/
// on initialise la page
initAgenda();
/*]]>*/
</script>
</body>
</html>
- 第 4 行:将表格置于一行 [row] 并添加彩色框 [alert alert-danger];
- 第 5 行:表格将横跨 6 列 [col-md-6];
- 第 6 行:HTML 表格由 Bootstrap 进行样式设置 [class='table'];
- 第 9 行:[data-toggle] 属性指定包含 [+/-] 符号的列,用于展开/折叠该行;
- 第 15 行:[data-hide='phone'] 属性指定当屏幕尺寸为手机屏幕时,该列应被隐藏。也可以使用值 'tablet';
- 第 31 行:[Book] 链接关联了一个 JS 函数;
- 第 46 行:为 [Delete] 链接关联了一个 JS 函数;
- 第 56–61 行:页面初始化;
上述使用的部分 CSS 类来自 CSS 文件 [bootstrapDemo.css]:
@CHARSET "UTF-8";
#creneaux th {
text-align: center;
}
#creneaux td {
text-align: center;
font-weight: bold;
}
.status-metro {
display: inline-block;
padding: 2px 5px;
color:#fff;
}
.status-metro.status-active {
background: #43c83c;
}
.status-metro.status-suspended {
background: #fa3031;
}
[status-*] 样式源自该库网站上一个使用 [footable] 表格的示例。
在 JS 文件 [bs-07.js] 中,页面的初始化代码如下:
function initAgenda() {
// time slot table
$("#creneaux").footable();
}
就这样。[$("#creneaux")] 指的是我们要使其响应式的 HTML 表格。此外,以下是与 [预订] 和 [删除] 这两个链接相关的 JS 函数:
function reserver(idCreneau) {
showInfo("Réservation du créneau n° " + idCreneau);
}
function supprimer(idRv) {
showInfo("Suppression du rv n° " + idRv);
}
8.6.4.9. 示例 #8:模态框
[/bs-08] 操作将显示以下 [bs-08.xml] 视图:

此前,点击 [Book] 链接会在信息框中显示相关信息,而在此处,我们将显示一个模态框以供选择预约的客户:

所使用的组件是 [bootstrap-modal] 组件 [https://github.com/jschr/bootstrap-modal/]。
[bs-08.xml] 视图如下:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width" />
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="resources/css/bootstrap-3.1.1-min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrap-select.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/datepicker3.css" />
<link rel="stylesheet" type="text/css" href="resources/css/footable.core.min.css" />
<link rel="stylesheet" type="text/css" href="resources/css/bootstrapDemo.css" />
<!-- Bootstrap core JavaScript ================================================== -->
<script type="text/javascript" src="resources/vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-select.js"></script>
<script type="text/javascript" src="resources/vendor/moment-with-locales.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-datepicker.fr.js"></script>
<script type="text/javascript" src="resources/vendor/bootstrap-modal.js"></script>
<script type="text/javascript" src="resources/vendor/footable.js"></script>
<!-- local script -->
<script type="text/javascript" src="resources/js/bs-08.js"></script>
</head>
<body id="body">
<div class="container">
<!-- navigation bar -->
<div th:include="navbar3" />
<!-- Bootstrap Jumbotron -->
<div th:include="jumbotron" />
<!-- content -->
<div id="content" th:include="choixmedecinjour" />
<div id="agenda" th:include="agenda-modal" />
<div th:include="resa" />
<!-- info -->
<div class="alert alert-success">
<span id="info">Ici, un texte d'information</span>
</div>
</div>
</body>
</html>
- 第 19 行:模态框所需的 JS 文件;
- 第 32 行:[agenda-modal] 视图与 [agenda] 视图完全相同,只有一个细节不同:处理 [Book] 链接的 JS 函数:
<a href="javascript:showDialogResa(14)" class="status-metro status-active">Réserver</a>
[showDialogResa] 函数负责显示用于选择客户的模态框;
- 第 33 行:[resa.xml] 视图即用于选择客户的模态框:
<!DOCTYPE HTML>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div id="resa" class="modal fade">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">
</span>
</button>
<!-- <h4 class="modal-title">Modal title</h4> -->
</div>
<div class="modal-body">
<div class="alert alert-info">
<h3>
<span>Prise de rendez-vous</span>
</h3>
</div>
<div class="row">
<div class="col-md-3">
<h2>Clients</h2>
<select id="idClient" class="combobox" data-style="btn-primary">
<option value="1">Mme Marguerite Planton</option>
<option value="2">Mr Maxime Franck</option>
<option value="3">Mlle Elisabeth Oron</option>
<option value="4">Mr Gaëtan Calot</option>
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-warning" onclick="javascript:cancelDialogResa()">Annuler</button>
<button type="button" class="btn btn-primary" onclick="javascript:validateResa()">Valider</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- init page -->
<script th:inline="javascript">
/*<![CDATA[*/
// on initialise la page
initResa();
/*]]>*/
</script>
</section>
- 第3-37行:模态框;
- 第13-30行:此框的内容(将显示的内容);
- 第 31-34 行:对话框按钮;
- 第 32 行:由 JS 函数 [cancelDialogResa] 处理的 [取消] 按钮;
- 第 33 行:由 JS 函数 [validateResa] 处理的 [确认] 按钮;
- 第 39–44 行:模态框初始化脚本;
这将生成以下视图:
![]() |
请注意,模态框默认情况下不会显示。这就是为什么应用程序启动时它不可见,尽管文档中包含了其 HTML 代码。
JS 文件 [bs-08.js] 内容如下:
var idCreneau;
var idClient;
var resa;
function showDialogResa(idCreneau) {
// on mémorise l'id du créneau
this.idCreneau = idCreneau;
// on affiche le dialogue de réservation
var resa = $("#resa");
resa.modal('show');
// log
showInfo("Réservation du créneau n° " + idCreneau);
}
function cancelDialogResa() {
// on cache la boîte de dialogue
resa.modal('hide');
}
// validation résa
function validateResa() {
// on récupère les infos
var idClient = $('#idClient option:selected').val();
// on cache la boîte de dialogue
resa.modal('hide');
// infos
showInfo("Réservation du créneau n° " + idCreneau + " pour le client n° " + idClient)
}
function initResa() {
// le select des clients
$('#idClient').selectpicker();
// boîte modale
resa = $("#resa");
resa.modal({});
}
- 第 30–36 行:模态框初始化函数;
- 第 32 行:模态框包含一个需要初始化的下拉列表;
- 第34–35行:模态框本身的初始化;
- 第 5–13 行:附加在 [Book] 链接上的 JS 函数;
- 第 7 行:该函数的参数存储在第 1 行定义的全局变量中;
- 第 9–10 行:显示模态框;
- 第 12 行:在信息框中记录信息;
- 第 15–18 行:处理 [Cancel] 按钮。我们仅将模态框隐藏(第 17 行);
- 第 21–31 行:绑定在 [Submit] 按钮上的 JavaScript 函数;
- 第 23 行:获取所选客户端的 [value] 属性;
- 第 25 行:隐藏对话框;
- 第 27 行:我们记录两条信息:预留的席位号及其对应的客户;
8.6.5. 步骤 2:编写视图
接下来我们将描述 [Web1] 服务器返回的视图及其模板。
![]() |
8.6.5.1. [navbar-start] 视图
它在启动页面上显示导航栏:

[navbar-start.xml] 的代码如下:
<!DOCTYPE HTML>
<section xmlns:th="http://www.thymeleaf.org">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="navbar-collapse collapse">
<img id="loading" src="resources/images/loading.gif" alt="waiting..." style="display: none" />
<!-- identification form -->
<div class="navbar-form navbar-right" role="form" id="formulaire">
<div class="form-group">
<input type="text" th:placeholder="#{service.url}" class="form-control" id="urlService" />
</div>
<div class="form-group">
<input type="text" th:placeholder="#{username}" class="form-control" id="login" />
</div>
<div class="form-group">
<input type="password" th:placeholder="#{password}" class="form-control" id="passwd" />
</div>
<button type="button" class="btn btn-success" th:text="#{login}" onclick="javascript:connecter()">Sign in</button>
<!-- languages -->
<div class="btn-group">
<button type="button" class="btn btn-danger" th:text="#{langues}">Action</button>
<button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown">
<span class="caret"></span>
<span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu" role="menu">
<li>
<a href="javascript:setLang('fr')" th:text="#{langues.fr}" />
</li>
<li>
<a href="javascript:setLang('en')" th:text="#{langues.en}" />
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- init page -->
<script th:inline="javascript">
/*<![CDATA[*/
// on initialise la page
initNavBarStart();
/*]]>*/
</script>
</section>
此视图没有模板。它具有以下事件处理程序:
事件 | 处理程序 |
点击登录按钮 | |
点击 [法语] 链接 | |
点击 [English] 链接 |
8.6.5.2. [jumbotron] 视图
这是启动页面导航栏 [navbar-start] 下方显示的视图:

其代码 [jumbotron.xml] 如下:
<!DOCTYPE html>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<!-- Bootstrap Jumbotron -->
<div class="jumbotron">
<div class="row">
<div class="col-md-2">
<img src="resources/images/caduceus.jpg" alt="RvMedecins" />
</div>
<div class="col-md-10">
<h1 th:utext="#{application.header}" />
</div>
</div>
</div>
</section>
[jumbotron] 视图没有模板或事件。
8.6.5.3. [登录]视图
这是启动页面上巨型显示屏下方显示的视图:

其代码 [login.xml] 如下:
<!DOCTYPE html>
<section xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="alert alert-info" th:text="#{identification}">Identification
</div>
</section>
该视图既没有模板,也没有事件。
8.6.5.4. [navbar-run] 视图
这是登录成功后显示的导航栏:

其代码 [navbar-run.xml] 如下:
<!DOCTYPE HTML>
<section xmlns:th="http://www.thymeleaf.org">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="collapse navbar-collapse">
<img id="loading" src="resources/images/loading.gif" alt="waiting..." style="display: none" />
<!-- right-hand buttons -->
<form class="navbar-form navbar-right" role="form">
<!-- disconnect -->
<button type="button" class="btn btn-success" th:text="#{options.deconnecter}" onclick="javascript:deconnecter()">Déconnexion</button>
<!-- languages -->
<div class="btn-group">
<button type="button" class="btn btn-danger" th:text="#{langues}">Langue</button>
<button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown">
<span class="caret"></span>
<span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu" role="menu">
<li>
<a href="javascript:setLang('fr')" th:text="#{langues.fr}" />
</li>
<li>
<a href="javascript:setLang('en')" th:text="#{langues.en}" />
</li>
</ul>
</div>
</form>
</div>
</div>
</div>
<!-- init page -->
<script th:inline="javascript">
/*<![CDATA[*/
// on initialise la page
initNavBarRun();
/*]]>*/
</script>
</section>
此视图没有模板。它具有以下事件处理程序:
event | 处理程序 |
点击注销按钮 | |
点击 [法语] 链接 | |
点击 [English] 链接 |
8.6.5.5. [首页]视图
这是紧接在导航栏 [navbar-run] 下方显示的视图:

其代码 [home.html] 如下:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<div class="alert alert-info" th:text="#{choixmedecinjour.title}">Veuillez choisir un médecin et une date</div>
<div class="row">
<div class="col-md-3">
<h2 th:text="#{rv.medecin}">Médecin</h2>
<select name="idMedecin" id="idMedecin" class="combobox" data-style="btn-primary">
<option th:each="medecinItem : ${rdvmedecins.medecinItems}" th:text="${medecinItem.texte}" th:value="${medecinItem.id}"/>
</select>
</div>
<div class="col-md-3">
<h2 th:text="#{rv.jour}">Date</h2>
<section id="calendar_container">
<div id="calendar" class="input-group date">
<input id="displayjour" type="text" class="form-control btn-primary" disabled="true">
<span class="input-group-addon">
<i class="glyphicon glyphicon-th"></i>
</span>
</input>
</div>
</section>
</div>
</div>
<!-- agenda -->
<div id="agenda"></div>
<!-- local script -->
<script th:inline="javascript">
/*<![CDATA[*/
// on initialise la page
initChoixMedecinJour();
/*]]>*/
</script>
</html>
其模板如下:
- [rdvmedecins.medecinItems](第8行):医生列表;
就目前形式来看,该视图似乎没有事件处理程序。实际上,这些处理程序是在 [initChoixMedecinJour] 函数中定义的。该函数已在第 8.6.4.7 节(第 466 页,更具体地说是第 469 页)中介绍过。它包含以下事件处理程序:
event | 处理程序 |
医生选择 | |
选择日期 |
8.6.5.6. [日历]视图
[日程]视图显示医生日历中的某一天:

其 [agenda.xml] 代码如下:
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<h3 class="alert alert-info" th:text="${agenda.titre}">Agenda de Mme Pélissier le 13/10/2014</h3>
<h4 class="alert alert-danger" th:if="${agenda.creneaux.length}==0" th:text="#{agenda.medecinsanscreneaux}">Ce médecin n'a pas encore de créneaux
de consultation</h4>
<th:block th:if="${agenda.creneaux.length}!=0">
<div class="row tab-content alert alert-warning">
<div class="tab-pane active col-md-6">
<table id="creneaux" class="table">
<thead>
<tr>
<th data-toggle="true">
<span th:text="#{agenda.creneauhoraire}">Créneau horaire</span>
</th>
<th>
<span th:text="#{agenda.client}">Client</span>
</th>
<th data-hide="phone">
<span th:text="#{agenda.action}">Action</span>
</th>
</tr>
</thead>
<tbody>
<tr th:each="creneau,iter : ${agenda.creneaux}">
<td>
<span th:if="${creneau.action}==1" class="status-metro status-active" th:text="${creneau.creneauHoraire}">Créneau horaire</span>
<span th:if="${creneau.action}==2" class="status-metro status-suspended" th:text="${creneau.creneauHoraire}">Créneau horaire</span>
</td>
<td>
<span th:text="${creneau.client}">Client</span>
</td>
<td>
<a th:if="${creneau.action}==1" th:href="@{'javascript:reserverCreneau('+${creneau.id}+')'}" th:text="${creneau.commande}"
class="status-metro status-active">Réserver
</a>
<a th:if="${creneau.action}==2" th:href="@{'javascript:supprimerRv('+${creneau.idRv}+')'}" th:text="${creneau.commande}"
class="status-metro status-suspended">Supprimer
</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- reservation -->
<section th:include="resa" />
</th:block>
<!-- init page -->
<script th:inline="javascript">
/*<![CDATA[*/
// on initialise la page
initAgenda();
/*]]>*/
</script>
</body>
</html>
此视图的模板仅包含一个元素:
- [agenda](第4行):一个专门用于显示日历的、稍显复杂的模板;
它具有以下事件处理程序:
event | 处理程序 |
点击 [删除] 按钮 | |
点击 [预订] 链接 |
第 47 行的 [resa] 视图是用户点击 [预订] 链接时显示的视图:

其代码 [resa.xml] 如下:
<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<div id="resa" class="modal fade">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">
</span>
</button>
<!-- <h4 class="modal-title">Modal title</h4> -->
</div>
<div class="modal-body">
<div class="alert alert-info">
<h3>
<span th:text="#{resa.titre}">Prise de rendez-vous</span>
</h3>
</div>
<div class="row">
<div class="col-md-3">
<h2 th:text="#{resa.client}">Client</h2>
<select name="idClient" id="idClient" class="combobox" data-style="btn-primary">
<option th:each="clientItem : ${clientItems}" th:text="${clientItem.texte}" th:value="${clientItem.id}" />
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-warning" onclick="javascript:cancelDialogResa()" th:text="#{resa.annuler}">Annuler</button>
<button type="button" class="btn btn-primary" onclick="javascript:validerRv()" th:text="#{resa.valider}">Valider</button>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div><!-- /.modal -->
<!-- init page -->
<script th:inline="javascript">
/*<![CDATA[*/
// on initialise la page
initResa();
/*]]>*/
</script>
</body>
</html>
该模型仅包含一个元素:
- [clientItems](第24行):客户端列表;
它具有以下事件处理程序:
事件 | 处理程序 |
点击 [取消] 按钮 | |
点击[确认]按钮 |
8.6.5.7. [错误]视图
如果用户请求的操作无法完成,将显示此视图:

[errors.xml] 的代码如下:
<!DOCTYPE HTML>
<section xmlns:th="http://www.thymeleaf.org">
<div class="alert alert-danger">
<h4>
<span th:text="#{erreurs.titre}">Les erreurs suivantes se sont produites :</span>
</h4>
<ul>
<li th:each="message : ${erreurs}" th:text="${message}" />
</ul>
</div>
</section>
其模板仅包含一个元素:
- [errors](第 8 行):要显示的错误列表;
该视图没有事件处理程序。
8.6.5.8. 总结
下表列出了视图及其对应的模型:
视图 | 模型 | 事件处理程序 |
navbar-start | ||
顶部横幅 | ||
登录 | ||
导航栏-运行 | ||
首页 | ||
日历 | ||
预订 | ||
错误 |
8.6.6. 步骤 3:编写操作
让我们回到 [Web1] Web 服务的架构:
![]() |
接下来我们将查看 [Web1] 暴露了哪些 URL 及其实现:
8.6.6.1. [Web1] 服务公开的 URL
具体如下:
- 针对上述每个视图或其组合的 URL;
- 一个用于添加约会的 URL;
- 一个用于删除约会的 URL;
它们均返回如下所示的 [Response] 类型的响应:
public class Reponse {
// ----------------- properties
// operation status
private int status;
// the navigation bar
private String navbar;
// the jumbotron
private String jumbotron;
// the body of the page
private String content;
// the diary
private String agenda;
...
}
- 第 5 行:响应状态:1(成功),2(错误);
- 第 7 行:[navbar-start] 或 [navbar-run] 视图的 HTML 流(视情况而定);
- 第 9 行:[jumbotron] 视图的 HTML 源;
- 第 13 行:[agenda] 视图的 HTML 源;
- 第 9 行:适用于 [home]、[errors] 或 [login] 视图的 HTML 源(视情况而定);
公开的 URL 如下
将 [navbar-start] 视图放置在 [Response.navbar] 中 | |
将 [navbar-run] 视图放置在 [Response.navbar] 中 | |
将 [home] 视图放置在 [Response.content] 中 | |
将 [jumbotron] 视图放置在 [Response.jumbotron] 中 | |
将 [agenda] 视图放置在 [Response.agenda] 中 | |
将 [login] 视图放置在 [Response.content] 中 | |
| |
将 [navbar-run] 视图放置在 [Response.navbar] 中,将 [jumbotron] 视图放置在 [Response.jumbotron] 中,将 [home] 视图放置在 [Response.content] 中,并将 [calendar] 视图放置在 [Response.calendar] 中 | |
添加所选预约,并将新日程放入 [Response.agenda] | |
删除所选的预约,并将新的日历放入 [Response.calendar] |
8.6.6.2. [ApplicationModel] 单例
![]() |
[ApplicationModel] 类被实例化为单例,并注入到应用程序控制器中。其代码如下:
package rdvmedecins.springthymeleaf.server.models;
import java.util.ArrayList;
...
@Component
public class ApplicationModel implements IDao {
....
}
- 第 6 行:[ApplicationModel] 是 Spring 组件;
- 第 7 行:它实现了 [DAO] 层的接口。我们这样做是为了让 Action 类无需了解 [DAO] 层,只需知道 [ApplicationModel] 单例即可。[Web1] 的架构因此变为如下所示:
![]() |
让我们回到 [ApplicationModel] 类的代码:
package rdvmedecins.springthymeleaf.server.models;
import java.util.ArrayList;
...
@Component
public class ApplicationModel implements IDao {
// the [DAO] layer
@Autowired
private IDao dao;
// configuration
@Autowired
private AppConfig appConfig;
// data from the [DAO] layer
private List<ClientItem> clientItems;
private List<MedecinItem> medecinItems;
// configuration data
private String userInit;
private String mdpUserInit;
private boolean corsAllowed;
// exception
private RdvMedecinsException rdvMedecinsException;
// manufacturer
public ApplicationModel() {
}
@PostConstruct
public void init() {
// config
userInit = appConfig.getUSER_INIT();
mdpUserInit = appConfig.getMDP_USER_INIT();
dao.setTimeout(appConfig.getTIMEOUT());
dao.setUrlServiceWebJson(appConfig.getWEBJSON_ROOT());
corsAllowed = appConfig.isCORS_ALLOWED();
// caching of physician and customer drop-down lists
List<Medecin> medecins = null;
List<Client> clients = null;
try {
medecins = dao.getAllMedecins(new User(userInit, mdpUserInit));
clients = dao.getAllClients(new User(userInit, mdpUserInit));
} catch (RdvMedecinsException ex) {
rdvMedecinsException = ex;
}
if (rdvMedecinsException == null) {
// create drop-down list items
medecinItems = new ArrayList<MedecinItem>();
for (Medecin médecin : medecins) {
medecinItems.add(new MedecinItem(médecin));
}
clientItems = new ArrayList<ClientItem>();
for (Client client : clients) {
clientItems.add(new ClientItem(client));
}
}
}
// getters and setters
...
// interface implementation [IDao]
@Override
public void setUrlServiceWebJson(String url) {
dao.setUrlServiceWebJson(url);
}
@Override
public void setTimeout(int timeout) {
dao.setTimeout(timeout);
}
@Override
public Rv ajouterRv(User user, String jour, long idCreneau, long idClient) {
return dao.ajouterRv(user, jour, idCreneau, idClient);
}
...
}
- 第 11 行:注入对 [DAO] 层实现的引用。随后使用该引用来实现 [IDao] 接口(第 64–80 行);
- 第 14 行:注入应用程序配置;
- 第 33–37 行:使用此配置来配置应用程序架构的各个元素;
- 第 38–46 行:我们将用于填充医生和客户下拉列表的信息进行缓存。因此,我们假设如果医生或客户发生变更,则必须重启应用程序。此处的思路是展示 Spring 单例可以作为 Web 应用程序的缓存;
[MedecinItem] 和 [ClientItem] 类均继承自以下 [PersonneItem] 类:
package rdvmedecins.springthymeleaf.server.models;
import rdvmedecins.client.entities.Personne;
public class PersonneItem {
// element of a list
private Long id;
private String texte;
// manufacturer
public PersonneItem() {
}
public PersonneItem(Personne personne) {
id = personne.getId();
texte = String.format("%s %s %s", personne.getTitre(), personne.getPrenom(), personne.getNom());
}
// getters and setters
...
}
- 第 8 行:[id] 字段将是下拉列表选项的 [value] 属性的值;
- 第 9 行:[text] 字段将显示下拉列表选项的文本;
8.6.6.3. [BaseController] 类
![]() |
[BaseController] 类是 [RdvMedecinsController] 和 [RdvMedecinsCorsController] 控制器类的父类。创建这个父类并非强制要求。我们将 [RdvMedecinsController] 类中的辅助方法集中到了这里,其中除了一项外,其余均非必需。这些方法可分为三类:
- 辅助方法;
- 将视图与模型合并渲染的方法;
- 用于初始化操作的方法
| 两个提供错误消息列表的辅助方法。我们之前已经遇到并使用过它们; |
| 返回不带模板的 [home] 视图 |
| 返回 [agenda] 视图及其模板 |
| 返回不带模型的 [login] 视图 |
| 当 请求的操作以错误结束时,返回给客户端的响应 |
| [RdvMedecinsController] 控制器所有操作的初始化方法 |
让我们来看看其中两个方法。
[getPartialViewAgenda] 方法负责渲染生成难度最大的视图——日历视图。其代码如下:
// feed [agenda]
protected String getPartialViewAgenda(ActionContext actionContext, AgendaMedecinJour agenda, Locale locale) {
// contexts
WebContext thymeleafContext = actionContext.getThymeleafContext();
WebApplicationContext springContext = actionContext.getSpringContext();
// build the [agenda] page template
ViewModelAgenda modelAgenda = setModelforAgenda(agenda, springContext, locale);
// the agenda with its model
thymeleafContext.setVariable("agenda", modelAgenda);
thymeleafContext.setVariable("clientItems", application.getClientItems());
return engine.process("agenda", thymeleafContext);
}
- 第 9–10 行:日历模型的两个元素:
- 第9行:显示的日历。
- 第10行:用户预约时显示的客户列表;
第 7 行中的 [setModelforAgenda] 方法如下:
// agenda] page template
private ViewModelAgenda setModelforAgenda(AgendaMedecinJour agenda, WebApplicationContext springContext, Locale locale) {
// page title
String dateFormat = springContext.getMessage("date.format", null, locale);
Medecin médecin = agenda.getMedecin();
String titre = springContext.getMessage("agenda.titre", new String[] { médecin.getTitre(), médecin.getPrenom(),
médecin.getNom(), new SimpleDateFormat(dateFormat).format(agenda.getJour()) }, locale);
// reservation slots
ViewModelCreneau[] modelCréneaux = new ViewModelCreneau[agenda.getCreneauxMedecinJour().length];
int i = 0;
for (CreneauMedecinJour creneauMedecinJour : agenda.getCreneauxMedecinJour()) {
// doctor's slot
Creneau créneau = creneauMedecinJour.getCreneau();
ViewModelCreneau modelCréneau = new ViewModelCreneau();
modelCréneaux[i] = modelCréneau;
// id
modelCréneau.setId(créneau.getId());
// time slot
modelCréneau.setCreneauHoraire(String.format("%02dh%02d-%02dh%02d", créneau.getHdebut(), créneau.getMdebut(),
créneau.getHfin(), créneau.getMfin()));
Rv rv = creneauMedecinJour.getRv();
// customer and order
String commande;
if (rv == null) {
modelCréneau.setClient("");
commande = springContext.getMessage("agenda.reserver", null, locale);
modelCréneau.setCommande(commande);
modelCréneau.setAction(ViewModelCreneau.ACTION_RESERVER);
} else {
Client client = rv.getClient();
modelCréneau.setClient(String.format("%s %s %s", client.getTitre(), client.getPrenom(), client.getNom()));
commande = springContext.getMessage("agenda.supprimer", null, locale);
modelCréneau.setCommande(commande);
modelCréneau.setIdRv(rv.getId());
modelCréneau.setAction(ViewModelCreneau.ACTION_SUPPRIMER);
}
// next slot
i++;
}
// we render the agenda model
ViewModelAgenda modelAgenda = new ViewModelAgenda();
modelAgenda.setTitre(titre);
modelAgenda.setCreneaux(modelCréneaux);
return modelAgenda;
}
- 第 6 行:日程表有一个标题:

或:

我们可以看到,日期格式取决于语言。我们从消息文件中获取该格式(第 4 行)。
- 第11–40行:对于每个时间段,我们必须显示视图:
![]()
或显示视图:
![]()
- 第19–20行:显示时间段;
- 第 25–28 行:时间段可用的情况。此时,必须显示 [预订] 按钮;
- 第31–36行:时间段已被占用的情况。此时,必须同时显示客户信息和[删除]按钮;
我们将详细讨论的另一个方法是 [getActionContext] 方法。该方法在 [RdvMedecinsController] 的每个操作开始时被调用。其签名如下:
protected ActionContext getActionContext(String lang, String origin, HttpServletRequest request,HttpServletResponse response, BindingResult result, RdvMedecinsCorsController rdvMedecinsCorsController)
它返回以下 [ActionContext] 类型:
public class ActionContext {
// data
private WebContext thymeleafContext;
private WebApplicationContext springContext;
private Locale locale;
private List<String> erreurs;
...
}
- 第 4 行:操作的 Thymeleaf 上下文;
- 第 5 行:操作的 Spring 上下文;
- 第6行:操作的区域设置;
- 第7行:可能出现的错误信息列表;
其参数如下:
- [lang]:操作所需的语言,值为 'en' 或 'fr';
- [origin]:跨域请求时的 HTTP [origin] 标头;
- [request]:当前正在处理的 HTTP 请求,即通常所说的“操作”;
- [response]:将作为对此请求的响应发送的响应;
- [result]:[RdvMedecinsController] 的每个操作都会接收一个表单提交的值,并对其有效性进行验证。[result] 即为该验证的结果;
- [rdvMedecinsController]:包含这些操作的控制器;
[getActionContext] 方法的实现如下:
// context of an action
protected ActionContext getActionContext(String lang, String origin, HttpServletRequest request,HttpServletResponse response, BindingResult result, RdvMedecinsCorsController rdvMedecinsCorsController) {
// language?
if (lang == null) {
lang = "fr";
}
// local
Locale locale = null;
if (lang.trim().toLowerCase().equals("fr")) {
// french
locale = new Locale("fr", "FR");
} else {
// everything else in English
locale = new Locale("en", "US");
}
// headers CORS
rdvMedecinsCorsController.sendOptions(origin, response);
// ActionContext
ActionContext actionContext = new ActionContext(new WebContext(request, response, request.getServletContext(),locale), WebApplicationContextUtils.getWebApplicationContext(request.getServletContext()), locale, null);
// initialization errors
RdvMedecinsException e = application.getRdvMedecinsException();
if (e != null) {
actionContext.setErreurs(e.getMessages());
return actionContext;
}
// POST errors?
if (result != null && result.hasErrors()) {
actionContext.setErreurs(getErreursForModel(result, locale, actionContext.getSpringContext()));
return actionContext;
}
// no errors
return actionContext;
}
- 第 3–15 行:根据 [lang] 参数,我们设置操作的区域设置;
- 第 17 行:发送跨域请求所需的 HTTP 头部。此处不再赘述。所用技术如第 8.4.14 节所述;
- 第 19 行:构建 [ActionContext] 对象,确保无错误;
- 第 21 行:我们在第 8.6.6.2 节中看到,[ApplicationModel] 单例会访问数据库以检索客户和医生。此访问可能会失败。此时我们会记录发生的异常。在第 21 行,我们获取该异常;
- 第22–25行:若应用程序启动期间发生异常,则无法执行任何操作。因此,对于任何操作,我们都会返回一个包含该异常错误信息的[ActionContext]对象;
- 第 27–20 行:我们分析 [result] 参数以确定提交的值是否有效。如果无效,则返回一个包含相应错误消息的 [ActionContext] 对象;
- 第 32 行:无错误的情况;
接下来我们将考察 [RdvMedecinsController] 的操作
8.6.6.4. [/getNavBarStart] 操作
[/getNavBarStart] 操作渲染 [navbar-start] 视图。其签名如下:
@RequestMapping(value = "/getNavbarStart", method = RequestMethod.POST)
@ResponseBody
public Reponse getNavbarStart(@Valid @RequestBody PostLang postLang, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin)
它返回以下 [Response] 类型:
public class Reponse {
// ----------------- properties
// operation status
private int status;
// the navigation bar
private String navbar;
// the jumbotron
private String jumbotron;
// the body of the page
private String content;
// the diary
private String agenda;
...
}
并具有以下参数:
- [PostLang postlang]:下一个发布值:
public class PostLang {
// data
@NotNull
private String lang;
...
}
[PostLang] 类是所有提交值的父类。这是因为客户端必须始终指定执行操作时所使用的语言。
[getNavbarStart] 方法的实现如下:
// navbar-start
@RequestMapping(value = "/getNavbarStart", method = RequestMethod.POST)
@ResponseBody
public Reponse getNavbarStart(@Valid @RequestBody PostLang postLang, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
// action contexts
ActionContext actionContext = getActionContext(postLang.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// mistakes?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// returns the [navbar-start] view
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setNavbar(engine.process("navbar-start", thymeleafContext));
return reponse;
}
- 第 7 行:操作的初始化;
- 第10–13行:如果操作初始化方法报告了错误,则将这些错误(第12行)连同状态码2一起发送给客户端:
- 第 15–18 行:发送 [navbar-start] 视图,状态码为 1:
下文中,我们将仅详细说明新功能。
8.6.6.5. [/getNavbarRun] 操作
[/getNavBarRun] 操作会渲染 [navbar-run] 视图:
// navbar-run
@RequestMapping(value = "/getNavbarRun", method = RequestMethod.POST)
@ResponseBody
public Reponse getNavbarRun(@Valid @RequestBody PostLang postLang, BindingResult result, HttpServletRequest request,
HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
// action contexts
ActionContext actionContext = getActionContext(postLang.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// mistakes?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// returns the [navbar-run] view
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setNavbar(engine.process("navbar-run", thymeleafContext));
return reponse;
}
该操作可以返回两种类型的响应:
- 包含错误的响应(第10–13行):
- 包含 [navbar-run] 视图的响应:
8.6.6.6. [/getJumbotron] 操作
[/getJumbotron] 操作返回 [jumbotron] 视图:
// jumbotron
@RequestMapping(value = "/getJumbotron", method = RequestMethod.POST)
@ResponseBody
public Reponse getJumbotron(@Valid @RequestBody PostLang postLang, BindingResult result, HttpServletRequest request,
HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
// action contexts
ActionContext actionContext = getActionContext(postLang.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// mistakes?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// return view [jumbotron]
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setJumbotron(engine.process("jumbotron", thymeleafContext));
return reponse;
}
该操作可返回两种类型的响应:
- 包含错误的响应(第10–13行):
- 包含 [jumbotron] 视图的响应:
8.6.6.7. [/getLogin] 操作
[/getLogin] 操作渲染 [login] 视图:
@RequestMapping(value = "/getLogin", method = RequestMethod.POST)
@ResponseBody
public Reponse getLogin(@Valid @RequestBody PostLang postLang, BindingResult result, HttpServletRequest request,
HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
// action contexts
ActionContext actionContext = getActionContext(postLang.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// mistakes?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// returns the [login] view
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setJumbotron(engine.process("jumbotron", thymeleafContext));
reponse.setNavbar(engine.process("navbar-start", thymeleafContext));
reponse.setContent(getPartialViewLogin(thymeleafContext));
return reponse;
}
该操作可以返回两种类型的响应:
- 包含错误的响应(第9–11行):
- 包含 [login] 视图的响应:
8.6.6.8. [/getHome] 操作
[/getHome] 操作返回 [home] 视图。其签名如下:
@RequestMapping(value = "/getAccueil", method = RequestMethod.POST)
@ResponseBody
public Reponse getAccueil(@Valid @RequestBody PostUser postUser, BindingResult result, HttpServletRequest request,HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin)
- 第 3 行:提交的值类型为 [PostUser],如下所示:
public class PostUser extends PostLang {
// data
@NotNull
private User user;
...
}
- 第 1 行:[PostUser] 类继承自 [PostLang] 类,因此包含一种语言;
- 第 4 行:用户正尝试检索视图;
实现代码如下:
@RequestMapping(value = "/getAccueil", method = RequestMethod.POST)
@ResponseBody
public Reponse getAccueil(@Valid @RequestBody PostUser postUser, BindingResult result, HttpServletRequest request,
HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
// action contexts
ActionContext actionContext = getActionContext(postUser.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// mistakes?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// the [home] view is protected
try{
// user
User user = postUser.getUser();
// we check identifiers [userName, password]
application.authenticate(user);
}catch(RdvMedecinsException e){
// an error is returned
return getViewErreurs(thymeleafContext, e.getMessages());
}
// returns the [home] view
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setContent(getPartialViewAccueil(thymeleafContext));
return reponse;
}
- 第 15–22 行:请注意,[首页] 受保护,因此用户必须经过身份验证;
该操作可返回两种类型的响应:
- 错误响应(第 11 行和第 21 行):
- 包含 [home] 视图的响应(第 24–27 行):
8.6.6.9. [/getNavbarRunJumbotronHome] 操作
[/getNavbarRunJumbotronHome] 操作会渲染 [navbar-run, jumbotron, home] 视图。其签名如下:
@RequestMapping(value = "/getNavbarRunJumbotronAccueil", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getNavbarRunJumbotronAccueil(@Valid @RequestBody PostUser post, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin)
- 第 3 行:提交的值类型为 [PostUser];
该操作的实现如下:
// navbar+ jumbotron + home
@RequestMapping(value = "/getNavbarRunJumbotronAccueil", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getNavbarRunJumbotronAccueil(@Valid @RequestBody PostUser postUser, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
// action contexts
ActionContext actionContext = getActionContext(postUser.getLang(), origin, request, response, result,
rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// mistakes?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// the [home] view is protected
try {
// user
User user = postUser.getUser();
// we check identifiers [userName, password]
application.authenticate(user);
} catch (RdvMedecinsException e) {
// an error is returned
return getViewErreurs(thymeleafContext, e.getMessages());
}
// we send the answer
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setNavbar(engine.process("navbar-run", thymeleafContext));
reponse.setJumbotron(engine.process("jumbotron", thymeleafContext));
reponse.setContent(getPartialViewAccueil(thymeleafContext));
return reponse;
}
该操作可以返回两种类型的响应:
- 包含错误的响应(第13、23行):
- 包含视图 [navbar-run, jumbotron, home] 的响应(第 26–31 行):
8.6.6.10. [/getAgenda] 操作
[/getAgenda] 操作渲染 [agenda] 视图。其签名如下:
@RequestMapping(value = "/getAgenda", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getAgenda(@RequestBody @Valid PostGetAgenda postGetAgenda, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin)
- 第 3 行:提交的值类型为 [PostGetAgenda],如下所示:
public class PostGetAgenda extends PostUser {
// data
@NotNull
private Long idMedecin;
@NotNull
@DateTimeFormat(pattern = "yyyy-MM-dd")
private Date jour;
...
}
- 第 1 行:[PostGetAgenda] 类继承自 [PostUser] 类,因此包含语言和用户信息;
- 第 5 行:所需日历所属医生的 ID;
- 第 8 行:日历中所需的日期;
实现如下:
@RequestMapping(value = "/getAgenda", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getAgenda(@RequestBody @Valid PostGetAgenda postGetAgenda, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
// action contexts
ActionContext actionContext = getActionContext(postGetAgenda.getLang(), origin, request, response, result, rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
WebApplicationContext springContext = actionContext.getSpringContext();
Locale locale = actionContext.getLocale();
// mistakes?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// check the validity of the post
if (result != null) {
new PostGetAgendaValidator().validate(postGetAgenda, result);
if (result.hasErrors()) {
// returns the [errors] view
return getViewErreurs(thymeleafContext, getErreursForModel(result, locale, springContext));
}
}
...
}
- 截至第 14 行,代码现已符合标准;
- 第16–21行:我们对提交的值进行额外验证。日期必须等于或晚于今天。为了验证这一点,我们使用了一个验证器:
package rdvmedecins.web.validators;
import java.text.SimpleDateFormat;
import java.util.Date;
import org.springframework.validation.Errors;
import org.springframework.validation.Validator;
import rdvmedecins.springthymeleaf.server.requests.PostGetAgenda;
import rdvmedecins.springthymeleaf.server.requests.PostValiderRv;
public class PostGetAgendaValidator implements Validator {
public PostGetAgendaValidator() {
}
@Override
public boolean supports(Class<?> classe) {
return PostGetAgenda.class.equals(classe) || PostValiderRv.class.equals(classe);
}
@Override
public void validate(Object post, Errors errors) {
// the day chosen for the appointment
Date jour = null;
if (post instanceof PostGetAgenda) {
jour = ((PostGetAgenda) post).getJour();
} else {
if (post instanceof PostValiderRv) {
jour = ((PostValiderRv) post).getJour();
}
}
// transform dates into yyyy-MM-dd format
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
String strJour = sdf.format(jour);
String strToday = sdf.format(new Date());
// the chosen day must not precede today's date
if (strJour.compareTo(strToday) < 0) {
errors.rejectValue("jour", "todayandafter.postChoixMedecinJour", null, null);
}
}
}
- 第 19 行:该验证器适用于两个类:[PostGetAgenda] 和 [PostValiderRv];
让我们回到 [/getAgenda] 操作的代码:
@RequestMapping(value = "/getAgenda", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getAgenda(@RequestBody @Valid PostGetAgenda postGetAgenda, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
...
// action
try {
// doctor's diary
AgendaMedecinJour agenda = application.getAgendaMedecinJour(postGetAgenda.getUser(), postGetAgenda.getIdMedecin(),
new SimpleDateFormat("yyyy-MM-dd").format(postGetAgenda.getJour()));
// answer
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setAgenda(getPartialViewAgenda(actionContext, agenda, locale));
return reponse;
} catch (RdvMedecinsException e1) {
// returns the [errors] view
return getViewErreurs(thymeleafContext, e1.getMessages());
} catch (Exception e2) {
// returns the [errors] view
return getViewErreurs(thymeleafContext, getErreursForException(e2));
}
}
- 第 9-10 行:使用提交的参数,我们请求医生的日程安排;
- 第12-13行:返回日程表:
- 第 17、21 行:我们返回包含错误的响应:
8.6.6.11. 操作 [/getNavbarRunJumbotronHomeCalendar]
操作 [/getNavbarRunJumbotronHomeCalendar] 渲染视图 [navbar-run, jumbotron, home, calendar]。其实现如下:
@RequestMapping(value = "/getNavbarRunJumbotronAccueilAgenda", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse getNavbarRunJumbotronAccueilAgenda(@Valid @RequestBody PostGetAgenda post, BindingResult result,
HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
// action contexts
ActionContext actionContext = getActionContext(post.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
// mistakes?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// agenda
Reponse agenda = getAgenda(post, result, request, response, null);
if (agenda.getStatus() != 1) {
return agenda;
}
// we send the answer
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setNavbar(engine.process("navbar-run", thymeleafContext));
reponse.setJumbotron(engine.process("jumbotron", thymeleafContext));
reponse.setContent(getPartialViewAccueil(thymeleafContext));
reponse.setAgenda(agenda.getAgenda());
return reponse;
}
- 第 15–18 行:我们利用 [/getAgenda] 操作的存在来调用它。然后检查响应状态(第 16 行)。如果检测到错误,则在此处停止并返回响应;
- 第 20 行:我们发送请求的视图:
8.6.6.12. [/supprimerRv] 操作
[/deleteRv] 操作允许您删除一个预约。其签名如下:
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse supprimerRv(@Valid @RequestBody PostSupprimerRv postSupprimerRv, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin)
- 第 3 行:提交的值类型为 [PostSupprimerRv],如下所示:
public class PostSupprimerRv extends PostUser {
// data
@NotNull
private Long idRv;
..
}
- 第 1 行:[PostSupprimerRv] 类继承自 [PostUser] 类,因此包含语言和用户信息;
- 第 5 行:待删除的预约编号;
该操作的实现如下:
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse supprimerRv(@Valid @RequestBody PostSupprimerRv postSupprimerRv, BindingResult result, HttpServletRequest request, HttpServletResponse response,
@RequestHeader(value = "Origin", required = false) String origin) {
// action contexts
ActionContext actionContext = getActionContext(postSupprimerRv.getLang(), origin, request, response, result,
rdvMedecinsCorsController);
WebContext thymeleafContext = actionContext.getThymeleafContext();
Locale locale = actionContext.getLocale();
// mistakes?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// posted values
User user = postSupprimerRv.getUser();
long idRv = postSupprimerRv.getIdRv();
// we delete the appointment
AgendaMedecinJour agenda = null;
try {
// we get it back
Rv rv = application.getRvById(user, idRv);
Creneau creneau = application.getCreneauById(user, rv.getIdCreneau());
long idMedecin = creneau.getIdMedecin();
Date jour = rv.getJour();
// delete the associated rv
application.supprimerRv(user, idRv);
// we regenerate the doctor's diary
agenda = application.getAgendaMedecinJour(user, idMedecin, new SimpleDateFormat("yyyy-MM-dd").format(jour));
// we return the new diary
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setAgenda(getPartialViewAgenda(actionContext, agenda, locale));
return reponse;
} catch (RdvMedecinsException ex) {
// returns the [errors] view
return getViewErreurs(thymeleafContext, ex.getMessages());
} catch (Exception e2) {
// returns the [errors] view
return getViewErreurs(thymeleafContext, getErreursForException(e2));
}
}
- 第 22 行:检索待删除的预约。如果不存在,则抛出异常;
- 第23–25行:根据该预约,查找医生及相关日期。这些信息用于重新生成医生的日程表;
- 第 27 行:删除该预约;
- 第 29 行:请求医生的最新日程表。这一点非常重要。除了刚刚腾出的时段外,应用程序的其他用户可能也对日程表进行了修改。向用户返回最新版本的日程表至关重要;
- 第31–34行:返回日历:
8.6.6.13. [/validerRv] 操作
[/validerRv] 操作用于向医生的日历中添加预约。其签名如下:
@RequestMapping(value = "/validerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse validerRv(@RequestBody PostValiderRv postValiderRv, BindingResult result, HttpServletRequest request, HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin)
- 第 3 行:提交的值类型为 [PostValiderRv],如下所示:
public class PostValiderRv extends PostUser {
// data
@NotNull
private Long idCreneau;
@NotNull
private Long idClient;
@NotNull
@DateTimeFormat(pattern = "yyyy-MM-dd")
private Date jour;
...
}
- 第 1 行:[PostValiderRv] 类继承自 [PostUser] 类,因此包含语言和用户;
- 第 5 行:时段编号;
- 第 7 行:预订所针对的客户 ID;
- 第 10 行:预约日期;
该操作的实现如下:
// appointment validation
@RequestMapping(value = "/validerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
@ResponseBody
public Reponse validerRv(@RequestBody PostValiderRv postValiderRv, BindingResult result, HttpServletRequest request, HttpServletResponse response, @RequestHeader(value = "Origin", required = false) String origin) {
// action contexts
ActionContext actionContext = getActionContext(postValiderRv.getLang(), origin, request, response, result,rdvMedecinsCorsController);
WebApplicationContext springContext = actionContext.getSpringContext();
WebContext thymeleafContext = actionContext.getThymeleafContext();
Locale locale = actionContext.getLocale();
// mistakes?
List<String> erreurs = actionContext.getErreurs();
if (erreurs != null) {
return getViewErreurs(thymeleafContext, erreurs);
}
// check the validity of the appointment date
if (result != null) {
new PostGetAgendaValidator().validate(postValiderRv, result);
if (result.hasErrors()) {
// returns the [errors] view
return getViewErreurs(thymeleafContext, getErreursForModel(result, locale, springContext));
}
}
// posted values
User user = postValiderRv.getUser();
long idClient = postValiderRv.getIdClient();
long idCreneau = postValiderRv.getIdCreneau();
Date jour = postValiderRv.getJour();
// action
try {
// get information on the niche
Creneau créneau = application.getCreneauById(user, idCreneau);
long idMedecin = créneau.getIdMedecin();
// we add the Rv
application.ajouterRv(postValiderRv.getUser(), new SimpleDateFormat("yyyy-MM-dd").format(jour), idCreneau,idClient);
// we regenerate the agenda
AgendaMedecinJour agenda = application.getAgendaMedecinJour(user, idMedecin,
new SimpleDateFormat("yyyy-MM-dd").format(jour));
// we return the new diary
Reponse reponse = new Reponse();
reponse.setStatus(1);
reponse.setAgenda(getPartialViewAgenda(actionContext, agenda, locale));
return reponse;
} catch (RdvMedecinsException ex) {
// returns the [errors] view
return getViewErreurs(thymeleafContext, ex.getMessages());
} catch (Exception e2) {
// returns the [errors] view
return getViewErreurs(thymeleafContext, getErreursForException(e2));
}
}
}
该代码与 [/deleteRv] 操作的代码类似。
8.6.7. 步骤 4:测试 Spring/Thymeleaf 服务器
现在我们将使用 Chrome 插件 [Advanced Rest Client](参见第 9.6 节)来测试上述各项操作。
8.6.7.1. 测试配置
所有操作都期望收到一个 POST 请求的值。我们将发送以下 JSON 字符串的不同变体:
{"user":{"login":"admin","passwd":"admin"},"lang":"en","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
此提交值包含对大多数操作而言多余的信息。不过,接收这些信息的操作会忽略这些内容,且不会引发错误。此提交值的优势在于涵盖了需要提交的各种值。
8.6.7.2. [/getNavbarStart] 操作
![]() |
- 在 [1] 中,即正在测试的操作;
- 在 [2] 中,提交的值;
- 在 [3] 中,提交的值是一个 JSON 字符串;
- 在 [4] 中,请求 [navbar-start] 视图时使用英语;
所得结果如下:
![]() |
我们收到了英文版的 [navbar-start] 视图(高亮显示的区域)。
现在,让我们引入一个错误。我们将提交值的 [lang] 属性设置为 null。我们得到以下结果:
![]() |
我们收到了一条错误响应(状态码 2),表明 [lang] 字段是必填的。
8.6.7.3. [/getNavbarRun] 操作
我们使用以下提交值请求 [getNavbarRun] 操作:
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
得到的结果如下:
![]() |
8.6.7.4. [/getJumbotron] 操作
我们使用以下 POST 数据调用 [getJumbotron] 操作:
{"user":{"login":"admin","passwd":"admin"},"lang":"en","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
获得的结果如下:
![]() |
8.6.7.5. [/getLogin] 操作
我们使用以下 POST 数据调用 [getLogin] 操作:
{"user":{"login":"admin","passwd":"admin"},"lang":"en","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
结果如下:
![]() |
8.6.7.6. [/getAccueil] 操作
我们使用以下提交值调用 [getAccueil] 操作:
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
获得的结果如下:
![]() |
我们再试一次,使用一个未知用户:
{"user":{"login":"x","passwd":"x"},"lang":"fr","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
结果如下:
![]() |
我们再次从一位现有用户开始,该用户尚未获得使用该应用程序的授权:
{"user":{"login":"user","passwd":"user"},"lang":"en","jour":"2015-01-22", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
结果如下:
![]() |
8.6.7.7. [/getAgenda] 操作
我们使用以下提交值调用 [getAgenda] 操作:
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-28", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
获得的结果如下:
![]() |
我们再试一次,使用一个早于今天的日期:
![]() |
我们再次从一位并不存在的医生说起:
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-28", "idMedecin":11, "idCreneau":2, "idClient":4, "idRv":93}
结果如下:
![]() |
8.6.7.8. 操作 [/getNavbarRunJumbotronAccueil]
我们请求 [getNavbarRunJumbotronAccueil] 操作,并提交以下值:
{"user":{"login":"admin","passwd":"admin"},"lang":"en","jour":"2015-01-28", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
结果如下:
![]() |
对于未知用户也是如此:
![]() |
8.6.7.9. 操作 [/getNavbarRunJumbotronHomeCalendar]
我们请求 [getNavbarRunJumbotronHomeCalendar] 操作,并提交以下值:
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-28", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
结果如下:
![]() |
我们输入一位并不存在的医生:
![]() |
8.6.7.10. [/deleteAppointment] 操作
我们使用以下提交值请求 [deleteAppointment] 操作:
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-28", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
预约 #93 不存在。获得的结果如下:
![]() |
已有预约时:
![]() |
我们可以从数据库中验证该预约是否确实已被删除。随后返回新的日历。
8.6.7.11. [/validateAppointment] 操作
我们使用以下提交值调用 [validateAppointment] 操作:
{"user":{"login":"admin","passwd":"admin"},"lang":"fr","jour":"2015-01-28", "idMedecin":1, "idCreneau":2, "idClient":4, "idRv":93}
结果如下:
![]() |
我们可以在数据库中验证该预约已成功创建。新的日历已返回。
我们对一个不存在的时段编号也进行同样的操作:
![]() |
对于不存在的客户端 ID,我们也采用同样的处理方式:
![]() |
8.6.8. 步骤 5:编写 JavaScript 客户端
让我们回到服务器 [Web1] 的架构:
![]() |
服务器 [Web1] 的客户端 [2] 是一个 SPV(单页应用程序)类型的 JavaScript 客户端:
- 客户端向 Web 服务器(不一定是 [Web1])请求启动页面;
- 它通过 Ajax 调用从服务器 [Web1] 请求以下页面;
为了构建此客户端,我们将使用 [Webstorm] 工具(参见第 9.8 节)。我发现该工具比 STS 更实用。其主要优势在于它提供了代码自动补全以及一些重构选项。这有助于避免许多错误。
8.6.8.1. JS 项目
JS 项目具有以下目录结构:
![]() |
- 在 [1] 中,包含整个 JS 客户端。[boot.html] 是启动页面。这是浏览器加载的唯一页面;
- 在 [2] 中,存放 Bootstrap 组件的样式表;
- 在 [3] 中,应用程序使用的少量图片;
![]() |
- 在 [4] 中,是 JS 脚本。我们的工作主要在此处进行;
- 在 [5] 中,所使用的 JS 库:主要是 jQuery,以及用于 Bootstrap 组件的库;
8.6.8.2. 代码架构
代码被划分为三个层次:
![]() |
- [呈现]层包含页面初始化函数[boot.xml]以及各种Bootstrap组件的相关函数。该层由文件[ui.js]实现;
- [事件]层包含[展示]层的所有事件处理程序。该层由文件[evts.js]实现;
- [DAO] 层负责向 [Web1] 服务器发起 HTTP 请求。该层由 [dao.js] 文件实现;
8.6.8.3. [呈现]层
![]() |
[呈现]层由以下[ui.js]文件实现:
//la couche [présentation]
var ui = {
// variables globales;
"agenda": "",
"resa": "",
"langue": "",
"urlService": "http://localhost:8081",
"page": "login",
"jourAgenda": "",
"idMedecin": "",
"user": {},
"login": {},
"exceptionTitle": {},
"calendar_infos": {},
"erreur": "",
"idCreneau": "",
"done": "",
// composants de la vue
"body": "",
"navbar": "",
"jumbotron": "",
"content": "",
"exception": "",
"exception_text": "",
"exception_title": "",
"loading": ""
};
// la couche des evts
var evts = {};
// la couche [dao]
var dao = {};
// ------------ document ready
$(document).ready(function () {
// initialisation document
console.log("document.ready");
// composants de la page
ui.navbar = $("#navbar");
ui.jumbotron = $("#jumbotron");
ui.content = $("#content");
ui.erreur = $("#erreur");
ui.exception = $("#exception");
ui.exception_text = $("#exception-text");
ui.exception_title = $("#exception-title");
// on mémorise la page de login pour pouvoir la restituer
ui.login.lang = ui.langue;
ui.login.navbar = ui.navbar.html();
ui.login.jumbotron = ui.jumbotron.html();
ui.login.content = ui.content.html();
// URL du service
$("#urlService").val(ui.urlService);
});
// ------------------------ Bootstrap component initialization functions
ui.initNavBarStart = function () {
...
};
ui.initNavBarRun = function () {
...
};
ui.initChoixMedecinJour = function () {
...
};
ui.updateCalendar = function (renew) {
...
};
// affiche le jour sélectionné
ui.displayJour = function () {
...
};
ui.initAgenda = function () {
...
};
ui.initResa = function () {
...
};
- 为了将各层相互隔离,决定将它们分别放置在三个对象中:
- [ui] 用于 [呈现] 层(第 2–27 行),
- [evts] 用于事件管理层(第 29 行),
- [dao] 用于 [DAO] 层(第 31 行);
将各层分离为三个对象有助于避免大量变量和函数名称冲突。每层使用的变量和函数均以封装该层的对象名称作为前缀。
- 第 38–44 行:我们存储那些无论显示何种视图都始终存在的字段。这避免了重复且不必要的 jQuery 搜索;
- 第 46–49 行:将登录页面存储在本地,以便用户注销且未更改语言时能够恢复;
- 第 54–83 行:Bootstrap 组件初始化函数。这些内容已在第 8.6.4 节关于 Bootstrap 组件的讨论中全部涵盖;
8.6.8.4. [events] 层的辅助函数
![]() |
事件处理程序已放置在 [evts.js] 文件中。事件处理程序经常使用几个函数。下面我们将介绍这些函数:
// début d'attente
evts.beginWaiting = function () {
// début attente
ui.loading = $("#loading");
ui.loading.show();
ui.exception.hide();
ui.erreur.hide();
evts.travailEnCours = true;
};
// fin d'attente
evts.stopWaiting = function () {
// fin attente
evts.travailEnCours = false;
ui.loading = $("#loading");
ui.loading.hide();
};
// affichage résultat
evts.showResult = function (result) {
// on affiche les données reçues
var data = result.data;
// on analyse le status
switch (result.status) {
case 1:
// erreur ?
if (data.status == 2) {
ui.erreur.html(data.content);
ui.erreur.show();
} else {
if (data.navbar) {
ui.navbar.html(data.navbar);
}
if (data.jumbotron) {
ui.jumbotron.html(data.jumbotron);
}
if (data.content) {
ui.content.html(data.content)
}
if (data.agenda) {
ui.agenda = $("#agenda");
ui.resa = $("#resa");
}
}
break;
case 2:
// affichage erreur
evts.showException(data);
break;
}
};
// ------------ fonctions diverses
evts.showException = function (data) {
// affichage erreur
ui.exception.show();
ui.exception_text.html(data);
ui.exception_title.text(ui.exceptionTitle[ui.langue]);
};
- 第 2 行:在任何异步 [DAO] 操作之前调用 [evts.beginwaiting] 函数;
- 第 4-5 行:显示等待状态的动画图像;
- 第 6-7 行:隐藏错误和异常显示区域(二者并不相同);
- 第 8 行:我们注意到一个异步任务正在进行中;
- 第 12 行:在异步 [DAO] 操作返回结果后调用 [evts.stopwaiting] 函数;
- 第 14 行:提示异步操作已完成;
- 第 15 行:隐藏等待动画;
- 第 20 行:[evts.showResult] 函数显示异步 [DAO] 操作的结果 [result]。该结果是一个形式如下所示的 JS 对象:{'status':status,'data':data,'sendMeBack':sendMeBack}。
- 第 47–50 行:当 [result.status == 2] 时使用。这种情况发生在 [Web1] 服务器发送包含 HTTP 错误标头(例如 403 Forbidden)的响应时。此时,[data] 是服务器发送的用于指示错误的 JSON 字符串;
- 第 25 行:从 [Web1] 服务器收到有效响应的情况。此时 [data] 字段包含服务器的响应:{'status':status,'navbar':navbar,'jumbotron':jumbotron,'agenda':agenda,'content':content};
- 第 27 行:服务器 [Web1] 发送了错误响应 {'status':2,'navbar':null,'jumbotron':null,'agenda':null,'content':errors} 的情况;
- 第 28–29 行:显示 [errors] 视图;
- 第 31–33 行:可选显示导航栏;
- 第 34–36 行:可选显示巨型横幅;
- 第 37–39 行:可能显示 [data.content] 字段。根据具体情况,这代表 [home, calendar] 中的某个视图;
- 第 40–43 行:如果日历已重新生成,则检索其某些组件的引用,以便无需在每次需要时都进行查找;
- 第 54 行:[evts.showException] 函数显示其 [data] 参数中包含的异常文本;
- 第 57-58 行:显示异常文本;
- 第 58 行:异常标题取决于当前语言;
[evts.js] 文件包含超过 300 行代码,我不会逐行进行说明。我将仅列举几个示例来阐明这一层的作用。
8.6.8.5. 用户登录

用户登录由以下函数处理:
// ------------------------ connexion
evts.connecter = function () {
// retrieve the values to be posted
var login = $("#login").val().trim();
var passwd = $("#passwd").val().trim();
// set the server's URL
ui.urlService = $("#urlService").val().trim();
dao.setUrlService(ui.urlService);
// query parameters
var post = {
"user": {
"login": login,
"passwd": passwd
},
"lang": ui.langue
};
var sendMeBack = {
"user": {
"login": login,
"passwd": passwd
},
"caller": evts.connecterDone
};
// query
evts.execute([{
"name": "accueil-sans-agenda",
"post": post,
"sendMeBack": sendMeBack
}]);
};
- 第 4-5 行:获取用户的登录名和密码;
- 第7-8行:获取[Web1]服务的URL。该URL同时存储在[ui]层和[dao]层中;
- 第10-16行:待提交的值:当前语言和尝试登录的用户;
- 第 17–23 行:将 [sendMeBack] 对象传递给即将被调用的 [DAO] 函数,该函数必须将其返回给第 22 行的函数。此处,[sendMeBack] 对象封装了尝试登录的用户;
- 第 25–29 行:[evts.execute] 函数能够执行一系列异步操作。此处,我们传递了一个仅包含单个操作的列表。其字段如下:
- [name]:待执行的异步操作名称,
- [post]:要发送至 [Web1] 服务器的值,
- [sendMeBack]:该异步操作必须随其结果一并返回的值;
在详细探讨 [evts.execute] 函数之前,让我们先看看第 22 行中的 [evts.connecterDone] 函数。这是被调用的异步 [DAO] 函数必须向其返回结果的函数:
evts.connecterDone = function (result) {
// affichage résultat
evts.showResult(result);
// connexion réussie ?
if (result.status == 1 && result.data.status == 1) {
// page
ui.page = "accueil-sans-agenda";
// on note l'utilisateur
ui.user = result.sendMeBack.user;
}
};
- 第 3 行:显示 [Web1] 服务器返回的结果;
- 第 5 行:如果该结果没有错误,则存储新页面的类型(第 7 行)以及经过身份验证的用户(第 9 行);
[evts.execute] 函数执行一系列异步操作:
// exécution d'une suite d'actions
evts.execute = function (actions) {
// travail en cours ?
if (evts.travailEnCours) {
// on ne fait rien
return;
}
// attente
evts.beginWaiting();
// exécution des actions
dao.doActions(actions, evts.stopWaiting);
};
- 第 2 行:[actions] 参数是要执行的异步操作列表;
- 第 4–7 行:仅当没有其他操作正在进行时才接受执行;
- 第 9 行:启动等待;
- 第 11 行:请求 [DAO] 层执行该操作序列。第二个参数是当序列中所有操作均返回结果后需执行的函数名称;
我们目前暂不详细探讨 [dao.doActions] 函数。我们将考察另一个事件。
8.6.8.6. 语言变更

语言切换由以下函数处理:
// ------------------------ changement de langue
evts.setLang = function (lang) {
// chgt de langue ?
if (lang == ui.langue) {
// on ne fait rien
return;
}
// nouvelle langue
ui.langue = lang;
// quelle page faut-il traduire ?
switch (ui.page) {
case "login":
evts.getLogin();
break;
case "accueil-sans-agenda":
evts.getAccueilSansAgenda();
break;
case "accueil-avec-agenda":
evts.getAccueilAvecAgenda(ui);
break;
}
};
- 第2行:[lang]参数表示新语言:'fr' 或 'en';
- 第 4–7 行:如果新语言与当前语言相同,则不执行任何操作;
- 第 9 行:存储新语言;
- 第 12–20 行:如果语言已更改,则必须重新加载浏览器当前显示的页面。可能有三种页面:
- 名为 [login] 的页面,该页面显示的是登录页面,
- 名为 [home-without-calendar] 的页面,即成功认证后立即显示的页面,
- 名为 [home-with-calendar] 的页面,该页面在显示第一个日历后立即呈现,并一直保留在屏幕上,直到用户注销;
我们将重点讨论 [home-with-calendar] 页面。该功能有三个版本:
![]() |
- [getAccueilAvecAgenda-one] 版本执行单个异步操作;
- [getAccueilAvecAgenda-parallel] 版本并行执行四个异步操作;
- [getAccueilAvecAgenda-sequence] 版本依次执行四个异步操作;
8.6.8.7. [getAccueilAvecAgenda-one] 函数
该函数如下所示:
// -------------------------- getAccueilAvecAgenda
evts.getAccueilAvecAgenda=function(ui) {
// query parameters
var post = {
"user": ui.user,
"lang": ui.langue,
"idMedecin": ui.idMedecin,
"jour": ui.jourAgenda
};
var sendMeBack = {
"caller": evts.getAccueilAvecAgendaDone
};
// request
evts.execute([{
"name": "accueil-avec-agenda",
"post": post,
"sendMeBack": sendMeBack
}]);
};
- 第 4–9 行:要提交的值封装了已登录的用户、目标语言、需要查询日程的医生 ID 以及目标日程的日期;
- 第 10–12 行:[sendMeBack] 对象是第 11 行将返回给函数的对象。此处,它不包含任何信息;
- 第14–18行:执行一系列异步操作,具体为名为[welcome-with-calendar]的操作(第15行);
- 第 11 行:当异步操作 [welcome-with-calendar] 返回结果时执行的函数;
第 11 行中的 [evts.getAccueilAvecAgendaDone] 函数用于显示名为 [accueil-avec-agenda] 的异步函数的结果:
evts.getAccueilAvecAgendaDone = function (result) {
// affichage résultat
evts.showResult(result);
// nouvelle page ?
if (result.status == 1 && result.data.status == 1) {
ui.page = "accueil-avec-agenda";
}
};
- 第 1 行:[result] 是名为 [home-with-calendar] 的异步函数的返回值;
- 第 3 行:显示该结果;
- 第 5 行:如果这是一个没有错误的结果,则加载新页面(第 6 行);
8.6.8.8. 函数 [getHomeWithCalendar-parallel]
这是以下函数:
// -------------------------- getAccueilAvecAgenda
evts.getAccueilAvecAgenda=function(ui) {
// actions [navbar-run, jumbotron, home, calendar] in //
// navbar-run
var navbarRun = {
"name": "navbar-run"
};
navbarRun.post = {
"lang": ui.langue
};
navbarRun.sendMeBack = {
"caller": evts.showResult
};
// jumbotron
var jumbotron = {
"name": "jumbotron"
};
jumbotron.post = {
"lang": ui.langue
};
jumbotron.sendMeBack = {
"caller": evts.showResult
};
// home
var accueil = {
"name": "accueil"
};
accueil.post = {
"lang": ui.langue,
"user": ui.user
};
accueil.sendMeBack = {
"caller": evts.showResult
};
// agenda
var agenda = {
"name": "agenda"
};
agenda.post = {
"user": ui.user,
"lang": ui.langue,
"idMedecin": ui.idMedecin,
"jour": ui.jourAgenda
};
agenda.sendMeBack = {
'idMedecin': ui.idMedecin,
'jour': ui.jourAgenda,
"caller": evts.getAgendaDone
};
// execution actions in //
evts.execute([navbarRun, jumbotron, accueil, agenda])
};
- 第 51 行:这次将执行四个异步操作。它们将并行执行;
- 第 5–13 行:定义 [navbarRun] 操作,该操作用于获取导航栏 [navbar-run];
- 第 12 行:当异步操作 [navbarRun] 返回结果后执行的函数;
- 第 15–23 行:定义 [jumbotron] 操作,该操作将获取 [jumbotron] 视图;
- 第 22 行:当异步操作 [jumbotron] 返回结果时执行的函数;
- 第 25–34 行:定义 [home] 操作,该操作获取 [home] 视图;
- 第 33 行:当异步操作 [home] 返回结果时要执行的函数;
- 第 36–49 行:定义了用于检索 [jumbotron] 视图的 [agenda] 操作;
- 第 48 行:当异步操作 [agenda] 返回结果时要执行的函数;
8.6.8.9. 函数 [getHomeWithAgenda-sequence]
// -------------------------- getAccueilAvecAgenda
evts.getAccueilAvecAgenda=function(ui) {
// actions [navbar-run, jumbotron, home, agenda] in order
// agenda
var agenda = {
"name" : "agenda"
};
agenda.post = {
"user" : ui.user,
"lang" : ui.langue,
"idMedecin" : ui.idMedecin,
"jour" : ui.jourAgenda
};
agenda.sendMeBack = {
'idMedecin' : ui.idMedecin,
'jour' : ui.jourAgenda,
"caller" : evts.getAgendaDone
};
// home
var accueil = {
"name" : "accueil"
};
accueil.post = {
"lang" : ui.langue,
"user" : ui.user
};
accueil.sendMeBack = {
"caller" : evts.showResult,
"next" : agenda
};
// jumbotron
var jumbotron = {
"name" : "jumbotron"
};
jumbotron.post = {
"lang" : ui.langue
};
jumbotron.sendMeBack = {
"caller" : evts.showResult,
"next" : accueil
};
// navbar-run
var navbarRun = {
"name" : "navbar-run"
};
navbarRun.post = {
"lang" : ui.langue
};
navbarRun.sendMeBack = {
"caller" : evts.showResult,
"next" : jumbotron
};
// execution actions in sequence
evts.execute([ navbarRun ])
};
- 第 54 行:执行 [navbarRun] 操作。该操作完成后,我们继续执行下一项:第 51 行的 [jumbotron]。随后依次执行该操作。该操作完成后,我们继续执行下一项:第 40 行的 [home]。该操作也依次执行。 执行完毕后,我们继续执行下一个动作:[agenda](第 29 行)。该动作依次执行。执行完毕后,我们停止执行,因为 [agenda] 动作之后没有后续动作。
8.6.8.10. [DAO] 层
![]() |
[dao.js] 文件包含 [DAO] 层的所有函数。我们将逐步介绍这些内容:
// URL exposed by the server
dao.urls = {
"login": "/getLogin",
"accueil": "/getAccueil",
"jumbotron": "/getJumbotron",
"agenda": "/getAgenda",
"supprimerRv": "/supprimerRv",
"validerRv": "/validerRv",
"navbar-start": "/getNavbarStart",
"navbar-run": "/getNavbarRun",
"accueil-sans-agenda": "/getNavbarRunJumbotronAccueil",
"accueil-avec-agenda": "/getNavbarRunJumbotronAccueilAgenda"
};
// --------------- interface
// server url
dao.setUrlService = function (urlService) {
dao.urlService = urlService;
};
- 第 16–18 行:设置服务 URL [Web1] 的函数;
- 第 2–13 行:将异步操作名称与待查询的 [Web1] 服务器 URL 关联起来的字典;
// ------------------ gestion générique des actions
// exécution d'une suite d'actions asynchrones
dao.doActions = function (actions, done) {
// traitement des actions
dao.actionsCount = actions.length;
dao.actionIndex = 0;
for (var i = 0; i < dao.actionsCount; i++) {
// requête DAO asynchrone
var deferred = $.Deferred();
deferred.done(dao.actionDone);
dao.doAction(deferred, actions[i], done);
}
};
- 第 3 行:[dao.doActions] 函数执行一系列异步操作 [actions]。参数 [done] 是当所有操作都返回结果后要执行的函数;
- 第 7–12 行:这些异步操作将并行执行。但是,如果其中一个操作有后续操作,则该后续操作将在前一个操作结束时执行;
- 第 9 行:处于 [pending] 状态的 [Deferred] 对象;
- 第 10 行:当该对象进入 [resolved] 状态时,将执行 [dao.actionDone] 函数;
- 第 11 行:列表中的操作 #i 将异步执行。第 3 行中的 [done] 参数将作为参数传递;
在每个异步操作结束时执行的 [dao.actionDone] 函数如下:
// on a reçu un résultat
dao.actionDone = function (result) {
// caller ?
var sendMeBack = result.sendMeBack;
if (sendMeBack && sendMeBack.caller) {
sendMeBack.caller(result);
}
// next ?
if (sendMeBack && sendMeBack.next) {
// requête DAO asynchrone
var deferred = $.Deferred();
deferred.done(dao.actionDone);
dao.doAction(deferred, sendMeBack.next, sendMeBack.done);
}
// fini ?
dao.actionIndex++;
if (dao.actionIndex == dao.actionsCount) {
// done ?
if (sendMeBack && sendMeBack.done) {
sendMeBack.done(result);
}
}
};
- 第 2 行:函数 [dao.actionDone] 接收来自待执行操作列表中某项异步操作的结果 [result];
- 第 4–7 行:如果已完成的异步操作指定了用于接收结果的函数,则调用该函数;
- 第 9–14 行:如果已完成的异步操作有后续操作,则依次执行该操作;
- 第 16 行:一个操作完成。已完成操作的计数器递增。具有不确定数量后续操作的操作被计为一个操作;
- 第 19–21 行:如果最初指定了一个 [done] 函数,用于在序列中的所有操作都返回结果后执行,那么现在将执行该函数;
[dao.doAction] 方法用于执行一个异步操作:
// exécution d'une action
dao.doAction = function (deferred, action, done) {
// fonction done à embarquer dans l'action
if (action.sendMeBack) {
action.sendMeBack.done = done;
} else {
action.sendMeBack = {
"done": done
};
}
// exécution action
dao.executePost(deferred, action.sendMeBack, dao.urls[action.name], action.post)
};
- 第 4–10 行:正如我们刚才所见,处理待执行异步操作结果的函数必须能够访问 [done] 函数。为此,我们将 [done] 函数放入 [sendMeBack] 对象中,该对象将成为异步操作结果的一部分;
- 第 12 行:我们调用 [dao.executePost] 函数,该函数会向 [Web1] 服务器发起 HTTP 请求。目标 URL 是与待执行操作名称关联的 URL;
[dao.executePost] 函数执行一个 HTTP 请求:
// requête HTTP
dao.executePost = function (deferred, sendMeBack, url, post) {
// on fait un appel Ajax à la main
$.ajax({
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
url: dao.urlService + url,
type: 'POST',
data: JSON3.stringify(post),
dataType: 'json',
success: function (data) {
// on rend le résultat
deferred.resolve({
"status": 1,
"data": data,
"sendMeBack": sendMeBack
});
},
error: function (jqXHR, textStatus, errorThrown) {
var data;
if (jqXHR.responseText) {
data = jqXHR.responseText;
} else {
data = textStatus;
}
// on rend l'erreur
deferred.resolve({
"status": 2,
"data": data,
"sendMeBack": sendMeBack
});
}
});
};
我们已经遇到并讨论过这个函数。请注意第 9 行中,目标 URL 是服务器 URL [Web1] 与操作名称相关联的 URL 的拼接结果。
8.6.8.11. 启动页面
![]() |

启动页面 [boot.html] 显示了上图所示的视图。这是唯一一个由浏览器直接加载的页面。其余页面均通过 Ajax 调用获取。其代码如下:
<!DOCTYPE HTML>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
<head>
<meta name="viewport" content="width=device-width"/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<title>RdvMedecins</title>
<!-- Bootstrap core CSS -->
<link rel="stylesheet" href="css/bootstrap-3.1.1-min.css"/>
<link rel="stylesheet" type="text/css" href="css/bootstrap-select.min.css"/>
<link rel="stylesheet" type="text/css" href="css/datepicker3.css"/>
<link rel="stylesheet" type="text/css" href="css/footable.core.min.css"/>
<!-- Custom styles for this template -->
<link rel="stylesheet" type="text/css" href="css/rdvmedecins.css"/>
<!-- Bootstrap core JavaScript ================================================== -->
<script type="text/javascript" src="vendor/jquery-2.1.1.min.js"></script>
<script type="text/javascript" src="vendor/bootstrap.js"></script>
<script type="text/javascript" src="vendor/bootstrap-select.js"></script>
<script type="text/javascript" src="vendor/moment-with-locales.js"></script>
<script type="text/javascript" src="vendor/bootstrap-datepicker.js"></script>
<script type="text/javascript" src="vendor/bootstrap-datepicker.fr.js"></script>
<script type="text/javascript" src="vendor/footable.js"></script>
<!-- user scripts -->
<script type="text/javascript" src="js/json3.js"></script>
<script type="text/javascript" src="js/ui.js"></script>
<script type="text/javascript" src="js/evts.js"></script>
<script type="text/javascript" src="js/getAccueilAvecAgenda-sequence.js"></script>
<script type="text/javascript" src="js/dao.js"></script>
</head>
<body id="body">
<div id="navbar">
<div class="navbar navbar-inverse navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
<span class="sr-only">Toggle navigation</span> <span class="icon-bar"></span> <span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="#">RdvMedecins</a>
</div>
<div class="navbar-collapse collapse">
<img id="loading" src="images/loading.gif" alt="waiting..." style="display: none"/>
<!-- identification form -->
<div class="navbar-form navbar-right" role="form" id="formulaire">
<div class="form-group">
<input type="text" placeholder="URL du serveur" class="form-control" id="urlService"/>
</div>
<div class="form-group">
<input type="text" placeholder="Utilisateur" class="form-control" id="login"/>
</div>
<div class="form-group">
<input type="password" placeholder="Mot de passe" class="form-control" id="passwd"/>
</div>
<button type="button" class="btn btn-success" onclick="javascript:evts.connecter()">Connexion</button>
<!-- languages -->
<div class="btn-group">
<button type="button" class="btn btn-danger">Langue</button>
<button type="button" class="btn btn-danger dropdown-toggle" data-toggle="dropdown">
<span class="caret"></span> <span class="sr-only">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu" role="menu">
<li><a href="javascript:evts.setLang('fr')">Français</a></li>
<li><a href="javascript:evts.setLang('en')">English</a></li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="container">
<!-- Bootstrap Jumbotron -->
<div id="jumbotron">
<div class="jumbotron">
<div class="row">
<div class="col-md-2">
<img src="images/caduceus.jpg" alt="RvMedecins"/>
</div>
<div class="col-md-10">
<h1>
Cabinet médical<br/>Les Médecins associés
</h1>
</div>
</div>
</div>
</div>
<!-- error panels -->
<div id="erreur"></div>
<div id="exception" class="alert alert-danger" style="display: none">
<h3 id="exception-title"></h3>
<span id="exception-text"></span>
</div>
<!-- content -->
<div id="content">
<div class="alert alert-info">Authentifiez-vous pour accéder à l'application</div>
</div>
</div>
<!-- init page -->
<script>
// on initialise la page
ui.langue = 'fr';
ui.exceptionTitle['fr'] = "L'erreur suivante s'est produite côté serveur :";
ui.exceptionTitle['en'] = "The following server error was met:";
ui.initNavBarStart();
</script>
</body>
</html>
- 我们在关于 Bootstrap 的章节(第 8.6.4 节)中已经遇到过这种类型的页面;
- 第99–105行:初始化[呈现]层的某些元素;
- 第27行:使用了脚本 [getAccueilAvecAgenda-sequence.js]。通过修改此行的脚本,我们可以获得三种不同的 [accueil-avec-agenda] 页面获取行为:
- [getAccueilAvecAgenda-one.js] 通过单次 HTTP 请求获取页面,
- [getAccueilAvecAgenda-parallel.js] 通过四个并行 HTTP 请求获取页面,
- [getAccueilAvecAgenda-sequence.js] 通过四个连续的 HTTP 请求获取页面;
8.6.8.12. 测试
运行测试有多种方式。在此,我们将使用 [Webstorm] 工具:
![]() |
- 在 [1] 中打开一个项目。我们只需选择包含待测试网站静态目录结构(HTML、CSS、JS)的文件夹 [2];
![]() |
- 在 [3] 中,加载静态网站;
- 在 [4-5] 中,加载 [boot.html] 页面;
![]() |
- 在 [5] 中,我们可以看到由 [WebStorm] 嵌入的服务器已通过端口 [63342] 提供了 [boot.html] 页面。这一点非常重要,因为这意味着 [boot.html] 页面上的脚本将向运行在 [localhost:8081] 上的 [Web1] 服务器发起跨域请求。 加载 [boot.html] 的浏览器知道该页面是从 [localhost:63342] 加载的。因此,由于端口不同,它不会允许该页面调用 [localhost:8081] 上的站点。因此,它将强制执行第 8.4.14 节中描述的跨域规则。 因此,[Web1] 应用程序必须配置为接受这些跨域请求。该配置位于 Spring/Thymeleaf 服务器的 [AppConfig] 文件中:
![]() |
@EnableAutoConfiguration
@ComponentScan(basePackages = { "rdvmedecins.springthymeleaf.server" })
@Import({ WebConfig.class, DaoConfig.class })
public class AppConfig {
// admin / admin
private final String USER_INIT = "admin";
private final String MDP_USER_INIT = "admin";
// root web service / json
private final String WEBJSON_ROOT = "http://localhost:8080";
// timeout in milliseconds
private final int TIMEOUT = 5000;
// CORS
private final boolean CORS_ALLOWED=true;
...
JS客户端的测试工作留给读者自行完成。它应该能够重现第8.6.3节中描述的功能。
验证 JavaScript 客户端后,可将其部署到服务器的 [Web1] 文件夹中,以避免需要允许跨域请求:
![]() |
在上一步中,我们将测试站点复制到了 [src/main/resources/static] 文件夹中。然后,我们可以请求 URL [http://localhost:8081/boot.html]:

现在我们不再需要跨域请求,可以在 [Web1] 服务器的 [AppConfig] 配置文件中写入以下内容:
// CORS
private final boolean CORS_ALLOWED=false;
上面的应用程序仍可正常运行。如果我们回到 [WebStorm] 应用程序,它将无法运行:


如果我们打开开发者控制台(Ctrl-Shift-I),可以看到错误的原因:

这是一个未经授权的跨域请求错误。
8.6.8.13. 结论
我们已实现以下 JavaScript 架构:
![]() |
- 各层之间划分得相当清晰;
- 我们拥有一个单页应用程序(SPA)。正是这一特性,现在使我们能够为各种移动平台(Android、iOS、Windows Phone)生成原生应用;
- 我们构建了一个模型,能够并行、顺序或混合执行异步操作;
8.6.9. 第 6 步:生成 Android 原生应用
[Phonegap] [http://phonegap.com/] 工具允许您从 HTML/JS/CSS 应用程序生成适用于移动设备(Android、iOS、Windows 8 等)的可执行文件。实现这一目标有多种方法。 我们将采用最简单的方法:使用 Phonegap 网站 [http://build.phonegap.com/apps] 上的在线工具。该工具会将待转换的静态网站的 ZIP 文件上传。启动页面必须命名为 [index.html]。因此,我们将页面 [boot.html] 重命名为 [index.html]:
![]() |
然后将文件夹压缩为 ZIP 文件,本例中为 [rdvmedecins-client-js-03]。接下来,访问 Phonegap 网站 [http://build.phonegap.com/apps]:
![]() |
- 在执行[1]之前,您可能需要创建一个账户;
- 在 [1] 处,我们开始操作;
- 在 [2],我们选择一个仅支持一个 Phonegap 应用的免费套餐;
![]() |
- 在 [3] 中,我们上传了压缩后的应用 [4];
![]() |
- 在 [5] 中,为应用命名;
- 在 [6] 中,进行构建。此过程可能需要 1 分钟。请等待各移动平台的图标显示构建完成;
![]() |
- 目前仅生成了 Android [7] 和 Windows [8] 的二进制文件;
- 点击 [7] 下载 Android 二进制文件;
![]() |
- 在 [9] 中,下载的 [apk] 二进制文件;
启动一个适用于 Android 平板电脑的 [GenyMotion] 模拟器(参见第 9.9 节):
![]() |
上文中,我们启动了一个基于 Android API 19 的平板电脑模拟器。模拟器启动后,
- 请将锁定图标(如有)向侧面拖动并松开以解锁;
- 使用鼠标,将您下载的 [PGBuildApp-debug.apk] 文件拖拽并放到模拟器上。随后该文件将被安装并运行;
![]() |
您需要将 URL 更改为 [1]。为此,请在命令提示符窗口中输入命令 [ipconfig](如下图第 1 行),这将显示您计算机的各种 IP 地址:
C:\Users\Serge Tahé>ipconfig
Configuration IP de Windows
Carte réseau sans fil Connexion au réseau local* 15 :
Statut du média. . . . . . . . . . . . : Média déconnecté
Suffixe DNS propre à la connexion. . . :
Carte Ethernet Connexion au réseau local :
Suffixe DNS propre à la connexion. . . : ad.univ-angers.fr
Adresse IPv6 de liaison locale. . . . .: fe80::698b:455a:925:6b13%4
Adresse IPv4. . . . . . . . . . . . . .: 172.19.81.34
Masque de sous-réseau. . . . . . . . . : 255.255.0.0
Passerelle par défaut. . . . . . . . . : 172.19.0.254
Carte réseau sans fil Wi-Fi :
Statut du média. . . . . . . . . . . . : Média déconnecté
Suffixe DNS propre à la connexion. . . :
...
请记录 Wi-Fi IP 地址(第 6–9 行)或本地网络 IP 地址(第 11–17 行)。然后在 Web 服务器 URL 中使用该 IP 地址:
![]() |
完成上述操作后,连接到 Web 服务:
![]() |
在模拟器上测试应用程序。它应该可以正常运行。在服务器端,您可以在 [ApplicationModel] 类中选择是否允许 CORS 头部:
// CORS
private final boolean CORS_ALLOWED=false;
这对 Android 应用来说无关紧要。它并非在浏览器中运行。CORS 头的要求来自浏览器,而非服务器。
8.6.10. 案例研究结论
我们开发了以下架构:
![]() |
这是一个复杂的三层架构。其设计旨在复用 [Web2] 层,该层是 [AngularJS / Spring 4 教程] 文档(网址:[http://tahe.developpez.com/angularjs-spring4/])中 [AngularJS-Spring MVC] 应用程序的服务器层。 这正是我们采用三层架构的唯一原因。而在[AngularJS-Spring MVC]应用程序中,[Web2]的客户端是[AngularJS]客户端,而在此处,[Web2]的客户端则是两层架构[jQuery] / [Spring MVC / Thymeleaf]。由于增加了层数,因此性能会有所下降。
本文讨论的应用程序是随着时间的推移,在三个不同的文档中逐步开发的:
- [JSF2、PrimeFaces 和 PrimeFaces Mobile 入门](网址:[http://tahe.developpez.com/java/primefaces/])。该案例研究随后基于 JSF2 / PrimeFaces 框架进行开发。 PrimeFaces 是一个支持 AJAX 的组件库,可免去编写 JavaScript 的需求。当时开发的应用程序比本文研究的这个要简单一些。它包含一个面向电脑的经典 Web 版本和一个面向手机的移动版本;
- [AngularJS / Spring 4 教程],网址为 [http://tahe.developpez.com/angularjs-spring4/]。当时开发的应用程序与本文讨论的应用程序具有相同的功能。该应用程序还被移植到了 Android 平台;
- 本文;
通过这项工作,以下几点令我印象深刻:
- [Primefaces] 应用程序的编写难度远低于其他方案,且其移动网页版本表现出了极高的性能。它无需任何 JavaScript 知识。虽然无法将其原生移植到各种移动设备的操作系统上,但这真的有必要吗?似乎很难更改应用程序的样式。事实上,我们正在使用 Primefaces 的样式表。这可能是一个缺点;
- [AngularJS-Spring MVC] 应用程序的编写过程较为复杂。[AngularJS] 框架一旦想要精通,似乎相当难以掌握。 [Angular客户端] / [Spring MVC实现的Web服务/JSON]架构尤为简洁且性能卓越。该架构适用于任何Web应用程序。在我看来,这是最具前景的架构,因为它在客户端和服务器端分别运用了不同的技能组合(客户端为JS+HTML+CSS,服务器端为Java或其他语言),从而允许客户端和服务器端并行开发;
- 对于本文中采用三层架构 [jQuery 客户端] / [Web1 服务器 / Spring MVC / Thymeleaf] / [Web2 服务器 / Spring MVC] 开发的应用程序,有些人可能会觉得 [jQuery+Spring MVC+Thymeleaf] 技术比 [AngularJS] 更容易掌握。我们编写的 JavaScript 客户端的 [DAO] 层可在其他应用程序中复用;

























































































































































































































































































