読者です 読者をやめる 読者になる 読者になる

Spring でリクエストボディの JSON に対してバリデーションを行う

Java Java EE Spring

問題

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 クラスのインスタンスに持たせて返してやればいいのです。

サンプルコード

  1. beforeBodyRead()
  2. initBinder()

の順番に呼ばれます。

github.com

ちなみに、上記クラスの使用方法は、プロジェクトの README.md の examples にも書いてあるように以下のように使います。

ちなみに

ちなみに 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()@InitBinderPOJO クラスに対して共通の処理を行うなら便利そうだけど、今回はリクエストボディの JSON 文字列を取得しないといけなかったので見送った。

まとめ

  • HttpServletRequest#getInputStream() で2回読む事はできない(IOException: Stream closed と言われる)
  • リクエストボディを文字列で取得したければ @ControllerAdvice アノテーションを付け、かつ RequestBodyAdvice インターフェースを実装し、beforeBodyRead() メソッドでストリームからリクエストボディを読み込む
  • 読み込んだ後、後続のコントローラー等の処理もストリームを読めるようにするため、読み込んだバイト列なり文字列を InputStream にまた変換してやり、HttpInputMessage クラスのインスタンスに渡して返す
  • コントローラーに引数で渡すクラスのインスタンスに対して共通処理を行うには RequestBodyAdvice#afterBodyRead() または WebDataBinder#getTarget() が使える

関連リンク

Spring MVC(+Spring Boot)上でのリクエスト共通処理の実装方法を理解する - Qiita

@ControllerAdvice ではなく Spring AOPAspectJ)を使う場合のコードはこちら。 ただ、InputStream の問題は同様に存在します。

参考リンク

1と2のアーキテクチャ概要図がソースコードを追う時にすごく参考になった。

  1. 01.基本概念:全体的な処理フロー - soracane
    • 詳しすぎて何者感ある資料。
  2. 2.2. Spring MVCアーキテクチャ概要 — TERASOLUNA Server Framework for Java (5.x) Development Guideline 5.2.0.RELEASE documentation
  3. 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回呼べない。 これじゃ前処理に使えない…

java-json-hyper-schema-validator/ServletJSONRequest.java at 1f4e78fccf60eaa615c1f2dfcb0b365513bb5dee · tyru/java-json-hyper-schema-validator · GitHub

色々調べたけど、どうやら元々の HttpServletRequst#getInputStream() を2回呼べない問題は単純に2回読んだ場合はどうしても解決できないっぽいので、Spring みたく Chain of Responsibility パターンで解決するか、他の何らかの方法で対策をする必要があるのか…

そういや JAX-RS の実装も ContainerRequestContext#getEntityStream() で InputStream 取得して中身読んでたけど普通にコントローラーちゃんと呼べてたなぁ… 対策してあるのか分からんけど、もしくは未定義動作…?

java-json-hyper-schema-validator/JaxrsJSONRequest.java at 1f4e78fccf60eaa615c1f2dfcb0b365513bb5dee · tyru/java-json-hyper-schema-validator · GitHub

  • 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.

コントローラーや initBinder() の引数はこれでインスタンスを生成して渡しているっぽい。

上記メソッドにブレークポイントを置くと、コントローラーに inject する引数を生成するために再帰的に @InitBinder のメソッドを読んでいるのが分かる。

f:id:tyru:20161013121558p:plain f:id:tyru:20161013121601p:plain

まずコントローラーの引数を 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 のインスタンスを上記のクラスのコンストラクタに渡して、以降はそのラッパークラスのインスタンスを使い回すのがいいかな?(細かく見てないので間違ってるかも)

*1:InputStream#markSupported() が true を返す実装クラスのインスタンスと PushBackStream は除く