はじめに
拙作ライブラリ Baum の紹介をしたいと思います。紹介記事というより、割と網羅的な日本語版ドキュメントになっています。 あまり新しいライブラリでもないので、ネタとして新鮮感はゼロですが、久しぶりに手を入れて先程 0.4.0 をリリースしたので少しだけ新しい話題もあります。
本当は nREPL ミドルウェアを自作する話でもしようと思っていたのですが、作っていたものが間に合わなかったので、またの機会にします(一生こないやつ)。
そして遅くなってしまってすみません。いつ投稿できそうかコメントだけ残そうと思ったら記事 URL を入力しないとコメント編集できず、ダミー URL を入力したら意図せず投稿扱いになってしまったので混乱を招いてしまったかもしれません…。(考えてみればそりゃそうだ)
Baum の概要
Baum は(主に)設定ファイルの記述のための宣言的な DSL ライブラリです。 Baum の特徴は以下の通りです:
- 拡張可能な DSL を EDN 上に構築するための基本的な仕組みの提供
- readers / reducers
- 基本的なビルトイン実装の提供
- Java Properties, 環境変数の参照
- 外部ファイルのインポート
- ローカル変数(let)
- グローバル変数(ホスト名など)
- 条件分岐(if, match)
Baum を使うと特別なコードを書くことなく、以下のことが比較的容易に実現できます:
- 環境変数の値やホスト名などの環境に応じた設定の切り替え
- 設定ファイルの分割
- 各開発者のローカルでのみ有効な設定のロード
- etc..
加えて、少しコードを書くことで、よりアプリケーションのドメインに特化した独自 DSL を構築することも出来ます。例として、私は過去に以下のような拡張を行った経験があります:
- ある特定の環境変数の値によって設定を切り変えるための糖衣構文
- 指定した範囲の空いている port を自動的に採用する命令
- EDN で記述された設定の一部を JSON に変換して設定値とする命令
- etc
インストール
Baum がクラスパス上に配置されていることが前提です。なお、本記事は 0.4.0 ベースで執筆しています。
[rkworks/baum "0.4.0"]
ファイルの読み込み
設定ファイルの読み込みは baum.core/read-file
を使います。 read-file
はパース結果をそのまま返すので、一度読み込んだらあとは標準関数でよしなに処理してください。
(ns your-ns
(:require [baum.core :as b]))
(def config (b/read-file "path/to/config.edn")) ; a map
(:foo config)
(get-in config [:foo :bar])
上の例ではファイルパスが渡されていますが、内部的に slurp
を使っているので slurp
が受けつけるものならなんでも良いです。例えば (clojure.java.io/resource "config.edn")
を渡せばクラスパス上の config.edn
が読まれることになります。
エイリアスについて
Baum では EDN ファイル中で変換のトリガとなる特殊なキーワードやシンボルについてはネームスペースを付けています。(e.g. :baum/override
, #baum/env
)
ただし、より簡潔に書くためにエイリアスが設定できます。また、エイリアスのプリセットが提供されており、デフォルトで有効になっています。この記事で扱う例では原則エイリアスを用いませんが、必要に応じて README を参照して下さい。 -> Built-in shorthand notation
以下はエイリアスを用いた場合の例です:
{$override* "config-local.edn"
:db {:host #env [:db-host "localhost"]
:port #env [:db-port 1234]}}
ちなみに私個人は常にエイリアスの方を使っています。
設定ファイルの記述
Baum はあくまで EDN の内部 DSL なので、シンタックスは EDN に準じます。tagged literal や、後述する reduction という変換を駆使することで表現力を高めています。これら2つの変換については後ほど詳しく見ていくとして、ここではまず簡単に例を見てみましょう。
{:db {
;; デフォルト設定
:adapter "mysql"
:database-name "baum"
:server-name "localhost"
:port-number 3306
:username "root"
:password nil
;; 環境変数 ENV の値によってデフォルト設定の一部を上書きする
:baum/override
#baum/match [#baum/env :env
;; production 環境固有の設定
"prod" {
:database-name "baum-prod"
;; 環境変数 DATABASE_HOST が定義されていればそれを使い、
;; なければ localhost を使う
:server-name #baum/env [:database-host "localhost"]
;; 上と同様
:username #baum/env [:database-username "root"]
;; fallback 値を指定しなければ nil になる
:password #baum/env :database-password}
;; dev 環境固有の設定
"dev" {:database-name "baum-dev"}
;; test 環境固有の設定
"test" {:adapter "h2"}]}}
何となくは読めるのではないかと思いますが、葉側から順に見ていきましょう。
#baum/env
は reader (tagged literal) と呼ばれるものです。パース時に #baum/env
とその隣の式は何らかのデータに展開されます。 #baum/env
の場合は指定したキーワードに対応する環境変数の内容に展開されます。ベクタを渡した場合は、最後が fallback 値として用いられます。ちなみに、「環境変数」と書きましたが、実際は内部的に Environ を用いているので、 Environ が対応しているソースなら Java properties など何でも大丈夫です。
#baum/match
はパターンマッチングを行なうための reader です。ここでは環境変数 ENV の値に応じて設定を選択しています。
:baum/override
は reducer と呼んでいるもので、これは reader とは異なり Clojure 標準の概念ではありません(Clojure の Reducers とは異なります)。reducer は後ほど紹介しますが、ここでは :baum/override
キーで指定したマップが、キーの所属しているマップ(ここではデフォルト設定)にマージされることだけご理解ください。
ところで、Baum の DSL は拡張可能であることを述べましたが、実際に私は上の例のような設定をより簡単に書くための設定を追加して使っています。細部は異なりますが、おおよそ以下のようなものです:
{:db {
;; デフォルト設定
:adapter "mysql"
:database-name "baum-dev"
:server-name "localhost"
:port-number 3306
:username "root"
:password nil
;; 環境によってデフォルト設定の一部を上書きする
:baum/override
#my/env-case {:prod {:database-name "baum-prod"
:server-name #baum/env [:database-host "localhost"]
:username #baum/env [:database-username "root"]
:password #baum/env :database-password}
:else {:adapter "h2"}}}}
#my/env-case
が新たに独自追加した reader で、ステージを示す環境変数を決め打ちにすることで少しだけシンプルにしています。このように reader や reducer の独自実装の追加は簡単できます。 #my/env-case
を実装する方法は本記事の Cookbook の中でも紹介しています。
なんとなく雰囲気は掴んでいただけたでしょうか。次はもう少し細かい仕様的な話をしようと思います。
Baum における二種類の変換
Baum では二種類の変換 reducers と readers を用いて DSL を構築します。
Reducers
Reducers はマップのキーをトリガーにして何らかの変換を行なうものです(名前が良くないのですが、Clojure コアの Reducers とは関係ありません)。例えば上で紹介した例に含まれていた :baum/override
は次のような変換を行ないます。
{:baum/override {:a :b'}
:a :b
:c :d}
;; 上のマップは最終的に次のような構造に変換される
{:a :b'
:c :d}
;; 要は、以下のコードような変換が起きる(正確には単なる merge ではない)
(merge {:a :b :c :d} {:a :b'})
カスタム reducer の実装
簡単な reducer を実装してみるとより分かりやすいと思います。ここでは以下のような reducer を実装することを考えます:
{:my/narrow [:a :c]
:a :foo
:b :bar
:c :baz
:d :qux}
;;; ↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓↓
{:a :foo
:c :baz}
要は clojure.core/select-keys
です。これを実装するためには以下のようにします:
(ns your-ns
(:require [baum.core :as b]))
(defn narrow [m v opts]
(select-keys m v))
;; 作成した reducer を有効にしてファイルを読み込む
(b/read-file "config.edn"
{:reducers {:my/narrow narrow}})
m
には自身のキー :my/narrow
を除くマップが渡され、 v
には :my/narrow
と対になっている value が渡されます。また、 opts
は read-file
に渡したオプション(デフォルト値を含む)が入ります。つまり、この reducer を上の例で示したデータに対して適用すると、関数 narrow
は以下のように呼ばれることになります。
(narrow {:a :foo :b :bar :c :baz :d :qux}
[:a :c]
{...})
Readers
Readers はタグ付きリテラル(tagged literals)、あるいはリーダーマクロとも呼ばれるものです。ご存知の通り、Clojure 標準で代表的なものには #inst
があります。これはパース時に展開されます。例えば、数値をインクリメントする #inc
という reader があったとして、設定ファイルに #inc 10
と書いてあれば、それは 11
に展開されることになります。
Readers の展開タイミング
鋭い方はお気付きかもしれませんが、reader の展開は EDN のパース中に行われます。一方で、reducer はパースが終わった後で適用されます。 しかし、Baum において reducer と reader はあくまで同レベルの言語コンストラクトであり、組み合わせて用いた場合でも直感的な順序で展開されて欲しいわけです。
そこで、内部的には reader はパース時に reducer に変換されるようになっています。パースが終わった後、reducer としてまとめて処理されます。
以下の例では、 :my/reducer
の後に #my/reader
が展開されます。正格評価のアナロジーとして考えれば自然に捉えられるでしょう。
{:a #my/reader {:my/reducer _}}
カスタム reader の実装
Reducer に続き、reader についても実装してみましょう。Reader の実装には defreader
マクロを利用します。 defreader
マクロで定義した reader は、前述した reader
から reducer
への変換を自動的に行なってくれます。本当にリード時に行いたい処理がある場合以外は原則 defreader
を用いたほうが良いでしょう。
ここでは文字列を upper case に変換する簡単な reader を作成してみましょう:
{:foo #uppercase "Hello World"} ; => {:foo "HELLO WORLD"}
実装は以下のようになります:
(ns your-ns
(:require [baum.core :as b]))
(b/defreader uppercase-reader [v opts]
(.toUpperCase v))
;; カスタム reader を有効にしてファイルを読み込む
(b/read-file "config.edn"
{:readers {'uppercase uppercase-reader}}) ; => {:foo "HELLO WORLD"}
;; カスタム reader を有効にする別の方法
(binding [*data-readers* (merge *data-readers*
{'uppercase uppercase-reader})]
(b/read-file "config.edn"))
defreader
で作成した reader は通常の Clojure の reader と同様に、ダイナミック変数 *data-readers*
や、 data_readers.clj
経由で有効にすることも出来ます(ただし、 defreader
で定義したものは、 先述の通り、一度 reducer に変換されるので、グローバルに有効にして Clojure のリーダーで使ってもあまり意味はないです)。
マージ戦略とその制御
外部ファイルを読み込む場合や、環境によって設定の一部を上書きする場合など、複数のデータをマージするケースがあります。Baum では単に Clojure の merge を呼び出すのではなく、独自の戦略に従って再帰的に merge します。
デフォルトのマージ戦略
デフォルトのマージ戦略は以下の通りです:
- left, right 両方マップならマージする
- マップの子要素に対しても再帰的にマージ
- そうでなければ right をとる
deep merge を実装する際に最も悩ましい点は nil をどのようにハンドリングするかです。left がマップで、right が nil だった場合、マージするべきか nil を返すべきかは場合によりけりです。deep merge が Clojure のコアに実装されないのは、誰にとっても明快なセマンティクスを定義することが難しいからだったと記憶しています[要出典]。
Baum では nil を特別扱いせず right の値を優先し、nil をそのまま返すようにしています。勝手にマージされてしまうと、本当に nil で上書きしたい場合にそうする手段がなくなってしまうためです。その逆であれば空のマップを指定すれば最終的に left の値が残ることになるので、こちらのほうがユーザーにとって選択の幅が広いと言えます。
マージ戦略の制御
バージョン 0.4.0 からマージ戦略をメタデータによって制御する仕組みが追加されました。これは Leiningen の同様の機能にインスパイアされたものですが、細かい挙動は異なります。
優先順位の制御
Leiningen と同様、 :replace
, :displace
を用います:
{:a {:b :c}
:baum/override {:a {:d :e}}}
;; => {:a {:b :c, :d :e}}
{:a {:b :c}
:baum/override {:a ^:replace {:d :e}}}
;; => {:a {:d :e}}
{:a ^:displace {:b :c}
:baum/override {:a {:d :e}}}
;; => {:a {:d :e}}
:replace
をメタデータとして right に付与すると、マージせず常に right が採用されることになります。
:displace
を left に付与すると、right が存在しなければそのまま left が採用されますが、right が存在すれば常に right がそのまま採用されます。
コレクションの結合
Leiningen と異なり、Baum でデフォルトでマージするのはマップだけです。ベクタやセット同士のマージでは単に right が採用されます。コレクション同士を結合したい場合は :append
, :prepend
を用います:
{:a [1 2 3]
:baum/override {:a [4 5 6]}}
;; => {:a [4 5 6]}
{:a [1 2 3]
:baum/override {:a ^:append [4 5 6]}}
;; => {:a [1 2 3 4 5 6]}
{:a [1 2 3]
:baum/override {:a ^:prepend [4 5 6]}}
;; => {:a [4 5 6 1 2 3]}
{:a #{1 2 3}
:baum/override {:a ^:append #{4 5 6}}}
;; => {:a #{1 2 3 4 5 6}}
Cookbook
ここでは、Baum が提供するビルトインの reducer/reader の紹介も兼ねて、いくつかのありがちなユースケースにどう対応すればよいか、ということを見ていきたいと思います。
Baum ではカスタマイズ性を主眼に置いているため、デフォルトであらゆるユースケースに対応することはゴールにしていませんが、それでも大抵のユースケースはカバーできるだけのビルトイン reducers/readers が提供されています。ここで全て列挙することはしないので、必要に応じて README を参照してください。 -> Built-in Reader Macros, Built-in Reducers
環境変数や Java properties、project.clj の値を参照する
#baum/env
, #baum/read-env
を用います:
{:foo #baum/env :user} ; => {:foo "rfkm"}
{:foo #baum/read-env :port} ; => {:foo 8080}
内部的に Environ を利用しているので、環境変数、Java system properties、 project.clj
のいずれからも読み出せます。詳しくは Environ のドキュメントを参照してください(特に project.clj
の値を読み出すためには Leiningen のプラグインの導入が必要です)。
#baum/read-env
が #baum/env
と違うのは、読み込んだ文字列を Baum 形式のデータとしてパースする点です。
ベクタを渡した場合は左から順に読み出し、最初に見つかったところの値が採用されます。また、最後の値はデフォルト値として使われます。
#baum/env [:non-existent-env "not-found"] ; => "not-found"
#baum/env [:non-existent-env :user "not-found"] ; => "rfkm"
#baum/env ["foo"] ; => "foo"
#baum/env [] ; => nil
#baum/read-env
を用いた場合でも、デフォルト値はパースされずそのまま採用されるので注意してください。
設定ファイルを分割する
以下のいずれかを用います:
#baum/import
#baum/import*
:baum/include
:baum/include*
:baum/override
:baum/override*
末尾にアスタリスクがついているものは、読み込み対象が存在しなくてもエラーにならないバージョンです。
#baum/import
#baum/import
を指定されたファイルを読み込みその内容で置きかえます:
{:foo #baum/import "sub.edn"}
;; => {:foo {:sub-key :sub-val}}
指定できる対象は、 baum.core/read-file
、すなわち slurp
が読み込めるものです。また、ベクタを渡すことで複数の読み込み対象を指定できます(これらはマージされます):
{:foo #baum/import ["sub.edn" "sub2.edn"]}
クラスパス上のファイルを読みたい場合は #baum/resource
を併用します:
{:foo #baum/import #baum/resource "config.edn"}
:baum/include
, :baum/override
:baum/override
は既に簡単に紹介していますが、これは外部ファイルの読み込みにも利用出来ます。
以下は :baum/override
を用いる例です:
{:baum/override "sub.edn" ; {:a :b'}
:a :b
:c :d}
;; => {:a :b' :c :d}
マップを渡した時はそのデータがそのままマージされますが、その他の読み込み可能な対象が渡された場合はその内容を読み込んでからその結果をマージします。
なお、 :baum/include
と :baum/override
の違いはマージ順序が異なる点だけです。 :baum/include
を用いた場合、上記例の読み込み結果は {:a :b :c :d}
になります。
パスの解決
読み込み対象として、相対パスを指定することもできます。パスは文脈を読んで解決されるため、可搬性が高くなります。 基本的に相対パスを用いることをおすすめします。
読み込み元のファイルがファイルシステム上にある場合、例えば /foo/bar.edn
にあるとき、 ./baz.edn
を読み込めば /foo/baz.edn
が読み込まれます。
一方、読み込み元のファイルが Jar ファイル内にある場合、例えば jar:file:/foo/bar.jar!/foo/bar.edn
にある場合は、 ./baz.edn
は jar:file:/foo/bar.jar!/foo/baz.edn
に解決されます。
これにより、開発時でも Jar に固めた後でも正しくファイルが読み込まれます。パスの解決ルールについて、詳しくは README を参照してください。 -> Context-aware path resolver
開発者個人の設定ファイルを読み込む
データベースの設定など、開発者ごとにローカルマシンでだけ一部の設定を上書きしたいことがあります。これは :baum/override*
を用いて実現できます:
;; resources/config.edn
{:baum/override* "./config-local.edn"
:db {:host "db.example.com"
:port 60082
:user "dbuser"
...}}
;; resources/config-local.edn
{:db {:user "rfkm"
:password "foobar"}}
先述の通り、 :baum/override*
のようにアスタリスクのついたものはファイルが存在しなくてもエラーになりません。 config-local.edn
は gitignore に追加するなどして、必要に応じて各個人がファイルを作成するようにすれば良いでしょう。
変数を使う
:baum/let
を用いて設定の一部を束縛して再利用出来ます:
{:baum/let [env #baum/env [:app-env "default"]
env-file #baum/str ["config-" #baum/ref env ".edn"]]
;; APP_ENV=dev のとき、config-dev.edn が読み込まれる。
;; APP_ENV が設定されていない時は config-local.edn が読み込まれる。
:baum/include #baum/ref env-file}
束縛した変数は #baum/ref
で参照できます。スコープは :baum/let
の所属するマップとその子供です。一見レキシカルスコープですが、実際はダイナミックスコープ的な動作で、外部ファイルを参照した場合、その中でもその変数を参照できます。ここは隠すべきかどうか悩みましたが、shadowing もできますし、意図せず参照してしまうケースはほぼないだろうと判断して、これで良しとしています。
ホストに応じた設定を行なう
#baum/ref
は :baum/let
で束縛した内容だけでなく、事前定義されたホスト名など取得にも利用できます:
{:hostname #baum/ref HOSTNAME} ; => {:hostname "foobar.local"}
現時点で事前定義されてるものは以下になります:
Symbol | Summary |
---|---|
HOSTNAME | host name |
HOSTADDRESS | host address |
また、マルチメソッド refer-global-variable
を実装することで拡張できます:
(defmethod c/refer-global-variable 'HOME [_]
(System/getProperty "user.home"))
これを使えばホスト名に応じた設定を行なうこともできます:
{:db {...
:host nil
...
:baum/override
#baum/match [#baum/ref HOSTNAME
#"MYAPP\d+" {:host "db.app.example.com"}
#"MYAPP_HONEYPOT\d+" {:host "db.honeypot.example.com"}]}}
#baum/match
は内部的に core.match を使っているので正規表現を使ったマッチングもできます。(ただし、正規表現リテラルは EDN では使えません。設定で EDN-only リーダーを使わないようにするか、代わりに #baum/regex
を使って下さい)
Clojure コードを埋め込む
宣言的なスタイルを崩すことになるので、出来るだけ避けたいところですが、必要な場合は Clojure コードを埋め込むこともできます:
{:ttl #baum/eval (* 30 60 1000)} ; 30 minutes
標準の #=
記法も使えますが(正確には設定によります)、これは Baum の実装が使われないため、pure な Clojure コードに対してのみ利用できます。以下のように Baum の変換と組みあわせた式は評価できませんので注意して下さい:
;; 動かない
{:baum/let [min 30]
:ttl #=(* #baum/ref min 60 1000)} ; 30 minutes
カスタム reader/reducer からビルトイン reader/reducer を呼び出す
カスタム reader/reducer の実装方法は既に紹介していますが、ここではその実装の中でビルトイン reader/reducer を呼び出す方法を紹介します。環境によって設定を切り替えるための糖衣構文を作成する例を紹介しましたが、それを実際にどうやって実装するかを見ていきます。
ここでは次のような reader を作りたいと思います:
;; APP_ENV=prod の時 80, APP_ENV=dev の時 3000, それ以外の時は 8080 になる
{:port #my/env-case {:prod 80
:dev 3000
:else 8080}}
これに相当するものをビルトイン reader だけで再現すると次のようになります:
{:port #baum/match [#baum/env :app-env
"prod" 80
"dev" 3000
:else 8080]}
#my/env-case
の実装は次のようになります:
(b/deflazyreader env-case-reader [clauses opts] ; (1)
(-> (concat [(keyword (b/env-reader :app-env))] ; (2)
(apply concat (dissoc clauses :else)) ; (3)
[:else (:else clauses)]) ; (3)
vec
b/match-reader)) ; (4)
- いきなりまだ紹介していないものがでてきましたが、今回は
defreader
ではなくdeflazyreader
を用いなくてはなりません。defreader
とdeflazyreader
の関係は、Clojure で言えば関数とマクロの関係に近いものです。defreader
では既に展開済みのデータが入力として渡されますが、deflazyreader
の場合は展開前のデータが渡されます。今回のケースでは、マッチしない節の設定は評価されて欲しくないので、deflazyreader
を用います。 -
baum.core/baum-reader
は#baum/env
の実装です。普通に関数として呼びだせば reader によって展開されたデータが帰ってきます。ただし、繰り返しになりますが、reader はその場で最終目的のデータに展開されるのではなく、reducer を経由するので、戻値は「環境変数の中身」ではなく、「環境変数を読みにいく reducer」になります。 -
:else
まわりでごちゃごちゃやっているのは、core.match
が:else
節は最後にないとエラーになるため、必ず末尾に置くように変換しています。 -
#baum/match
に渡すベクタが作成できたのでそれをbaum.core/match-reader
に渡します。
おわりに
長くなりましたが、Baum の紹介を一通り行ないました。簡単な設定に使うのにはオーバースペックに思えるかもしれませんが、プロジェクトの成長に応じて拡張していけることが強みだと思っています。よかったら使ってみてください。フィードバックなどお待ちしています。