3.1 使用JDBC读取和写入数据

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的使用。