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