Beyond the middleware pattern

Middleware pattern とは Ring などで利用されている一般的な実装パターンですが、今回はこの Middleware pattern の問題点に触れその解決策について書いていきます。

一般的な Middleware pattern の実装と適用方法について

ミドルウェアは通常、次のように実装されます。

(defn mw-1
  "リクエストマップへ `tag-a` を挿入"
  [handler]
  (fn [req]
    (handler (assoc req :tag-a 1))))

(defn mw-2
  "レスポンスマップへ `tag-b` を挿入"
  [handler]
  (fn [req]
    (-> (handler req)
        (assoc :tag-b 2))))

ミドルウェアはこのように実装することにより、リクエストとレスポンスに対して処理を行うことができるようになっています。またミドルウェアは Ring ハンドラを適用する度に新しい Ring ハンドラを返却しますがこれは一般的に次のように書けます。

(def ep (-> handler mw-1 mw-2))

ミドルウェアを適用した Ring ハンドラは次の図のようなイメージとなります。

../../../_images/ring_middleware.png

中央の矢印はリクエストが入ってきてレスポンスが出ていく様子です。ミドルウェアを適用していくと Ring ハンドラは雪だるま式に大きくなるのですが、外側にあるミドルウェアがリクエストマップには先に作用し、レスポンスマップには後に作用するようになっていて、逆に内側にあるミドルウェアほどリクエストマップに作用するのは後になり、レスポンスマップには先に作用するようになります。

前述の例ではミドルウェアがふたつしかなかったので、全貌を把握しやすかったですがこれがもっと増えた場合どうなるか、 ring-defaults を 例に見てみましょう。

(defn wrap-defaults
  "Wraps a handler in default Ring middleware, as specified by the supplied
  configuration map.
  See: api-defaults
       site-defaults
       secure-api-defaults
       secure-site-defaults"
  [handler config]
  (-> handler
      (wrap wrap-anti-forgery     (get-in config [:security :anti-forgery] false))
      (wrap wrap-flash            (get-in config [:session :flash] false))
      (wrap wrap-session          (:session config false))
      (wrap wrap-keyword-params   (get-in config [:params :keywordize] false))
      (wrap wrap-nested-params    (get-in config [:params :nested] false))
      (wrap wrap-multipart-params (get-in config [:params :multipart] false))
      (wrap wrap-params           (get-in config [:params :urlencoded] false))
      (wrap wrap-cookies          (get-in config [:cookies] false))
      (wrap wrap-absolute-redirects (get-in config [:responses :absolute-redirects] false))
      (wrap wrap-resource         (get-in config [:static :resources] false))
      (wrap wrap-file             (get-in config [:static :files] false))
      (wrap wrap-content-type     (get-in config [:responses :content-types] false))
      (wrap wrap-default-charset  (get-in config [:responses :default-charset] false))
      (wrap wrap-not-modified     (get-in config [:responses :not-modified-responses] false))
      (wrap wrap-x-headers        (:security config))
      (wrap wrap-hsts             (get-in config [:security :hsts] false))
      (wrap wrap-ssl-redirect     (get-in config [:security :ssl-redirect] false))
      (wrap wrap-forwarded-scheme      (boolean (:proxy config)))
      (wrap wrap-forwarded-remote-addr (boolean (:proxy config)))))

このようにスレッディングマクロで綺麗に適用する順番が明らかにされていて、とても分かりやすいですね。また設定を外部から受け取りそれによってミドルウェアを適用するかなどが容易に変更できているのも分かりやすいところでしょう。 ですが、これは本当に分かりやすいのでしょうか?というのが今回の話です。順を追って見ていきます。

Middleware pattern の問題点

ここまで見てきた Middleware pattern の実装と適用方法について、大きく分けてふたつ問題があります。

  1. ミドルウェア同士の依存関係が分からない
  2. ミドルウェアに対して外部リソースなどを渡しづらい

どちらも小/中規模のソフトウェアを開発している場合はそこまで問題にならないと思いますが、それ以上になってきた場合に問題になりやすいです(実際に ring-defaults 以外のミドルウェアを追加したり、 ring-defaults の中見を差し替える必要が出てくると分かると思います)。

例えば wrap-keyword-params, wrap-nested-params, wrap-params というミドルウェアがありますがこれらには依存関係があります。これらは全てリクエストマップに対して作用するミドルウェアで、 wrap-params, wrap-nested-params, wrap-keyword-params の順でリクエストマップに作用させないといけません。つまり、 Ring ハンドラに適用する順番は逆で wrap-keyword-params, wrap-nested-params, wrap-params という順番で適用する必要があります( ring-defaults の実装でもそういう順番で適用されます / 話がややこしいですね…)。

それから例えば毎回リクエストの度に会社の情報をデータベースから取得したいという場合を考えてみましょう。このときミドルウェアによって解決して、リクエストマップに :company-info のようなキーで会社の情報を挿入したいとします。このときこのミドルウェア( company-info ミドルウェアとしましょう)はどのようにしてデータベースへの接続情報などを受け取ればいいのでしょうか。 ring-defaults のように設定を外部から受け取るようにしてもいいかもしれませんが、このような情報を露出させるのはあまり美しくない気がしますよね(テストなどのときにデータベースへの接続情報などが外部から渡すのはめんどうですし、データベースへの依存関係は company-info ミドルウェアが勝手に解決してくれると嬉しそうですよね)。

解決策

これらの問題を解決するために今回 Stuart Sierra の作っている dependency というライブラリと component というライブラリを利用することにします。

つまり、ミドルウェア同士の依存関係を明確にして依存関係を元に自動的にミドルウェアを適用する順番を決めるということと、ミドルウェアをコンポーネントとして定義して外部リソースなどの依存関係を外側から注入するということをやります。

今回の話の概念実証は GitHub にあげているので詳細はそちらを見てください。

アプリケーション全体としては Stuart Sierra’s Component を DI フレームワークとして利用する形で実装していて、全体像は以下のようになっています。

;;; https://github.com/ayato-p/clojure-sandbox/blob/master/beyond-the-middleware-pattern/src/demo/system.clj
(defn demo-system [conf]
  (-> (c/system-map :middleware/middlewares {:params wrap-params
                                             :nested-params wrap-nested-params
                                             :keyword-params wrap-keyword-params}
                    :middleware/dependency-map {:nested-params {:params :before}
                                                :keyword-params {:nested-params :before}}
                    :db/spec {:database-url "postgresql://database.example.com/demodb"}
                    :company-middleware (ci/map->CompanyInfo {})
                    :middleware (m/map->MiddlewareAggregator {})
                    :endpoint (e/map->Endpoint {})
                    :server (s/map->WebServer {:host "localhost" :port 3000}))
      (c/system-using
       {:company-middleware {:db :db/spec}
        :middleware {:middlewares    :middleware/middlewares
                     :dependency-map :middleware/dependency-map
                     :company-middleware :company-middleware}
        :endpoint   [:middleware]
        :server     [:endpoint]})))

まず、注目してほしいのは以下の点です。

:middleware/middlewares {:params wrap-params
                         :nested-params wrap-nested-params
                         :keyword-params wrap-keyword-params}
:middleware/dependency-map {:nested-params {:params :before}
                            :keyword-params {:nested-params :before}}

このようにまず :middleware/middlewares に対して id とミドルウェアの対を宣言しておき、 :middleware/dependency-map でそれぞれのミドルウェアの依存関係を明確にします。

この依存関係の読み方は :nested-params {:params :before} と書いてある場合、「 :nested-params ミドルウェアは :params より :before (前)に適用する」ということにしておきます。また、今回は使用してませんが、次のように :after も指定可能です。

:middleware/dependency-map {:nested-params {:params :before
                                            :keyword-params :after}}

system-using:middleware の依存関係に上記ふたつのキーを指定することによって :middleware にてこれらを利用することが出来ます。 system-map では :middlewareMiddlewareAggregator というのが指定されていますが、これは次のような定義になっています。

;;; https://github.com/ayato-p/clojure-sandbox/blob/master/beyond-the-middleware-pattern/src/demo/component/middleware_aggregator.clj
(defrecord MiddlewareAggregator [middlewares dependency-map]
  p/IMiddleware
  (wrap [this handler]
    (let [ms (reduce (fn [ms [k v]]
                       (if (satisfies? p/IMiddleware v)
                         (assoc ms k v)
                         ms))
                     (or middlewares {})
                     this)]
      (-> (build-deps-graph dependency-map)
          dep/topo-comparator
          (sort (keys ms))
          (->> (reduce (fn [f k]
                         (println "Applying middleware:" k)
                         (p/wrap (get ms k) f))
                       handler))))))

system-using で宣言されていた middlewaresdependency-map をうけとって、グラフをつくりトポロジカルソートを利用してミドルウェアを期待する順番通り適用するようにしています。 ここでミドルウェアが適用される順番が正しくなっているかどうか確認できるように println でミドルウェア名を吐いてますが、実際にアプリケーションを起動すると以下のようなログを見ることが出きます。

Applying middleware: :keyword-params
Applying middleware: :nested-params
Applying middleware: :params
Applying middleware: :company-middleware

上から順に Ring ハンドラへと適用されてるのが確認できます。また :middleware/dependency-map に空のマップを渡した場合以下のように期待していない順序でミドルウェアが適用されるのが確認できます。

Applying middleware: :params
Applying middleware: :nested-params
Applying middleware: :keyword-params
Applying middleware: :company-middleware

このように依存関係を明確にすることでミドルウェア同士の依存関係が分かりやすくなり、後からミドルウェアを追加する場合でも自分が追加したいミドルウェアがどのミドルウェアより後(あるいは前)に適用されればいいのか表明しておくだけでいいので、ミドルウェア全体の適用順序をプログラマが意識する必要がなくなります。

さて、次に外部リソースを受け取るという話ですが、既にコード例の中にちらほらと登場しています。 demo-system 内の system-map に次の宣言がありました。

:db/spec {:database-url "postgresql://database.example.com/demodb"}
:company-middleware (ci/map->CompanyInfo {})

まず、データベースを外部リソースとして使いたいので :db/spec を宣言していて、さらにそれを利用する CompanyInfo というミドルウェアに :company-middleware という名前をつけています。 system-using 内では :company-middleware:db/spec に依存していて、 :middleware:company-middleware に依存しているというように依存関係を明らかにしています。

CompanyInfo の実装を見てみましょう。

(defrecord CompanyInfo [db]
  p/IMiddleware
  (wrap [this handler]
    (fn [req]
      (let [company (find-company db)]
        (handler (assoc req :company-info company))))))

IMiddleware プロトコルの wrap 関数を実装しています。このプロトコルは MiddlewareAggregator 内で利用されています。このレコード型はシステム(ここでいう`システム`とは Stuart Sierra’s Component のシステムのこと)が起動するタイミングで、 db (つまりデータベースへの接続情報など)が外側から注入されるようになっていて、 wrap 関数が実行されると外側から注入された db を閉じ込めた関数を返却するようになっています。

ミドルウェアを Stuart Sierra’s Component に則ったスタイルで定義することにより、外部リソースなどの依存を外側から注入出来るようになりました。

まとめ

Middleware pattern というのはシンプルなわりに強力な仕組みですが、素晴らしいだけではなく幾つかの問題もあるんだよという話をしました。

まとめると、この記事では次のことについて解説しました。

  • Ring ミドルウェア同士の依存関係を明示することによって、プログラマがミドルウェアの適用順序を考える必要がなくなりました
  • Stuart Sierra’s Component をミドルウェアに適用することによって、依存を注入することが出来るようになりました

今回のこの話は僕が偉そうに書いていますが、元々のアイデアは弊社で使用しているフレームワークを作った人のものです。それを僕が今回、自分で実装しなおして文章にしたという感じなのであまり実装については洗練されてません。 ただ、このアイデアは世の中に広まっても良いものだと思ったのでこのように公開することにしました(社内で使用しているフレームワークについては時期が来たら公開されるかもしれませんし、されないかもしれません)。

質問などあれば Twitter などから気軽にどうぞ :)