Ringに学ぶ、Javaライブラリへの依存を剥がす方法
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."
(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."
(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)))))
たったこれだけのために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
(defprotocol CookieDateTime
利用する側も次のように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."
(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)))))
それでもJoda-Timeへの依存関係外せなくない?
察しが良い人は気付いたと思いますが、これだけではJoda-Timeへの依存を外せたとは言えません。上記の変更だけだと、Ringを利用するユーザー側でプロトコルを実装せねばならず、結局ユーザーにとっては破壊的な変更になってしまいます。 Clojureのプロトコルは動的な拡張をサポートしています。つまりは、実行時のタイミングでプロトコルを実装することができるということです。また、JavaではClass/forNameというメソッドを利用することによって、動的にClassオブジェクトを取得することができます。ということは、実行時にJoda-TimeのClassオブジェクトを取得できる場合は、それに対するプロトコルの実装を与えるということができるわけです。 code:src/ring/middleware/cookies.clj
(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))]
(.print fmtr ^org.joda.time.DateTime interval))))}))
(extend interval
CookieInterval
{:->seconds
(eval '(fn dt (.getSeconds (org.joda.time.Seconds/secondsIn dt))))})) 元々、eval使わなくてもよくない?という議論もPR上でされていましたが、それだとJavaのリフレクションを使ってゴリゴリやらねばならず、可読性が著しく落ちるのもあり、evalで実現されています。 まとめ
Clojureのプロトコルはすごい