Toyokumo Tech Blog

トヨクモ株式会社の開発本部のブログです。

Clojureで作るAPI ルーターを追加する

[連載]Clojureで作るAPIの10記事目です。

前回の記事はこちらです。

tech.toyokumo.co.jp

これまでWebアプリケーションの開発をするための準備をしてきましたが、これからは実際にWebアプリケーションとしての機能を追加していきます。 今回の記事ではWebアプリケーションにルーターを追加していきます。

ルーターの必要性

今まで作ってきたアプリケーションはどのパスにアクセスしても同じレスポンスを返します。 ここにルーターを加えることでリクエストされたパスとHTTPメソッドによって異なった処理を呼び出すことができるようにします。

ルーターとRingの関係性についてはこちらの記事で詳しく解説しているので参照してください。

tech.toyokumo.co.jp

以降はこちらの内容をご理解いただいた前提で進めていきます。

reititを追加する

ルーターライブラリの選択肢は複数ありますが、今回は今最も有力な選択肢であるreititを使用します。

github.com

まずはdeps.ednに追加します。reititは複数のモジュールが含まれるので必要なものだけを選択して加えることもできますが、今回は簡単のためにまとめて追加します。

{:paths ["src" "resources"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}
        info.sunng/ring-jetty9-adapter {:mvn/version "0.17.6" :exclusions [org.slf4j/slf4j-api]}
        org.clojure/tools.logging {:mvn/version "1.2.4"}
        spootnik/unilog {:mvn/version "0.7.30"}
        com.stuartsierra/component {:mvn/version "1.1.0"}
        aero/aero {:mvn/version "1.1.6"}
        ;; 追加
        metosin/reitit {:mvn/version "0.5.18"}
        metosin/ring-http-response {:mvn/version "0.9.3"}}
 :aliases {:dev {:extra-paths ["dev"]}
           :test {:extra-deps {lambdaisland/kaocha {:mvn/version "1.68.1059"}}
                  :main-opts ["-m" "kaocha.runner"]}
           :build {:deps {io.github.clojure/tools.build {:git/tag "v0.8.2" :git/sha "ba1a2bf"}}
                   :ns-default build}}}

Handlerを書きやすくするためのライブラリとしてmetosin/ring-http-responseも加えています。 次項ですぐに使用します。

Handlerを追加する

試しにHandlerを3つ追加してみます。 発展していくことを考慮してnamespaceも分けておきます。

;; ./src/cljapi/handler/health.clj
(ns cljapi.handler.health
  (:require
   [ring.util.http-response :as res]))

(defn health
  "ヘルスチェックに対応するためのHandlerとして意図しています"
  [_]
  (res/ok "Application is runnig"))
;; ./src/cljapi/handler/api/greeting.clj
(ns cljapi.handler.api.greeting
  (:require
   [ring.util.http-response :as res]))

(defn hello [_]
  (res/ok "Hello world"))

(defn goodbye [_]
  (res/ok "Goodbye!"))

ring.util.http-responseRingのレスポンスマップを意図が分かりやすく生成するためのユーティリティを提供してくれるライブラリです。 (res/ok "hoge") を実行すると {:status 200 :headers {} :body "hoge"} が返ります。

Routerを定義する

前項で作成したHandlerを使ってRouterを定義します。

;; ./src/cljapi/router.clj
(ns cljapi.router
  (:require
   [cljapi.handler.api.greeting :as api.greeting]
   [cljapi.handler.health :as health]
   [reitit.ring :as ring]))

(def router
  (ring/router
   [["/health" health/health]
    ["/api"
     ["/hello" api.greeting/hello]
     ["/goodbye" api.greeting/goodbye]]]))

ring/router に与えているベクタがreititを利用したrouterの定義です。 構文は見たままで上記の範囲は理解しやすいと思います。 詳しい解説は次の公式ページを参照してください。

cljdoc.org

RouterからRing Handlerを作る

前項で作成したRouterからRing Handler作り、それをHandlerとしてWebサーバーに与えるようにします。

;; ./src/cljapi/component/handler.clj
(ns cljapi.component.handler
  (:require
   [cljapi.router :as router]
   [com.stuartsierra.component :as component]
   [reitit.ring :as ring]))

(defn- build-handler
  []
  (ring/ring-handler router/router))

(defrecord Handler [handler]
  component/Lifecycle
  (start [this]
    (assoc this :handler (build-handler)))
  (stop [this]
    (assoc this :handler nil)))

ここまで書けたらREPLを立ち上げて (user/go) としてcurlで試してみます。

$ curl http://localhost:8000/health
Application is runnig
$ curl http://localhost:8000/api/hello
Hello world
$ curl http://localhost:8000/api/goodbye
Goodbye!

定義したパスに応じて異なるHandlerが呼ばれ、異なるレスポンスが返ってきていることが確認できます。

開発生産性を引き上げる

ここまででルーターを使う根本の目的は達成できていますが、長く開発を続けていく上では十分ではありません。

試しに src/cljapi/handler/api/greeting.cljhello を次のようにして保存してみます。

(defn hello [_]
  (res/ok "Hello cljapi"))

その上で curl http://localhost:8000/api/hello を実行してもレスポンスは変わりません。 変更した関数を評価しても変わりません。

現状でHandlerの変更を反映するには、変更したHandlerとそれを使用しているRouterをいずれも評価した上で (user/go) をREPLで評価してWebサーバーを再起動する必要があります。 この状態では

  1. 変更した関数などの評価を忘れる
  2. 毎回goでSystemを再起動するのが手間がかかるかつ遅い

という問題があります。 開発生産性を引き上げるためにそれぞれの問題を解決します。

評価のし忘れを防ぐ

評価のし忘れを防ぐために、開発環境で立ち上がっているアプリケーションにアクセスがきた時に、変更があったnamespaceを再読み込みできるようにします。

ring-develにある wrap-reload というRing Middlewareがこの機能を提供してくれます。

Rinig MiddlewareについてはRing Middlewareとは何かをご参照ください。 本項はこの内容を理解していただいた前提で進めていきます。

ライブラリを依存関係に加えます。

;; ./deps.edn
{:paths ["src" "resources"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}
        info.sunng/ring-jetty9-adapter {:mvn/version "0.17.6" :exclusions [org.slf4j/slf4j-api]}
        org.clojure/tools.logging {:mvn/version "1.2.4"}
        spootnik/unilog {:mvn/version "0.7.30"}
        com.stuartsierra/component {:mvn/version "1.1.0"}
        aero/aero {:mvn/version "1.1.6"}
        metosin/reitit {:mvn/version "0.5.18"}
        metosin/ring-http-response {:mvn/version "0.9.3"}
        ;; 追加
        ring/ring-devel {:mvn/version "1.9.5"}}
 :aliases {:dev {:extra-paths ["dev"]}
           :test {:extra-deps {lambdaisland/kaocha {:mvn/version "1.68.1059"}}
                  :main-opts ["-m" "kaocha.runner"]}
           :build {:deps {io.github.clojure/tools.build {:git/tag "v0.8.2" :git/sha "ba1a2bf"}}
                   :ns-default build}}}

リクエスト時の自動的な再評価は開発時だけ有効化したいので、 profile をSystemから渡すことで開発時かどうかの判別ができるようにします。

;; ./src/cljapi/system.clj
(ns cljapi.system
  (:require
   [cljapi.component.a :as c.handler]
   [cljapi.component.server :as c.server]
   [cljapi.config :as config]
   [clojure.tools.logging :as log]
   [com.stuartsierra.component :as component]
   [unilog.config :as unilog]))

(defn- new-system [{:as config :keys [:profile]}]
  (component/system-map
   ;; config内のprofileをHandler Componentに渡すように変更している
   :handler (c.handler/map->Handler {:profile profile})
   :server (component/using
            (c.server/map->Jetty9Server (:server config))
            [:handler])))

(defn- init-logging! [config]
  (unilog/start-logging! (:logging config)))

(defn start [profile]
  (let [config (config/read-config profile)
        system (new-system config)
        _ (init-logging! config)
        _ (log/info "system is ready to start")
        started-system (component/start system)]
    (log/info "system is started")
    started-system))

(defn stop [system]
  (component/stop system))

後は開発時のHandlerに適用するだけです。 Ring Middlewareの適用方法は上記の記事で解説した通常通りの関数実行を行う方法でももちろんできますが、 今回はreititの提供するMiddlewareの適用方法を使います。

;; ./src/cljapi/component/handler.clj
(ns cljapi.component.handler
  (:require
   [cljapi.router :as router]
   [com.stuartsierra.component :as component]
   [reitit.ring :as ring]
   [ring.middleware.lint :as m.lint]
   [ring.middleware.reload :as m.reload]
   [ring.middleware.stacktrace :as m.stacktrace]))

(defn- build-handler
  [profile]
  ;; ring/ring-handlerの引数を変更しています
  ;; 第1引数はこれまで通りrouter
  ;; 第2引数はdefalut-handlerですがこれは今のところはnilにしておきます
  ;; 第3引数のマップにmiddlewareを複数加えています
  (ring/ring-handler
   router/router
   nil
   {:middleware (if (= profile :prod)
                  []
                  ;; 開発時に適用するRing Middlewareです。
                  ;; 今回はwrap-reloadだけでなく、ring-develの提供する他の開発用のMiddlewareもついでに適用しています。
                  [;; 指定したディレクトリ以下の変更を検知してリロードさせるMiddleware
                   ;; オプションはデフォルトそのままをあえて可視化のために書いています
                   [m.reload/wrap-reload {:dirs ["src"]
                                          :reload-compile-errors? true}]
                   ;; リクエストマップとレスポンスマップがRingの仕様を満たしているかをチェックするMiddleware
                   m.lint/wrap-lint
                   ;; 例外をわかりやすく表示してくれるMiddleware
                   [m.stacktrace/wrap-stacktrace {:color? true}]])}))

(defrecord Handler [handler profile]
  component/Lifecycle
  (start [this]
    (assoc this :handler (build-handler profile)))
  (stop [this]
    (assoc this :handler nil)))

今回使用しているreititのMiddlewareの適用方法は、通常の関数実行で適用した時と視覚的に順序が逆であることに注意が必要です。

関数実行でスレッディングマクロを使って適用した場合は次のようになりますが、

(-> handler
    (hatena-middleware)  ; 前処理3番目、後処理1番目
    (debug-middleware)   ; 前処理2番目、後処理2番目
    (no-mean-middleware) ; 前処理1番目、後処理3番目
    )

reititの提供する適用方法を利用すると次のようになります。

{:middleware [hatena-middleware   ; 前処理1番目、後処理3番目
              debug-middleware    ; 前処理2番目、後処理2番目
              no-mean-middleware  ; 前処理3番目、後処理1番目
              ]}

本項では自動で再評価するやり方について書いてきましたが、そもそもどの開発環境にもすべてのnamespaceを一括で再読み込みするコマンドがあります。 ですので評価のし忘れは開発に慣れてくればコマンド1回で解決する問題ではあります。

自動で評価してしまうということは、評価する前後での変化を見たいなどの柔軟性を失ってしまうということでもありますから、 慣れてきたらオフにするか選択できるようにするなどの対応を入れることも選択肢の1つだと思います。

Systemの再起動を減らす

Webサーバーは起動時のHandlerを参照しているため、今のままでは評価したものを反映するにはWebサーバーを再起動する必要があります。

この問題を解決するために、マルチメソッドを使います。

マルチメソッドは動的ポリモーフィズムを実現するための機能の1つです。 defmulti の定義に使った関数の戻り値がdispatchするための値となり、 defmethod で定義した値と一致した defmethod が呼び出されます。 以下に簡単な例を示します。

(defmulti currency
  "この下に定義している関数の戻り値がdispatchを実現するための関数となる。
   以下の例ではマップmのキー :type のバリューでdispatchしようとしている。"
  (fn [m] (get m :type)))

;; Clojureのキーワードは関数としても使えるから次のようにも書ける
;; (defmulti currency :type)

(defmethod currency :yen
  [{:keys [:amount]}]
  (str "¥" amount))

(defmethod currency :dollar
  [{:keys [:amount]}]
  (str "$" amount))

(defmethod currency :euro
  [{:keys [:amount]}]
  (str "€" amount))

;; :default はマッチするものがない時に使われます
(defmethod currency :default
  [_]
  "unknown")

(currency {:type :yen
           :amount 500})
;; => "¥500"

(currency {:type :dollar
           :amount 500})
;; => "$500"

(currency {:type :euro
           :amount 500})
;; => "€500"

(currency {:type :pound
           :amount 500})
;; => "unknown"

Routerからはマルチメソッドの定義(抽象)にだけ依存するようにし、実装には依存しないようにします。 そうすることでHandlerの実装が変わってもWebサーバーの再起動が不要になります。

まずマルチメソッドの定義を用意します。

;; ./src/cljapi/handler.clj
(ns cljapi.handler
  (:require
   [ring.util.http-response :as res]))

(defmulti handler
  "引数はリクエストマップ
   reititで作られたHandler(reitit.ring/ring-handlerで作ったHandler)を通すと
   リクエストマップには :reitit.core/match というキーにマッチしたrouteのdataが含まれる。
   そこからマッチしたrouteの名前を取り出す。
   またRingの仕様により :request-method にHTTP Methodが含まれる。
   この2つを組み合わせて [route-name, request-method] という2つの情報でHandlerの実装とマッチさせる。"
  (fn [req]
    [(get-in req [:reitit.core/match :data :name])
     (get req :request-method)]))

(defmethod handler :default
  [_]
  (res/not-found "not found"))

Routerの定義をこのマルチメソッドに依存するように書き換えます。

;; ./src/cljapi/router.clj
(ns cljapi.router
  (:require
   ;; 具体的なhandlerへの依存が消えている
   [cljapi.handler :as h]
   [reitit.ring :as ring]))

(def router
  (ring/router
   [["/health" {:name ::health
                :handler h/handler}]
    ["/api"
     ["/hello" {:name ::hello
                :handler h/handler}]
     ["/goodbye" {:name ::goodbye
                  :handler h/handler}]]]))

今までは ["/health" health/health] だったところが ["/health" {:name ::health :handler h/handler}] に変わっています。 これはreititのRoute Dataという仕様で、 マップを書くとこれがDataとしてリクエストマップに含まれるようになります。 defmulti handler の関数ではこれを利用してマッチしたHandlerの名前を取り出しているということです。

Handlerの実装も書き換えます。

;; ./src/cljapi/handler/health.clj
(ns cljapi.handler.health
  (:require
   [cljapi.handler :as h]
   [cljapi.router :as r]
   [ring.util.http-response :as res]))

(defmethod h/handler [::r/health :get]
  [_]
  (res/ok "Application is runnig"))
;; ./src/cljapi/handler/api/greeting.clj
(ns cljapi.handler.api.greeting
  (:require
   [cljapi.handler :as h]
   [cljapi.router :as r]
   [ring.util.http-response :as res]))

(defmethod h/handler [::r/hello :get]
  [_]
  (res/ok "Hello world"))

(defmethod h/handler [::r/goodbye :get]
  [_]
  (res/ok "Goodbye!"))

いずれも関数だったHandlerがマルチメソッドの実装(defmethod)に変わっています。 マッチさせるためのデータも [Route名のキーワード HTTPメソッド] となっていることがわかります。

最後に、この状態では defmethod しているHandlerの実装のnamespaceはどこからも読み込まれていないので、 Handler Componentからrequireしておきます。

(ns cljapi.component.handler
  (:require
   ;; ここにrequireを足している
   [cljapi.handler.api.greeting]
   [cljapi.handler.health]
   [cljapi.router :as router]
   [com.stuartsierra.component :as component]
   [reitit.ring :as ring]
   [ring.middleware.lint :as m.lint]
   [ring.middleware.reload :as m.reload]
   [ring.middleware.stacktrace :as m.stacktrace]))

(defn- build-handler
  [profile]
  (ring/ring-handler
   router/router
   nil
   {:middleware (if (= profile :prod)
                  []
                  [[m.reload/wrap-reload {:dirs ["src"]
                                          :reload-compile-errors? true}]
                   m.lint/wrap-lint
                   [m.stacktrace/wrap-stacktrace {:color? true}]])}))

(defrecord Handler [handler profile]
  component/Lifecycle
  (start [this]
    (assoc this :handler (build-handler profile)))
  (stop [this]
    (assoc this :handler nil)))

ここまで書けたらREPLを再起動して動作を確認してみましょう。

$ curl http://localhost:8000/health
Application is runnig
$ curl http://localhost:8000/api/hello
Hello world
$ curl http://localhost:8000/api/goodbye
Goodbye!

これまで通りのことは変わりなくできています。

次にHandlerを書き換えて保存してからもう一度curlを投げてみます。

;; ./src/cljapi/handler/api/greeting.clj
(ns cljapi.handler.api.greeting
  (:require
   [cljapi.handler :as h]
   [cljapi.router :as r]
   [ring.util.http-response :as res]))

(defmethod h/handler [::r/hello :get]
  [_]
  (res/ok "Hello cljapi!!")) ;; ここを変更

(defmethod h/handler [::r/goodbye :get]
  [_]
  (res/ok "Goodbye!"))
$ curl http://localhost:8000/api/hello
Hello cljapi!!

これで目的だった、「ファイルを変更して保存し、再度APIアクセスをしたら変更が反映される」状態を作ることができました。

おわりに

ここまででルーターをアプリケーションに適用し、かつ生産的に開発を続けていく準備もできました。

コードはGitHubのリポジトリに上げてあります。 10_ルーターを追加するというタグからご覧ください。

次はRing Middlewareを使ってより本格的なAPIサーバーとして必要な機能を足していきます。


トヨクモでは一緒に働いてくれる技術が好きなエンジニアを募集しております。

採用に関する情報を公開しております。 気になった方はこちらからご応募ください。