使用Spring Boot和Spring Data实现自动分页 - reflectoring


作为Web应用程序的用户,我们希望页面能够快速加载并仅显示与我们相关的信息。对于显示项目列表的页面,这意味着仅显示项目的一部分,而不是一次显示所有项目。
一旦第一页快速加载,UI就可以提供过滤,排序和分页等选项,帮助用户快速找到他或她正在寻找的项目。
在本教程中,我们将检查Spring Data的分页支持,并创建如何使用和配置它的示例,以及有关它如何工作的一些信息。
github上的工作示例代码。

Paging vs. Pagination
这两个通常是同义词。然而,它们并不完全相同。在咨询了各种网络词典之后,我拼凑了以下定义,我将在本文中使用:

  • Paging分页是从数据库中加载一页接一页的操作,以便保留资源。这就是本文的大部分内容。
  • Pagination分页是一个UI元素,它提供一系列页码,让用户选择下一个要加载的页面。

初始化示例项目
我们在本教程中使用Spring Boot来引导项目。您可以使用Spring Initializr并选择以下依赖项来创建类似的项目:

  • Web
  • JPA
  • H2
  • Lombok

我还用JUnit 5替换了JUnit 4,因此生成的依赖项看起来像这样(Gradle表示法):

dependencies {
  implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
  implementation 'org.springframework.boot:spring-boot-starter-web'
  compileOnly 'org.projectlombok:lombok'
  annotationProcessor 'org.projectlombok:lombok'
  runtimeOnly 'com.h2database:h2'
  testImplementation('org.junit.jupiter:junit-jupiter:5.4.0')
  testImplementation('org.springframework.boot:spring-boot-starter-test'){
    exclude group: 'junit', module: 'junit'
  }
}

Spring Data的 Pageable
无论我们是想进行传统的分页,无限滚动还是简单的“前一个”和“下一个”链接,后端的实现都是一样的。
如果客户端只想显示项列表的“切片”,则需要提供一些描述该切片的输入参数。在Spring Data中,这些参数捆绑在Pageable接口中。它提供了以下方法,其中包括(评论是我的):

public interface Pageable {
    
  // number of the current page  
  int getPageNumber();
  
  // size of the pages
  int getPageSize();
  
  // sorting parameters
  Sort getSort();
    
  // ... more methods
}

每当我们只想加载一个完整的项目列表时,我们就可以使用一个Pageable实例作为输入参数,因为它提供了要加载的页面数量以及页面的大小。通过Sort该类,它还允许定义要排序的字段以及它们应该排序的方向(升序或降序)。
创建Pageable实例的最常用方法是使用PageRequest实现:

Pageable pageable = PageRequest.of(0, 5, Sort.by(
    Order.asc("name"),
    Order.desc("id")));

这将创建第一页的请求,其中5个项目首先按名称(升序)排序,第二个按ID(降序)排序。请注意,页面索引默认情况下从零开始!

困惑与java.awt.print.Pageable?
使用时Pageable,您会注意到IDE有时会建议导入java.awt.print.Pageable而不是Spring的Pageable 类。由于我们很可能不需要java.awt包中的任何类,我们可以告诉我们的IDE完全忽略它。

在IntelliJ中,转到设置中的“常规 - >编辑器 - >自动导入”,然后添加 java.awt.*到标记为“从导入和完成中排除”的列表中。

在Eclipse中,转到首选项中的“Java - >外观 - >类型过滤器”并添加java.awt.*到包列表中。

Spring Data Page和Slice分片
在Pageable捆绑分页请求的输入参数时,Page和Slice接口提供返回给客户端的项目页面的元数据:

public interface Page<T> extends Slice<T>{
  
  // total number of pages
  int getTotalPages();
  
  // total number of items
  long getTotalElements();
  
  // ... more methods
  
}

public interface Slice<T> {
  
  // current page number
  int getNumber();
    
  // page size
  int getSize();
    
  // number of items on the current page
  int getNumberOfElements();
    
  // list of items on this page
  List<T> getContent();
  
  // ... more methods
  
}

通过Page接口提供的数据,客户端具有提供分页功能所需的所有信息。
如果我们不需要项目或页面的总数,我们可以使用Slice接口,例如,如果我们只想提供“上一页”和“下一页”按钮而不需要“第一页”和“最后一页“按钮。
该Page接口最常见的实现由PageImpl类提供:

Pageable pageable = ...;
List<MovieCharacter> listOfCharacters = ...;
long totalCharacters = 100;
Page<MovieCharacter> page = 
    new PageImpl<>(listOfCharacters, pageable, totalCharacters);

在Web控制器中分页
如果我们想要在Web控制器中返回Page(或Slice)项目,则需要接受Pageable定义分页参数的参数,将其传递给数据库,然后将Page对象返回给客户端。

激活Spring Data Web Support
基础持久层必须支持分页,以便为任何查询提供分页答案。这就是为什么在Pageable和Page类起源于Spring data模块。
Spring Boot应用程序中默认设置启用了自动配置,我们不必执行任何操作,因为SpringDataWebAutoConfiguration默认情况下它将加载,其中包括@EnableSpringDataWebSupport加载必要bean 的注释。
在没有Spring Boot 的普通Spring应用程序中,我们必须自己@EnableSpringDataWebSupport 在@Configuration类上使用:

@Configuration
@EnableSpringDataWebSupport
class PaginationConfiguration {
}

如果我们在没有激活Spring Data Web支持的情况下在Web控制器方法中使用Pageable或Sort参数,我们将获得以下异常:

java.lang.NoSuchMethodException: org.springframework.data.domain.Pageable.<init>()
java.lang.NoSuchMethodException: org.springframework.data.domain.Sort.<init>()

这些异常意味着Spring尝试创建一个Pageable或Sort实例并失败,因为它们没有默认构造函数。
这是由Spring data Web支持的,因为它添加PageableHandlerMethodArgumentResolver 和SortHandlerMethodArgumentResolver bean到应用环境中,这是负责寻找Web控制器方法的Pageable Sort类型的参数,并会使用查询参数中page,size和sort值填充导入进去 。

接受Pageable参数
启用S​​pring Data Web支持后,我们可以简单地使用a Pageable作为Web控制器方法的输入参数,并将Page对象返回给客户端:

@RestController
@RequiredArgsConstructor
class PagedController {

  private final MovieCharacterRepository characterRepository;

  @GetMapping(path = "/characters/page")
  Page<MovieCharacter> loadCharactersPage(Pageable pageable) {
    return characterRepository.findAllPage(pageable);
  }
  
}

集成测试表明,查询参数page,size以及sort现在正在被“注入”到我们的Web控制器方法的Pageable参数中:

@WebMvcTest(controllers = PagedController.class)
class PagedControllerTest {

  @MockBean
  private MovieCharacterRepository characterRepository;

  @Autowired
  private MockMvc mockMvc;

  @Test
  void evaluatesPageableParameter() throws Exception {

    mockMvc.perform(get("/characters/page")
        .param("page", "5")
        .param("size", "10")
        .param("sort", "id,desc")   // <-- no space after comma!
        .param("sort", "name,asc")) // <-- no space after comma!
        .andExpect(status().isOk());

    ArgumentCaptor<Pageable> pageableCaptor = 
        ArgumentCaptor.forClass(Pageable.class);
    verify(characterRepository).findAllPage(pageableCaptor.capture());
    PageRequest pageable = (PageRequest) pageableCaptor.getValue();

    assertThat(pageable).hasPageNumber(5);
    assertThat(pageable).hasPageSize(10);
    assertThat(pageable).hasSort("name", Sort.Direction.ASC);
    assertThat(pageable).hasSort("id", Sort.Direction.DESC);
  }
}

该测试捕获传递到存储库方法的Pageable参数,并验证它是否具有查询参数定义的属性。
请注意,我使用自定义AssertJ断言 在Pageable实例上创建可读断言。
另请注意,为了按多个字段排序,我们必须sort多次提供查询参数。每个可以只包含一个字段名称,比如是升序,或者一个带有排序的字段名称,用逗号分隔,不带空格。如果字段名称和顺序之间有空格,则顺序不会执行排列。

接受Sort参数
同样,我们可以在Web控制器方法中使用独立Sort参数:

@RestController
@RequiredArgsConstructor
class PagedController {

  private final MovieCharacterRepository characterRepository;

  @GetMapping(path = "/characters/sorted")
  List<MovieCharacter> loadCharactersSorted(Sort sort) {
    return characterRepository.findAllSorted(sort);
  }
}

当然,Sort对象只使用sort查询参数的值进行导入,因为此测试显示:

@WebMvcTest(controllers = PagedController.class)
class PagedControllerTest {

  @MockBean
  private MovieCharacterRepository characterRepository;

  @Autowired
  private MockMvc mockMvc;

  @Test
  void evaluatesSortParameter() throws Exception {

    mockMvc.perform(get("/characters/sorted")
        .param("sort", "id,desc")   // <-- no space after comma!!!
        .param("sort", "name,asc")) // <-- no space after comma!!!
        .andExpect(status().isOk());

    ArgumentCaptor<Sort> sortCaptor = ArgumentCaptor.forClass(Sort.class);
    verify(characterRepository).findAllSorted(sortCaptor.capture());
    Sort sort = sortCaptor.getValue();

    assertThat(sort).hasSort("name", Sort.Direction.ASC);
    assertThat(sort).hasSort("id", Sort.Direction.DESC);
  }
}

自定义全局分页默认值
当调用一个带有Pageable参数的控制器方法时,如果我们不提供page,size或者sort查询参数,它会使用默认值来填充。
Spring Boot使用 @ConfigurationProperties功能将以下属性绑定到类型SpringDataWebProperties的一个bean上 :

spring.data.web.pageable.size-parameter=size
spring.data.web.pageable.page-parameter=page
spring.data.web.pageable.default-page-size=20
spring.data.web.pageable.one-indexed-parameters=false
spring.data.web.pageable.max-page-size=2000
spring.data.web.pageable.prefix=
spring.data.web.pageable.qualifier-delimiter=_

上面的值是默认值。其中一些属性不是不言自明的,所以这是他们的工作:

  • 使用size-parameter我们可以改变size查询参数的名称
  • 使用page-parameter我们可以改变page查询参数的名称
  • 使用default-page-size,我们可以定义size参数的默认值,如果没有指定的话
  • 使用one-indexed-parameters,如果page参数是从0或1开始,我们可以选择。
  • 使用max-page-size,我们可以选择size查询参数允许的最大值(值大于这将减少)
  • 使用prefix,我们可以为page和size查询参数定义一个前缀名称(不适用于sort参数!)

qualifier-delimiter属性是一个非常特殊的情况。我们可以在Pageable方法参数是使用@Qualifier注释来为分页查询参数提供本地前缀:

@RestController
class PagedController {

  @GetMapping(path = "/characters/qualifier")
  Page<MovieCharacter> loadCharactersPageWithQualifier(
      @Qualifier("my") Pageable pageable) {
    ...
  }

}

这与prefix上面的属性有类似的效果,但它也适用于 sort参数。该qualifier-delimiter用于分隔从参数名称的前缀。在上面的例子中,仅查询参数my_page,my_size并my_sort 有效。

spring.data.web.* 属性无效?
如果对上述配置属性的更改无效,则SpringDataWebProperties bean可能未加载到应用程序上下文中。
其中一个原因可能是您已经习惯@EnableSpringDataWebSupport 了激活分页支持。这将覆盖SpringDataWebAutoConfiguration,在其中SpringDataWebProperties创建bean。 只在一个普通的 Spring应用程序中使用@EnableSpringDataWebSupport。

自定义本地分页默认值
有时我们可能只想为单个控制器方法定义默认的分页参数。对于这种情况,我们可以使用@PagableDefault和@SortDefault注释:

@RestController
class PagedController {

  @GetMapping(path = "/characters/page")
  Page<MovieCharacter> loadCharactersPage(
      @PageableDefault(page = 0, size = 20)
      @SortDefault.SortDefaults({
          @SortDefault(sort = "name", direction = Sort.Direction.DESC),
          @SortDefault(sort = "id", direction = Sort.Direction.ASC)
      }) Pageable pageable) {
    ...
  }
  
}

如果未给出查询参数,Pageable则现在将使用注释中定义的默认值填充对象。
请注意,@PageableDefault注释也有一个sort属性,但如果我们要定义多个字段以按不同方向排序,我们必须使用@SortDefault。

在Spring Data Repository中进行分页
由于本文中描述的分页功能来自Spring Data,因此Spring Data完全支持分页并不奇怪。但是,如何支持很快得到解释,我们只需要向存储库接口添加正确的参数和返回值。

传递分页参数
我们可以简单地将一个Pageable或Sort实例传递给任何Spring Data存储库方法:

interface MovieCharacterRepository 
        extends CrudRepository<MovieCharacter, Long> {

  List<MovieCharacter> findByMovie(String movieName, Pageable pageable);
  
  @Query("select c from MovieCharacter c where c.movie = :movie")
  List<MovieCharacter> findByMovieCustom(
      @Param("movie") String movieName, Pageable pageable);
  
  @Query("select c from MovieCharacter c where c.movie = :movie")
  List<MovieCharacter> findByMovieSorted(
      @Param("movie") String movieName, Sort sort);

}

即使Spring Data提供了一个PagingAndSortingRepository,我们也不必使用它来获得分页支持。它只提供了两种方便的findAll方法,一种是a Sort,一种是Pageable 参数。

返回页面元数据
如果我们想要将页面信息返回给客户端而不是简单列表,我们只需让我们的存储库方法只返回一个Slice或一个Page:

interface MovieCharacterRepository 
        extends CrudRepository<MovieCharacter, Long> {

  Page<MovieCharacter> findByMovie(String movieName, Pageable pageable);

  @Query("select c from MovieCharacter c where c.movie = :movie")
  Slice<MovieCharacter> findByMovieCustom(
      @Param("movie") String movieName, Pageable pageable);

}

每个返回一个Slice或Page必须只有一个Pageable参数的方法,否则Spring Data会在启动时抱怨异常。

结论
Spring Data Web支持使普通的Spring应用程序以及Spring Boot应用程序中的分页变得容易。这是激活它然后在控制器和存储库方法中使用正确的输入和输出参数的问题。
使用Spring Boot的配置属性,我们可以对默认值和参数名称进行细粒度控制。
可以在github上找到本文中使用的示例代码。