2. Spring 4 服务器
![]() |
在上述架构中,我们将着手构建基于 Spring 4 框架的 Web 服务 / JSON。我们将分几个步骤进行编写:
- 首先是 [业务] 和 [DAO](数据访问对象)层。此处我们将使用 Spring Data;
- 接着是无身份验证的 JSON Web 服务。此处将使用 Spring MVC;
- 接着,我们将使用 Spring Security 添加身份验证组件。
我们将首先说明支撑该应用程序的数据库结构。
2.1. 数据库
![]() |
该数据库(以下简称 [ dbrdvmedecins])是一个 MySQL5 数据库,包含以下表:
![]() |
预约由以下表管理:
- [doctors]:包含该诊所的医生列表;
- [clients]:包含该诊所的患者列表;
- [slots]:包含每位医生的可用时段;
- [rv]:包含医生预约的列表。
表 [roles]、[users] 和 [users_roles] 与身份验证相关。目前,我们暂不涉及这些表。
管理预约的表之间的关系如下:
![]() |
2.1.1. [MEDECINS] 表
该表包含由 [RdvMedecins] 应用程序管理的医生相关信息。
![]() | ![]() |
- ID:用于标识医生的ID号——该表的主键
- VERSION:标识表中该行版本的数字。每次对该行进行更改时,该数字都会增加 1。
- LAST_NAME:医生的姓
- FIRST_NAME:医生的名字
- TITLE:称谓(Ms.、Mrs.、Mr.)
2.1.2. [CLIENTS] 表
各医生的客户信息存储在 [CLIENTS] 表中:
![]() | ![]() |
- ID:用于标识客户的ID号——该表的主键
- VERSION:标识该表中该行版本的编号。每次对该行进行修改时,该编号会递增1。
- LAST NAME:客户的姓
- 名字:客户的名字
- 标题:称谓(Ms.、Mrs.、Mr.)
2.1.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 女士)。
2.1.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 女士。
2.2. 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] 中,是最终项目。
2.2.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.0.2.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 文件中的可执行类。
2.2.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 实现从未被命名。
2.2.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] 层。
2.2.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],用于定义要使用的事务管理器;
在此处,这些变量均未被定义。
- 第 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);
- 第 1 行:执行 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] 实现;
- 第 12 行:出现 [hibernate]。这是已选定的 JPA 实现;
- 第 19 行:Hibernate 方言是与数据库管理系统 (DBMS) 配合使用的 SQL 变体。此处的 [H2Dialect] 方言表明 Hibernate 将与 H2 数据库管理系统配合使用;
- 第 22–24 行:创建了 [CUSTOMER] 表。这意味着 Hibernate 已被配置为根据 JPA 定义生成表,本例中即 [Customer] 类的 JPA 定义;
- 第 27–32 行:Hibernate 日志显示已向 [CUSTOMER] 表插入行。这表示已配置 Hibernate 生成日志;
- 第 35–39 行:已插入的五位客户;
- 第 42–44 行:接口 [findOne] 方法的执行结果;
- 第 47–50 行:[findByLastName] 方法的结果;
- 第 51 行及后续:关闭 Spring 上下文时的日志。
2.2.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.0.5.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>4.0.5.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-beans</artifactId>
<version>4.0.5.RELEASE</version>
</dependency>
<!-- Spring transactions -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aop</artifactId>
<version>4.0.5.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-tx</artifactId>
<version>4.0.5.RELEASE</version>
</dependency>
<!-- Spring Data -->
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-jpa</artifactId>
<version>1.5.2.RELEASE</version>
</dependency>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot</artifactId>
<version>1.0.2.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>
- 第 3–17 行:Spring 核心库;
- 第 19–28 行:用于管理数据库事务的 Spring 库;
- 第 30–34 行:用于访问数据库的 Spring Data;
- 第 36–40 行:用于启动应用程序的 Spring Boot;
- 第 48–52 行:H2 数据库管理系统;
- 第 54–63 行:数据库通常与开放式连接池配合使用,以避免反复打开和关闭连接。此处采用的是 [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 组件是指带有 @Service、@Component、@Controller 等 Spring 注解的类。此处除 [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。
执行结果与之前相同。
2.2.6. 创建可执行归档文件
要创建该项目的可执行归档文件,请按以下步骤操作:
![]() |
- 在 [1] 中:创建一个运行时配置;
- 在 [2] 中:选择 [Java 应用程序] 类型
- 在 [3] 中:指定要运行的项目(使用“浏览”按钮);
- 在 [4] 中:指定要运行的类;
- 在 [5] 中:运行配置的名称——可以是任意名称;
![]() |
- 在 [6] 中:导出项目;
- 在 [7] 中:作为可执行 JAR 归档文件;
- 在 [8] 中:指定要创建的可执行文件的路径和名称;
- 在 [9] 中:在 [5] 中创建的运行配置的名称;
完成上述操作后,在包含可执行归档文件的文件夹中打开一个控制台:
该归档文件的执行方式如下:
.....\dist>java -jar gs-accessing-data-jpa-2.jar
控制台显示的结果如下:
2.2.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.1.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 应用程序的骨架,可以继续完善它,编写我们的预约管理应用程序的服务器端持久化层。
2.3. Eclipse 服务器项目
![]() |
![]() |
该项目的主要组成部分如下:
- [pom.xml]:项目的 Maven 配置文件;
- [rdvmedecins.entities]:JPA 实体;
- [rdvmedecins.repositories]:用于访问 JPA 实体的 Spring Data 接口;
- [rdvmedecins.metier]:[业务]层;
- [rdvmedecins.domain]:由[业务]层处理的实体;
- [rdvmdecins.config]:持久层配置类;
- [rdvmedecins.boot]:一个基本的控制台应用程序;
2.4. 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.0.0.RELEASE</version>
</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>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>commons-dbcp</groupId>
<artifactId>commons-dbcp</artifactId>
</dependency>
<dependency>
<groupId>commons-pool</groupId>
<artifactId>commons-pool</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<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>istia.st.spring.data.main.Application</start-class>
</properties>
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
</plugin>
<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]。对于父项目中已存在的依赖项,不指定版本。将使用父项目中定义的版本。其他依赖项按常规方式声明;
- 第 14–17 行:用于 Spring Data;
- 第 18–22 行:用于 JUnit 测试;
- 第 23–26 行:MySQL5 数据库管理系统(DBMS)的 JDBC 驱动程序;
- 第 27–34 行:Commons DBCP 连接池;
- 第 35–38 行:用于 JSON 处理的 Jackson 库;
- 第 39–43 行:Google Collections 库;
[spring-boot-starter-parent] 的 1.1.0.RC1 版本使用了以下库版本:
2.5. 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.AUTO)
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)) {
return false;
}
AbstractEntity other = (AbstractEntity) entity;
return this.id == other.id;
}
// getters and setters
..
}
- 第 11 行:[@MappedSuperclass] 注解表示被注解的类是 JPA [@Entity] 实体的父类;
- 第 15–17 行:为每个实体定义主键 [id]。正是 [@Id] 注解使 [id] 字段成为主键。[@GeneratedValue(strategy = GenerationType.AUTO)] 注解表示该主键的值由数据库管理系统 (DBMS) 生成,且不强制采用任何生成模式;
- 第 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 标识符,则被视为相等;
[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] 属性指定:当从持久化上下文请求 [Creneau] 实体且必须从数据库中检索时,不会同时检索 [Medecin] 实体。此模式的优势在于,[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];
2.6. [DAO] 层
![]() |
我们将使用 Spring Data 实现 [DAO] 层:
![]() |
[DAO] 层通过四个 Spring Data 接口实现:
- [ClientRepository]:提供对 JPA 实体 [Client] 的访问;
- [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> {
// list of physician slots
@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] 语法实现,该语法要求与外键引用的表执行连接操作,以便检索被引用的实体;
2.7. [业务]层
![]() |
![]() |
2.7.1. 实体
[DoctorTimeSlot] 实体将时间段与该时间段内预订的任何预约相关联:
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
...
}
2.7.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 to the list
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;
}
建议读者阅读注释。算法如下:
2.8. 项目配置
![]() |
[DomainAndPersistenceConfig] 类用于配置整个项目:
package rdvmedecins.config;
import javax.sql.DataSource;
import org.apache.commons.dbcp.BasicDataSource;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.orm.jpa.EntityScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.orm.jpa.JpaVendorAdapter;
import org.springframework.orm.jpa.vendor.Database;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@EnableJpaRepositories(basePackages = { "rdvmedecins.repositories" })
@EnableAutoConfiguration
@ComponentScan(basePackages = { "rdvmedecins" })
@EntityScan(basePackages = { "rdvmedecins.entities" })
@EnableTransactionManagement
public class 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;
}
// provider JPA - not required if you're happy with the default values used by Spring boot
// here we define it to enable / disable logs SQL
@Bean
public JpaVendorAdapter jpaVendorAdapter() {
HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
hibernateJpaVendorAdapter.setShowSql(false);
hibernateJpaVendorAdapter.setGenerateDdl(false);
hibernateJpaVendorAdapter.setDatabase(Database.MYSQL);
return hibernateJpaVendorAdapter;
}
// the EntityManagerFactory and TransactionManager are defined with default values by Spring boot
}
- 第 45 行:我们将不显式定义 [EntityManagerFactory] 和 [TransactionManager] bean。而是依赖 Spring Boot 的 [@EnableAutoConfiguration] 注解(第 17 行);
- 第 24–32 行:定义 MySQL 5 数据源。这是一个 Spring Boot 通常无法自动配置的 Bean;
- 第 36–43 行:我们还配置了 JPA 实现,将 Hibernate 的 [showSql] 属性设置为 false(第 39 行)。默认情况下,该属性设置为 true;
- 目前,Spring 管理的组件仅包括第 25 行和第 37 行的 Bean,以及通过自动配置生成的 [EntityManagerFactory] 和 [TransactionManager] Bean。我们需要添加来自 [business] 和 [DAO] 层的 Bean;
- 第 16 行将 [rdvmdecins.repositories] 包中继承自 [CrudRepository] 接口的接口添加到 Spring 上下文中;
- 第 18 行将 [rdvmedecins] 包中所有带有 Spring 注解的类及其子类添加到 Spring 上下文中。在 [rdvmdecins.metier] 包中,带有 [@Service] 注解的 [Metier] 类将被找到并添加到 Spring 上下文中;
- 第 45 行:Spring Boot 会默认定义一个 [entityManagerFactory] Bean。我们必须告知该 Bean 其需要管理的 JPA 实体所在的位置。第 19 行即实现了这一功能;
- 第 20 行:指定继承自 [CrudRepository] 接口的接口的方法必须在事务内执行;
2.9. [业务]层的测试
[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
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 行:我们检查是否获取到了空指针,这表明所查找的预约不存在;
测试运行成功:
![]() |
2.10. 控制台程序
![]() |
该控制台程序非常简单。它演示了如何检索外键:
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 行:我们显示该医生的预约列表;
控制台输出如下:
2.11. Spring MVC 入门
![]() |
接下来我们将探讨 Web 层的构建。该层主要由处理特定 URL 并以 JSON(JavaScript 对象表示法)格式返回一行文本的方法组成。这个 Web 层是一个 Web 接口,有时也被称为 Web API。我们将使用 Spring 生态系统中的另一个组件——Spring MVC 来实现该接口。首先,我们将参考 [http://spring.io] 上的指南之一。
2.11.1. 演示项目
![]() |
- 在[1]中,我们引入了其中一份Spring指南;
![]() |
- 在 [2] 中,我们选择 [Rest Service] 示例;
- 在 [3] 中,我们选择 Maven 项目;
- 在 [4] 中,我们选择指南的最终版本;
- 在 [5] 中,我们确认;
- 在 [6] 中,导入项目;
通过标准 URL 访问并返回 JSON 文本的 Web 服务通常被称为 REST(表征状态转移)服务。在本文中,我将把我们要构建的服务简称为 Web/JSON 服务。如果一个服务遵循某些规则,则被称为 RESTful 服务。我并未尝试遵守这些规则。
现在让我们来检查这个导入的项目,首先从它的 Maven 配置开始。
2.11.2. 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-rest-service</artifactId>
<version>0.1.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.1.0.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
</dependencies>
<properties>
<start-class>hello.Application</start-class>
</properties>
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>spring-releases</id>
<url>http://repo.spring.io/release</url>
</repository>
</repositories>
<pluginRepositories>
<pluginRepository>
<id>spring-releases</id>
<url>http://repo.spring.io/release</url>
</pluginRepository>
</pluginRepositories>
</project>
- 第 10–14 行:与 [Spring Data] 项目类似,这里也存在父项目 [Spring Boot];
- 第 17–20 行:[spring-boot-starter-web] 构建产物包含 Spring MVC 项目所需的库。特别是,它包含一个嵌入式 Tomcat 服务器。应用程序将在该服务器上运行;
- 第 21–24 行:Jackson 库负责处理 JSON:将 Java 对象转换为 JSON 字符串,反之亦然;
此配置包含大量库:
![]() | ![]() |
上图中,我们可以看到三个 Tomcat 服务器压缩包。
2.11.3. Spring REST 服务的架构
Spring MVC 通过以下方式实现了 MVC(模型-视图-控制器)架构模式:
![]() |
客户端请求的处理流程如下:
- 请求 - 请求的 URL 格式为 http://machine:port/contexte/Action/param1/param2/....?p1=v1&p2=v2&... [Dispatcher Servlet] 是 Spring 框架中负责处理传入 URL 的类。它会将 URL “路由”到应处理该请求的操作。这些操作是称为 [控制器] 的特定类中的方法。 此处的 MVC 中的 C 代表 [Dispatcher Servlet、Controller、Action] 这一链条。如果未配置任何 Action 来处理传入的 URL,[Dispatcher Servlet] 将返回请求的 URL 未找到(404 NOT FOUND 错误);
- 处理
- 被选中的 Action 可以使用 [Dispatcher Servlet] 传递给它的参数。这些参数可能来自多个来源:
- URL 的路径 [/param1/param2/...],
- URL 参数 [p1=v1&p2=v2],
- 浏览器随请求提交的参数;
- 在处理用户请求时,操作可能需要调用 [业务] 层 [2b]。一旦处理完客户端的请求,可能会触发各种响应。一个典型的例子是:
- 若请求无法正确处理,则返回错误页面
- 否则则显示确认页面
- 操作会指示显示特定的视图 [3]。该视图将展示被称为视图模型的数据。这就是 MVC 中的 M。操作将创建这个 M 模型 [2c],并指示显示 V 视图 [3];
- 响应——选定的视图 V 使用操作生成的模型 M 来初始化其必须发送给客户端的 HTML 响应中的动态部分,然后发送该响应。
对于 Web 服务/JSON,上述架构稍作修改:
![]() |
- 在 [4a] 中,模型(即一个 Java 类)通过 JSON 库转换为 JSON 字符串;
- 在[4b]中,该JSON字符串被发送至浏览器;
2.11.4. C 控制器
![]() |
导入的应用程序包含以下控制器:
package hello;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
@Controller
public class GreetingController {
private static final String template = "Hello, %s!";
private final AtomicLong counter = new AtomicLong();
@RequestMapping("/greeting")
public @ResponseBody
Greeting greeting(@RequestParam(value = "name", required = false, defaultValue = "World") String name) {
return new Greeting(counter.incrementAndGet(), String.format(template, name));
}
}
- 第 9 行:[@Controller] 注解将 [GreetingController] 类定义为 Spring 控制器,这意味着其方法已被注册以处理 URL;
- 第 15 行:[@RequestMapping] 注解指定了该方法处理的 URL,本例中为 [/greeting]。稍后我们将看到,该 URL 可以带参数,并且可以获取这些参数;
- 第 16 行:[@ResponseBody] 注解表示该方法不会生成用于发送给客户端浏览器的视图模板(如 JSP、JSF、Thymeleaf 等),而是直接向浏览器生成响应。 此处,它生成一个 [Greeting] 类型的对象(第 18 行)。虽然在此处并不明显,但该对象在发送至浏览器之前会先被转换为 JSON。正是项目依赖中包含的 JSON 库,促使 Spring Boot 自动以这种方式配置项目;
- 第 17 行:[greeting] 方法有一个 [String name] 参数。注解 [@RequestParam(value = "name", required = false, defaultValue = "World"]] 表示该参数必须通过名为 [name] 的参数进行初始化(@RequestParam(value = "name")。 该参数可以是 GET 或 POST 参数。此参数为非必填(required = false)。在此情况下,方法的 [name] 参数将初始化为值 [World](defaultValue = "World")。
2.11.5. M 模型
通过前一种方法生成的 M 模型是以下 [Greeting] 对象:
![]() |
package hello;
public class Greeting {
private final long id;
private final String content;
public Greeting(long id, String content) {
this.id = id;
this.content = content;
}
public long getId() {
return id;
}
public String getContent() {
return content;
}
}
对该对象进行 JSON 转换后,将生成字符串 {"id":n,"content":"text"}。最终,控制器方法生成的 JSON 字符串将呈现为以下形式:
或
2.11.6. 项目配置
![]() |
该项目由以下 [Application] 类配置:
package hello;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.SpringApplication;
import org.springframework.context.annotation.ComponentScan;
@ComponentScan
@EnableAutoConfiguration
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
- 第 11 行:有趣的是,该类可通过专用于控制台应用程序的 [main] 方法执行。事实确实如此。第 12 行的 [SpringApplication] 类将启动依赖项中存在的 Tomcat 服务器,并在其上部署 REST 服务;
- 第 4 行:我们可以看到 [SpringApplication] 类属于 [Spring Boot] 项目;
- 第 12 行:第一个参数是用于配置项目的类,第二个参数包含任何附加参数;
- 第 8 行:[@EnableAutoConfiguration] 注解指示 Spring Boot 配置该项目;
- 第 7 行:[@ComponentScan] 注解会扫描包含 [Application] 类的目录以查找 Spring 组件。将找到一个:[GreetingController] 类,该类带有 [@Controller] 注解,因此成为 Spring 组件;
2.11.7. 运行项目
现在让我们运行该项目:
![]() |
我们得到以下控制台日志:
____ _ __ _ _
- 第 12 行:Tomcat 服务器在端口 8080 上启动(第 11 行);
- 第 16 行:存在 [DispatcherServlet] Servlet;
- 第 19 行:已发现方法 [GreetingController.greeting];
要测试该 Web 应用程序,请访问 URL [http://localhost:8080/greeting]:
![]() | ![]() |
我们收到了预期的 JSON 字符串。查看服务器发送的 HTTP 头部信息可能会很有趣。为此,我们将使用名为 [Advanced Rest Client] 的 Chrome 插件(参见附录):
![]() |
- 在 [1] 中,请求的 URL;
- 在 [2] 中,使用了 GET 方法;
- 在 [3] 中,JSON 响应;
- 在 [4] 中,服务器表明其将以 JSON 格式发送响应;
- 在 [5] 中,我们请求相同的 URL,但这次使用 POST 请求;
- 在 [7] 中,信息以 [urlencoded] 格式发送至服务器;
- 在 [6] 中,name 参数及其值;
- 在 [8] 中,浏览器告知服务器将发送 [urlencoded] 格式的数据;
- 在 [9] 中,服务器的 JSON 响应;
2.11.8. 创建可执行归档文件
可以在 Eclipse 外部创建可执行归档文件。所需的配置位于 [pom.xml] 文件中:
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<start-class>istia.st.Application</start-class>
<java.version>1.7</java.version>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
- 第 9–12 行定义了用于创建可执行归档文件的插件;
- 第 3 行定义了项目的可执行类;
操作步骤如下:
![]() |
- 在 [1] 中:执行一个 Maven 目标;
- 在 [2] 中:有两个目标:[clean] 用于从 Maven 项目中删除 [target] 文件夹,[package] 用于重新生成该文件夹;
- 在 [3] 中:生成的 [target] 文件夹将位于此文件夹中;
- 在 [4] 中:目标已生成;
在控制台显示的日志中,请务必留意 [spring-boot-maven-plugin] 插件。该插件负责生成可执行归档文件。
使用终端,导航至生成的文件夹:
- 第 5 行:生成的归档文件;
该归档文件的执行方式如下:
现在 Web 应用程序已运行,您可以通过浏览器访问它:
![]() |
2.11.9. 在 Tomcat 服务器上部署应用程序
虽然 Spring Boot 在开发模式下非常方便,但生产环境中的应用程序通常会部署在真正的 Tomcat 服务器上。具体操作如下:
按以下方式修改 [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-rest-service</artifactId>
<version>0.1.0</version>
<packaging>war</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.1.0.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-tomcat</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
<properties>
<start-class>hello.Application</start-class>
</properties>
....
</project>
需要在两个地方进行修改:
- 第 9 行:必须指定要生成 WAR(Web Archive)文件;
- 第 26–30 行:您需要添加对 [spring-boot-starter-tomcat] 构建产品的依赖。该构建产品会将所有 Tomcat 类添加到项目的依赖项中;
- 第 29 行:该工件的类型为 [provided],这意味着相应的归档文件不会包含在生成的 WAR 文件中。相反,这些归档文件将位于应用程序运行的 Tomcat 服务器上;
您还必须配置 Web 应用程序。如果没有 [web.xml] 文件,则通过继承 [SpringBootServletInitializer] 的类来完成此配置:
![]() |
[ApplicationInitializer] 类的定义如下:
package hello;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.context.web.SpringBootServletInitializer;
public class ApplicationInitializer extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder application) {
return application.sources(Application.class);
}
}
- 第 6 行:[ApplicationInitializer] 类继承自 [SpringBootServletInitializer] 类;
- 第 9 行:重写了 [configure] 方法(第 8 行);
- 第 10 行:提供了用于配置该项目的类;
要运行该项目,请按以下步骤操作:
![]() |
- 在 [1] 中,在 Eclipse IDE 中已注册的某台服务器上运行该项目;
- 在 [2] 中,选择 [tc Server Developer],这是默认选项。这是 Tomcat 的一个变体;
完成上述操作后,您可以在浏览器中输入 URL [http://localhost:8080/gs-rest-service/greeting/?name=Mitchell]:
![]() |
现在我们已经掌握了生成 WAR 归档文件的方法。接下来,我们将继续使用 Spring Boot 及其可执行 JAR 归档文件进行开发。
2.11.10. 创建新的 Web 项目
要创建一个新的 Web 项目,请按照以下步骤操作:
![]() |
- 在 [1] 中:文件 / 新建 / Spring 启动项目
- 在 [2] 中:选择 [Web]。请勿选择任何视图库,因为在 Web 服务/JSON 中不存在视图;
- 生成的项目将是一个 Maven 项目。在 [3] 中,输入要创建的 Maven 工件的组名;在 [4] 中,输入工件名称;
- 在 [5] 中,输入 Spring 将放置项目配置类的包名;
- 在 [6] 中,为 Eclipse 项目命名——该名称可以与 [4] 中的名称不同;
![]() |
2.12. [Web] 层
![]() |
![]() |
我们将分几个步骤构建Web层:
- 步骤 1:构建一个不包含身份验证功能的可运行 Web 层;
- 步骤 2:使用 Spring Security 实现身份验证;
- 步骤 3:实现 CORS [跨源资源共享(CORS)是一种机制,允许网页上的许多资源(例如字体、JavaScript 等)从资源源域之外的另一个域进行请求。 (维基百科)]。我们的 Web 服务客户端将是一个 Angular Web 客户端,它未必与我们的 Web 服务位于同一域名下。默认情况下,除非 Web 服务授权,否则它无法访问该服务。我们将了解具体实现方法;
2.12.1. Maven 配置
该项目的 [pom.xml] 文件如下:
<modelVersion>4.0.0</modelVersion>
<groupId>istia.st.spring4.mvc</groupId>
<artifactId>rdvmedecins-webapi-v1</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>rdvmedecins-webapi-v1</name>
<description>Gestion de RV Médecins</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.0.0.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>istia.st.spring4.rdvmedecins</groupId>
<artifactId>rdvmedecins-metier-dao</artifactId>
<version>0.0.1-SNAPSHOT</version>
</dependency>
</dependencies>
- 第 7–11 行:父 Maven 项目;
- 第 13–16 行:Spring MVC 项目的依赖项;
- 第 17–21 行:[业务逻辑、DAO、JPA] 层的依赖项;
2.12.2. Web 服务接口
![]() |
- 在上述[1]中,浏览器只能请求数量有限且具有特定语法的URL;
- 而在[4]中,它会收到一个JSON响应;
我们Web服务返回的响应都将采用相同的格式,对应如下所示的[Response]类型对象的JSON表示形式:
package rdvmedecins.web.models;
public class Reponse {
// ----------------- properties
// operation status
private int status;
// the answer JSON
private Object data;
// ---------------constructeurs
public Reponse() {
}
public Reponse(int status, Object data) {
this.status = status;
this.data = data;
}
// methods
public void incrStatusBy(int increment) {
status += increment;
}
// ----------------------getters and setters
...
}
- 第 7 行:响应错误代码 0 表示成功,其他情况表示失败;
- 第 9 行:响应正文;
下面是展示 Web 服务 / JSON 接口的屏幕截图:
该诊所的所有患者列表 [/getAllClients]
![]() |
该诊所所有医生的名单 [/getAllMedecins]
![]() |
某位医生的可用时段列表 [/getAllCreneaux/{idMedecin}]
![]() |
某位医生的预约列表 [/getRvMedecinJour/{idMedecin}/{yyyy-mm-dd}
![]() |
医生的每日日程 [/getAgendaMedecinJour/{idMedecin}/{yyyy-mm-dd}]
![]() |
要添加或删除预约,我们使用 Chrome 扩展程序 [Advanced Rest Client],因为这些操作是通过 POST 请求实现的。
![]() |
- 在 [0] 中,是 Web 服务 URL;
- 在 [1] 中,使用 POST 方法;
- 在 [2] 中,发送给 Web 服务的 JSON 文本格式为 {day, clientId, slotId};
- 在 [3] 中,客户端向 Web 服务指定其发送的信息采用 JSON 格式;
响应如下:
![]() |
- 在 [4] 中:客户端发送标头,表明其发送的数据采用 JSON 格式;
- 在 [5] 中:Web 服务响应称其同样发送的是 JSON;
- 在 [6] 中:Web 服务的 JSON 响应。其中的 [data] 字段包含已添加约会的 JSON 表示形式;
可以验证新预约是否已存在:
![]() |
删除预约 [/deleteApp]
![]() |
- 在 [1] 中,Web 服务 URL;
- 在 [2] 中,使用 POST 方法;
- 在 [3] 中,发送给 Web 服务的 JSON 文本采用 {idRv} 格式;
- 在 [4] 中,客户端向 Web 服务指定其正在发送 JSON 数据;
响应如下:
![]() |
- 在 [5] 中:[status] 字段被设置为 0,表示操作成功;
可以验证该约会的删除情况:
![]() |
如上所示,患者 [GERMAN 女士] 的预约已不再显示。
该 Web 服务还允许您通过 ID 检索实体:
![]() |
![]() |
![]() |
![]() |
所有这些 URL 均由 [RdvMedecinsController] 控制器处理,下面我们将对此进行介绍。
2.12.3. [ RdvMedecinsController] 控制器的框架
![]() |
[RdvMedecinsController] 控制器如下:
package rdvmedecins.web.controllers;
import java.text.ParseException;
...
@RestController
public class RdvMedecinsController {
@Autowired
private ApplicationModel application;
private List<String> messages;
@PostConstruct
public void init() {
// application error messages
messages = application.getMessages();
}
// list of doctors
@RequestMapping(value = "/getAllMedecins", method = RequestMethod.GET)
public Reponse getAllMedecins() {
...
}
// customer list
@RequestMapping(value = "/getAllClients", method = RequestMethod.GET)
public Reponse getAllClients() {
...
}
// list of physician slots
@RequestMapping(value = "/getAllCreneaux/{idMedecin}", method = RequestMethod.GET)
public Reponse getAllCreneaux(@PathVariable("idMedecin") long idMedecin) {
...
}
// list of doctor's appointments
@RequestMapping(value = "/getRvMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET)
public Reponse getRvMedecinJour(@PathVariable("idMedecin") long idMedecin,
@PathVariable("jour") String jour) {
...
}
@RequestMapping(value = "/getClientById/{id}", method = RequestMethod.GET)
public Reponse getClientById(@PathVariable("id") long id) {
...
}
@RequestMapping(value = "/getMedecinById/{id}", method = RequestMethod.GET)
public Reponse getMedecinById(@PathVariable("id") long id) {
...
}
@RequestMapping(value = "/getRvById/{id}", method = RequestMethod.GET)
public Reponse getRvById(@PathVariable("id") long id) {
...
}
@RequestMapping(value = "/getCreneauById/{id}", method = RequestMethod.GET)
public Reponse getCreneauById(@PathVariable("id") long id) {
...
}
@RequestMapping(value = "/ajouterRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
public Reponse ajouterRv(@RequestBody PostAjouterRv post) {
...
}
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
public Reponse supprimerRv(@RequestBody PostSupprimerRv post) {
...
}
@RequestMapping(value = "/getAgendaMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET)
public Reponse getAgendaMedecinJour(
@PathVariable("idMedecin") long idMedecin,
@PathVariable("jour") String jour) {
...
}
}
- 第 6 行:[@RestController] 注解将 [RdvMedecinsController] 类定义为 Spring 控制器。此外,它确保处理 URL 的方法生成的响应会自动转换为 JSON;
- 第 9–10 行:Spring 将在此处注入一个 [ApplicationModel] 类型的对象;
- 第 13 行:[@PostConstruct] 注解标记的方法将在类实例化后立即执行。当该方法运行时,Spring 注入的对象已可用;
- 所有方法均返回 [Response] 类型的对象,如下所示:
package rdvmedecins.web.models;
public class Reponse {
// ----------------- properties
// operation status
private int status;
// the answer
private Object data;
...
}
该对象在发送至客户端浏览器之前会被序列化为 JSON;
- 第 20 行:[@RequestMapping] 注解设置了调用该方法的条件。在此,该方法处理来自 URL [/getAllMedecins] 的 GET 请求。如果通过 POST 请求此 URL,请求将被拒绝,Spring MVC 会向 Web 客户端发送 HTTP 错误代码;
- 第 32 行:URL 配置了 {idMedecin}。该参数通过第 33 行的 [@PathVariable] 注解进行获取;
- 第 33 行:单个参数 [long idMedecin] 通过 [@PathVariable("idMedecin")] 从 URL 中的 {idMedecin} 参数获取其值。URL 中的参数与方法中的参数名称可能不同。 请注意,[@PathVariable("idMedecin")] 的类型为 String(整个 URL 是一个字符串),而参数 [long idMedecin] 的类型为 [long]。类型转换会自动进行。如果类型转换失败,将返回一个 HTTP 错误代码;
- 第 65 行:[@RequestBody] 注解指代请求正文。在 GET 请求中,几乎不会包含请求正文(但可以包含);而在 POST 请求中,通常会包含请求正文(但也可以省略)。对于 URL [ajouterRv],Web 客户端会在其 POST 请求中发送以下 JSON 字符串:
语法 [@RequestBody PostAjouterRv post](第 65 行),结合该方法期望接收 JSON [consumes = "application/json; charset=UTF-8"](第 64 行)这一事实,将导致 Web 客户端发送的 JSON 字符串被反序列化为类型为 [PostAjouter] 的对象。该对象定义如下:
package rdvmedecins.web.models;
public class PostAjouterRv {
// pOST DATA
private String jour;
private long idClient;
private long idCreneau;
// getters and setters
...
}
在此处,必要的类型转换也会自动进行;
- 第 69–70 行包含针对 URL [/deleteRv] 的类似机制。提交的 JSON 字符串如下:
而 [PostSupprimerRv] 类型如下:
package rdvmedecins.web.models;
public class PostSupprimerRv {
// pOST DATA
private long idRv;
// getters and setters
...
}
2.12.4. Web 服务模型
![]() |
我们已经介绍了 [Response、PostAddAppointment、PostDeleteAppointment] 模型。[ApplicationModel] 模型如下:
package rdvmedecins.web.models;
import java.util.Date;
...
@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;
// error messages
private List<String> messages;
@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(Rv rv) {
métier.supprimerRv(rv);
}
@Override
public AgendaMedecinJour getAgendaMedecinJour(long idMedecin, Date jour) {
return métier.getAgendaMedecinJour(idMedecin, jour);
}
}
- 第 6 行:[@Component] 注解将 [ApplicationModel] 类定义为 Spring 组件。与迄今为止所见的所有 Spring 组件(@Controller 除外)一样,该类型的对象仅会实例化一个(单例);
- 第 7 行:[ApplicationModel] 类实现了 [IMetier] 接口;
- 第 10–11 行:Spring 注入了对 [business] 层的引用;
- 第 19 行:[@PostConstruct] 注解确保 [init] 方法将在 [ApplicationModel] 类实例化后立即执行;
- 第 23–24 行:从 [business] 层获取医生和客户列表;
- 第 26 行:如果发生异常,我们将异常堆栈中的消息存储在第 17 行的字段中;
[ApplicationModel] 类将承担两个作用:
- 作为缓存,用于存储医生和患者(客户)的列表;
- 作为控制器统一的接口;
Web 层的架构演变如下:
![]() |
- 在 [2b] 中,控制器(们)的方法与 [ApplicationModel] 单例进行通信;
这种策略为缓存管理提供了灵活性。目前,医生的预约时段并未被缓存。若要缓存它们,只需修改 [ApplicationModel] 类即可。这不会对控制器产生任何影响,控制器将一如既往地使用 [List<Creneau> getAllCreneaux(long idMedecin)] 方法。需要更改的是 [ApplicationModel] 中该方法的实现。
2.12.5. 静态类
[Static] 类包含一组静态辅助方法,这些方法不涉及任何“业务”或“Web”方面的内容:
![]() |
其代码如下:
package rdvmedecins.web.helpers;
import java.text.SimpleDateFormat;
...
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;
}
// mappers Object --> Map
// --------------------------------------------------------
....
}
- 第 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()] 的错误消息。
[Static] 类还包含其他实用方法,我们将在遇到它们时再作说明。
接下来我们将详细说明 Web 服务的 URL 处理机制。该过程涉及三个主要类:
- 控制器 [RdvMedecinsController];
- 辅助方法类 [Static];
- 缓存类 [ApplicationModel];
![]() |
2.12.6. 控制器中的 [init] 方法
[RdvMedecinsController] 控制器(参见第 2.12.3 节)有一个 [init] 方法,该方法在实例化后立即执行:
@Autowired
private ApplicationModel application;
private List<String> messages;
@PostConstruct
public void init() {
// application error messages
messages = application.getMessages();
}
- 第 8 行:存储在应用程序缓存 [ApplicationModel] 中的错误消息被保存在第 3 行的字段中。这使得方法能够判断应用程序是否已正确初始化。
2.12.7. URL [/getAllMedecins]
URL [/getAllDoctors] 由控制器 [RdvMedecinsController] 中的以下方法处理:
// list of doctors
@RequestMapping(value = "/getAllMedecins", method = RequestMethod.GET)
public Reponse getAllMedecins() {
// application status
if (messages != null) {
return new Reponse(-1, messages);
}
// list of doctors
try {
return new Reponse(0, application.getAllMedecins());
} catch (Exception e) {
return new Reponse(1, Static.getErreursForException(e));
}
}
- 第 5 行:我们检查应用程序是否已正确初始化(messages == null)。如果未初始化,则返回一个状态码为 -1、数据为 messages 的响应;
- 第 10 行:否则,我们返回状态为 0 的医生列表。方法 [application.getAllMedecins()] 不会抛出异常,因为它只是返回一个缓存列表。尽管如此,我们仍保留此异常处理,以防医生信息已不再缓存;
我们尚未演示应用程序初始化失败的情况。让我们停止 MySQL5 数据库管理系统,启动 Web 服务,然后请求 URL [/getAllMedecins]:

我们确实遇到了错误。在正常情况下,我们会看到以下视图:
![]() |
2.12.8. URL [/getAllClients]
URL [/getAllClients] 由 [RdvMedecinsController] 中的以下方法处理:
// customer list
@RequestMapping(value = "/getAllClients")
public Reponse getAllClients() {
// application status
if (messages != null) {
return new Reponse(-1, messages);
}
// customer list
try {
return new Reponse(0, application.getAllClients());
} catch (Exception e) {
return new Reponse(1, Static.getErreursForException(e));
}
}
这与我们之前学习过的 [getAllMedecins] 方法类似。得到的结果如下:
![]() |
2.12.9. URL [/getAllSlots/{doctorId}]
URL [/getAllSlots/{doctorId}] 由 [RdvMedecinsController] 控制器中的以下方法处理:
// list of physician slots
@RequestMapping(value = "/getAllCreneaux/{idMedecin}", method = RequestMethod.GET)
public Reponse getAllCreneaux(@PathVariable("idMedecin") long idMedecin) {
// application status
if (messages != null) {
return new Reponse(-1, messages);
}
// we get the doctor back
Reponse réponse = getMedecin(idMedecin);
if (réponse.getStatus() != 0) {
return réponse;
}
Medecin médecin = (Medecin) réponse.getData();
// doctor's slots
List<Creneau> créneaux = null;
try {
créneaux = application.getAllCreneaux(médecin.getId());
} catch (Exception e1) {
return new Reponse(3, Static.getErreursForException(e1));
}
// we return the answer
return new Reponse(0, Static.getListMapForCreneaux(créneaux));
}
- 第 9 行:通过 [id] 参数标识的医生是从本地方法中调用的:
private Reponse getMedecin(long id) {
// we get the doctor back
Medecin médecin = null;
try {
médecin = application.getMedecinById(id);
} catch (Exception e1) {
return new Reponse(1, Static.getErreursForException(e1));
}
// existing doctor?
if (médecin == null) {
return new Reponse(2, null);
}
// ok
return new Reponse(0, médecin);
}
该方法返回一个取值范围在 [0,1,2] 之间的状态值。让我们回到 [getAllSlots] 方法的代码:
- 第 10–12 行:如果状态 ≠ 0,则立即返回响应;
- 第 13 行:获取该医生;
- 第 17 行:获取该医生的时段;
- 第 22 行:将 [Static.getListMapForCreneaux(slots)] 对象作为响应返回;
让我们回顾一下 [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 字符串。这是不必要的。 因此,与其序列化一个 [Creneau] 对象,不如序列化一个仅包含所需字段的 [Map] 对象。
让我们回到之前查看过的代码:
// we return the answer
return new Reponse(0, Static.getListMapForCreneaux(créneaux));
[Static.getListMapForCreneaux] 方法如下:
// List<Creneau> --> List<Map>
public static List<Map<String, Object>> getListMapForCreneaux(List<Creneau> créneaux) {
// liste de dictionnaires <String,Object>
List<Map<String, Object>> liste = new ArrayList<Map<String, Object>>();
for (Creneau créneau : créneaux) {
liste.add(Static.getMapForCreneau(créneau));
}
// on rend la liste
return liste;
}
而 [Static.getMapForCreneau] 方法如下:
// Creneau --> Map
public static Map<String, Object> getMapForCreneau(Creneau créneau) {
// qq chose à faire ?
if (créneau == null) {
return null;
}
// dictionnaire <String,Object>
Map<String, Object> hash = new HashMap<String, Object>();
hash.put("id", créneau.getId());
hash.put("hDebut", créneau.getHdebut());
hash.put("mDebut", créneau.getMdebut());
hash.put("hFin", créneau.getHfin());
hash.put("mFin", créneau.getMfin());
// on rend le dictionnaire
return hash;
}
- 第 8 行:我们创建一个字典;
- 第 9–13 行:我们将想要保留的字段添加到 JSON 字符串中。[doctor] 字段未被包含;
- 第 15 行:返回此字典;
所得结果如下:
![]() |
如果该时间段不存在,则返回以下结果:
![]() |
或者在访问数据库时出现错误的情况下使用这些:
![]() |
2.12.10. URL [/getRvMedecinJour/{idMedecin}/{jour}]
URL [/getRvMedecinJour/{idMedecin}/{jour}] 由 [RdvMedecinsController] 控制器中的以下方法处理:
// list of doctor's appointments
@RequestMapping(value = "/getRvMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET)
public Reponse getRvMedecinJour(@PathVariable("idMedecin") long idMedecin, @PathVariable("jour") String jour) {
// application status
if (messages != null) {
return new Reponse(-1, messages);
}
// check the date
Date jourAgenda = null;
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.setLenient(false);
try {
jourAgenda = sdf.parse(jour);
} catch (ParseException e) {
return new Reponse(3, null);
}
// we get the doctor back
Reponse réponse = getMedecin(idMedecin);
if (réponse.getStatus() != 0) {
return réponse;
}
Medecin médecin = (Medecin) réponse.getData();
// list of appointments
List<Rv> rvs = null;
try {
rvs = application.getRvMedecinJour(médecin.getId(), jourAgenda);
} catch (Exception e1) {
return new Reponse(4, Static.getErreursForException(e1));
}
// we return the answer
return new Reponse(0, Static.getListMapForRvs(rvs));
}
- 第 31 行:我们返回的是 List<Map<String, Object>> 对象,而不是 List<Rv> 对象。回顾一下 [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] 和 [creneau] 字段,我们显式地执行了连接操作。此外,由于 [cr.medecin.id=?1] 这一连接条件,我们还会获取到医生信息。因此,每位医生的信息都会出现在每个预约的 JSON 字符串中。然而,这些重复的信息其实是多余的。让我们回到该方法的代码:
- 第 31 行:我们自行构建了要序列化为 JSON 的字典;
为一次预约构建的字典如下:
// Rv --> Map
public static Map<String, Object> getMapForRv(Rv rv) {
// anything to do?
if (rv == null) {
return null;
}
// dictionary <String,Object>
Map<String, Object> hash = new HashMap<String, Object>();
hash.put("id", rv.getId());
hash.put("client", rv.getClient());
hash.put("creneau", getMapForCreneau(rv.getCreneau()));
// we return the dictionary
return hash;
}
- 第 11 行:我们从之前介绍的 [Creneau] 对象中获取字典;
所得结果如下:
![]() |
或者这些日期有误的结果:
![]() |
或者这些日期有误的:
![]() |
2.12.11. URL [/getAgendaMedecinJour/{idMedecin}/{jour}]
URL [/getAgendaMedecinJour/{idMedecin}/{jour}] 由 [RdvMedecinsController] 控制器中的以下方法处理:
@RequestMapping(value = "/getAgendaMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET)
public Reponse getAgendaMedecinJour(@PathVariable("idMedecin") long idMedecin, @PathVariable("jour") String jour) {
// application status
if (messages != null) {
return new Reponse(-1, messages);
}
// check the date
Date jourAgenda = null;
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.setLenient(false);
try {
jourAgenda = sdf.parse(jour);
} catch (ParseException e) {
return new Reponse(3, new String[] { String.format("jour [%s] invalide", jour) });
}
// we get the doctor back
Reponse réponse = getMedecin(idMedecin);
if (réponse.getStatus() != 0) {
return réponse;
}
Medecin médecin = (Medecin) réponse.getData();
// get your diary back
AgendaMedecinJour agenda = null;
try {
agenda = application.getAgendaMedecinJour(médecin.getId(), jourAgenda);
} catch (Exception e1) {
return new Reponse(4, Static.getErreursForException(e1));
}
// ok
return new Reponse(0, Static.getMapForAgendaMedecinJour(agenda));
}
}
- 第 30 行返回一个类型为 List<Map<String, Object>> 的对象。
方法 [Static.getMapForAgendaMedecinJour] 如下所示:
// AgendaMedecinJour --> Map
public static Map<String, Object> getMapForAgendaMedecinJour(AgendaMedecinJour agenda) {
// anything to do?
if (agenda == null) {
return null;
}
// dictionary <String,Object>
Map<String, Object> hash = new HashMap<String, Object>();
hash.put("medecin", agenda.getMedecin());
hash.put("jour", new SimpleDateFormat("yyyy-MM-dd").format(agenda.getJour()));
List<Map<String, Object>> créneaux = new ArrayList<Map<String, Object>>();
for (CreneauMedecinJour créneau : agenda.getCreneauxMedecinJour()) {
créneaux.add(getMapForCreneauMedecinJour(créneau));
}
hash.put("creneauxMedecin", créneaux);
// we return the dictionary
return hash;
}
生成的字典包含三个字段:
- [doctor]:日程表所属的医生。我们保留了这一信息,因为它仅出现一次,而在之前的案例中,该信息会在每个 JSON 字符串中重复出现;
- [day]:日历中的日期;
- [doctorSlots]:该医生的可用时段列表,包括该时段内已安排的任何预约;
第 13 行调用的 [getMapForCreneauMedecinJour] 方法如下:
// CreneauMedecinJour --> map
public static Map<String, Object> getMapForCreneauMedecinJour(CreneauMedecinJour créneau) {
// anything to do?
if (créneau == null) {
return null;
}
// dictionary <String,Object>
Map<String, Object> hash = new HashMap<String, Object>();
hash.put("creneau", getMapForCreneau(créneau.getCreneau()));
hash.put("rv", getMapForRv(créneau.getRv()));
// we return the dictionary
return hash;
}
- 第 9-10 行:我们使用之前讨论过的 [Creneau] 和 [Rv] 类型的字典,因此这些字典中不包含任何 [Medecin] 对象;
所得结果如下:
![]() |
如果日期不正确,则为以下结果:
![]() |
如果医生ID无效,请尝试以下选项:
![]() |
2.12.12. URL [/getMedecinById/{id}]
URL [/getMedecinById/{id}] 由 [RdvMedecinsController] 控制器中的以下方法处理:
@RequestMapping(value = "/getMedecinById/{id}", method = RequestMethod.GET)
public Reponse getMedecinById(@PathVariable("id") long id) {
// application status
if (messages != null) {
return new Reponse(-1, messages);
}
// we get the doctor back
return getMedecin(id);
}
第 8 行,[getMedecin] 方法如下:
private Reponse getMedecin(long id) {
// we get the doctor back
Medecin médecin = null;
try {
médecin = application.getMedecinById(id);
} catch (Exception e1) {
return new Reponse(1, Static.getErreursForException(e1));
}
// existing doctor?
if (médecin == null) {
return new Reponse(2, null);
}
// ok
return new Reponse(0, médecin);
}
结果如下:
![]() |
如果医生的ID不正确,则显示以下内容:
![]() |
2.12.13. URL [/getClientById/{id}]
URL [/getClientById/{id}] 由控制器 [RdvMedecinsController] 中的以下方法处理:
@RequestMapping(value = "/getClientById/{id}", method = RequestMethod.GET)
public Reponse getClientById(@PathVariable("id") long id) {
// application status
if (messages != null) {
return new Reponse(-1, messages);
}
// we get the customer back
return getClient(id);
}
第 8 行,[getClient] 方法如下:
private Reponse getClient(long id) {
// we get the customer back
Client client = null;
try {
client = application.getClientById(id);
} catch (Exception e1) {
return new Reponse(1, Static.getErreursForException(e1));
}
// existing customer?
if (client == null) {
return new Reponse(2, null);
}
// ok
return new Reponse(0, client);
}
结果如下:
![]() |
如果客户 ID 不正确,则显示以下内容:
![]() |
2.12.14. URL [/getCreneauById/{id}]
URL [/getCreneauById/{id}] 由控制器 [RdvMedecinsController] 中的以下方法处理:
@RequestMapping(value = "/getCreneauById/{id}", method = RequestMethod.GET)
public Reponse getCreneauById(@PathVariable("id") long id) {
// application status
if (messages != null) {
return new Reponse(-1, messages);
}
// we get the slot back
Reponse réponse = getCreneau(id);
if (réponse.getStatus() == 0) {
réponse.setData(Static.getMapForCreneau((Creneau) réponse.getData()));
}
// result
return réponse;
}
第 8 行,[getCreneau] 方法如下:
private Reponse getCreneau(long id) {
// we get the slot back
Creneau créneau = null;
try {
créneau = application.getCreneauById(id);
} catch (Exception e1) {
return new Reponse(1, Static.getErreursForException(e1));
}
// existing niche?
if (créneau == null) {
return new Reponse(2, null);
}
// ok
return new Reponse(0, créneau);
}
所得结果如下:
![]() |
或者,如果槽位编号不正确,则为:
![]() |
2.12.15. URL [/getRvById/{id}]
URL [/getRvById/{id}] 由 [RdvMedecinsController] 控制器中的以下方法处理:
@RequestMapping(value = "/getRvById/{id}", method = RequestMethod.GET)
public Reponse getRvById(@PathVariable("id") long id) {
// application status
if (messages != null) {
return new Reponse(-1, messages);
}
// recovering the rv
Reponse réponse = getRv(id);
if (réponse.getStatus() == 0) {
réponse.setData(Static.getMapForRv2((Rv) réponse.getData()));
}
// result
return réponse;
}
第 8 行,[getRv] 方法如下:
private Reponse getRv(long id) {
// we recover the Rv
Rv rv = null;
try {
rv = application.getRvById(id);
} catch (Exception e1) {
return new Reponse(1, Static.getErreursForException(e1));
}
// Existing Rv?
if (rv == null) {
return new Reponse(2, null);
}
// ok
return new Reponse(0, rv);
}
第 10 行,[Static.getMapForRv2] 方法如下:
// Rv --> Map
public static Map<String, Object> getMapForRv2(Rv rv) {
// qq chose à faire ?
if (rv == null) {
return null;
}
// dictionnaire <String,Object>
Map<String, Object> hash = new HashMap<String, Object>();
hash.put("id", rv.getId());
hash.put("idClient", rv.getIdClient());
hash.put("idCreneau", rv.getIdCreneau());
// on rend le dictionnaire
return hash;
}
结果如下:
![]() |
如果预约ID不正确,则显示以下内容:
![]() |
2.12.16. URL [/ajouterRv]
URL [/ajouterRv] 由 [RdvMedecinsController] 控制器中的以下方法处理:
@RequestMapping(value = "/ajouterRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
public Reponse ajouterRv(@RequestBody PostAjouterRv post) {
// application status
if (messages != null) {
return new Reponse(-1, messages);
}
// retrieve posted values
String jour = post.getJour();
long idCreneau = post.getIdCreneau();
long idClient = post.getIdClient();
// check the date
Date jourAgenda = null;
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.setLenient(false);
try {
jourAgenda = sdf.parse(jour);
} catch (ParseException e) {
return new Reponse(6, null);
}
// we get the slot back
Reponse réponse = getCreneau(idCreneau);
if (réponse.getStatus() != 0) {
return réponse;
}
Creneau créneau = (Creneau) réponse.getData();
// we get the customer back
réponse = getClient(idClient);
if (réponse.getStatus() != 0) {
réponse.incrStatusBy(2);
return réponse;
}
Client client = (Client) réponse.getData();
// we add the Rv
Rv rv = null;
try {
rv = application.ajouterRv(jourAgenda, créneau, client);
} catch (Exception e1) {
return new Reponse(5, Static.getErreursForException(e1));
}
// we return the answer
return new Reponse(0, Static.getMapForRv(rv));
}
这里的内容我们之前都见过。在第 41 行,我们返回了第 36 行添加的预约。
使用 [Advanced Rest Client] 时,得到的结果如下:
![]() |
或者,例如,如果我们提供一个不存在的槽号,结果如下所示:
![]() |
![]() |
2.12.17. URL [/deleteAppointment]
URL [/deleteAppointment] 由 [RdvMedecinsController] 控制器中的以下方法处理:
@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
public Reponse supprimerRv(@RequestBody PostSupprimerRv post) {
// application status
if (messages != null) {
return new Reponse(-1, messages);
}
// retrieve posted values
long idRv = post.getIdRv();
// recovering the rv
Reponse réponse = getRv(idRv);
if (réponse.getStatus() != 0) {
return réponse;
}
// rv deletion
try {
application.supprimerRv(idRv);
} catch (Exception e1) {
return new Reponse(3, Static.getErreursForException(e1));
}
// ok
return new Reponse(0, null);
}
![]() |
如果预约ID不存在,则返回以下内容:
![]() |
控制器部分已经完成。现在让我们看看如何配置该项目。
2.12.18. Web 服务配置
![]() |
配置类 [AppConfig] 如下所示:
package rdvmedecins.web.config;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Import;
import rdvmedecins.config.DomainAndPersistenceConfig;
@EnableAutoConfiguration
@ComponentScan(basePackages = { "rdvmedecins.web" })
@Import({ DomainAndPersistenceConfig.class })
public class AppConfig {
}
- 第 9 行:我们将模式设置为 [AutoConfiguration],以便 Spring Boot 能根据项目类路径中找到的文件来配置项目;
- 第 10 行:我们指定 Spring 组件应在 [rdvmedecins.web] 包及其子包中进行搜索。通过这种方式,将发现以下组件:
- 位于 [rdvmedecins.web.controllers] 包中的 [@RestController RdvMedecinsController];
- 位于 [rdvmedecins.web.models] 包中的 [@Component ApplicationModel];
- 第 11 行:我们导入 [DomainAndPersistenceConfig] 类,该类配置 [rdvmedecins-metier-dao] 项目以提供对该项目 Bean 的访问;
2.12.19. Web 服务的可执行类
![]() |
[Boot] 类如下所示:
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] 控制器部署到该服务器上。
执行过程中的日志如下:
- 第 17 行:Tomcat 服务器启动;
- 第 23-31 行:[业务逻辑、DAO、JPA] 层初始化;
- 第 34 行:已发现处理 URL [/getRvMedecinJour/{idMedecin}/{jour}] 的方法。此发现控制器方法的过程将重复直至第 44 行;
- 第 52 行:Spring MVC Servlet [DispatcherServlet] 已准备好响应 Web 客户端的请求;
现在我们已拥有一个可被 Web 客户端查询的 Web 服务。接下来我们将处理该服务的安全性:我们希望仅允许特定人员管理医生的预约。为此,我们将使用 Spring 生态系统中的组件——Spring Security 框架。
2.13. Spring Security 简介
我们将再次导入一份 Spring 指南,具体操作如下:
![]() |
![]() |
该项目包括以下内容:
- 在 [templates] 文件夹中,您将找到该项目的 HTML 页面;
- [Application]:是项目的可执行类;
- [MvcConfig]:是 Spring MVC 的配置类;
- [WebSecurityConfig]:是 Spring Security 的配置类;
2.13.1. Maven 配置
项目 [3] 是一个 Maven 项目。让我们查看其 [pom.xml] 文件,了解其依赖项:
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>1.1.1.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
</dependencies>
- 第 1–5 行:该项目是一个 Spring Boot 项目;
- 第 8–11 行:依赖 [Thymeleaf] 框架,该框架支持生成动态 HTML 页面。该框架可替代 JSP(Java Server Pages),后者直到最近仍是 Spring MVC 的默认视图框架;
- 第 12–15 行:引入 Spring Security 框架;
2.13.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>
- [th:xx] 属性是 Thymeleaf 属性。在 HTML 页面发送给客户端之前,Thymeleaf 会对其进行解析。客户端无法看到这些属性;
- 第 12 行:[th:href="@{/hello}"] 属性将生成 <a> 标签的 [href] 属性。值 [@{/hello}] 将生成路径 [<context>/hello],其中 [context] 是 Web 应用程序上下文;
生成的 HTML 代码如下:
- 第 10 行:应用程序上下文是根目录 /;
[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 代码如下:
- 第 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 代码如下:
请注意第 21 行,Thymeleaf 已添加了一个名为 [_csrf] 的隐藏字段。
2.13.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 视图关联起来。该处建立了以下关联:
视图 | |
/templates/home.html | |
/templates/hello.html | |
/templates/login.html |
后缀 [html] 和 [templates] 文件夹是 Thymeleaf 使用的默认值。它们可以通过配置进行更改。 [templates] 文件夹必须位于项目类路径的根目录下:
![]() |
在上文的 [1] 中,[main] 和 [resources] 文件夹均为源文件夹。这意味着它们的内容将位于项目类路径的根目录下。因此,在 [2] 中,[hello] 和 [templates] 文件夹将位于类路径的根目录下。
2.13.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 与访问权限相关联。其中建立了以下关联:
规则 | 代码 | |
无需身份验证的访问 | | |
仅限经过身份验证的访问 |
- 第 15 行:定义身份验证方法。身份验证通过一个对所有人开放的 URL 表单 [/login] 进行 [http.formLogin().loginPage("/login").permitAll()]。注销功能也对所有人开放。
- 第 19-21 行:重新定义管理用户的 [configure(AuthenticationManagerBuilder auth)] 方法;
- 第 20 行:使用硬编码的用户进行身份验证 [auth.inMemoryAuthentication()]。此处通过登录名 [user]、密码 [password] 和角色 [USER] 定义用户。具有相同角色的用户可被授予相同的权限;
2.13.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 受访问权限保护。
2.13.6. 测试应用程序
让我们先请求 URL [/],这是四个有效 URL 之一。它关联的视图是 [/templates/home.html]:
![]() |
请求的 URL [/] 对所有人开放。这就是我们能够获取它的原因。链接 [此处] 如下:
点击该链接时,系统将请求 URL [/hello]。该链接受保护:
规则 | 代码 | |
无需身份验证的访问 | | |
仅限经过身份验证的访问 |
您必须经过身份验证才能访问该页面。随后,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>
2.13.7. 结论
在上一个示例中,我们本可以先编写 Web 应用程序,然后在后续阶段再对其进行安全加固。Spring Security 具有非侵入性。您可以为已经编写好的 Web 应用程序实现安全功能。此外,我们还发现了以下几点:
- 可以定义身份验证页面;
- 身份验证必须伴随 Spring Security 生成的 CSRF 令牌;
- 如果身份验证失败,系统会将您重定向至身份验证页面,且 URL 中会附加一个错误参数;
- 如果认证成功,系统会将您重定向到认证时请求的页面。如果您不经过中间页面而直接请求认证页面,Spring Security 会将您重定向到 URL [/](此情况未演示);
- 您可通过向 URL [/logout] 发送 POST 请求来注销。随后 Spring Security 会将您重定向至认证页面,并在 URL 中包含 "logout" 参数;
以上结论均基于 Spring Security 的默认行为。可通过覆盖 [WebSecurityConfigurerAdapter] 类的特定方法,在配置中修改此行为。
之前的教程对我们后续的工作帮助不大。实际上,我们将使用:
- 一个数据库来存储用户、密码及其角色;
- 基于 HTTP 头部的身份验证;
关于我们要实现的功能,现有的教程寥寥无几。我们将提出的解决方案是整合了从各处收集的代码片段。
2.14. 为在线预约服务实现安全防护
2.14.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] 都必须进行修改:
![]() |
2.14.2. 用于 [业务逻辑、DAO、JPA] 的新 Eclipse 项目
我们将初始项目 [rdvmedecins-business-dao] 复制为 [rdvmedecins-business-dao-v2]:
![]() |
- 在 [1] 中:新项目;
- 在 [2] 中:因实现安全功能而引入的变更已被归入单个包 [rdvmedecins.security]。这些新元素属于 [JPA] 和 [DAO] 层,但为简化起见,我将其归入了一个包。
2.14.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] 表的外键;
2.14.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> {
// list of user roles identified by id
@Query("select ur.role from UserRole ur where ur.user.id=?1")
Iterable<Role> getRoles(long id);
// list of user roles identified by login and password
@Query("select ur.role from UserRole ur where ur.user.login=?1 and ur.user.password=?2")
Iterable<Role> getRoles(String login, String password);
// search for a user via login
User findUserByLogin(String login);
}
- 第 9 行:[UserRepository] 接口继承了 Spring Data 的 [CrudRepository] 接口(第 4 行);
- 第 12-13 行:[getRoles(User user)] 方法根据 [id] 检索指定用户的全部角色
- 第 16-17 行:与上文相同,但针对通过登录名和密码标识的用户;
[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] 接口,而未添加任何新方法;
2.14.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] 接口的类:
![]() |
该接口由以下 [AppUserDetails] 类实现:
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 行);
2.14.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];
测试运行成功,日志如下:
2.14.7. 阶段性结论
在对原始项目进行最小改动的情况下,已添加了 Spring Security 所需的类。总结如下:
- 在 [pom.xml] 文件中添加对 Spring Security 的依赖;
- 在数据库中创建了三个额外表;
- 在 [rdvmedecins.security] 包中创建了 JPA 实体和 Spring 组件;
这种非常理想的方案源于一个事实:即添加到数据库中的这三个表与现有表是相互独立的。 我们甚至可以将它们放置在独立的数据库中。之所以能够这样做,是因为我们认定用户与医生及客户是相互独立的。如果后者被视为潜在用户,我们就必须在 [USERS] 表与 [MEDECINS] 及 [CLIENTS] 表之间建立关联。这将对现有项目产生重大影响。
2.14.8. [web] 层的 Eclipse 项目
![]() |
之前的 [rdvmedecins-webapi] 项目已在 [rdvmedecins-webapi-v2] 项目中复制 [1]:
![]() |
唯一需要修改的是 [rdvmedecins.web.config] 包,其中必须配置 Spring Security。我们已经遇到过一个 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.boot.autoconfigure.EnableAutoConfiguration;
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.crypto.bcrypt.BCryptPasswordEncoder;
import rdvmedecins.security.AppUserDetailsService;
@EnableAutoConfiguration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private AppUserDetailsService appUserDetailsService;
@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();
// the password is transmitted by the header Authorization: Basic xxxx
http.httpBasic();
// only the ADMIN role can use the application
http.authorizeRequests() //
.antMatchers("/", "/**") // all URL
.hasRole("ADMIN");
}
}
- 第 14-15 行:我们复用了示例中的注解;
- 第 17-18 行:注入了 [AppUserDetails] 类,该类提供对应用程序用户的访问;
- 第 20-21 行:[configure(HttpSecurity http)] 方法定义用户及其角色。它将 [AuthenticationManagerBuilder] 类型作为参数。该参数包含两项信息:
- 来自第 18 行的 [appUserDetailsService] 引用,该服务提供对已注册用户的访问。请注意,这里并未明确说明用户存储在数据库中。因此,用户数据也可能存储在缓存中,或由 Web 服务提供等。
- 密码所使用的加密类型。回顾一下,我们使用了 BCrypt 算法;
- 第 27–40 行:[configure(HttpSecurity http)] 方法定义了对 Web 服务 URL 的访问权限;
- 第 30 行:我们在入门项目中看到,默认情况下 Spring Security 会管理一个 CSRF(跨站请求伪造)令牌,希望进行身份验证的用户必须将该令牌发回给服务器。在此处,此机制已被禁用;
- 第 32 行:我们启用了通过 HTTP 头进行身份验证。客户端必须发送以下 HTTP 头:
其中 code 是登录名:密码字符串的 Base64 编码。例如,字符串 admin:admin 的 Base64 编码为 YWRtaW46YWRtaW4=。因此,登录名为 [admin]、密码为 [admin] 的用户将发送以下 HTTP 头进行身份验证:
- 第 34–36 行:表示具有 [ROLE_ADMIN] 角色的用户可以访问 Web 服务的所有 URL。这意味着没有此角色的用户无法访问该 Web 服务;
用于配置整个应用程序的 [AppConfig] 类更新如下:
![]() |
package rdvmedecins.web.config;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Import;
import rdvmedecins.config.DomainAndPersistenceConfig;
@EnableAutoConfiguration
@ComponentScan(basePackages = { "rdvmedecins.web" })
@Import({ DomainAndPersistenceConfig.class, SecurityConfig.class })
public class AppConfig {
}
- 第 11 行进行了修改:它指定现在有两个配置文件可供使用:[DomainAndPersistenceConfig] 和 [SecurityConfig]。
2.14.9. Web 服务测试
我们将使用 Chrome 客户端 [Advanced Rest Client] 测试 Web 服务。我们需要指定 HTTP 身份验证头:
其中 [code] 是 Base64 编码的字符串 [login:password]。要生成此代码,您可以使用以下程序:
![]() |
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 服务。然后,使用 Chrome 客户端 [Advanced Rest Client],我们请求获取所有医生的列表:
![]() |
- 在 [1] 中,我们使用 GET 方法请求医生列表的 URL;
- 在 [2] 中,使用 GET 方法;
- 在 [3] 中,我们提供 HTTP 身份验证头。代码 [YWRtaW46YWRtaW4=] 是字符串 [admin:admin] 的 Base64 编码;
- 在 [4] 中,我们发送 HTTP 请求;
服务器的响应如下:
![]() |
- 在 [1] 中,HTTP 身份验证标头;
- 在 [2] 中,服务器返回一个 JSON 响应;
- 在 [3] 中,医生列表。
现在,让我们尝试发送一个包含错误身份验证标头的 HTTP 请求。响应如下:
![]() |
- 在 [1] 和 [3] 中:HTTP 身份验证标头;
- 在 [2] 中:Web 服务响应;
现在,我们来试一下用户 / user。该用户存在,但无权访问该 Web 服务。如果我们使用两个参数 [user user] 运行 Base64 编码程序:
![]() |
我们得到以下结果:
![]() |
- 在 [1] 和 [3] 中:HTTP 身份验证标头;
- 在 [2] 中:Web 服务响应。它与之前的 [401 未授权] 不同。这次,用户认证成功,但没有足够的权限访问该 URL;
2.15. 结论
让我们回顾一下我们这个客户端/服务器应用程序的整体架构:
![]() |
一个安全的 Web 服务现已投入运行。我们将看到,由于在开发 Angular JS 客户端过程中会出现一些问题,该服务需要进行修改。但我们会等到遇到问题时再解决。现在,我们将构建 Angular 客户端,它将提供一个用于管理医生预约的 Web 界面。

















































































































































