在 Spring Boot 分頁查詢方法中一次取得所有結果
1. 概述
在 Spring Boot 應用程式中,我們經常需要一次向客戶端呈現 20 或 50 行的表格資料。分頁是從大型資料集中返回一小部分資料的常見做法。然而,有些場景我們需要一次獲得完整的結果。
在本教學中,我們將首先回顧如何使用 Spring Boot 檢索分頁資料。接下來,我們將探討如何使用分頁一次從一個資料庫表中檢索所有結果。最後,我們將深入研究一個更複雜的場景,透過關係檢索資料。
2. Repository
Repository
是一個 Spring Data 接口,提供資料存取抽象。根據我們選擇的Repository
子接口,抽象提供一組預先定義的資料庫操作。
我們不需要為標準資料庫操作(例如選擇、儲存和刪除)編寫程式碼。我們需要的只是為我們的實體建立一個介面並將其擴展到所選的Repository
子介面。
在運行時,Spring Data 創建一個代理實作來處理我們儲存庫的方法呼叫。當我們呼叫Repository
介面上的方法時,Spring Data 會根據該方法和參數動態產生查詢。
Spring Data 中定義了三個常見的Repository
子介面:
-
CrudRepository
– Spring Data 提供的最基本的Repository
介面。它提供 CRUD(建立、讀取、更新和刪除)實體操作 -
PagingAndSortingRepository
– 它擴展了CrudRepository
接口,並添加了額外的方法來輕鬆支援分頁存取和結果排序 -
JpaRepository
– 它擴展了PagingAndSortingRepository
介面並引入了 JPA 特定的操作,例如保存和刷新實體以及批量刪除實體
3. 取得分頁數據
讓我們從一個使用分頁從資料庫取得資料的簡單場景開始。我們先建立一個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 行StudentRepository.
第一個參數表示目前頁面,它是零索引,而第二個參數表示每頁取得的記錄數:
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);
4. 取得所有數據
經常出現一個常見問題:如果我們想一次檢索所有資料怎麼辦?我們是否需要呼叫findAll()
來取得所有資料?答案是不行。 Pageable
介面定義了一個靜態方法unpaged(),
該方法傳回一個預先定義的Pageable
實例,該實例不包含分頁資訊。我們透過使用該Pageable
實例呼叫findAll(Pageable)
來取得所有資料:
Page<Student> studentPage = studentRepository.findAll(Pageable.unpaged());
如果我們需要對結果進行排序,從 Spring Boot 3.2 開始,我們可以提供一個Sort
實例作為unpaged()
方法的參數。例如,假設我們想要按lastName
欄位升序對結果進行排序:
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);
5. 取得具有關係的數據
我們經常在物件關係映射(ORM)框架中定義實體之間的關係。利用 JPA 等 ORM 框架可以幫助開發人員快速建模實體和關係,並消除編寫 SQL 查詢的需要。
然而,如果我們不徹底了解資料檢索的底層工作原理,就會出現潛在的問題。在嘗試從具有關係的實體檢索結果集合時,我們必須小心,因為這可能會導致效能影響,尤其是在獲取所有資料時。
5.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
}
現在,我們希望從資料庫中檢索所有Student
記錄並調查 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>
現在,我們建立一個測試案例來檢索所有Student
記錄:
@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 個Student
記錄。 JPA 不是對Student
表執行單一選擇查詢,而是對School
表執行額外的 N 個查詢以取得每個Student
的關聯記錄。
當ModelMapper
嘗試讀取Student
實例中的school
欄位時,會在轉換過程中出現此行為。物件關係映射效能中的這個問題稱為 N+1 問題。
值得一提的是,JPA 並不總是在每次Student
取得時對School
表發出 N 次查詢。實際計數取決於數據。 JPA 具有一級快取機制,可確保它不會再次從資料庫中取得快取的School
實例。
5.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
記錄時主動執行其他查詢,儘管之後無法存取該欄位。
5.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
5.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
六,結論
在本文中,我們學習如何在 Spring Boot 中對查詢結果進行分頁和排序,包括檢索部分資料和完整資料。我們也學習了 Spring Boot 中的一些有效的資料檢索實踐,特別是在處理關係時。
與往常一樣,範例程式碼可在 GitHub 上取得。