Clojureで作ったAPIをマイクロサービスの海に隠す
弊社ではマイクロサービスでサービスを開発しています。その中には当然のようにありとあらゆるプログラミング言語(例えばKotlinとかRust, Go lnagなど)で書かれたAPIが存在しています。そして最近流行りのClojureで書かれたAPIも当たり前のように存在しています。
それぞれのAPIは基本的にはHTTP通信によってコミュニケーションしているわけですが、その際にひとつだけ気をつけておかないといけないことがあります。それは命名規則です。といってもそんなに難しい話ではなく、シンプルにURLのパスやJSONのキーをlowerCamelCaseにしておきましょう、というだけです(注意: あくまでも弊社の内部での約束事として)。Clojureでは一般的に変数や関数の命名にkebab-caseを用います。うっかりしているとプログラムの中にlowerCamelCaseが現れてしまったり、kebab-caseが外部に漏れてしまうことになります。
Clojureを書いているとナチュラルに、それはもう息を吸うようにkebab-caseでコードを書いてしまうので、ときどきlowerCamelCaseのことを思い出してあげないといけないのは大変です。なので、今回はできる限りkebab-caseでプログラムを書くためのハックを紹介します。
とりあえず、一般的なDuctアプリケーションを作っておきます。これをベースに話を進めることにしましょう(現在のDuctのテンプレートは0.12.3が最新です)。
code:sh
$ lein new duct sample +api +ataraxy +postgres
ルーティング定義
最初はルーティング定義について話をしていきましょう。外部からAPIを叩くときにhttp://localhost/camelCaseとなっていてほしいのですが、アプリケーションの内部でルーティング定義を書くときにうっかりkebab-caseで書いてしまうかもしれません。うっかりkebab-caseで書いてしまっても大丈夫なようにしてみます。
まずはRingハンドラーを以下のように定義しておきます。
code:src/sample/handler/acme.clj
(ns sample.handler.acme
(:require integrant.core :as ig
ring.util.response :as res))
(defmethod ig/init-key ::camel-case _
(constantly (res/response {:lowerCamelCase false})))
次にconfig.ednです。どうやらうっかりさんが/camel-caseと書いてしまったようです。
code:resources/sample/config.edn
{
:duct.profile/base
{:duct.core/project-ns sample
:duct.router/ataraxy
{:routes
{:get "/camel-case" :sample.handler.acme/camel-case}}
:sample.handler.acme/camel-case
{}}
;;………
}
この状態でAPIを起動してみると以下のようにリクエストをすることができます。
code:sh
$ curl 'localhost:3000/camel-case'
{"lower-camel-case":false}
このままだと他のAPIはURLのパスをkebab-caseで書かなくてはなりません。これだと約束を守れていないので軽くハックしていきます。以下のようにrouter.cljを書いてみます。
code:src/sample/router.clj
(ns sample.router
(:require clojure.string :as str
[clojure.walk :refer postwalk]
[inflections.core :refer camel-case]
integrant.core :as ig))
(derive ::camelize-router :duct.router/ataraxy)
(defn camelize-routes routes
(postwalk
(fn x
(if (string? x)
(->> (str/split x #"/")
(map #(camel-case % :lower))
(str/join "/"))
x))
routes))
(defmethod ig/init-key ::camelize-router {:keys routes :as options}
(->> (update options :routes camelize-routes)
(ig/init-key :duct.router/ataraxy)))
ポイントはAtaraxyのシンタックス定義から文字列はパスにしか利用されていないことが分かるため、適当にルーティング定義情報を走査しながらlowerCamelCaseへと変換していきます。kebab-caseをlowerCamelCaseへと変換する部分はinflectionsを利用しています。
もうひとつのポイントとしてはDuctで既に定義されている:duct.router/ataraxyをderiveしていることです。これによって、:duct.router/ataraxyを必要とするところに自動的にインジェクションされるコンポーネントを定義することができます。
c.f. https://github.com/weavejester/integrant#derived-keywords
inflectionsを追加したためproject.cljは以下のように変更してあります。
code:project.clj
(defproject sample "0.1.0-SNAPSHOT"
;; ………
:dependencies [org.clojure/clojure "1.10.3"
duct/core "0.8.0"
duct/module.ataraxy "0.3.0"
duct/module.logging "0.5.0"
duct/module.sql "0.6.1"
duct/module.web "0.7.3"
org.postgresql/postgresql "42.2.19"
inflections "0.13.2"]
それからconfig.ednの:duct.router/ataraxyだったところを:sample.router/camelize-routerへと変更します。
code:resources/sample/config.edn
:sample.router/camelize-router
{:routes
{:get "/camel-case" :sample.handler.acme/camel-case}}
APIを再起動して以下のようにリクエストしてみると、lowerCamelCaseで受け付けるようになったことがわかります。これでうっかりルーティングをkebab-caseで書いても大丈夫そうです。
code:sh
$ curl 'localhost:3000/camel-case'
{"error":"not-found"}
$ curl 'localhost:3000/camelCase'
{"lower-camel-case":false}
JSONレスポンス
さて、次はさっきからちらほら見えているJSONのレスポンスをlowerCamelCaseにしてみましょう。うっかりさんがsrc/sample/handler/acme.cljで(res/response {:lower-camel-case false})と書いてしまっているため、返ってくるJSONのキーがkebab-caseとなってしまっています。これをハンドラーを書き換えることなく、lowerCamelCaseなキーのJSONになるように修正していきます。
レスポンスに作用させるミドルウェアを定義します。ここでも先程と同様にinflectionsを利用することにします。
code:src/sample/middleware.clj
(ns sample.middleware
(:require [inflections.core :refer camel-case-keys]
integrant.core :as ig))
(defn wrap-camelize-response handler
(fn req
(-> (handler req)
(update :body #(camel-case-keys % :lower)))))
(defmethod ig/init-key ::wrap-camelize-response _
wrap-camelize-response)
ざくっと書いたらconfig.ednを修正します。:duct.handler/rootの:middlewareに定義したミドルウェアを差込みます。Ductプロジェクトを作成するさいに+apiや+siteというオプションを渡していると最初からいくらかミドルウェアがインジェクションされているため、その設定とマージします。
code:resources/sample/config.edn
{:duct.profile/base
{:duct.core/project-ns sample
:duct.handler/root
{:middleware
^:demote #ig/ref :sample.middleware/wrap-camelize-response}
:sample.middleware/wrap-camelize-response
{}
;;………
期待したようにミドルウェアを差込むことができたかは、REPLから以下のフォームを評価することで知ることができます。
code:clojure
clj꞉dev꞉> (pprint (:duct.handler/root config))
{:router #ig/ref :duct/router,
:middleware [#ig/ref :sample.middleware/wrap-camelize-response
#ig/ref :duct.middleware.web/not-found
#ig/ref :duct.middleware.web/format
#ig/ref :duct.middleware.web/defaults
#ig/ref :duct.middleware.web/log-requests
#ig/ref :duct.middleware.web/log-errors
#ig/ref :duct.middleware.web/stacktrace]}
nil
ここまで書けたらAPIを再起動して、リクエストを送ってみます。
code:sh
$ curl 'localhost:3000/camelCase'
{"lowerCamelCase":false}
返ってくるレスポンスJSONのキーがlowerCamelCaseになりました。これでうっかり返却するJSONのキーをkebab-caseで書いてしまっても安心ですね。
JSONボディ
次に外部のAPIからPOSTリクエストやPUTリクエストで送られてくるJSONです。容赦なく送られてくるlowerCamelCaseなキーを持つJSONは、何もしないとハンドラーへlowerCamelCaseなキーのままのマップを渡してしまいます。うっかりしているとハンドラーの中にlowerCamelCaseが現れてしまい窒息してしまうので、キーをしれっとkebab-caseにしていきましょう。とりあえず以下のようなリクエストが送られてくることを考えてみます。
code:sh
$ curl -XPOST 'localhost:3000/postJson' -H 'content-type: application/json' -d '{"fooBarBaz": 42}'
例によってconfig.ednを修正して、ルーティング定義の修正とハンドラーの追加を行います。
code:resources/sample/config.edn
:sample.router/camelize-router
{:routes
{:get "/camel-case" :sample.handler.acme/camel-case
:post "/post-json" :sample.handler.acme/post-json}}
:sample.handler.acme/camel-case
{}
:sample.handler.acme/post-json
{}
ハンドラーはあまり重要ではないので、JSONから:foo-bar-bazというキーを抜き出してレスポンスとして返却するようにしてみましょう。
code:src/sample/handler/acme.clj
(ns sample.handler.acme
(:require integrant.core :as ig
ring.util.response :as res))
;;………
(defmethod ig/init-key ::post-json _
(fn [{:keys body-params :as req}]
(res/response (str (:foo-bar-baz body-params)))))
このままではまださっきのリクエストを送っても何も返せないのでミドルウェアを追加していきましょう。次のようにリクエストに作用するミドルウェアを書きます。もはや何も難しくないですね!
code:src/sample/middleware.clj
(ns sample.middleware
(:require [inflections.core :refer camel-case-keys hyphenate-keys]
integrant.core :as ig))
;;………
(defn wrap-kebab-request handler
(fn req
(handler (update req :body-params hyphenate-keys))))
(defmethod ig/init-key ::wrap-kebab-request _
wrap-kebab-request)
config.ednを修正してwrap-kebab-requestをミドルウェアに差込みます。さくさくっと進んでいきます。
code:resources/sample/config.edn
:duct.handler/root
{:middleware
^:demote [#ig/ref :sample.middleware/wrap-camelize-response
#ig/ref :sample.middleware/wrap-kebab-request]}
:sample.middleware/wrap-camelize-response
{}
:sample.middleware/wrap-kebab-request
{}
APIを再起動して、最初に送ったリクエストをもう一度送ってみます。
code:sh
$ curl -XPOST 'localhost:3000/postJson' -H 'content-type: application/json' -d '{"fooBarBaz": 42}'
42
どうやらkebab-caseでアクセスして値を取得することに成功したようです。これで息を吸うようにkebab-caseでコードを書いても問題なさそうですね。
クエリパラメータ
最後の敵はクエリパラメータです。もう息を吸うようにkebab-caseを書いてしまう私たちとしては、ここまできたらkebab-caseだけを書いていたいです。あと少しがんばりましょう。
私たちとしては以下のようにルーティング定義を書きたいわけです。今回/queryParamsというパスにcamelCaseParamというクエリパラメータが渡るようにしたいです。ですが、このままではリクエストする側は/queryParams?camel-case-param=42と書かねばなりません。
code:resources/sample/config.edn
:sample.router/camelize-router
{:routes
{:get "/camel-case" :sample.handler.acme/camel-case
:post "/post-json" :sample.handler.acme/post-json
:get "/query-params" #{camel-case-param} :sample.handler.acme/query-params camel-case-param}}
;;………
:sample.handler.acme/query-params
{}}
ハンドラーは以下のようにざっくり定義しておきます。ちなみにこちらはベクタから取り出すことになるので、変数名がlowerCamelCaseかkebab-caseかは関係ありません。
code:src/sample/handler/acme.clj
(ns sample.handler.acme
(:require integrant.core :as ig
ring.util.response :as res))
;;………
(defmethod ig/init-key ::query-params _
(fn [{:ataraxy/keys result :as req}]
(let [camel-case-param result]
(res/response camel-case-param))))
ここまで読んでくれた方はもう察しがついているかと思いますが、最初に定義したルーターを修正していくことにします。Ataraxyの定義上シンボルはふたつ意味があるので、あまり簡素な実装をするとダメなんですがとりあえず目を瞑ることにします。以下のようにcamelize-routesを修正しました(あと?が最初にくるパターンもとりあえず無視しました)。
code:src/sample/router.clj
(defn camelize-routes routes
(postwalk
(fn x
(cond
(string? x)
(->> (str/split x #"/")
(map #(camel-case % :lower))
(str/join "/"))
(symbol? x)
(camel-case x :lower)
:else
x))
routes))
これでAPIを再起動すると…
code:sh
$ curl 'localhost:3000/queryParams?camelCaseParam=42'
42
完璧ですね!
まとめ
マイクロサービスの海にClojureで作ったAPIを隠すことに成功しました。これで誰もこのAPIがClojureで書かれているなんてわかりませんね(少なくとも外からは)。
余談にはなりますが、HTTPクライアントを使ってJSONを送るときや、返ってきたJSONをパースするときにも、inflectionsを使えば簡単にキーをlowerCamelCaseにしたりkebab-caseにすることができます。JSONシリアライザなどの機能にキーをモディファイするものもあるのでしっかり確認しておくと良いです。JDBCドライバのラッパーなどにもだいたい似たような機能がちゃんとあります。
c.f. https://cljdoc.org/d/com.github.seancorfield/next.jdbc/1.2.761/doc/getting-started/result-set-builders