IntegrantでRingハンドラーをどう扱うか
悩ましい問題らしいので、ここで少し書くことにします。ちなみにComponentを利用していても、問題としては似たようなものなので適当に読み替えてください。 基本的には以下の2パターンがあると思います。
すべてのRingハンドラーをコンポーネントとして扱う ルーター部分までをコンポーネントにして、Ringハンドラーはただの関数として扱う
すべてのRingハンドラーをコンポーネントとして扱う
ちなみにAtaraxyで説明しても良いんですが、今回は僕が慣れているものを選んだためbidiで説明します(あと、まだ開発中でAPI変わるかも、ってほのめかしているし)。 バージョンは[integrant "0.6.3"]、[bidi "2.1.2"]です。
code:clojure
(ns demo.system1
(:require bidi.ring
(defonce system (atom nil))
(defmethod ig/init-key :app/database
_
(atom {}))
(defmethod ig/halt-key! :app/database
db
(reset! db nil))
(defmethod ig/init-key :app.handler/x
{:status 200}))
(defmethod ig/init-key :app.handler/y
{:status 200}))
(defmethod ig/init-key :app.handler/z
{:status 200}))
(defmethod ig/init-key :app/router
(bidi.ring/make-handler routes handlers))
(def system-config
{:app/database {}
:app.handler/x {:db (ig/ref :app/database)}
:app.handler/y {:db (ig/ref :app/database)}
:app.handler/z {:db (ig/ref :app/database)}
:app/router
{:handlers
{:app.handler/x (ig/ref :app.handler/x)
:app.handler/y (ig/ref :app.handler/y)
:app.handler/z (ig/ref :app.handler/z)}
:routes
["/" {"this-is-x" :app.handler/x
"this" {"/is" {"/y" :app.handler/y
"/z" {:post :app.handler/z}}}}]}})
(defn reset-system []
(when @system
(ig/halt! @system)
(reset! system nil))
(reset! system (ig/init system-config)))
見たままですね。:app.handler/x, yやzがRingハンドラーのコンポーネントになっていて、それぞれのコンポーネントは初期化時にRingハンドラーを返却するようになっています。また、Ringハンドラーのコンポーネントは:app/databaseに依存しているため、起動したデータベースコンポーネントをRingハンドラー内で閉じ込めておいて利用することができます。
実際にこれを利用すると以下のような結果を得ることができます。
code:clojure
(comment
(reset-system)
((:app/router @system) {:uri "/this-is-x"})
;; => {:status 200}
@(:app/database @system)
;; => {:count {:x 1}}
((:app/router @system) {:uri "/this/is/y"})
;; => {:status 200}
@(:app/database @system)
;; => {:count {:x 1, :y 1}}
((:app/router @system) {:uri "/this/is/z" :request-method :post})
;; => {:status 200}
@(:app/database @system)
;; => {:count {:x 1, :y 1, :z 1}}
((:app/router @system) {:uri "/this/is/z"})
;; => nil
@(:app/database @system)
;; => {:count {:x 1, :y 1, :z 1}}
)
良い感じですね。
ルーター部分までをコンポーネントにして、Ringハンドラーはただの関数として扱う
次にルーター部分までしかコンポーネントにしないパターンです。以下のようなコードになります。
code:clojure
(ns demo.system2
(:require bidi.ring
(defonce system (atom nil))
(defmethod ig/init-key :app/database
_
(atom {}))
(defmethod ig/halt-key! :app/database
db
(reset! db nil))
(defmulti resolve-handler identity)
{:status 200})
(defmethod resolve-handler :app.handler/x _ x-handler) {:status 200})
(defmethod resolve-handler :app.handler/y _ y-handler) {:status 200})
(defmethod resolve-handler :app.handler/z _ z-handler) (def routes
["/" {"this-is-x" :app.handler/x
"this" {"/is" {"/y" :app.handler/y
"/z" {:post :app.handler/z}}}}])
(defmethod ig/init-key :app/router
(bidi.ring/make-handler routes resolver))
(defmethod ig/init-key :app/endpoint
(handler (assoc req :db database))))]
(wrap-db router)))
(def system-config
{:app/database {}
:app/router {:routes routes
:resolver resolve-handler}
:app/endpoint {:router (ig/ref :app/router)
:database (ig/ref :app/database)}})
(defn reset-system []
(when @system
(ig/halt! @system)
(reset! system nil))
(reset! system (ig/init system-config)))
最初の例と違うのは:app/routerが初期化時に受けとるものが変わった点と、Ringハンドラーが完全に独立した関数になっている点です。この場合、データベースのコンポーネントを関数生成時に受け取ることができないので、リクエストマップ経由で受け取るようになっています(:app/endpointコンポーネントの初期化時にwrap-dbというミドルウェアを生成して適用することで、すべてのリクエストでデータベースを参照することができる)。あ、resolve-handlerの実装はなんでもいいので、別にマルチメソッドである必要はないです(この場合キーワードを受け取って関数を返せればなんでもよい)。
これもテストしてみるとこんな感じです。
code:clojure
(comment
(reset-system)
((:app/endpoint @system) {:uri "/this-is-x"})
;; => {:status 200}
@(:app/database @system)
;; => {:count {:x 1}}
((:app/endpoint @system) {:uri "/this/is/y"})
;; => {:status 200}
@(:app/database @system)
;; => {:count {:x 1, :y 1}}
((:app/endpoint @system) {:uri "/this/is/z" :request-method :post})
;; => {:status 200}
@(:app/database @system)
;; => {:count {:x 1, :y 1, :z 1}}
((:app/endpoint @system) {:uri "/this/is/z"})
;; => nil
@(:app/database @system)
;; => {:count {:x 1, :y 1, :z 1}}
)
悪くなさそうです。
それぞれのPros/Cons
すべてのRingハンドラーをコンポーネントとして扱うパターン Pros
Ringハンドラーが依存するコンポーネントを簡単に表現できる
Cons
ハンドラーを書き換える度にシステムの再起動が必要になる
アプリケーションが肥大していくと設定ファイルが巨大になりやすくつらい
ほとんどのRingハンドラーはどうせデータベース(任意のコンポーネント)に依存するのだから、明示的に書くのは面倒
ルーター部分までをコンポーネントにして、Ringハンドラーはただの関数として扱うパターン
Pros
比較的設定ファイルが巨大になり難い
リクエストマップにコンポーネントが入ってくることを期待することができて楽
Ringハンドラーのテストでいちいちシステムを起動しなくても済む
これは結局依存しているコンポーネントがある場合、どの道システムを起動してしまった方が楽だったりするので一概には言えない
あとミドルウェアが差し込まれたりするので、結局システムを起動した方が良かったりする
Cons
Ringハンドラーが依存しているコンポーネントがコードを読まないと分からない
依存するコンポーネントが増えてくると色んなものがリクエストマップに入ってくるので、何がどこに入ってるか分かり難い
まとめ
どちらのパターンもPros/Consがあり、一概にどちらが良いとは良い切れないです。ただ、James ReevesはRingハンドラーもコンポーネントとして扱うことを良しとしている気がしますね(実際、Ductがそうなので)。 また、一般的にComponent/Integrantを利用すると「すべてコンポーネント化しないといけない」と思われている節がありますが、実際にはWebアプリケーションを開発するときにはルーターの部分でコンポーネントが波及していくことを止めることかができる、というのがこの記事から分かると思います。