SPEEDA開発の中村です。今回の内容は,弊社主催のclj-ebisu #2で発表した「実践Duct(仮)」です。 ClojureのサーバサイドフレームワークDuctを業務で使って学んだことを紹介します。 connpass.com 勉強会で発表した資料はこちらです。
はじめに,Ductのコアで使われているフレームワークIntegrantを紹介し, サーバサイドでIntegrantを使って感じた課題についてお話しします。 次に,課題に役立つDuctのmoduleのしくみと作り方を説明します。 想定読者は,Clojureを書いたことがあってDuctを使ったことがない方です。
- Integrantのつかいかた
- Integrantなせかい
- Integrantでこまること
- Ductのmodule
- Ductでこまること
- Ductによるコンフィグレーションマップの展開
- Ductのmoduleのつくりかた
- 参考資料
Integrantのつかいかた
Integrantは,アプリケーションのモジュール構成をmapやednのようなデータで表現するためのDIフレームワークの一種です。 Integrantは,アプリケーション起動時にデータが示すモジュールの依存関係を解決し,アプリケーションを初期化します。 Integrantのドキュメントでは,データで構造が表現されたアプリケーションをデータドリブンアーキテクチャと呼んでいます。
まず,例題を通してIntegrantのイメージをつかみましょう。とりあげる例題は,ヘルスチェックを行うringサーバのハンドラを初期化する処理です。
get-health
は引数に与えられたURL先のステータスを問い合わせる関数と考えてください。
(def endpoint “http://localhost/health”) (defn handler [request] (get-health endpoint)) (defn -main [] (jetty/run-jetty handler {:port 3000}))
なんてことはないプレーンなコードですが,変数や関数のスコープが必要以上に大きいという問題があります。
main
はhandler
に,handler
はendpoint
に依存しており,本来main
からendpoint
が見える必要はありません。
しかし,main
とendpoint
がグローバルなスコープを持っているため,お互いに参照できるようになっています。
関数のスコープは呼び出される関数だけから見える大きさで十分です。
Integrantは,このスコープと依存解決の問題をクロージャで解決します。
次の2つのコードは上のコードをIntegrantで再実装したものです。
この2つのコードは,上段がdefmethod
で囲まれたモジュールの実装で,下段がモジュール間の依存関係の定義するednです。
依存するオブジェクトをdispatchの引数として関数に与えることで,Integrantはスコープを小さくします。
(defmethod ig/init-key :ebisu/hanlder [_ endpoint] (fn [request] (get-health endpoint))) (defmethod ig/init-key :ebisu/jetty [_ {:keys [handler] :as options}] (jetty/run-jetty handler options))
{:ebisu/handler “http://localhost/health” :ebisu/jetty {:handler #ig/ref :ebisu/handler :port 3000}}
この依存関係を宣言するednが,コンフィグレーションマップと呼ばれるアプリケーションの構造を定義するデータです。
マップの最上位の各キーはそれぞれモジュールであり,キーの値はモジュールの初期化に必要な別モジュールや値です。
#ig/ref
を使えば別のキーワードを参照できます。
ここでは,ハンドラはURLに依存し,jettyサーバはハンドラとポート番号に依存するという関係があります。
アプリケーションを初期化するには,コンフィグレーションマップをintegrant.core/init
に渡します。
(integrant.core/init (integrant.core/read-string (slurp "config.edn")))
init
は,最上位のキーの値がマルチメソッドの返り値に置き換わったマップを返します。
上の例であれば,init
は,マップにある:ebisu/handler
がget-health
を本体で呼び出す高階関数に置き換わったマップを返します。
余談ですが,IntegrantもDuctもClojureのライブラリをたくさん作られているweavejester先生の作品です。 本稿ではDuctより先にIntengrantについて説明しますが,IntegrantはDuctより後に生まれたフレームワークです。 Integrantが担っているDuctの機能には,もともとComponentが使われていました。 後に,先生は何かに不満を覚えたのか,Componentを使っていた部分をIntegrantに置き換えました。
Integrantなせかい
これまでみたように,Integrantは,モジュールの実装(defmethod
の本体)とアプリケーションの構造(コンフィグレーションマップ)にプログラムを分離します。
次は,分割の後ろにある考え方を説明します。
Integrantは,Arachneから影響を受けて作られたため,Arachneの考えを受け継いでいます。 Arachneの世界観を詳しく知りたい方は,Clojure eXchange 2016での作者によるプレゼンテーションを観るとよいでしょう。 プレゼン前半のテーマは,フレームワークとライブラリの違いです。 自分たちのコードを呼び出す側にあるのがフレームワークで呼び出される側にあるのがライブラリ, そしてフレームワークはアプリケーションの構造を規定する,という主張でした。 テーマの後ろには制御の反転やハリウッドの法則があります。
IntegrantはArachneのアイデアをベースにしているので,Integrantにも,アプリケーションの構造を定義する役割があります。 モジュールを指すシグネチャがあれば,アプリケーションの構造(モジュールの依存関係)を宣言できます。 モジュールの実装はアプリケーションを実際に起動するまでいりません。 このように,モジュールの実装とモジュール間の依存関係は別の関心事とみなせることができます。 それゆえに,関心の分離にしたがって,Integrantは,モジュールの実装とモジュールの依存関係をマルチメソッドとコンフィグレーションマップに分けています。
Integrantでこまること
弊社では,Clojureで作るものは主にWebアプリケーションです。 Webアプリケーションには,ルーティングやDBアクセスなどドメインを問わず実装すべきものがあります。 フルスタック系のWebフレームワークであればこれらをサポートしているものもありますが,Integrantにはありません。 使いたい機能があれば,自分でそれをIntegrantのモジュールにする必要があります。
Ductのmodule
DuctはIntegrantをベースとするサーバサイドのフレームワークです。 フレームワークとしてIntegrantと同じ役割を果たし, module1と呼ばれる形式でアプリケーションを問わずよく使われる機能を提供しています。 moduleを使えば,Integrantで必要だったボイラープレートコードを減らせることができます。 有名なmoduleはductのリポジトリのREADMEで紹介されています。 github.com
Ductでこまること
上のサイトを見た方は気づいたかと思いますが,執筆時点においてmoduleの数は多くありません。 moduleを使えばボイラープレートコードを減らすことができますが,なければどうしようもありません。 アプリケーション間で再利用したいコードがあり,その機能を担うmoduleがないのであれば,moduleを自分で作るしかないでしょう。
ところが,module同様に,moduleを作るための参考資料もまた少ないのです。 いざmoduleを自作してみようとしたところ,情報が少なくて困りました。 そこで,moduleの作成する上で知っておくと役立つmoduleの振る舞いと作り方について紹介します。
Ductによるコンフィグレーションマップの展開
moduleのしごとは,アプリケーションのig/init-key
マルチメソッドを呼び出す前にコンフィグレーションマップにエントリを追加したり,
追加したエントリに対応する実装(マルチメソッド)を提供したりすることです。
Integrantではアプリケーションの初期化手順は
- (read step) コンフィグレーションマップを読み込み
- (init step)
init-key
マルチメソッドを呼び出す
という2手順に分解できます。 Ductにはread stepとinit stepの間にprep stepがあり,prep stepでコンフィグレーションマップが展開されます。
一例として,logging moduleが,コンフィグレーションマップを書き換える過程を追ってみましょう。
moduleは,自分たちで作るモジュールと同様,Integrantのキーワードにすぎません。
logging moduleの場合は:duct.module/logging
です。
以下では,dev
で開発環境用のコンフィグレーションマップを読み込んで初期化処理をread stepまで進めています。
$ cat resources/ebius/config.edn {:duct.module/logging {} ...} $ lein repl user => (dev) :loaded
次にprep
でprep stepまで進めます。
config
を評価すると展開後のコンフィグレーションマップを確認できます。
(dev) => (prep) :prepped dev => (pprint config) {:duct.logger/timbre {:level :debug, :appenders {: ...} :duct.logger.timbre/spit {:fname “logs/..”} :duct.module/logging {} ...}
コンフィグレーションマップにログのレベルや出力先が追加されました。
logging moduleはロギングライブラリtimbreを利用するため,追加されたキーワードにはtimbre
が含まれています。
最後にgo
を呼び出すことで,展開後のコンフィグレーションマップに従い初期化されたWebサーバが起動します。
(go) :duct.server.http.jetty/starting-server {:port 3000} :initiated
Ductのmoduleのつくりかた
ここまでで,外から見たmoduleの振る舞いを確認しました。
次は,Google pubsubを非同期pullで購読するためのmoduleの実装duct.module/message
を作り,moduleの振る舞いの実装方法を紹介します。
例題で扱うのは,公式のpull サブスクライバー ガイドにあるjavaコードです。 プロジェクトIDとサブスクリプションIDで指定したキューからのメッセージの受け取り処理を開始するプログラムです。 このJavaコードは,Clojureで次のように書き直せます。
(def s-name // -> moduleに (SubscriptionName/create “project-id” “subscription-id”)) (def receiver // -> ig/init (reify MessageReceiver (receiveMessage [this message consume] (println (. message getData))))) (def subscriber (Subscriber/newBuilder s-name receiver)) (. (. subscriber build) startAsync) // -> protocol
s-name
で購読するキューを指定,receiver
でメッセージを処理し,
subscriber
が受信したメッセージをreceiver
に渡しています。
moduleが担う処理はドメインを問わず行うべき処理なので,s-name
を文字列から作る処理をmodule化することを目指しましょう。
leiningenプロジェクトをDuctのmoduleにするには,src
に以下のようなマップが書かれたファイルduct_hierarchy.edn
を作る必要があります。
{:duct.module/message [:duct/module]}
コンフィグレーションマップのキーワード間には継承関係を定義でき,
これにより,クラスの継承と同じ考え方でコンフィグレーションマップの抽象度を上げることができます。
全てのmoduleのキーワードは,:duct/module
を継承する必要があり,
上のコードはduct.module/message
がduct/module
の子キーワードであることを宣言しています。
prep stepでは,duct/module
を継承するキーワードのマルチメソッドが呼ばれます。
このマルチメソッドは次のように:fn
をキーとするマップを返す必要があります。
(defmethod ig/init-key :duct.module/message [_ options] {:fn (fn [config] (core/merge-configs config ; ユーザが書いたコンフィグレーションマップ {:duct.message/pubsub ; 追加するキー {:logger (ig/ref :duct/logger)}}))})
:fn
の設定すべき値は,コンフィグレーションマップを受取り新しいコンフィグレーションマップを返す関数です。
prep stepでのコンフィグレーションマップの書き換えは,:fn
の関数適用の結果です。
上のコードは,ユーザが定義したコンフィグレーションマップに:duct.message/pubsub
をキーとするエントリを追加しています。
merge-configs
は引数に渡されたマップをマージし,マージ後のマップを返します。
コンフィグレーションマップに追加したキーワード:duct.message/pubsub
を追加したので,
moduleには,このキーワードのマルチメソッドも含める必要があります。
(defmethod ig/init-key :duct.message/pubsub [_ {:keys [p-id s-id] :as opt}] (assoc opt :s-name (SubscriptionName/create p-id s-id)))
購読したいキューを指定するにはプロジェクトIDとサブスクリプションIDが必要なので,
ユーザにはduct.message/pubsub
の値にIDを書いてもらうようにします。
これでmoduleの出来上がりです。
最後に,作ったmoduleを実際に使ってみます。 まず,キューのIDとmoduleのキーワードを含んだコンフィグレーションマップを作ります。
{:duct.message/pubsub {:p-id "project-id" :s-id "subscription-id"} :duct.module/message {} :ebisu.boundary/message #ig/ref :duct.message/pubsub}
マルチメソッドでは,コールバックと購読の開始処理を書いています。
s-name
には,moduleにあるSubscriptionName/create
の返り値が渡ります。
(defprotocol Receiver (start [this]) (stop [this])) (defrecord PubSubReceiver [subscriber] Receiver (start [this] (. subscriber startAsync)) (stop [this] (. subscriber stopAsync))) (defmethod ig/init-key :clj-ebis2.boundary/message [_ {:keys [s-name]}] (let [subscriber (. (Subscriber/newBuilder subscription-name (reify MessageReceiver (receiveMessage [this message consumer] (println (.. message getData toStringUtf8)) (. consumer ack)))) build)] (let [receiver (->PubSubReceiver subscriber)] (start receiver) receiver)))
参考資料
- Duct Framework and supporting libraries
- Arachne: building a framework in Clojure
- Productive Duct
- Enter Integrant: a micro-framework for data-driven architecture with James Reeves
- Duct, Covered
- 本稿の前半でモジュールと表現しているIntengrantのマルチメソッドとは異なるものなので,英語表記にして両者を区別します。↩