動作確認環境
- 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.ioのspring.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を含めずにリクエストするとpageが0でsizeが20のPageableが作られる。sizeが20なのは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.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
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も)にして、自分でPageRequestをPageRequest.of(page - 1, size)で生成した方がいいかもしれないと考えた。
確かに問題なく動くが、pageに0以下の数字を指定されたときの制御やspring.data.web.pageable.max-page-sizeの制御を自分でやらなくてはいけなくなってしまった。
できればControllerでPageableを受け取ってなるべくフレームワークに処理を任せたほうがいいと考えている。