フレクトのクラウドblog re:newal

http://blog.flect.co.jp/cloud/からさらに引っ越しています

Vue.jsなどSPA実装されたエラーページをSpring Webの「404 Not Found」エラーページとして表示するには

みなさんこんにちは。エンジニアの佐藤です。今回はSpring MVC(Webサーバー機能)のエラー処理に関するお話です。

先日筆者は「Spring Framworkの『404 Not Found』エラーページをVue.JSで表示したい」という相談を受けました。

Vue.JSは主にSPA(Single Page Application)を実現するためのフレームワークであり、今回のプロジェクトでもその目的で使われていました。SPAでは、WebページのユーザーインターフェイスはすべてJavascriptアーカイブで実装され、WebサーバーはSPAモジュールから発信されるAPIリクエストを処理する実装になります。今回もそのように実装されており、APIサーバーはSpring MVCで開発されており、SPAのJavascriptアーカイブもここに静的リソースとして配置されていました。

SPA実装では通常、HTMLページのリクエストは最初のJavascriptアーカイブを配置する目的でしか使われません。しかし例外があり、それはディープリンクです。

このプロジェクトの場合、メールに記されたリンクを利用者が踏むというシナリオがあり、その場合はWebサーバーがそのHTTPリクエストを受けます。APIしか実装せず、初回リクエストしか準備のないWebサーバーに、このディープリンクを処理させることができるのでしょうか?それは可能で、以下のような実装になります。

  1. ディープリンクに対応するハンドラを記述し、Javascriptアーカイブ配置ページ(/index.htmlなど)の中身を返す。
  2. ブラウザはJavascriptアーカイブ配置ページを読み取り、SPAを開始する。
  3. 開始したSPAは、ロケーション(アドレスバー文字列に対応)から自分がディープリンクを開いていることを認識し、対応するルーティング・表示処理を行う。

1の部分の実装例は、例えば以下のようなものです。

@Controller
@RequestMapping("/")
public class DeepLinkController {

    @GetMapping("a_deep_link")
    public ResponseEntity<InputStreamResource> a_deep_link() {
        return returnResource("static/index.html", "text/html");
    } // end of verification

    private ResponseEntity<InputStreamResource> returnResource(String resourcePath, String contentType) {
        InputStreamResource isr = new InputStreamResource(getClass().getClassLoader().getResourceAsStream(resourcePath));
        HttpHeaders headers = new HttpHeaders();
        headers.add("Content-Type", contentType);
        return new ResponseEntity<>(isr, headers, HttpStatus.OK);
    } // end of returnIndex
}

しかし、今回相談されたのは、こういうディープリンクが、「サポート外」のパスだった場合です。否定、invertなのです。バリエーションは無限です。一体どうやれば実装できるのでしょうか?

結論を先に

筆者が試行錯誤の末にたどり着いた実装方法は、以下のようなものです。

  • server.error.whitelabel.enabled=false
  • server.error.path=/error2
  • /error2のリクエストハンドラを作成し、/errorへのリダイレクトを返す処理を書く。
  • /errorのリクエストハンドラで、Javascriptアーカイブ配置ページ(/index.htmlなど)の中身を返す。
  • Vue.JSのルーター(ブラウザのロケーション文字列に応じて異なるページ描画を設定する機能)で、/errorを設定する。

実装方法の解説

  • server.error.whitelabel.enabled=false

Spring MVCには「ホワイトラベルエラーページ」というものが用意されており、既定では、HTTPリクエスト処理で例外が発生した場合は、その例外の内容を表示する開発時用のページが表示されます。今回はエラーページはSPAを実装するVue.JSで作ることになっているので、この機能は無効にします。

  • server.error.path=/error2

ここが、今回の実装の核心部分です。Spring MVCでは、HTTPリクエストハンドルの過程で発生したエラーを、このリクエストパスに対応するコントローラに通知する仕様になっているようです。ようなのです、と書いたのは、筆者は結局Spring MVCのどこでこの処理が書かれているのか、見つけることができなかったからです。ここではそういうものと考えたいと思います。

  • /error2のリクエストハンドラを作成し、/errorへのリダイレクトを返す処理を書く。

/error2のコントローラには、/errorへのリダイレクト処理を書きます。以下のような感じです。

@RequestMapping("error2")
public ResponseEntity<String> error(HttpServletRequest req, HttpServletResponse res) {
    final int status = res.getStatus();
    final String contextPath = req.getContextPath();
    headers.add("Location", StringUtils.join("/error?code=", status));
    return new ResponseEntity<>(headers, HttpStatus.FOUND);
}

/error2というのはもちろん、例えばの話で、その後のリダイレクト処理で利用する/errorと重ならなければ何でも構いません。Spring MVCのエラー処理の仕掛けを利用しつつ、リダイレクトによりディープリンク処理に持ち込むところがポイントです。

ここまで来たら、あとは冒頭に紹介したディープリンクと同じです。

  • /errorのリクエストハンドラで、Javascriptアーカイブ配置ページ(/index.htmlなど)の中身を返す。
  • Vue.JSのルーター(ブラウザのロケーション文字列に応じて異なるページ描画を設定する機能)で、/errorを設定する。

失敗した作戦と理由

(以下は失敗談や筆者の所感なので、読み飛ばしていただいても構いません。)

このブログを書こうと思ったのは、表題のような実装について解説したブログを、どこにも発見できなかったからです。この実装にたどり着くまでには様々な試行錯誤がありました。以下は失敗談ですが、何かの参考になるかもしれないと思い、共有させていただきます。

フィルタ

Spring Securityでは、ログイン処理にフィルタを設定することができます。

Spring Security Architecture https://spring.io/guides/topicals/spring-security-architecture/#web-security

最初はこの機能を使い、ワイルドカード指定したURLでリダイレクトを設定すればいいだろうと思っていたのですが、挫折しました。

ワイルドカードだけでは、サポートするURI以外を全部という指定はできないことに気がついたからです。。

コントロールアドバイス(例外ハンドラ)

Spring MVCコントローラの種類によらず、リクエストハンドル処理で起こった特定の例外を集中的にハンドルする仕掛けがあります。この仕掛けでは@ControllerAdviceと@ExceptionHandlerアノテーションを設定し、例外ハンドラを設定します。

Global Exception Handling https://spring.io/blog/2013/11/01/exception-handling-in-spring-mvc#global-exception-handling

「該当URLが無かったときも、これで集中的に拾えるのでは?」と思いましたが、そうは行きませんでした。

まず、spring.mvc.throw-exception-if-no-handler-foundをtrueに変更しなければなりません。

https://docs.spring.io/spring-boot/docs/current/reference/html/application-properties.html#application-properties.web.spring.mvc.throw-exception-if-no-handler-found

それはかまわないのですが、このフラグをtrueに設定すると、NoHandlerFoundExceptionに様々な例外が通知され、「サポートサれていないURIのすべて」とは異なることがわかりました。

また、静的リソースハンドラのソースコードを見てみると、例外を投げずに直接 404 NotFoundをHttpServletResponseに書き込んでいる実装もあり、サポートサれていないURIの中には例外スローを経由しない場合もあることに気付かされました。

インターセプタ

Spring MVCにはインターセプタと呼ばれる、フィルタと似た仕掛けがあります。

1.11.5. Interceptors https://docs.spring.io/spring-framework/docs/current/reference/html/web.html#mvc-config-interceptors

様々なリクエストハンドルステージで仕掛けられ、postHandleやafterCompletionなどもありますので、いろいろ処理して最終的に404 NotFoundとなった処理結果について、リダイレクトに変更すればよいのではないか?というアイディアもありました。

しかし、postHandleやafterCompletionでは、リクエスト処理をここで完結させるという機能がありません。ハンドルするのは良いのですが、既に処理フローの決まっているレスポンスを変更して、その後の処理がおかしくなるのは困ると思いました。

多機能だが難解なSpring Framework

Spring Frameworkを使ったWebサーバーの実装は、定番の一つとして普及しているように思えます。アノテーションを付けたクラスを配置してSpringApplicationを開始すると、驚くほど高機能なアプリケーションを少ない記述量で実現できます。6年前に初めて手がけてから今回まで何度か使っていますが、うまい仕掛けだと思います。

しかし一方で数年来改善されていない問題もあると思います。文献の不足です。

不正確かもしれませんが、ReactやFlaskは、結構Google検索だけで何とかなってしまうことが多いのですが、Spring Frameworkはそうは行きません。今回もそうでしたが、結局ライブラリの裏側、ソースコードの読解まで踏み込む必要が生じることが多いのです。Spring Framworkには長い歴史があり、過去何度か大幅な改定をしているため、古い時代の巷情報が検索されてくると紛らわしいという問題もあります。

ソースコードを含めたドキュメントの探し方をノウハウとして持っていないとなかなか使いこなせないフレームワークだと思います。

最後までお読みいただきありがとうございました。