Skip to content

2. Spring 4 服务器

在上述架构中,我们将开始构建基于 Spring 4 框架的 Web 服务 / JSON。我们将分几个步骤编写:

  • 首先是 [métier] 和 [DAO] 层(数据访问对象)。此处我们将使用 Spring Data;
  • 接着是未启用身份验证的 Web 服务 JSON。此处我们将使用 Spring MVC;
  • 随后将使用 Spring Security 添加身份验证功能。

首先,我们将详细说明支撑该应用程序的数据库结构。

2.1. 数据库

后续将称为 [dbrdvmedecins] 的数据库 是一个包含以下表的 MySQL5 数据库:

  

预约由以下表管理:

  • [medecins]:包含诊所医生列表;
  • [clients]:包含诊所的患者列表;
  • [creneaux]:包含每位医生的可用时段;
  • [rv]:包含医生预约列表。

表 [roles]、[users] 和 [users_roles] 是与身份验证相关的表。目前,我们暂不处理这些表。

管理预约的表之间的关系如下:

 
  • 一个时段属于一位医生——一位医生拥有0个或多个时段;
  • 一个预约通过医生的时段将客户与医生联系在一起;
  • 一位客户拥有0个或多个预约;
  • 一个时间段关联 0 个或多个预约(在不同的日期)。

2.1.1. 表 [MEDECINS]

该表包含由应用程序 [RdvMedecins] 管理的医生信息。

  • ID:医生标识号——该表的主键
  • VERSION:表中该行的版本标识号。每次对该行进行修改时,该数字都会增加 1。
  • NOM:医生姓名
  • PRENOM:其名字
  • TITRE:其称谓(小姐、女士、先生)

2.1.2. 表 [CLIENTS]

各医生的患者信息存储在表 [CLIENTS] 中:

  • ID:客户标识号——该表的主键
  • VERSION:标识表中该行版本的编号。每次对该行进行修改时,该数字都会递增1。
  • NOM:客户姓名
  • PRENOM:客户名字
  • TITRE:其称谓(小姐、女士、先生)

2.1.3. 表 [CRENEAUX]

该表列出了可进行 RV 操作的时间段:

  • ID:时间段标识号——该表的主键(第8行)
  • VERSION:表中该行的版本标识号。每次对该行进行修改时,该数字会递增1。
  • ID_MEDECIN:标识该时段所属医生的编号——MEDECINS(ID)列的外键。
  • HDEBUT:时段开始时间
  • MDEBUT:时段开始分钟
  • HFIN:时段结束时间
  • MFIN:时段结束分钟

例如,表 [CRENEAUX] 的第二行(参见上文 [1])表明,第 2 号时段于 8 点 20 分开始,8 点 40 分结束,归属于第 1 号医生 (玛丽女士 PELISSIER)。

2.1.4. 表 [RV]

该表列出了每位医生所分配的 RV:

  • ID:唯一标识RV的编号——主键
  • JOUR:RV的日期
  • ID_CRENEAU:RV的时间段——表[CRENEAUX]中字段[ID]的外键——同时确定时间段和相关医生。
  • ID_CLIENT:预订对象的客户编号——作为表[CLIENTS]中字段[ID]的外键

该表的 对关联列(JOUR、ID_CRENEAU)的值施加了唯一性约束:

ALTER TABLE RV ADD CONSTRAINT UNQ1_RV UNIQUE (JOUR, ID_CRENEAU);

如果表[RV]中某行具有值 (JOUR1, ID_CRENEAU1) 作为列值(JOUR, ID_CRENEAU),则该值在其他任何地方都不能出现。 否则,这意味着同一时间针对同一位医生生成了两个 RV。从 Java 编程的角度来看,当这种情况发生时,数据库中的 JDBC 驱动程序会触发一个 SQLException

id 行值为 3(参见上文的 [1]),表示 2006 年 8 月 23 日为第 20 个时段和第 4 号客户预订了一个 RV。 [CRENEAUX]表显示,第20号时段对应16:20-16:40的时间段,由第1号医生(Marie PELISSIER女士)负责。 表 [CLIENTS] 显示,第 4 号客户是布里吉特·BISTROU 小姐。

2.2. Spring Data 入门

我们将使用 Spring 生态系统中的一个分支——Spring Data,来实现该项目的 [DAO] 层。

Spring 官网上有许多关于 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>
        <!-- 所有操作均使用 UTF-8 -->
        <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 行:定义了对 SGBD 和 H2 的依赖,这些依赖用于创建和管理内存数据库。

让我们看看这些依赖引入的类:

这些类数量众多:

  • 部分属于 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 实现将使用所用 SGBD 特有的主键生成方法,此处为 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行)。它继承了Spring Data的接口[CrudRepository](第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 持久化到数据库中。它使用 SGBD 赋予的主键 id 将实体持久化。该方法还支持根据主键 id 更新实体 T。 具体执行哪种操作取决于主键 id 的值:若 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 持久化查询语言)查询来实现它:

select t from T t where t.something=:value

因此,类型 T 必须包含一个名为 [something] 的字段。这样,方法

List<Customer> findByLastName(String lastName);

将通过类似以下代码实现:

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. [console] 层

  

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

        // 保存部分客户
        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"));

        // 获取所有客户
        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();

        // 通过 ID 获取单个客户
        Customer customer = repository.findOne(1L);
        System.out.println("Customer found with findOne(1L):");
        System.out.println("--------------------------------");
        System.out.println(customer);
        System.out.println();

        // 按姓氏检索客户
        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。需要提醒的是,当使用 SGBD 时,必须定义各种 Spring Bean:
    • 一个 [EntityManagerFactory],用于定义要使用的 JPA 实现;
    • 一个 [DataSource],用于定义要使用的数据源,
    • 一个 [TransactionManager],用于定义要使用的事务管理器;

此处未定义任何这些 Bean。

  • 第 11 行:注解 [EnableAutoConfiguration] 源自项目 [Spring Boot](第 5-6 行)。 该注解通过类 [SpringApplication](第 16 行)要求 Spring Boot 根据其类路径中找到的库来配置应用程序。由于 Hibernate 库位于类路径中,因此 Bean [entityManagerFactory] 将使用 Hibernate 进行实现。 由于 SGBD 和 H2 库位于类路径中,因此 [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 行,此时上下文已被关闭。控制台输出结果如下:

.   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v1.0.2.RELEASE)

2014-06-05 16:23:13.877  INFO 11664 --- [           main] hello.Application                        : 在 Gportpers3 上启动应用程序,使用 PID 11664 (D:\Temp\wksSTS\gs-accessing-data-jpa-complete\target\classes 由 ST 在 D:\Temp\wksSTS\gs-accessing-data-jpa-complete 中启动)
2014-06-05 16:23:13.936  INFO 11664 --- [           main] s.c.a.AnnotationConfigApplicationContext : 刷新 org.springframework.context.annotation.AnnotationConfigApplicationContext@331a8fa0: 启动日期 [Thu Jun 05 16:23:13 CEST 2014]; 上下文层次结构的根节点
2014-06-05 16:23:15.424  INFO 11664 --- [           main] j.LocalContainerEntityManagerFactoryBean : 正在为持久化单元“default”构建容器 JPA EntityManagerFactory
2014-06-05 16:23:15.518  INFO 11664 --- [           main] o.hibernate.jpa.internal.util.LogHelper  : HHH000204: 处理中 PersistenceUnitInfo [
    name: default
    ...]
2014-06-05 16:23:15.690  INFO 11664 --- [           main] org.hibernate.Version                    : HHH000412: Hibernate Core {4.3.1.Final}
2014-06-05 16:23:15.692  INFO 11664 --- [           main] org.hibernate.cfg.Environment            : HHH000206: hibernate.properties 未找到
2014-06-05 16:23:15.694  INFO 11664 --- [           main] org.hibernate.cfg.Environment            : HHH000021: 字节码提供程序名称:javassist
2014-06-05 16:23:15.988  INFO 11664 --- [        main] o.hibernate.annotations.common.Version   : HCANN000001: Hibernate Commons Annotations {4.0.4.Final}
2014-06-05 16:23:16.078  INFO 11664 --- [           main] org.hibernate.dialect.Dialect            : HHH000400: 使用方言: org.hibernate.dialect.H2Dialect
2014-06-05 16:23:16.300  INFO 11664 --- [           main] o.h.h.i.ast.ASTQueryTranslatorFactory    : HHH000397: 使用 ASTQueryTranslatorFactory
2014-06-05 16:23:16.613  INFO 11664 --- [           main] org.hibernate.tool.hbm2ddl.SchemaExport  : HHH000227: 正在运行 hbm2ddl 模式导出
Hibernate: drop table customer if exists
Hibernate: create table customer (id bigint generated by default as identity, first_name varchar(255), last_name varchar(255), primary key (id))
2014-06-05 16:23:16.619  INFO 11664 --- [           main] org.hibernate.tool.hbm2ddl.SchemaExport  : HHH000230: 模式导出完成
2014-06-05 16:23:17.074  INFO 11664 --- [           main] o.s.j.e.a.AnnotationMBeanExporter        : 启动时为 JMX 暴露注册 Bean
2014-06-05 16:23:17.094  INFO 11664 --- [           main] hello.Application                        : 应用程序在 3.906 秒内启动(JVM 运行了 5.013 秒)
Hibernate: insert into customer (id, first_name, last_name) values (null, ?, ?)
Hibernate: insert into customer (id, first_name, last_name) values (null, ?, ?)
Hibernate: insert into customer (id, first_name, last_name) values (null, ?, ?)
Hibernate: insert into customer (id, first_name, last_name) values (null, ?, ?)
Hibernate: insert into customer (id, first_name, last_name) values (null, ?, ?)
Hibernate: select customer0_.id as id1_0_, customer0_.first_name as first_na2_0_, customer0_.last_name as last_nam3_0_ from customer customer0_
Customers found with findAll():
-------------------------------
Customer[id=1, firstName='Jack', lastName='Bauer']
Customer[id=2, firstName='Chloe', lastName='O'Brian']
Customer[id=3, firstName='Kim', lastName='Bauer']
Customer[id=4, firstName='David', lastName='Palmer']
Customer[id=5, firstName='Michelle', lastName='Dessler']

Hibernate: select customer0_.id as id1_0_0_, customer0_.first_name as first_na2_0_0_, customer0_.last_name as last_nam3_0_0_ from customer customer0_ where customer0_.id=?
Customer found with findOne(1L):
--------------------------------
Customer[id=1, firstName='Jack', lastName='Bauer']

Hibernate: select customer0_.id as id1_0_, customer0_.first_name as first_na2_0_, customer0_.last_name as last_nam3_0_ from customer customer0_ where customer0_.last_name=?
Customer found with findByLastName('Bauer'):
--------------------------------------------
Customer[id=1, firstName='Jack', lastName='Bauer']
Customer[id=3, firstName='Kim', lastName='Bauer']
2014-06-05 16:23:17.330  INFO 11664 --- [           main] s.c.a.AnnotationConfigApplicationContext : 关闭 org.springframework.context.annotation.AnnotationConfigApplicationContext@331a8fa0: 启动日期 [Thu Jun 05 16:23:13 CEST 2014]; 上下文层次结构的根节点
2014-06-05 16:23:17.332  INFO 11664 --- [           main] o.s.j.e.a.AnnotationMBeanExporter        : 关机时注销 JMX 公开的 Bean
2014-06-05 16:23:17.333  INFO 11664 --- [           main] j.LocalContainerEntityManagerFactoryBean : 正在关闭持久化单元“default”的 JPA EntityManagerFactory
2014-06-05 16:23:17.334  INFO 11664 --- [           main] org.hibernate.tool.hbm2ddl.SchemaExport  : HHH000227: 正在运行 hbm2ddl 模式导出
Hibernate: drop table customer if exists
2014-06-05 16:23:17.336  INFO 11664 --- [           main] org.hibernate.tool.hbm2ddl.SchemaExport  : HHH000230: 模式导出完成
  • 第1-8行:Spring Boot项目的徽标;
  • 第 9 行:执行 [hello.Application] 类;
  • 第 10 行:[AnnotationConfigApplicationContext] 是实现 Spring 的 [ApplicationContext] 接口的类。这是一个 Bean 容器;
  • 第 11 行:Bean [entityManagerFactory] 通过 Spring 类 [LocalContainerEntityManagerFactory] 实现;
  • 第 12 行:出现了 [hibernate]。最终选定的是该实现类 JPA;
  • 第 19 行:Hibernate 方言为 SQL,需与 SGBD 配合使用。 此处的 [H2Dialect] 方言表明,Hibernate 将与 SGBD 和 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事务 -->
        <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 数据库 -->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <version>1.4.178</version>
        </dependency>
        <!-- CommonsDBCP -->
        <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行:SGBD 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
    @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;
    }

    // 提供商 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();
    }

    // 事务管理器
    @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 组件,并在 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 行:配置类 [Config] 现由 Spring 类 [AnnotationConfigApplicationContext] 调用。从第 5 行可以看出,现在已不再依赖 Spring Boot。

运行结果与之前相同。

2.2.6. 创建可执行归档文件

要为该项目创建可执行归档文件,可按以下步骤操作:

  • 在 [1] 中:创建一个执行配置;
  • 在 [2] 中:类型为 [Java Application]
  • 在 [3] 中:指定要执行的项目(使用 Browse 按钮);
  • 在 [4] 中:指定要执行的类;
  • 在 [5] 中:运行配置的名称——可以是任意名称;
  • 在 [6] 中:导出项目;
  • 在 [7] 中:以可执行存档的形式;
  • 在 [8] 中:指定要创建的可执行文件的路径和名称;
  • 在 [9] 中:输入在 [5] 中创建的运行配置名称;

完成上述操作后,在包含可执行存档的文件夹中打开一个控制台:

.....\dist>dir
12/06/2014  09:11        15 104 869 gs-accessing-data-jpa-2.jar

按以下方式执行该归档文件:


.....\dist>java -jar gs-accessing-data-jpa-2.jar

控制台显示的结果如下:

SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder".
SLF4J: Defaulting to no-operation (NOP) logger implementation
SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder 了解更多详情。
juin 12, 2014 9:48:38 AM org.hibernate.ejb.HibernatePersistence logDeprecation
WARN: HHH015016: Encountered a deprecated javax.persistence.spi.PersistenceProvider [org.hibernate.ejb.HibernatePersistence]; use [org.hibernate.jpa.HibernatePersistenceProvider] instead.
juin 12, 2014 9:48:38 AM org.hibernate.jpa.internal.util.LogHelper logPersistenceUnitInformation
INFO: HHH000204: Processing PersistenceUnitInfo [
        name: default
        ...]
juin 12, 2014 9:48:38 AM org.hibernate.Version logVersion
INFO: HHH000412: Hibernate Core {4.3.4.Final}
juin 12, 2014 9:48:38 AM org.hibernate.cfg.Environment <clinit>
INFO: HHH000206: hibernate.properties not found
juin 12, 2014 9:48:38 AM org.hibernate.cfg.Environment buildBytecodeProvider
INFO: HHH000021: Bytecode provider name : javassist
juin 12, 2014 9:48:39 AM org.hibernate.annotations.common.reflection.java.JavaReflectionManager <clinit>
INFO: HCANN000001: Hibernate Commons Annotations {4.0.4.Final}
juin 12, 2014 9:48:39 AM org.hibernate.dialect.Dialect <init>
INFO: HHH000400: Using dialect: org.hibernate.dialect.H2Dialect
juin 12, 2014 9:48:39 AM org.hibernate.hql.internal.ast.ASTQueryTranslatorFactory <init>
INFO: HHH000397: Using ASTQueryTranslatorFactory
juin 12, 2014 9:48:40 AM org.hibernate.tool.hbm2ddl.SchemaUpdate execute
INFO: HHH000228: Running hbm2ddl schema update
juin 12, 2014 9:48:40 AM org.hibernate.tool.hbm2ddl.SchemaUpdate execute
INFO: HHH000102: Fetching database metadata
juin 12, 2014 9:48:40 AM org.hibernate.tool.hbm2ddl.SchemaUpdate execute
INFO: HHH000396: Updating schema
juin 12, 2014 9:48:40 AM org.hibernate.tool.hbm2ddl.DatabaseMetadata getTableMetadata
INFO: HHH000262: Table not found: Customer
juin 12, 2014 9:48:40 AM org.hibernate.tool.hbm2ddl.DatabaseMetadata getTableMetadata
INFO: HHH000262: Table not found: Customer
juin 12, 2014 9:48:40 AM org.hibernate.tool.hbm2ddl.DatabaseMetadata getTableMetadata
INFO: HHH000262: Table not found: Customer
juin 12, 2014 9:48:40 AM org.hibernate.tool.hbm2ddl.SchemaUpdate execute
INFO: HHH000232: Schema update complete
Customers found with findAll():
-------------------------------
Customer[id=1, firstName='Jack', lastName='Bauer']
Customer[id=2, firstName='Chloe', lastName='O'Brian']
Customer[id=3, firstName='Kim', lastName='Bauer']
Customer[id=4, firstName='David', lastName='Palmer']
Customer[id=5, firstName='Michelle', lastName='Dessler']

Customer found with findOne(1L):
--------------------------------
Customer[id=1, firstName='Jack', lastName='Bauer']

Customer found with findByLastName('Bauer'):
--------------------------------------------
Customer[id=1, firstName='Jack', lastName='Bauer']
Customer[id=3, firstName='Kim', lastName='Bauer']

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/> <!-- 从存储库中查找父级 -->
    </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 行:JUnit 测试所需的依赖项(已集成 Spring);

可执行类 [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]:[métier] 层;
  • [rdvmedecins.domain]:由该层操作的实体 [métier];
  • [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>
        <!-- 所有内容均使用 UTF-8 -->
        <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 行:JDBC 驱动程序(基于 SGBD 和 MySQL5);
  • 第 27-34 行:Commons DBCP 连接池;
  • 第 35-38 行:JSON 的 Jackson 管理库;
  • 第 39-43 行:Google 集合管理库;

[spring-boot-starter-parent] 的 1.1.0.RC1 版本使用了以下库的版本:

<activemq.version>5.9.1</activemq.version>
    <aspectj.version>1.8.0</aspectj.version>
    <codahale-metrics.version>3.0.2</codahale-metrics.version>
    <commons-beanutils.version>1.9.1</commons-beanutils.version>
    <commons-collections.version>3.2.1</commons-collections.version>
    <commons-dbcp.version>1.4</commons-dbcp.version>
    <commons-digester.version>2.1</commons-digester.version>
    <commons-pool.version>1.6</commons-pool.version>
    <commons-pool2.version>2.2</commons-pool2.version>
    <crashub.version>1.3.0-beta20</crashub.version>
    <flyway.version>3.0</flyway.version>
    <freemarker.version>2.3.20</freemarker.version>
    <gemfire.version>7.0.2</gemfire.version>
    <gradle.version>1.6</gradle.version>
    <groovy.version>2.3.2</groovy.version>
    <h2.version>1.3.175</h2.version>
    <hamcrest.version>1.3</hamcrest.version>
    <hibernate-entitymanager.version>4.3.1.Final</hibernate-entitymanager.version>
    <hibernate-jpa-api.version>1.0.1.Final</hibernate-jpa-api.version>
    <hibernate-validator.version>5.0.3.Final</hibernate-validator.version>
    <hibernate.version>4.3.1.Final</hibernate.version>
    <hikaricp.version>1.3.8</hikaricp.version>
    <hornetq.version>2.4.1.Final</hornetq.version>
    <hsqldb.version>2.3.2</hsqldb.version>
    <httpasyncclient.version>4.0.1</httpasyncclient.version>
    <httpclient.version>4.3.3</httpclient.version>
    <jackson.version>2.3.3</jackson.version>
    <java.version>1.6</java.version>
    <javassist.version>3.18.1-GA</javassist.version>
    <jedis.version>2.4.1</jedis.version>
    <jetty-jsp.version>2.2.0.v201112011158</jetty-jsp.version>
    <jetty.version>8.1.14.v20131031</jetty.version>
    <joda-time.version>2.3</joda-time.version>
    <jolokia.version>1.2.0</jolokia.version>
    <jstl.version>1.2</jstl.version>
    <junit.version>4.11</junit.version>
    <liquibase.version>3.0.8</liquibase.version>
    <log4j.version>1.2.17</log4j.version>
    <logback.version>1.1.2</logback.version>
    <mockito.version>1.9.5</mockito.version>
    <mongodb.version>2.12.1</mongodb.version>
    <mysql.version>5.1.30</mysql.version>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    <reactor.version>1.1.1.RELEASE</reactor.version>
    <servlet-api.version>3.0.1</servlet-api.version>
    <slf4j.version>1.7.7</slf4j.version>
    <snakeyaml.version>1.13</snakeyaml.version>
    <solr.version>4.7.2</solr.version>
    <spock.version>0.7-groovy-2.0</spock.version>
    <spring-amqp.version>1.3.4.RELEASE</spring-amqp.version>
    <spring-batch.version>3.0.0.RELEASE</spring-batch.version>
    <spring-boot.version>1.1.0.RC1</spring-boot.version>
    <spring-data-releasetrain.version>Dijkstra-RELEASE</spring-data-releasetrain.version>
    <spring-hateoas.version>0.12.0.RELEASE</spring-hateoas.version>
    <spring-integration.version>4.0.2.RELEASE</spring-integration.version>
    <spring-loaded.version>1.2.0.RELEASE</spring-loaded.version>
    <spring-mobile.version>1.1.1.RELEASE</spring-mobile.version>
    <spring-security-jwt.version>1.0.2.RELEASE</spring-security-jwt.version>
    <spring-security.version>3.2.4.RELEASE</spring-security.version>
    <spring-social-facebook.version>1.1.1.RELEASE</spring-social-facebook.version>
    <spring-social-linkedin.version>1.0.1.RELEASE</spring-social-linkedin.version>
    <spring-social-twitter.version>1.1.0.RELEASE</spring-social-twitter.version>
    <spring-social.version>1.1.0.RELEASE</spring-social.version>
    <spring.version>4.0.5.RELEASE</spring.version>
    <thymeleaf-extras-springsecurity3.version>2.1.1.RELEASE</thymeleaf-extras-springsecurity3.version>
    <thymeleaf-layout-dialect.version>1.2.4</thymeleaf-layout-dialect.version>
    <thymeleaf.version>2.1.3.RELEASE</thymeleaf.version>
    <tomcat.version>7.0.54</tomcat.version>
    <velocity-tools.version>2.0</velocity-tools.version>
    <velocity.version>1.7</velocity.version>

2.5. 实体 JPA

实体 JPA 是用于封装数据库表中各行的对象。

  

类 [AbstractEntity] 是实体 [Personne, Creneau, Rv] 的父类。其定义如下:


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

    // 初始化
    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;
    }

    // 获取器和设置器
    ..
}
  • 第 11 行:注解 [@MappedSuperclass] 表示被注解的类是实体 JPA 和 [@Entity] 的父类;
  • 第15-17行:定义了每个实体的主键[id]。正是注释[@Id]将字段[id]设为主键。 注释 [@GeneratedValue(strategy = GenerationType.AUTO)] 表明该主键的值由 SGBD 生成,且未强制指定任何生成模式;
  • 第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标识符,则被视为相等;

实体 [Personne] 是实体 [Medecin] 和 [Client] 的父类:


package rdvmedecins.entities;

import javax.persistence.Column;
import javax.persistence.MappedSuperclass;

@MappedSuperclass
public class Personne extends AbstractEntity {
    private static final long serialVersionUID = 1L;
    // 人员的属性
    @Column(length = 5)
    private String titre;
    @Column(length = 20)
    private String nom;
    @Column(length = 20)
    private String prenom;

    // 默认构造函数
    public Personne() {
    }

    // 带参数的构造函数
    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);
    }

    // 获取器和设置器
    ...
}
  • 第 6 行:注释 [@MappedSuperclass] 表明被注释的类是实体 JPA 和 [@Entity] 的父类;
  • 第10-15行:某人有一个头衔(Melle)、一个名字(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;

    // 默认构造函数
    public Medecin() {
    }

    // 带参数的构造函数
    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 行:关联数据库中的表 [MEDECINS];
  • 第 8 行:实体 [Medecin] 继承自实体 [Personne];

医生实体可按以下方式初始化:

Medecin m=new Medecin("Mr","Paul","Tatou");

此外,若需为其分配标识符和版本号,可写为:

Medecin m=new Medecin("Mr","Paul","Tatou").build(10,1);

其中方法 [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;

    // 默认构造函数
    public Client() {
    }

    // 带参数的构造函数
    public Client(String titre, String nom, String prenom) {
        super(titre, nom, prenom);
    }

    // 身份
    public String toString() {
        return String.format("Client[%s]", super.toString());
    }

}
  • 第 6 行:该类是一个 JPA 实体;
  • 第 7 行:关联数据库中的表 [CLIENTS];
  • 第 8 行:实体 [Client] 派生自实体 [Personne];

实体 [Creneau] 如下:


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;
    // RV 时段的特征
    private int hdebut;
    private int mdebut;
    private int hfin;
    private int mfin;

    // 一个时段与一名医生相关联
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "id_medecin")
    private Medecin medecin;

    // 外键
    @Column(name = "id_medecin", insertable = false, updatable = false)
    private long idMedecin;

    // 默认构造函数
    public Creneau() {
    }

    // 带参数的构造函数
    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);
    }

    // 外键
    public long getIdMedecin() {
        return idMedecin;
    }

    // setter - getter
    ...
}
  • 第 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] 不会随其一同返回。 此模式的优势在于,只有当开发人员明确请求时,才会检索实体 [Medecin]。这样既节省了内存,又提高了性能;
  • 第 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;

    // Rv 的特征
    @Temporal(TemporalType.DATE)
    private Date jour;

    // RV与客户相关联
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "id_client")
    private Client client;

    // RV 与时间段相关联
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "id_creneau")
    private Creneau creneau;

    // 外键
    @Column(name = "id_client", insertable = false, updatable = false)
    private long idClient;
    @Column(name = "id_creneau", insertable = false, updatable = false)
    private long idCreneau;

    // 默认制造商
    public Rv() {
    }

    // 带参数
    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);
    }

    // 外键
    public long getIdCreneau() {
        return idCreneau;
    }

    public long getIdClient() {
        return idClient;
    }

    // 获取器和设置器
...
}
  • 第 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行:外键[idCreneau];

2.6. [DAO]层

我们将使用 Spring Data 实现 [DAO] 层:

  

[DAO] 层通过四个 Spring Data 接口实现:

  • [ClientRepository]:提供对实体 JPA 和 [Client] 的访问;
  • [CreneauRepository]:可访问实体 JPA 和 [Creneau];
  • [MedecinRepository]:可访问实体 JPA [Medecin];
  • [RvRepository]:提供对实体 JPA 和 [Rv] 的访问权限;

接口 [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> {
    // 医生的时间段列表
    @Query("select c from Creneau c where c.medecin.id=?1")
    Iterable<Creneau> getAllCreneaux(long idMedecin);
}
  • 第 8 行:接口 [CreneauRepository] 继承了接口 [CrudRepository] 的方法;
  • 第10-11行:方法[getAllCreneaux]用于获取医生的可用时段;
  • 第11行:参数为医生的标识符。结果是一个以[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 请求:
select rv from Rv rv where rv.creneau.medecin.id=?1 and rv.jour=?2

因为 Rv 类中类型为 [Client] 和 [Creneau] 的字段是在 [FetchType.LAZY] 模式下获取的,这意味着必须显式请求才能获取它们。 这在查询 JPQL 中通过语法 [left join fetch entité] 实现,该语法要求与外键指向的表进行连接,以便检索被引用的实体;

2.7. [métier] 层

  
  • [IMetier] 是 [métier] 层的接口,而 [Metier] 是其实现;
  • [AgendaMedecinJour] 和 [CreneauMedecinJour] 是两个业务实体;

2.7.1. 实体

实体 [CreneauMedecinJour] 将一个时间段与其内可能安排的预约相关联:


package rdvmedecins.domain;

import java.io.Serializable;

import rdvmedecins.entities.Creneau;
import rdvmedecins.entities.Rv;

public class CreneauMedecinJour implements Serializable {

    private static final long serialVersionUID = 1L;
    // 字段
    private Creneau creneau;
    private Rv rv;

    // 构造函数
    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);
    }

    // 获取器和设置器
...
}
  • 第 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;
    // 字段
    private Medecin medecin;
    private Date jour;
    private CreneauMedecinJour[] creneauxMedecinJour;

    // 构造函数
    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());
    }

    // getter 和 setter
...
}
  • 第13行:医生;
  • 第14行:日程表中的日期;
  • 第15行:其有预约或无预约的时间段;

2.7.2. 服务

[métier] 层面的界面如下:


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 {

    // 客户列表
    public List<Client> getAllClients();

    // 医生列表
    public List<Medecin> getAllMedecins();

    // 医生时段列表
    public List<Creneau> getAllCreneaux(long idMedecin);

    // 某医生在特定日期的预约列表
    public List<Rv> getRvMedecinJour(long idMedecin, Date jour);

    // 按ID查找客户
    public Client getClientById(long id);

    // 按ID查找客户
    public Medecin getMedecinById(long id);

    // 根据ID查找预约
    public Rv getRvById(long id);

    // 根据ID查找时间段
    public Creneau getCreneauById(long id);

    // 添加一个 RV
    public Rv ajouterRv(Date jour, Creneau créneau, Client client);

    // 删除一个 RV
    public void supprimerRv(Rv rv);

    // 业务
    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 {

    // 存储库
    @Autowired
    private MedecinRepository medecinRepository;
    @Autowired
    private ClientRepository clientRepository;
    @Autowired
    private CreneauRepository creneauRepository;
    @Autowired
    private RvRepository rvRepository;

    // 接口实现
    @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 管理的组件。组件可以命名,也可以不命名。此组件命名为 [métier];
  • 第 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行的方法是专属于[métier]层面的。将其放置在此处是因为它执行的是业务处理,而不仅仅是简单的数据访问。如果没有这个方法,就没有必要创建[métier]层。 [getAgendaMedecinJour]方法如下:


public AgendaMedecinJour getAgendaMedecinJour(long idMedecin, Date jour) {
        // 医生的时间段列表
        List<Creneau> creneauxHoraires = getAllCreneaux(idMedecin);
        // 该医生同一天的预约列表
        List<Rv> reservations = getRvMedecinJour(idMedecin, jour);
        // 根据已预约的就诊时间创建字典
        Map<Long, Rv> hReservations = new Hashtable<Long, Rv>();
        for (Rv resa : reservations) {
            hReservations.put(resa.getCreneau().getId(), resa);
        }
        // 生成指定日期的日程表
        AgendaMedecinJour agenda = new AgendaMedecinJour();
        // 医生
        agenda.setMedecin(getMedecinById(idMedecin));
        // 日期
        agenda.setJour(jour);
        // 预约时段
        CreneauMedecinJour[] creneauxMedecinJour = new CreneauMedecinJour[creneauxHoraires.size()];
        agenda.setCreneauxMedecinJour(creneauxMedecinJour);
        // 预订时段的填充
        for (int i = 0; i < creneauxHoraires.size(); i++) {
            // 日程表第 i 行
            creneauxMedecinJour[i] = new CreneauMedecinJour();
            // 时间段
            Creneau créneau = creneauxHoraires.get(i);
            long idCreneau = créneau.getId();
            creneauxMedecinJour[i].setCreneau(créneau);
            // 该时段是空闲还是已预订?
            if (hReservations.containsKey(idCreneau)) {
                // 时段已被占用 - 记录预订
                Rv resa = hReservations.get(idCreneau);
                creneauxMedecinJour[i].setRv(resa);
            }
        }
        // 返回结果
        return agenda;
    }

建议读者阅读注释。算法如下:

  • 检索指定医生的所有时间段;
  • 获取该医生在指定日期内的所有预约;
  • 利用这两项信息,即可判断某个时段是空闲还是已预约;

2.8. 项目配置

  

类 [DomainAndPersitenceConfig] 负责配置整个项目:


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 {

    // 数据源 MySQL
    @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;
    }

    // 提供程序 JPA - 如果对 Spring Boot 使用的默认值满意,则无需此项
    // 此处定义该提供程序用于启用/禁用日志SQL
    @Bean
    public JpaVendorAdapter jpaVendorAdapter() {
        HibernateJpaVendorAdapter hibernateJpaVendorAdapter = new HibernateJpaVendorAdapter();
        hibernateJpaVendorAdapter.setShowSql(false);
        hibernateJpaVendorAdapter.setGenerateDdl(false);
        hibernateJpaVendorAdapter.setDatabase(Database.MYSQL);
        return hibernateJpaVendorAdapter;
    }

    // EntityManagerFactory 和 TransactionManager 由 Spring Boot 设置了默认值

}
  • 第 45 行:我们不会定义 [EntityManagerFactory] 和 [TransactionManager] 这两个 Bean。为此,我们将借助 Spring Boot 的 [@EnableAutoConfiguration] 注解(第 17 行);
  • 第 24-32 行:定义数据源 MySQL5。这是一个通常无法被 Spring Boot 自动推断出的 Bean;
  • 第 36-43 行:我们还配置了 JPA 实现,用于将 Hibernate 的 [showSql] 属性设为 false(第 39 行)。默认情况下,该属性为 true;
  • 目前,Spring管理的组件仅包括第25行和第37行的Bean,以及通过自动配置生成的[EntityManagerFactory]和[TransactionManager]这两个Bean。 我们需要添加 [métier] 和 [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. [métier]层的测试

  

类 [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(){
        // 客户显示
        List<Client> clients = métier.getAllClients();
        display("Liste des clients :", clients);
        // 医生列表
        List<Medecin> medecins = métier.getAllMedecins();
        display("Liste des médecins :", medecins);
        // 医生预约时段显示
        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);
        // 某医生在特定日期的预约列表
        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));
        // 添加 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);
        // 验证
        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));
        // 在同一天的同一时段添加一个 RV
        // 应引发异常
        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();
            }
            // 记录错误
            erreur = true;
        }
        // 验证是否发生错误
        Assert.assertTrue(erreur);
        // 列出 RV
        display(String.format("Liste des Rv du médecin %s, le [%s]", médecin, jour), métier.getRvMedecinJour(médecin.getId(), jour));
        // 显示日程表
        AgendaMedecinJour agenda = métier.getAgendaMedecinJour(médecin.getId(), jour);
        System.out.println(agenda);
        Assert.assertEquals(rv, agenda.getCreneauxMedecinJour()[2].getRv());
        // 删除一个 RV
        System.out.println("Suppression du Rv ajouté");
        métier.supprimerRv(rv);
        // 验证
        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));
    }

    // 实用程序方法——显示集合中的元素
    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行:在测试类中注入对[métier]层的引用;
  • 许多测试仅是简单的视觉测试:
    • 第32-33行:客户列表;
    • 第 35-36 行:医生列表;
    • 第 39-40 行:医生预约时段列表;
    • 第 43 行:某位医生的预约列表;
  • 第50行:添加新预约。方法[ajouterRv]会返回该预约并附带额外信息——其主键id
  • 第 53 行:使用该主键在数据库中查询预约;
  • 第54行:验证所查询的预约与检索到的预约是否一致。 需要提醒的是,实体 [Rv] 的方法 [equals] 已被重新定义:如果两个预约的 id 相同,则视为相等。此处表明,新增的预约已成功存入数据库;
  • 第61-73行:尝试第二次添加同一条预约。由于存在唯一性约束,此操作应被SGBD拒绝:

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 行表明,[JOUR, ID_CRENEAU] 组合必须是唯一的,这阻止了在同一天的同一时间段内添加两个约会。

  • 第73行:验证是否确实发生了异常;
  • 第77行:查询刚刚为其添加了预约的医生的日程表;
  • 第79行:验证所添加的预约是否已正确显示在该医生的日程表中;
  • 第82行:删除已添加的预约;
  • 第84行:从数据库中检索被删除的预约;
  • 第85行:验证是否获取到了针null,这表明所查找的预约不存在;

测试执行成功:

 

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 {
    // 启动
    public static void main(String[] args) {
        // 准备配置
        SpringApplication app = new SpringApplication(DomainAndPersistenceConfig.class);
        app.setLogStartupInfo(false);
        // 启动
        ConfigurableApplicationContext context = app.run(args);
        // 业务
        IMetier métier = context.getBean(IMetier.class);
        try {
            // 添加一个 RV
            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));
            // 验证
            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());
        }
        // 关闭 Spring 上下文
        context.close();
    }

    // 辅助方法 - 显示集合中的元素
    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 的引用。因此,这是一个指向 [métier] 层的引用;
  • 第 27-31 行:为第 1 号客户在第 1 个时段内添加一个新的今日预约。客户和时段是专门创建的,旨在说明仅使用标识符。此处初始化了版本号,但其实可以填入任意值。该版本号在此处未被使用;
  • 第34行:需要查询拥有第1号时段的医生。为此,需从数据库中检索第1号时段。由于当前处于[FetchType.LAZY]模式,系统不会随时段一并返回医生信息。 不过,我们在实体 [Creneau] 中特意预留了一个字段 [idMedecin],用于获取医生的主键;
  • 第35行:获取医生的主表;
  • 第36行:显示该医生的预约列表;

控制台输出结果如下:

1
2
3
4
Ajout d'un Rv le [10/06/2014] dans le créneau 1 pour le client 1
Rv ajouté = Rv[113, Tue Jun 10 16:51:01 CEST 2014, 1, 1]
Liste des rendez-vous
Rv[113, 2014-06-10, 1, 1]

2.11. Spring 入门 MVC

现在我们开始构建Web层。该层主要由处理特定URL请求并以JSON(JavaScript对象表示法)格式返回一行文本的方法组成。 该Web层是一个Web接口,有时也被称为Web应用。我们将使用Spring Web(Spring生态系统的另一个分支)来实现该接口。首先,我们来研究一份在[http://spring.io]上找到的指南。

2.11.1. 演示项目

  • 在 [1] 中,我们导入了一个 Spring 指南;
  • 在 [2] 中,我们选择示例 [Rest Service];
  • 在 [3] 中,我们选择 Maven 项目;
  • 在 [4] 中,我们选用指南的最终版本;
  • 在 [5] 中,进行确认;
  • 在 [6] 中,导入该项目;

通过标准 URL 访问并返回文本 JSON 的 Web 服务通常被称为 REST 服务(REpresentational 状态传递)。 在本文中,我将把我们要构建的服务简称为 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 架构模型(模型 – 视图 – 控制器):

客户端请求的处理流程如下:

  1. 请求 - 请求的 URL 采用 http://machine:port/contexte/Action/param1/param2/....?p1=v1&p2=v2&... 格式[Dispatcher Servlet] 是 Spring 中的类,负责处理传入的 URL。 它将 URL “路由”到负责处理它的操作。这些操作是名为 [Contrôleurs] 的特定类的方法。此处 MVC 的 C 部分是字符串 [Dispatcher Servlet, Contrôleur, Action]。 如果未配置任何操作来处理传入的 URL,则 [Dispatcher Servlet] Servlet 将响应称未找到所请求的 URL (404 错误 NOT FOUND);
  1. 处理
  • 所选操作可利用 parami 参数,这些参数由 [Dispatcher Servlet] Servlet 传递而来。这些参数可能来自多个来源:
    • [/param1/param2/...] 的路径,
    • 来自 URL 的 [p1=v1&p2=v2] 参数
    • 浏览器随请求发送的参数;
  • 在处理用户请求时,该操作可能需要 [metier] 和 [2b] 层。一旦处理了客户端的请求,该操作可能会触发各种响应。一个典型的例子是:
    • 如果请求无法正确处理,则返回错误页面
    • 否则显示确认页面
  • 该操作会要求显示某个视图 [3]。该视图将显示被称为视图模型的数据。这就是 MVC 中的 M。 该操作将创建视图模型 M [2c],并要求显示视图 V [3];
  1. 响应——选定的视图 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,此处为 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":"文本"}。最终,由控制器方法生成的 JSON 字符串将呈现如下形式:

{"id":2,"content":"Hello, World!"}

{"id":2,"content":"Hello, John!"}

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 组件。 将找到一个组件,即带有注解 [@Controller] 的类 [GreetingController],该注解使其成为 Spring 组件;

2.11.7. 运行项目

运行项目:

 

将获得以下控制台日志:

____ _ __ _ _

 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v1.1.0.RELEASE)

2014-06-11 14:31:36.435  INFO 11744 --- [           main] hello.Application                        : Starting Application on Gportpers3 with PID 11744 (D:\Temp\wksSTS\gs-rest-service-complete\target\classes started by ST in D:\Temp\wksSTS\gs-rest-service-complete)
2014-06-11 14:31:36.473  INFO 11744 --- [           main] ationConfigEmbeddedWebApplicationContext : Refreshing org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@7684af0b: startup date [Wed Jun 11 14:31:36 CEST 2014]; root of context hierarchy
2014-06-11 14:31:36.966  INFO 11744 --- [           main] o.s.b.f.s.DefaultListableBeanFactory     : Overriding bean definition for bean 'beanNameViewResolver': replacing [Root bean: class [null]; scope=; abstract=false; lazyInit=false; autowireMode=3; dependencyCheck=0; autowireCandidate=true; primary=false; factoryBeanName=org.springframework.boot.autoconfigure.web.ErrorMvcAutoConfiguration$WhitelabelErrorViewConfiguration; factoryMethodName=beanNameViewResolver; initMethodName=null; destroyMethodName=(inferred); defined in class path resource [org/springframework/boot/autoconfigure/web/ErrorMvcAutoConfiguration$WhitelabelErrorViewConfiguration.class]] with [Root bean: class [null]; scope=; abstract=false; lazyInit=false; autowireMode=3; dependencyCheck=0; autowireCandidate=true; primary=false; factoryBeanName=org.springframework.boot.autoconfigure.web.WebMvcAutoConfiguration$WebMvcAutoConfigurationAdapter; factoryMethodName=beanNameViewResolver; initMethodName=null; destroyMethodName=(inferred); defined in class path resource [org/springframework/boot/autoconfigure/web/WebMvcAutoConfiguration$WebMvcAutoConfigurationAdapter.class]]
2014-06-11 14:31:37.760  INFO 11744 --- [           main] .t.TomcatEmbeddedServletContainerFactory : Server initialized with port: 8080
2014-06-11 14:31:37.955  INFO 11744 --- [           main] o.apache.catalina.core.StandardService   : Starting service Tomcat
2014-06-11 14:31:37.956  INFO 11744 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet Engine: Apache Tomcat/7.0.54
2014-06-11 14:31:38.053  INFO 11744 --- [ost-startStop-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2014-06-11 14:31:38.054  INFO 11744 --- [ost-startStop-1] o.s.web.context.ContextLoader            : Root WebApplicationContext: initialization completed in 1584 ms
2014-06-11 14:31:38.596  INFO 11744 --- [ost-startStop-1] o.s.b.c.e.ServletRegistrationBean        : Mapping servlet: 'dispatcherServlet' to [/]
2014-06-11 14:31:38.598  INFO 11744 --- [ost-startStop-1] o.s.b.c.embedded.FilterRegistrationBean  : Mapping filter: 'hiddenHttpMethodFilter' to: [/*]
2014-06-11 14:31:38.919  INFO 11744 --- [           main] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped URL path [/**/favicon.ico] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
2014-06-11 14:31:39.125  INFO 11744 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/greeting],methods=[],params=[],headers=[],consumes=[],produces=[],custom=[]}" onto public hello.Greeting hello.GreetingController.greeting(java.lang.String)
2014-06-11 14:31:39.129  INFO 11744 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/error],methods=[],params=[],headers=[],consumes=[],produces=[],custom=[]}" onto public org.springframework.http.ResponseEntity<java.util.Map<java.lang.String, java.lang.Object>> org.springframework.boot.autoconfigure.web.BasicErrorController.error(javax.servlet.http.HttpServletRequest)
2014-06-11 14:31:39.130  INFO 11744 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/error],methods=[],params=[],headers=[],consumes=[],produces=[text/html],custom=[]}" onto public org.springframework.web.servlet.ModelAndView org.springframework.boot.autoconfigure.web.BasicErrorController.errorHtml(javax.servlet.http.HttpServletRequest)
2014-06-11 14:31:39.160  INFO 11744 --- [           main] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped URL path [/**] 类型为 [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler] 的处理程序
2014-06-11 14:31:39.160  INFO 11744 --- [           main] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped URL path [/webjars/**] 类型为 QZXW2HTML 的处理程序 BW2NsYXNzIG9yZy5zcHJpbmdmcmFtZXdvcmsud2ViLnNlcnZsZXQucmVzb3VyY2UuUmVzb3VyY2VIdHRwUmVxdWVzdEhhbmRsZXJdZQX
2014-06-11 14:31:39.448  INFO 11744 --- [           main] o.s.j.e.a.AnnotationMBeanExporter        : Registering beans for JMX exposure on startup
2014-06-11 14:31:39.490  INFO 11744 --- [           main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat started on port(s): 8080/http
2014-06-11 14:31:39.492  INFO 11744 --- [           main] hello.Application                        : Started Application in 3.45 seconds (JVM running for 3.93)
  • 第 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] 中:有两个目标(goals):[clean] 用于删除 Maven 项目中的 [target] 文件夹,[package] 用于重新生成该文件夹;
  • 在 [3] 中:生成的 [target] 文件夹将位于此文件夹内;
  • 在 [4] 中:生成目标;

在控制台显示的日志中,务必确认是否出现了插件 [spring-boot-maven-plugin]。正是它负责生成可执行压缩包。

[INFO] --- spring-boot-maven-plugin:1.1.0.RELEASE:repackage (default) @ gs-rest-service ---

在控制台中,进入生成的文件夹:

1
2
3
4
5
6
7
8
9
D:\Temp\wksSTS\gs-rest-service-complete\target>dir
 ...
11/06/2014  15:30    <DIR>          classes
11/06/2014  15:30    <DIR>          generated-sources
11/06/2014  15:30        11 073 572 gs-rest-service-0.1.0.jar
11/06/2014  15:30             3 690 gs-rest-service-0.1.0.jar.original
11/06/2014  15:30    <DIR>          maven-archiver
11/06/2014  15:30    <DIR>          maven-status
...
  • 第 5 行:生成的压缩包;

该可执行包的运行方式如下:

D:\Temp\wksSTS\gs-rest-service-complete\target>java -jar gs-rest-service-0.1.0.jar

  .   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v1.1.0.RELEASE)

2014-06-11 15:32:47.088  INFO 4972 --- [           main] hello.Application
                  : Starting Application on Gportpers3 with PID 4972 (D:\Temp\wk
sSTS\gs-rest-service-complete\target\gs-rest-service-0.1.0.jar started by ST in
D:\Temp\wksSTS\gs-rest-service-complete\target)
...

现在 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 [Cross-origin resource sharing (CORS) is a mechanism that allows many resources (e.g. fonts, JavaScript, etc.) on a web page to be requested from another domain outside the domain the resource originated from. (Wikipedia)]。我们的 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 行:[métier, DAO, JPA] 层级项目的依赖项;

2.12.2. Web 服务接口

  • 在 [1] 中,如上所述,浏览器只能请求数量有限的 URL,且必须采用特定语法;
  • 在 [4] 中,它收到一个 JSON 响应;

我们Web服务的响应都将采用相同的形式,对应于将以下[Reponse]类型对象转换为JSON的过程:


package rdvmedecins.web.models;

public class Reponse {

    // ----------------- 属性
    // 操作状态
    private int status;
    // 响应JSON
    private Object data;

    // ---------------构造函数
    public Reponse() {
    }

    public Reponse(int status, Object data) {
        this.status = status;
        this.data = data;
    }

    // 方法
    public void incrStatusBy(int increment) {
        status += increment;
    }

    // ----------------------获取器和设置器
...
}
  • 第 7 行:响应错误代码 0:OK,其他情况:KO;
  • 第 9 行:响应正文;

现在我们展示一些截图,以说明 Web 服务的界面 / JSON:

[/getAllClients] 诊所所有患者列表

[/getAllMedecins] 诊所所有医生的列表

某位医生的预约时段列表 [/getAllCreneaux/{idMedecin}]

某位医生的预约列表 [/getRvMedecinJour/{idMedecin}/{yyyy-mm-dd}

医生日程表 [/getAgendaMedecinJour/{idMedecin}/{aaaa-mm-jj}]

要添加/删除预约,我们使用 Chrome 扩展程序 [Advanced Rest Client],因为这些操作需要通过 POST 完成。

添加预约 [/ajouterRv]

  • 在 [0] 中,使用 Web 服务的 URL;
  • 在 [1] 中,使用 POST 方法;
  • 在 [2] 中,以 {day, JSON, idCreneau} 形式传输给 Web 服务的文本为 JSON;
  • 在 [3] 中,客户端告知 Web 服务,其发送的信息格式为 JSON;

响应如下:

  • 在 [4] 中:客户端发送的头部信息表明其发送的数据格式为 JSON;
  • 变为 [5]:Web 服务响应称其同样发送 JSON;
  • 在 [6] 中:Web 服务的响应为 JSON。字段 [data] 包含新增约会的 JSON 格式;

可验证新预约是否存在:

删除预约 [/supprimerRv]

  • 在 [1] 中,使用 Web 服务的 URL;
  • 在 [2] 中,使用 POST 方法;
  • 在 [3] 中,以 {idRv} 形式传输给 Web 服务的 JSON 文本;
  • 在 [4] 中,客户端告知 Web 服务将向其发送 JSON 信息;

响应如下:

  • 在 [5] 中:字段 [status] 的值为 0,表明操作成功;

可验证预约已删除:

如上所示,患者 [Mme GERMAN] 的预约已不存在。

该 Web 服务还支持通过标识符检索实体:

所有这些 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() {
        // 应用程序错误消息
        messages = application.getMessages();
    }

    // 医生列表
    @RequestMapping(value = "/getAllMedecins", method = RequestMethod.GET)
    public Reponse getAllMedecins() {
...
    }

    // 客户列表
    @RequestMapping(value = "/getAllClients", method = RequestMethod.GET)
    public Reponse getAllClients() {
...
    }

    // 医生预约时段列表
    @RequestMapping(value = "/getAllCreneaux/{idMedecin}", method = RequestMethod.GET)
    public Reponse getAllCreneaux(@PathVariable("idMedecin") long idMedecin) {
...
    }

    // 医生预约列表
    @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注入的对象已可用;
  • 所有方法返回如下 [Reponse] 类型的对象:

package rdvmedecins.web.models;

public class Reponse {

    // ----------------- 属性
    // 操作状态
    private int status;
    // 响应
    private Object data;
...
}

该对象在发送至客户端浏览器前会被序列化为 JSON;

  • 第 20 行:注解 [@RequestMapping] 规定了方法的调用条件。此处该方法处理来自 URL 的 GET 请求。 如果该 URL 请求是由 POST 发起的,则会被拒绝,Spring MVC 会向 Web 客户端发送 HTTP 错误代码;
  • 第 32 行:URL 由 {idMedecin} 进行配置。该参数通过第 33 行的注解 [@PathVariable] 获取;
  • 第 33 行:唯一参数 [long idMedecin] 的值来自 URL 中的 {idMedecin} 参数; URL 中的参数与方法中的参数可能具有不同的名称。 这里需要注意的是,[@PathVariable("idMedecin")] 的类型为 String(整个 URL 都是 String),而参数 [long idMedecin] 的类型为 [long]。类型转换是自动进行的。 如果类型转换失败,将返回错误代码 HTTP;
  • 第 65 行:注释 [@RequestBody] 指代请求正文。 在 GET 请求中,几乎从不包含请求主体(但可以添加)。而在 POST 请求中,通常包含请求主体(但也可以不包含)。 对于 URL 和 [ajouterRv],Web 客户端在其 POST 中发送以下字符串:
{"jour":"2014-06-12", "idClient":3, "idCreneau":7}

[@RequestBody PostAjouterRv post](第 65 行)的语法 加上该方法期望第64行的JSON [consumes = "application/json; charset=UTF-8"],将导致Web客户端发送的字符串JSON被反序列化为[PostAjouter]类型的对象。 该对象如下所示:


package rdvmedecins.web.models;

public class PostAjouterRv {

    // 帖子数据
    private String jour;
    private long idClient;
    private long idCreneau;

    // 获取器和设置器
    ...
}

在此处,必要的类型转换也会自动进行;

  • 第 69-70 行中,针对 URL 和 [/supprimerRv] 采用了类似的机制。发送的字符串 JSON 如下:
{"idRv":116}

而类型 [PostSupprimerRv] 如下:


package rdvmedecins.web.models;

public class PostSupprimerRv {

    // POST数据
    private long idRv;

    // 获取器和设置器
    ...
}

2.12.4. Web 服务的模板

  

我们之前已经介绍过型号为[Reponse, PostAjouterRv, PostSupprimerRv]的产品。型号为[ApplicationModel]的产品如下:


package rdvmedecins.web.models;

import java.util.Date;
...

@Component
public class ApplicationModel implements IMetier {

    // [métier] 层
    @Autowired
    private IMetier métier;

    // 来自 [métier] 层的数据
    private List<Medecin> médecins;
    private List<Client> clients;
    // 错误消息
   private List<String> messages;

    @PostConstruct
    public void init() {
        // 获取医生和客户
        try {
            médecins = métier.getAllMedecins();
            clients = métier.getAllClients();
        } catch (Exception ex) {
            messages = Static.getErreursForException(ex);
        }
    }

    // getter
    public List<String> getMessages() {
        return messages;
    }

    // ------------------------- [métier] 层接口
    @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 注入了一个指向 [métier] 层的引用;
  • 第 19 行:注解 [@PostConstruct] 确保在 [ApplicationModel] 类实例化后立即执行方法 [init];
  • 第 23-24 行:从 [métier] 层获取医生和客户列表;
  • 第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() {
    }

    // 异常的错误消息列表
    public static List<String> getErreursForException(Exception exception) {
        // 获取异常的错误消息列表
        Throwable cause = exception;
        List<String> erreurs = new ArrayList<String>();
        while (cause != null) {
            erreurs.add(cause.getMessage());
            cause = cause.getCause();
        }
        return erreurs;
    }

    // Object --> Map 映射器
    // --------------------------------------------------------
....
}
  • 第 12 行:方法 [Static.getErreursForException],该方法在类 [ApplicationModel] 的方法 [init] 中被调用(见下文第 8 行):

    @PostConstruct
    public void init() {
        // 获取医生和客户
        try {
            médecins = métier.getAllMedecins();
            clients = métier.getAllClients();
        } catch (Exception ex) {
            messages = Static.getErreursForException(ex);
        }
}

该方法使用异常 [exception] 及其包含的异常 [exception.getCause()] 的错误消息,构建一个 [List<String>] 对象。

[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() {
        // 应用程序的错误信息
        messages = application.getMessages();
}
  • 第 8 行:存储在 [ApplicationModel] 缓存应用程序中的错误消息被保存在第 3 行的字段中。这将使方法能够判断应用程序是否已正确初始化。

2.12.7. URL [/getAllMedecins]

URL [/getAllMedecins] 由控制器 [RdvMedecinsController] 的以下方法处理:


    // 医生列表
    @RequestMapping(value = "/getAllMedecins", method = RequestMethod.GET)
    public Reponse getAllMedecins() {
        // 应用程序状态
        if (messages != null) {
            return new Reponse(-1, messages);
        }
        // 医生列表
        try {
            return new Reponse(0, application.getAllMedecins());
        } catch (Exception e) {
            return new Reponse(1, Static.getErreursForException(e));
        }
}
  • 第 5 行:检查应用程序是否已正确初始化(messages==null)。若未初始化,则返回包含 status=-1 data=messages 的响应;
  • 第10行:否则,返回医生列表,此时 status 设为 0。方法 [application.getAllMedecins()] 不会抛出异常,因为它仅返回缓存中的列表。不过,为了应对医生信息不再被缓存的情况,我们仍保留此异常处理机制;

我们尚未说明应用程序初始化失败的情况。 现在停止 SGBD 和 MySQL5,启动 Web 服务,然后调用 URL 和 [/getAllMedecins]:

Image

确实会出现错误。在正常情况下,会显示以下视图:

2.12.8. URL [/getAllClients]

URL [/getAllClients] 由控制器 [RdvMedecinsController] 的以下方法处理:


    // 客户列表
    @RequestMapping(value = "/getAllClients")
    public Reponse getAllClients() {
        // 应用程序状态
        if (messages != null) {
            return new Reponse(-1, messages);
        }
        // 客户列表
        try {
            return new Reponse(0, application.getAllClients());
        } catch (Exception e) {
            return new Reponse(1, Static.getErreursForException(e));
        }
}

该方法与先前研究的 [getAllMedecins] 方法类似。所得结果如下:

2.12.9. URL [/getAllCreneaux/{idMedecin}]

URL [/getAllCreneaux/{idMedecin}] 由控制器 [RdvMedecinsController] 的以下方法处理:


// 医生预约时段列表
    @RequestMapping(value = "/getAllCreneaux/{idMedecin}", method = RequestMethod.GET)
    public Reponse getAllCreneaux(@PathVariable("idMedecin") long idMedecin) {
        // 应用程序状态
        if (messages != null) {
            return new Reponse(-1, messages);
        }
        // 获取医生
        Reponse réponse = getMedecin(idMedecin);
        if (réponse.getStatus() != 0) {
            return réponse;
        }
        Medecin médecin = (Medecin) réponse.getData();
        // 医生排班
        List<Creneau> créneaux = null;
        try {
            créneaux = application.getAllCreneaux(médecin.getId());
        } catch (Exception e1) {
            return new Reponse(3, Static.getErreursForException(e1));
        }
        // 返回响应
        return new Reponse(0, Static.getListMapForCreneaux(créneaux));
    }
  • 第 9 行:通过参数 [id] 标识的医生被调用至一个本地方法:

    private Reponse getMedecin(long id) {
        // 获取医生
        Medecin médecin = null;
        try {
            médecin = application.getMedecinById(id);
        } catch (Exception e1) {
            return new Reponse(1, Static.getErreursForException(e1));
        }
        // 已有医生?
        if (médecin == null) {
            return new Reponse(2, null);
        }
        // 好的
        return new Reponse(0, médecin);
}

该方法返回时,将 status 赋值给 [0,1,2]。让我们回到 [getAllCreneaux] 方法的代码:

  • 第 10-12 行:如果status!=0,则立即返回响应;
  • 第13行:获取该医生;
  • 第17行:获取该医生的预约时段;
  • 第22行:返回一个[Static.getListMapForCreneaux(créneaux)]对象;

回顾类 [Creneau] 的定义:


@Entity
@Table(name = "creneaux")
public class Creneau extends AbstractEntity {

    private static final long serialVersionUID = 1L;
    // RV 时段的特征
    private int hdebut;
    private int mdebut;
    private int hfin;
    private int mfin;

    // 一个时段与一位医生相关联
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "id_medecin")
    private Medecin medecin;

    // 外键
    @Column(name = "id_medecin", insertable = false, updatable = false)
    private long idMedecin;
...
}
  • 第13行:以[FetchType.LAZY]模式查询医生;

回顾实现 [getAllCreneaux] 方法的 JPQL 请求(位于 [DAO] 层):


@Query("select c from Creneau c where c.medecin.id=?1")

[c.medecin.id] 这种命名方式强制要求对 [CRENEAUX] 和 [MEDECINS] 这两张表进行连接。 因此,该查询会返回该医生的所有时段,且每个时段中都包含该医生。当将这些时段序列化为 JSON 时,每个时段中都会出现该医生的字符串 JSON。这是多余的。 因此,与其序列化一个 [Creneau] 对象,不如序列化一个 [Map] 对象,并在其中仅包含所需的字段。

让我们回到最初研究的代码:


// 返回响应
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) {
        // <String,Object> 字典列表
        List<Map<String, Object>> liste = new ArrayList<Map<String, Object>>();
        for (Creneau créneau : créneaux) {
            liste.add(Static.getMapForCreneau(créneau));
        }
        // 返回列表
        return liste;
}

方法 [Static.getMapForCreneau] 如下:


    // Creneau --> Map
    public static Map<String, Object> getMapForCreneau(Creneau créneau) {
        // 需要做些什么?
        if (créneau == null) {
            return null;
        }
        // 字典 <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());
        // 将字典转换为
        return hash;
}
  • 第 8 行:创建一个字典;
  • 第 9-13 行:将需要保留在 JSON 字符串中的字段放入该字典中。字段 [medecin] 未包含其中;
  • 第15行:返回该字典;

所得结果如下:

或者,如果该时段不存在,则为:

或者在访问数据库时出现错误的情况下:

2.12.10. URL [/getRvMedecinJour/{idMedecin}/{jour}]

URL [/getRvMedecinJour/{idMedecin}/{jour}] 由控制器 [RdvMedecinsController] 的以下方法处理:


// 医生的预约列表
    @RequestMapping(value = "/getRvMedecinJour/{idMedecin}/{jour}", method = RequestMethod.GET)
    public Reponse getRvMedecinJour(@PathVariable("idMedecin") long idMedecin, @PathVariable("jour") String jour) {
        // 应用程序状态
        if (messages != null) {
            return new Reponse(-1, messages);
        }
        // 验证日期
        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);
        }
        // 获取医生
        Reponse réponse = getMedecin(idMedecin);
        if (réponse.getStatus() != 0) {
            return réponse;
        }
        Medecin médecin = (Medecin) réponse.getData();
        // 其预约列表
        List<Rv> rvs = null;
        try {
            rvs = application.getRvMedecinJour(médecin.getId(), jourAgenda);
        } catch (Exception e1) {
            return new Reponse(4, Static.getErreursForException(e1));
        }
        // 返回响应
        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;

    // 预约详情
    @Temporal(TemporalType.DATE)
    private Date jour;

    // 预约与客户相关联
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "id_client")
    private Client client;

    // 预约与时段相关联
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "id_creneau")
    private Creneau creneau;

    // 外部键
    @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 --> 地图
    public static Map<String, Object> getMapForRv(Rv rv) {
        // 需要做些什么?
        if (rv == null) {
            return null;
        }
        // <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()));
        // 返回字典
        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) {
        // 应用程序状态
        if (messages != null) {
            return new Reponse(-1, messages);
        }
        // 验证日期
        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) });
        }
        // 获取医生
        Reponse réponse = getMedecin(idMedecin);
        if (réponse.getStatus() != 0) {
            return réponse;
        }
        Medecin médecin = (Medecin) réponse.getData();
        // 获取其日程表
        AgendaMedecinJour agenda = null;
        try {
            agenda = application.getAgendaMedecinJour(médecin.getId(), jourAgenda);
        } catch (Exception e1) {
            return new Reponse(4, Static.getErreursForException(e1));
        }
        // 完成
        return new Reponse(0, Static.getMapForAgendaMedecinJour(agenda));
    }
}
  • 第 30 行,返回一个 List<Map<String,Object>> 类型的对象。

[Static.getMapForAgendaMedecinJour] 方法如下:


    // AgendaMedecinJour --> 地图
    public static Map<String, Object> getMapForAgendaMedecinJour(AgendaMedecinJour agenda) {
        // 有什么需要做的吗?
        if (agenda == null) {
            return null;
        }
        // 字典 <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);
        // 生成字典
        return hash;
}

构建的字典包含三个字段:

  • [medecin]:日程表所属的医生。保留此信息是因为它仅出现一次,而在之前的情况中,该信息在每个字符串中都会重复出现 JSON;
  • [jour]:日程表的日期;
  • [creneauxMedecin]:医生的可用时段列表,其中可能包含该时段内的预约;

第13行使用的[getMapForCreneauMedecinJour]方法如下:


    // CreneauMedecinJour --> 映射
    public static Map<String, Object> getMapForCreneauMedecinJour(CreneauMedecinJour créneau) {
        // 有什么需要做的吗?
        if (créneau == null) {
            return null;
        }
        // 字典 <String,Object>
        Map<String, Object> hash = new HashMap<String, Object>();
        hash.put("creneau", getMapForCreneau(créneau.getCreneau()));
        hash.put("rv", getMapForRv(créneau.getRv()));
        // 返回字典
        return hash;
}
  • 第9-10行:使用已分析过的字典处理类型[Creneau]和[Rv],因此这些类型不包含[Medecin]对象;

所得结果如下:

或者,如果日期有误,则为:

或者,如果医生编号无效,则为:

2.12.12. URL [/getMedecinById/{id}]

URL [/getMedecinById/{id}] 由 [RdvMedecinsController] 控制器通过以下方法处理:


    @RequestMapping(value = "/getMedecinById/{id}", method = RequestMethod.GET)
    public Reponse getMedecinById(@PathVariable("id") long id) {
        // 应用程序状态
        if (messages != null) {
            return new Reponse(-1, messages);
        }
        // 获取医生
        return getMedecin(id);
}

第 8 行,[getMedecin] 方法如下:


    private Reponse getMedecin(long id) {
        // 获取医生
        Medecin médecin = null;
        try {
            médecin = application.getMedecinById(id);
        } catch (Exception e1) {
            return new Reponse(1, Static.getErreursForException(e1));
        }
        // 医生是否存在?
        if (médecin == null) {
            return new Reponse(2, null);
        }
        // 成功
        return new Reponse(0, médecin);
}

所得结果如下:

或者,如果医生编号不正确,则显示以下结果:

2.12.13. URL [/getClientById/{id}]

URL [/getClientById/{id}] 由控制器 [RdvMedecinsController] 通过以下方法处理:


    @RequestMapping(value = "/getClientById/{id}", method = RequestMethod.GET)
    public Reponse getClientById(@PathVariable("id") long id) {
        // 应用程序状态
        if (messages != null) {
            return new Reponse(-1, messages);
        }
        // 正在获取客户
        return getClient(id);
}

第 8 行,[getClient] 方法如下:


    private Reponse getClient(long id) {
        // 正在检索客户
        Client client = null;
        try {
            client = application.getClientById(id);
        } catch (Exception e1) {
            return new Reponse(1, Static.getErreursForException(e1));
        }
        // 客户是否已存在?
        if (client == null) {
            return new Reponse(2, null);
        }
        // ok
        return new Reponse(0, client);
}

所得结果如下:

或者,如果客户编号不正确,则显示如下:

2.12.14. URL [/getCreneauById/{id}]

URL [/getCreneauById/{id}] 由控制器 [RdvMedecinsController] 通过以下方法处理:


    @RequestMapping(value = "/getCreneauById/{id}", method = RequestMethod.GET)
    public Reponse getCreneauById(@PathVariable("id") long id) {
        // 应用程序状态
        if (messages != null) {
            return new Reponse(-1, messages);
        }
        // 正在获取时段
        Reponse réponse = getCreneau(id);
        if (réponse.getStatus() == 0) {
            réponse.setData(Static.getMapForCreneau((Creneau) réponse.getData()));
        }
        // 结果
        return réponse;
}

第 8 行,[getCreneau] 方法如下:


    private Reponse getCreneau(long id) {
        // 正在获取时段
        Creneau créneau = null;
        try {
            créneau = application.getCreneauById(id);
        } catch (Exception e1) {
            return new Reponse(1, Static.getErreursForException(e1));
        }
        // 时段是否已存在?
        if (créneau == null) {
            return new Reponse(2, null);
        }
        // 成功
        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) {
        // 应用程序状态
        if (messages != null) {
            return new Reponse(-1, messages);
        }
        // 获取预约
        Reponse réponse = getRv(id);
        if (réponse.getStatus() == 0) {
            réponse.setData(Static.getMapForRv2((Rv) réponse.getData()));
        }
        // 结果
        return réponse;
}

第 8 行,[getRv] 方法如下:


    private Reponse getRv(long id) {
        // 获取 Rv
        Rv rv = null;
        try {
            rv = application.getRvById(id);
        } catch (Exception e1) {
            return new Reponse(1, Static.getErreursForException(e1));
        }
        // Rv是否已存在?
        if (rv == null) {
            return new Reponse(2, null);
        }
        // 成功
        return new Reponse(0, rv);
}

第 10 行,方法 [Static.getMapForRv2] 如下:


// Rv --> 地图
    public static Map<String, Object> getMapForRv2(Rv rv) {
        // 需要做些什么?
        if (rv == null) {
            return null;
        }
        // <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());
        // 返回字典
        return hash;
    }

所得结果如下:

或者,如果预约编号不正确,则显示如下:

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) {
        // 应用程序状态
        if (messages != null) {
            return new Reponse(-1, messages);
        }
        // 获取提交的值
        String jour = post.getJour();
        long idCreneau = post.getIdCreneau();
        long idClient = post.getIdClient();
        // 验证日期
        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);
        }
        // 获取时间段
        Reponse réponse = getCreneau(idCreneau);
        if (réponse.getStatus() != 0) {
            return réponse;
        }
        Creneau créneau = (Creneau) réponse.getData();
        // 获取客户
        réponse = getClient(idClient);
        if (réponse.getStatus() != 0) {
            réponse.incrStatusBy(2);
            return réponse;
        }
        Client client = (Client) réponse.getData();
        // 添加预约
        Rv rv = null;
        try {
            rv = application.ajouterRv(jourAgenda, créneau, client);
        } catch (Exception e1) {
            return new Reponse(5, Static.getErreursForException(e1));
        }
        // 返回响应
        return new Reponse(0, Static.getMapForRv(rv));
    }

这里没有任何新内容。第41行,返回了第36行添加的约会。

使用客户端 [Advanced Rest Client] 时,所得结果如下:

或者,如果输入一个不存在的时段编号,结果如下:

2.12.17. URL [/supprimerRv]

URL [/supprimerRv] 由控制器 [RdvMedecinsController] 的以下方法处理:


@RequestMapping(value = "/supprimerRv", method = RequestMethod.POST, consumes = "application/json; charset=UTF-8")
    public Reponse supprimerRv(@RequestBody PostSupprimerRv post) {
        // 应用程序状态
        if (messages != null) {
            return new Reponse(-1, messages);
        }
        // 获取提交的值
        long idRv = post.getIdRv();
        // 获取返回值
        Reponse réponse = getRv(idRv);
        if (réponse.getStatus() != 0) {
            return réponse;
        }
        // 删除返回值
        try {
            application.supprimerRv(idRv);
        } catch (Exception e1) {
            return new Reponse(3, Static.getErreursForException(e1));
        }
        // 完成
        return new Reponse(0, null);
    }

所得结果如下:

或者,如果预约编号不存在,则为以下结果:

控制器部分到此结束。接下来我们将了解如何配置该项目。

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 能根据项目 Classpath 中找到的包来配置项目;
  • 第 10 行:要求在 [rdvmedecins.web] 包及其子包中查找 Spring 组件。这样就能发现位于 [rdvmedecins.web.controllers] 包中的组件:
    • [@RestController RdvMedecinsController] 位于 [rdvmedecins.web.controllers] 包中;
    • [@Component ApplicationModel] 位于 [rdvmedecins.web.models] 包中;
  • 第 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]。

执行时的日志如下:

.   ____          _            __ _ _
 /\\ / ___'_ __ _ _(_)_ __  __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
 \\/  ___)| |_)| | | | | || (_| |  ) ) ) )
  '  |____| .__|_| |_|_| |_\__, | / / / /
 =========|_|==============|___/=/_/_/_/
 :: Spring Boot ::        (v1.0.0.RELEASE)

2014-06-12 17:30:41.261  INFO 9388 --- [           main] rdvmedecins.web.boot.Boot                : Starting Boot on Gportpers3 with PID 9388 (D:\data\istia-1314\polys\istia\angularjs-spring4\dvp\rdvmedecins-webapi\target\classes started by ST)
2014-06-12 17:30:41.306  INFO 9388 --- [           main] ationConfigEmbeddedWebApplicationContext : Refreshing org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@a1e932e: startup date [Thu Jun 12 17:30:41 CEST 2014]; root of context hierarchy
2014-06-12 17:30:42.058  INFO 9388 --- [           main] o.s.b.f.s.DefaultListableBeanFactory     : Overriding bean definition for bean 'org.springframework.boot.autoconfigure.AutoConfigurationPackages': replacing [Generic bean: class [org.springframework.boot.autoconfigure.AutoConfigurationPackages$BasePackages]; scope=; abstract=false; lazyInit=false; autowireMode=0; dependencyCheck=0; autowireCandidate=true; primary=false; factoryBeanName=null; factoryMethodName=null; initMethodName=null; destroyMethodName=null] with [Generic bean: class [org.springframework.boot.autoconfigure.AutoConfigurationPackages$BasePackages]; scope=; abstract=false; lazyInit=false; autowireMode=0; dependencyCheck=0; autowireCandidate=true; primary=false; factoryBeanName=null; factoryMethodName=null; initMethodName=null; destroyMethodName=null]
2014-06-12 17:30:42.866  INFO 9388 --- [           main] trationDelegate$BeanPostProcessorChecker : Bean 'org.springframework.transaction.annotation.ProxyTransactionManagementConfiguration' of type [class org.springframework.transaction.annotation.ProxyTransactionManagementConfiguration$$EnhancerBySpringCGLIB$$fd7a7b18] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)
2014-06-12 17:30:42.900  INFO 9388 --- [           main] trationDelegate$BeanPostProcessorChecker : Bean 'transactionAttributeSource' of type [class org.springframework.transaction.annotation.AnnotationTransactionAttributeSource] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)
2014-06-12 17:30:42.915  INFO 9388 --- [           main] trationDelegate$BeanPostProcessorChecker : Bean 'transactionInterceptor' of type [class org.springframework.transaction.interceptor.TransactionInterceptor] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)
2014-06-12 17:30:42.920  INFO 9388 --- [           main] trationDelegate$BeanPostProcessorChecker : Bean 'org.springframework.transaction.config.internalTransactionAdvisor' of type [class org.springframework.transaction.interceptor.BeanFactoryTransactionAttributeSourceAdvisor] is not eligible for getting processed by all BeanPostProcessors (for example: not eligible for auto-proxying)
2014-06-12 17:30:43.164  INFO 9388 --- [           main] .t.TomcatEmbeddedServletContainerFactory : Server initialized with port: 8080
2014-06-12 17:30:43.403  INFO 9388 --- [           main] o.apache.catalina.core.StandardService   : Starting service Tomcat
2014-06-12 17:30:43.403  INFO 9388 --- [           main] org.apache.catalina.core.StandardEngine  : Starting Servlet Engine: Apache Tomcat/7.0.52
2014-06-12 17:30:43.582  INFO 9388 --- [ost-startStop-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring embedded WebApplicationContext
2014-06-12 17:30:43.582  INFO 9388 --- [ost-startStop-1] o.s.web.context.ContextLoader            : Root WebApplicationContext: initialization completed in 2279 ms
2014-06-12 17:30:44.117  INFO 9388 --- [ost-startStop-1] o.s.b.c.e.ServletRegistrationBean        : Mapping servlet: 'dispatcherServlet' to [/]
2014-06-12 17:30:44.119  INFO 9388 --- [ost-startStop-1] o.s.b.c.embedded.FilterRegistrationBean  : Mapping filter: 'hiddenHttpMethodFilter' to: [/*]
2014-06-12 17:30:44.662  INFO 9388 --- [           main] j.LocalContainerEntityManagerFactoryBean : Building JPA container EntityManagerFactory for persistence unit 'default'
2014-06-12 17:30:44.707  INFO 9388 --- [           main] o.hibernate.jpa.internal.util.LogHelper  : HHH000204: Processing PersistenceUnitInfo [
    name: default
    ...]
2014-06-12 17:30:44.839  INFO 9388 --- [           main] org.hibernate.Version                    : HHH000412: Hibernate Core {4.3.1.Final}
2014-06-12 17:30:44.842  INFO 9388 --- [           main] org.hibernate.cfg.Environment            : HHH000206: hibernate.properties not found
2014-06-12 17:30:44.844  INFO 9388 --- [           main] org.hibernate.cfg.Environment            : HHH000021: Bytecode provider name : javassist
2014-06-12 17:30:45.189  INFO 9388 --- [           main] o.hibernate.annotations.common.Version   : HCANN000001: Hibernate Commons Annotations {4.0.4.Final}
2014-06-12 17:30:45.616  INFO 9388 --- [           main] org.hibernate.dialect.Dialect            : HHH000400: Using dialect: org.hibernate.dialect.MySQLDialect
2014-06-12 17:30:45.783  INFO 9388 --- [           main] o.h.h.i.ast.ASTQueryTranslatorFactory    : HHH000397: Using ASTQueryTranslatorFactory
2014-06-12 17:30:46.729  INFO 9388 --- [           main] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped URL path [/**/favicon.ico] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
2014-06-12 17:30:46.825  INFO 9388 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/getRvMedecinJour/{idMedecin}/{jour}],methods=[GET],params=[],headers=[],consumes=[],produces=[],custom=[]}" onto public rdvmedecins.web.models.Reponse rdvmedecins.web.controllers.RdvMedecinsController.getRvMedecinJour(long,java.lang.String)
2014-06-12 17:30:46.826  INFO 9388 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/getAllCreneaux/{idMedecin}],methods=[GET],params=[],headers=[],consumes=[],produces=[],custom=[]}" onto public rdvmedecins.web.models.Reponse rdvmedecins.web.controllers.RdvMedecinsController.getAllCreneaux(long)
2014-06-12 17:30:46.826  INFO 9388 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/getAgendaMedecinJour/{idMedecin}/{jour}],methods=[GET],params=[],headers=[],consumes=[],produces=[],custom=[]}" onto public rdvmedecins.web.models.Reponse rdvmedecins.web.controllers.RdvMedecinsController.getAgendaMedecinJour(long,java.lang.String)
2014-06-12 17:30:46.826  INFO 9388 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/getAllMedecins],methods=[GET],params=[],headers=[],consumes=[],produces=[],custom=[]}" onto public rdvmedecins.web.models.Reponse rdvmedecins.web.controllers.RdvMedecinsController.getAllMedecins()
2014-06-12 17:30:46.826  INFO 9388 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/getMedecinById/{id}],methods=[GET],params=[],headers=[],consumes=[],produces=[],custom=[]}" onto public rdvmedecins.web.models.Reponse rdvmedecins.web.controllers.RdvMedecinsController.getMedecinById(long)
2014-06-12 17:30:46.827  INFO 9388 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/getCreneauById/{id}],methods=[GET],params=[],headers=[],consumes=[],produces=[],custom=[]}" onto public rdvmedecins.web.models.Reponse rdvmedecins.web.controllers.RdvMedecinsController.getCreneauById(long)
2014-06-12 17:30:46.827  INFO 9388 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/getClientById/{id}],methods=[GET],params=[],headers=[],consumes=[],produces=[],custom=[]}" onto public rdvmedecins.web.models.Reponse rdvmedecins.web.controllers.RdvMedecinsController.getClientById(long)
2014-06-12 17:30:46.827  INFO 9388 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/getRvById/{id}],methods=[GET],params=[],headers=[],consumes=[],produces=[],custom=[]}" onto public rdvmedecins.web.models.Reponse rdvmedecins.web.controllers.RdvMedecinsController.getRvById(long)
2014-06-12 17:30:46.827  INFO 9388 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/getAllClients],methods=[],params=[],headers=[],consumes=[],produces=[],custom=[]}" onto public rdvmedecins.web.models.Reponse rdvmedecins.web.controllers.RdvMedecinsController.getAllClients()
2014-06-12 17:30:46.827  INFO 9388 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/ajouterRv],methods=[POST],params=[],headers=[],consumes=[application/json;charset=UTF-8],produces=[],custom=[]}" onto public rdvmedecins.web.models.Reponse rdvmedecins.web.controllers.RdvMedecinsController.ajouterRv(rdvmedecins.web.models.PostAjouterRv)
2014-06-12 17:30:46.828  INFO 9388 --- [           main] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped "{[/supprimerRv],methods=[POST],params=[],headers=[],consumes=[application/json;charset=UTF-8],produces=[],custom=[]}" onto public rdvmedecins.web.models.Reponse rdvmedecins.web.controllers.RdvMedecinsController.supprimerRv(rdvmedecins.web.models.PostSupprimerRv)
2014-06-12 17:30:46.851  INFO 9388 --- [           main] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped URL path [/**] 转至类型为 [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler] 的处理程序
2014-06-12 17:30:46.851  INFO 9388 --- [           main] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped URL path [/webjars/**] 类型为 [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler] 的处理程序
2014-06-12 17:30:47.131  INFO 9388 --- [           main] o.s.j.e.a.AnnotationMBeanExporter        : Registering beans for JMX exposure on startup
2014-06-12 17:30:47.169  INFO 9388 --- [           main] s.b.c.e.t.TomcatEmbeddedServletContainer : Tomcat started on port(s): 8080/http
2014-06-12 17:30:47.170  INFO 9388 --- [           main] rdvmedecins.web.boot.Boot                : Started Boot in 6.302 seconds (JVM running for 6.906)
2014-06-12 17:30:55.520  INFO 9388 --- [nio-8080-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/]       : Initializing Spring FrameworkServlet 'dispatcherServlet'
2014-06-12 17:30:55.520  INFO 9388 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : FrameworkServlet 'dispatcherServlet': initialization started
2014-06-12 17:30:55.538  INFO 9388 --- [nio-8080-exec-1] o.s.web.servlet.DispatcherServlet        : FrameworkServlet 'dispatcherServlet': initialization completed in 18 ms
  • 第 17 行:Tomcat 服务器启动;
  • 第 23-31 行:[métier, DAO, JPA] 层初始化;
  • 第 34 行:已发现处理 URL 的 [/getRvMedecinJour/{idMedecin}/{jour}] 方法。此控制器方法的发现过程一直持续到第 44 行;
  • 第 52 行:Spring Servlet MVC [DispatcherServlet] 已准备好响应 Web 客户端的请求;

现在,我们已经拥有了一个可通过Web客户端进行查询的、可正常运行的Web服务。接下来,我们将探讨如何对该服务进行安全加固:我们希望仅允许特定人员管理医生的预约。为此,我们将使用Spring Security框架,它是Spring生态系统的一个分支。

2.13. Spring Security 简介

我们将再次导入 Spring 指南,具体步骤如下:

  

该项目由以下部分组成:

  • 在 [templates] 文件夹中,包含项目的 HTML 页面;
  • [Application]:是项目的可执行类;
  • [MvcConfig]:是 Spring 的配置类;
  • [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 如下:

<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
    <h1>Welcome!</h1>
    <p>
        Click <a href="/hello">here</a> to see a greeting.
    </p>
</body>
</html>
  • 第 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 如下:

<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Hello World!</title>
</head>
<body>
    <h1>Hello user!</h1>
    <form method="post" action="/logout">
        <input type="submit" value="Sign Out" />
    <input type="hidden" name="_csrf" value="c60cf557-1f3b-415f-a628-39380de7b69a" /></form>
</body>
</html>
  • 第 8 行:Hello [[${#httpServletRequest.remoteUser}]]! 的翻译;
  • 第 9 行:@{/logout} 的翻译;
  • 第 11 行:一个名为 _csrf 的隐藏字段(属性 name);

最后一个视图 [login.html] 如下:

  

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
    xmlns:th="http://www.thymeleaf.org"
    xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>
    <div th:if="${param.error}">Invalid username and password.</div>
    <div th:if="${param.logout}">You have been logged out.</div>
    <form th:action="@{/login}" method="post">
        <div>
            <label> User Name : <input type="text" name="username" />
            </label>
        </div>
        <div>
            <label> Password: <input type="password" name="password" />
            </label>
        </div>
        <div>
            <input type="submit" value="Sign In" />
        </div>
    </form>
</body>
</html>
  • 第 9 行:属性 [th:if="${param.error}"] 确保只有当显示登录页面的 URL 包含参数 [error](http://context/login?error)时,才会生成 <div> 标签;
  • 第 10 行:属性 [th:if="${param.logout}"] 确保只有当显示登录页面的 URL 包含参数 [logout](http://context/login?logout)时,才会生成 <div> 标签;
  • 第 11-23 行:一个 HTML 表单;
  • 第 11 行:表单将提交至 URL [<context>/login],其中 <context> 是 Web 应用程序的上下文;
  • 第 13 行:一个名为 [username] 的输入字段;
  • 第 17 行:一个名为 [password] 的输入字段;

生成的代码 HTML 如下:

<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
<head>
<title>Spring Security Example</title>
</head>
<body>

    <form method="post" action="/login">
        <div>
            <label> User Name : <input type="text" name="username" />
            </label>
        </div>
        <div>
            <label> Password: <input type="password" name="password" />
            </label>
        </div>
        <div>
            <input type="submit" value="Sign In" />
        </div>
    <input type="hidden" name="_csrf" value="c60cf557-1f3b-415f-a628-39380de7b69a" /></form>
</body>
</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 关联。其中建立了以下关联:
URL
视图
/, /home
/templates/home.html
/hello
/templates/hello.html
/login
/templates/login.html

后缀 [html] 和文件夹 [templates] 是 Thymeleaf 使用的默认值。它们可以通过配置进行更改。文件夹 [templates] 必须位于项目类路径的根目录下:

在 [1] 之上,文件夹 [main] 和 [resources] 均为源文件夹(source folders)。这意味着它们的内容将位于项目类路径的根目录下。 因此,在 [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与访问权限关联。其中建立了以下关联:
URL
规则
代码
/, /home
无需身份验证的访问

http.authorizeRequests().antMatchers("/", "/home").permitAll()
autres URL
仅限经过身份验证的用户访问
http.anyRequest().authenticated();
  • 第 15 行:定义身份验证方法。身份验证通过表单 URL [/login] 进行,该表单对所有人开放 [http.formLogin().loginPage("/login").permitAll()]。注销(logout)功能同样对所有人开放。
  • 第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 行:静态方法 [SpringApplication.run] 被调用,其参数为配置类 [Application]。 我们之前已经遇到过这个过程,并且知道项目 Maven 依赖中嵌入的 Tomcat 服务器将被启动,项目也将部署到该服务器上。我们看到四个 URL 由 [/, /home, /login, /hello] 管理,其中部分受访问权限保护。

2.13.6. 应用程序测试

首先,我们请求 URL(即 [/]),这是四个被接受的 URL 之一。它关联的视图是 [/templates/home.html]:

 

所请求的 URL(即 [/])对所有人开放。这就是我们获取它的原因。[here] 的链接如下:

Click <a href="/hello">here</a> to see a greeting.

点击该链接后,系统将请求 URL [/hello]。该文件受保护:

URL
规则
代码
/, /home
无需认证即可访问

http.authorizeRequests().antMatchers("/", "/home").permitAll()
autres URL
仅限经过身份验证的访问
http.anyRequest().authenticated();

必须经过身份验证才能获取。Spring Security 随后会将客户端浏览器重定向至身份验证页面。根据所见配置,该页面为 URL [/login]。该页面对所有人开放:


http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();

因此我们得到 [1]:

生成的页面源代码如下:

<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml" xmlns:sec="http://www.thymeleaf.org/thymeleaf-extras-springsecurity3">
...
    <form method="post" action="/login">
...
       <input type="hidden" name="_csrf" value="87bea06a-a177-459d-b279-c6068a7ad3eb" />
   </form>
</body>
</html>
  • 第7行出现了一个隐藏字段,该字段在原始页面[login.html]中并不存在。这是Thymeleaf添加的。该代码名为CSRF(跨站请求伪造),旨在消除一个安全漏洞。 该令牌必须随身份验证信息一并发送回 Spring Security,才能被后者接受;

我们记得,Spring Security 仅识别 user/password 用户名和密码。如果我们在 [2] 中输入其他内容,我们会得到同一页面,并在 [3] 中看到一条错误信息。 Spring Security 将浏览器重定向到了 URL [http://localhost:8080/login?error]。参数 [error] 的存在触发了以下标签的显示:


<div th:if="${param.error}">Invalid username and password.</div>

现在,请输入预期值 user/password [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>

点击按钮 [Sign Out] 后,将在 URL 和 [/logout] 上生成 POST。 该文件与 URL、[/login] 一样,对所有人开放:


http.formLogin().loginPage("/login").permitAll().and().logout().permitAll();

在我们的关联 URL / 视图 中,我们未对 URL [/logout] 进行任何定义。会发生什么情况?让我们试一试:

  • 在 [6] 中,我们点击 [Sign Out] 按钮;
  • 在 [7] 处,我们看到已被重定向至 URL [http://localhost:8080/login?logout]。这是 Spring Security 发起的重定向。 由于 [logout] 参数出现在 URL 中,导致视图中显示了以下内容:

<div th:if="${param.logout}">You have been logged out.</div>

2.13.7. 结论

在前面的示例中,我们本可以先编写 Web 应用程序,然后再对其进行安全加固。Spring Security 并不具有侵入性。我们可以为已经编写好的 Web 应用程序配置安全机制。此外,我们还发现了以下几点:

  • 可以定义一个身份验证页面;
  • 身份验证必须附带由 Spring Security 签发的令牌 CSRF;
  • 若认证失败,系统将重定向至认证页面,且 URL 参数中会包含 error 字段;
  • 若认证成功,将重定向至认证发生时请求的页面。 如果直接请求身份验证页面而不经过中间页面,则 Spring Security 会将我们重定向到 URL [/](此情况未被提及);
  • 通过请求 URL [/logout] 并携带 POST 参数即可注销。 Spring Security 随后会将我们重定向至包含 logout 参数的 URL 认证页面;

所有这些结论均基于 Spring Security 的默认行为。通过重写 [WebSecurityConfigurerAdapter] 类的某些方法,可以配置更改这些行为。

之前的教程在后续内容中帮助不大。实际上,我们将使用:

  • 一个数据库来存储用户、密码及其角色;
  • 基于 HTTP 头部的身份验证;

关于我们此处要实现的功能,现有的教程相当有限。即将提出的解决方案是整合了从各处收集的代码。

2.14. 在预约Web服务上部署安全措施

2.14.1. 数据库

[rdvmedecins] 数据库进行了扩展,以支持用户、密码和角色的管理。新增了三个表:

Image

表 [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:角色标识符;
 

由于我们修改了数据库,因此项目 [métier, DAO, JPA] 的所有层都需要进行修改:

2.14.2. [métier, DAO, JPA]的新Eclipse项目

我们将原始项目 [rdvmedecins-metier-dao] 复制为 [rdvmedecins-metier-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;

    // 房产
    private String identity;
    private String login;
    private String password;

    // 构造函数
    public User() {
    }

    public User(String identity, String login, String password) {
        this.identity = identity;
        this.login = login;
        this.password = password;
    }

    // 身份
    @Override
    public String toString() {
        return String.format("User[%s,%s,%s]", identity, login, password);
    }

    // 获取器和设置器
....
}
  • 第 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;

    // 属性
    private String name;

    // 制造商
    public Role() {
    }

    public Role(String name) {
        this.name = name;
    }

    // 身份
    @Override
    public String toString() {
        return String.format("Role[%s]", name);
    }

    // 获取器和设置器
...
}

类 [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;

    // 一个 UserRole 引用一个 User
    @ManyToOne
    @JoinColumn(name = "USER_ID")
    private User user;
    // 一个 UserRole 引用一个 Role
    @ManyToOne
    @JoinColumn(name = "ROLE_ID")
    private Role role;

    // 获取器和设置器
...
}
  • 第 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> {

    // 通过 ID 标识的用户角色列表
    @Query("select ur.role from UserRole ur where ur.user.id=?1")
    Iterable<Role> getRoles(long id);

    // 通过登录名和密码识别的用户角色列表
    @Query("select ur.role from UserRole ur where ur.user.login=?1 and ur.user.password=?2")
    Iterable<Role> getRoles(String login, String password);

    // 通过登录名查询用户
    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> {

    // 通过角色名称搜索角色
    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;

    // 属性
    private User user;
    private UserRepository userRepository;

    // 构造函数
    public AppUserDetails() {
    }

    public AppUserDetails(User user, UserRepository userRepository) {
        this.user = user;
        this.userRepository = userRepository;
    }

    // -------------------------接口
    @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;
    }

    // 获取器和设置器
    ...
}
  • 第 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 {
        // 通过登录名查找用户
        User user = userRepository.findUserByLogin(login);
        // 找到了吗?
        if (user == null) {
            throw new UsernameNotFoundException(String.format("login [%s] inexistant", login));
        }
        // 返回用户详细信息
        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) {
        // 语法:登录名 密码roleName

        // 需要三个参数
        if (args.length != 3) {
            System.out.println("Syntaxe : [pg] user password role");
            System.exit(0);
        }
        // 获取参数
        String login = args[0];
        String password = args[1];
        String roleName = String.format("ROLE_%s", args[2].toUpperCase());
        // Spring 上下文
        AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(DomainAndPersistenceConfig.class);
        UserRepository userRepository = context.getBean(UserRepository.class);
        RoleRepository roleRepository = context.getBean(RoleRepository.class);
        UserRoleRepository userRoleRepository = context.getBean(UserRoleRepository.class);
        // 该角色是否已存在?
        Role role = roleRepository.findRoleByName(roleName);
        // 如果不存在则创建
        if (role == null) {
            role = roleRepository.save(new Role(roleName));
        }
        // 用户是否已存在?
        User user = userRepository.findUserByLogin(login);
        // 如果不存在则创建
        if (user == null) {
            // 使用 bcrypt 对密码进行哈希处理
            String crypt = BCrypt.hashpw(password, BCrypt.gensalt());
            // 保存用户
            user = userRepository.save(new User(login, login, crypt));
            // 建立与角色的关联
            userRoleRepository.save(new UserRole(user, role));
        } else {
            // 用户已存在——是否拥有所请求的角色?
            boolean trouvé = false;
            for (Role r : userRepository.getRoles(user.getId())) {
                if (r.getName().equals(roleName)) {
                    trouvé = true;
                    break;
                }
            }
            // 若未找到,则创建与角色的关联
            if (!trouvé) {
                userRoleRepository.save(new UserRole(user, role));
            }
        }

        // 关闭 Spring 上下文
        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() {
        // 获取用户 [admin]
        User user = userRepository.findUserByLogin("admin");
        // 验证其密码是否为 [admin]
        Assert.assertTrue(BCrypt.checkpw("admin", user.getPassword()));
        // 验证管理员角色 / 管理员
        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() {
        // 检索用户 [admin]
        AppUserDetails userDetails = (AppUserDetails) appUserDetailsService.loadUserByUsername("admin");
        // 验证其密码为 [admin]
        Assert.assertTrue(BCrypt.checkpw("admin", userDetails.getPassword()));
        // 验证 admin / admin 角色
        @SuppressWarnings("unchecked")
        List<SimpleGrantedAuthority> authorities = (List<SimpleGrantedAuthority>) userDetails.getAuthorities();
        Assert.assertEquals(1L, authorities.size());
        Assert.assertEquals("ROLE_ADMIN", authorities.get(0).getAuthority());
    }

    // 实用方法 - 显示集合中的元素
    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];

测试执行成功,日志如下:

User[guest,guest,$2a$10$Gzyp54mvkgMH0SPQkXo.Zeu.DvJ/Ql50PRXLf2FkolMTs7fr6A2J2]
Roles :
Role[ROLE_GUEST]
User[admin,admin,$2a$10$m79V6MKt9GPDdpjSulyqReqUioqYwXy8ollt/.ia15FhX2fym3AE6]
Roles :
Role[ROLE_ADMIN]
User[user,user,$2a$10$ph5y/1H89YC11oGVLB49fON.dZwnu44bAOKMK1FFl//xjAvsr/Ese]
Roles :
Role[ROLE_USER]
User[x,x,$2a$10$dAKd2SuQplR1iFhoBUUFs.XiA0lYxNqOmrkv97Gbr5KBoHzEi/5HG]
Roles :
Role[ROLE_GUEST]

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 {
        // 身份验证由 Bean 处理 [appUserDetailsService]
        // 密码通过 Bcrypt 哈希算法进行加密
        registry.userDetailsService(appUserDetailsService).passwordEncoder(new BCryptPasswordEncoder());
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // CSRF
        http.csrf().disable();
        // 密码通过 Authorization: Basic xxxx 头部传递
        http.httpBasic();
        // 仅 ADMIN 角色可使用该应用程序
        http.authorizeRequests() //
                .antMatchers("/", "/**") // 所有 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 标头:
Authorization:Basic code

其中 code 是使用 Base64 算法对字符串 login:password 进行编码后的结果。例如,字符串 admin:admin 的 Base64 编码为 YWRtaW46YWRtaW4=。 因此,用户名 [admin] 和密码 [admin] 的用户将发送以下标头 HTTP 进行身份验证:

Authorization:Basic YWRtaW46YWRtaW4=
  • 第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:

Authorization:Basic code

其中 [code] 是字符串 [login:password] 的 Base64 编码。要生成此代码,可以使用以下程序:

  

package rdvmedecins.helpers;

import org.springframework.security.crypto.codec.Base64;

public class Base64Encoder {

    public static void main(String[] args) {
        // 需要两个参数:登录名和密码
        if (args.length != 2) {
            System.out.println("Syntaxe : login password");
            System.exit(0);
        }
        // 获取这两个参数
        String chaîne = String.format("%s:%s", args[0], args[1]);
        // 对字符串进行编码
        byte[] data = Base64.encode(chaîne.getBytes());
        // 显示其 Base64 编码
        System.out.println(new String(data));
    }

}

如果我们使用两个参数 [admin admin] 运行该程序:

  

将得到以下结果:

YWRtaW46YWRtaW4=

现在我们已经知道如何生成 HTTP 身份验证头,接下来启动已加密的 Web 服务。然后使用 Chrome 客户端 [Advanced Rest Client] 请求所有医生的列表:

  • 在 [1] 中,我们请求医生的 URL;
  • 在 [2] 中,使用 GET 方法;
  • 在 [3] 中,我们提供身份验证的 HTTP 头部。代码 [YWRtaW46YWRtaW4=] 是字符串 [admin:admin] 的 Base64 编码;
  • 在 [4] 中,我们发送命令 HTTP;

服务器的响应如下:

  • 在 [1] 中,包含认证头 HTTP;
  • 在 [2] 中,服务器返回响应 JSON;
  • [3],医生列表。

现在,让我们尝试发送一个带有错误认证头部的 HTTP 请求。此时响应如下:

  • 在 [1] 和 [3] 中:认证头 HTTP;
  • [2]:Web服务的响应;

现在,我们来尝试用户 user / user。该用户存在,但无法访问 Web 服务。如果我们使用两个参数 [user user] 运行 Base64 编码程序:

  

将得到以下结果:

dXNlcjp1c2Vy
  • 其中 [1] 和 [3] 表示认证头 HTTP;
  • [2]:Web服务的响应。它与之前的[401 Unauthorized]不同。这次,用户已成功认证,但没有足够的权限访问URL;

2.15. Conclusion

回顾一下我们客户端/服务器应用程序的整体架构:

一个安全的 Web 服务现已投入运行。我们将看到,在构建 Angular 客户端 JS 时,由于出现的问题,该服务需要进行修改。但我们将在遇到问题时再进行解决。现在,我们将构建 Angular 客户端,该客户端将提供一个 Web 界面来管理医生的预约。