UZABASE Tech Blog

〜迷ったら挑戦する道を選ぶ〜 株式会社ユーザベースの技術チームブログです。

Ringアプリケーションで例外をいい感じにハンドリングする方法(Ductでの解説も含む)

こんにちは!こんにちは!SPEEDA開発チームのあやぴーです。 社内のClojureを使ったAPIにおいて、「例外をうまくハンドリングしたいんだけど…」という話が出てきたので、今回はRingアプリケーションにおける例外のハンドリング方法について解説します。また、昨今Ductを使ってアプリケーションを作る機会が増えているので、それについては最後の方に解説をします。

また具体的なコードはGitHubにあるので、参考にしてみてください。

https://github.com/ayato-p/exception-handling

この記事で解説する具体的な内容については以下の通り

  • 例外のハンドリングにRingミドルウェアを使う
  • 例外によってレスポンスを変更する
  • Ductアプリケーションへの適用方法
  • まとめ

例外のハンドリングにRingミドルウェアを使う

Ringアプリケーションの特にRingハンドラー(以下、ハンドラー)部分で例外が出る場合について考えてみます。簡単のために以下のように、例外を投げる処理do-somethingを実行するハンドラーthrow-exception-handlerがあることにします。

(ns demo.core
  (:require [ring.util.response :as response]))

(defn- do-something []
  (throw (ex-info "err" {:exception/type :server-error})))

(defn throw-exception-handler [req]
  (let [res (do-something)]
    (response/response res)))

本来であればdo-somethingが適当な値を返して、それをハンドラーのレスポンスとして返すところですが、この例ではdo-somethingが例外を起こすため、適切なレスポンスを返すことができません(Ringアダプターの実装にもよりますが、多くの場合は500エラーが返ると思います)。このハンドラーの挙動は以下のテストで確認することができます。

(ns demo.core-test
  (:require [clojure.test :as t]
            [demo.core :as sut]
            [ring.mock.request :as mock]))

(t/deftest throw-exception-handler-test
  (t/testing "throw-exception-handlerが例外を返すこと"
    (let [req (mock/request :get "/err")]
      (t/is
       (thrown-with-msg? clojure.lang.ExceptionInfo #"err"
                         (sut/throw-exception-handler req)))

      (let [data (try
                   (sut/throw-exception-handler req)
                   (catch clojure.lang.ExceptionInfo e
                     (ex-data e)))]
        (t/is (= {:exception/type :server-error}
                 data))))))

このようにハンドラーの中で例外が発生する場合、各ハンドラーやその手前のレイヤーで適切にハンドリングして、例外をハンドラーの外側に伝播させないというのも場合によってはやると思います。しかし、予期しない例外や一般的な例外などに対してすべてのハンドラーで対応するのはあまりにも面倒です。そういった例外をハンドルするための、いい感じの機能が欲しいところです。

Javaの場合、JAX-RSのExceptionMapperあたりが今回欲しい機能に該当しそうです。Ringには残念ながら同じようなものはありません。そのため、少しだけ考えてみることにします。上記のテストコードがヒントになりそうです。上のコードのようにthrow-exception-handlerにリクエストマップを渡すようなコードをtry~catchで囲んで、例外を吐き出したときのみ例外のレスポンスマップを返すように書くことを考えれないでしょうか。

(try
  (throw-exception-handler req)
  (catch Exception e
    (-> (response/response "Internal Server Error!!")
        (response/status 500))))

このようにハンドラーを実行して、その結果次第でレスポンスに影響を与える方法を僕らは既に知っているはずです。Ringミドルウェア(以下、ミドルウェア)です。次のようなミドルウェアwrap-exception-handlerを考えてみます。

;; demo.core
(defn wrap-exception-handler [handler]
  (fn exception-handler [req]
    (try
      (handler req)
      (catch Exception e
        (-> (response/response "Internal Server Error!!")
            (response/status 500))))))

これはハンドラーを受け取って、新しいハンドラーを返します。新しいハンドラーは元のハンドラーに対して、自身が受け取ったリクエストを適用するだけで基本的には何もしませんが、例外が投げられたときにそれをキャッチして500のステータスコードを返すようにしています。

wrap-exception-handlerは以下のテストコードで機能していることを確認することができます。

;; demo.core-test
(t/deftest wrap-exception-handler-test
  (t/testing
      "例外を投げないハンドラーが実行されたら何もせずに元の結果を返すこと"
    (let [req (mock/request :get "/err")
          handler (constantly {:status 200
                               :body "Hello, world"})
          app (sut/wrap-exception-handler handler)]
      (t/is (= {:status 200
                :body "Hello, world"}
               (app req)))))
  (t/testing
      "例外を投げるハンドラーが実行されたらステータスコード500のレスポンスを返すこと"
    (let [req (mock/request :get "/err")
          handler (fn [_] (throw (Exception. "err")))
          app (sut/wrap-exception-handler handler)]
      (t/is (= {:status 500
                :body "Internal Server Error!!"
                :headers {}}
               (app req))))))

例外によってレスポンスを変更する

例外をハンドルすることができたので、今度は例外の種類によって返すレスポンスを変化させてみます。既に想像がついていると思いますが、try~catchで掴む例外を複数用意すれば簡単にレスポンスを変えることができます。やってみましょう。

以下ではIllegalArgumentExceptionのときに500clojure.lang.ExceptionInfoのときはex-data:exception/typeの値によってステータスコードを変えています。また、対応できていない例外に対しては500を返しつつも、標準出力に"Unhandled Exception:"と流すようにしています。

(t/deftest wrap-exception-handler-test
  ;; "例外を投げないハンドラーが実行されたら何もせずに元の結果を返すこと"

  (t/testing
      "例外を投げるハンドラーが実行されたら適切なエラーコードを返すこと"

    (t/testing "IllegalArgumentExceptionのとき500を返す"
      (let [req (mock/request :get "/err")
            handler (fn [_] (throw (IllegalArgumentException. "err")))
            app (sut/wrap-exception-handler handler)]
        (t/is (= {:status 500
                  :body "Internal Server Error!!"
                  :headers {}}
                 (app req)))))

    (t/testing "対応できていない例外は500を返す"
      (let [req (mock/request :get "/err")
            handler (fn [_] (throw (NullPointerException. "err")))
            app (sut/wrap-exception-handler handler)]
        (t/is (= {:status 500
                  :body "Internal Server Error!!"
                  :headers {}}
                 (app req)))
        (t/is (str/starts-with?
               (with-out-str (app req))
               "Unhandled Exception: java.lang.NullPointerException"))))

    (t/testing "ExceptionInfo"
      (t/testing ":exception/typeが:server-errorのとき500を返す"
        (let [req (mock/request :get "/err")
              handler (fn [_] (throw (ex-info "err" {:exception/type :server-error})))
              app (sut/wrap-exception-handler handler)]
          (t/is (= {:status 500
                    :body "Internal Server Error!!"
                    :headers {}}
                   (app req)))))

      (t/testing ":exception/typeが:not-foundのとき400を返す"
        (let [req (mock/request :get "/err")
              handler (fn [_] (throw (ex-info "err" {:exception/type :not-found})))
              app (sut/wrap-exception-handler handler)]
          (t/is (= {:status 404
                    :body "Not Found!!"
                    :headers {}}
                   (app req))))))))

こうなるようにwrap-exception-handlerを実装すると、以下のようになります。

(defn- internal-server-error-response []
  (-> (response/response "Internal Server Error!!")
      (response/status 500)))

(defn- not-found-response []
  (-> (response/response "Not Found!!")
      (response/status 404)))

(defprotocol ExceptionToResponse
  (->response [e]))

(extend-protocol ExceptionToResponse
  Exception
  (->response [e]
    (println "Unhandled Exception:" (type e))
    (clojure.stacktrace/print-stack-trace e)
    (internal-server-error-response))

  IllegalArgumentException
  (->response [e]
    (internal-server-error-response))

  clojure.lang.ExceptionInfo
  (->response [e]
    (let [{t :exception/type} (ex-data e)]
      (case t
        :server-error
        (internal-server-error-response)
        :not-found
        (not-found-response)))))

(defn wrap-exception-handler [handler]
  (fn exception-handler [req]
    (try
      (handler req)
      (catch Exception e
        (->response e)))))

500404のレスポンスだけの関数(internal-server-error-response, not-found-response)を作りました。また実際に例外をふりわけるところはExceptionToResponseというプロトコルに任せて、それぞれの例外ごとに実装をできるようにしています。これによってwrap-exception-handlerでやっていることは非常に明快になりました。

このようにRingミドルウェアを応用することで、例外ごとに任意のレスポンスを返すことができました。

Ductアプリケーションへの適用方法

上記のwrap-exception-handlerを適用するDuctアプリケーションに適用するのは非常に簡単です。まずはプロジェクトを用意するところから。次のコマンドを使ってDuctプロジェクトの雛形を作ります。

$ lein new duct demo --to-dir exception-handling-api -- +api +ataraxy

今回はAtaraxyというデータでルーティングを記述できるライブラリを利用します。これはDuctで既にモジュール化されているため、簡単に使い始めることができます。

次に先程のwrap-exception-handlerを使って、Ductから使えるコンポーネントを用意します。

(ns demo.middleware.exception-handler
  (:require [ring.util.response :as response]
            [integrant.core :as ig]))

;; 中略

(defn wrap-exception-handler [handler]
  (fn exception-handler [req]
    (try
      (handler req)
      (catch Exception e
        (->response e)))))

(defmethod ig/init-key :demo.middleware/exception-handler [_ _]
  wrap-exception-handler)

wrap-exception-handlerの実装については、既に説明したものをそのまま利用します。そして、ミドルウェアのコンポーネント:demo.middleware/exception-handlerでは、単にwrap-exception-handlerを返すようにします。

次に、このミドルウェアを適用するには、config.ednに以下のような記述をします。大事なところは:duct.module/ataraxyのキーに対応するマップです。具体的な記法の説明については、Duct module.ataraxyのREADMEに譲りますが、Ataraxyのシンタックスでいうresult部に対してメタ情報として任意のミドルウェアを指定することで先程のミドルウェアのコンポーネントを適用することができます。

{:duct.profile/base
 {:duct.core/project-ns demo

  ;; Middlewares
  :demo.middleware/exception-handler {}

  ;; Handlers
  :demo.handler.throw-exception/not-found {}
  :demo.handler.throw-exception/server-error {}}

 :duct.profile/dev   #duct/include "dev"
 :duct.profile/local #duct/include "local"
 :duct.profile/prod  {}

 :duct.module/ataraxy
 {"/" ^:exception-handler
  {"not-found" [:throw-exception/not-found]
   "server-error" [:throw-exception/server-error]}}

 :duct.module/logging {}
 :duct.module.web/api
 {}}

まとめ

ClojureでWebアプリケーションを作っても、簡単に例外のハンドリングはできるよ!