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()方法完成了它的工作并处理了表单提交,但是它让一些错误的信息混入了进来。表单中的大多数输入域包含的可能都是不正确的信息。我们接下来添加一些校验,确保所提交的数据至少与所需的信息比较接近。

2.1 展现信息

从根本上来讲,Taco Cloud是一个可以在线订购taco的地方。但是,除此之外,Taco Cloud允许客户展现其创意,能够让他们通过丰富的配料(ingredient)设计自己的taco。

因此,Taco Cloud需要有一个页面为taco艺术家展现都可以选择哪些配料。可选的配料可能随时会发生变化,所以不能将它们硬编码到HTML页面中。我们应该从数据库中获取可用的配料并将其传递给页面,进而展现给客户。

在Spring Web应用中,获取和处理数据是控制器的任务,而将数据渲染到HTML中并在浏览器中展现则是视图的任务。为了支撑taco的创建页面,我们需要构建如下组件。

  • 用来定义taco配料属性的领域类。
  • 用来获取配料信息并将其传递至视图的Spring MVC控制器类。
  • 用来在用户的浏览器中渲染配料列表的视图模板。

这些组件之间的关系如图2.1所示。

epub_29101559_17

图2.1 典型的Spring MVC请求流

因为本章主要关注Spring的Web框架,所以我们会将数据库相关的内容放到第3章中进行讲解。现在的控制器只负责向视图提供配料。在第3章中,我们会重新改造这个控制器,让它能够与repository协作,从数据库中获取配料数据。

在编写控制器和视图之前,我们首先确定一下用来表示配料的领域类型,它会为我们开发Web组件奠定基础。

2.1.1 构建领域类

应用的领域指的是它所要解决的主题范围:也就是会影响到对应用理解的理念和概念[^1]。在Tao Cloud应用中,领域对象包括taco设计、组成这些设计的配料、顾客以及顾客所下的订单。作为开始,我们首先关注taco的配料。

在我们的领域中,taco配料是非常简单的对象。每种配料都有一个名称和类型,以便于对其进行可视化的分类(蛋白质、奶酪、酱汁等)。每种配料还有一个ID,这样的话对它的引用就能非常容易和明确。如下的Ingredient类定义了我们所需的领域对象。

程序清单2.1 定义taco配料

1
2
3
4
5
6
7
8
9
10
11
12
13
package tacos;
import lombok.Data;
import lombok.RequiredArgsConstructor;
@Data
@RequiredArgsConstructor
public class Ingredient {
private final String id;
private final String name;
private final Type type;
public static enum Type {
WRAP, PROTEIN, VEGGIES, CHEESE, SAUCE
}
}

我们可以看到,这是一个非常普通的Java领域类,它定义了描述配料所需的3个属性。在程序清单2.1中,Ingredient类最不寻常的一点是它似乎缺少了常见的getter和setter方法,以及equals()、hashCode()、toString()等方法。

在程序清单2.1中没有这些方法,部分原因是节省空间,此外还因为我们使用了名为Lombok的库(这是一个非常棒的库,它能够在运行时动态生成这些方法)。实际上,类级别的@Data注解就是由Lombok提供的,它会告诉Lombok生成所有缺失的方法,同时还会生成所有以final属性作为参数的构造器。通过使用Lombok,我们能够让Ingredient的代码简洁明了。

Lombok并不是Spring库,但是它非常有用,我发现如果没有它,开发工作将很难开展。当我需要在书中将代码示例编写得短小简洁时,它简直成了我的救星。

要使用Lombok,首先要将其作为依赖添加到项目中。如果你使用Spring ToolSuite,那么只需要用右键点击pom.xml,并从Spring上下文菜单选项中选择“Edit Starters”。在第1章中看到的选择依赖的对话框将会再次出现(见图1.4),这样的话我们就有机会添加依赖或修改已选择的依赖了。找到Lombok选项,并确保它处于已选中的状态,然后点击“OK”,Spring Tool Suite会自动将其添加到构建规范中。

另外,你也可以在pom.xml中通过如下条目进行手动添加:

1
2
3
4
5
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>

这个依赖将会在开发阶段为你提供Lombok注解(例如@Data),并且会在运行时进行自动化的方法生成。但是,我们还需要将Lombok作为扩展添加到IDE上,否则IDE将会报错,提示缺少方法和final属性没有赋值。参见Lombok项目页面,以查阅如何在你所选择的IDE上安装Lombok。

我相信你会发现Lombok非常有用,但你也要知道,它是可选的。在开发Spring应用的时候,它并不是必备的,所以如果你不想使用它的话,完全可以手动编写这些缺失的方法。当你完成之后,我们将会在应用中添加一些控制器,让它们来处理Web请求。

2.1.2 创建控制器类

在Spring MVC框架中,控制器是重要的参与者。它们的主要职责是处理HTTP请求,要么将请求传递给视图以便于渲染HTML(浏览器展现),要么直接将数据写入响应体(RESTful)。在本章中,我们将会关注使用视图来为Web浏览器生成内容的控制器。在第6章中,我们将会看到如何以REST API的形式编写控制器来处理请求。

对于Taco Cloud应用来说,我们需要一个简单的控制器,它要完成如下功能。

  • 处理路径为“/design”的HTTP GET请求。
  • 构建配料的列表。
  • 处理请求,并将配料数据传递给要渲染为HTML的视图模板,发送给发起请求的Web浏览器。

程序清单2.2中的DesignTacoController类解决了这些需求。

程序清单2.2 初始的Spring控制器类

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
package tacos.web;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import lombok.extern.slf4j.Slf4j;
import tacos.Taco;
import tacos.Ingredient;
import tacos.Ingredient.Type;
@Slf4j
@Controller
@RequestMapping("/design")
public class DesignTacoController {
@GetMapping
public String showDesignForm(Model model) {
List<Ingredient> ingredients = Arrays.asList(
new Ingredient("FLTO", "Flour Tortilla", Type.WRAP),
new Ingredient("COTO", "Corn Tortilla", Type.WRAP),
new Ingredient("GRBF", "Ground Beef", Type.PROTEIN),
new Ingredient("CARN", "Carnitas", Type.PROTEIN),
new Ingredient("TMTO", "Diced Tomatoes", Type.VEGGIES),
new Ingredient("LETC", "Lettuce", Type.VEGGIES),
new Ingredient("CHED", "Cheddar", Type.CHEESE),
new Ingredient("JACK", "Monterrey Jack", Type.CHEESE),
new Ingredient("SLSA", "Salsa", Type.SAUCE),
new Ingredient("SRCR", "Sour Cream", Type.SAUCE)
);
Type[] types = Ingredient.Type.values();
for (Type type : types) {
model.addAttribute(type.toString().toLowerCase(),
filterByType(ingredients, type));
}
model.addAttribute("design", new Taco());
return "design";
}
private List<Ingredient> filterByType(
<Ingredient> ingredients, Type type) {
return ingredients
.stream()
.filter(x -> x.getType().equals(type))
.collect(Collectors.toList());
}
}

对于DesignTacoController,我们先要注意在类级别所应用的注解。首先是@Slf4j,这是Lombok所提供的注解,在运行时,它会在这个类中自动生成一个SLF4J(Simple Logging Facade for Java)Logger。这个简单的注解和在类中通过如下代码显式声明的效果是一样的:

1
2
private static final org.slf4j.Logger log =
org.slf4j.LoggerFactory.getLogger(DesignTacoController.class);

随后,我们将会用到这个Logger。

DesignTacoController用到的下一个注解是@Controller。这个注解会将这个类识别为控制器,并且将其作为组件扫描的候选者,所以Spring会发现它并自动创建一个DesignTacoController实例,并将该实例作为Spring应用上下文中的bean。

DesignTacoController还带有@RequestMapping注解。当@RequestMapping注解用到类级别的时候,它能够指定该控制器所处理的请求类型。在本例中,它规定DesignTacoController将会处理路径以“/design”开头的请求。

处理GET请求

修饰showDesignForm()方法的@GetMapping注解对类级别的@RequestMapping进行了细化。@GetMapping结合类级别的@RequestMapping,指明当接收到对“/design”的HTTP GET请求时,将会调用showDesignForm()来处理请求。

@GetMapping是一个相对较新的注解,是在Spring 4.3引入的。在Spring 4.3之前,你可能需要使用方法级别的@RequestMapping注解作为替代:

1
@RequestMapping(method=RequestMethod.GET)

显然,@GetMapping更加简洁,并且指明了它的目标HTTP方法。@GetMapping只是诸多请求映射注解中的一个。表2.1列出了Spring MVC中所有可用的请求映射注解。

表2.1 Spring MVC的请求映射注解

epub_29101559_19


让正确的事情变得更容易

在为控制器方法声明请求映射时,越具体越好。这意味着至少要声明路径(或者从类级别的@RequestMapping继承一个路径)以及它所处理的HTTP方法。

但是更长的@RequestMapping(method=RequestMethod.GET)注解很容易让开发人员采取懒惰的方式,也就是忽略掉method属性。幸亏有了Spring 4.3的新注解,正确的事情变得更容易了,我们的输入变得更少了。

新的请求映射注解具有和@RequestMapping完全相同的属性,所以我们可以在使用@RequestMapping的任何地方使用它们。

通常,我喜欢只在类级别上使用@RequestMapping,以便于指定基本路径。在每个处理器方法上,我会使用更具体的@GetMapping@PostMapping等注解。


现在,我们已经知道showDesignForm()方法会处理请求,接下来我们看一下方法体,看它都做了些什么工作。这个方法构建了一个Ingredient对象的列表。现在,这个列表是硬编码的。当我们学习第3章的时候,会从数据库中获取可用taco配料并将其放到列表中。

配料列表准备就绪之后,showDesignForm()方法接下来的几行代码会根据配料类型过滤列表。配料类型的列表会作为属性添加到Model对象上,这个对象是以参数的形式传递给showDesignForm()方法的。Model对象负责在控制器和展现数据的视图之间传递数据。实际上,放到Model属性中的数据将会复制到ServletResponse的属性中,这样视图就能在这里找到它们了。showDesignForm()方法最后返回“design”,这是视图的逻辑名称,会用来将模型渲染到视图上。

我们的DesignTacoController已经具备雏形了。如果你现在运行应用并在浏览器上访问“/design”路径,DesignTacoController的showDesignForm()将会被调用,它会从repository中获取数据并放到模型中,然后将请求传递给视图。但是,我们现在还没有定义视图,请求将会遇到很糟糕的问题,也就是HTTP 404(NotFound)。为了解决这个问题,我们将注意力切换到视图上,在这里数据将会使用HTML进行装饰,以便于在用户的Web浏览器中进行展现。

2.1.3 设计视图

在控制器完成它的工作之后,现在就该视图登场了。Spring提供了多种定义视图的方式,包括JavaServer Pages(JSP)、Thymeleaf、FreeMarker、Mustache和基于Groovy的模板。就现在来讲,我们会使用Thymeleaf,这也是我们在第1章开启这个项目时的选择。我们会在第2.5节考虑其他的可选方案。

为了使用Thymeleaf,我们需要添加另外一个依赖到项目构建中。如下的<dependency>条目使用了Spring Boot的Thymeleaf starter,从而能够让Thymeleaf渲染我们将要创建的视图:

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

在运行时,Spring Boot的自动配置功能会发现Thymeleaf在类路径中,因此会为Spring MVC创建支撑Thymeleaf视图的bean。

像Thymeleaf这样的视图库在设计时是与特定的Web框架解耦的。这样的话,它们无法感知Spring的模型抽象,因此无法与控制器放到Model中的数据协同工作。但是,它们可以与Servlet的request属性协作。所以,在Spring将请求转移到视图之前,它会把模型数据复制到request属性中,Thymeleaf和其他的视图模板方案就能访问到它们了。

Thymeleaf模板就是增加一些额外元素属性的HTML,这些属性能够指导模板如何渲染request数据。举例来说,如果有一个请求属性的key为“message”,我们想要使用Thymeleaf将其渲染到一个HTML <p>标签中,那么在Thymeleaf模板中我们可以这样写:

1
<p th:text="${message}">placeholder message</p>

当模板渲染成HTML的时候,<p>元素体将会被替换为Servlet Request中key为“message”的属性值。“th:text”是Thymeleaf命名空间中的属性,它会执行这个替换过程。${}会告诉它要使用某个请求属性(在本例中,也就是“message”)中的值。

Thymeleaf还提供了一个属性“th:each”,它会迭代一个元素集合,为集合中的每个条目渲染HTML。在我们设计视图展现模型中的配料列表时,这就非常便利了。举例来说,如果只想渲染“wrap”配料的列表,我们可以使用如下的HTML片段:

1
2
3
4
5
<h3>Designate your wrap:</h3>
<div th:each="ingredient : ${wrap}">
<input name="ingredients" type="checkbox" th:value="${ingredient.id}" />
<span th:text="${ingredient.name}">INGREDIENT</span><br/>
</div>

在这里,我们在<div>标签中使用th:each属性,这样的话就能针对wrap request属性所对应集合中的每个元素重复渲染<div>了。在每次迭代的时候,配料元素都会绑定到一个名为ingredient的Thymeleaf变量上。

<div>元素中,有一个<input>复选框元素,还有一个为复选框提供标签的<span>元素。复选框使用Thymeleafth:value来为渲染出的<input>元素设置value属性,这里会将其设置为所找到的ingredientid属性。<span>元素使用th:text将“INGREDIENT”占位符文本替换为ingredientname属性。

当用实际的模型数据进行渲染的时候,其中一个<div>迭代的渲染结果可能会如下所示:

1
2
3
4
<div>
<input name="ingredients" type="checkbox" value="FLTO" />
<span>Flour Tortilla</span><br/>
</div>

最终,上述的Thymeleaf片段会成为一大段HTML表单的一部分,我们taco艺术家用户会通过这个表单来提交其美味的作品。完整的Thymeleaf模板会包括所有的配料类型,表单如程序清单2.3所示:

程序清单2.3 设计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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
<!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>
<h1>Design your taco!</h1>
<img th:src="@{/images/TacoCloud.png}"/>
<form method="POST" th:object="${design}">
<div class="grid">
<div class="ingredient-group" id="wraps">
<h3>Designate your wrap:</h3>
<div th:each="ingredient : ${wrap}">
<input name="ingredients" type="checkbox" th:value="${ingredient.id}"
/>
<span th:text="${ingredient.name}">INGREDIENT</span><br/>
</div>
</div>
<div class="ingredient-group" id="proteins">
<h3>Pick your protein:</h3>
<div th:each="ingredient : ${protein}">
<input name="ingredients" type="checkbox" th:value="${ingredient.id}"
/>
<span th:text="${ingredient.name}">INGREDIENT</span><br/>
</div>
</div>
<div class="ingredient-group" id="cheeses">
<h3>Choose your cheese:</h3>
<div th:each="ingredient : ${cheese}">
<input name="ingredients" type="checkbox" th:value="${ingredient.id}"
/>
<span th:text="${ingredient.name}">INGREDIENT</span><br/>
</div>
</div>
<div class="ingredient-group" id="veggies">
<h3>Determine your veggies:</h3>
<div th:each="ingredient : ${veggies}">
<input name="ingredients" type="checkbox" th:value="${ingredient.id}"
/>
<span th:text="${ingredient.name}">INGREDIENT</span><br/>
</div>
</div>
<div class="ingredient-group" id="sauces">
<h3>Select your sauce:</h3>
<div th:each="ingredient : ${sauce}">
<input name="ingredients" type="checkbox" th:value="${ingredient.id}"
/>
<span th:text="${ingredient.name}">INGREDIENT</span><br/>
</div>
</div>
</div>
<div>
<h3>Name your taco creation:</h3>
<input type="text" th:field="*{name}"/>
<br/>
<button>Submit your taco</button>
</div>
</form>
</body>
</html>

可以看到,我们会为各种类型的配料重复定义<div>片段。另外,我们还包含了一个Submit按钮,以及用户用来定义其作品名称的输入域。

值得注意的是,完整的模板包含了一个Taco Cloud的logo图片以及对样式表的<link>引用[^2]。在这两个场景中,都使用了Thymeleaf的@{}操作符,用来生成一个相对上下文的路径,以便于引用我们需要的静态制件(artifact)。正如我们在第1章中所学到的,在Spring Boot应用中,静态内容要放到根路径的“/static”目录下。

我们的控制器和视图已经完成了,现在我们可以将应用启动起来,看一下我们的劳动成果。运行Spring Boot应用有很多种方式。在第1章中,我为你首先展示了如何将应用构建成一个可执行的JAR文件,然后通过java –jar命令来运行这个JAR。我还展示了如何直接通过mvn spring-boot:run构建命令来运行应用。

不管你采用哪种方式来启动Taco Cloud应用,在启动之后都可以通过 http://localhost:8080/design 来进行访问。你将会看到如图2.2所示的一个页面。

epub_29101559_21

图2.2 渲染之后的taco设计页面

这看上去非常不错!访问你站点的taco艺术家可以看到一个表单,这个表单中包含了各种taco配料,他们可以使用这些配料创建自己的杰作。但是当他们点击Submit Your Taco按钮的时候会发生什么呢?

我们的DesignTacoController还没有为接收创建taco的请求做好准备。如果提交设计表单,用户就会遇到一个错误(具体来讲,将会是一个HTTP 405错误:Request Method “POST” Not Supported)。接下来,我们通过编写一些处理表单提交的控制器代码来修正这个错误。

[^1]: 如果你想更深入地了解应用领域,我推荐你阅读Eric Evans的《领域驱动设计》(ISBN978-7-115-37675-6,人民邮电出版社出版)。
[^2]: 样式表的内容与我们的讨论无关,它只是包含了让配料两列显示的样式,避免出现一个很长的配料列表。

第2章 开发Web应用


本章内容:

  • 在浏览器中展现模型数据
  • 处理和校验表单输入
  • 选择视图模板库

第一印象是非常重要的:Curb Appeal能够在购房者真正进门之前就将房子卖掉;如果一辆车喷成了樱桃色,那么它的油漆会比它的引擎更引人注目;文学作品中充满了一见钟情的故事。内在固然非常重要,但是外在的,也就是第一眼看到的东西同样非常重要。

我们使用Spring所构建的应用能完成各种各样的事情,包括处理数据、从数据库中读取信息以及与其他应用进行交互。但是,用户对应用程序的第一印象来源于用户界面。在很多应用中,UI是以浏览器中的Web应用的形式来展现的。

在第1章中,我们创建了第一个Spring MVC控制器来展现应用的主页。但是,Spring MVC能做很多的事情,并不局限于展现静态内容。在本章中,我们将会开发Taco Cloud的第一个主要功能:设计定制taco的能力。在这个过程中,我们将会深入研究Spring MVC并会看到如何展现模型数据和处理表单输入。

1.5 小结

  • Spring旨在简化开发人员所面临的挑战,比如创建Web应用程序、处理数据库、保护应用程序以及实现微服务。
  • Spring Boot构建在Spring之上,通过简化依赖管理、自动配置和运行时洞察,使Spring更加易用。
  • Spring应用程序可以使用Spring Initializr进行初始化。Spring Initializr是基于Web的应用,并且为大多数Java开发环境提供了原生支持。
  • 在Spring应用上下文中,组件(通常称为bean)既可以使用Java或XML显式声明,也可以通过组件扫描发现,还可以使用Spring Boot自动配置功能实现自动化配置。