Spring Data JPA 入门
前言、Spring Data JPA 是什么?
1、背景
近年来国内大多数都是使用 MyBatis-Plus 框架进行查询数据库数据,MyBatis-Plus 简单易上手的操作让很多中小公司的开发人员爱不释手。再加上 MyBatis-Plus 官方人员也非常给力,陆续开发出很多好用的插件,尤其是代码生成器,让小型项目的搭建更加简单快速,直接生成 MVC 的基础代码和包结构,这简直是小型项目或者初期项目快速实现的大杀器。
而就是因为前期为了快速实现,后面业务慢慢的发展,那么问题就会逐一暴露出来。在一个项目的初期开发者完全可以通过 MyBatis-Plus 的组件快速生成一整个系统,然后简单修改后就可以直接上线。但是当业务慢慢越来越复杂,Java 代码中类与类的关系本身就是不明确的,如果再加上初期没有表结构设计和一些项目设计文档,那对于新加入的新人来说,面对超级多的 Entity 类、Mapper 接口和 XML 中那大量的 SQL 代码,那绝对是灾难级别的。
2、优势
针对上述问题,对于 Spring Data JPA 来说,当业务变得复杂时,Spring Data JPA 是能够通过面向对象的方式,更好地管理和表达实体之间的关系,减少出错的可能性。由于代码风格统一、结构清晰,新加入的成员能够更快地理解每个模块实体与实体之间的关系,并且能够快速上手项目代码,不会说还得面对几百上千行的 SQL 时需要一直去问各个表之间的关系,减少了沟通成本和学习曲线。
3、Spring Data JPA 和 MyBatis-Plus 对比
下面是 Spring Data JPA 和 Mybatis-Plus 两个框架之间的对比:
对比维度 | Spring Data JPA | MyBatis-Plus |
---|---|---|
编程范式 | ORM 框架,面向对象 | 半 ORM,SQL 为中心 |
开发效率 | 高,自动化 CRUD,方法名解析查询 | 高,通用 Mapper,代码生成器 |
灵活性 | 较高,受限于 JPA 规范 | 高,可直接编写 SQL,支持数据库特性 |
学习成本 | 需要理解 JPA 规范,可能较高 | 较低,熟悉 SQL 即可 |
性能 | 需注意延迟加载和批量操作,性能优化需深入理解 | 性能可控,可通过优化 SQL 提升性能 |
社区支持 | Spring 生态,社区活跃,文档丰富(中文文档比较少) | 社区活跃,文档和示例丰富 |
适用场景 | 面向对象程度高、实体关系复杂、追求代码规范的项目 | 复杂查询、多数据库特性、需要精细化 SQL 优化的项目 |
4、Spring Data JPA 与 JPA 的关系是什么?
简单点说,JPA 是 Java EE(Jakarta EE)标准规范,而 Spring Data JPA 则是更高级的抽象接口,它底层调用的还是 JPA 规范的接口,而真正实现 JPA 规范的框架是叫做 Hibernate。
本文章主要是聊 Spring Data JPA 提供的高级抽象接口的使用,可能会有读者知道 EntityManager 这个接口,这个接口其实是 JPA 规范中的接口。
一、准备
接下来,博主会使用一些场景,来进行演示 Spring Data JPA 的使用方式。在演示之前,我们在项目中需要引入 Spring Data JPA 的依赖,如果读者使用的是 Spring Boot 框架,那就太好了,因为你只需要引入依赖,不需要定义版本。而对于项目中使用非 Spring Boot 框架的读者来说,可能就需要好好注意版本的问题。
1、依赖引入
Spring Boot 框架依赖引入:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependencies>
非 Spring Boot 框架依赖引入:
<dependencies>
<!-- Spring 核心依赖 -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.3.29</version>
</dependency>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-jpa</artifactId>
<version>2.7.12</version>
</dependency>
<!-- JPA 实现,如 Hibernate -->
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-core</artifactId>
<version>5.6.15.Final</version>
</dependency>
<!-- 数据库驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
</dependency>
<!-- Spring ORM -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-orm</artifactId>
<version>5.3.29</version>
</dependency>
</dependencies>
2、定义实体
在定义实体之前,先描述一个简单的业务场景,这样下面的使用时,会比较容易理解,而 JPA 中的一些注解,我会在使用到的地方进行介绍,最后会有一个总结,这样如果读者一点一点往下读下去,就不会一下子被很多的知识砸晕了。
拿一个简单的学生、学生证和班级业务举例,包括学生(Student)、学生证(StudentCard)和班级(Classroom)。一个学生只会拥有一个学生证,一个学生只会有一个所属班级,而一个班级下有 N 个学生,用这个关系去定义实体模型。
3、创建表结构和实体
表结构
-- 创建 Classroom 表
CREATE TABLE Classroom (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
grade VARCHAR(50) NOT NULL
);
-- 创建 StudentCard 表
CREATE TABLE StudentCard (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
card_number VARCHAR(50) NOT NULL UNIQUE,
issue_date DATE NOT NULL
);
-- 创建 Student 表
CREATE TABLE Student (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE,
age INT,
classroom_id BIGINT,
student_card_id BIGINT UNIQUE
);
学生(Student)
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import javax.persistence.*;
import java.io.Serializable;
@Setter
@Getter
@ToString
@Entity
@Table(name = "student")
public class Student implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 姓名
private String name;
// 邮箱
private String email;
// 年龄
private Integer age;
// 所属班级
@ManyToOne
@JoinColumn(name = "classroom_id")
private Classroom classroom;
// 学生证
@OneToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "student_card_id", referencedColumnName = "id")
private StudentCard studentCard;
}
学生证(StudentCard)
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import javax.persistence.*;
import java.io.Serializable;
import java.time.LocalDateTime;
@Setter
@Getter
@ToString
@Entity
@Table(name = "student_card")
public class StudentCard implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 学生证编号
@Column(name = "card_number")
private String cardNumber;
// 发放日期
@Column(name = "issue_date")
private LocalDateTime issueDate;
@OneToOne(mappedBy = "studentCard")
private Student student;
}
班级(Classroom)
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import javax.persistence.*;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
@Setter
@Getter
@ToString
@Entity
@Table(name = "classroom")
public class Classroom implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// 班级名称
private String name;
// 年级
private String grade;
// 关联学生
@OneToMany(mappedBy = "classroom", cascade = CascadeType.ALL)
private List<Student> students = new ArrayList<>();
}
二、实体中使用到的注解介绍
@Entity
@Eentity 注解放在类上,它表示该类代表一个实体。
@Table
@Table 注解用于指定实体类映射到数据库的哪一个表,以及表的相关属性。如果没有使用该注解,JPA 将默认使用实体类的类名作为表名。一般情况下会自己定义实体对应数据库中的表名。
@Table(
name = "student"
)
public class Student implements Serializable {}
@Id
@Id 注解表示该属性是一个主键。如果说我的表是一个符合主键该怎么办呢?不用担心,JPA 中提供了两种定义复合主键方式,分别是 @IdClass 和 @EmbeddedId。一般使用 @IdClass 的方式比较多。
@IdClass:
需要注意的是,在 StudentId 实体中,一定要重写 equals 和 hashCode ,并且要实现 Serializable 接口。我这里是使用了 lombok 的 @EqualsAndHashCode 注解。
@Setter
@Getter
@ToString
@Entity
@IdClass(StudentId.class)
public class Student implements Serializable {
@Id
private Long studentAId;
@Id
private Long studentBId;
}
@Setter
@Getter
@ToString
@EqualsAndHashCode
public class StudentId implements Serializable {
private Long studentAId;
private Long studentBId;
}
@EmbeddedId:
需要注意的是,在 StudentId 实体中,一定要重写 equals 和 hashCode ,并且要实现 Serializable 接口。我这里是使用了 lombok 的 @EqualsAndHashCode 注解。
@Setter
@Getter
@ToString
@Entity
@IdClass(StudentId.class)
public class Student implements Serializable {
@EmbeddedId
private StudentId id;
}
@Setter
@Getter
@ToString
@EqualsAndHashCode
@Embeddable
public class StudentId implements Serializable {
private Long studentAId;
private Long studentBId;
}
@GeneratedValue
@GeneratedValue 注解用于定义 主键字段 的生成策略。当我们希望数据库自动为实体生成唯一标识符(主键)时,可以在主键字段上使用该注解。它通常与 @Id 注解一起使用,标识该字段为主键,并指定主键的生成策略。需要注意的是 @GeneratedValue 只适用于简单主键,不支持复合主键。
@GeneratedValue 提供了多种主键生成策略,通过 strategy 属性进行指定。主键生成策略由 GenerationType 枚举定义,包括以下几种:
-
AUTO:JPA 自动选择合适的主键生成策略,具体取决于底层数据库和 JPA 实现提供商。
-
IDENTITY(最常用):采用数据库的 自增字段 来生成主键值。每次插入新记录时,数据库自动生成一个唯一的主键值。
-
SEQUENCE:使用数据库的序列(Sequence)来生成主键值,需要指定序列生成器。
-
TABLE:通过使用一张专用的数据库表来生成主键值,需要指定表生成器。
在实际的项目中,我们的 ID 都不会使用这几种,而是自己系统内部定义一个 ID 生成器,比如雪花、UUID等方式,那么这种情况下,我们会在创建实体时手动设置该实体的 ID 值。
@OneToOne、@OneToMany、@ManyToOne 和 @JoinColumn
@OneToOne(一对一)、@OneToMany(一对多)、@ManyToOne(多对一)是实体关系之间映射的注解,以上述的业务例子来说,一个学生有一个学生证,那就是 OneToOne 的关系。
@OneToOne 注解包含以下常用属性:
-
targetEntity:指定关联的目标实体类。可以不写,则默认使用属性的类型。
-
cascade:设置当前实体发生操作时,有哪些操作会影响到关联实体。
-
fetch:指定对关联实体的加载策略,默认为 FetchType.EAGER(直接加载),这个属性值在查询时有很多要说的点,我会在查询那里着重讲述。
-
optional:是否允许关联为空,默认为 true。
-
mappedBy:在双向关联中,由被关联方使用,指定关系的维护端。
-
orphanRemoval:删除实体时,是否将关联实体一块删除,默认是 false。
public class Student implements Serializable {
// 学生证
@OneToOne(cascade = CascadeType.ALL)
@JoinColumn(name = "student_card_id", referencedColumnName = "id")
private StudentCard studentCard;
}
public class StudentCard implements Serializable {
@OneToOne(mappedBy = "studentCard")
private Student student;
}
再次拿过来 Student 和 StudentCard 实体,其中 @OneToOne 就是关联的实体,但是在两个实体中所设置的参数却是不一样的。在 Student 中设置了 cascade = CascadeType.ALL **,这里叫做联级操作,是表示当前实体发生操作时,会有哪种相同的操作影响到关联的实体。比如说,当删除 Student 时,那么关联的 StudentCard 实体也要一块删除。
在 CascadeType 中定义了很多属性值:
- ALL:所有操作都会影响到关联实体。
- PERSIST:持久化(存储)时同时保存关联实体。
- MERGE:合并时同时合并关联实体。合并这里的意思其实就是将修改后的数据同步更新到数据库中。比如从数据库中查询了 Id 为 1 的学生,修改了姓名,这时你可以使用 merge 操作,将修改后的数据同步到数据库中。
- REMOVE:删除时同时删除关联实体。
- REFRESH:刷新时同时刷新关联实体。刷新就是从数据库中,重新读取数据。
- DETACH:分离时同时分离关联实体。分离通常用于清理或优化内存。
注意:在实际业务中要尽量避免使用联级操作,因为会涉及到性能的问题。
在 StudentCard 中,设置了 @OneToOne(mappedBy = “studentCard”),表示 StudentCard 实体是被关联的实体。需要注意的是,不能 Student 和 StudentCard 都设置为相同的(比如都设置成被关联实体),必须要区分主动关联和被动关联。
接下来再看 Student 中的 @JoinColumn(name = “student_card_id”, referencedColumnName = “id”),@JoinColumn 注解用于指定 关联实体之间使用的外键列。当在实体类中定义关联关系时,可以使用 @JoinColumn 来精确控制外键列的名称、引用的列,以及其他属性。
@JoinColumn 中主要的属性是 name 和 referencedColumnName 这两个属性了,name 属性表示当前实体中的外键是哪一个字段,referencedColumnName 则是表示关联的表中哪一个字段跟当前实体中的外键进行关联的。就如案例中 Student 实体中的 student_card_id 字段对应 StudentCard 实体中的 id 字段关系一样。而 @JoinColumn 其他的属性就不太常用了,就不在这里一一介绍了。
在说到这,其实你就已经会了 @OneToMany 和 @ManyToOne 注解了。这两个跟 @OneToOne 是一样的用法,只是表达的关联关系不一样罢了,而还有一些案例中没有提到的关联关系注解,其实意思都是这样。
@Column
@Column 注解是用于 映射实体类的属性或字段到数据库表中的列,并可以通过其属性来定义列的详细信息,如列名、类型、长度、是否可为空等。一般很少会用到这个注解,很很多情况下就是不设置直接走默认。
在这里需要补充一点,就是实体类中是小驼峰的写法,而数据库中是各个单词之间下划线表示,可以通过配置去解决:
jpa:
hibernate:
naming:
physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
三、查询
在 Spring Data JPA 中,可以使用很多方式进行查询数据,比如方法名查询、@Query 注解方式、Specifications 动态复杂查询、命名查询(NamedQuery)、Example(示例匹配查询)和 Query by Example(QBE)。本篇文章不会全讲述完,只会讲几个常用且实用的查询方式,保证让你在真实项目实战中能够应付,并且还绰绰有余。
在开始之前,我会先介绍 JpaRepositorye 和 CrudRepository 查询接口,这两个查询接口是 Spring Data JPA 官方给出的接口,我们只要实现这个接口,就可以进行一些很常用的查询。包括我下面要讲到的查询方式,都是基于这两个接口去扩展。
1、JpaRepository 和 CrudRepository 介绍
CrudRepository 和 JpaRepository 的关系是 JpaRepository 继承了 CrudRepository 接口。CrudRepository 是只提供了一些最基础的 CRUD 操作,而 JpaRepository 则是在 CrudRepository 的基础上,增加了对 JPA 规范的支持,提供了更多的操作方法和功能。像分页查询、刷新持久化上下文、批量操作、等操作都是在 JpaRepository 中进行扩展的,所以在实际的开发过程中,都是会直接基础 JpaRepository 进行操作。
在这里不会对这两个接口中的方法做过多的介绍,在文章的最后,我会把这两个接口中的方法做一个列举,下面的案例虽然基于这两个接口,但是并不妨碍理解。
2、方法名查询
方法名查询,顾名思义就是根据方法的名称进行查询。JPA 中有一套命名规则,基于方法名称的前缀去做不同的处理,比如查询单个的方法名称前缀规则就是 find,查询多个是 findAll,删除就是 remove 等等,这里我不会讲太多的规则,我会在文章最后做一个非常详细的表格,这里只讲如何使用。
那么我们就先写几个业务案例,这样你就会明白的。
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Collection;
import java.util.List;
public interface MethodNameQueryStudentRepository extends JpaRepository<Student, Long> {
// 根据学生邮箱查询学生信息
Student findByEmail(String email);
// 根据学生姓名模糊查询学生信息
List<Student> findAllByNameLike(String name);
// 根据学生年龄大于某个值查询学生信息
List<Student> findAllByAgeGreaterThan(Integer ageIsGreaterThan);
// 根据学生年龄大于某个值且姓名模糊查询学生信息
List<Student> findAllByAgeGreaterThanAndNameLike(Integer ageIsGreaterThan, String name);
// 根据Id批量删除多个学生
void removeByIdIsIn(Collection<Long> ids);
}
在上述的案例中,我使用到了查询精准查询、模糊查询、大于某个值、大于某个值并且模糊查询,以及删除多个Id的方法。Spring Data JPA 通过多个关键词在前缀规则后进行追加的方式进行构建操作方法,只可惜的是只能适用于比较简单的查询,对于业务中一些复杂的操作查询,就不太够用了。下面列举几个常用的关键字符:
- 逻辑运算符:And、Or
- 比较运算符:Is、Equals、Between、LessThan、GreaterThan、Like、In、NotIn 等
- 特殊条件:IsNull、IsNotNull、IsEmpty、IsNotEmpty 等
在一般情况下,使用方法名查询能够做到大多数的业务都能够覆盖得到,会有一少部分覆盖不到,而至于分页和排序查询,我会在下面继续讲述,而连表条件查询,使用单纯的方法名查询方式是行不通的,需要配合其他注解,下面的 @Query 注解会有连表的条件查询。
3、@Query 注解查询
在讲述完基本的方法名查询后,就来介绍一个 @Query 注解查询方式。@Query 注解是其中一种强大且灵活的查询方式。它允许在接口方法上直接编写 JPQL(Java Persistence Query Language)或原生 SQL 查询,适用于单表和多表查询。
下面还是用几个案例,来介绍 @Query 注解查询的使用方法:
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.List;
public interface QueryAnnotationQueryStudentRepository extends JpaRepository<Student, Long> {
// 根据学生邮箱查询学生信息
@Query("SELECT stu FROM Student stu WHERE stu.email = ?1")
Student findByEmail(String email);
// 根据班级名称查询班级下的所有学生
@Query("SELECT stu FROM Student stu JOIN stu.classroom classroom WHERE classroom.name = :classroomName")
List<Student> findUsersByClassroomName(@Param("classroomName") String classroomName);
// 删除多个学生
@Modifying
@Query("DELETE FROM Student stu WHERE stu.id in (?1)")
int removeAllByIdIn(Collection<Long> ids);
}
可以看到上面的例子,能够使用原生的 SQL 进行单表查询和连表查询,而其中的 ?1 和 :classroomName 则是位置参数和命名参数,这两种做法主要是防止 SQL 注入,并且可以通过 JPQL 的方式去进行连表查询。而针对修改和删除时,则需要在方法上面加上 @Modifying 注解,这是必须的,不能不加,因为框架内部需要知道该方法是修改或删除的操作,不加会抛出 InvalidDataAccessApiUsageException 异常。
4、Specifications(动态查询)
Specifications 是 Spring Data JPA 提供的一种动态查询机制,基于 JPA 的 Criteria API。通过 Specifications,可以在代码中以 类型安全 且 面向对象 的方式构建查询条件。相比于固定的 JPQL 或 SQL 查询,Specifications 更适合在条件多变或需要根据用户输入临时拼装查询条件的场景。
Specifications 这种方式对比前两种的写法更加的复杂,但优点就是扩展性特别强!!!我有一篇很详细的文章专门去讲述通过 Specifications 这种方式查询,链接,在这里我就简单举个例子:
import com.demo.entity.Student; import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List;
public interface SpecificationsQueryStudentRepository extends JpaRepository<Student, Long> {
// 定义查询接口
List<Student> findAll(Specification<Student> specification);
}
import com.demo.repository.SpecificationsQueryStudentRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.jpa.domain.Specification;
import javax.persistence.criteria.*;
import java.util.ArrayList;
import java.util.List;
// 这只是 Demo 的启动类,不用管
@SpringBootTest(classes = DemoApplication.class)
public class SpecificationsQueryStudentRepositoryTest {
@Autowired
private SpecificationsQueryStudentRepository repository;
@Test
public void testSaveAndFindStudent() {
Specification<Student> specification = this.buildConditions();
List<Student> list = repository.findAll(specification);
}
private Specification<Student> buildConditions() {
Specification<Student> specifications = new Specification<Student>() {
// 通过 CriteriaBuilder 来构建 Predicate 对象表示查询条件
@Override
public Predicate toPredicate(Root<Student> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) {
// 查询条件集合
List<Predicate> predicates = new ArrayList<>();
// 查询所有姓名等于张三的通宵
predicates.add(criteriaBuilder.equal(root.get("name"), "张三"));
// 连表查询,班级左关联学生,查询班级名称包含“二”【注意:classroom 是与 Student 实体中的属性名称一致】
Join<Object, Object> join = root.join("classroom", JoinType.LEFT);
predicates.add(criteriaBuilder.like(join.get("name"), "%二%"));
//
return query.where(criteriaBuilder.and(predicates.toArray(new Predicate[0]))).getGroupRestriction();
}
};
return specifications;
}
}
还有另外一种封装的比较好的写法,这种封装的更清晰一些:
public void testSaveAndFindStudent() {
Specification<Student> spec = Specification
.where(hasName("张三"))
.and(hasClassroomName("二"));
List<Student> all = repository.findAll(spec);
}
public static Specification<Student> hasName(String name) {
return (Root<Student> root, CriteriaQuery<?> query, CriteriaBuilder cb) -> {
if (name == null || name.trim().isEmpty()) {
// 没有条件
return cb.conjunction();
}
return cb.equal(root.get("name"), name);
};
}
public static Specification<Student> hasClassroomName(String classroomName) {
return (Root<Student> root, CriteriaQuery<?> query, CriteriaBuilder cb) -> {
if (classroomName == null || classroomName.trim().isEmpty()) {
return cb.conjunction();
}
Join<Object, Object> join = root.join("classroom", JoinType.LEFT);
return cb.like(join.get("name"), "%" + classroomName + "%");
};
}
上述的示例中,都是通过 CriteriaBuilder 去构建 Predicate 对象表示查询条件,将多个 Specification 条件组合(and、or)实现复杂查询。在实战的开发中,两种写法都是可以的,只不过是封装的颗粒的大小的问题。相对方法名查询和 @Query 注解的方式查询,这种写的扩展性更强,并且对于像常用的分页列表多个条件查询,用户没有输入则不查询的这种场景下,非常适合,所以,一般在实战项目的开发中,这几种都是混着使用的,根据不同的业务需求和场景,用不同的方式。
简单、固定的查询:用方法名查询最为快速。
中度复杂查询或需要多表关联:使用 @Query 注解,手写 JPQL 或原生 SQL,更直观。
条件动态变化、复杂灵活的查询:使用 Specifications,动态组合条件。
特性 | 方法名称查询 | @Query 查询 | Specifications (动态查询) |
---|---|---|---|
学习成本 | 低 | 中 | 高 |
实现复杂查询 | 较弱(简单查询为主) | 强(可编写复杂查询) | 最强(动态组合条件) |
可维护性 | 中,方法名易过长 | 中,查询语句需手写 | 中等偏高,需要编写 Predicate |
类型安全 | 是(方法名) | 不严格(需检查 JPQL/SQL) | 是(使用 Criteria API) |
动态性 | 较弱(方法名固定) | 较弱(查询固定) | 强(可根据条件组合Specification) |
适用场景 | 简单固定查询 | 中复杂度查询 | 条件多变、复杂动态查询 |
5、分页和排序
在上面常用的几种方式查询中,都没有去讲分页和排序,因为对于分页和排序来说,各自查询方式的用法更加重要,分页和排序则只是加一个参数罢了,下面就开始吧。
方法名称 分页排序
import com.demo.entity.Student;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MethodNameQueryStudentRepository extends JpaRepository<Student, Long> {
// 分页排序查询:根据学生年龄大于某个值且姓名模糊查询学生信息
Page<Student> findAllByAgeGreaterThanAndNameLike(Integer ageIsGreaterThan, String name, Pageable pageable);
// 排序查询:根据学生年龄大于某个值且姓名模糊查询学生信息
List<Student> findAllByAgeGreaterThanAndNameLike(Integer ageIsGreaterThan, String name, Sort sort);
}
import com.demo.DemoApplication;
import com.demo.entity.Student;
import com.demo.repository.MethodNameQueryStudentRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import java.util.List;
@SpringBootTest(classes = DemoApplication.class)
public class MethodNameQueryStudentRepositoryTest {
@Autowired
private MethodNameQueryStudentRepository methodNameQueryStudentRepository;
@Test
public void testPageStudent() {
// 第 1 页(索引从 0 开始),每页 10 条数据,按学生姓名升序排序,年龄降序排序
PageRequest pageable = PageRequest.of(0, 10, Sort.by("name").ascending().and(Sort.by("age").descending()));
// PageRequest pageable = PageRequest.of(0, 10, Sort.by("name").ascending().and(Sort.by("age").descending()));
// 年龄大于 18,姓名包含 "张"
Page<Student> page = methodNameQueryStudentRepository.findAllByAgeGreaterThanAndNameLike(18, "张", pageable);
// 总页数
int totalPages = page.getTotalPages();
// 本页多少条
long totalElements = page.getTotalElements();
// 本页数据
List<Student> content = page.getContent();
}
}
@Query 分页排序
@Query 这种方式,与方法名称的写法一样,就是在方法上加参数即可,下面举两个例子,不多探讨了:
public interface QueryAnnotationQueryStudentRepository extends JpaRepository<Student, Long> {
// 分页排序查询:根据班级名称查询班级下的所有学生
@Query("SELECT stu FROM Student stu JOIN stu.classroom classroom WHERE classroom.name = :classroomName")
Page<Student> findUsersByClassroomName(@Param("classroomName") String classroomName, Pageable pageable);
// 排序查询:根据班级名称查询班级下的所有学生
List<Student> findUsersByClassroomName(@Param("classroomName") String classroomName, Sort sort);
}
Specifications(动态查询) 分页排序
Specifications 与上述两种也都一样,加参数即可:
public interface SpecificationsQueryStudentRepository extends JpaRepository<Student, Long> {
Page<Student> findAll(Specification<Student> specification, Pageable pageable);
}
public class SpecificationsQueryStudentRepositoryTest {
public void testFindAllPage() {
// 分页和排序
PageRequest pageRequest = PageRequest.of(0, 10, Sort.by("name").ascending());
// 查询条件
Specification<Student> specification = this.buildConditions();
// 查询
Page<Student> page = repository.findAll(specification, pageRequest);
}
}
四、性能优化和常见问题
1、延迟加载 与 LazyInitializationException
延迟加载 和 LazyInitializationException 异常,是两个常常伴随出现的概念与问题,出现一方,就会牵扯出另一方。在聊之前,要先一个一个知道是什么,再说解决办法。
什么是延迟加载(Lazy Loading)
延迟加载(Lazy Loading)是为了提高性能和减少不必要的数据加载而引入的一种策略。当使用 JPA 对象关系映射框架时,实体之间经常存在关联关系,通过设置 @OneToMany、@ManyToOne、@OneToOne、@ManyToMany ,来达到实体与实体之间的关系,而在这些注解中,会有一个 fetch 属性方法,用来设置加载方式,默认的情况是直接加载,也就是查询这一个实体,会把实体中所关联的实体数据都会查询出来。但是在实际查询中,我们并不总是需要立即加载关联的所有数据,所以就需要去设置延迟加载。延迟加载策略允许我们在初次查询实体时仅加载该实体的基本字,而将关联实体的数据加载推迟到真正访问该关联属性时再进行。
什么是 LazyInitializationException
LazyInitializationException 是当我们在 session 已关闭(或超出实体管理上下文范围)后尝试访问尚未初始化的延迟加载数据时抛出的异常。简单来说,就是当我们通过延迟加载这种方式去查询后,因为关联实体的关系初始化时未查询,但是在使用时需要使用到这个关联实体的数据,如果当前的 session 已经关闭了,那么就会出现这个问题。可能这种讲述还是有些抽象,我举个实际中的例子吧:
public void queryAll() {
Optional<Student> studentOptional = repository.findById(1L);
Student student = studentOptional.get();
Long id = student.getId();
String name = student.getName();
Classroom classroom = student.getClassroom();
}
@Transactional
public void queryByTransaction() {
Optional<Student> studentOptional = repository.findById(1L);
Student student = studentOptional.get();
Long id = student.getId();
String name = student.getName();
Classroom classroom = student.getClassroom();
}
有没有发现这两段代码有什么不同?
没错,下面的方法上面有一个 @Transactional 注解!
@Transactional 注解并不单单只是用于常用的异常回滚操作,还能让查询数据库时连接数据库的 session 不关闭!所以,有了这个不关闭 session 的操作,就可以使用延迟加载了。
解决 LazyInitializationException 的常见策略
到这里就可以引入解决的策略了,上面已经给出了一种解决办法,这里会系统的给出解决办法。
解决方法主要分成两种,第一种就是直接配置不懒加载了,设置成直接加载,那么问题就直接解决!但是缺点是一旦面临关联实体中数据量庞大的情况,就会出现性能的问题。
第二种方式就是在查询的方法上增加 @Transactional 注解,这里需要注意一下,如果该方法只是查询,没有其他操作,可以直接写成 @Transactional(readOnly = true) ,这样也会提升性能,但是我并没有测试过会提升多少。
在上述查询方式中,主要有三种查询的方式:方法名称、@Query 和 Specifications。这三种查询方式,方法名称和 Specifications 都是可以直接在方法上增加 @Transactional 注解,而 @Query 注解查询的方式,除了在方法上增加 @Transactional 注解,还可以通过原生 SQL 的方式进行设置直接加载,通过在 JOIN 后面增加 FETCH 进行直接全部加载:
@Query("SELECT stu FROM Student stu JOIN FETCH stu.classroom classroom WHERE classroom.name = :classroomName")
List<Student> findUsersByClassroomName(@Param("classroomName") String classroomName);
除了讲述到的这两种解决办法,还有一些其他的操作,比如做一个延迟加载过滤器,让 session 重新连接,这种方式我就强烈不太推荐了,因为这种会破坏分层架构的能力定义,并且增加后面维护的很多问题。
还需要注意的是,一定要合理的配置加载策略(EAGER or LAZY),也可以在业务初期全部都设置直接加载的方式,避免出现 LazyInitializationException 异常。但是随着业务数据的增长,查询效率下降时,就得考虑 LazyInitializationException 的问题了。总之,有利有弊!
2、N+1 查询问题
问题
N+1 查询问题是使用 ORM(对象关系映射)框架时常见的性能隐患之一。当你在查询数据库中的实体及其关联关系时,如果处理方式不当,程序可能会在不经意间发送过多的 SQL 查询,导致性能明显降低。
比如实体 Classroom,它与另一个实体 Student 存在一对多的关联关系。例如,你需要查询所有的 Classroom,然后对每个 Classroom 实例访问其关联的 Student 集合。理想情况下,你希望能够通过少量的 SQL(甚至一条查询)就拿到所有所需数据。但在使用延迟加载(Lazy Loading)或映射策略不当的情况下,ORM 框架可能会产生以下查询模式:
- 首先执行 1 条查询 来获取 Classroom 实体的列表,假设返回 N 条记录。
- 当你在代码中遍历这 N 条 Classroom 实体时,每次访问 Classroom 的关联属性(比如 classroom.getStudent())时,都会单独为当前 Classroom 实例发送 1 条查询 到数据库,以获取其关联的 Student 列表。
此时你所面临的情况是:
- 第一次获取所有 Classroom 实体的查询为 1 条。
- 然后每个 Classroom 的关联 Student 数据又各产生 N 条查询。
总共 = 1 + N = N+1 条查询。
当 N 很大时,这将导致大量 SQL 查询的执行,显著增加数据库负载和应用程序的响应时间。下面是一段错误的代码示例(查询方式是延迟加载):
public void queryClassroom() {
// 延迟加载查询
List<Classroom> all = classroomRepository.findAll();
for (Classroom classroom : all) {
// 一个 Classroom 对应多个 Student
List<Student> students = classroom.getStudents();
}
}
解决办法
解决办法的核心就一个,就是让查询的数据一次性全部查询出来,不要一个一个再去查询了,
1、@Query 注解中的原生 SQL 使用 JOIN FETCH;
2、实体中关系注解(@OneToMany)配置直接加载(默认就是直接全部加载);
3、批量操作
在 Spring Data JPA 执行批量操作(主要指批量插入)中,虽然也是一个数据库连接,但是 JPA 中会一个一个的 SQL 往数据库发送。比如插入 1000 条数据,那么就会生成 1000 条 INSERT 语句,就算是一个连接,消耗的时间也是非常大的。
批量插入的解决办法有这么三个吧。
第一种是直接用 Mybatis-Plus 框架进行专门处理批量插入的问题;
第二种是通过设置配置(JDBC配置)来解决批量插入的条数:
properties 文件格式:
spring.jpa.properties.hibernate.jdbc.batch_size=1000
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
yml 文件格式:
spring:
jpa:
properties:
hibernate:
jdbc:
batch_size: 1000
order_inserts: true
order_updates: true
配置说明:
-
hibernate.jdbc.batch_size:设置批处理的大小(如每 1000 条 SQL 语句为一个批次)。
-
hibernate.order_inserts 和 hibernate.order_updates:优化批处理,通过将相同类型的 SQL 语句集中在一起,提高批处理效率。
第三种是通过 JPA 中的 EntityManager 进行操作,下面我给一个示例:
@SpringBootTest(classes = DemoApplication.class)
public class MethodNameQueryStudentRepositoryTest {
@Autowired
private EntityManager entityManager;
@Test
@Transactional
public void testSaveAndFindStudent() {
// 多个学生
List<Student> all = ....;
int batchSize = 50;
for (int i = 0; i < all.size(); i++) {
// 插入新学生
entityManager.persist(all.get(i));
if (i > 0 && i % batchSize == 0) {
// 将持久化上下文中的更改同步到数据库
entityManager.flush();
// 清理持久化上下文,释放内存
entityManager.clear();
}
}
// 将持久化上下文中的更改同步到数据库
entityManager.flush();
// 清理持久化上下文,释放内存
entityManager.clear();
}
}
4、缓存机制
缓存机制在现在的应用程序中扮演着至关重要的角色,尤其是在数据密集型应用中。通过有效利用缓存,可以显著提升应用的性能、降低数据库负载,并改善用户体验。Spring Data JPA 提供了多层次的缓存机制,主要包括一级缓存(First-Level Cache)、二级缓存(Second-Level Cache)以及查询缓存(Query Cache)。
在 Spring Data JPA 中,缓存机制主要分为以下几类:
-
一级缓存(First-Level Cache) :与 EntityManager 或 Session 关联的缓存。
-
二级缓存(Second-Level Cache) :跨 EntityManager 或 Session 的共享缓存。
-
查询缓存(Query Cache) :缓存查询结果,提高查询性能。
一级缓存(First-Level Cache)
一级缓存是 JPA 中的一个核心概念,它与每个 EntityManager(在 Spring 中通常由 @Transactional 管理的事务)或 Hibernate 的 Session 实例相关联。一级缓存的主要作用是存储在当前持久化上下文中管理的实体实例。
下面给一个示例:
@SpringBootTest(classes = DemoApplication.class)
public class CacheStudentRepositoryTest {
@Autowired
private MethodNameQueryStudentRepository repository;
@Test
@Transactional(readOnly = true)
public void queryAll() {
Optional<Student> studentOptional1 = repository.findById(1L);
Student student1 = studentOptional1.get();
System.out.println("查询第一次...");
Optional<Student> studentOptional2 = repository.findById(1L);
Student student2 = studentOptional2.get();
System.out.println("查询第二次...");
}
}
打印输出:
Hibernate: select student0_.id as id1_1_0_, student0_.age as age2_1_0_, student0_.classroom_id as classroo5_1_0_, student0_.email as email3_1_0_, student0_.name as name4_1_0_, student0_.student_card_id as student_6_1_0_, classroom1_.id as id1_0_1_, classroom1_.grade as grade2_0_1_, classroom1_.name as name3_0_1_, studentcar2_.id as id1_2_2_, studentcar2_.card_number as card_num2_2_2_, studentcar2_.issue_date as issue_da3_2_2_ from student student0_ left outer join classroom classroom1_ on student0_.classroom_id=classroom1_.id left outer join student_card studentcar2_ on student0_.student_card_id=studentcar2_.id where student0_.id=?
查询第一次...
查询第二次...
能够看到第一次的时候先查询数据库,而在第二次查询的时候,却没有查询数据库,这就是一级缓存,只要是在当前这个事务中,它就是生效的。一级缓存不需要我们做任何事情,它本身默认就是开启的。
二级缓存(Second-Level Cache)
二级缓存是一个跨 EntityManager 或 Session 的共享缓存,旨在缓存常用实体或查询结果,以减少对数据库的访问次数。与一级缓存不同,二级缓存是 全局的,可以被多个持久化上下文共享。
启用二级缓存我们需要一些外部的框架,比如我们常说的 Redis。当然,对于很多小型项目来说,引入 Redis 会让项目的技术栈变得复杂,这样的话也可以使用一些本地缓存框架,比如 Ehcache、Caffeine、JCache 等。据目前各个公司内部,用的最多的还是 Ehcache,这里说的是小项目,中大型项目还是 Redis 最多。
在这里我就以 Ehcache 举例,如果你的项目中不想使用这个缓存框架,想要换掉,可以直接把 Ehcache 框架的依赖和陪着去掉,换成其他的缓存框架配置和依赖。
这里使用 Spring 规定的 Cache 规范,后期换框架的话方便一些,Spring 提供的缓存抽象,通过注解如 @
Cacheable、@CachePut 和 @CacheEvict 管理方法级别的缓存,下面就一步一步的进行配置:
依赖:
<!-- SpringBoot JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- SpringBoot Cache -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- Ehcache -->
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
<version>3.10.6</version>
</dependency>
<!-- Hibernate Ehcache -->
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-ehcache</artifactId>
<version>5.6.15.Final</version>
</dependency>
在 src/main/resources
目录下创建 ehcache.xml,我这里用的是 Ehcache 2 的配置写法:
<?xml version="1.0" encoding="UTF-8"?>
<ehcache>
<!-- 默认缓存 -->
<defaultCache
maxElementsInMemory="10000"
eternal="false"
timeToIdleSeconds="120"
timeToLiveSeconds="120"
overflowToDisk="false"
maxElementsOnDisk="10000000"
diskPersistent="false"
diskExpiryThreadIntervalSeconds="120"
memoryStoreEvictionPolicy="LRU"
/>
<!-- Student 缓存 -->
<cache name="studentCache"
maxElementsInMemory="2000"
eternal="false"
timeToIdleSeconds="4"
timeToLiveSeconds="4"
overflowToDisk="false"
/>
</ehcache>
application.yml 中配置:
spring:
jpa:
# 打印 SQL
show-sql: true
properties:
hibernate:
cache:
# 启用二级缓存
use_second_level_cache: true
use_query_cache: true
# 配置缓存区域工厂
region:
factory_class: org.hibernate.cache.ehcache.EhCacheRegionFactory
# Spring 指定 Ehcache 配置文件
cache:
ehcache:
config: classpath:ehcache.xml
type: ehcache
启动类配置,增加 @EnableCaching 注解,用来启用 Spring 的缓存支持:
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@EnableCaching
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
Repository 查询接口配置,在 Repository 方法上使用 @Cacheable 注解,实现方法级别的缓存。这里的缓存区域为 queryCache,对应 ehcache.xml 中的查询缓存配置:
import com.demo.entity.Student;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
/**
* 缓存
*/
public interface CacheStudentRepository extends JpaRepository<Student, Long> {
@Query("SELECT u FROM Student u WHERE u.name = :name")
@Cacheable(cacheNames = "studentCache", key = "'findByName:' + #name")
Student findByName(@Param("name") String name);
@CacheEvict(cacheNames = "studentCache", allEntries = true)
void removeById(Long id);
@CacheEvict(cacheNames = "studentCache", allEntries = true)
@CachePut(cacheNames = "studentCache", key = "'allUsers'")
Student save(Student student);
}
使用缓存测试一下,我上面配置的缓存时间是 4 秒,这里先查询然后再暂停 3 秒试试:
import com.demo.DemoApplication;
import com.demo.entity.Student;
import com.demo.repository.CacheStudentRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import javax.annotation.PostConstruct;
@SpringBootTest(classes = DemoApplication.class)
public class CacheStudentRepositoryTest {
@Autowired
private CacheStudentRepository repository;
@PostConstruct
public void init() {
Student student = new Student();
student.setId(1L);
student.setName("张三");
student.setEmail("zhangsan@xxx.com");
student.setAge(18);
repository.save(student);
}
@Test
public void test1() throws InterruptedException {
Student student = repository.findByName("张三");
System.out.println("111 " + student);
Thread.sleep(3000L);
Student student1 = repository.findByName("张三");
System.out.println("222 " + student1);
}
}
打印结果:
Hibernate: select student0_.id as id1_1_, student0_.age as age2_1_, student0_.classroom_id as classroo5_1_, student0_.email as email3_1_, student0_.name as name4_1_, student0_.student_card_id as student_6_1_ from student student0_ where student0_.name=?
111 Student(id=1, name=张三, email=zhangsan@xxx.com, age=18, classroom=null, studentCard=null)
222 Student(id=1, name=张三, email=zhangsan@xxx.com, age=18, classroom=null, studentCard=null)
超过缓存时间试试:
import com.demo.DemoApplication;
import com.demo.entity.Student;
import com.demo.repository.CacheStudentRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import javax.annotation.PostConstruct;
@SpringBootTest(classes = DemoApplication.class)
public class CacheStudentRepositoryTest {
@Autowired
private CacheStudentRepository repository;
@PostConstruct
public void init() {
Student student = new Student();
student.setId(1L);
student.setName("张三");
student.setEmail("zhangsan@xxx.com");
student.setAge(18);
repository.save(student);
}
@Test
public void test1() throws InterruptedException {
Student student = repository.findByName("张三");
System.out.println("111 " + student);
Thread.sleep(5000L);
Student student1 = repository.findByName("张三");
System.out.println("222 " + student1);
}
}
打印结果:
Hibernate: select student0_.id as id1_1_, student0_.age as age2_1_, student0_.classroom_id as classroo5_1_, student0_.email as email3_1_, student0_.name as name4_1_, student0_.student_card_id as student_6_1_ from student student0_ where student0_.name=?
111 Student(id=1, name=张三, email=zhangsan@xxx.com, age=18, classroom=null, studentCard=null)
Hibernate: select student0_.id as id1_1_, student0_.age as age2_1_, student0_.classroom_id as classroo5_1_, student0_.email as email3_1_, student0_.name as name4_1_, student0_.student_card_id as student_6_1_ from student student0_ where student0_.name=?
222 Student(id=1, name=张三, email=zhangsan@xxx.com, age=18, classroom=null, studentCard=null)
能够看到两次查询数据库,这样就成功了!
五、Github 项目地址
最后再附上 Spring Data JPA 入门的项目地址,项目内是已经配置好了配置,可以直接通过单元测试直接进行测试,无需数据库。
原文地址:https://blog.csdn.net/weixin_43657300/article/details/144353038
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!