Spring Boot中分页查询方法一次获取所有结果

在 Spring Boot 应用程序中,我们经常需要一次向客户端呈现 20 或 50 行的表格数据。分页是从大型数据集中返回一小部分数据的常见做法。然而,有些场景我们需要一次性获得完整的结果。

在本教程中,我们将首先回顾如何使用 Spring Boot 检索分页数据。接下来,我们将探讨如何使用分页一次从一个数据库表中检索所有结果。最后,我们将深入研究一个更复杂的场景,通过关系检索数据。

Repository
Repository是一个Spring Data接口,提供数据访问抽象。根据我们选择的存储库子接口,抽象提供一组预定义的数据库操作。

我们不需要为标准数据库操作(例如选择、保存和删除)编写代码。我们需要的只是为我们的实体创建一个接口并将其扩展到所选的存储库子接口。

在运行时,Spring Data 创建一个代理实现来处理我们存储库的方法调用。当我们调用Repository接口上的方法时,Spring Data 会根据该方法和参数动态生成查询。

Spring Data 中定义了三个常见的Repository 子接口:

  • CrudRepository – Spring Data 提供的最基本的Repository接口。它提供 CRUD(创建、读取、更新和删除)实体操作
  • PagingAndSortingRepository – 它扩展了CrudRepository接口,并添加了额外的方法来轻松支持分页访问和结果排序
  • JpaRepository – 它扩展了PagingAndSortingRepository接口并引入了 JPA 特定的操作,例如保存和刷新实体以及批量删除实体

获取分页数据
让我们从一个使用分页从数据库获取数据的简单场景开始。我们首先创建一个Student实体类:

@Entity
@Table(name = "student")
public class Student {
    @Id
    @Column(name =
"student_id")
    private String id;
    @Column(name =
"first_name")
    private String firstName;
    @Column(name =
"last_name")
    private String lastName;
}

随后,我们将创建一个StudentRepository用于从数据库中检索Student实体。 JpaRepository接口默认包含方法findAll (Pageable pageable) 。因此,我们不需要定义额外的方法,因为我们只想检索页面中的数据而不选择字段:

public interface StudentRepository extends JpaRepository<Student, String> {
}

我们可以通过调用 StudentRepository上的findAll(Pageable)来获取Student的第一页,每页 10 行。 第一个参数表示当前页面,它是零索引,而第二个参数表示每页获取的记录数:

Pageable pageable = PageRequest.of(0, 10);
Page<Student> studentPage = studentRepository.findAll(pageable);

通常,我们必须返回按特定字段排序的分页结果。在这种情况下,我们在创建Pageable实例时提供一个Sort实例。此示例显示我们将按Student 的id字段按升序对页面结果进行排序:

Sort sort = Sort.by(Sort.Direction.ASC, "id");
Pageable pageable = PageRequest.of(0, 10).withSort(sort);
Page<Student> studentPage = studentRepository.findAll(pageable);

获取所有数据
经常出现一个常见问题:如果我们想一次检索所有数据怎么办?我们是否需要调用findAll()来获取所有数据?答案是不。Pageable接口定义了一个静态方法unpaged(),该方法返回一个预定义的Pageable实例,该实例不包含分页信息。我们通过使用该Pageable实例调用findAll(Pageable)来获取所有数据:

Page<Student> studentPage = studentRepository.findAll(Pageable.unpaged());


如果我们需要对结果进行排序,从 Spring Boot 3.2 开始,我们可以提供一个Sort实例作为unpaged()方法的参数。例如,假设我们想按姓氏字段升序对结果进行排序:

Sort sort = Sort.by(Sort.Direction.ASC, "lastName");
Page<Student> studentPage = studentRepository.findAll(Pageable.unpaged(sort));


然而,在 3.2 以下的版本中实现相同的功能有点棘手,因为unpaged()不接受任何参数。相反,我们必须创建一个具有最大页面大小和Sort参数的PageRequest:

Pageable pageable = PageRequest.of(0, Integer.MAX_VALUE).withSort(sort);
Page<Student> studentPage = studentRepository.getStudents(pageable);


获取具有关系的数据
我们经常在对象关系映射(ORM)框架中定义实体之间的关系。利用 JPA 等 ORM 框架可以帮助开发人员快速建模实体和关系,并消除编写 SQL 查询的需要。

然而,如果我们不彻底了解数据检索的底层工作原理,就会出现潜在的问题。在尝试从具有关系的实体检索结果集合时,我们必须小心,因为这可能会导致性能影响,尤其是在获取所有数据时。

1. N+1问题
我们举个例子来说明这个问题。考虑带有额外的多对一映射的Student实体:

@Entity
@Table(name = "student")
public class Student {
    @Id
    @Column(name =
"student_id")
    private String id;
    @Column(name =
"first_name")
    private String firstName;
    @Column(name =
"last_name")
    private String lastName;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name =
"school_id", referencedColumnName = "school_id")
    private School school;
   
// getters and setters
}

现在每个Student都与School关联,我们将School实体定义为:

@Entity
@Table(name = "school")
public class School {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name =
"school_id")
    private Integer id;
    private String name;
   
// getters and setters
}

现在,我们希望从数据库中检索所有学生记录并调查 JPA 发出的 SQL 查询的实际数量。 Hypersistence Utilities是一个数据库实用程序库,它提供assertSelectCount()方法来识别执行的选择查询的数量。让我们将其 Maven 依赖项包含在pom.xml文件中:

<dependency>
    <groupId>io.hypersistence</groupId>
    <artifactId>hypersistence-utils-hibernate-62</artifactId>
    <version>3.7.0</version>
</dependency>

现在,我们创建一个测试用例来检索所有学生记录:

@Test
public void whenGetStudentsWithSchool_thenMultipleSelectQueriesAreExecuted() {
    Page<Student> studentPage = studentRepository.findAll(Pageable.unpaged());
    List<StudentWithSchoolNameDTO> list = studentPage.get()
      .map(student -> modelMapper.map(student, StudentWithSchoolNameDTO.class))
      .collect(Collectors.toList());
    assertSelectCount((int) studentPage.getContent().size() + 1);
}

在一个完整的应用程序中,我们不想将我们的内部实体暴露给客户端。在实践中,我们会将内部实体映射到外部DTO并将其返回给客户端。在这个例子中,我们采用ModelMapper将Student转换为StudentWithSchoolNameDTO ,其中包含Student的所有字段和School的 name 字段:

public class StudentWithSchoolNameDTO {
    private String id;
    private String firstName;
    private String lastName;
    private String schoolName;
    // constructor, getters and setters
}

我们来观察执行测试用例后的Hibernate日志:

Hibernate: select studentent0_.student_id as student_1_1_, studentent0_.first_name as first_na2_1_, studentent0_.last_name as last_nam3_1_, studentent0_.school_id as school_i4_1_ from student studentent0_
Hibernate: select schoolenti0_.school_id as school_i1_0_0_, schoolenti0_.name as name2_0_0_ from school schoolenti0_ where schoolenti0_.school_id=?
Hibernate: select schoolenti0_.school_id as school_i1_0_0_, schoolenti0_.name as name2_0_0_ from school schoolenti0_ where schoolenti0_.school_id=?
...

考虑我们已经从数据库中检索了 N 个学生 记录。 JPA 不是对Student表执行单个选择查询,而是对School表执行额外的 N 个查询来获取每个Student的关联记录。

当ModelMapper尝试读取Student实例中的school字段时,会在转换过程中出现此行为。对象关系映射性能中的这个问题称为 N+1 问题。

值得一提的是,JPA 并不总是在每次Student获取时对School表发出 N 次查询。实际计数取决于数据。 JPA 具有一级缓存机制,可确保它不会再次从数据库中获取缓存的School实例。

2.避免获取关系
将 DTO 返回给客户端时,并不总是需要包含实体类中的所有字段。大多数情况下,我们只需要其中的一个子集。为了避免从实体中的关联关系触发额外的查询,我们应该只提取必要的字段。

在我们的示例中,我们可以创建一个指定的 DTO 类,其中仅包含Student表中的字段。如果我们不访问school字段,JPA 将不会对School执行任何其他查询:

public class StudentDTO {
    private String id;
    private String firstName;
    private String lastName;
    // constructor, getters and setters
}

此方法假设我们正在查询的实体类上定义的关联获取类型设置为执行关联实体的延迟获取:

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "school_id", referencedColumnName = "school_id")
private School school;

需要注意的是,如果fetch属性设置为FetchType.EAGER ,JPA 将在获取Student记录时主动执行其他查询,尽管之后无法访问该字段。

3.自定义查询
每当 DTO 中School中的字段是必需的时,我们就可以定义一个自定义查询来指示 JPA 执行fetch join以在初始Student查询中急切地检索关联的School实体:

public interface StudentRepository extends JpaRepository<Student, String> {
    @Query(value = "SELECT stu FROM Student stu LEFT JOIN FETCH stu.school",
      countQuery =
"SELECT COUNT(stu) FROM Student stu")
    Page<Student> findAll(Pageable pageable);
}

执行相同的测试用例后,我们可以从 Hibernate 日志中观察到,现在只执行了一个连接Student和School表的查询:

Hibernate: select s1_0.student_id,s1_0.first_name,s1_0.last_name,s2_0.school_id,s2_0.name 
from student s1_0 left join school s2_0 on s2_0.school_id=s1_0.school_id

4.实体图
一个更简洁的解决方案是使用@EntityGraph 注释。这有助于通过在单个查询中获取实体而不是为每个关联执行额外的查询来优化检索性能。 JPA 使用此注释来指定应急切获取哪些关联实体。

让我们看一个临时实体图示例,该示例定义attributePaths来指示 JPA在查询Student记录时获取School关联:

public interface StudentRepository extends JpaRepository<Student, String> {
    @EntityGraph(attributePaths = "school")
    Page<Student> findAll(Pageable pageable);
}

还有一种定义实体图的替代方法,即在Student实体上放置@NamedEntityGraph注释:

@Entity
@Table(name = "student")
@NamedEntityGraph(name =
"Student.school", attributeNodes = @NamedAttributeNode("school"))
public class Student {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name =
"school_id", referencedColumnName = "school_id")
    private School school;
   
// Other fields, getters and setters
}

随后,我们将注释@EntityGraph添加到StudentRepository findAll()方法中,并引用我们在Student类中定义的命名实体图:

public interface StudentRepository extends JpaRepository<Student, String> {
    @EntityGraph(value = "Student.school")
    Page<Student> findAll(Pageable pageable);
}

在执行测试用例时,与自定义查询方法相比,我们将看到 JPA 执行相同的联接查询:

Hibernate: select s1_0.student_id,s1_0.first_name,s1_0.last_name,s2_0.school_id,s2_0.name 
from student s1_0 left join school s2_0 on s2_0.school_id=s1_0.school_id