component入門

背景

アプリケーションの中で一度だけ行いたい初期化・終了処理を書くことはよくあります. 例えば,データベースへの接続や外部リソースの初期化などです.

Clojureプログラムにおいて,このような初期化・終了の処理というのは意外と面倒です. 普通に走るプログラムであれば -main 関数などの開始時・終了時に処理を書けばよいのですが,ClojureプログラムはREPL環境で開発される場合が多いので,そうもいきません.

例えば,データベースへのハンドラを管理する場合を考えてみましょう.アプリケーションの開始時にコネクションを作成し,終了時に開放するとします.一番簡単なのは,varに束縛してしまうことです.

(def conn (create-db-conn))

(defn foo []
  ;; connを使う
  )

しかし,これだとconnの解放のタイミングがよくわかりません.REPLのセッション中ではずっとコネクションを維持したいですから,foo関数の中で開放してしまうわけに行きません.また,コードを更新後にリソース(上の例で言うconn)をREPL上で作り直したいこともあります.これは,手動で connの解放・再作成をすればOKですが面倒です.

さらに,このような初期化・解放処理が必要なリソースがプログラム中にいくつも存在する場合はどうでしょう?さらに,それらのリソース間に依存性があることも考えられます.例えば,データベースコネクションに依存したリソースはよくありそうですね.それらも含めてリフレッシュしたい場合は?あちこちの名前空間に多数のvarが存在するようになると,アプリケーションの開発効率とメンテナンス性は急速に悪化します.

結局のところ問題は,「アプリケーションの名前空間内にmutableな状態が存在する」というところにつきます.Clojureはimmutableなデータ構造を使ってプログラムを書けるのがよいところですが,時には状態を持ったオブジェクトを管理する必要もあるわけです.このような構造をアプリケーションを支援してくれるのが stuartsierra/component です.

概要

componentを使ったプログラミングは,大きくわけて2つの要素からなります.

コンポーネントは,オブジェクト指向でいうオブジェクトに近いものですが,通常のClojureアプリケーションではかなり粗粒度のオブジェクトになるでしょう.典型的には各ネームスペースに1つのコンポーネントを定義することが多いようです.またコンポーネント同士は依存関係を持つことができます.

システムはコンポーネントの集合体として定義され,コンポーネント間の依存関係を考慮した初期化・終了処理を実行してくれます.

使い方/各コンポーネントの構築

まずは,stuartsierra/componentをプロジェクトで利用できるようにしましょう. プロジェクトの依存関係に [com.stuartsierra/component “0.3.0”]を追加しておきます.

ここでは,架空のプロジェクトとして,DB接続を管理するDB component, 初期化・終了処理が必要な外部APIを管理するAPI coponent, そしてそれらを利用するcomponent Aがあるとします.

それぞれのcomponentは,component/Lifecycleプロトコルdefrecordを用いて定義します

;; DB接続を管理するComponent
(defrecord DBComponent [conn host port]
  component/Lifecycle

  (start [this]
    (println "Initializing DB component.")
    ;; DB接続の開始処理
    ;; ここでは仮に,"DB connection"という文字列を代入しておく
    (assoc this :conn (format "<DB connection: %s:%d" host port)))

  (stop [this]
    (println "Stopping DB component.")
    ;; DB接続の終了処理
    ;; 注意: this自身からフィールドをnilに設定して返すが,dissocをしてしまうと
    ;; 戻り値が普通のmapになってしまう
    (assoc this :conn nil)))

メインとなるのはstartstop関数で,これらを用いて初期化を行います. 引数として渡されるthisは,コンポーネントのベースとなるrecordが渡されます.アプリケーションの各コンポーネントは,thisに必要なデータを格納していくことでコンポーネントを構築していきます.

使い方/システムの構築

(defn app-system [config-options]
  (let [{db-host :db-host
         db-port :db-port
         api-host :api-host} config-options]
    (component/system-map
     ;; (B)
     :db (map->DBComponent {:host db-host :port db-port})
     :api (map->APIComponent {:host api-host})
     :A (component/using
         (map->ComponentA config-options)
         {:db  :db
          :api :api}))))

component/system-map関数を用いてシステムを構築します.system-map関数にはmapを渡しますが,これがシステム内でのキーとコンポーネントの関係を示します.各コンポーネントは,defrecord時に定義されるmap->xxx関数を使って構築されているのがわかると思います. component/usingは依存関係を定義する関数で,ここではComponentADBComponentAPIComponentに依存していることを示しています.

使い方/REPLでの活用法

REPLでの実行では,user名前空間にシステムをvarとして定義して使っていく方法がメインとなります.

;; systemを定義
(def system (app-system {:db-host "dbhost" :db-port 9999 :api-host "apihost"}))

(alter-var-root #'system component/start) ; systemを開始
;;Initializing DB component.
;;Starting API component
;;Initializing A: db-conn= <DB connection: dbhost:9999  api= <External API session host=apihost>



(alter-var-root #'system component/stop) ; systemを終了
;; Finalizing A
;; Stopping API component
;; Stopping DB component.

これによって,user/system にシステムが束縛されますので,systemを使ったプログラミングを行うことができます.それぞれのコンポーネントは,定義した当該のキーで取り出すことができます

user> (:db system)
#example-ns.DBComponent{:conn "<DB connection: dbhost:9999", :host "dbhost", :port 9999}

tools.namespaceとの連携によってより便利に使うことができますが,また次の機会に紹介したいと思います.

サンプル全体のソースコードこちらにあります.