動作確認環境

  • Java 11
  • Spring Boot 2.5.5

一定量以上のoffsetや意図しないSortによる問題

一定量以上のoffset

Spring Bootによるpage, sizeへの制限

Spring BootのPageableをControllerの引数にセットすればpage, size等のクエリパラメータを解釈してPageableにセットしてくれる。

OutOfMemoryを防ぐために、Spring Bootのデフォルト設定でsizeに2,000件以上設定しても2,000件になるようになっている(docs.spring.ioか日本語訳spring.pleiades.iospring.data.web.pageable.max-page-sizeを参照)。つまりPageableの1ページの件数はセーフガードが入ってる。

しかしpageには上限の設定がない。確かにJavaのOutOfMemoryの観点ではpageの上限設定は不要だ。

offsetが大きい場合のデメリット

pageに上限設定がないということは、getOffset()の値がものすごく大きくなりうるということ。大きすぎるoffsetをデータベースに渡すと問題が発生しうる。

例えば、Elasticsearchだとページ番号を指定したクエリを実行したければsearch_afterじゃなくfrom, sizeを用いる必要があるが、Elasticsearchのデフォルト設定だとfrom + sizeが10, 000件までしか検索できずにエラーになる。

MySQLでもoffsetが大きくなりすぎると走査する行がその分増えるため、性能問題をおこしかねない。

意図しないSort

Pageableにはpage, size以外にもsortというパラメータでソート順を指定できる。

しかしソート機能は本当に必要だろうか?

ユーザーがソートキーを決められるUIは、スマホよりもPC、toCよりもtoBが多い気がする。ユーザーがソートキーを決められるにしても、そのような画面はかなり限られていて、サービスの最重要画面だけかつソートキーもサービス提供側が用意した数パターンしかないのではないか。

ユーザーが自由にソートキーを指定できる必要がないのにsortパラメータを有効にしていると、Spring Data JPAにPageableを渡したときに、指定されたソートキーでソートしてしまい、性能問題を引き起こしかねない。

一定量以上のoffsetをエラーにしたり、Sortを無効にする

一定量以上のoffsetになる場合はBad Requestを返し、sortを指定されても無視するようにする。

実装

内容はControllerで受け取ったPageableをSpring Data JPAに引き渡し、MySQLのpersonsテーブルからoffset, limitを効かせて取得したものをレスポンスするというもの。

実行イメージ

$ curl -w'\n' 'localhost:8080/pageable?sort=age,desc' # sort無効
[{"id":1,"age":10},{"id":2,"age":13}]
$ curl -w'\n' 'localhost:8080/pageable?page=2&size=1'
[{"id":2,"age":13}]
$ curl -w'%{http_code}\n' 'localhost:8080/pageable?size=10&page=1001' # offsetエラー
400

Controller, Entity, Repository

まずは特に対策を施さず実装する。

Controller

package com.example.pageable.controller;

import com.example.pageable.entity.Person;
import com.example.pageable.repository.PersonRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RequiredArgsConstructor
@RestController
public class SampleController {

    private final PersonRepository personRepository;

    @GetMapping("/pageable")
    List<Person> pageable(@PageableDefault Pageable pageable) {
        Page<Person> resultPage = personRepository.findByAgeLessThan(20, pageable);
        return resultPage.getContent();
    }

}

Entity

package com.example.pageable.entity;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@NoArgsConstructor
@AllArgsConstructor
@Data
@Entity(name = "persons")
public class Person {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(columnDefinition = "int unsigned")
    private Long id;

    @Column(nullable = false)
    private Integer age;
}

Repository

package com.example.pageable.repository;

import com.example.pageable.entity.Person;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.PagingAndSortingRepository;

public interface PersonRepository extends PagingAndSortingRepository<Person, Long> {
    Page<Person> findByAgeLessThan(int age, Pageable pageable);
}

offsetをチェックし、sortを無効にするクラス

このままでは、一定量以上のoffsetになったり、sortを指定されるので、Pageableに対してチェックを行うユーティリティクラスを作成する。

package com.example.pageable.util;

import com.example.pageable.exception.BadRequestException;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;

public class PageRequestConverter {

    public static PageRequest offsetLimitAndNoSort(Pageable pageable) {
        var p = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize()); // sortは無指定にする
        long offset = p.getOffset();
        int size = p.getPageSize();
        if (offset + size > 10000) {
            throw new BadRequestException("over offset limit. offset=" + offset + ", size=" + size);
        }
        return p;
    }
}

これをControllerで受け取ったPageableに対して適用する。

    @GetMapping("/pageable")
    List<Person> pageable(@PageableDefault Pageable pageable) {
        Page<Person> resultPage = personRepository.findByAgeLessThan(20, PageRequestConverter.offsetLimitAndNoSort(pageable));
        return resultPage.getContent();
    }

例外処理

PageRequestConverterで例外を出すことにしたので、例外処理を追加する。

例外クラス

public class BadRequestException extends RuntimeException {
    public BadRequestException(String message) {
        super(message);
    }
}

ExceptionHandler

package com.example.pageable.controller;

import com.example.pageable.exception.BadRequestException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Slf4j
@RestControllerAdvice(annotations = RestController.class)
public class ExceptionHandlerRestController {

    @ExceptionHandler(BadRequestException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    void badRequestException(Exception e) {
        log.info(e.getMessage(), e);
    }
}

Spring Bootを動かす上で最低限の設定

起動クラス

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

application.properties

# MYSQL
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost/test
spring.datasource.username=test
spring.datasource.password=test_password
spring.jpa.database=MYSQL
spring.jpa.hibernate.ddl-auto=update
# paging
spring.data.web.pageable.one-indexed-parameters=true

一定量以上のoffsetをエラーにしたり、Sortを無効にする別解その1

PageRequestConverterでおこなっているoffsetのチェックは、BeanValidationの自作アノテーションにしてもよいと思う(実際の実務ではこの方法でoffsetのチェックをしている)。

ただしその場合はsortを無効にするということができないので、sortが指定されていたら例外を発生させるということになってしまう。

例外ではなくsortを無視するにとどめられるPageRequestConverterの方が僅差で優れているのではないかと考えている。

Pageableへの自作BeanValidationはSpring DataのPageableに対するバリデーションって必要だよな〜も参考になる。

一定量以上のoffsetをエラーにしたり、Sortを無効にする別解その2

ソートを使わないならControllerの引数にPageableを置くのやめて@RequestParam(defaultValue = "1") int page(必要ならint sizeも)にして、自分でPageRequestPageRequest.of(page - 1, size)で生成した方がいいかもしれないと考えた。

確かに問題なく動くが、pageに0以下の数字を指定されたときの制御やspring.data.web.pageable.max-page-sizeの制御を自分でやらなくてはいけなくなってしまった。

できればControllerでPageableを受け取ってなるべくフレームワークに処理を任せたほうがいいと考えている。