Spring Boot / Spring MVC でリクエストの間に処理を挟む

Java
JavaSpringバックエンド

Spring MVC で リクエストの前後に処理を挟む方法です。複数のやり方があります。

Spring MVC のリクエストの流れ

Spring Framework — Filter vs Dispatcher Servlet vs Interceptor vs Controller という記事で掲載されていた Spring MVC のリクエスト時イメージです。わかりやすいですね。下に書いていますが、この図にある Filters や Interceptors 部分に処理を挟むことができます。

画像ソース:https://medium.com/javarevisited/spring-framework-filter-vs-dispatcher-servlet-vs-interceptor-vs-controller-745aa34b08d8

処理を挟み込む方法

Spring MVC での呼び出し前後に任意の処理を実行するには、大きく以下があると理解しました。

  1. サーブレットフィルタを利用する(リクエスト毎に前後に処理を挟む)
    上の図でいう「Filters」です。
  2. HandlerInterceptor を利用する(Controllerの前後で処理を挟む)
    上の図でいう「HandlerInterceptor」です。
  3. ControllerAdvice / RestControllerAdvice を利用する(挟むと言うよりは Controller 横断の処理が定義できる)
  4. Aspect を利用する(指定スコープに対して横断で前後に処理を挟む。Spring MVC で言うと、Controller / Service などの各レイヤーの特定メソッドに対して共通処理を挟み込むといった事が可能)

厳密には他にも色々ありそうです(例えば、Filter より前、リクエストが Spring アプリケーションに到達する前に処理を挟める javax.servlet.ServletRequestListener など)が、上記が把握できていれば大抵ケースは対応できるとと理解しました

準備

プロジェクト作成

・Java 17
・Spring Boot 3.0.1

API の作成

文字列を返すだけの API を2つ用意。

@Slf4j
@RestController
public class MyRestController {

    @GetMapping("/api1/hello")
    public String api1Hello() {
        log.info("@ /api1/hello");
        return "foo";
    }

    @GetMapping("/api2/hello")
    public String api2Hello() {
        log.info("@ /api2/hello");
        return "bar";
    }

}

検証

サーブレットフィルタを利用する

DispatchServlet (Spring MVCでリクエストが来たときに処理される入り口)部分に対して任意の処理が挟み込み可能。

具体的には以下の実装方法があり、大抵の場合は 1回のみの実行が保証されている OncePerRequestFIlter を使うのがよさそうです。

  1. javax.servlet.Filter インタフェースを実装する
  2. org.springframework.web.filter.OncePerRequestFilter を継承して実装する(この継承元の GenericFilterBean を継承して実装するのでもよい)

OncePerRequestFilter – 全体に適用

特定の URL パスではなく、来るすべてのリクエストに対して処理を挟む例です。
以下は色々書いてあるようにみえますが、メインの処理 doFilter の前後にログを出しているだけです。

@Slf4j
@Component
public class MyOncePerRequestFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // リクエスト/レスポンスの中身をここで扱う方法
        // https://qiita.com/suke_masa/items/f64b0d18e3b97cbfbdf0
        ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
        ContentCachingResponseWrapper responseWrapper = new ContentCachingResponseWrapper(response);

        // リクエスト
        requestWrapper.getParameterNames();
        String requestBody = new String(requestWrapper.getContentAsByteArray(), requestWrapper.getCharacterEncoding());
        log.info("@ request User-Agent: " + request.getHeader("User-Agent"));
        log.info("@ request body: " + requestBody);

        // メインの処理
        filterChain.doFilter(requestWrapper, responseWrapper);

        // レスポンス
        String responseBody = new String(responseWrapper.getContentAsByteArray(), responseWrapper.getCharacterEncoding());
        log.info("@ response status: " + response.getStatus());
        log.info("@ response body: " + responseBody);
        responseWrapper.copyBodyToResponse();
    }
}

実行して /hello にアクセスしたときのログです。
ちゃんとリクエストの内容、Controller の処理、レスポンス結果の順でそれぞれ値が取れていることが確認できました。

OncePerRequestFilter – 特定パターンへ適用

上の例はすべてのリクエストに対して有効になります。
特定のパスにだけ適用したい場合は、フィルタの作り方は上のままでよいのですが、@Component アノテーションをつけるのではなく、FilterRegistrationBean を利用してフィルタを追加します。
以下は /api1/* の場合にだけ適用させるフィルタとして登録する例です。

@Configuration
public class FilterConfiguration {
    @Bean
    public FilterRegistrationBean<?> myOncePerRequestFilter() {
        FilterRegistrationBean<MyOncePerRequestFilter> registrationBean = new FilterRegistrationBean<>(new MyOncePerRequestFilter());
        registrationBean.setOrder(Integer.MIN_VALUE);
        registrationBean.addUrlPatterns("/api1/*");
        return registrationBean;
    }
}

1-5行目が /api1/hello
6行目が /api2/hello の場合のログです。api1 のパスにだけ適用されていますね。

なお、Spring 標準では多くのフィルタが用意されています。

HandlerInterceptor を利用する

HandlerInterceptor インタフェースを実装したクラスを作成することで処理を挟み込むことができます(HandlerInterceptorAdaptor は非推奨)。用意されているタイミングは以下。
 ・Controllerの処理前: preHandler
 ・Controllerの処理後(成功時):postHandle
 ・Controllerの処理後:afterCompletion
非同期処理を行う場合は WebRequestInterceptor を用いる。詳細は こちら の記事参照。

@Slf4j
public class MyHandlerInterceptor implements HandlerInterceptor{

    // Controller 処理実行前
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // true の場合処理続行
        log.info("@ HandlerInterceptor: preHandle");
        return true;
    }

    // Controller 処理成功後
    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        log.info("@ HandlerInterceptor: postHandle");
    }

    // Controller 処理終了後(成功 / 失敗関係なく)
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        log.info("@ HandlerInterceptor: afterCompletion");
    }
}

このクラスを適用させるには、WebMvcConfigurer を実装したクラスを用意し、addInterceptors をオーバライドし、上の Interceptor を追加します。今回も /api1/* にのみ適用。

@Configuration
public class FilterConfiguration implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new MyHandlerInterceptor())
                .addPathPatterns("/api1/*");
    }
    ...元の処理
}

なお、公式の以下の通り、Spring Boot を利用している場合は @EnableWebMvc アノテーションは追加しないようにとのこと。

If you want to keep those Spring Boot MVC customizations and make more MVC customizations (interceptors, formatters, view controllers, and other features), you can add your own @Configuration class of type WebMvcConfigurer but without @EnableWebMvc.

https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#web.servlet.spring-mvc

結果です。期待通り以下の順番でログが出ていることが確認できます。
 1. サーブレットフィルタの前処理
 2. Interceptor の preHandle
 3. メイン処理
 4. Interceptor の postHandle
 5. Interceptor の afterCompletion
 6. サーブレットフィルタの後処理

ControllerAdvice / RestControllerAdvice を利用する

処理の前後ではなく、以下の様な処理を Controller 共通処理として実装したい場合に利用できる。
・リクエストパラメータを Java オブジェクトにバインドする処理のカスタマイズ
・例外処理

イメージはこのあたりで掴みました。

 【Spring Boot】Controller Advice
 【Spring Boot】Controllerの基礎(REST API)

例外処理に関しては、以前例外処理をまとめた以下あたりで具体的な実装例を記載しました。
ので、ここではスキップ。

Spring AOP(AspectJ)

AOP(Aspect Oriented Programming) と言われるものです。
ControllerAdvice のように、横断で共通処理を挟むことができます。
「どこを対象に処理を挟むか(Joint Point)」と「挟むタイミング(Before / After など)」が指定できます。

JointPoint を表現したものを PointCut と言うそうですが、色々な指定が可能です。
以下が詳しく説明が書かれていました。

サポートされているポイントカット指定子
Spring AOP ポイントカット指定子の書き方について

以下は、今回作成した Controller クラス内の public メソッド実行前後に処理を挟む例です。

pom.xml

以下を追加します

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-aop</artifactId>
		</dependency>

@Aspect

@Aspect で Aspect のクラスを定義します。
@EnbaleAspectJAutoProxy はAspect の機能を有効にするために必要とのこと。

@Slf4j
@Aspect
@Configuration
@EnableAspectJAutoProxy
public class MyAspect {
    @Before("execution(public * *..*Controller.*(..))")
    public void before(JoinPoint jp) {
        log.info("@ aspect before: " + jp.getArgs());
    }

    @After(value="execution(public * *..*Controller.*(..))")
    public void after(JoinPoint jp) {
        log.info("@ aspect after");
    }

    @AfterReturning(value="execution(public * *..*Controller.*(..))", returning="returnValue")
    public void afterReturning(JoinPoint jp, Object returnValue) {
        log.info("@ aspect afterReturning: " + returnValue);
    }
}

補足

・Before は JointPoint 実行前、After は JointPoint 実行後、AfterReturning は Join Pointが正常終了した後に実行されるよう指定します。

・PointCut ですが、「public * *..*Controller.*(..))」は左から以下の通りです。

記述意味
publicpublic メソッド
*戻り値の型の指定なし
*..*Controller.任意のパッケージの XXXController クラス
.*(..)メソッド名は任意、引数も任意

AfterReturning で引数に returnValue を指定していますが、これをするためにアノテーションの中で returning="returnValue" が必要になります。なお、returning は JointPoint の対象のメソッドの戻り値です。

実行結果です。Interceptor の後に Before → AfterReturning → After の順で実行されているのがわかります。

以下の書籍は出版されて年数が経っており最新ではない部分はありますが、それでもベースを理解する上では今でも有用でした(自分も何度も買うか悩んだ末 2023/1 に買いましたがやはり体系だったものごとが見れるのは助かります)。Spring は歴史があるぶん奥が深いので、迷っている方は読んで見ることをおすすめです。