プログラムを半自動生成して楽をした話
今日はマクロじゃないプログラム生成の話。
僕が作っているライブラリのひとつにcybozu-http-cljというライブラリがある。これはkintoneのREST
APIをラップして、Clojureから利用しやすくしたもの(多くの人はkintoneをClojureから操作したいなんて思わないのでだいぶニッチではある)。
それでこのライブラリ、ほぼすべてのAPIを現時点で網羅しているわけなんだけど、少し前(今日の朝)まで問題があった。それは外部のモノ(API)に直接依存しているため、ライブラリを利用する側でテスト書くときに、どうしてもwith-redefsなどを利用する必要があったということ。もちろん、ライブラリを利用する側でラッパーを書いたりすれば良いというのはあるけど、どちらにしてもこのライブラリを利用するユーザーが直面する問題ならばライブラリ側で解決してもいいかと思い重い腰をあげることにした。
元々のコードはこんな感じになっていた。
code:clojure
(defapi get-fields :get "/app/form/fields.json"
app :- app-id
lang :- language)
https://github.com/ayato-p/cybozu-http-clj/blob/1cd7ea8aa7cada708d50e0b14163e001e0b52338/src/cybozu_http/kintone/api/app.clj#L15-L17
このdefapiというマクロはちょっとカオスなので興味ある人は適当に見て欲しいのだけど、上のような定義をしたときにget-fieldsという、第1引数にauthという名前でkintoneに接続するために必要な情報を取り、第2引数以降は[app :- app-id]で指定されている値を順に受け取り、最後の引数は[lang :- language]で指定されている値をマップで受け取る関数を作る(第2引数以降の引数はAPIの必須項目、最後の引数はAPIのオプション項目)。
https://github.com/ayato-p/cybozu-http-clj/blob/1cd7ea8aa7cada708d50e0b14163e001e0b52338/src/cybozu_http/kintone/api/bare.clj#L76-L119
なので、上記のget-fields関数は次のシグネチャを持つ。
code:clojure
(get-fields (auth app-id)([auth app-id {:keys language :as opts}]))
そして、このライブラリはほとんどのAPIをdefapi経由でラップしているので、すべて同じようなシグネチャで定義される(ここが大事)。
これを最終的にどういう形にしたかったかというと、次のプロトコルのようにしたかった。
code:clojure
(defprotocol AppFieldsAPI
(get-fields
auth app-id
auth app-id opts))
https://github.com/ayato-p/cybozu-http-clj/blob/06f26761a0dd0145de176baecc1d8e06a6ba118f/src/cybozu_http/kintone/api/app.clj#L31-L42
第1引数として渡されるのはdefapiのおかげで必ずauthという名前のマップであることが確定しているので、以下のようにマップに対してプロトコルを実装することができる。
code:clojure
(extend-protocol AppFieldsAPI
clojure.lang.IPersistentMap
(get-fields
(auth app-id
(internal/get-fields auth app-id))
(auth app-id opts
(internal/get-fields auth app-id opts))))
この例で言えばget-fieldsという関数は、cybozu-http.kintone.api.appというネームスペースに存在していたのだけど、できるだけユーザー側のコードに影響を与えないように修正したかったので、cybozu-http.kintone.api.appネームスペースに今回新しく追加するプロトコルを定義して、前からあったコードはcybozu-http.kintone.api.internal.appというネームスペースに全て移動した。
さて、ここまで読んでいて気付いたかもしれないけれど、get-fieldsという関数をたったひとつラップするだけでもこれだけのコードを書かないといけない。これは非常に面倒で、そんな無駄なことをする程人生は長くない。思い出して欲しいのだけれど、ほぼすべてのAPIはdefapiマクロによって同じようなシグネチャを持つようになっている。だとすれば、この作業は自動化できると考えるのが道理だ。というわけでやってみた結果が以下の通り。
code:clojure
(ns convert
(:require clojure.string :as str))
(defn- find-function-meta-data ns-sym
(let [xform (comp (filter (comp fn? var-get))
(map meta)
(filter (comp not #(str/ends-with? % "*") name :name)))]
(into [] xform (vals (ns-publics ns-sym)))))
(defn- m-to-as-name m
(cond-> m (map? m) :as))
(defn gen-protocol-form protocol-name ns-sym
`(~'defprotocol ~protocol-name
~@(for m (sort-by :line (find-function-meta-data ns-sym))
`(~(:name m) ~@(->> (:arglists m)
(mapv #(mapv m-to-as-name %)))))))
(defn gen-extend-protocol-form protocol-name from-ns as-name
`(~'extend-protocol ~protocol-name
~'clojure.lang.IPersistentMap
~@(for [m (sort-by :line (find-function-meta-data from-ns))
:let {fname :name arglists :arglists} m]
`(~fname
~@(for [argv (->> (sort-by count arglists)
(map #(mapv m-to-as-name %)))]
`(~argv (~(symbol (name as-name) (name fname)) ~@argv)))))))
適当にやっつけのコードなので、多少雑なのには目をつむってほしい。gen-protocol-formでdefprotocolの定義を、gen-extend-protocol-formでextend-protocolの定義を生成するようになっている。これは実際には以下のように利用した。
code:clojure
(let [protocol-sym 'PreviewAppAPI
ns-sym 'cybozu-http.kintone.api.internal.preview-app]
(list
(gen-protocol-form protocol-sym ns-sym)
(gen-extend-protocol-form protocol-sym ns-sym 'internal)))
こうすることでinternalへ移動した関数をラップするためのプロトコルと実装部が生成できた。生成したら後は手作業で切り貼りして整形していけば作業は完了。
今回の修正は以下に全部あるので興味があれば覗いてみて欲しい。これだけの変更を手でやろうと思うと気が遠くなるけど、プログラムからプログラムを操作できるおかげで、圧倒的に楽をできた。
https://github.com/ayato-p/cybozu-http-clj/pull/6/files#diff-0fff143854a4f5c0469a3819b978a483R23
まとめ
Clojureはいいぞ。
#Clojure