CLOVER🍀

That was when it all began.

Typesafe Configと、ラッパーFicusで遊ぶ

前から名前は聞いたことはありましたが、使ったことのなかったTypesafeの提供するConfigでちょっと遊んでみました。

typesafehub/config(Configuration library for JVM languages)
https://github.com/typesafehub/config

Typesafeって、あのTypesafe社ですね。

Scala用のライブラリかと思いきや、Javaで実装されたものなので、ふつうにJavaから使えます。とはいっても、今回Javaからは呼んではいませんが…。

どんなものなのか?

Javaで設定ファイルといえば、Properties形式が有名(あとはXMLとか…)ですが、もう少しフォーマットが扱えます。

  • Propertiesライクな形式
  • JSON(拡張子を.jsonに)
  • HOCON(Human-Optimized Config Object Notation)

単純なPropertiesライクな形式で書いても、Listを返却することができたり、構造を扱えたりとそこそこ便利です。

その他、設定項目の変数展開、別ファイルのインクルードも可能だったりします。

使用する設定ファイルですが、デフォルトでは「application.conf」という名前のファイルを読みにいきますが、自分でファイル名を指定したり、Stringからパースすることも可能です。

Javadocは、こちら。

http://typesafehub.github.io/config/latest/api/

使ってみる

というわけで、使ってみましょう。
build.sbt

name := "typesafe-config-example"

version := "0.0.1-SNAPSHOT"

scalaVersion := "2.10.3"

organization := "org.littlewings"

scalacOptions ++= Seq("-Xlint", "-deprecation", "-unchecked")

libraryDependencies ++= Seq(
  "com.typesafe" % "config" % "1.0.2",
  "org.scalatest" %% "scalatest" % "2.0" % "test"
)

Scalaで試します。

こんなコードを用意しまして、中でいろいろ試していきます。
src/test/scala/org/littlewings/config/TypeSafeConfigSpec.scala

package org.littlewings.config

import com.typesafe.config.{Config, ConfigFactory}

import org.scalatest.FunSpec
import org.scalatest.Matchers._

class TypeSafeConfigSpec extends FunSpec {
  describe("typesafe-config spec") {
    // ここで、設定ファイルをロードして確認するテストコードを書く!
  }
}

では、順次試していきます。

デフォルトの設定ファイルを読む

デフォルトのファイル名は、「application.conf」ということでしたが、こんなProperties形式で用意してみます。
src/test/resources/application.conf

app.name=ConfigTest
multibyte.value=こんにちは 世界
int.value=10
strings=["foo", bar, hoge]

これに対する、テストコードはこちら。

    it("default configration load") {
      val config: Config = ConfigFactory.load()

      config.getString("app.name") should be ("ConfigTest")
      config.getString("multibyte.value") should be ("こんにちは 世界")
      config.getInt("int.value") should be (10)
      config.getStringList("strings") should contain theSameElementsInOrderAs Array("foo", "bar", "hoge")
    }

ConfigFactory#loadで、デフォルトの設定ファイルをロードします。ここを変えることで、他のファイルなどを読み込んだりすることが可能です。

ConfigFactory
http://typesafehub.github.io/config/latest/api/com/typesafe/config/ConfigFactory.html

ConfigFactory#loadの戻り値はConfigで、ここからgetStringやgetInt、getStringListなどで設定値を取得することができます。

Config
http://typesafehub.github.io/config/latest/api/com/typesafe/config/Config.html

なお、指定した項目名に該当する設定がなかった場合は、com.typesafe.config.ConfigException$Missingがスローされます。

JSON形式の設定ファイルを読む

今度は、設定ファイルをJSON形式で用意します。
src/test/resources/configtest.json

{
    "config": {
        "string": "こんにちは、世界",
        "array": [1, 2, 3],

        "more-nested": {
            "numeric": 10
        }
    }
}

これに対する、テストコードはこちら。

    it("json configuration load") {
      // ファイル名を指定
      val config = ConfigFactory.load("configtest.json")

      config.getString("config.string") should be ("こんにちは、世界")
      config.getIntList("config.array") should contain theSameElementsInOrderAs Array(1, 2, 3)
      config.getInt("config.more-nested.numeric") should be (10)

      // ネストした部分を、Configとしても取得できる
      val c = config.getConfig("config")
      c.getString("string") should be ("こんにちは、世界")
      c.getIntList("array") should contain theSameElementsInOrderAs Array(1, 2, 3)

      // Config#getListした場合は、Listを実装したConfigListが返却される
      // ConfigListの中身は、ConfigValueでunrappedで戻せる
      config.getList("config.array").unwrapped should contain theSameElementsInOrderAs Array(1, 2, 3)
      config.getList("config.array").get(0).unwrapped should be (1)
      config.getList("config.array").get(1).unwrapped should be (2)
      config.getList("config.array").get(2).unwrapped should be (3)
    }

先の例とは変わった試みとして、ネストされた構造をConfigとして取得したり、ConfigListを使用したりしています。

ネストされた構造をConfigとして取得した場合は、そこからgetStringしたりする場合は、相対表記になります。

Propertiesライクな形式を、もう少し

今度は、こんなファイルを用意。
src/test/resources/propertylike1.conf

ns.string.value=Hello World
ns.intvalue=20
ns.nested.intvalue=10
ns.nested.stringvalue=string
ns.nested.substitutions=${ns.string.value} ${ns.intvalue}

テストコード。

    it("property like configuration load") {
      // Property形式でも、ネストした構造を扱える
      val config = ConfigFactory.load("propertylike1.conf")

      val c = config.getConfig("ns")
      c.getString("string.value") should be ("Hello World")
      c.getInt("intvalue") should be (20)

      val nested = config.getConfig("ns.nested")
      nested.getInt("intvalue") should be (10)
      nested.getString("stringvalue") should be ("string")

      // 設定項目の展開も可能
      nested.getString("substitutions") should be ("Hello World 20")
    }

キーの命名さえ合わせておけば、Propertiesライクな形式でも、構造化して扱えます。また、ここでは変数展開の例も示しました。

HOCON(Human-Optimized Config Object Notation)形式の設定ファイルを読む

最後は、HOCON形式。

先ほどのページに、HOCONについてはいろいろ書かれています。

typesafehub/config(Configuration library for JVM languages)
https://github.com/typesafehub/config

JSONのスーパーセット?書き方もいろいろあるみたいなので、興味のある方は詳細をご覧になるとよいでしょう。コメントが書けたり、インクルードができたりするようです。

では、簡単な例でお試し。
src/test/resources/hocon.conf

hocon-config {
  name = "HOCON Presentation Configuration"
  int-value = 1

  nested {
    list-values = ["foo", "bar", "fuga"]
    numeric = 10.5
  }
}

利用する側は、特に変わりなく…。

    it("HOCON configuration load") {
      // HOCON(Human-Optimized Config Object Notation)という形式でも書けるらしい
      val config = ConfigFactory.load("hocon.conf")

      config.getNumber("hocon-config.nested.numeric") should be (10.5)

      val hc = config.getConfig("hocon-config")
      hc.getString("name") should be ("HOCON Presentation Configuration")
      hc.getInt("int-value") should be (1)

      hc.getStringList("nested.list-values") should contain theSameElementsInOrderAs Array("foo", "bar", "fuga")

      val nested = hc.getConfig("nested")
      nested.getNumber("numeric") should be (10.5)
    }

構文を覚える必要がありますが、これはこれで便利そう。どのくらい使われてるのかな?

Ficus

Typesafe Configは、完全にJava向けなので戻ってくるListなどの型はJavaのListだったり、Scala向けのものではありません。

今回、Typesafe Configのラッパーとして、Ficusというものを見つけたので、こちらでも遊んでみました。

Ficus
https://github.com/ceedubs/ficus/tree/master

同様のものとして、configzというものもあるそうな。

configz
https://github.com/arosien/configz

今回は、Ficusを使用します。

依存関係の定義などは、こんな感じ。
build.sbt

name := "ficus-example"

version := "0.0.1-SNAPSHOT"

scalaVersion := "2.10.3"

organization := "org.littlewings"

scalacOptions ++= Seq("-Xlint", "-deprecation", "-unchecked")

resolvers += "Sonatype OSS Releases" at "http://oss.sonatype.org/content/repositories/releases/"

libraryDependencies ++= Seq(
  "com.typesafe" % "config" % "1.0.2",
  "net.ceedubs" %% "ficus" % "1.0.0",
  "org.scalatest" %% "scalatest" % "2.0" % "test"
)

Sonatypeのリポジトリを加える必要があります。また、Ficusの依存しているTypesafe Configが1.0だったので、好奇心的に上げてみました…。

使用する設定ファイルは、すべて先ほどのサンプルと同じものとして、利用するコードだけFicusで置き換えます。

テストコードの骨格。
src/test/scala/org/littlewings/config/FicusSpec.scala

package org.littlewings.config

import com.typesafe.config.{Config, ConfigFactory}

import net.ceedubs.ficus.FicusConfig._
import net.ceedubs.ficus.SimpleConfigKey
import net.ceedubs.ficus.readers.ValueReader

import org.scalatest.FunSpec
import org.scalatest.Matchers._

class FicusConfigSpec extends FunSpec {
  describe("typesafe-config spec") {
    // ここに、Ficusを使ったテストコードを書く!
  }
}

使用するクラスにもよりますが、最低限以下はimportしておいてください。

import net.ceedubs.ficus.FicusConfig._

では、先ほどのサンプルを順次置き換えていきます。

    it("default configration load") {
      val config: Config = ConfigFactory.load()

      // as、getAs、applyのいずれかを使う
      config.as[String]("app.name") should be ("ConfigTest")
      config.getAs[String]("app.name") should be (Some("ConfigTest"))  // Optionになる
      config.apply[String](SimpleConfigKey("app.name")) should be ("ConfigTest")

      // Optionとして受けとることができる
      config.as[Option[String]]("multibyte.value") should be (Some("こんにちは 世界"))
      config.as[Int]("int.value") should be (10)
      config.as[List[String]]("strings") should contain theSameElementsInOrderAs Array("foo", "bar", "hoge")

      // Optionが使用できるため、存在しない項目に対しても安全
      an [com.typesafe.config.ConfigException$Missing] should be thrownBy config.as[String]("missing.entry")
      config.as[Option[String]]("missing.entry") should be (None)

      // Setとして受け取ることも可能
      config.as[Set[String]]("strings") should contain only ("foo", "bar", "hoge")
    }

TypesafeのConfigに対して、as、getAs、applyのいずれかで取得可能になります。getAsは、戻り値がOptionになります。

その他、ScalaのListやSetでも受け取れたりと、確かにScalaフレンドリーですね。

Optionが使えるため、存在しない項目はOptionで取得してハンドリングすることができます。

本家のサンプルコードがこちらにあるので、詳しく見たい方はそちらへ…。

https://github.com/ceedubs/ficus/blob/master/src/test/scala/net/ceedubs/ficus/ExampleSpec.scala

残りのサンプルも、置き換えましょう。

    it("json configuration load") {
      // ファイル名を指定
      val config = ConfigFactory.load("configtest.json")

      config.as[Option[String]]("config.string") should be (Some("こんにちは、世界"))
      // OptionのListとして受けることも可能
      config.as[Option[List[Int]]]("config.array") should be (Some(List(1, 2, 3)))
      config.as[Int]("config.more-nested.numeric") should be (10)

      // ネストした部分を、Configとしても取得できる
      val c = config.as[Config]("config")
      c.as[String]("string") should be ("こんにちは、世界")
      c.as[Set[Int]]("array") should contain only (1, 2, 3)

      // さすがに、ConfigListのサポートはなさそう…
      config.as[Array[Int]]("config.array") should contain theSameElementsInOrderAs Array(1, 2, 3)
      config.as[Array[Int]]("config.array").apply(0) should be (1)
      config.as[Array[Int]]("config.array").apply(1) should be (2)
      config.as[Array[Int]]("config.array").apply(2) should be (3)
    }

    it("property like configuration load") {
      // Property形式でも、ネストした構造を扱える
      val config = ConfigFactory.load("propertylike1.conf")

      val c = config.as[Config]("ns")
      c.as[String]("string.value") should be ("Hello World")
      c.as[Int]("intvalue") should be (20)

      val nested = config.as[Config]("ns.nested")
      nested.as[Int]("intvalue") should be (10)
      nested.as[String]("stringvalue") should be ("string")
      nested.as[String]("substitutions") should be ("Hello World 20")
    }

    it("HOCON configuration load") {
      // HOCON(Human-Optimized Config Object Notation)という形式でも書けるらしい
      val config = ConfigFactory.load("hocon.conf")

      // Doubleのような場合は、Numberではなくて具体的な型になるらしい
      config.as[Double]("hocon-config.nested.numeric") should be (10.5)

      val hc = config.as[Config]("hocon-config")
      hc.as[String]("name") should be ("HOCON Presentation Configuration")
      hc.as[Int]("int-value") should be (1)

      hc.as[Set[String]]("nested.list-values") should contain only ("foo", "bar", "fuga")

      val nested = hc.as[Config]("nested")
      nested.as[Double]("numeric") should be (10.5)
    }

さすがに、ConfigListのサポートはなさそうですね。

あと、拡張として、Configの内容を任意の型に変換することができます。今回はConfigを自分で定義したCase Classに変換しました。…まあ、元のサイトにそんなサンプルもありますけどね。

    it("using Case Class") {
      // 設定ファイルの内容を、独自のCase Classで扱うパターン
      val config = ConfigFactory.load("configtest.json")

      case class MyConfig(string: String, array: Array[Int], numeric: Int)

      // Implicit Valとして、ValueReaderを定義すれば任意の型に変換可能
      implicit val myConfigReader: ValueReader[MyConfig] = ValueReader.relative { config =>
        MyConfig(config.as[String]("string"),
                 config.as[Array[Int]]("array"),
                 config.as[Int]("more-nested.numeric"))
      }

      val myConfig = config.as[MyConfig]("config")
      myConfig.string should be ("こんにちは、世界")
      myConfig.array should contain theSameElementsInOrderAs Array(1, 2, 3)
      myConfig.numeric should be (10)
    }

実は、Config#asの後ろにImplicit Parameterが隠れていて、これに対して自分で定義したValueReaderを使わせるようにすれば、変換が可能になるというわけです。

      // Implicit Valとして、ValueReaderを定義すれば任意の型に変換可能
      implicit val myConfigReader: ValueReader[MyConfig] = ValueReader.relative { config =>
        MyConfig(config.as[String]("string"),
                 config.as[Array[Int]]("array"),
                 config.as[Int]("more-nested.numeric"))
      }

こんな感じで。

設定ファイルにはこれまであまり拘ってこなかったのですが、こういうのもいいのかなぁとちょっと思いました。

今回作成したサンプルは、こちらにアップしています。

https://github.com/kazuhira-r/typesafe-config-examples