Spring でリクエストボディの JSON に対してバリデーションを行う
問題
Spring でリクエストボディの JSON に対して共通のバリデーション処理を行うに当たって、まずは問題がある。
@ControllerAdvice を使うと、コントローラーが実行される前にバリデーションなどの共通処理を行うことができるが、 @ControllerAdvice
で実行できるメソッドに渡ってくるのはリクエストボディのストリームである。
これは元の HttpServletRequest#getInputStream()
がそうだから。
それの何が問題なのかと言うと、一度ストリームを読んでしまうと、Stream は通常元に戻せない *1 ため、 @ControllerAdvice
のメソッドでバリデーションするために読み込んでしまうとコントローラーが実行されるタイミングで EOF (Stream closed) となってしまいます。
実はこの問題は Spring に限りません。
先程 HttpServletRequest#getInputStream()
由来の問題であると述べたように、Servlet の filter などで前後処理を行う際もこの問題は起き得ます。
解決策
では Spring はこれに対して対策をしていないのかというと、もちろんしています。
@ControllerAdvice
アノテーションが付いていて、かつ RequestBodyAdvice
インターフェースを実装したクラスは RequestBodyAdvice#beforeBodyRead()
で新たな InputStream を含む HttpInputMessage
クラスのインスタンスを返すことができます。
これにより、一度リクエストボディを文字列として読み込んでバリデーションなどの前処理を行ったあと、そのボディの文字列をまた InputStream に変換してやり、それを HttpInputMessage
クラスのインスタンスに持たせて返してやればいいのです。
サンプルコード
- beforeBodyRead()
- initBinder()
の順番に呼ばれます。
ちなみに、上記クラスの使用方法は、プロジェクトの README.md の examples にも書いてあるように以下のように使います。
- spring-boot-helloapp/SpringJSONValidationFilter.java at 91dcbd95b5cfe66cc340861869d75939454c243b · tyru/spring-boot-helloapp · GitHub
- spring-boot-helloapp/HelloApp.java at 91dcbd95b5cfe66cc340861869d75939454c243b · tyru/spring-boot-helloapp · GitHub
ちなみに
ちなみに RequestBodyAdvice
の似たメソッド afterBodyRead()
は何をするためのメソッドかと言うと、このメソッドの第1引数にはリクエストボディが変換された後の POJO クラスのインスタンスが渡される。
どういうことかというと、
@PostMapping @ResponseStatus(HttpStatus.CREATED) public ResponseEntity<HelloReqBean> addMsg(@RequestBody HelloReqBean hello) { service.addMsg(hello); }
コントローラーのクラスにこのようなエンドポイントのメソッドがあるとして、この @RequestBody
が付いた引数 HelloReqBean クラスのインスタンスが上記で言った POJO クラスのインスタンスにあたる。
ちなみに @PostMapping
は @RequestMapping(method=RequestMethod.POST)
のエイリアス。
@RequestMapping(method=RequestMethod.POST, value="/path/to/resource")
の代わりに @PostMapping("/path/to/resource")
みたいにも書ける。
この変数は Object 型として渡ってくるが、あくまでメソッドシグニチャのためであり、HelloReqBean 型にキャストできる。
@Override public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) { HelloReqBean bean = (HelloReqBean) body; // ... }
また @InitBinder
が付けられたメソッドで取得できる WebDataBinder
クラスのインスタンスのメソッド getTarget()
でもこの POJO クラスのインスタンスを取得することができるようだ。
@InitBinder private void initBinder(WebDataBinder binder) throws IOException { HelloReqBean bean = (HelloReqBean) binder.getTarget(); // ... }
afterBodyRead()
や @InitBinder
は POJO クラスに対して共通の処理を行うなら便利そうだけど、今回はリクエストボディの JSON 文字列を取得しないといけなかったので見送った。
まとめ
- HttpServletRequest#getInputStream() で2回読む事はできない(IOException: Stream closed と言われる)
- リクエストボディを文字列で取得したければ
@ControllerAdvice
アノテーションを付け、かつRequestBodyAdvice
インターフェースを実装し、beforeBodyRead()
メソッドでストリームからリクエストボディを読み込む - 読み込んだ後、後続のコントローラー等の処理もストリームを読めるようにするため、読み込んだバイト列なり文字列を
InputStream
にまた変換してやり、HttpInputMessage
クラスのインスタンスに渡して返す - コントローラーに引数で渡すクラスのインスタンスに対して共通処理を行うには
RequestBodyAdvice#afterBodyRead()
またはWebDataBinder#getTarget()
が使える
関連リンク
Spring MVC(+Spring Boot)上でのリクエスト共通処理の実装方法を理解する - Qiita
@ControllerAdvice
ではなく Spring AOP(AspectJ)を使う場合のコードはこちら。
ただ、InputStream の問題は同様に存在します。
参考リンク
1と2のアーキテクチャ概要図がソースコードを追う時にすごく参考になった。
- 01.基本概念:全体的な処理フロー - soracane
- 詳しすぎて何者感ある資料。
- 2.2. Spring MVCアーキテクチャ概要 — TERASOLUNA Server Framework for Java (5.x) Development Guideline 5.2.0.RELEASE documentation
- Spring ベースのフレームワークを TERASOLUNA を作った NTT データさんの資料。
- Spring Framework Reference Documentation
- 公式資料だけど見づらい…
コードリーディングメモ
Spring のソースコード追っかけてた時の自分用メモ。
- org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodArgumentResolver.readWithMessageConverters(HttpInputMessage, MethodParameter, Type)
でそれぞれの ControllerAdvice を呼んでいる。
- org.springframework.web.servlet.mvc.method.annotation.AbstractMessageConverterMethodArgumentResolver.EmptyBodyCheckingHttpInputMessage.EmptyBodyCheckingHttpInputMessage(HttpInputMessage)
PushBackStream 初めて知った。 そういや InputStream の mark も使ったことないな…
一応 JAX-RS、Spring MVC の他に素の HttpServletRequest 対応も考えてるので入れたい。 一応クラスは入れてあるものの、HttpServletRequst#getInputStream() を直接呼んでるので2回呼べない。 これじゃ前処理に使えない…
色々調べたけど、どうやら元々の HttpServletRequst#getInputStream()
を2回呼べない問題は単純に2回読んだ場合はどうしても解決できないっぽいので、Spring みたく Chain of Responsibility パターンで解決するか、他の何らかの方法で対策をする必要があるのか…
そういや JAX-RS の実装も ContainerRequestContext#getEntityStream() で InputStream 取得して中身読んでたけど普通にコントローラーちゃんと呼べてたなぁ… 対策してあるのか分からんけど、もしくは未定義動作…?
- org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(NativeWebRequest, ModelAndViewContainer, Object...)
- DefaultParameterNameDiscoverer
- Default implementation of the ParameterNameDiscoverer strategy interface, using the Java 8 standard reflection mechanism (if available)
- HandlerMethodArgumentResolverComposite
- Resolves method parameters by delegating to a list of registered HandlerMethodArgumentResolvers.
- DefaultParameterNameDiscoverer
コントローラーや initBinder() の引数はこれでインスタンスを生成して渡しているっぽい。
上記メソッドにブレークポイントを置くと、コントローラーに inject する引数を生成するために再帰的に @InitBinder のメソッドを読んでいるのが分かる。
まずコントローラーの引数を ServletInvocableHandlerMethod#getMethodArgumentValues(NativeWebRequest, ModelAndViewContainer, Object...) で解決しようとする。 次に InvocableHandlerMethod.java 161行目の
args[i] = this.argumentResolvers.resolveArgument(parameter, mavContainer, request, this.dataBinderFactory);
により @ControllerAdvice の @InitBinder が呼び出される。
これ Spring じゃないけど、HttpServletRequest をラップするとしたらこんな感じのコードがよさそう。 まぁこのクラスに HttpServletRequest のインスタンスを渡したらその時点で HttpServletRequest#getInputStream() が呼ばれて読み込まれるから、 元の HttpServletRequest は使えなくなるけど…
あくまで本記事のようにリクエストボディのみではなく HttpServletRequest クラスをラッパークラスに渡して、そのサブクラスとして扱いたい場合は、HttpServletRequest のインスタンスを上記のクラスのコンストラクタに渡して、以降はそのラッパークラスのインスタンスを使い回すのがいいかな?(細かく見てないので間違ってるかも)