3.1 使用 JDBC 读写数据

几十年来,关系数据库和 SQL 一直是数据持久化的首选。尽管近年来出现了许多替代数据库类型,但关系数据库仍然是通用数据存储的首选,而且不太可能很快被取代。

在处理关系数据时,Java 开发人员有多个选择。两个最常见的选择是 JDBC 和 JPA。Spring 通过抽象支持这两种方式,这使得使用 JDBC 或 JPA 比不使用 Spring 更容易。在本节中,我们将重点讨论 Spring 是如何支持 JDBC 的,然后在第 3.2 节中讨论 Spring 对 JPA 的支持。

Spring JDBC 支持起源于 JdbcTemplate 类。JdbcTemplate 提供了一种方法,通过这种方法,开发人员可以对关系数据库执行 SQL 操作,与通常使用 JDBC 不同的是,这里不需要满足所有的条件和样板代码。

为了更好地理解 JdbcTemplate 的作用,我们首先来看一个示例,看看如何在没有 JdbcTemplate 的情况下用 Java 执行一个简单的查询。程序清单 3.1 不使用 JdbcTemplate 查询数据库

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

在程序清单 3.1 的某个地方,有几行代码用于查询数据库中的 ingredients。但是很难在 JDBC 的混乱代码中找到查询指针。它被创建连接、创建语句和通过关闭连接、语句和结果集来清理的代码所包围。

更糟糕的是,在创建连接或语句或执行查询时,可能会出现许多问题。这要求捕获一个 SQLException,这可能有助于(也可能无助于)找出问题出在哪里或如何解决问题。

SQLException 是一个被检查的异常,它需要在 catch 块中进行处理。但是最常见的问题,如未能创建到数据库的连接或输入错误的查询,不可能在 catch 块中得到解决,可能会重新向上抛出以求处理。相反,要是考虑使用 JdbcTemplate 的方法。程序清单 3.2 使用 JdbcTemplate 查询数据库

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

程序清单 3.2 中的代码显然比程序清单 3.1 中的原始 JDBC 示例简单得多;没有创建任何语句或连接。而且,在方法完成之后,不会对那些对象进行任何清理。最后,这样做不会存在任何在 catch 块中不能处理的异常。剩下的代码只专注于执行查询(调用 JdbcTemplate 的 queryForObject() 方法)并将结果映射到 Ingredient 对象(在 mapRowToIngredient() 方法中)。

程序清单 3.2 中的代码是使用 JdbcTemplate 在 Taco Cloud 应用程序中持久化和读取数据所需要做的工作的一个片段。让我们采取下一步必要的步骤来为应用程序配备 JDBC 持久话。我们将首先对域对象进行一些调整。

3.1.1 为域适配持久化

在将对象持久化到数据库时,通常最好有一个惟一标识对象的字段。Ingredient 类已经有一个 id 字段,但是需要向 Taco 和 Order 添加 id 字段。

此外,了解何时创建 Taco 以及何时放置 Order 可能很有用。还需要向每个对象添加一个字段,以捕获保存对象的日期和时间。下面的程序清单显示了 Taco 类中需要的新 id 和 createdAt 字段。程序清单 3.3 向 Taco 类添加 id 和 timestamp 字段

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

因为使用 Lombok 在运行时自动生成访问器方法,所以除了声明 id 和 createdAt 属性外,不需要做任何事情。它们将在运行时根据需要生成适当的 getter 和 setter 方法。Order 类也需要做类似的修改,如下所示:

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

同样,Lombok 会自动生成访问字段的方法,因此只需要按顺序进行这些更改。(如果由于某种原因选择不使用 Lombok,那么需要自己编写这些方法。)

域类现在已经为持久化做好了准备。让我们看看如何使用 JdbcTemplate 在数据中对它们进行读写。

3.1.2 使用 JdbcTemplate

在开始使用 JdbcTemplate 之前,需要将它添加到项目类路径中。这很容易通过添加 Spring Boot 的 JDBC starter 依赖来实现:

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

还需要一个存储数据的数据库。出于开发目的,嵌入式数据库也可以。我喜欢 H2 嵌入式数据库,所以我添加了以下依赖进行构建:

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

稍后,将看到如何配置应用程序来使用外部数据库。但是现在,让我们继续编写一个获取和保存 Ingredient 数据的存储库。

定义 JDBC 存储库

Ingredient repository 需要执行以下操作:

  • 查询所有的 Ingredient 使之变成一个 Ingredient 的集合对象
  • 通过它的 id 查询单个 Ingredient
  • 保存一个 Ingredient 对象

以下 IngredientRepository 接口将这三种操作定义为方法声明:

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

尽管该接口体现了需要 Ingredient repository 做的事情的本质,但是仍然需要编写一个使用 JdbcTemplate 来查询数据库的 IngredientRepository 的实现。下面的程序清单是编写实现的第一步。程序清单 3.4 使用 JdbcTemplate 开始编写 Ingredient repository

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

JdbcIngredientRepository 使用 @Repository 进行了注解。这个注解是 Spring 定义的少数几个原型注解之一,包括 @Controller 和 @Component。通过使用 @Repository 对 JdbcIngredientRepository 进行注解,这样它就会由 Spring 组件在扫描时自动发现,并在 Spring 应用程序上下文中生成 bean 实例。

当 Spring 创建 JdbcIngredientRepository bean 时,通过 @Autowired 注解将 JdbcTemplate 注入到 bean 中。构造函数将 JdbcTemplate 分配给一个实例变量,该变量将在其他方法中用于查询和插入数据库。谈到那些其他方法,让我们来看看 findAll() 和 findById() 的实现。程序清单 3.5 使用 JdbcTemplate 查询数据库

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

findAll() 和 findById() 都以类似的方式使用 JdbcTemplate。期望返回对象集合的 findAll() 方法使用了 JdbcTemplate 的 query() 方法。query() 方法接受查询的 SQL 以及 Spring 的 RowMapper 实现,以便将结果集中的每一行映射到一个对象。findAll() 还接受查询中所需的所有参数的列表作为它的最后一个参数。但是,在本例中,没有任何必需的参数。

findById() 方法只期望返回单个成分对象,因此它使用 JdbcTemplate 的 queryForObject() 方法而不是 query()。queryForObject() 的工作原理与 query() 非常相似,只是它返回的是单个对象,而不是对象列表。在本例中,它给出了要执行的查询、一个 RowMapper 和要获取的 Ingredient 的 id,后者用于代替查询 SQL 中 的 ?

如程序清单 3.5 所示,findAll() 和 findById() 的 RowMapper 参数作为 mapRowToIngredient() 方法的方法引用。当使用 JdbcTemplate 作为显式 RowMapper 实现的替代方案时,使用 Java 8 的方法引用和 lambda 非常方便。但是,如果出于某种原因,想要或是需要一个显式的 RowMapper,那么 findAll() 的以下实现将展示如何做到这一点:

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

从数据库读取数据只是问题的一部分。在某些情况下,必须将数据写入数据库以便能够读取。因此,让我们来看看如何实现 save() 方法。

插入一行

JdbcTemplate 的 update() 方法可用于在数据库中写入或更新数据的任何查询。并且,如下面的程序清单所示,它可以用来将数据插入数据库。程序清单 3.6 使用 JdbcTemplate 插入数据

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

因为没有必要将 ResultSet 数据映射到对象,所以 update() 方法要比 query() 或 queryForObject() 简单得多。它只需要一个包含 SQL 的字符串来执行,以及为任何查询参数赋值。在本例中,查询有三个参数,它们对应于 save() 方法的最后三个参数,提供了 Ingredient 的 id、name 和 type。

完成了 JdbcIngredientRepository后,现在可以将其注入到 DesignTacoController 中,并使用它来提供一个 Ingredient 对象列表,而不是使用硬编码的值(正如第 2 章中所做的那样)。DesignTacoController 的变化如下所示。程序清单 3.7 在控制器中注入并使用 repository

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

请注意,showDesignForm() 方法的第 2 行现在调用了注入的 IngredientRepository 的 findAll() 方法。findAll() 方法从数据库中提取所有 Ingredient,然后将它们对应到到模型的不同类型中。

几乎已经准备好启动应用程序并尝试这些更改了。但是在开始从查询中引用的 Ingredient 表读取数据之前,可能应该创建这个表并写一些 Ingredient 数据进去。

3.1.3 定义模式并预加载数据

除了 Ingredient 表之外,还需要一些保存订单和设计信息的表。图 3.1 说明了需要的表以及这些表之间的关系。

图 3.1 Taco Cloud 数据表

图 3.1 Taco Cloud 数据表

图 3.1中的表有以下用途:

  • Ingredient - 保存着原料信息
  • Taco - 保存着关于 taco 设计的重要信息
  • Taco_Ingredient - 包含 Taco 表中每一行的一个或多行数据,将 Taco 映射到该 Taco 的 Ingredient
  • Taco_Order - 保存着重要的订单细节
  • Taco_Order_Tacos - 包含 Taco_Order 表中的每一行的一个或多行数据,将 Order 映射到 Order 中的Tacos

下一个程序清单显示了创建表的 SQL 语句。程序清单 3.8 定义 Taco Cloud 模式

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

最大的问题是把这个模式定义放在哪里。事实证明,Spring Boot 回答了这个问题。

如果有一个名为 schema.sql 的文件。在应用程序的类路径根目录下执行 sql,然后在应用程序启动时对数据库执行该文件中的 SQL。因此,应该将程序清单 3.8 的内容写入一个名为 schema.sql 的文件中,然后放在项目的 src/main/resources 文件夹下。

还需要用一些 Ingredient 数据来预加载数据库。幸运的是,Spring Boot 还将执行一个名为 data.sql 的文件,这个文件位于根路径下。因此,可以使用 src/main/resources/data.sql 中的下面程序清单中的 insert 语句来加载包含 Ingredient 数据的数据库。程序清单 3.9 预加载数据库

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

即使只开发了 Ingredient 数据的存储库,也可以启动 Taco Cloud 应用程序并访问设计页面,查看JdbcIngredientRepository 的运行情况。继续……试试吧。当回到代码中时,可以继续编写用于持久化 Taco、Order 的存储库和相应的数据。

3.1.4 插入数据

到此,已经了解了如何使用 JdbcTemplate 向数据库写入数据。JdbcIngredientRepository 中的 save() 方法使用 JdbcTemplate 的 update() 方法将 Ingredient 对象保存到数据库中。

虽然这是第一个很好的例子,但是它可能有点太简单了。保存数据可能比 JdbcIngredientRepository 所需要的更复杂。使用 JdbcTemplate 保存数据的两种方法包括:

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

让我们首先看看,当持久话需求比保存一个 Ingredient 所需要的更复杂时,如何使用 update() 方法。

使用 JdbcTemplate 保存数据

目前,Taco 和 Order 存储库需要做的惟一一件事是保存它们各自的对象。为了保存 Taco 对象,TacoRepository 声明了一个 save() 方法,如下所示:

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

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

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

看起来很简单,对吧?没那么快。保存一个 Taco 设计需要将与该 Taco 关联的 Ingredient 保存到 Taco_Ingredient 表中。同样,保存 Order 也需要将与 Order 关联的 Taco 保存到 Taco_Order_Tacos 表中。这使得保存 Taco 和 Order 比 保存 Ingredient 更有挑战性。

要实现 TacoRepository,需要一个 save() 方法,该方法首先保存基本的 Taco 设计细节(例如,名称和创建时间),然后为 Taco 对象中的每个 Ingredient 在 Taco_Ingredients 中插入一行。下面的程序清单显示了完整的 JdbcTacoRepository 类。程序清单 3.10 使用 JdbcTemplate 实现 TacoRepository

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

save() 方法首先调用私有的 saveTacoInfo() 方法,然后使用该方法返回的 Taco id 调用 saveIngredientToTaco(),它保存每个成分。关键在于 saveTacoInfo() 的细节。

在 Taco 中插入一行时,需要知道数据库生成的 id,以便在每个 Ingredient 中引用它。保存 Ingrendient 数据时使用的 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 中的每个成分,调用 saveIngredientToTaco() 方法。saveIngredientToTaco() 方法使用更简单的 update() 形式来保存对 Taco_Ingredient 表引用。

TacoRepository 剩下所要做的就是将它注入到 DesignTacoController 中,并在保存 Taco 时使用它。下面的程序清单显示了注入存储库所需的改变。程序清单 3.11 注入并使用 TacoRepository

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

构造函数包含一个 IngredientRepository 和一个TacoRepository。它将这两个变量都赋值给实例变量,以便它们可以在 showDesignForm() 和 processDesign() 方法中使用。

说到 processDesign() 方法,它的更改比 showDesignForm() 所做的更改要广泛一些。下一个程序清单显示了新的 processDesign() 方法。程序清单 3.12 保存 Taco 设计并链接到 Order

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

关于程序清单 3.12 中的代码,首先注意到的是 DesignTacoController 现在使用 @SessionAttributes(“order”) 进行了注解,并且在 order() 方法上有一个新的注解 @ModelAttribute。与 taco() 方法一样,order() 方法上的 @ModelAttribute 注解确保在模型中能够创建 Order 对象。但是与 session 中的 Taco 对象不同,这里需要在多个请求间显示订单,因此可以创建多个 Taco 并将它们添加到订单中。类级别的 @SessionAttributes 注解指定了任何模型对象,比如应该保存在会话中的 order 属性,并且可以跨多个请求使用。

taco 设计的实际处理发生在 processDesign() 方法中,除了 Taco 和 Errors 对象外,该方法现在还接受 Order 对象作为参数。Order 参数使用 @ModelAttribute 进行注解,以指示其值应该来自模型,而 Spring MVC 不应该试图给它绑定请求参数。

在检查验证错误之后,processDesign() 使用注入的 TacoRepository 来保存 Taco。然后,它将 Taco 对象添加到保存于 session 中 Order 对象中。

实际上,Order 对象仍然保留在 session 中,直到用户完成并提交 Order 表单才会保存到数据库中。此时,OrderController 需要调用 OrderRepository 的实现来保存订单。我们来写一下这个实现。

使用 SimpleJdbcInsert 插入数据

保存一个 taco 不仅要将 taco 的名称和创建时间保存到 Taco 表中,还要将与 taco 相关的配料的引用保存到 Taco_Ingredient 表中。对于这个操作还需要知道 Taco 的 id,这是使用 KeyHolder 和 PreparedStatementCreator 来获得的。

在保存订单方面,也存在类似的情况。不仅必须将订单数据保存到 Taco_Order 表中,还必须引用 Taco_Order_Tacos 表中的每个 taco。但是不是使用繁琐的 PreparedStatementCreator, 而是使用SimpleJdbcInsert, SimpleJdbcInsert 是一个包装了 JdbcTemplate 的对象,它让向表插入数据的操作变得更容易。

首先创建一个 JdbcOrderRepository,它是 OrderRepository 的一个实现。但是在编写 save() 方法实现之前,让我们先关注构造函数,在构造函数中,将创建两个 SimpleJdbcInsert 实例,用于将值插入 Taco_Order 和 Taco_Order_Tacos 表中。下面的程序清单显示了 JdbcOrderRepository(没有 save() 方法)。程序清单 3.13 从 JdbcTemplate 创建一个 SimpleJdbcTemplate

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

与 JdbcTacoRepository 一样,JdbcOrderRepository 也通过其构造函数注入了 JdbcTemplate。但是,构造函数并没有将 JdbcTemplate 直接分配给一个实例变量,而是使用它来构造两个 SimpleJdbcInsert 实例。

第一个实例被分配给 orderInserter 实例变量,它被配置为使用 Taco_Order 表,并假定 id 属性将由数据库提供或生成。分配给 orderTacoInserter 的第二个实例被配置为使用 Taco_Order_Tacos 表,但是没有声明如何在该表中生成任何 id。

构造函数还创建 ObjectMapper 实例,并将其分配给实例变量。尽管 Jackson 用于 JSON 处理,但稍后将看到如何重新使用它来帮助保存订单及其关联的 tacos。

现在让我们看看 save() 方法如何使用 SimpleJdbcInsert 实例。下一个程序清单显示了 save() 方法,以及几个用于实际工作的 save() 委托的私有方法。程序清单 3.14 使用 SimpleJdbcInsert 插入数据

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

save() 方法实际上并不保存任何东西。它定义了保存订单及其关联 Taco 对象的流,并将持久性工作委托给 saveOrderDetails() 和 saveTacoToOrder()。

SimpleJdbcInsert 有两个执行插入的有用方法:execute() 和 executeAndReturnKey()。两者都接受 Map,其中 Map 键对应于数据插入的表中的列名,映射的值被插入到这些列中。

通过将 Order 中的值复制到 Map 的条目中,很容易创建这样的 Map。但是 Order 有几个属性,这些属性和它们要进入的列有相同的名字。因此,在 saveOrderDetails() 中,我决定使用 Jackson 的 ObjectMapper 及其 convertValue() 方法将 Order 转换为 Map。这是必要的,否则 ObjectMapper 会将 Date 属性转换为 long,这与 Taco_Order 表中的 placedAt 字段不兼容。

随着 Map 中填充完成订单数据,我们可以在 orderInserter 上调用 executeAndReturnKey() 方法了。这会将订单信息保存到 Taco_Order 表中,并将数据库生成的 id 作为一个 Number 对象返回,调用 longValue() 方法将其转换为从方法返回的 long 值。

saveTacoToOrder() 方法要简单得多。不是使用 ObjectMapper 将对象转换为 Map,而是创建 Map 并设置适当的值。同样,映射键对应于表中的列名。对 orderTacoInserter 的 execute() 方法的简单调用就能执行插入操作。

现在可以将 OrderRepository 注入到 OrderController 中并开始使用它。下面的程序清单显示了完整的 OrderController,包括因使用注入的 OrderRepository 而做的更改。程序清单 3.15 在 OrderController 中使用 OrderRepository

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

除了将 OrderRepository 注入控制器之外,OrderController 中惟一重要的更改是 processOrder() 方法。在这里,表单中提交的 Order 对象(恰好也是在 session 中维护的 Order 对象)通过注入的 OrderRepository 上的 save() 方法保存。

一旦订单被保存,就不再需要它存在于 session 中了。事实上,如果不清除它,订单将保持在 session 中,包括其关联的 tacos,下一个订单将从旧订单中包含的任何 tacos 开始。因此需要 processOrder() 方法请求 SessionStatus 参数并调用其 setComplete() 方法来重置会话。

所有的 JDBC 持久化代码都准备好了。现在,可以启动 Taco Cloud 应用程序并进行测试。你想要多少 tacos 和多少 orders 都可以。

可能还会发现在数据库中进行挖掘是很有帮助的。因为使用 H2 作为嵌入式数据库,而且 Spring Boot DevTools 已经就位,所以应该能够用浏览器访问 http://localhost:8080/h2-console 来查看 H2 控制台。虽然需要确保 JDBC URL 字段被设置为 JDBC:h2:mem:testdb,但是默认的凭证应该可以让你进入。登录后,应该能够对 Taco Cloud 模式中的表发起查询。

Spring 的 JdbcTemplate 和 SimpleJdbcInsert 使得使用关系数据库比普通 JDBC 简单得多。但是可能会发现 JPA 使它更加简单。让我们回顾一下之前的工作,看看如何使用 Spring 数据使数据持久化更加容易。