動作確認環境

  • 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

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

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();
    }

}
@PageableDefault

余談だがここで@PageableDefaultについて記載する。

リクエストパラメーターにpage, sizeを含めずにリクエストするとpage0size20Pageableが作られる。size20なのはspring.data.web.pageable.default-page-sizeのデフォルト値が20だからで、application.propertiesで設定を変えればそれに応じて変わる。

アプリケーションでページングの件数が全て統一されている場合はspring.data.web.pageable.default-page-sizeをその値に設定すればいいが、APIごとにページングがバラバラの場合は、@PageableDefaultをつけてそのAPIのデフォルトのページング件数を設定するとよい。

今はこのAPIを10ページにしたいので@PageableDefaultにしているし、もし30ページにしたいのであれば@PageableDefault(30)にする。

Entity, Repository

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();
    }
AOP

Controllerで受け取ったPageableに対してPageRequestConverter.offsetLimitAndNoSortを適用したが、全API統一的に処理するのであればAOPを使うのも一案となる。

package com.example.pageable.controller;

import com.example.pageable.util.PageRequestConverter;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Component;

@Component
@Aspect
public class PageableAOP {

    @Around("execution(* com.example.pageable.controller.*Controller.*(..,org.springframework.data.domain.Pageable,..))")
    public Object aop(ProceedingJoinPoint pjp) throws Throwable {
        Object[] args = pjp.getArgs();
        for (int i = 0; i < args.length; i++) {
            Object arg = args[i];
            if (arg instanceof Pageable) {
                args[i] = PageRequestConverter.offsetLimitAndNoSort((Pageable) arg);
            }
        }
        return pjp.proceed(args);
    }
}

~Controllerクラスのメソッドの引数にPageableがあれば、PageRequestConverter.offsetLimitAndNoSortを適用したPageableに引数を変える。

AOPの種類としては、引数を変えるために@Around, ProceedingJoinPoint, proceedを使っている。

参考: docs.spring.io: Proceeding with Arguments

例外処理

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を受け取ってなるべくフレームワークに処理を任せたほうがいいと考えている。