3.2 使用Spring Data JPA持久化数据

3.2 使用Spring Data JPA持久化数据

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

  • Spring Data JPA:基于关系型数据库进行JPA持久化。
  • Spring Data MongoDB:持久化到Mongo文档数据库。
  • Spring Data Neo4j:持久化到Neo4j图数据库。
  • Spring Data Redis:持久化到Redis key-value存储。
  • Spring Data Cassandra:持久化到Cassandra数据库。

Spring Data为所有项目提供了一项最有趣且最有用的特性,就是基于repository规范接口自动生成repository的功能。

要了解Spring Data是如何运行的,我们需要重新开始,将本章前文基于JDBC的repository替换为使用Spring Data JPA的repository。首先,我们需要将SpringData JPA添加到项目的构建文件中。

3.2.1 添加Spring Data JPA到项目中

Spring Boot应用可以通过JPA starter来添加Spring Data JPA。这个starter依赖不仅会引入Spring Data JPA,还会传递性地将Hibernate作为JPA实现引入进来:

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

如果你想要使用不同的JPA实现,那么至少需要将Hibernate依赖排除出去并将你所选择的JPA库包含进来。举例来说,如果想要使用EclipseLink来替代Hibernate,就需要像这样修改构建文件:

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

需要注意,根据你所选择的JPA实现,这里可能还需要其他的变更。你可以参考所选择的JPA实现文档以了解更多细节。现在,我们重新看一下领域对象,并为它们添加注解,使其支持JPA持久化。

3.2.2 将领域对象标注为实体

你马上将会看到,在创建repository方面,Spring Data为我们做了很多非常棒的事情。但是,在使用JPA映射注解标注领域对象方面,它却没有提供太多的助益。我们需要打开Ingredient、Taco和Order类,并为其添加一些注解,首先是Ingredient类,如程序清单3.16所示。

程序清单3.16 为Ingredient添加注解使其支持JPA持久化

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

为了将Ingredient声明为JPA实体,它必须添加@Entity注解。它的id属性需要使用@Id注解,以便于将其指定为数据库中唯一标识该实体的属性。

除了JPA特定的注解,你可能会发现我们在类级别添加了@NoArgsConstructor注解。JPA需要实体有一个无参的构造器,Lombok的@NoArgsConstructor注解能够帮助我们实现这一点。但是,我们不想直接使用它,因此通过将access属性设置为AccessLevel.PRIVATE使其变成私有的。因为这里有必须要设置的final属性,所以我们将force设置为true,这样Lombok生成的构造器就会将它们设置为null。

我们还添加了一个@RequiredArgsConstructor注解。@Data注解会为我们添加一个有参构造器,但是使用@NoArgsConstructor注解之后,这个构造器就会被移除掉。现在,我们显式添加@RequiredArgsConstructor注解,以确保除了private的无参构造器之外,我们还会有一个有参构造器。

接下来,我们看一下程序清单3.17所示的Taco类,看看它是如何标注为JPA实体的。

程序清单3.17 将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
26
27
28
29
30
31
package tacos;
import java.util.Date;
import java.util.List;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.ManyToMany;
import javax.persistence.OneToMany;
import javax.persistence.PrePersist;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import lombok.Data;
@Data
@Entity
public class Taco {
@Id
@GeneratedValue(strategy=GenerationType.AUTO)
private Long id;
@NotNull
@Size(min=5, message="Name must be at least 5 characters long")
private String name;
private Date createdAt;
@ManyToMany(targetEntity=Ingredient.class)
@Size(min=1, message="You must choose at least 1 ingredient")
private List<Ingredient> ingredients;
@PrePersist
void createdAt() {
this.createdAt = new Date();
}
}

与Ingredient类似,Taco类现在添加了@Entity注解,并为其id属性添加了@Id注解。因为我们要依赖数据库自动生成ID值,所以在这里还为id属性设置了@GeneratedValue,将它的strategy设置为AUTO。

为了声明Taco与其关联的Ingredient列表之间的关系,我们为ingredients添加了@ManyToMany注解。每个Taco可以有多个Ingredient,而每个Ingredient可以是多个Taco的组成部分。

你会看到,在这里有一个新的方法createdAt(),并使用了@PrePersist注解。在Taco持久化之前,我们会使用这个方法将createdAt设置为当前的日期和时间。最后,我们要将Order对象标注为实体。程序清单3.18展示了新的Order类。

程序清单3.18 将Order标注为JPA实体

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
package tacos;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.ManyToMany;
import javax.persistence.OneToMany;
import javax.persistence.PrePersist;
import javax.persistence.Table;
import javax.validation.constraints.Digits;
import javax.validation.constraints.Pattern;
import org.hibernate.validator.constraints.CreditCardNumber;
import org.hibernate.validator.constraints.NotBlank;
import lombok.Data;
@Data
@Entity
@Table(name="Taco_Order")
public class Order implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy=GenerationType.AUTO)
private Long id;
private Date placedAt;
...
@ManyToMany(targetEntity=Taco.class)
private List<Taco> tacos = new ArrayList<>();
public void addDesign(Taco design) {
this.tacos.add(design);
}
@PrePersist
void placedAt() {
this.placedAt = new Date();
}
}

我们可以看到,Order所需的变更就是Taco的翻版。但是,在类级别这里有了一个新的注解,即@Table。它表明Order实体应该持久化到数据库中名为Taco_Order的表中。

我们可以将这个注解用到所有的实体上,但是只有Order有必要这样做。如果没有它,JPA默认会将实体持久化到名为Order的表中,但是order是SQL的保留字,这样做的话会产生问题。实体都已经标注好了,现在我们该编写repository了。

3.2.3 声明JPA repository

在JDBC版本的repository中,我们显式声明想要repository提供的方法。但是,借助Spring Data,我们可以扩展CrudRepository接口。举例来说,如下是新的IngredientRepository接口。

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

CrudRepository定义了很多用于CRUD(创建、读取、更新、删除)操作的方法。注意,它是参数化的,第一个参数是repository要持久化的实体类型,第二个参数是实体ID属性的类型。对于IngredientRepository来说,参数应该是Ingredient和String。

我们可以非常简单地定义TacoRepository:

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

IngredientRepository和TacoRepository之间唯一比较明显的区别就是CrudRepository的参数。在这里,我们将其设置为Taco和Long,从而指定Taco实体(及其ID类型)是该repository接口的持久化单元。最后,相同的变更可以用到OrderRepository上:

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

现在,我们有了3个repository。你可能会想,我们应该需要编写它们的实现类,包括每个实现类所需的十多个方法。但是,Spring Data JPA带来的好消息是,我们根本就不用编写实现类!当应用启动的时候,Spring Data JPA会在运行期自动生成实现类。这意味着,我们现在就可以使用这些repository了。我们只需要像使用基于JDBC的实现那样将它们注入控制器中就可以了。

CrudRepository所提供的方法对于实体的通用持久化是非常有用的。但是,如果我们的需求并不局限于基本持久化,那又该怎么办呢?接下来,我们看一下如何自定义repository来执行特定领域的查询。

3.2.4 自定义JPA repository

假设除了CrudRepository提供的基本CRUD操作之外,我们还需要获取投递到指定邮编(Zip)的订单。实际上,我们只需要添加如下的方法声明到OrderRepository中,这个问题就解决了:

1
List<Order> findByDeliveryZip(String deliveryZip);

当创建repository实现的时候,Spring Data会检查repository接口的所有方法,解析方法的名称,并基于被持久化的对象来试图推测方法的目的。本质上,SpringData定义了一组小型的领域特定语言(Domain-Specific Language,DSL),在这里持久化的细节都是通过repository方法的签名来描述的。

Spring Data能够知道这个方法是要查找Order的,因为我们使用Order对CrudRepository进行了参数化。方法名findByDeliveryZip()确定该方法需要根据deliveryZip属性相匹配来查找Order,而deliveryZip的值是作为参数传递到方法中来的。

findByDeliveryZip ()方法非常简单,但是Spring Data也能处理更加有意思的方法名称。repository方法是由一个动词、一个可选的主题(Subject)、关键词By以及一个断言所组成的。在findByDeliveryZip()这个样例中,动词是find,断言是DeliveryZip,主题并没有指定,暗含的主题是Order。

我们考虑另外一个更复杂的样例。假设我们想要查找投递到指定邮编且在一定时间范围内的订单。在这种情况下,我们可以将如下的方法添加到OrderRepository中,它就能达到我们的目的。

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

图3.2展现了Spring Data在生成repository实现的时候是如何解析和理解readOrdersByDeliveryZipAndPlacedAtBetween()方法的。我们可以看到,在readOrdersByDeliveryZipAndPlacedAtBetween()中,动词是read。SpringData会将get、read和find视为同义词,它们都是用来获取一个或多个实体的。另外,我们还可以使用count作为动词,它会返回一个int值,代表匹配实体的数量。

epub_29101559_29

图3.2 Spring Data解析repository方法签名来确定要执行的查询

尽管方法的主题是可选的,但是这里要查找的就是Order。Spring Data会忽略主题中大部分的单词,所以你尽可以将方法命名为readPuppiesBy…,它依然会去查找Order实体,因为CrudRepository的类型是参数化的。

单词By后面的断言是方法签名中最为有意思的一部分。在本例中,断言指定了Order的两个属性:deliveryZip和placedAt。deliveryZip属性的值必须要等于方法第一个参数传入的值。关键字Between表明placedAt属性的值必须要位于方法最后两个参数的值之间。

除了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
2
List<Order> findByDeliveryToAndDeliveryCityAllIgnoresCase(
String deliveryTo, String deliveryCity);

最后,我们还可以在方法名称的结尾处添加OrderBy,实现结果集根据某个列排序。例如,我们可以按照deliveryTo属性排序:

1
List<Order> findByDeliveryCityOrderByDeliveryTo(String city);

尽管方法名称约定对于相对简单的查询非常有用,但是,不难想象,对于更为复杂的查询,方法名可能会面临失控的风险。在这种情况下,可以将方法定义为任何你想要的名称,并为其添加@Query注解,从而明确指明方法调用时要执行的查询,如下面的样例所示:

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

在本例中,通过使用@Query,我们声明只查询所有投递到Seattle的订单。但是,我们可以使用@Query执行任何想要的查询,有些查询是通过方法命名约定很难甚至根本无法实现的。