Ringに学ぶ、Javaライブラリへの依存を剥がす方法
昨日はRingの依存関係からclj-timeが外れたという話をしました(ClojureでWebアプリケーションをつくるときに、Joda-Timeに依存しないようにする)。
今日はRingがどうやってclj-timeの依存関係を剥がしたのか解説をしたいと思います。
Ring 1.7.1以前にclj-timeはどう利用されていたのか
まずは以前のRing(1.7.1以前)はどのようにclj-timeを利用していたのかを見ていきます。以下のコードがclj-timeに依存していた部分です。何をしているかというと非常に単純で、wrap-cookiesが引数に取るオプション:max-age, :expiresにJoda-Timeのインスタンスが渡されることを期待しています(もしくは整数値、文字列)。
code:src/ring/middleware/cookies.clj
(ns ring.middleware.cookies
"Middleware for parsing and generating cookies."
(:import org.joda.time DateTime Interval)
(:require ring.util.codec :as codec
clojure.string :as str
[clj-time.core :refer in-seconds]
[clj-time.format :refer formatters unparse with-locale]
[ring.util.parsing :refer re-token]))
(defn- valid-attr?
"Is the attribute valid?"
key value
(and (contains? set-cookie-attrs key)
(not (.contains (str value) ";"))
(case key
:max-age (or (instance? Interval value) (integer? value))
:expires (or (instance? DateTime value) (string? value))
:same-site (contains? same-site-values value)
true)))
(defn- write-attr-map
"Write a map of cookie attributes to a string."
attrs
{:pre (every? valid-attr? attrs)}
(for [key value attrs]
(let attr-name (name (set-cookie-attrs key))
(cond
(instance? Interval value) (str ";" attr-name "=" (in-seconds value))
(instance? DateTime value) (str ";" attr-name "=" (unparse rfc822-formatter value))
(true? value) (str ";" attr-name)
(false? value) ""
(= :same-site key) (str ";" attr-name "=" (same-site-values value))
:else (str ";" attr-name "=" value)))))
https://github.com/ring-clojure/ring/blob/1.7.1/ring-core/src/ring/middleware/cookies.clj
たったこれだけのためにclj-timeに依存していたのかーという気持ちになりますね。
どうすれば、clj-timeの依存関係を外せるのか
問題はここからです。ここで依存関係からclj-timeをただ抜いて、Date and Time APIを使うように変えるのは簡単です。しかし、Ringは広く一般に利用されています。この破壊的変更の影響は非常に大きいです。できれば、破壊的な変更をせずにこの依存関係を外したいです。
wrap-cookiesは既にJoda-Timeのオブジェクトを取る、と世界と契約してしまっているため、普通にこれを除くのは骨が折れます。しかし、write-attr-mapに注目してみると、「何かから秒数を取り出す」「何かからRFC822に則った文字列を取り出す」ことができれば良さそうなことに気が付きます。これに気がつけるとClojureなら簡単に解決できそうなことが分かります。つまり、プロトコルを使えばいいのです。
実際に、1.8.0では次のようなプロトコルが追加されています。
code:src/ring/middleware/cookies.clj
(defprotocol CookieInterval
(->seconds this))
(defprotocol CookieDateTime
(rfc822-format this))
https://github.com/ring-clojure/ring/blob/1.8.0/ring-core/src/ring/middleware/cookies.clj
利用する側も次のようにsatisfies?を使うように変わっています。このように具体に対する依存を抽象への依存へとすり替えることによって、他のプロジェクトに影響を与えずに変更が実現されました。
code:src/ring/middleware/cookies.clj
(defn- valid-attr?
"Is the attribute valid?"
key value
(and (contains? set-cookie-attrs key)
(not (.contains (str value) ";"))
(case key
:max-age (or (satisfies? CookieInterval value) (integer? value))
:expires (or (satisfies? CookieDateTime value) (string? value))
:same-site (contains? same-site-values value)
true)))
(defn- write-attr-map
"Write a map of cookie attributes to a string."
attrs
{:pre (every? valid-attr? attrs)}
(for [key value attrs]
(let attr-name (name (set-cookie-attrs key))
(cond
(satisfies? CookieInterval value) (str ";" attr-name "=" (->seconds value))
(satisfies? CookieDateTime value) (str ";" attr-name "=" (rfc822-format value))
(true? value) (str ";" attr-name)
(false? value) ""
(= :same-site key) (str ";" attr-name "=" (same-site-values value))
:else (str ";" attr-name "=" value)))))
https://github.com/ring-clojure/ring/blob/1.8.0/ring-core/src/ring/middleware/cookies.clj
それでもJoda-Timeへの依存関係外せなくない?
察しが良い人は気付いたと思いますが、これだけではJoda-Timeへの依存を外せたとは言えません。上記の変更だけだと、Ringを利用するユーザー側でプロトコルを実装せねばならず、結局ユーザーにとっては破壊的な変更になってしまいます。
これを回避するためには、Joda-Timeが提供するクラスに対して、プロトコルを実装しておかねばなりません。しかし、Ringの依存関係にはJoda-Timeを含めたくありません。どうすればよいのでしょう。
Clojureのプロトコルは動的な拡張をサポートしています。つまりは、実行時のタイミングでプロトコルを実装することができるということです。また、JavaではClass/forNameというメソッドを利用することによって、動的にClassオブジェクトを取得することができます。ということは、実行時にJoda-TimeのClassオブジェクトを取得できる場合は、それに対するプロトコルの実装を与えるということができるわけです。
Ringでは以下のように実装されています。
code:src/ring/middleware/cookies.clj
(when-let dt (class-by-name "org.joda.time.DateTime")
(extend dt
CookieDateTime
{:rfc822-format
(eval
'(let [fmtr (.. (org.joda.time.format.DateTimeFormat/forPattern "EEE, dd MMM yyyy HH:mm:ss Z")
(withZone org.joda.time.DateTimeZone/UTC)
(withLocale java.util.Locale/US))]
(fn interval
(.print fmtr ^org.joda.time.DateTime interval))))}))
(when-let interval (class-by-name "org.joda.time.Interval")
(extend interval
CookieInterval
{:->seconds
(eval '(fn dt (.getSeconds (org.joda.time.Seconds/secondsIn dt))))}))
https://github.com/ring-clojure/ring/blob/1.8.0/ring-core/src/ring/middleware/cookies.clj
元々、eval使わなくてもよくない?という議論もPR上でされていましたが、それだとJavaのリフレクションを使ってゴリゴリやらねばならず、可読性が著しく落ちるのもあり、evalで実現されています。
まとめ
Clojureのプロトコルはすごい
cf. https://github.com/ring-clojure/ring/pull/359