3.2 使用 Spring Data JPA 持久化数据

Spring Data 项目是一个相当大的伞形项目,几个子项目组成,其中大多数子项目关注于具有各种不同数据库类型的数据持久化。一些最流行的 Spring 数据项目包括:

  • Spring Data JPA - 针对关系数据库的持久化
  • Spring Data Mongo - 针对 Mongo 文档数据库的持久化
  • Spring Data Neo4j - 针对 Neo4j 图形数据库的持久化
  • Spring Data Redis - 针对 Redis 键值存储的持久化
  • Spring Data Cassandra - 针对 Cassandra 数据库的持久化

Spring Data 为所有这些项目提供的最有意思和最有用的特性之一是能够基于存储库规范接口自动创建存储库。

为了了解 Spring Data 是如何工作的,需要将本章前面介绍的基于 jdbc 的存储库替换为 Spring Data JPA 创建的存储库。但是首先,需要将 Spring Data JPA 添加到项目构建中。

3.2.1 添加 Spring Data JPA 到数据库中

Spring Data JPA 可用于具有 JPA starter 的 Spring Boot 应用程序。这个 starter 依赖不仅带来了 Spring Data JPA,还包括 Hibernate 作为 JPA 的实现:

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

如果想使用不同的 JPA 实现,那么至少需要排除 Hibernate 依赖,并包含所选择的 JPA 库。例如,要使用 EclipseLink 而不是 Hibernate,需要按如下方式更改构建:

  1. <dependency>
  2. <groupId>org.springframework.boot</groupId>
  3. <artifactId>spring-boot-starter-data-jpa</artifactId>
  4. <exclusions>
  5. <exclusion>
  6. <artifactId>hibernate-entitymanager</artifactId>
  7. <groupId>org.hibernate</groupId>
  8. </exclusion>
  9. </exclusions>
  10. </dependency>
  11. <dependency>
  12. <groupId>org.eclipse.persistence</groupId>
  13. <artifactId>eclipselink</artifactId>
  14. <version>2.5.2</version>
  15. </dependency>

请注意,根据对 JPA 实现的选择,可能需要进行其他更改。详细信息请参阅选择的 JPA 实现的文档。现在,让我们重新查看域对象并对它们进行注解以实现 JPA 持久化。

3.2.2 注解域作为实体

很快就会看到,在创建存储库方面,Spring Data 做了一些惊人的事情。但不幸的是,在使用 JPA 映射注解注解域对象时,它并没有太大的帮助。需要打开 Ingredient、Taco 和 Order 类,并添加一些注解。首先是 Ingredient 类。程序清单 3.16 为 JPA 持久化注解 Ingredient

  1. package tacos;
  2. import javax.persistence.Entity;
  3. import javax.persistence.Id;
  4. import lombok.AccessLevel;
  5. import lombok.Data;
  6. import lombok.NoArgsConstructor;
  7. import lombok.RequiredArgsConstructor;
  8. @Data
  9. @RequiredArgsConstructor
  10. @NoArgsConstructor(access=AccessLevel.PRIVATE, force=true)
  11. @Entity
  12. public class Ingredient {
  13. @Id
  14. private final String id;
  15. private final String name;
  16. private final Type type;
  17. public static enum Type {
  18. WRAP, PROTEIN, VEGGIES, CHEESE, SAUCE
  19. }
  20. }

为了将其声明为 JPA 实体,必须使用 @Entity 注解。它的 id 属性必须使用 @Id 进行注解,以便将其指定为惟一标识数据库中实体的属性。

除了特定于 JPA 的注解之外,还在类级别上添加了 @NoArgsConstructor 注解。JPA 要求实体有一个无参构造函数,所以 Lombok 的 @NoArgsConstructor 实现了这一点。但是要是不希望使用它,可以通过将 access 属性设置为 AccessLevel.PRIVATE 来将其设置为私有。因为必须设置 final 属性,所以还要将 force 属性设置为 true,这将导致 Lombok 生成的构造函数将它们设置为 null。

还添加了一个 @RequiredArgsConstructor。@Data 隐式地添加了一个必需的有参构造函数,但是当使用 @NoArgsConstructor 时,该构造函数将被删除。显式的 @RequiredArgsConstructor 确保除了私有无参数构造函数外,仍然有一个必需有参构造函数。

现在让我们转到 Taco 类,看看如何将其注解为 JPA 实体。程序清单 3.17 把 Taco 注解为实体

  1. package tacos;
  2. import java.util.Date;
  3. import java.util.List;
  4. import javax.persistence.Entity;
  5. import javax.persistence.GeneratedValue;
  6. import javax.persistence.GenerationType;
  7. import javax.persistence.Id;
  8. import javax.persistence.ManyToMany;
  9. import javax.persistence.OneToMany;
  10. import javax.persistence.PrePersist;
  11. import javax.validation.constraints.NotNull;
  12. import javax.validation.constraints.Size;
  13. import lombok.Data;
  14. @Data
  15. @Entity
  16. public class Taco {
  17. @Id
  18. @GeneratedValue(strategy=GenerationType.AUTO)
  19. private Long id;
  20. @NotNull
  21. @Size(min=5, message="Name must be at least 5 characters long")
  22. private String name;
  23. private Date createdAt;
  24. @ManyToMany(targetEntity=Ingredient.class)
  25. @Size(min=1, message="You must choose at least 1 ingredient")
  26. private List<Ingredient> ingredients;
  27. @PrePersist
  28. void createdAt() {
  29. this.createdAt = new Date();
  30. }
  31. }

与 Ingredient 一样,Taco 类现在使用 @Entity 注解,其 id 属性使用 @Id 注解。因为依赖于数据库自动生成 id 值,所以还使用 @GeneratedValue 注解 id 属性,指定自动策略。

要声明 Taco 及其相关 Ingredient 列表之间的关系,可以使用 @ManyToMany 注解 ingredient 属性。一个 Taco 可以有很多 Ingredient,一个 Ingredient 可以是很多 Taco 的一部分。

还有一个新方法 createdAt(),它用 @PrePersist 注解。将使用它将 createdAt 属性设置为保存 Taco 之前的当前日期和时间。最后,让我们将 Order 对象注解为一个实体。下一个程序清单展示了新的 Order 类。程序清单 3.18 把 Order 注解为 JPA 实体

  1. package tacos;
  2. import java.io.Serializable;
  3. import java.util.ArrayList;
  4. import java.util.Date;
  5. import java.util.List;
  6. import javax.persistence.Entity;
  7. import javax.persistence.GeneratedValue;
  8. import javax.persistence.GenerationType;
  9. import javax.persistence.Id;
  10. import javax.persistence.ManyToMany;
  11. import javax.persistence.OneToMany;
  12. import javax.persistence.PrePersist;
  13. import javax.persistence.Table;
  14. import javax.validation.constraints.Digits;
  15. import javax.validation.constraints.Pattern;
  16. import org.hibernate.validator.constraints.CreditCardNumber;
  17. import org.hibernate.validator.constraints.NotBlank;
  18. import lombok.Data;
  19. @Data
  20. @Entity
  21. @Table(name="Taco_Order")
  22. public class Order implements Serializable {
  23. private static final long serialVersionUID = 1L;
  24. @Id
  25. @GeneratedValue(strategy=GenerationType.AUTO)
  26. private Long id;
  27. private Date placedAt;
  28. ...
  29. @ManyToMany(targetEntity=Taco.class)
  30. private List<Taco> tacos = new ArrayList<>();
  31. public void addDesign(Taco design) {
  32. this.tacos.add(design);
  33. }
  34. @PrePersist
  35. void placedAt() {
  36. this.placedAt = new Date();
  37. }
  38. }

对 Order 的更改与对 Taco 的更改非常相似。但是在类级别有一个新的注解:@Table。这指定订单实体应该持久化到数据库中名为 Taco_Order 的表中。

尽管可以在任何实体上使用这个注解,但它对于 Order 是必需的。没有它,JPA 将默认将实体持久化到一个名为 Order 的表中,但是 Order 在 SQL 中是一个保留字,会导致问题。现在实体已经得到了正确的注解,该编写 repository 了。

3.2.3 声明 JPA repository

在存储库的 JDBC 版本中,显式地声明了希望 repository 提供的方法。但是使用 Spring Data,扩展 CrudRepository 接口。例如,这是一个新的 IngredientRepository 接口:

  1. package tacos.data;
  2. import org.springframework.data.repository.CrudRepository;
  3. import tacos.Ingredient;
  4. public interface IngredientRepository extends CrudRepository<Ingredient, String> {
  5. }

CrudRepository 为 CRUD(创建、读取、更新、删除)操作声明了十几个方法。注意,它是参数化的,第一个参数是存储库要持久化的实体类型,第二个参数是实体 id 属性的类型。对于 IngredientRepository,参数应该是 Ingredient 和 String 类型。

也可以这样定义 TacoRepository:

  1. package tacos.data;
  2. import org.springframework.data.repository.CrudRepository;
  3. import tacos.Taco;
  4. public interface TacoRepository extends CrudRepository<Taco, Long> {
  5. }

IngredientRepository 和 TacoRepository 之间唯一显著的区别是对于 CrudRepository 的参数不同。在这里,它们被设置为 Taco 和 Long 去指定 Taco 实体(及其 id 类型)作为这个 respository 接口的持久化单元。最后,同样的更改可以应用到 OrderRepository:

  1. package tacos.data;
  2. import org.springframework.data.repository.CrudRepository;
  3. import tacos.Order;
  4. public interface OrderRepository extends CrudRepository<Order, Long> {
  5. }

现在有了这三个 repository,可能认为需要为这三个 repository 编写实现,还包括每种实现的十几个方法。但这就是 Spring Data JPA 优秀的地方 —— 不需要编写实现!当应用程序启动时,Spring Data JPA 会动态地自动生成一个实现。这意味着 repository 可以从一开始就使用。只需将它们注入到控制器中,就像在基于 JDBC 的实现中所做的那样。

CrudRepository 提供的方法非常适合用于实体的通用持久化。但是如果有一些基本持久化之外的需求呢?让我们看看如何自定义 repository 来执行域特有的查询。

3.2.4 自定义 JPA repository

想象一下,除了 CrudRepository 提供的基本 CRUD 操作之外,还需要获取投递给指定邮政编码的所有订单。事实证明,通过在 OrderRepository 中添加以下方法声明可以很容易地解决这个问题:

  1. List<Order> findByDeliveryZip(String deliveryZip);

在生成 repository 实现时,Spring Data 检查存储库接口中的任何方法,解析方法名称,并尝试在持久化对象的上下文中理解方法的用途(在本例中是 Order)。本质上,Spring Data 定义了一种小型的领域特定语言(DSL),其中持久化细节用 repository 中的方法签名表示。

Spring Data 知道这个方法是用来查找订单的,因为已经用 Order 参数化了 CrudRepository。方法名 findByDeliveryZip() 表明,该方法应该通过将其 deliveryZip 属性与作为参数,传递给匹配的方法来查找所有订单实体。

findByDeliveryZip() 方法非常简单,但是 Spring Data 也可以处理更有趣的方法名。repository 的方法由一个动词、一个可选的主语、单词 by 和一个谓词组成。在 findByDeliveryZip() 中,动词是 find,谓词是 DeliveryZip,主语没有指定,暗示是一个 Order。

让我们考虑另一个更复杂的例子。假设需要查询在给定日期范围内投递给指定邮政编码的所有订单。在这种情况下,当添加到 OrderRepository 时,下面的方法可能会被证明是有用的:

  1. List<Order> readOrdersByDeliveryZipAndPlacedAtBetween(
  2. String deliveryZip, Date startDate, Date endDate);

图 3.2 说明了在生成 respository 实现时,Spring Data 如何解析和理解 readOrdersByDeliveryZipAndPlacedAtBetween() 方法。可以看到,readOrdersByDeliveryZipAndPlacedAtBetween() 中的动词是 read。Spring Data 还将 find、read 和 get 理解为获取一个或多个实体的同义词。另外,如果只希望方法返回一个带有匹配实体计数的 int,也可以使用 count 作为动词。

图 3.2

图 3.2 Spring Data 解析 repository 方法特征来确定如何运行查询语句

尽管该方法的主语是可选的,但在这里它表示 Order。Spring Data 会忽略主题中的大多数单词,因此可以将方法命名为 readPuppiesBy…它仍然可以找到 Order 实体,因为这是 CrudRepository 参数化的类型。

谓词跟在方法名中的 By 后面,是方法签名中最有趣的部分。在本例中,谓词引用两个 Order属性:deliveryZip 和 placedAt。deliveryZip 属性必须与传递给方法的第一个参数的值一致。Between 关键字表示 deliveryZip 的值必须位于传入方法最后两个参数的值之间。

除了一个隐式的 Equals 操作和 Between 操作外,Spring Data 方法签名还可以包括以下任何操作:

  • IsAfter, After, IsGreaterThan, GreaterThan
  • IsGreaterThanEqual, GreaterThanEqual
  • IsBefore, Before, IsLessThan, LessThan
  • IsLessThanEqual, LessThanEqual
  • IsBetween, Between
  • IsNull, Null
  • IsNotNull, NotNull
  • IsIn, In
  • IsNotIn, NotIn
  • IsStartingWith, StartingWith, StartsWith
  • IsEndingWith, EndingWith, EndsWith
  • IsContaining, Containing, Contains
  • IsLike, Like
  • IsNotLike, NotLike
  • IsTrue, True
  • IsFalse, False
  • Is, Equals
  • IsNot, Not
  • IgnoringCase, IgnoresCase

作为 IgnoringCase 和 IgnoresCase 的替代方法,可以在方法上放置 AllIgnoringCase 或 AllIgnoresCase 来忽略所有 String 比较的大小写。例如,考虑以下方法:

  1. List<Order> findByDeliveryToAndDeliveryCityAllIgnoresCase(
  2. String deliveryTo, String deliveryCity);

最后,还可以将 OrderBy 放在方法名的末尾,以便根据指定的列对结果进行排序。例如,通过 deliveryTo 属性来订购:

  1. List<Order> findByDeliveryCityOrderByDeliveryTo(String city);

虽然命名约定对于相对简单的查询很有用,但是对于更复杂的查询,不需要太多的想象就可以看出方法名称可能会失控。在这种情况下,可以随意将方法命名为任何想要的名称,并使用 @Query 对其进行注解,以显式地指定调用方法时要执行的查询,如下例所示:

  1. @Query("Order o where o.deliveryCity='Seattle'")
  2. List<Order> readOrdersDeliveredInSeattle();

在这个 @Query 的简单用法中,请求在西雅图交付的所有订单。但是也可以使用 @Query 来执行几乎任何想要的查询,即使通过遵循命名约定来实现查询很困难或不可能。