第4章 保护Spring

本章内容:
  • 自动配置Spring Security
  • 设置自定义的用户存储
  • 自定义登录页
  • 防范CSRF攻击
  • 知道用户是谁

有一点不知道你是否在意过,那就是在电视剧中大多数人从不锁门。在Leave it toBeaver热播的时代,人们不锁门这事并不值得大惊小怪,但是在这个隐私和安全被看得极其重要的年代,看到电视剧中的角色允许别人大摇大摆地进入自己的寓所或家中实在让人难以置信。

现在,信息可能是我们最有价值的东西,一些不怀好意的人想尽办法试图偷偷进入不安全的应用程序来窃取我们的数据和身份信息。作为软件开发人员,我们必须采取措施来保护应用程序中的信息。无论你是通过用户名/密码来保护电子邮件账号,还是基于交易PIN来保护经纪账户,安全性都是绝大多数应用系统中的一个重要切面。

3.3 小结

  • Spring的JdbcTemplate能够极大地简化JDBC的使用。
  • 在我们需要知道数据库所生成的ID值时,可以组合使用PreparedStatementCreator和KeyHolder。
  • 为了简化数据的插入,可以使用SimpleJdbcInsert。
  • Spring Data JPA能够极大地简化JPA持久化,我们只需编写repository接口即可。

3.2 使用Spring Data JPA持久化数据

Spring Data是一个非常大的伞形项目,由多个子项目组成,其中大多数子项目都关注对不同的数据库类型进行数据持久化。比较流行的几个Spring Data项目包括:

  • Spring Data JPA:基于关系型数据库进行JPA持久化。
  • Spring Data MongoDB:持久化到Mongo文档数据库。
  • Spring Data Neo4j:持久化到Neo4j图数据库。
  • Spring Data Redis:持久化到Redis key-value存储。
  • Spring Data Cassandra:持久化到Cassandra数据库。

Spring Data为所有项目提供了一项最有趣且最有用的特性,就是基于repository规范接口自动生成repository的功能。

要了解Spring Data是如何运行的,我们需要重新开始,将本章前文基于JDBC的repository替换为使用Spring Data JPA的repository。首先,我们需要将SpringData JPA添加到项目的构建文件中。

3.2.1 添加Spring Data JPA到项目中

Spring Boot应用可以通过JPA starter来添加Spring Data JPA。这个starter依赖不仅会引入Spring Data JPA,还会传递性地将Hibernate作为JPA实现引入进来:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

如果你想要使用不同的JPA实现,那么至少需要将Hibernate依赖排除出去并将你所选择的JPA库包含进来。举例来说,如果想要使用EclipseLink来替代Hibernate,就需要像这样修改构建文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
<exclusions>
<exclusion>
<artifactId>hibernate-entitymanager</artifactId>
<groupId>org.hibernate</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.eclipse.persistence</groupId>
<artifactId>eclipselink</artifactId>
<version>2.5.2</version>
</dependency>

需要注意,根据你所选择的JPA实现,这里可能还需要其他的变更。你可以参考所选择的JPA实现文档以了解更多细节。现在,我们重新看一下领域对象,并为它们添加注解,使其支持JPA持久化。

3.2.2 将领域对象标注为实体

你马上将会看到,在创建repository方面,Spring Data为我们做了很多非常棒的事情。但是,在使用JPA映射注解标注领域对象方面,它却没有提供太多的助益。我们需要打开Ingredient、Taco和Order类,并为其添加一些注解,首先是Ingredient类,如程序清单3.16所示。

程序清单3.16 为Ingredient添加注解使其支持JPA持久化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package tacos;
import javax.persistence.Entity;
import javax.persistence.Id;
import lombok.AccessLevel;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;
@Data
@RequiredArgsConstructor
@NoArgsConstructor(access=AccessLevel.PRIVATE, force=true)
@Entity
public class Ingredient {
@Id
private final String id;
private final String name;
private final Type type;
public static enum Type {
WRAP, PROTEIN, VEGGIES, CHEESE, SAUCE
}
}

为了将Ingredient声明为JPA实体,它必须添加@Entity注解。它的id属性需要使用@Id注解,以便于将其指定为数据库中唯一标识该实体的属性。

除了JPA特定的注解,你可能会发现我们在类级别添加了@NoArgsConstructor注解。JPA需要实体有一个无参的构造器,Lombok的@NoArgsConstructor注解能够帮助我们实现这一点。但是,我们不想直接使用它,因此通过将access属性设置为AccessLevel.PRIVATE使其变成私有的。因为这里有必须要设置的final属性,所以我们将force设置为true,这样Lombok生成的构造器就会将它们设置为null。

我们还添加了一个@RequiredArgsConstructor注解。@Data注解会为我们添加一个有参构造器,但是使用@NoArgsConstructor注解之后,这个构造器就会被移除掉。现在,我们显式添加@RequiredArgsConstructor注解,以确保除了private的无参构造器之外,我们还会有一个有参构造器。

接下来,我们看一下程序清单3.17所示的Taco类,看看它是如何标注为JPA实体的。

程序清单3.17 将Taco标注为实体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package tacos;
import java.util.Date;
import java.util.List;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.ManyToMany;
import javax.persistence.OneToMany;
import javax.persistence.PrePersist;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import lombok.Data;
@Data
@Entity
public class Taco {
@Id
@GeneratedValue(strategy=GenerationType.AUTO)
private Long id;
@NotNull
@Size(min=5, message="Name must be at least 5 characters long")
private String name;
private Date createdAt;
@ManyToMany(targetEntity=Ingredient.class)
@Size(min=1, message="You must choose at least 1 ingredient")
private List<Ingredient> ingredients;
@PrePersist
void createdAt() {
this.createdAt = new Date();
}
}

与Ingredient类似,Taco类现在添加了@Entity注解,并为其id属性添加了@Id注解。因为我们要依赖数据库自动生成ID值,所以在这里还为id属性设置了@GeneratedValue,将它的strategy设置为AUTO。

为了声明Taco与其关联的Ingredient列表之间的关系,我们为ingredients添加了@ManyToMany注解。每个Taco可以有多个Ingredient,而每个Ingredient可以是多个Taco的组成部分。

你会看到,在这里有一个新的方法createdAt(),并使用了@PrePersist注解。在Taco持久化之前,我们会使用这个方法将createdAt设置为当前的日期和时间。最后,我们要将Order对象标注为实体。程序清单3.18展示了新的Order类。

程序清单3.18 将Order标注为JPA实体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package tacos;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.ManyToMany;
import javax.persistence.OneToMany;
import javax.persistence.PrePersist;
import javax.persistence.Table;
import javax.validation.constraints.Digits;
import javax.validation.constraints.Pattern;
import org.hibernate.validator.constraints.CreditCardNumber;
import org.hibernate.validator.constraints.NotBlank;
import lombok.Data;
@Data
@Entity
@Table(name="Taco_Order")
public class Order implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy=GenerationType.AUTO)
private Long id;
private Date placedAt;
...
@ManyToMany(targetEntity=Taco.class)
private List<Taco> tacos = new ArrayList<>();
public void addDesign(Taco design) {
this.tacos.add(design);
}
@PrePersist
void placedAt() {
this.placedAt = new Date();
}
}

我们可以看到,Order所需的变更就是Taco的翻版。但是,在类级别这里有了一个新的注解,即@Table。它表明Order实体应该持久化到数据库中名为Taco_Order的表中。

我们可以将这个注解用到所有的实体上,但是只有Order有必要这样做。如果没有它,JPA默认会将实体持久化到名为Order的表中,但是order是SQL的保留字,这样做的话会产生问题。实体都已经标注好了,现在我们该编写repository了。

3.2.3 声明JPA repository

在JDBC版本的repository中,我们显式声明想要repository提供的方法。但是,借助Spring Data,我们可以扩展CrudRepository接口。举例来说,如下是新的IngredientRepository接口。

1
2
3
4
5
6
package tacos.data;
import org.springframework.data.repository.CrudRepository;
import tacos.Ingredient;
public interface IngredientRepository
extends CrudRepository<Ingredient, String> {
}

CrudRepository定义了很多用于CRUD(创建、读取、更新、删除)操作的方法。注意,它是参数化的,第一个参数是repository要持久化的实体类型,第二个参数是实体ID属性的类型。对于IngredientRepository来说,参数应该是Ingredient和String。

我们可以非常简单地定义TacoRepository:

1
2
3
4
5
6
package tacos.data;
import org.springframework.data.repository.CrudRepository;
import tacos.Taco;
public interface TacoRepository
extends CrudRepository<Taco, Long> {
}

IngredientRepository和TacoRepository之间唯一比较明显的区别就是CrudRepository的参数。在这里,我们将其设置为Taco和Long,从而指定Taco实体(及其ID类型)是该repository接口的持久化单元。最后,相同的变更可以用到OrderRepository上:

1
2
3
4
5
6
package tacos.data;
import org.springframework.data.repository.CrudRepository;
import tacos.Order;
public interface OrderRepository
extends CrudRepository<Order, Long> {
}

现在,我们有了3个repository。你可能会想,我们应该需要编写它们的实现类,包括每个实现类所需的十多个方法。但是,Spring Data JPA带来的好消息是,我们根本就不用编写实现类!当应用启动的时候,Spring Data JPA会在运行期自动生成实现类。这意味着,我们现在就可以使用这些repository了。我们只需要像使用基于JDBC的实现那样将它们注入控制器中就可以了。

CrudRepository所提供的方法对于实体的通用持久化是非常有用的。但是,如果我们的需求并不局限于基本持久化,那又该怎么办呢?接下来,我们看一下如何自定义repository来执行特定领域的查询。

3.2.4 自定义JPA repository

假设除了CrudRepository提供的基本CRUD操作之外,我们还需要获取投递到指定邮编(Zip)的订单。实际上,我们只需要添加如下的方法声明到OrderRepository中,这个问题就解决了:

1
List<Order> findByDeliveryZip(String deliveryZip);

当创建repository实现的时候,Spring Data会检查repository接口的所有方法,解析方法的名称,并基于被持久化的对象来试图推测方法的目的。本质上,SpringData定义了一组小型的领域特定语言(Domain-Specific Language,DSL),在这里持久化的细节都是通过repository方法的签名来描述的。

Spring Data能够知道这个方法是要查找Order的,因为我们使用Order对CrudRepository进行了参数化。方法名findByDeliveryZip()确定该方法需要根据deliveryZip属性相匹配来查找Order,而deliveryZip的值是作为参数传递到方法中来的。

findByDeliveryZip ()方法非常简单,但是Spring Data也能处理更加有意思的方法名称。repository方法是由一个动词、一个可选的主题(Subject)、关键词By以及一个断言所组成的。在findByDeliveryZip()这个样例中,动词是find,断言是DeliveryZip,主题并没有指定,暗含的主题是Order。

我们考虑另外一个更复杂的样例。假设我们想要查找投递到指定邮编且在一定时间范围内的订单。在这种情况下,我们可以将如下的方法添加到OrderRepository中,它就能达到我们的目的。

1
2
List<Order> readOrdersByDeliveryZipAndPlacedAtBetween(
String deliveryZip, Date startDate, Date endDate);

图3.2展现了Spring Data在生成repository实现的时候是如何解析和理解readOrdersByDeliveryZipAndPlacedAtBetween()方法的。我们可以看到,在readOrdersByDeliveryZipAndPlacedAtBetween()中,动词是read。SpringData会将get、read和find视为同义词,它们都是用来获取一个或多个实体的。另外,我们还可以使用count作为动词,它会返回一个int值,代表匹配实体的数量。

epub_29101559_29

图3.2 Spring Data解析repository方法签名来确定要执行的查询

尽管方法的主题是可选的,但是这里要查找的就是Order。Spring Data会忽略主题中大部分的单词,所以你尽可以将方法命名为readPuppiesBy…,它依然会去查找Order实体,因为CrudRepository的类型是参数化的。

单词By后面的断言是方法签名中最为有意思的一部分。在本例中,断言指定了Order的两个属性:deliveryZip和placedAt。deliveryZip属性的值必须要等于方法第一个参数传入的值。关键字Between表明placedAt属性的值必须要位于方法最后两个参数的值之间。

除了Equals和Between操作之外,Spring Data方法签名还能包括如下的操作符:

  • IsAfter、After、IsGreaterThan、GreaterThan
  • IsGreaterThanEqual、GreaterThanEqual
  • IsBefore、Before、IsLessThan、LessThan
  • IsLessThanEqual、LessThanEqual
  • IsBetween、Between
  • IsNull、Null
  • IsNotNull、NotNull
  • IsIn、In
  • IsNotIn、NotIn
  • IsStartingWith、StartingWith、StartsWith
  • IsEndingWith、EndingWith、EndsWith
  • IsContaining、Containing、Contains
  • IsLike、Like
  • IsNotLike、NotLike
  • IsTrue、True
  • IsFalse、False
  • Is、Equals
  • IsNot、Not
  • IgnoringCase、IgnoresCase

作为IgnoringCase/IgnoresCase的替代方案,我们还可以在方法上添加AllIgnoringCase或AllIgnoresCase,这样它就会忽略所有String对比的大小写。例如,请看如下方法:

1
2
List<Order> findByDeliveryToAndDeliveryCityAllIgnoresCase(
String deliveryTo, String deliveryCity);

最后,我们还可以在方法名称的结尾处添加OrderBy,实现结果集根据某个列排序。例如,我们可以按照deliveryTo属性排序:

1
List<Order> findByDeliveryCityOrderByDeliveryTo(String city);

尽管方法名称约定对于相对简单的查询非常有用,但是,不难想象,对于更为复杂的查询,方法名可能会面临失控的风险。在这种情况下,可以将方法定义为任何你想要的名称,并为其添加@Query注解,从而明确指明方法调用时要执行的查询,如下面的样例所示:

1
2
@Query("Order o where o.deliveryCity='Seattle'")
List<Order> readOrdersDeliveredInSeattle();

在本例中,通过使用@Query,我们声明只查询所有投递到Seattle的订单。但是,我们可以使用@Query执行任何想要的查询,有些查询是通过方法命名约定很难甚至根本无法实现的。

3.1 使用JDBC读取和写入数据

几十年以来,关系型数据库和SQL一直是数据持久化领域的首选方案。尽管近年来涌现了许多可选的数据库类型,但是关系型数据库依然是通用数据存储的首选,而且短期内不太可能撼动它的地位。

在处理关系型数据的时候,Java开发人员有多种可选方案,其中最常见的是JDBC和JPA。Spring同时支持这两种抽象形式,能够让JDBC或JPA的使用更加容易。在本节中,我们将会讨论Spring如何支持JDBC,然后会在3.2节讨论Spring对JPA的支持。

Spring对JDBC的支持要归功于JdbcTemplate类。JdbcTemplate提供了一种特殊的方式,通过这种方式,开发人员在对关系型数据库执行SQL操作的时候能够避免使用JDBC时常见的繁文缛节和样板式代码。

为了更好地理解JdbcTemplate的功能,我们首先看一个不使用JdbcTemplate的样例,看一下如何在Java中执行一个简单的查询,如程序清单3.1所示。

程序清单3.1 不使用JdbcTemplate查询数据库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Override
public Ingredient findOne(String id) {
Connection connection = null;
PreparedStatement statement = null;
ResultSet resultSet = null;
try {
connection = dataSource.getConnection();
statement = connection.prepareStatement(
"select id, name, type from Ingredient where id=?");
statement.setString(1, id);
resultSet = statement.executeQuery();
Ingredient ingredient = null;
if(resultSet.next()) {
ingredient = new Ingredient(
resultSet.getString("id"),
resultSet.getString("name"),
Ingredient.Type.valueOf(resultSet.getString("type")));
}
return ingredient;
} catch (SQLException e) {
// ??? What should be done here ???
} finally {
if (resultSet != null) {
try {
resultSet.close();
} catch (SQLException e) {}
}
if (statement != null) {
try {
statement.close();
} catch (SQLException e) {}
}
if (connection != null) {
try {
connection.close();
} catch (SQLException e) {}
}
}
return null;
}

我向你保证,在程序清单3.1中存在查询数据库获取配料的那几行代码,但我敢肯定你很难在JDBC代码段中将这个查询找出来,它被创建连接、创建语句以及关闭连接、语句和结果集的清理功能完全包围了起来。

在创建连接、创建语句或执行查询的时候,可能会出现很多错误。这就要求我们捕获SQLException,它对于找出哪里出现了问题或如何解决问题可能有所帮助,也可能毫无用处。

SQLException是一个检查型异常,它需要在catch代码块中进行处理。但是,对于常见的问题,如创建到数据库的连接失败或者输入的查询有错误,在catch代码块中是无法解决的,并且有可能要继续抛出以便于上游进行处理。作为对比,我们看一下使用JdbcTemplate的方式,如程序清单3.2所示。

程序清单3.2 使用JdbcTemplate查询数据库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private JdbcTemplate jdbc;
@Override
public Ingredient findOne(String id) {
return jdbc.queryForObject(
"select id, name, type from Ingredient where id=?",
this::mapRowToIngredient, id);
}
private Ingredient mapRowToIngredient(ResultSet rs, int rowNum)
throws SQLException {
return new Ingredient(
rs.getString("id"),
rs.getString("name"),
Ingredient.Type.valueOf(rs.getString("type")));
}

程序清单3.2中的代码显然要比程序清单3.1中的原始JDBC示例简单得多。这里没有创建任何的连接和语句。而且,在方法完成之后不需要对这些对象进行清理。最后,这里也没有任何catch代码块中无法处理的异常。剩下的代码仅仅关注执行查询(调用JdbcTemplate的queryForObject())和将结果映射到Ingredient对象(在mapRowToIngredient()方法中)上。

程序清单3.2中的代码仅仅是在Taco Cloud应用中使用JdbcTemplate持久化和读取数据的一个片段。接下来,我们着手实现让应用程序支持JDBC持久化的下一个步骤。我们首先要对领域对象进行一些调整。

3.1.1 调整领域对象以适应持久化

在将对象持久化到数据库的时候,通常最好有一个字段作为对象的唯一标识。Ingredient类现在已经有了一个id字段,但是我们还需要将id字段添加到Taco和Order类中。

除此之外,记录Taco和Order是何时创建的可能会非常有用。所以,我们还会为每个对象添加一个字段来捕获它所创建的日期和时间。程序清单3.3展现了Taco类中新增的id和createdAt字段。

程序清单3.3 为Taco类添加ID和时间戳字段

1
2
3
4
5
6
@Data
public class Taco {
private Long id;
private Date createdAt;
...
}

因为我们使用Lombok在运行时生成访问器方法,所以在这里只需要声明id和createdAt属性就可以了。在运行时,它们都会有对应的getter和setter方法。类似的变更还需要应用到Order类上,如下所示:

1
2
3
4
5
6
@Data
public class Order {
private Long id;
private Date placedAt;
...
}

同样,Lombok会自动生成访问器方法,所以这是Order类的唯一变更(如果因为某种原因你无法使用Lombok,就需要自行编写这些方法了)。

现在,我们的领域类已经为持久化做好了准备。接下来,我们看一下该如何使用JdbcTemplate实现数据库的读取和写入。

3.1.2 使用JdbcTemplate

在开始使用JdbcTemplate之前,我们需要将它添加到项目的类路径中。这一点非常容易,只需要将Spring Boot的JDBC starter依赖添加到构建文件中就可以了:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

我们还需要一个存储数据的数据库。对于开发来说,嵌入式的数据库就足够了。我比较喜欢H2嵌入式数据库,所以我会将如下的依赖添加到构建文件中:

1
2
3
4
5
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>

随后,你将会看到如何配置应用以使用外部的数据库,现在我们看一下如何编写获取和保存Ingredient数据的repository。

定义JDBC repository

我们的Ingredient repository需要完成如下操作:

  • 查询所有的配料信息,将它们放到一个Ingredient对象的集合中;
  • 根据id,查询单个Ingredient;
  • 保存Ingredient对象。

如下的IngredientRepository接口以方法声明的方式定义了3个操作:

1
2
3
4
5
6
7
package tacos.data;
import tacos.Ingredient;
public interface IngredientRepository {
Iterable<Ingredient> findAll();
Ingredient findOne(String id);
Ingredient save(Ingredient ingredient);
}

尽管该接口敏锐捕捉到了配料repository都需要做些什么,但是我们依然需要编写一个IngredientRepository实现,使用JdbcTemplate来查询数据库。程序清单3.4展示了编写该实现的第一步。

程序清单3.4 开始使用JdbcTemplate编写配料repository

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package tacos.data;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
import tacos.Ingredient;
@Repository
public class JdbcIngredientRepository
implements IngredientRepository {
private JdbcTemplate jdbc;
@Autowired
public JdbcIngredientRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
...
}

我们可以看到,JdbcIngredientRepository添加了@Repository注解。Spring定义了一系列的构造型(stereotype)注解,@Repository是其中之一,其他注解还包括@Controller和@Component。为JdbcIngredientRepository添加@Repository注解之后,Spring的组件扫描就会自动发现它,并且会将其初始化为Spring应用上下文中的bean。

当Spring创建JdbcIngredientRepository bean的时候,它会通过@Autowired标注的构造器将JdbcTemplate注入进来。这个构造器将JdbcTemplate赋值给一个实例变量,这个变量会被其他方法用来执行数据库查询和插入操作。说到其他的这些方法,让我们先看一下findAll()和findOne()的实现,如程序清单3.5所示。

程序清单3.5 使用JdbcTemplate查询数据库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Override
public Iterable<Ingredient> findAll() {
return jdbc.query("select id, name, type from Ingredient",
this::mapRowToIngredient);
}
@Override
public Ingredient findOne(String id) {
return jdbc.queryForObject(
"select id, name, type from Ingredient where id=?",
this::mapRowToIngredient, id);
}
private Ingredient mapRowToIngredient(ResultSet rs, int rowNum)
throws SQLException {
return new Ingredient(
rs.getString("id"),
rs.getString("name"),
Ingredient.Type.valueOf(rs.getString("type")));
}

findAll()和findOne()以相同的方式使用了JdbcTemplate。findAll()方法预期返回一个对象的集合,它使用了JdbcTemplate的query()方法。query()会接受要执行的SQL以及Spring RowMapper的一个实现(用来将结果集中的每行数据映射为一个对象)。query ()方法还能以最终参数的形式接收查询中所需的任意参数。但是,在本例中,我们不需要任何参数。

findOne()方法预期只会返回一个Ingredient对象,所以它使用了JdbcTemplate的queryForObject()方法,而不是query()方法。queryForObject()方法的运行方式和query()非常类似,只不过它只返回一个对象,而不是对象的List。在本例中,它接受要执行的查询、RowMapper以及要获取的Ingredient的id,该id会替换查询中的“?”。

如程序清单3.5所示,findAll()和findOne()中的RowMapper参数都是通过对mapRowToIngredient()的方法引用指定的。在使用JdbcTemplate的时候,Java 8的方法引用和lambda表达式非常便利,它们能够替代显式的RowMapper实现。但是,如果因为某种原因,你想要或者必须使用显式RowMapper,那么如下的findOne()实现将阐述该如何按照这种方式进行编写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Override
public Ingredient findOne(String id) {
return jdbc.queryForObject(
"select id, name, type from Ingredient where id=?",
new RowMapper<Ingredient>() {
public Ingredient mapRow(ResultSet rs, int rowNum)
throws SQLException {
return new Ingredient(
rs.getString("id"),
rs.getString("name"),
Ingredient.Type.valueOf(rs.getString("type")));
};
}, id);
}

从数据库中读取数据只是问题的一部分。在有些情况下,我们必须先将数据写入数据库,这样才能进行读取。所以,我们接下来看一下该如何实现save()方法。

插入一行数据

JdbcTemplate的update()方法可以用来执行向数据库中写入或更新数据的查询语句。如程序清单3.6所示,它可以用来将数据插入到数据库中。

程序清单3.6 使用JdbcTemplate插入数据

1
2
3
4
5
6
7
8
9
@Override
public Ingredient save(Ingredient ingredient) {
jdbc.update(
"insert into Ingredient (id, name, type) values (?, ?, ?)",
ingredient.getId(),
ingredient.getName(),
ingredient.getType().toString());
return ingredient;
}

因为在这里不需要将ResultSet数据映射为对象,所以update()方法要比query()或queryForObject()简单得多。它只需要一个包含待执行SQL的String以及每个查询参数对应的值即可。在本例中,查询有3个参数,对应save()方法最后的3个参数,分别是配料的id、名称和类型。

JdbcIngredientRepository编写完成之后,我们就可以将其注入到DesignTacoController中了,然后使用它来提供Ingredient对象的列表,不用再使用硬编码的值(就像第2章中所做的那样)。修改后的DesignTacoController如程序清单3.7所示。

程序清单3.7 在控制器中注入和使用repository

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Controller
@RequestMapping("/design")
@SessionAttributes("order")
public class DesignTacoController {
private final IngredientRepository ingredientRepo;
@Autowired
public DesignTacoController(IngredientRepository ingredientRepo) {
this.ingredientRepo = ingredientRepo;
}
@GetMapping
public String showDesignForm(Model model) {
List<Ingredient> ingredients = new ArrayList<>();
ingredientRepo.findAll().forEach(i -> ingredients.add(i));
Type[] types = Ingredient.Type.values();
for (Type type : types) {
model.addAttribute(type.toString().toLowerCase(),
filterByType(ingredients, type));
}
return "design";
}
...
}

需要注意的是,showDesignForm()方法的第二行调用了注入的IngredientRepository的findAll()方法。findAll()方法会从数据库中获取所有的配料,并将它们过滤成不同的类型,然后放到模型中。

现在,我们马上就能启动应用并尝试这些变更了。但是,在使用查询语句从Ingredient表中读取数据之前,我们需要先创建这个表并填充一些配料数据。

3.1.3 定义模式和预加载数据

除了Ingredient表之外,我们还需要其他的一些表来保存订单和设计信息。图3.1描述了我们所需要的表以及这些表之间的关联关系。

epub_29101559_27

图3.1 Taco Cloud模式的表

图3.1中的表主要实现如下目的。

  • Ingredient:保存配料信息。
  • Taco:保存taco设计相关的信息。
  • Taco_Ingredients:Taco中的每行数据都对应一行或多行,将taco和与之相关的配料映射在一起。
  • Taco_Order:保存必要的订单细节。
  • Taco_Order_Tacos:Taco_Order中的每行数据都对应一行或多行,将订单和与之相关的taco映射在一起。

程序清单3.8展示了创建表的SQL。

程序清单3.8 定义Taco Cloud的模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
create table if not exists Ingredient (
id varchar(4) not null,
name varchar(25) not null,
type varchar(10) not null
);
create table if not exists Taco (
id identity,
name varchar(50) not null,
createdAt timestamp not null
);
create table if not exists Taco_Ingredients (
taco bigint not null,
ingredient varchar(4) not null
);
alter table Taco_Ingredients
add foreign key (taco) references Taco(id);
alter table Taco_Ingredients
add foreign key (ingredient) references Ingredient(id);
create table if not exists Taco_Order (
id identity,
deliveryName varchar(50) not null,
deliveryStreet varchar(50) not null,
deliveryCity varchar(50) not null,
deliveryState varchar(2) not null,
deliveryZip varchar(10) not null,
ccNumber varchar(16) not null,
ccExpiration varchar(5) not null,
ccCVV varchar(3) not null,
placedAt timestamp not null
);
create table if not exists Taco_Order_Tacos (
tacoOrder bigint not null,
taco bigint not null
);
alter table Taco_Order_Tacos
add foreign key (tacoOrder) references Taco_Order(id);
alter table Taco_Order_Tacos
add foreign key (taco) references Taco(id);

现在,最大的问题是将这些模式定义放在什么地方。实际上,Spring Boot回答了这个问题。

如果在应用的根类路径下存在名为schema.sql的文件,那么在应用启动的时候将会基于数据库执行这个文件中的SQL。所以,我们需要将程序清单3.8中的内容保存为名为schema.sql的文件并放到“src/main/resources”文件夹下。

我们可能还希望在数据库中预加载一些配料数据。幸运的是,Spring Boot还会在应用启动的时候执行根类路径下名为data.sql的文件。所以,我们可以使用程序清单3.9中的插入语句为数据库加载配料数据,并将其保存到“src/main/resources/data.sql”文件中。

程序清单3.9 预加载数据库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
delete from Taco_Order_Tacos;
delete from Taco_Ingredients;
delete from Taco;
delete from Taco_Order;
delete from Ingredient;
insert into Ingredient (id, name, type)
values ('FLTO', 'Flour Tortilla', 'WRAP');
insert into Ingredient (id, name, type)
values ('COTO', 'Corn Tortilla', 'WRAP');
insert into Ingredient (id, name, type)
values ('GRBF', 'Ground Beef', 'PROTEIN');
insert into Ingredient (id, name, type)
values ('CARN', 'Carnitas', 'PROTEIN');
insert into Ingredient (id, name, type)
values ('TMTO', 'Diced Tomatoes', 'VEGGIES');
insert into Ingredient (id, name, type)
values ('LETC', 'Lettuce', 'VEGGIES');
insert into Ingredient (id, name, type)
values ('CHED', 'Cheddar', 'CHEESE');
insert into Ingredient (id, name, type)
values ('JACK', 'Monterrey Jack', 'CHEESE');
insert into Ingredient (id, name, type)
values ('SLSA', 'Salsa', 'SAUCE');
insert into Ingredient (id, name, type)
values ('SRCR', 'Sour Cream', 'SAUCE');

尽管我们目前只为配料数据编写了一个repository,但是你依然可以将Taco Cloud应用启动起来并访问设计页面,看一下JdbcIngredientRepository的实际功能。尽可以去尝试一下!当你尝试完回来的时候,我们将会编写Taco、Order和数据持久化的repository。

3.1.4 插入数据

我们已经粗略看到了如何使用JdbcTemplate将数据写入到数据库中。JdbcIngredient Repository的save()方法使用JdbcTemplate的update()方法将Ingredient对象保存到了数据库中。

尽管这是一个非常好的起步样例,但是它过于简单了。你马上将会看到保存数据可能会比JdbcIngredientRepository更加复杂。借助JdbcTemplate,我们有以下两种保存数据的方法。

  • 直接使用update()方法。
  • 使用SimpleJdbcInsert包装器类。

让我们首先看一下在持久化需求比保存Ingredient更为复杂的情况下该如何使用update()方法。

使用JdbcTemplate保存数据

现在,taco和order的repository唯一需要做的事情就是保存对应的对象。为了保存Taco对象,TacoRepository声明了一个save()方法:

1
2
3
4
5
package tacos.data;
import tacos.Taco;
public interface TacoRepository {
Taco save(Taco design);
}

与之类似,OrderRepository也声明了一个save()方法:

1
2
3
4
5
package tacos.data;
import tacos.Order;
public interface OrderRepository {
Order save(Order order);
}

看起来非常简单,对吧?但是,保存taco的时候需要同时将与该taco关联的配料保存到Taco_Ingredients表中。与之类似,保存订单的时候,需要同时将与该订单关联的taco保存到Taco_Order_Tacos表中。这样看来,保存taco和订单就会比保存配料更困难一些。

为了实现TacoRepository,我们需要用save()方法首先保存必要的taco设计细节(比如,名称和创建时间),然后对Taco对象中的每种配料都插入一行数据到Taco_Ingredients中。程序清单3.10展示了完整的JdbcTacoRepository类。

程序清单3.10 使用JdbcTemplate实现TacoRepository

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package tacos.data;
import java.sql.Timestamp;
import java.sql.Types;
import java.util.Arrays;
import java.util.Date;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.PreparedStatementCreator;
import org.springframework.jdbc.core.PreparedStatementCreatorFactory;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;
import tacos.Ingredient;
import tacos.Taco;
@Repository
public class JdbcTacoRepository implements TacoRepository {
private JdbcTemplate jdbc;
public JdbcTacoRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
@Override
public Taco save(Taco taco) {
long tacoId = saveTacoInfo(taco);
taco.setId(tacoId);
for (Ingredient ingredient : taco.getIngredients()) {
saveIngredientToTaco(ingredient, tacoId);
}
return taco;
}
private long saveTacoInfo(Taco taco) {
taco.setCreatedAt(new Date());
PreparedStatementCreator psc =
new PreparedStatementCreatorFactory(
"insert into Taco (name, createdAt) values (?, ?)",
Types.VARCHAR, Types.TIMESTAMP
).newPreparedStatementCreator(
Arrays.asList(
taco.getName(),
new Timestamp(taco.getCreatedAt().getTime())));
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbc.update(psc, keyHolder);
return keyHolder.getKey().longValue();
}
private void saveIngredientToTaco(
Ingredient ingredient, long tacoId) {
jdbc.update(
"insert into Taco_Ingredients (taco, ingredient) " +
"values (?, ?)",
tacoId, ingredient.getId());
}
}

我们可以看到,save()方法首先调用了私有的saveTacoInfo()方法,然后使用该方法所返回的taco ID来调用saveIngredientToTaco(),最后的这个方法会保存每种配料。这里的问题在于saveTacoInfo()方法的细节。

当向Taco中插入一行数据的时候,我们需要知道数据库生成的ID,这样我们才可以在每个配料信息中引用它。保存配料数据时所使用的update()方法无法帮助我们得到所生成的ID,所以在这里我们需要一个不同的update()方法。

这里的update()方法需要接受一个PreparedStatementCreator和一个KeyHolder。KeyHolder将会为我们提供生成的taco ID。但是,为了使用该方法,我们必须还要创建一个PreparedStatementCreator。

从程序清单3.10中可以看到,创建PreparedStatementCreator并不简单。首先需要创建PreparedStatementCreatorFactory,并将我们要执行的SQL传递给它,同时还要包含每个查询参数的类型。随后,需要调用该工厂类的newPreparedStatementCreator()方法,并将查询参数所需的值传递进来,这样才能生成一个PreparedStatementCreator。

有了PreparedStatementCreator之后,我们就可以调用update()方法了,并且需要将PreparedStatementCreator和KeyHolder(在本例中,也就是GeneratedKeyHolder的实例)传递进来。update()调用完成之后,我们就可以通过keyHolder.getKey().longValue()返回taco的ID。

回到save()方法,接下来我们会轮询Taco中的每个Ingredient,并调用saveIngredient ToTaco()。saveIngredientToTaco()使用更简单的update()形式来将对配料的引用保存到Taco_Ingredients表中。

对于TacoRepository来说,剩下的事情就是将它注入到DesignTacoController中,并在保存taco的时候调用它。程序清单3.11展现了注入repository所需的必要变更。

程序清单3.11 注入并使用TacoRepository

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Controller
@RequestMapping("/design")
@SessionAttributes("order")
public class DesignTacoController {
private final IngredientRepository ingredientRepo;
private TacoRepository designRepo;
@Autowired
public DesignTacoController(
IngredientRepository ingredientRepo,
TacoRepository designRepo) {
this.ingredientRepo = ingredientRepo;
this.designRepo = designRepo;
}
...
}

正如我们所看到的,构造器能够同时接受IngredientRepository和TacoRepository对象。该构造器将得到的对象赋值给实例变量,这样它们就可以在showDesignForm()和processDesign()中使用了。

谈到processDesign()方法,它的变更要比showDesignForm()的变更更大一些。程序清单3.12展现了新的processDesign()方法。

程序清单3.12 保存taco设计并将它们链接至订单页面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Controller
@RequestMapping("/design")
@SessionAttributes("order")
public class DesignTacoController {
@ModelAttribute(name = "order")
public Order order() {
return new Order();
}
@ModelAttribute(name = "taco")
public Taco taco() {
return new Taco();
}
@PostMapping
public String processDesign(
@Valid Taco design, Errors errors,
@ModelAttribute Order order) {
if (errors.hasErrors()) {
return "design";
}
Taco saved = designRepo.save(design);
order.addDesign(saved);
return "redirect:/orders/current";
}
...
}

在程序清单3.12中,你首先关注到的事情可能是DesignTacoController类添加了@SessionAttributes(“order”)注解,并且它有一个新的带有@ModelAttribute注解的方法,即order()方法。与taco()方法类似,order()方法上的@ModelAttribute注解能够确保会在模型中创建一个Order对象。但是与模型中的Taco对象不同,我们需要订单信息在多个请求中都能出现,这样的话我们就能创建多个taco并将它们添加到该订单中。类级别的@SessionAttributes能够指定模型对象(如订单属性)要保存在session中,这样才能跨请求使用。

对taco设计的处理位于processDesign()方法中。该方法接受Order对象作为参数,同时还包括Taco和Errors对象。Order参数带有@ModelAttribute注解,表明它的值应该是来自模型的,Spring MVC不会尝试将请求参数绑定到它上面。

在检查完校验错误之后,processDesign()使用注入的TacoRepository来保存taco。然后,它将Taco对象保存到session里面的Order中。

实际上,在用户完成操作并提交订单表单之前,Order对象会一直保存在session中,并没有保存到数据库中。到时,OrderController需要调用OrderRepository的实现来保存订单。接下来,我们编写这个实现类。

使用SimpleJdbcInsert插入数据

在前文中提到,保存taco的时候不仅要将taco的名称和创建时间保存到Taco表中,还需要将该taco所引用的配料保存到Taco_Ingredients表中。此时,需要我们知道Taco的ID,而这是通过KeyHolder和PreparedStatementCreator获取的。

在保存订单的时候,存在类似的情况。我们不仅要将订单数据保存到Taco_Order表中,还要将订单对每个taco的引用保存到Taco_Order_Tacos表中。但是,在这里,我们不再使用烦琐的PreparedStatementCreator,而是引入SimpleJdbcInsert,这个对象对JdbcTemplate进行了包装,能够更容易地将数据插入到表中。

首先,我们要创建一个JdbcOrderRepository,它是OrderRepository的实现。但在编写save()方法的实现之前,我们先关注一下构造器,在构造器中我们会创建SimpleJdbcInsert的两个实例,分别用来把值插入到Taco_Order和Taco_Order_Tacos表中。程序清单3.13展现了JdbcOrderRepository(尚不包含save()方法)。

程序清单3.13 通过JdbcTemplate创建SimpleJdbcInsert

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
package tacos.data;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.simple.SimpleJdbcInsert;
import org.springframework.stereotype.Repository;
import com.fasterxml.jackson.databind.ObjectMapper;
import tacos.Taco;
import tacos.Order;
@Repository
public class JdbcOrderRepository implements OrderRepository {
private SimpleJdbcInsert orderInserter;
private SimpleJdbcInsert orderTacoInserter;
private ObjectMapper objectMapper;
@Autowired
public JdbcOrderRepository(JdbcTemplate jdbc) {
this.orderInserter = new SimpleJdbcInsert(jdbc)
.withTableName("Taco_Order")
.usingGeneratedKeyColumns("id");
this.orderTacoInserter = new SimpleJdbcInsert(jdbc)
.withTableName("Taco_Order_Tacos");
this.objectMapper = new ObjectMapper();
}
...
}

与JdbcTacoRepository类似,JdbcOrderRepository通过构造器将JdbcTemplate注入进来。但是在这里,我们没有将JdbcTemplate直接赋给实例变量,而是使用它构建了两个SimpleJdbcInsert实例。第一个实例赋值给了orderInserter实例变量,配置为与Taco_Order表协作,并且假定id属性将会由数据库提供或生成。第二个实例赋值给了orderTacoInserter实例变量,配置为与Taco_Order_Tacos表协作,但是没有声明该表中ID是如何生成的。

该构造器还创建了Jackson中ObjectMapper类的一个实例,并将其赋值给一个实例变量。尽管Jackson的初衷是进行JSON处理,但是你很快就会看到我们是如何使用它来帮助我们保存订单和关联的taco的。

现在,我们看一下save()方法该如何使用SimpleJdbcInsert实例。程序清单3.14展示了save()方法以及一些私有方法,其中save()方法会将实际的工作委托给这些私有方法。

程序清单3.14 使用SimpleJdbcInsert插入数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Override
public Order save(Order order) {
order.setPlacedAt(new Date());
long orderId = saveOrderDetails(order);
order.setId(orderId);
List<Taco> tacos = order.getTacos();
for (Taco taco : tacos) {
saveTacoToOrder(taco, orderId);
}
return order;
}
private long saveOrderDetails(Order order) {
@SuppressWarnings("unchecked")
Map<String, Object> values =
objectMapper.convertValue(order, Map.class);
values.put("placedAt", order.getPlacedAt());
long orderId =
orderInserter
.executeAndReturnKey(values)
.longValue();
return orderId;
}
private void saveTacoToOrder(Taco taco, long orderId) {
Map<String, Object> values = new HashMap<>();
values.put("tacoOrder", orderId);
values.put("taco", taco.getId());
orderTacoInserter.execute(values);
}

save()方法实际上没有保存任何内容,只是定义了保存Order及其关联的Taco对象的流程,并将实际的持久化任务委托给了saveOrderDetails()和saveTacoToOrder()。

SimpleJdbcInsert有两个非常有用的方法来执行数据插入操作:execute()和execute AndReturnKey()。它们都接受Map<String, Object>作为参数,其中Map的key对应表中要插入数据的列名,而Map中的value对应要插入到列中的实际值。

我们只需将Order中的值复制到Map的条目中就能很容易地创建一个这样的Map。但是,Order有很多属性,这些属性与对应的列有着相同的名称。鉴于此,在saveOrderDetails()中,我决定使用Jackson的ObjectMapper及其convertValue()方法,以便于将Order转换为Map[^1]。Map创建完成之后,我们将Map中placedAt条目的值设置为Order对象placedAt属性的值。之所以需要这样做,是因为ObjectMapper会将Date属性转换为long,这会导致与Taco_Order表中的placedAt字段不兼容。

当Map中准备好订单数据之后,我们就可以调用orderInserter的executeAndReturnKey()方法了。该方法会将订单信息保存到Taco_Order表中,并以Number对象的形式返回数据库生成的ID,继而调用longValue()方法将返回值转换为long类型。

saveTacoToOrder()方法要简单得多。在这里我们没有使用ObjectMapper将对象转换为Map,而是直接创建了一个Map并设置对应的值。同样,Map的key与表中的列名对应。我们只需要简单地调用orderTacoInserter的execute()方法就能执行插入操作了。

现在,我们可以将OrderRepository注入到OrderController中并开始使用它了。程序清单3.15展示了完整的OrderController,包括使用注入的OrderRepository相关的变更。

程序清单3.15 在OrderController中使用OrderRepository

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package tacos.web;
import javax.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.validation.Errors;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.SessionAttributes;
import org.springframework.web.bind.support.SessionStatus;
import tacos.Order;
import tacos.data.OrderRepository;
@Controller
@RequestMapping("/orders")
@SessionAttributes("order")
public class OrderController {
private OrderRepository orderRepo;
public OrderController(OrderRepository orderRepo) {
this.orderRepo = orderRepo;
}
@GetMapping("/current")
public String orderForm() {
return "orderForm";
}
@PostMapping
public String processOrder(@Valid Order order, Errors errors,
SessionStatus sessionStatus) {
if (errors.hasErrors()) {
return "orderForm";
}
orderRepo.save(order);
sessionStatus.setComplete();
return "redirect:/";
}
}

除了将OrderRepository注入到控制器中,OrderController唯一明显的变化就是processOrder()方法。在这个方法中,通过表单提交的Order对象(同时也是session中持有的Object对象)会通过注入的OrderRepository的save()方法进行保存。

订单保存完成之后,我们就不需要在session中持有它了。实际上,如果我们不把它清理掉,那么订单会继续保留在session中,其中包括与之关联的taco,下一次的订单将会从旧订单中保存的taco开始。所以,processOrder()方法请求了一个SessionStatus参数,并调用了它的setComplete()方法来重置session。

所有JDBC持久化代码已经就绪,现在我们可以启动Taco Cloud应用并进行尝试了。你可以按照自己的意愿创建任意数量的taco和订单。

你可能会发现,深入研究一下数据库中的内容是非常有帮助的。我们目前使用H2作为嵌入式数据库并且启用了Spring Boot DevTools,所以我们可以在浏览器中访问http://localhost:8080/h2-console以查看H2 Console。使用默认的凭证应该就可以进入,但是你需要确保JDBC URL字段设置成了dbc:h2:mem:testdb。登录之后,我们可以对Taco Cloud模式下的表执行任意的查询。

相对于普通的JDBC,Spring的JdbcTemplate和SimpleJdbcInsert能够极大地简化关系型数据库的使用。但是,你会发现使用JPA会更加简单。我们回顾一下自己的工作内容,看一下Spring Data是如何让数据持久化变得更简单的。

[^1]: 我要承认这里对ObjectMapper的使用并不高明,但是我们毕竟已经将Jackson引入到了类路径中,这是由Spring Boot的web starter引入的。另外,使用ObjectMapper将对象映射为Map要比复制对象的每个属性到Map中容易得多。你尽可以使用其他技术来构建Inserter对象所需的Map,以替换对ObjectMapper的使用。

2.5 选择视图模板库

在大多数情况下,视图模板库的选择完全取决于个人喜好。Spring非常灵活,能够支持很多常见的模板方案。除了个别情况之外,你所选择的模板库本身甚至不知道它在与Spring协作[^1]。

表2.2列出Spring Boot自动配置功能所支持的模板方案。

表2.2 支持的模板方案

epub_29101559_25

通常来讲,你只需要选择想要的视图模板库,将其作为依赖项添加到构建文件中,然后就可以在“/templates”目录下(在基于Maven或Gradle构建的项目中,它会在“src/main/resources”目录下)编写模板了。Spring Boot会探测到你所选择的模板库,并自动配置为Spring MVC控制器生成视图所需的各种组件。

Taco Cloud应用中,我们已经按照这种方式使用了Thymeleaf模板库。在第1章中,在初始化项目的时候,我们选择了Thymeleaf复选框。这样会自动将SpringBootThymeleaf starter依赖添加到pom.xml文件中。当应用启动的时候,Spring Boot的自动配置功能会探测到存在Thymeleaf并自动为我们配置Thymeleaf bean。我们所需要做的就是在“/templates”中开始编写模板。

如果你想要使用不同的模板库,只需要在项目初始化的时候选择它或者编辑已有的项目构建文件,将新选择的模板库添加进来即可。

例如,我们想要使用Mustache来替换Thymeleaf,没有问题!只需要找到pom.xml文件,并将如下的代码

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

替换为:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mustache</artifactId>
</dependency>

当然,我们还需要确保按照Mustache语法来编写模板,而不是再使用Thymeleaf标签。Mustache的特定用法(以及其他备选模板语言)超出了本书的范围,但是我在这里给你一个直观的印象,让你明白大致会是什么样子。如下代码是Mustache模板的一个片段,它能够渲染taco设计表单中的某个配料组:

Designate your wrap:

{{#wrap}}
{{name}}
{{/wrap}}

这是2.1.3小节中Thymeleaf代码片段的Mustache等价实现。{{#wrap}}代码块(结尾对应使用{{/wrap}})会遍历请求中key为wrap的属性并为每个条目渲染嵌入式HTML。{{id}}{{name}}标签分别会引用每个条目(应该是一个Ingredient)的id和name属性。

你可能已经注意到了,在表2.2中,JSP并不需要在构建文件中添加任何特殊的依赖。这是因为Servlet容器本身(默认是Tomcat)会实现JSP,因此不需要额外的依赖。但是,如果你选择使用JSP,会有另外一个问题。事实上,Java Servlet容器包括嵌入式的Tomcat和Jetty容器,通常会在“/WEB-INF”目录下寻找JSP。如果我们将应用构建成一个可执行的JAR文件,就无法满足这种需求了。因此,只有在将应用构建为WAR文件并部署到传统的Servlet容器中时,才能选择JSP方案。如果你想要构建可执行的JAR文件,那么必须选择Thymeleaf、FreeMarker或表2.2中的其他方案。

缓存模板

默认情况下,模板只有在第一次使用的时候解析一次,解析的结果会被后续的请求所使用。对于生产环境来说,这是一个很棒的特性,它能防止每次请求时多余的模板解析过程,因此有助于提升性能。

但是,在开发期,这个特性就不太友好了。假设我们启动完应用之后访问taco的设计页面,然后决定对它做一些修改,但是当我们刷新Web浏览器的时候显示的依然是原始的版本。要想看到变更效果,就必须重新启动应用,这当然是非常不方便的。

幸运的是,有一种方法可以禁用缓存。我们所需要做的就是将相关的缓存属性设置为false。表2.3列出每种模板库所对应的缓存属性。

表2.3 启用/禁用模板缓存的属性

epub_29101559_26

默认情况下,这些属性都设置成了true,以便于启用缓存。我们可以将缓存属性设置为false,从而禁用所选模板引擎的缓存。例如,要禁用Thymeleaf缓存,我们只需要在application.properties中添加如下这行代码:

1
spring.thymeleaf.cache=false

唯一需要注意的是,在将应用部署到生产环境之前,一定要删除这一行代码(或者将其设置为true)。有一种方法是将该属性设置到profile中(我们将会在第5章讨论profile)。另外一种更简单的方式是使用Spring Boot的DevTools,就像我们在第1章中的做法一样。DevTools提供了很多非常有用的开发期特性,其中有一项功能就是禁用所有模板库的缓存,但是在应用部署的时候DevTools会将自身禁用掉(从而能够重新启用模板缓存)。

[^1]: 其中一个这样的例外情况就是Thymeleaf的Spring Security方言,我们将会在第4章进行讨论。

第3章 使用数据

本章内容:

  • 使用Spring的JdbcTemplate
  • 使用SimpleJdbcInsert插入数据
  • 使用Spring Data声明JPA repository

大多数应用程序提供的不仅仅是一个漂亮的界面,虽然用户界面可能会提供一些与应用程序的交互,但是应用程序和静态Web站点的区别在于它所展现和存储的数据。

在Taco Cloud应用中,我们需要维护配料、taco和订单的信息。如果没有数据库来存储信息,那么这个应用在第2章的基础上也就没有什么进展了。

在本章中,我们将会为Taco Cloud应用添加对数据持久化的支持。首先,我们会使用Spring对JDBC(Java Database Connectivity)的支持来消除样板式代码。随后,我们会使用JPA(Java Persistence API)重写数据repository,进一步消除更多的代码。

2.6 小结

  • Spring提供了一个强大的Web框架,名为Spring MVC,能够用来为Spring应用开发Web前端。
  • Spring MVC是基于注解的,通过像@RequestMapping、@GetMapping和@PostMapping这样的注解来启用请求处理方法的声明。
  • 大多数的请求处理方法最终会返回一个视图的逻辑名称,比如Thymeleaf模板,请求会转发到这样的视图上(同时会带有任意的模型数据)。
  • Spring MVC支持校验,这是通过Java Bean Validation API和ValidationAPI的实现(如Hibernate Validator)完成的。
  • 对于没有模型数据和逻辑处理的HTTP GET请求,可以使用视图控制器。
  • 除了Thymeleaf之外,Spring支持各种视图方案,包括FreeMarker、Groovy Templates和Mustache。

2.4 使用视图控制器

到目前为止,我们已经为Taco Cloud应用编写了3个控制器。尽管这3个控制器服务于应用程序的不同功能,但是它们基本上都遵循相同的编程模型:

  • 它们都使用了@Controller注解,表明它们是控制器类,并且应该被Spring的组件扫描功能自动发现并初始化为Spring应用上下文中的bean;
  • 除了HomeController之外,其他的控制器都在类级别使用了@RequestMapping注解,据此定义该控制器所处理的基本请求模式;
  • 它们都有一个或多个带@GetMapping或@PostMapping注解的方法,指明了该由哪个方法来处理某种类型的请求。

我们所编写的大部分控制器都将遵循这个模式。但是,如果一个控制器非常简单,不需要填充模型或处理输入(在我们的场景中,也就是HomeController),那么还有一种方式可以定义控制器。请参考下面的程序清单2.15来学习如何声明视图控制器:也就是只将请求转发到视图而不做其他事情的控制器。

程序清单2.15 声明视图控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
package tacos.web;
import org.springframework.context.annotation.Configuration;
import
org.springframework.web.servlet.config.annotation.ViewControllerRegistry
;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/").setViewName("home");
}
}

关于WebConfig,最需要注意的事情就是它实现了WebMvcConfigurer接口。WebMvcConfigurer定义了多个方法来配置Spring MVC。尽管只是一个接口,但是它提供了所有方法的默认实现,只需要覆盖所需的方法即可。在本例中,我们覆盖了addViewControllers方法。

addViewControllers()方法会接收一个ViewControllerRegistry对象,我们可以使用它注册一个或多个视图控制器。在这里,我们调用registry的addViewController()方法,将“/”传递了进去,视图控制器将会针对该路径执行GET请求。这个方法会返回ViewControllerRegistration对象,我们马上基于该对象调用了setViewName()方法,用它指明当请求“/”的时候要转发到“home”视图上。

如前文所示,我们用配置类中的几行代码就替换了HomeController类。现在,我们可以删除HomeController了,应用的功能应该和之前完全一样。唯一需要注意的是,我们要重新找到第1章中的HomeControllerTest类,从@WebMvcTest注解中移除对HomeController的引用,这样测试类的编译才不会报错。

在这里,我们创建了一个新的WebConfig配置类来存放视图控制器的声明。但是,所有的配置类都可以实现WebMvcConfigurer接口并覆盖addViewController方法。举例来说,我们可以将相同的视图控制器声明添加到TacoCloudApplication引导类中,如下所示:

1
2
3
4
5
6
7
8
9
10
@SpringBootApplication
public class TacoCloudApplication implements WebMvcConfigurer {
public static void main(String[] args) {
SpringApplication.run(TacoCloudApplication.class, args);
}
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/").setViewName("home");
}
}

采用扩展已有配置类的方式能够避免创建新的配置类,从而减少项目中制件的数量。但是,我倾向于为每种配置(Web、数据、安全等)创建新的配置类,这样能够保持应用的引导配置类尽可能地整洁和简单。

在视图控制器方面,或者更通俗地来讲,在控制器将请求所转发到的视图方面,到目前为止,我们都是使用Thymeleaf来实现所有视图的。我很喜欢Thymeleaf,但是你可能想要为你的应用选择不同的模板模型,下面让我们来看一下Spring所能支持的众多视图方案。

2.3 校验表单输入

在设计新的taco作品的时候,如果用户没有选择配料或者没有为他们的作品指定名称,那么将会怎样呢?当提交表单的时候,没有填写所需的地址输入域又将发生什么呢?或者,在信用卡域中输入了一个根本不合法的数字,又该怎么办呢?

就目前的情况来看,没有什么能够阻止用户在创建taco的时候不选择任何配料,或者输入空的快递地址,甚至将他们喜欢的歌词作为信用卡号进行提交。这是因为我们还没有指明这些输入域该如何进行校验。

有种校验方法就是在processDesign()和processOrder()方法中添加大量乱七八糟的if/then代码块,逐个检查,确保每个输入域都满足对应的校验规则。但是,这样会非常烦琐,并且难以阅读和调试。

比较幸运的是,Spring支持Java的Bean校验API(Bean Validation API,也被称为JSR-303)。这样的话,我们能够更容易地声明检验规则,而不必在应用程序代码中显式编写声明逻辑。借助Spring Boot,要在项目中添加校验库,我们甚至不需要做任何特殊的操作,这是因为Validation API以及Validation API的Hibernate实现将会作为Spring Boot web starter的传递性依赖自动添加到项目中。

要在Spring MVC中应用校验,我们需要。

  • 在要被校验的类上声明校验规则:具体到我们的场景中,也就是Taco类。
  • 在控制器方法中声明要进行校验:具体来讲,也就是DesignTacoController的processDesign()方法和OrderController的processOrder()方法。
  • 修改表单视图以展现校验错误。

Validation API提供了一些可以添加到领域对象上的注解,以便于声明校验规则。Hibernate的Validation AP实现又添加了一些校验注解。接下来,我们看一下如何使用其中的一些注解来校验用户提交的Taco和Order。

2.3.1 声明校验规则

对于Taco类来说,我们想要确保name属性不能为空或null,同时希望选中的配料至少要包含一项。程序清单2.10将展示更新后的Taco类,它使用@NotNull和@Size注解来声明这些校验规则。

程序清单2.10 为Taco领域类添加校验

1
2
3
4
5
6
7
8
9
10
11
12
13
package tacos;
import java.util.List;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import lombok.Data;
@Data
public class Taco {
@NotNull
@Size(min=5, message="Name must be at least 5 characters long")
private String name;
@Size(min=1, message="You must choose at least 1 ingredient")
private List<String> ingredients;
}

我们可以发现,除了要求name属性不为null之外,我们还声明了它的值在长度上至少要有5个字符。

在对提交的taco订单进行校验时,我们必须要给Order类添加注解。对于地址相关的属性,我们只想确保用户没有提交空白字段。为此,我们可以使用HibernateValidator的@NotBlank注解。

但是,支付相关的字段就比较复杂了。我们不仅要确保ccNumber属性不为空,还要保证它所包含的值是一个合法的信用卡号码。ccExpiration属性必须符合MM/YY格式(两位的月份和年份)。ccCVV属性需要是一个3位的数字。为了实现这种校验,我们需要其他的一些Java Bean Validation API注解,并结合来自Hibernate Validator的注解。程序清单2.11展现了校验Order类所需的变更。

程序清单2.11 校验订单的字段

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package tacos;
import javax.validation.constraints.Digits;
import javax.validation.constraints.Pattern;
import org.hibernate.validator.constraints.CreditCardNumber;
import javax.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class Order {
@NotBlank(message="Name is required")
private String name;
@NotBlank(message="Street is required")
private String street;
@NotBlank(message="City is required")
private String city;
@NotBlank(message="State is required")
private String state;
@NotBlank(message="Zip code is required")
private String zip;
@CreditCardNumber(message="Not a valid credit card number")
private String ccNumber;
@Pattern(regexp="^(0[1-9]|1[0-2])([\\/])([1-9][0-9])$",
message="Must be formatted MM/YY")
private String ccExpiration;
@Digits(integer=3, fraction=0, message="Invalid CVV")
private String ccCVV;
}

我们可以看到,ccNumber属性添加了@CreditCardNumber注解。这个注解声明该属性的值必须是合法的信用卡号,它要能通过Luhn算法的检查。这能防止用户有意或无意地输入错误的数据,但是该检查并不能确保这个信用卡号真的分配给了某个账户,也不能保证这个账号能够用来进行支付。

令人遗憾的是,目前还没有现成的注解来校验ccExpiration属性的MM/YY格式。在这里,我使用了@Pattern注解并为其提供了一个正则表达式,确保属性值符合预期的格式。如果你想知道如何解释这个正则表达式,那么我建议你参考一些在线的正则表达式指南。正则表达式是一种魔法,已经超出了本书的范围。

最后,在ccCVV属性上添加了@Digits注解,能够确保它的值包含3位数字。

所有的校验注解都包含了一个message属性,该属性定义了当输入的信息不满足声明的校验规则时要给用户展现的消息。

2.3.2 在表单绑定的时候执行校验

现在,我们已经声明了如何校验Taco和Order,接下来我们要重新修改每个控制器,让表单在POST提交至对应的控制器方法时执行对应的校验。

要校验提交的Taco,我们需要为DesignTacoController中processDesign()方法的Taco参数添加一个Java Bean Validation API的@Valid注解,如程序清单2.12所示。

程序清单2.12 校验POST提交的Taco

1
2
3
4
5
6
7
8
9
10
@PostMapping
public String processDesign(@Valid Taco design, Errors errors) {
if (errors.hasErrors()) {
return "design";
}
// Save the taco design...
// We'll do this in chapter 3
log.info("Processing design: " + design);
return "redirect:/orders/current";
}

@Valid注解会告诉Spring MVC要对提交的Taco对象进行校验,而校验时机是在它绑定完表单数据之后、调用processDesign()之前。如果存在校验错误,那么这些错误的细节将会捕获到一个Errors对象中并传递给processDesign()。processDesign()方法的前几行会查阅Errors对象,调用其hasErrors()方法判断是否有校验错误。如果存在校验错误,那么这个方法将不会处理Taco对象并返回“design”视图名,表单会重新展现。

为了对提交的Order对象进行校验,OrderController的processOrder()方法也需要进行类似的变更,如程序清单2.13所示。

程序清单2.13 校验POST提交的Order

1
2
3
4
5
6
7
8
@PostMapping
public String processOrder(@Valid Order order, Errors errors) {
if (errors.hasErrors()) {
return "orderForm";
}
log.info("Order submitted: " + order);
return "redirect:/";
}

在这两个场景中,如果没有校验错误,那么方法都会允许处理提交的数据。如果存在校验错误,那么请求将会被转发至表单视图上,以便让用户有机会纠正他们的错误。

但是,用户该如何知道有哪些要纠正的错误呢?如果我们无法指出表单上的错误,那么用户只能不断猜测如何才能成功提交表单。

2.3.3 展现校验错误

Thymeleaf提供了便捷访问Errors对象的方法,这就是借助fields及其th:errors属性。举例来说,为了展现信用卡字段的校验错误,我们可以添加一个<span>元素,该元素会将对错误的引用用到订单的表单模板上,如程序清单2.14所示。

程序清单2.14 展现校验错误

1
2
3
4
5
<label for="ccNumber">Credit Card #: </label>
<input type="text" th:field="*{ccNumber}"/>
<span class="validationError"
th:if="${#fields.hasErrors('ccNumber')}"
th:errors="*{ccNumber}">CC Num Error</span>

在这里,<span>元素使用class属性来为错误添加样式,以引起用户的注意。除此之外,它还使用th:if属性来决定是否要显示该元素。fields属性的hasErrors()方法会检查ccNumber域是否存在错误,如果存在,就将会渲染<span>

th:errors属性引用了ccNumber输入域,如果该输入域存在错误,那么它会将<span>元素的占位符内容替换为校验信息。

在为订单表单的其他输入域都添加类似的<span>标签之后,如果提交错误信息,那么表单将会如图2.4所示。错误信息提示姓名、城市和邮政编码字段为空,而且所有支付相关的输入域均未满足校验条件。

epub_29101559_23

图2.4 在订单表单上展现校验错误

现在,我们的Taco Cloud控制器不仅能够展现和捕获输入,还能校验用户提交的信息是否满足一定的基本验证规则。接下来,我们后退一步,重新考虑一下第1章中的HomeController,介绍一种替代实现方案。

2.2 处理表单提交

仔细看一下视图中的<form>标签,你将会发现它的method属性被设置成了POST。除此之外,<form>并没有声明action属性。这意味着当表单提交的时候,浏览器会收集表单中的所有数据,并以HTTP POST请求的形式将其发送至服务器端,发送路径与渲染表单的GET请求路径相同,也就是“/design”。

因此,在该POST请求的接收端,我们需要有一个控制器处理方法。在DesignTacoController中,我们会编写一个新的处理器方法来处理针对“/design”的POST请求。

在程序清单2.2中,我们曾经使用@GetMapping注解声明showDesignForm()方法要处理针对“/design”的HTTP GET请求。与@GetMapping处理GET请求类似,我们可以使用@PostMapping来处理POST请求。为了处理taco设计的表单提交,在DesignTacoController中添加程序清单2.4所述的processDesign()方法。

程序清单2.4 使用@PostMapping来处理POST请求

1
2
3
4
5
6
7
@PostMapping
public String processDesign(Taco design) {
// Save the taco design...
// We'll do this in chapter 3
log.info("Processing design: " + design);
return "redirect:/orders/current";
}

如processDesign()方法所示,@PostMapping与类级别的@RequestMapping协作,指定processDesign()方法要处理针对“/design”的POST请求。我们所需要的正是以这种方式处理taco艺术家的表单提交。

当表单提交的时候,表单中的输入域会绑定到Taco对象(这个类会在下面的程序清单中进行介绍)的属性中,该对象会以参数的形式传递给processDesign()。从这里开始,processDesign()就可以针对Taco对象采取任意操作了。

程序清单2.5 定义taco设计的领域对象

1
2
3
4
5
6
7
8
package tacos;
import java.util.List;
import lombok.Data;
@Data
public class Taco {
private String name;
private List<String> ingredients;
}

我们可以看到,Taco是一个非常简单的Java领域对象,其中包含了几项属性。与Ingredient类似,Taco类也添加了@Data注解,会在编译期自动生成必要的JavaBean方法,所以这些方法在运行期是可用的。

我们可以看到,Taco是一个非常简单的Java领域对象,其中包含了几项属性。与Ingredient类似,Taco类也添加了@Data注解,会在编译期自动生成必要的JavaBean方法,所以这些方法在运行期是可用的。

回过头来再看一下程序清单2.3中的表单,你会发现其中包含多个checkbox元素,它们的名字都是ingredients,另外还有一个名为name的文本输入元素。表单中的这些输入域直接对应Taco类的ingredients和name属性。

表单中的name输入域只需要捕获一个简单的文本值。因此,Taconame属性是String类型的。配料的复选框也有文本值,但是用户可能会选择一个或多个,所以它们所绑定的ingredients属性是一个List<String>,能够捕获选中的每种配料。

processDesign()方法对Taco对象没有执行任何操作。实际上,这个方法什么都没做。现在,这样是可以的。到第3章,我们将会添加一些持久化的逻辑,将提交的Taco保存到一个数据库中。

与showDesignForm()方法类似,processDesign()最后也返回了一个String类型的值。同样与showDesignForm()相似,返回的这个值代表了一个要展现给用户的视图。但是,区别在于processDesign()返回的值带有“redirect:”前缀,表明这是一个重定向视图。更具体地讲,它表明在processDesign()完成之后,用户的浏览器将会重定向到相对路径“/order/current”。

这里的想法是在创建完taco后,用户将会被重定向到一个订单表单页面,在这里用户可以创建一个订单,将他们所创建的taco快递过去。但是,我们现在还没有处理“/orders/current”请求的控制器。

根据已经学到的关于@Controller、@RequestMapping和@GetMapping的知识,我们可以很容易地创建这样的控制器。它应该如程序清单2.6所示。

程序清单2.6 展现taco订单表单的控制器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package tacos.web;
import javax.validation.Valid;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.Errors;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import lombok.extern.slf4j.Slf4j;
import tacos.Order;
@Slf4j
@Controller
@RequestMapping("/orders")
public class OrderController {
@GetMapping("/current")
public String orderForm(Model model) {
model.addAttribute("order", new Order());
return "orderForm";
}
}

在这里,我们再次使用Lombok @Slf4j注解在运行期创建一个SLF4J Logger对象。稍后,我们将会使用这个Logger记录所提交订单的详细信息。

类级别的@RequestMapping指明这个控制器的请求处理方法都会处理路径以“/orders”开头的请求。当与方法级别的@GetMapping注解结合之后,它就能够指定orderForm()方法,会处理针对“/orders/current”的HTTP GET请求。

orderForm()方法本身非常简单,只是返回了一个名为orderForm的逻辑视图名。在第3章学习完如何将所创建的taco保存到数据库之后,我们将会重新回到这个方法并对其进行修改,用一个Taco对象的列表来填充模型并将其放到订单中。

orderForm视图是由名为orderForm.html的Thymeleaf模板来提供的,如程序清单2.7所示。

程序清单2.7 一个taco订单表单视图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:th="http://www.thymeleaf.org">
<head>
<title>Taco Cloud</title>
<link rel="stylesheet" th:href="@{/styles.css}" />
</head>
<body>
<form method="POST" th:action="@{/orders}" th:object="${order}">
<h1>Order your taco creations!</h1>
<img th:src="@{/images/TacoCloud.png}"/>
<a th:href="@{/design}" id="another">Design another taco</a><br/>
<div th:if="${#fields.hasErrors()}">
<span class="validationError">
Please correct the problems below and resubmit.
</span>
</div>
<h3>Deliver my taco masterpieces to...</h3>
<label for="name">Name: </label>
<input type="text" th:field="*{name}"/>
<br/>
<label for="street">Street address: </label>
<input type="text" th:field="*{street}"/>
<br/>
<label for="city">City: </label>
<input type="text" th:field="*{city}"/>
<br/>
<label for="state">State: </label>
<input type="text" th:field="*{state}"/>
<br/>
<label for="zip">Zip code: </label>
<input type="text" th:field="*{zip}"/>
<br/>
<h3>Here's how I'll pay...</h3>
<label for="ccNumber">Credit Card #: </label>
<input type="text" th:field="*{ccNumber}"/>
<br/>
<label for="ccExpiration">Expiration: </label>
<input type="text" th:field="*{ccExpiration}"/>
<br/>
<label for="ccCVV">CVV: </label>
<input type="text" th:field="*{ccCVV}"/>
<br/>
<input type="submit" value="Submit order"/>
</form>
</body>
</html>

从很大程度上来讲,orderForm.html就是典型的HTML/Thymeleaf内容,不需要过多关注。但是,需要注意一点,这里的<form>标签和程序清单2.3中的<form>标签有所不同,它指定了一个表单的action。如果不指定action,那么表单将会以HTTP POST的形式提交到与展现该表单相同的URL上。在这里,我们明确指明表单要POST提交到“/orders”上(使用Thymeleaf的@{…}操作符指定相对上下文的路径)。

因此,我们需要在OrderController中添加另外一个方法,以便于处理针对“/orders”的POST请求。我们在第3章才会对订单进行持久化,在此之前,我们让它尽可能简单,如程序清单2.8所示。

程序清单2.8 处理taco订单的提交

1
2
3
4
5
@PostMapping
public String processOrder(Order order) {
log.info("Order submitted: " + order);
return "redirect:/";
}

当调用processOrder()方法处理所提交的订单时,我们会得到一个Order对象,它的属性绑定了所提交的表单域。Order与Taco非常相似,是一个非常简单的类,其中包含了订单的信息,如程序清单2.9所示。

程序清单2.9 taco订单的领域对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package tacos;
import javax.validation.constraints.Digits;
import javax.validation.constraints.Pattern;
import org.hibernate.validator.constraints.CreditCardNumber;
import org.hibernate.validator.constraints.NotBlank;
import lombok.Data;
@Data
public class Order {
private String name;
private String street;
private String city;
private String state;
private String zip;
private String ccNumber;
private String ccExpiration;
private String ccCVV;
}

现在,我们已经开发了OrderController和订单表单的视图,接下来我们可以尝试运行一下。打开浏览器并访问http://localhost:8080/design,为taco选择一些配料,并点击“Submit Your Taco”按钮,将会看到如图2.3所示的一个表单。

epub_29101559_22

图2.3 taco订单表单

填充表单的一些输入域并点击“Submit order”按钮。请关注应用的日志来查看你的订单信息。在我尝试运行的时候,日志条目如下所示(为了适应页面的宽度,重新进行了格式化):

1
2
3
Order submitted: Order(name=Craig Walls,street1=1234 7th Street,
city=Somewhere, state=Who knows?, zip=zipzap, ccNumber=Who can guess?,
ccExpiration=Some day, ccCVV=See-vee-vee)

如果仔细查看上述测试订单的日志,就会发现尽管processOrder()方法完成了它的工作并处理了表单提交,但是它让一些错误的信息混入了进来。表单中的大多数输入域包含的可能都是不正确的信息。我们接下来添加一些校验,确保所提交的数据至少与所需的信息比较接近。