使用Spring Data JDBC

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

  • Spring Data JDBC:对关系型数据库进行 JDBC 持久化。

  • Spring Data JPA:对关系型数据库进行 JPA 持久化。

  • Spring Data MongoDB:持久化到 Mongo 文档数据库。

  • Spring Data Neo4j:持久化到 Neo4j 图数据库。

  • Spring Data Redis:持久化到 Redis 键-值存储。

  • Spring Data Cassandra:持久化到 Cassandra 列存储数据库。

Spring Data 为各种项目提供了一项有趣且有用的特性:基于存储库规范接口自动创建存储库。因此,使用 Spring Data 项目进行持久化只有很少(甚至没有)持久化相关的逻辑,只需要编写一个或多个存储库接口。

我们看一下如何将 Spring Data JDBC 用到我们的项目中,以简化JDBC的数据持久化。首先,需要将 Spring Data JDBC 添加到项目构建中。

添加Spring Data JDBC到构建文件中

对于 Spring Boot 应用,Spring Data JDBC 能够以 starter 依赖的形式添加进来。在添加到项目的 pom.xml 文件时,starter 依赖如程序清单3.12所示。

程序清单3.12 添加Spring Data JDBC依赖到构建中
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-jdbc</artifactId>
</dependency>

现在,不再需要为我们提供 JdbcTemplate 依赖了,所以我们可以移除如下所示的这个 starter:

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

我们依然还需要数据库,所以不会移除 H2 依赖。

定义存储库接口

好消息是,我们已经创建了 IngredientRepository 和 OrderRepository,所以定义存储库的很多工作其实已经完成了。但是,我们需要对它们做一些细微的改变,从而以 Spring Data JDBC 的方式使用它们。

Spring Data 会在运行时自动生成存储库接口的实现。但是,只有当接口扩展自 Spring Data 提供的存储库接口时,它才会帮我们实现这一点。至少,我们的存储库接口需要扩展 Repository,这样 Spring Data 才会自动为我们创建实现。例如,我们可以按照如下的方式编写 IngredientRepository,让它扩展 Repository 接口:

package tacos.data;
import java.util.Optional;
import org.springframework.data.repository.Repository;
import tacos.Ingredient;

public interface IngredientRepository
         extends Repository<Ingredient, String> {
  Iterable<Ingredient> findAll();

  Optional<Ingredient> findById(String id);

  Ingredient save(Ingredient ingredient);

}

可以看到,Repository 接口是参数化的,其中第一个参数是该存储库要持久化的对象类型,在本例中也就是 Ingredient。第二个参数是要持久化对象的 ID 字段的类型,对于 Ingredient 来说,也就是 String。

尽管 IngredientRepository 可以像展示的这样通过扩展 Repository 实现我们的功能,但是 Spring Data 也为一些常见的操作,提供了 CrudRepository 基础接口,其中就包含了我们在 IngredientRepository 中所定义的 3 个方法。所以,与其扩展 Repository,还不如扩展 CrudRepository,如程序清单3.13所示。

程序清单3.13 为持久化Ingredient定义存储库接口
package tacos.data;

import org.springframework.data.repository.CrudRepository;

import tacos.Ingredient;

public interface IngredientRepository
         extends CrudRepository<Ingredient, String> {

}

类似地,我们的 OrderRepository 也可以扩展 CrudRepository,如程序清单3.14所示。

程序清单3.14 为持久化taco订单定义存储库接口
package tacos.data;

import org.springframework.data.repository.CrudRepository;

import tacos.TacoOrder;

public interface OrderRepository
         extends CrudRepository<TacoOrder, Long> {

}

在这两种情况下,因为 CrudRepository 已经定义了我们需要的方法,所以没有必要在 IngredientRepository 和 OrderRepository 中显式定义它们了。

现在,我们有了两个存储库。你可能会想,我们接下来需要为这两个存储库(甚至包括 CrudRepository 中定义的十多个方法)编写实现了。但是,关于 Spring Data 的好消息是:我们根本不需要编写任何实现!当应用启动的时候,Spring Data 会在运行时自动生成一个实现。这意味着存储库已经准备就绪,我们将其注入控制器就可以了。

更重要的是,因为 Spring Data 会在运行时自动创建这些接口的实现,所以我们不需要 JdbcIngredientRepository 和 JdbcOrderRepository 这两个显式实现。我们可以干脆利落地删除这两个类。

为领域类添加持久化的注解

我们唯一需要做的就是为领域类添加注解,这样 Spring Data JDBC 才能知道如何持久化它们。一般来讲,这涉及为标识属性添加 @Id,以让 Spring Data 知道哪个字段代表了对象的唯一标识,并且我们可能需要在类上添加 @Table 注解,不过后面的这个步骤是可选的。

举例来说,TacoOrder 类可能需要添加 @Table 和 @Id 注解,如程序清单3.15所示。

程序清单3.15 为Taco类准备持久化
package tacos;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;

import javax.validation.constraints.Digits;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;

import org.hibernate.validator.constraints.CreditCardNumber;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;

import lombok.Data;

@Data
@Table
public class TacoOrder implements Serializable {

  private static final long serialVersionUID = 1L;

  @Id
  private Long id;

 // ...

}

@Table 注解完全是可选的。默认情况下,对象会基于领域类的名称映射到数据库的表上。在本例中,TacoOrder 会映射至名为 “Taco_Order” 的表。如果这种行为对你来说没有问题,那么就可以完全删除 @Table 注解,或者在使用它的时候不添加任何的参数。但是,如果想要映射至一个不同的表名,可以在 @Table上 指定表名,如下所示:

@Table("Taco_Cloud_Order")
public class TacoOrder {
  ...
}

如代码所示,TacoOrder 会被映射到名为 “Taco_Cloud_Order” 的表上。至于 @Id 注解,它指定 id 属性作为 TacoOrder 的唯一标识。TacoOrder 中的其他属性会根据其属性名自动映射到数据库的列上。例如,deliveryName 会自动映射至名为 delivery_name 的列。但是,如果想要显式定义列名映射,可以按照如下的方式为属性添加 @Column 注解:

@Column("customer_name")
@NotBlank(message = "Delivery name is required")
private String deliveryName;

在本例中,@Column 声明 deliveryName 属性会映射到名为 customer_name 的列上。

我们还需要将 @Table 和 @Id 用到其他的领域类上,包括 Ingredient,如程序清单3.16所示。

package tacos;

import org.springframework.data.annotation.Id;
import org.springframework.data.domain.Persistable;
import org.springframework.data.relational.core.mapping.Table;

import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@Table
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PRIVATE, force = true)
public class Ingredient implements Persistable<String> {

  @Id
  private String id;

  // ...

}

另外,还包括 Taco 类,如程序清单3.17所示。

程序清单3.17 为Taco类准备持久化
package tacos;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Table;

import lombok.Data;

@Data
@Table
public class Taco {

  @Id
  private Long id;

  // ...

}

至于 IngredientRef,它会自动映射到名为 “Ingredient_Ref” 的表,这恰好满足我们应用的需要。你如果愿意,可以为其添加 @Table 注解,但这并不是必需的。而且,Ingredient_Ref 表中没有唯一标识字段,因此没有必要为 IngredientRef 中的任何字段添加 @Id 注解。

完成了这些小的变更,尤其是移除了 JdbcIngredientRepository 和 JdbcOrderRepository 之后,我们的持久化代码少了很多。即便如此,Spring Data 在运行时生成的存储库实现依然能够完成使用 JdbcTemplate 的存储库所做的所有功能。实际上,它们完全可以做更多的事情,因为这两个存储库接口都扩展了 CrudRepository 接口,这个接口提供了十多种操作来创建、读取、更新和删除对象。

使用CommandLineRunner预加载数据

使用 JdbcTemplate 时,我们在应用启动阶段会借助 data.sql 预加载 Ingredient 数据,这个过程会在数据源bean初始化的时候针对数据库来执行。同样的方式也适用于 Spring Data JDBC。实际上,它适用于任何以关系型数据库作为支撑数据库的持久化机制。但是,不妨看一下另一种在启动时填充数据库的方式,这种方式会提供更多的灵活性。

Spring Boot 提供了两个非常有用的接口,用于在应用启动的时候执行一定的逻辑,即 CommandLineRunner 和 ApplicationRunner。这两个接口非常类似,它们都是函数式接口,都需要实现一个 run() 方法。当应用启动的时候,应用上下文中所有实现了 CommandLineRunner 或 ApplicationRunner 的 bean 都会执行其 run() 方法,执行时机是应用上下文和所有 bean 装配完毕之后、所有其他功能执行之前。这为将数据加载到数据库中提供了便利。

因为 CommandLineRunner 和 ApplicationRunner 都是函数式接口,所以在配置类中可以很容易地将其声明为 bean,只需在一个返回 lambda 表达式的方法上使用 @Bean 注解。例如,下面展现了如何创建一个数据加载的 CommandLineRunner bean:

@Bean
public CommandLineRunner dataLoader(IngredientRepository repo) {
  return args -> {
    repo.save(new Ingredient("FLTO", "Flour Tortilla", Type.WRAP));
    repo.save(new Ingredient("COTO", "Corn Tortilla", Type.WRAP));
    repo.save(new Ingredient("GRBF", "Ground Beef", Type.PROTEIN));
    repo.save(new Ingredient("CARN", "Carnitas", Type.PROTEIN));
    repo.save(new Ingredient("TMTO", "Diced Tomatoes", Type.VEGGIES));
    repo.save(new Ingredient("LETC", "Lettuce", Type.VEGGIES));
    repo.save(new Ingredient("CHED", "Cheddar", Type.CHEESE));
    repo.save(new Ingredient("JACK", "Monterrey Jack", Type.CHEESE));
    repo.save(new Ingredient("SLSA", "Salsa", Type.SAUCE));
    repo.save(new Ingredient("SRCR", "Sour Cream", Type.SAUCE));
  };
}

在这里,IngredientRepository 被注入 bean 方法,并在 lambda 表达式中被用以创建 Ingredient 对象。CommandLineRunner 的 run() 方法接受一个参数,这是 String 类型的可变长度参数(vararg),其中包含了运行应用时所有的命令行参数。在将配料加载到数据库时,我们不需要这些参数,所以 args 参数可以忽略。

作为替代方案,我们还可以将数据加载的 bean 定义为 ApplicationRunner 的 lambda 表达式实现,如下所示:

@Bean
public ApplicationRunner dataLoader(IngredientRepository repo) {
  return args -> {
    repo.save(new Ingredient("FLTO", "Flour Tortilla", Type.WRAP));
    repo.save(new Ingredient("COTO", "Corn Tortilla", Type.WRAP));
    repo.save(new Ingredient("GRBF", "Ground Beef", Type.PROTEIN));
    repo.save(new Ingredient("CARN", "Carnitas", Type.PROTEIN));
    repo.save(new Ingredient("TMTO", "Diced Tomatoes", Type.VEGGIES));
    repo.save(new Ingredient("LETC", "Lettuce", Type.VEGGIES));
    repo.save(new Ingredient("CHED", "Cheddar", Type.CHEESE));
    repo.save(new Ingredient("JACK", "Monterrey Jack", Type.CHEESE));
    repo.save(new Ingredient("SLSA", "Salsa", Type.SAUCE));
    repo.save(new Ingredient("SRCR", "Sour Cream", Type.SAUCE));
  };
}

CommandLineRunner 和 ApplicationRunner 的关键区别在于传递给各自 run() 方法的参数。CommandLineRunner 会接受一个 String 类型的可变长度参数,代表传递给命令行的原始参数。但是,ApplicationRunner 会接受一个 ApplicationArguments 参数,其提供了访问已解析命令行组件参数的方法。

例如,假设我们的应用接受命令行参数 “--version 1.2.3”,而且我们的数据加载 bean 需要考虑这个参数。如果使用 CommandLineRunner,那么我们需要在数组中搜索 “--version”,然后精确地去找数组中的下一个值。但是,如果使用 ApplicationRunner,那么我们可以查询给定的 ApplicationArguments 对象以获取 “--version” 参数,如下所示:

public ApplicationRunner dataLoader(IngredientRepository repo) {
  return args -> {
    List<String> version = args.getOptionValues("version");
    ...
  };
}

getOptionValues() 方法会返回一个 List<String>,允许可变参数多次设置它的值。不过,不管是使用 CommandLineRunner 还是 ApplicationRunner,我们加载数据都不需要命令行参数。所以,在数据加载 bean 中,我们可以忽略 args 参数。

使用 CommandLineRunner 或 ApplicationRunner 初始化数据加载的好处在于,它们使用存储库来创建要持久化的对象,而不是使用 SQL 脚本。这意味着这种方式对关系型数据库和非关系型数据库同样有效。在第 4 章使用 Spring Data 持久化到非关系型数据库时,这一点会发挥其优势。

但在此之前,我们先看一下用于持久化关系型数据库数据的另一个 Spring Data 项目:Spring Data JPA。