UZABASE Tech Blog

株式会社ユーザベースの技術チームブログです。 主に週次の持ち回りLTやセミナー・イベント情報について書きます。

はじめてのDuct

 SPEEDA開発の中村です。今回の内容は,弊社主催のclj-ebisu #2で発表した「実践Duct(仮)」です。 ClojureのサーバサイドフレームワークDuctを業務で使って学んだことを紹介します。 connpass.com 勉強会で発表した資料はこちらです。

 はじめに,Ductのコアで使われているフレームワークIntegrantを紹介し, サーバサイドでIntegrantを使って感じた課題についてお話しします。 次に,課題に役立つDuctのmoduleのしくみと作り方を説明します。 想定読者は,Clojureを書いたことがあってDuctを使ったことがない方です。

目次

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}))

なんてことはないプレーンなコードですが,変数や関数のスコープが必要以上に大きいという問題があります。 mainhandlerに,handlerendpointに依存しており,本来mainからendpointが見える必要はありません。 しかし,mainendpointがグローバルなスコープを持っているため,お互いに参照できるようになっています。 関数のスコープは呼び出される関数だけから見える大きさで十分です。

 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/handlerget-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ではアプリケーションの初期化手順は

  1. (read step) コンフィグレーションマップを読み込み
  2. (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/messageduct/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)))

参考資料

  1. Duct Framework and supporting libraries
  2. Arachne: building a framework in Clojure
  3. Productive Duct
  4. Enter Integrant: a micro-framework for data-driven architecture with James Reeves
  5. Duct, Covered

  1. 本稿の前半でモジュールと表現しているIntengrantのマルチメソッドとは異なるものなので,英語表記にして両者を区別します。