使用JDBC读取和写入数据

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

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

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

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

程序清单 3.1 不使用JdbcTemplate查询数据库
@Override
public Optional<Ingredient> findById(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 Optional.of(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 Optional.empty();
}

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

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

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

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

public Optional<Ingredient> findById(String id) {
  List<Ingredient> results = jdbcTemplate.query(
      "select id, name, type from Ingredient where id = ?",
      this::mapRowToIngredient,
      id);
  return results.size() == 0 ?
          Optional.empty() :
          Optional.of(results.get(0));
}
private Ingredient mapRowToIngredient(ResultSet row, int rowNum)
    throws SQLException {
  return new Ingredient(
      row.getString("id"),
      row.getString("name"),
      Ingredient.Type.valueOf(row.getString("type")));
}

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

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

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

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

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

@Data
public class Taco {

  private Long id;

  private Date createdAt = new Date();

  // ...
}

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

@Data
public class TacoOrder implements Serializable {

  private static final long serialVersionUID = 1L;

  private Long id;

  private Date placedAt;
  // ...

}

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

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

使用JdbcTemplate

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

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

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

<dependency>
  <groupId>com.h2database</groupId>
  <artifactId>h2</artifactId>
  <scope>runtime</scope>
</dependency>

默认情况下,数据库的名称是随机生成的。但是,如果我们基于某种原因,想要使用 H2 控制台连接数据库(Spring Boot DevTools 会在 http://localhost:8080/h2-console 启用该功能),采取这种方式会让我们很难确定数据库的 URL。所以,更好的办法是在 application.properties 中通过设置几个属性确定数据库的名称,如下所示:

spring.datasource.generate-unique-name = false
spring.datasource.name = tacocloud

或者,根据你的喜好,也可以将 application.properties 重命名为 application.yml,并以 YAML 的格式添加属性,如下所示:

spring:
  datasource:
    generate-unique-name: false
    name: tacocloud

至于选择属性文件格式还是 YAML 则完全取决于我们自己。Spring Boot 对两者都能接受。鉴于 YAML 的结构和更好的可读性,本书的其余部分都会使用 YAML 来配置属性。

通过将 spring.datasource.generate-unique-name 属性设置为 false,我们告诉 Spring 不要生成一个随机的值作为数据库的名称。它的名称应该使用 spring.datasource.name 属性所设置的值。在本例中,数据库的名称将会是 “tacocloud”。因此,数据库的 URL 会是 “jdbc:h2:mem:tacocloud”,我们可以将其设置到 H2 控制台连接的 JDBC URL 中。

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

定义JDBC存储库

我们的 Ingredient 存储库需要完成如下的操作:

  • 查询所有的配料信息,将它们放到一个 Ingredient 对象的集合中;

  • 根据 id,查询单个 Ingredient;

  • 保存 Ingredient 对象。

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

package tacos.data;

import java.util.Optional;

import tacos.Ingredient;

public interface IngredientRepository {
  Iterable<Ingredient> findAll();

  Optional<Ingredient> findById(String id);

  Ingredient save(Ingredient ingredient);

}

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

程序清单3.4 开始使用JdbcTemplate编写配料存储库
package tacos.data;
import java.sql.ResultSet;
import java.sql.SQLException;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

import tacos.Ingredient;

@Repository
public class JdbcIngredientRepository implements IngredientRepository {

  private JdbcTemplate jdbcTemplate;

  public JdbcIngredientRepository(JdbcTemplate jdbcTemplate) {
    this.jdbcTemplate = jdbcTemplate;
  }

  // ...

}

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

Spring 创建 JdbcIngredientRepository bean 时会将 JdbcTemplate 注入。这是因为当类只有一个构造器的时候,Spring 会隐式地通过该构造器的参数应用依赖的自动装配。如果有一个以上的构造器,或者想要明确声明自动装配,那么可以在构造器上添加 @Autowired 注解,如下所示:

@Autowired
public JdbcIngredientRepository(JdbcTemplate jdbcTemplate) {
  this.jdbcTemplate = jdbcTemplate;
}

这个构造器将 JdbcTemplate 赋值给一个实例变量,这个变量会被其他方法用来执行数据库查询和插入操作。说到其他方法,不妨先看一下 findAll() 和 findById() 的实现,如程序清单3.5所示。

程序清单3.5 使用JdbcTemplate查询数据库
@Override
public Iterable<Ingredient> findAll() {
  return jdbcTemplate.query(
      "select id, name, type from Ingredient",
      this::mapRowToIngredient);
}

@Override
public Optional<Ingredient> findById(String id) {
  List<Ingredient> results = jdbcTemplate.query(
      "select id, name, type from Ingredient where id = ?",
      this::mapRowToIngredient,
      id);
  return results.size() == 0 ?
          Optional.empty() :
          Optional.of(results.get(0));
}

private Ingredient mapRowToIngredient(ResultSet row, int rowNum)
    throws SQLException {
  return new Ingredient(
      row.getString("id"),
      row.getString("name"),
      Ingredient.Type.valueOf(row.getString("type")));
}

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

与之不同的是,findById() 方法需要在它的查询中包含一个 where 子句,以对比数据库中 id 列的值与传入的 id 参数的值。因此,对 query() 的调用会以最终参数的形式包含 id 参数。当查询执行的时候,“?” 将会被替换为这个值。

程序清单3.5中,findAll() 和 findById() 中的 RowMapper 参数都是通过对 mapRowToIngredient() 的方法引用指定的。在使用 JdbcTemplate 的时候,Java 的方法引用和 lambda 表达式是非常便利的,它们能够替代显式的 RowMapper 实现。如果因为某种原因你想要或者必须使用显式 RowMapper,那么如下的 findAll() 实现阐述了如何做到这一点:

@Override
public Ingredient findById(String id) {
  return jdbcTemplate.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插入数据
@Override
public Ingredient save(Ingredient ingredient) {
  jdbcTemplate.update(
      "insert into Ingredient (id, name, type) values (?, ?, ?)",
      ingredient.getId(),
      ingredient.getName(),
      ingredient.getType().toString());
  return ingredient;
}

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

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

程序清单3.7 在控制器中注入和使用存储库
@Controller
@RequestMapping("/design")
@SessionAttributes("tacoOrder")
public class DesignTacoController {

  private final IngredientRepository ingredientRepo;

  @Autowired
  public DesignTacoController(
        IngredientRepository ingredientRepo) {
    this.ingredientRepo = ingredientRepo;
  }

  @ModelAttribute
  public void addIngredientsToModel(Model model) {
    Iterable<Ingredient> ingredients = ingredientRepo.findAll();
    Type[] types = Ingredient.Type.values();
    for (Type type : types) {
      model.addAttribute(type.toString().toLowerCase(),
          filterByType(ingredients, type));
    }
  }

  // ...
}

addIngredientsToModel() 方法使用了注入的 IngredientRepository 的 findAll() 方法,从而能够获取数据库中所有的配料。该方法将它们过滤成不同的类型,然后放到模型中。

现在,我们有了 IngredientRepository 来获取 Ingredient 对象,那么在第 2 章创建的 IngredientByIdConverter 也可以进行简化了。可以将硬编码的 Ingredient 对象的 Map 替换为对 IngredientRepository.findById() 的简单调用,如程序清单3.8所示。

程序清单3.8 简化IngredientByIdConverter
package tacos.web;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.convert.converter.Converter;
import org.springframework.stereotype.Component;

import tacos.Ingredient;
import tacos.data.IngredientRepository;

@Component
public class IngredientByIdConverter implements Converter<String, Ingredient> {

  private IngredientRepository ingredientRepo;

  @Autowired
  public IngredientByIdConverter(IngredientRepository ingredientRepo) {
    this.ingredientRepo = ingredientRepo;
  }

  @Override
  public Ingredient convert(String id) {
    return ingredientRepo.findById(id).orElse(null);
  }

}

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

定义模式和预加载数据

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

image 2024 03 12 23 26 41 411
Figure 1. 图3.1 Taco Cloud模式的表

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

  • Taco_Order:保存订单的细节信息。

  • Taco:保存 taco 设计相关的必要信息。

  • Ingredient_Ref:将 taco 和与之相关的配料映射在一起,Taco 中的每行数据都对应该表中的一行或多行数据。

  • Ingredient:保存配料信息。

在我们的应用中,Taco 无法在 Taco_Order 环境之外存在。因此,Taco_Order 和 Taco 被视为同一个聚合(aggregate)的成员,其中 Taco_Order 是聚合根(aggregate root)。而 Ingredient 对象则是其聚合的唯一成员,并且会通过 Ingredient_Ref 建立与 Taco 的引用关系。

注意:聚合和聚合根是领域驱动设计的核心概念,这种设计方式提倡软件代码的结构和语言要与业务领域匹配。在 Taco Cloud 领域对象中只使用了一点领域驱动设计(Domain-Driven Design, DDD)的思想,但是 DDD 的内容远不止聚合和聚合根。如果想要了解这项技术的更多内容,请阅读该主题的开创性著作——Eric Evans的《领域驱动设计》。

程序清单3.9展示了创建表的SQL语句。

程序清单3.9 定义Taco Cloud的模式
create table if not exists Taco_Order (
  id identity,
  delivery_Name varchar(50) not null,
  delivery_Street varchar(50) not null,
  delivery_City varchar(50) not null,
  delivery_State varchar(2) not null,
  delivery_Zip varchar(10) not null,
  cc_number varchar(16) not null,
  cc_expiration varchar(5) not null,
  cc_cvv varchar(3) not null,
  placed_at timestamp not null
);

create table if not exists Taco (
  id identity,
  name varchar(50) not null,
  taco_order bigint not null,
  taco_order_key bigint not null,
  created_at timestamp not null
);

create table if not exists Ingredient_Ref (
  ingredient varchar(4) not null,
  taco bigint not null,
  taco_key bigint not null
);

create table if not exists Ingredient (
  id varchar(4) not null,
  name varchar(25) not null,
  type varchar(10) not null
);

alter table Taco
    add foreign key (taco_order) references Taco_Order(id);
alter table Ingredient_Ref
    add foreign key (ingredient) references Ingredient(id);

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

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

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

程序清单3.10 使用data.sql预加载数据库
delete from Ingredient_Ref;
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');

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

插入数据

我们已经走马观花地了解了如何使用 JdbcTemplate 将数据写入数据库。JdbcIngredientRepository 中的 save() 方法使用 JdbcTemplate 的 update() 方法将 Ingredient 对象保存到了数据库中。

这尽管是一个非常好的起步样例,但有些过于简单了。你马上会看到,保存数据可能会比 JdbcIngredientRepository 更加复杂。

在我们的设计中,TacoOrder 和 Taco 组成了一个聚合,其中 TacoOrder 是聚合根。换句话说,Taco 不能在 TacoOrder 的环境之外存在。所以,我们现在只需要定义一个存储库来持久化 TacoOrder 对象,而 Taco 也就会随之一起保存了。这样的存储库可以通过如下所示的 OrderRepository 接口来定义:

package tacos.data;

import java.util.Optional;

import tacos.TacoOrder;

public interface OrderRepository {

  TacoOrder save(TacoOrder order);

}

可以肯定地说,这个 save() 方法将比我们先前为保存一个简陋的 Ingredient 对象而创建的对应方法要有趣得多。save() 方法要做的另外一件事情就是在订单保存时确定为订单分配什么样的 ID。根据模式,Taco_Order 表的 id 属性是一个 identity 字段,这意味着数据库将会自动确定这个值。但是,如果是由数据库确定这个值,我们需要确切知道它是什么,这样在 save() 方法返回的 TacoOrder 对象中才能包含它。幸好,Spring 提供了一个辅助类 GeneratedKeyHolder,能够帮助我们实现这一点。不过,它需要与一个预处理语句(prepared statement)协作,如下面的 save() 方法实现所示:

package tacos.data;

import java.sql.Types;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import java.util.Optional;

import org.springframework.asm.Type;
import org.springframework.jdbc.core.JdbcOperations;
import org.springframework.jdbc.core.PreparedStatementCreator;
import org.springframework.jdbc.core.PreparedStatementCreatorFactory;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import tacos.IngredientRef;
import tacos.Taco;
import tacos.TacoOrder;

@Repository
public class JdbcOrderRepository implements OrderRepository {

  private JdbcOperations jdbcOperations;

  public JdbcOrderRepository(JdbcOperations jdbcOperations) {
    this.jdbcOperations = jdbcOperations;
  }
  @Override
  @Transactional
  public TacoOrder save(TacoOrder order) {
    PreparedStatementCreatorFactory pscf =
      new PreparedStatementCreatorFactory(
        "insert into Taco_Order "
        + "(delivery_name, delivery_street, delivery_city, "
        + "delivery_state, delivery_zip, cc_number, "
        + "cc_expiration, cc_cvv, placed_at) "
        + "values (?,?,?,?,?,?,?,?,?)",
        Types.VARCHAR, Types.VARCHAR, Types.VARCHAR,
        Types.VARCHAR, Types.VARCHAR, Types.VARCHAR,
        Types.VARCHAR, Types.VARCHAR, Types.TIMESTAMP
    );
    pscf.setReturnGeneratedKeys(true);

    order.setPlacedAt(new Date());
    PreparedStatementCreator psc =
        pscf.newPreparedStatementCreator(
            Arrays.asList(
                order.getDeliveryName(),
                order.getDeliveryStreet(),
                order.getDeliveryCity(),
                order.getDeliveryState(),
                order.getDeliveryZip(),
                order.getCcNumber(),
                order.getCcExpiration(),
                order.getCcCVV(),
                order.getPlacedAt()));

    GeneratedKeyHolder keyHolder = new GeneratedKeyHolder();
    jdbcOperations.update(psc, keyHolder);
    long orderId = keyHolder.getKey().longValue();
    order.setId(orderId);

    List<Taco> tacos = order.getTacos();
    int i = 0;
    for (Taco taco : tacos) {
      saveTaco(orderId, i + +, taco);
    }

    return order;
  }
}

在 save() 方法中,似乎有很多的内容,但是我们可以将其拆分为几个重要的步骤。首先,我们创建了一个 PreparedStatementCreatorFactory,它描述了 insert 查询以及查询的输入字段的类型。我们稍后要获取已保存订单的 ID,所以还需要调用 setReturn GeneratedKeys(true)。

在定义 PreparedStatementCreatorFactory 之后,就可以使用它来创建 PreparedStatementCreator,并将要持久化的 TacoOrder 对象的值传递进来。我们传给 PreparedStatementCreator 的最后一个字段是订单的创建日期,这个日期也要设置到 TacoOrder 对象本身上,这样返回的对象才会包含这个信息。

现在,我们已经有了 PreparedStatementCreator,接下来就可以通过调用 JdbcTemplate 的 update() 方法真正保存订单数据了,这个过程中需要将 PreparedStatementCreator 和 GeneratedKeyHolder 传递进去。订单数据保存之后,GeneratedKeyHolder 将会包含数据库所分配的 id 字段的值,我们应该要将这个值复制到 TacoOrder 对象的 id 属性上。

此时,订单已经保存完成,但是我们还需要保存与这个订单相关联的 Taco 对象。这可以通过为订单中的每个 Taco 调用 saveTaco() 方法来实现。

saveTaco() 方法与 save() 方法非常相似,如下所示:

private long saveTaco(Long orderId, int orderKey, Taco taco) {
  taco.setCreatedAt(new Date());
  PreparedStatementCreatorFactory pscf =
          new PreparedStatementCreatorFactory(
      "insert into Taco "
      + "(name, created_at, taco_order, taco_order_key) "
      + "values (?, ?, ?, ?)",
      Types.VARCHAR, Types.TIMESTAMP, Type.LONG, Type.LONG
  );
  pscf.setReturnGeneratedKeys(true);

  PreparedStatementCreator psc =
      pscf.newPreparedStatementCreator(
          Arrays.asList(
              taco.getName(),
              taco.getCreatedAt(),
              orderId,
              orderKey));

  GeneratedKeyHolder keyHolder = new GeneratedKeyHolder();
  jdbcOperations.update(psc, keyHolder);
  long tacoId = keyHolder.getKey().longValue();
  taco.setId(tacoId);

  saveIngredientRefs(tacoId, taco.getIngredients());

  return tacoId;
}

saveTaco() 结构中的每一步都和 save() 对应,只不过这里处理的是 Taco 数据,而不是 TacoOrder 数据。最后,它会调用 saveIngredientRefs(),在 Ingredient_Ref 表中创建一条记录,从而将 Taco 行与 Ingredient 行联系在一起。saveIngredientRefs() 方法如下所示:

private void saveIngredientRefs(
    long tacoId, List<IngredientRef> ingredientRefs) {
    int key = 0;
    for (IngredientRef ingredientRef : ingredientRefs) {
      jdbcOperations.update(
          "insert into Ingredient_Ref (ingredient, taco, taco_key) "
          + "values (?, ?, ?)",
          ingredientRef.getIngredient(), tacoId, key + +);
    }
}

saveIngredientRefs() 方法要简单得多。它循环一个 Ingredient 对象的列表,并将列表中的每个元素保存到 Ingredient_Ref 表中。它还有一个用作索引的局部的 key 变量,以确保配料的顺序保持不变。

我们对 OrderRepository 所做的最后一件事就是将其注入 OrderController,并在保存订单的时候使用它。程序清单3.11展现了注入存储库所需的必要变更。

程序清单3.11 注入和使用OrderRepository
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.TacoOrder;
import tacos.data.OrderRepository;

@Controller
@RequestMapping("/orders")
@SessionAttributes("tacoOrder")
public class OrderController {

  private OrderRepository orderRepo;

  public OrderController(OrderRepository orderRepo) {
    this.orderRepo = orderRepo;
  }

  // ...

  @PostMapping
  public String processOrder(@Valid TacoOrder order, Errors errors,
     SessionStatus sessionStatus) {
    if (errors.hasErrors()) {
      return "orderForm";
    }
    orderRepo.save(order);
    sessionStatus.setComplete();

    return "redirect:/";
  }
}

可以看到,构造器接受一个 OrderRepository 作为参数,并将其设置给一个实例变量,以便在 processOrder() 方法中使用。至于 processOrder() 方法,我们已经将其修改为调用 OrderRepository 的 save() 方法,而不再是以日志记录 TacoOrder 对象。

借助 Spring 的 JdbcTemplate 实现与关系型数据库的交互要比普通的 JDBC 简单得多。但即便有了 JdbcTemplate,一些持久化任务仍然很有挑战性,尤其是在持久化聚合中的嵌套领域对象时。如果有一种方法能让 JDBC 的使用变得更简单就好了。

接下来看一下 Spring Data JDBC,它让 JDBC 的使用变得异常简单,即便是在持久化聚合的时候也是如此。