コンフィギュレーションが Scala で書ける型安全+純粋関数型な Configuration as Code ライブラリ Ciris について。
Ciris の動機
そもそも設定ファイルをプログラムとは別の言語で書いて別扱いする理由とは何だったか?
「再コンパイルせずにすむ」というのが、よくある答えだと思う。特に、コンパイル済みの実行可能ファイルが配布され、エンドユーザ環境でインストールされるようなプロダクトだと、再コンパイルせずにアプリケーションの挙動を変える需要は確かにある。
ただし設定ファイルがプログラムと同じコードベースでバージョン管理され、レビューされ、デプロイされるような使われ方の場合、事実上ソースコードと変わらないライフサイクルになって「再コンパイル不要」は大きなメリットではなくなる。となると、例えば JSON や HOCON で resources フォルダ下の application.conf などに書いたりしなくても、普通の Scala コードでも良いことにもなる。
Typelevel の Ciris は、この観点で configuration file と configuration as code1 を対比して、後者を Scala で実装したライブラリになる。
また環境変数や外部サービス2などから実行時に値を取得したり、ローカル/テスト/プロダクトなど環境ごとに設定値を切り替えたりする仕組みは依然として必要となるが、これも Ciris で提供される。
さらに Scala であることから、書きっぷりの自由度が高く柔軟に記述でき、既存のライブラリを活用したコンパイル時の型安全も手厚く提供される。
以下、コードを書きながらざっと紹介してみる(※ ライブラリのバージョンなどはここ)
基本的な使い方
古式にならって Hello World を書いてみる。もしも環境変数 NAME
に値が設定されていればその名前への挨拶、なければエラーメッセージを標準出力するという仕様にしてみる。
まず従来の設定ファイルに相当するデータを、以下のような Config
型で表現する3。このConfig#name
に環境変数 NAME
を反映させたい。
case class Config(name: String)
以下のような値で、環境変数 NAME
へのアクセスが表現できる。
val entry: ConfigEntry[Id, String, String, String] = env[String]("NAME")
entry
の型をあえて日本語にすると、「文脈Id
で、String型のキーを使って、String型の環境変数を得て、String型にデコードした設定項目」といったものになる。
env
は環境変数にアクセスしているが、プログラム引数用の arg
、Java のシステムプロパティ用の prop
などもある。また Id
の代りに抽象化した F[_]
が指定できるピュア版の envF
、argF
、propF
もある(後述)。
ちなみに、型注釈を env[Option[String]]
として Option#getOrElse
と併用してデフォルト値を指定したり、env[T]("FOO_KEY").orElse(prop["foo.key"]).orNone
のように第二候補以降を指定するコードも自然な Scala コードで書ける。
また秘匿情報へのアクセス用途で、Kubernetes secret 用に ciris-kubernetes、credstash 用の ciris-credstash、AWS Systems Manager パラメータストア用 の ciris-aws-ssm などが、サードパーティから提供されている。
この entry
を使って、以下のようなコードで Config
を組み立てる
val result: ConfigResult[Id, Config] = loadConfig(entry) { name =>
Config(name)
}
loadConfig
の最初のパラメータリストで ConfigEntry
を並べて(ここでは簡単のため一個だけ)、次のパラメータで、得られた設定値を使ってコンフィギュレーション全体を組み立てる関数を与える。また (A1, A2,..., Ai) => Z の形の関数ではなく、(A1, A2,..., Ai) => ConfigResult[F, Z] となる関数を渡せる withValues
メソッドもあり、環境ごとに設定を変える場合などに使われる(後述)。
これを使って最終的に設定情報を扱うコードは以下のように書ける。
val config: Id[Either[ConfigErrors, Config]] = result.result
val greeting = config.fold(_.message, c => s"Hello, ${c.name}!")
println(greeting)
実行すると、もし環境変数 NAME
に設定値が存在し、例えばそれが "World" なら "Hello, World!"、もし設定されていなければ "- Missing environment variable [NAME]." といったエラーメッセージが標準出力される。
環境の切り替え
環境ごとの設定値切り替えも元々の設定ファイルの目的の一つだった。
Ciris では、環境変数 APP_ENV などに実行環境を設定して切り分けるような方針になる。上の Hello World を改変して、APP_ENV=PRODUCTION なら環境変数 NAME の値、そうでなければ固定値 "local" を用いるコンフィギュレーションを書いてみる。
case class Config(name: String)
val config: ConfigResult[Id, Config] =
withValues(env[Option[String]]("APP_ENV")) {
case Some("PRODUCTION") => loadConfig(env[String]("NAME"))(Config)
case _ => loadConfig(Config("local"))
}
def main(args: Array[String]): Unit = {
val greeting = config.result.fold(_.message, c => s"Hello, ${c.name}!")
println(greeting)
}
ここで上で少し触れた withValues
を使っている。ただの Scala コードなので如何様にも書ける自由度がある。ちなみに簡単のため、ここでは APP_ENV の型を文字列としたが、Ciris のドキュメントでは列挙値を使うことが推薦されている。
Cats Effect / Monix Task との連携
Ciris では zero dependency のため、Apply
や Sync
など Cats にすでにある型クラスが、自前で実装されているが、ciris-cats, ciris-cats-effect モジュールを組み込むと、Cats版のいくつかの型クラスが Ciris 版の同名型クラスと同等に扱えるようになる。
従って、Cats Effect Sync
のインスタンスがある型、例えば Cats Effect IO
や Monix Task
などは、Ciris の Sync
としても使えるようになる。
たとえば、上の Hello World の Monix Task 版は下のように書ける。
object Main extends TaskApp {
def config[F[_]: CApplicative] = loadConfig(envF[F, String]("NAME"))(Config)
def program[F[_]: Sync](c: Config) = Sync[F].delay { println(s"Hello, ${c.name}!") }
def run(args: List[String]): Task[ExitCode] = for {
r <- config[Task].result
x <- r.fold(
e => Task(println(e.message)) as Error,
c => program[Task](c) as Success
)
} yield x
}
当然、IO
でも同様に書ける。
Refined, Squants, Spire, Shapeless との連携
型安全を求めてやまない Scala プログラマとしては、もしもコンフィギュレーションが成功裏に得られたならば、その時点ではもう不正な値が存在し得ないことが、できれば型として表現され保証されていてほしい。Ciris ではそうした型安全を実現するライブラリとの連携モジュールが提供されている。
以降、Scala Worksheet でサンプルを書いてみる。
ソースGist
Refined
Refined は型に制約をつけて洗練させるライブラリで、以下のように Ciris と連携できる。
type NonSystemPortNumber = Int Refined Interval.Closed[W.`1024`.T, W.`65535`.T]
val rType = ConfigKeyType[String]("refined key")
ConfigSource.fromEntries(rType)("key" -> "123")
.read("key").decodeValue[NonSystemPortNumber].result
// Left(ConfigErrors(WrongType(key, ConfigKeyType(refined key), Right(123), 123, Refined[Int, Closed[Int(1024),Int(65535)]], Left predicate of (!(123 < 1024) && !(123 > 65535)) failed: Predicate (123 < 1024) did not fail.)))
ConfigSource.fromEntries(rType)("key" -> "8080")
.read("key").decodeValue[NonSystemPortNumber].result
// Right(8080)
例としてこのスライドのポート番号の型を参考にした。"123" では範囲外なので Left値になるが、"8080"なら正しく値が得られる。
Squants
Squants は、各種の量と単位を提供するライブラリで、以下のように Ciris と連携できる。
val qType = ConfigKeyType[String]("squants key")
ConfigSource.fromEntries(qType)("key" -> "3°C")
.read("key").decodeValue[Temperature].result
// Right(3.0°C)
ConfigSource.fromEntries(qType)("key" -> "3J")
.read("key").decodeValue[Temperature].result
// Left(ConfigErrors(WrongType(key, ConfigKeyType(squants key), Right(3J), 3J, Temperature, QuantityParseException: Unable to parse Temperature:3J)))
ConfigSource.fromEntries(qType)("key" -> "3J")
.read("key").decodeValue[Energy].result
// Right(3.0 J)
"3°C" は温度の正しい設定値として解釈されるが、"3J" は温度設定値としては解釈できない。ただしエネルギーとしては "3J"は正しい設定値となる。
Spire
Spire は数値型のライブラリで、以下のように Ciris と連携できる。
val sType = ConfigKeyType[String]("spire key")
ConfigSource.fromEntries(sType)("key" -> "123/456")
.read("key").decodeValue[Rational].result
// Right(41/152)
ConfigSource.fromEntries(sType)("key" -> "123/0")
.read("key").decodeValue[Rational].result
// Left(ConfigErrors(WrongType(key, ConfigKeyType(spire key), Right(123/0), 123/0, Rational, IllegalArgumentException: 0 denominator)))
"123/456" は有理数の正しい設定値と解釈されるが、"123/0" は不正であると判別される。
Shapeless
Shapeless と Ciris の連携では以下のように Coproduct が使える。
val gType = ConfigKeyType[String]("generic key")
ConfigSource.fromEntries(gType)("key" -> "5.0")
.read("key").decodeValue[Float :+: String :+: CNil].result
// Right(Inl(5.0))
ConfigSource.fromEntries(gType)("key" -> "abc")
.read("key").decodeValue[Float :+: String :+: CNil].result
// Right(Inr(Inl(abc)))
ConfigSource.fromEntries(gType)("key" -> "123")
.read("key").decodeValue[String :+: Float :+: CNil].result
// Right(Inl(abc))
左から順に見て、最初にマッチした型が使われるらしい。
所感・補足
- センシティブな秘匿設定情報が生でログ出力されないようにする仕組みなども提供されている。
- 公式サイトでは、独自のコンフィギュレーションソースや、独自のデコーディングの書き方なども解説されている。
- どうしても「設定ファイル」でなければならない縛りが特になければ、この Ciris などを使った configuration as (Scala) code で良いのではないかと思う。
- それでもやはり、JSON や HOCON といった従来の設定ファイルを使うべき事情があるとしたら、Ciris の作者自身のブログ記事で紹介されている PureConfig + Refined などの手法が良いかもしれない