精通Spring Boot 3 : 5. 使用 Spring Boot 的 Spring Data (3)
Spring 数据 JPA
雅加达持久性 API(JPA)(之前称为 Java 持久性 API)并不是一个工具或框架,而是一套为任何实现者定义概念的规范。与 Spring Data JDBC 类似,Spring Data JPA 可以作为 ORM 工具使用。现在,随着 JPA 3 规范的发布,JPA 可以扩展以支持 NoSQL 数据库。JDBC 和 JPA 之间的主要区别在于,在 JPA 中,您需要关注的是 Java 代码的持久性规则,而不是关系;而在 JDBC 中,您需要手动将代码转换为关系表、列和键。
如果您在非 Spring 应用程序中使用普通 JPA,仍然需要进行大量编码和配置,例如处理连接和会话管理、事务设置和管理、使用 XML 文件定义映射和持久化单元、指定要使用的 JPA 实现等。
Spring Data JPA 通过自动配置数据访问层的实现,极大地减少了开发工作。它的主要特点是开发者只需编写仓库接口,并在必要时添加自定义查找方法,具体实现则由 Spring Data JPA 处理。以下是它的一些其他功能:
- Spring 编程模型
- 选择使用 CrudRepository 接口还是 JpaRepository 接口
- 带有 Querydsl 扩展谓词支持的 Spring Data 扩展,实现类型安全的 JPA 查询
- 基于多个关键字的仓库查询方法(如 findBy<字段名>、deleteBy<字段名>、findBy<字段名>And<其他字段名>等)以及领域类的字段名称
- 域类的透明审计
- 支持分页、动态执行查询,并能够通过多个接口与 PagingAndSortingRepository 集成自定义数据访问代码
- 回调与事件
- 对各个仓库的定制化管理
- 在启动时验证带有 @Query 注解的查询,以便更好地控制您所需的自定义选项
- 基于 JavaConfig 的仓库配置,使用@EnableJpaRepositories 注解
- 强大的扩展功能,能够增强 Spring Data JPA,例如使用 Hibernate Envers 进行审计和版本管理
基于 Spring Boot 的 Spring Data JPA
在一个不使用 Spring Boot 的 Spring 应用中使用 Spring Data JPA,需要添加一些配置(可以是 JavaConfig 或 XML),声明 DataSource、PlatformTransactionManager 和 LocalContainerEntityManagerFactoryBean,并在使用@Configuration 注解的类中使用@EnableJpaRepositories 和@EnableTransactionManagement 注解。
由于 Spring Boot 的自动配置,我们无需做任何额外的工作。只需添加 spring-boot-starter-data-jpa 启动依赖项,它就能正常运行。Spring Boot 允许我们通过设置 spring.jpa.* 属性来覆盖默认配置。此外,Spring Boot 还默认注册了 OpenEntityManagerInViewInterceptor,这个拦截器实现了在视图中打开实体管理器的模式,从而支持在网页视图中进行懒加载。
默认情况下,Spring Boot 使用 Hibernate JPA 实现,这为数据应用程序提供了企业级的水平。
在接下来的章节中,我们将展示如何在应用程序中使用 Spring Data JPA 和 Spring Boot。
基于 Spring Boot 和 Spring Data JPA 的用户应用程序
请记住,您可以访问代码,因此如果想从头开始,可以前往 Spring Initializr (https://start.spring.io) 并接受默认设置。将 Group 字段设置为 com.apress,将 Artifact 和 Name 字段都设置为 users。对于依赖项,添加 Web、Validation、JPA、Lombok、H2 和 PostgreSQL。下载项目后,解压缩并导入到您喜欢的 IDE 中。本节仅涵盖与前几节不同的类。到本节结束时,您应该能够看到图 5-3 中所示的结构。
重要的是,我们不再有 schema.sql 文件。Spring Boot 和 Spring Data JPA 将负责创建我们的数据库表,具体内容将在接下来的章节中说明。
首先打开 build.gradle 文件,然后用列表 5-18 中的内容替换它的内容。
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.3'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.apress'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'org.postgresql:postgresql'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// Web
implementation 'org.webjars:bootstrap:5.2.3'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
test {
testLogging {
events "passed", "skipped", "failed"
showExceptions true
exceptionFormat "full"
showCauses true
showStackTraces true
showStandardStreams = false
}
}
5-18 build.gradle
正如您所看到的,我们正在使用 spring-boot-starter-data-jpa 启动依赖以及两个驱动程序。
接下来,我们来看看领域类。请打开或创建用户类,参见列表 5-19。
package com.apress.users;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.*;
import java.util.Collections;
import java.util.List;
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity(name="USERS")
public class User {
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
private Long id;
@NotBlank(message = "Email can not be empty")
private String email;
@NotBlank(message = "Name can not be empty")
private String name;
private String gravatarUrl;
@Pattern(message = "Password must be at least 8 characters long and contain at least one number, one uppercase, one lowercase and one special character",
regexp = "^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[@#$%^&+=!])(?=\\S+$).{8,}#34;)
private String password;
@Singular("role")
private List<UserRole> userRole;
private boolean active;
@PrePersist
private void prePersist(){
if (this.gravatarUrl == null)
this.gravatarUrl = UserGravatar.getGravatarUrlFromEmail(this.email);
if(this.userRole == null)
this.userRole = Collections.singletonList(UserRole.INFO);
}
}
5-19 src/main/java/apress/com/users/User.java
列表 5-19 显示,User 类包含以下注解(这些注解均来自 jakarta.persistence.*包):
- @Entity:这个注解将我们的类标记为实体,并接受一个参数来设置表的名称——在这里是 USERS 表。
- @Id:这个注解是用来设置类的身份标识,也就是数据库中的主键。
- @GeneratedValue:该注解用于设置 ID 生成策略。它接受一个参数来指定使用的策略,这取决于您的数据库引擎。如果您的数据库不支持身份列,您可以使用 GenerationType.SEQUENCE 策略。如果您的数据库支持身份列,则可以选择以下任意一种策略:GenerationType.TABLE(表示持久性提供者必须使用底层数据库表为实体分配主键,以确保唯一性)、GenerationType.UUID(表示持久性提供者必须通过生成 RFC 4122 通用唯一标识符为实体分配主键)、GenerationType.AUTO(表示持久性提供者应为数据库选择合适的策略)。
- @PrePersist:该注解将方法标记为对应生命周期事件的回调。还有许多其他类似的回调注解,例如 @PreUpdate、@PreRemove、@PostUpdate、@PostRemove、@PostPersist 和 @PostLoad。
接下来,打开或创建 UserRepository 接口。请参阅第 5-20 号列表。
package com.apress.users;
import org.springframework.data.repository.CrudRepository;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
public interface UserRepository extends CrudRepository<User,Long>{
Optional<User> findByEmail(String email);
@Transactional
void deleteByEmail(String email);
}
5-20 src/main/java/apress/com/users/UserRepository.java
用户仓库接口扩展自 CrudRepository……等等……这是什么?我们在清单 5-4 中使用了相同的接口。我们可以使用与 JPA 相关的接口,但我们将在 My Retro App 项目中使用它。请记住,CrudRepository 接口声明了多个方法,Spring Data 会负责实现。此外,请注意我们添加了 @Transactional 注解,这使得我们的调用可以是线程安全的。
接下来,打开 application.properties 文件,并将其内容替换为列表 5-21 中所示的内容。
# H2
spring.h2.console.enabled=true
# DataSource
spring.datasource.generate-unique-name=false
spring.datasource.name=test-db
# JPA
spring.jpa.show-sql=true
spring.jpa.generate-ddl=true
spring.jpa.hibernate.ddl-auto=update
spring.jpa.properties.hibernate.format_sql=true
5-21 src/main/resources/application.properties
应用程序属性文件的新添加包括两个 spring.jpa.* 属性,第一个属性会显示每个执行的 SQL 语句,第二个属性则帮助自动生成我们数据库的 DDL——这真是太好了,因为我们不再需要担心创建 schema.sql 文件!
spring.jpa.hibernate.ddl-auto 属性可用以下值:
- 更新:在必要时更新架构。
- 创建:建立模式并清除之前的数据。
- 创建数据库模式,并在会话结束时将其销毁。
- 禁用数据定义语言(DDL)处理。
- 验证:检查模式的有效性,不会对数据库进行任何修改。
正如您所见,DDL 生成支持涵盖了多种场景。其他类,如 UserConfiguration、UserGravatar、UserRole 和 UsersController,以及测试类 UsersHttpRequestTests,保持不变。
用户应用的测试与运行
你可以在 IDE 中运行测试,或者使用下面的命令:
./gradlew clean test
UsersHttpRequestTests > userEndPointFindUserShouldReturnUser() PASSED
UsersHttpRequestTests > userEndPointDeleteUserShouldReturnVoid() PASSED
UsersHttpRequestTests > indexPageShouldReturnHeaderOneContent() PASSED
UsersHttpRequestTests > userEndPointPostNewUserShouldReturnUser() PASSED
UsersHttpRequestTests > usersEndPointShouldReturnCollectionWithTwoUsers() PASSED
您可以在 IDE 中运行用户应用,或者使用以下命令:
./gradlew bootRun
...
Hibernate:
create table users (
id bigint generated by default as identity,
active boolean not null,
email varchar(255),
gravatar_url varchar(255),
password varchar(255),
user_role tinyint array,
primary key (id)
)
...
如你所见,我们不再需要 schema.sql。Spring Boot 和 Spring Data JPA 会自动创建数据库中的表。
打开 build.gradle 文件,并将其内容替换为列表 5-22 中的内容。
plugins {
id 'java'
id 'org.springframework.boot' version '3.2.3'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.apress'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '17'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
developmentOnly 'org.springframework.boot:spring-boot-docker-compose'
annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
runtimeOnly 'org.postgresql:postgresql'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// Web
implementation 'org.webjars:bootstrap:5.2.3'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
列表 5-22 的 build.gradle 文件
正如您所看到的,我们正在引入 spring-boot-starter-data-jpa 和 spring-boot-docker-compose 启动依赖。
接下来,打开或创建 board 包,以及 Card 和 RetroBoard 类。列表 5-23 展示了 Card 类的内容。
package com.apress.myretro.board;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.UUID;
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
public class Card {
@GeneratedValue(strategy = GenerationType.UUID)
@Id
private UUID id;
@NotBlank
private String comment;
@NotNull
@Enumerated(EnumType.STRING)
private CardType cardType;
@ManyToOne
@JoinColumn(name = "retro_board_id")
@JsonIgnore
RetroBoard retroBoard;
}
5-23 src/main/java/apress/com/myretro/board/Card.java
让我们来分析一下 Card 类的注释,看看与之前版本相比有什么变化
@Entity:该注解将我们的类标识为实体,来源于 jakarta.persistence.*包。默认情况下,表的名称为 CARD。
@GeneratedValue:该注解用于设置 ID 生成策略。它接受一个参数来指定使用的策略,这取决于您的数据库引擎。在此情况下,我们使用 UUID。
@Id:该注解用于设置类的身份标识,即数据库中的主键。该注解同样来自 jakarta.persistence.*包。
@Enumerated: 此注解用于指定某个属性或字段应以枚举类型进行持久化。它可以接受 EnumType 的值,包括 ORDINAL 或 STRING。使用此设置,将生成以下 SQL 语句(这只是一个片段):
create table card (
id uuid not null,
retro_board_id uuid,
card_type varchar(255) not null check (card_type in ('HAPPY','MEH','SAD')),
comment varchar(255),
primary key (id)
)
- @ManyToOne:该注解用于指定与另一个实体类之间的单值多对一关联。此外,还有@ManyToMany、@OneToOne 和@OneToMany 注解。
- @JoinColumn:该注解用于指定连接实体关联或元素集合的列。在此情况下,我们确定要连接的列为 retro_board_id。请注意,命名策略会生效,使用蛇形命名法来创建字段。您可以在之前的代码片段中看到这一点。
如前所述,使用 JPA 时,我们需要关注对象及其依赖关系。在这种情况下,我们依赖于 RetroBoard 类,如清单 5-24 所示。
package com.apress.myretro.board;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import lombok.*;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
public class RetroBoard {
@GeneratedValue(strategy = GenerationType.UUID)
@Id
private UUID id;
@NotBlank(message = "A name must be provided")
private String name;
@Singular
@OneToMany(mappedBy = "retroBoard")
private List<Card> cards = new ArrayList<>();
}
5-24 src/main/java/apress/com/myretro/board/RetroBoard.java
请注意,在列表 5-24 中,我们仍然使用必需的注解 @Entity 和 @Id。我们还使用了新的 @OneToMany 注解,它能够根据 RetroBoard 的 ID 获取所有的 Cards。@OneToMany 注解指定了一种多值关联,具有一对多的关系。
接下来,打开或创建持久化包,以及 CardRepository 和 RetroBoardRepository 类,具体内容请参见清单 5-25 和 5-26。
package com.apress.myretro.persistence;
import com.apress.myretro.board.Card;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface CardRepository extends JpaRepository<Card, UUID> {
}
5-25 src/main/java/apress/com/myretro/persistence/CardRepository.java
package com.apress.myretro.persistence;
import com.apress.myretro.board.RetroBoard;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface RetroBoardRepository extends JpaRepository<RetroBoard,UUID> {
}
5-26 src/main/java/apress/com/myretro/persistence/RetroBoardRepository.java
列表 5-25 和 5-26 显示,CardRepository 和 RetroBoardRepository 接口都继承自 JpaRepository 接口,这与 JPA 相关。请查看以下代码片段:
public interface JpaRepository<T, ID> extends ListCrudRepository<T, ID>, ListPagingAndSortingRepository<T, ID>, QueryByExampleExecutor<T> {
void flush();
<S extends T> S saveAndFlush(S entity);
<S extends T> List<S> saveAllAndFlush(Iterable<S> entities);
void deleteAllInBatch(Iterable<T> entities);
void deleteAllByIdInBatch(Iterable<ID> ids);
void deleteAllInBatch();
T getReferenceById(ID id);
@Override
<S extends T> List<S> findAll(Example<S> example);
@Override
<S extends T> List<S> findAll(Example<S> example, Sort sort);
}
正如您所看到的,JpaRepository 继承了其他支持分页和排序的接口,以及 CrudRepository,这个接口提供了您已经熟悉的方法。
接下来,打开或创建服务包以及 RetroBoardService 类。请参阅列表 5-27。
package com.apress.myretro.service;
import com.apress.myretro.board.Card;
import com.apress.myretro.board.RetroBoard;
import com.apress.myretro.exception.CardNotFoundException;
import com.apress.myretro.exception.RetroBoardNotFoundException;
import com.apress.myretro.persistence.CardRepository;
import com.apress.myretro.persistence.RetroBoardRepository;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@AllArgsConstructor
@Service
public class RetroBoardService {
RetroBoardRepository retroBoardRepository;
CardRepository cardRepository;
public RetroBoard save(RetroBoard domain) {
return this.retroBoardRepository.save(domain);
}
public RetroBoard findById(UUID uuid) {
return this.retroBoardRepository.findById(uuid).get();
}
public Iterable<RetroBoard> findAll() {
return this.retroBoardRepository.findAll();
}
public void delete(UUID uuid) {
this.retroBoardRepository.deleteById(uuid);
}
public Iterable<Card> findAllCardsFromRetroBoard(UUID uuid) {
return this.findById(uuid).getCards();
}
public Card addCardToRetroBoard(UUID uuid, Card card){
Card result = retroBoardRepository.findById(uuid).map(retroBoard -> {
card.setRetroBoard(retroBoard);
return cardRepository.save(card);
}).orElseThrow(() -> new RetroBoardNotFoundException());
return result;
}
public void addMultipleCardsToRetroBoard(UUID uuid, List<Card> cards){
RetroBoard retroBoard = this.findById(uuid);
cards.forEach(card -> card.setRetroBoard(retroBoard));
cardRepository.saveAll(cards);
}
public Card findCardByUUID(UUID uuidCard){
Optional<Card> result = cardRepository.findById(uuidCard);
if(result.isPresent()){
return result.get();
}else{
throw new CardNotFoundException();
}
}
public Card saveCard(Card card){
return cardRepository.save(card);
}
public void removeCardByUUID(UUID cardUUID){
cardRepository.deleteById(cardUUID);
}
}
5-27 src/main/java/apress/com/myretro/service/RetroBoardService.java
正如您所见,RetroBoardService 类非常简单。在这种情况下,我们使用了两个存储库:CardRepository 和 RetroBoardRepository 接口,作为将由 Spring 注入的依赖项。
接下来,打开 application.properties 文件,并将其内容替换为列表 5-28 中所示的内容。
# DataSource
spring.datasource.generate-unique-name=false
spring.datasource.name=test-db
# JPA
spring.jpa.show-sql=true
spring.jpa.generate-ddl=true
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
5-28 src/main/resources/application.properties
唯一的新属性是 hibernate.dialect,这只是为了说明您可以指定一个方言,但这并不是必要的,因为当自动配置启动时,它已经知道您正在使用 PostgreSQL。
这些建议、配置、异常和网络包及其类与之前的章节相同。如前所述,您可以通过 Apress 网站(https://www.apress.com/gp/services/source-code)访问源代码。
运行我的复古应用程序
你可以在 IDE 中运行 My Retro App,或者执行以下命令:
./gradlew clean bootRun
在输出中,您应该看到 Docker Compose 启动了 PostgreSQL 服务,并且部分 SQL 查询正在执行。请注意,JPA 生成了三个表:card、retro_board 和 retro_board_card,这反映了我们如何创建类和它们之间的关系。
现在,您可以选择打开浏览器访问 http://localhost:8080/retros,或者执行以下命令:
curl -s http://localhost:8080/retros | jq .
[
{
"id": "57964a9a-9b56-453d-925a-64f63b502a48",
"name": "Spring Boot Conference",
"cards": [
{
"id": "080d4feb-8f84-4fc7-b6c3-9da741291846",
"comment": "Spring Boot Rocks!",
"cardType": "HAPPY"
},
{
"comment": "Meet everyone in person",
"cardType": "HAPPY"
},
{
"comment": "When is the next one?",
"cardType": "MEH"
},
{
"id": "91a49f48-a99b-4163-a0c7-230aa0142dc2",
"comment": "Not enough time to talk to everyone",
"cardType": "SAD"
}
]
}
]
正如您所见,由于使用了@JsonIgnore 注解,卡片未能打印出 retroBoard 实例。
就这样,你已经意识到 JPA 的强大了。但有没有更简单的方法呢?