Dhallによるリッチな設定ファイル体験

(この記事のレギュレーション: lts-11.9)

Dhall という設定記述用言語があり、使ってみたところ良い感じだったので紹介します。

なお、この記事は先日某所で発表したものの拡大版になります。

speakerdeck.com

Dhallとは何か

Dhallについて短かく表現するなら公式サイトの以下の説明が分かりやすいです。

You can think of Dhall as: JSON + functions + types + imports

データ表現にプログラマブルさと静的な検査とファイルのインポートを加えたものというわけです。

まだ開発中のためかあまりアピールされていませんがツールチェインも充実しており、 ちょっとした処理を確かめるためのREPLや、 今どきの言語らしく公式フォーマッタもあります。

あと大事なのはチューリング完全ではないということです。 具体的にはループなどは書けません。

設定ファイルの役割というものはソフトウェアの成長に従って肥大化していく宿命にあり、 最初は単純なJSONYAMLで済んでいたものの上に独自形式のマクロが追加され、 単純な文字列置き換えで済んでいるうちはいいものの要求の向上に従ってマクロの役割は増えていき、 屋上屋を重ねた末にはシンタックスハイライトが効かない・実行時にならないとどういう設定になるか分からない・下手すれば無限ループして停止しない設定ファイルができあがってしまいます。

Dhallは意図的にパワーを抑えて設定ファイルの領分を守りながらも「簡単な関数くらいは使いたいよね」という要望に応えてくれる、 とても良いバランスを達成していると思います。

Dhallはすでにいくつかの 導入事例 もまとまっており、プロダクションで十分に利用可能と言えるでしょう。 (僭越ながら先日私も会社で使われている長大なYAMLをDhallから生成する仕組みを作りました。 とても便利)。

導入方法

Dhallには dhall コマンドなどのコマンドラインツール群と、 dhall-json や dhall-to-cabal といったアプリケーション、 そして各プログラミング言語から Dhall ファイルを読み込むためのいくつかのバインディングがあります。 サポートされている言語には今のところ Haskell と Nix があり、他にも Scala と Rust が開発中のようです。

Dhallコマンドラインツール

Stackがない場合はインストールします。 例として Un*x の場合:

$ curl -sSL https://get.haskellstack.org/ | sh
$ stack setup --resolver=lts-11.9

必要に応じて ~/.local/bin にPATHを通してください。

Dhallツール群は以下のようにしてインストールします。

$ stack install dhall --resolver=lts-11.9
$ ls ~/.local/bin/dhall*
dhall    dhall-format    dhall-hash    dhall-repl

(注意: 最近コマンドラインツールの構成に変更があり dhall-formatdhall-hashdhall のサブコマンドになりました (issue)。 この変更は今回採用している lts-11.9 にはまだ入っていないので、この記事ではこれらはまだ独立したコマンドとして扱います。)

dhall コマンドは Dhall ファイルの評価をするもので、関数の展開や型チェックをします (つまり、アプリケーションに投入する前にどのような設定になるか&形式が間違っていないかが分かる!)。

dhall-format コマンドは Dhall ファイルのフォーマッタです。 Dhall ファイルの保存時にこれが走るようにエディタを設定しておくのがおすすめです。 Emacsであれば dhall-mode を入れておけば勝手にそのような設定にしてくれます。

Dhallアプリケーション

現在のところ Dhall を利用したアプリケーションとしては以下のものがあるようです。

dhall-json
Dhall ファイルを JSONYAML に変換します。 既に JSONYAML の設定ファイルを使用しているのであれば、 これを使えば静的検査や関数のある Dhall を本番フローに導入しやすいかもしれません。 stack install dhall-json --resolver=lts-11.9 とすると dhall-to-jsondhall-to-yaml の2つのコマンドがインストールされます。

dhall-to-cabal
Dhall ファイルを cabal ファイルに変換します。 またそれだけでなく、cabal ファイルのための(おそらく cabal を完全にカバーする**多くの型定義、 そして記述を楽にするための多くの関数を提供します。 Dhall を本格利用する上でのノウハウの宝庫ですので cabal ファイルに興味がない人でも参考文献として覚えておくとよいでしょう。 stack install dhall-to-cabal --resolver=lts-11.9 でインストールできます。

dhall-kubernetes Dhall ファイルで Kubernetes の設定を記述できるようにするツールです。 Kubernetes は長大な YAML 設定ファイルで知られるのでまさに Dhall が生きる領域と言えるでしょう。 dhall-kubernetes 自体は Dhall ファイルのみで構成され、YAMLへの変換には dhall-json を用います。 ただ、要求する Dhall のバージョンがより新しいもののため LTS-11.9 環境ではインストールできません。 stack instal dhall-json --resolver=nightly で最新の dhall-json をインストールする必要があります。

バインディング

Dhall をプログラミング言語から利用する場合はその言語での Dhall のバインディングが必要です。

たとえば Dhall ファイルを Haskell アプリケーションから読む場合は Haskell の dependencies に dhall パッケージを加えます。

Dhall基礎編

それではDhallの各要素を見ていきましょう。

データ表現

データの表現は一般的な設定ファイルを表現するのに十分なものを揃えています。

プリミティブとして Bool, Integer, Double, Text (あとそれほど使わない気がするけど正の整数を表す Natural) があります。

$ echo 'True || False' | dhall
Bool

True
$ echo '1' | dhall
Integer

1
$ echo '2.0' | dhall
Double

2.0
$ echo '"Hello, World"' | dhall
Text

"Hello, World"

複合表現として List, Optional, Record, Union があります。

$ echo '[1, 2]' | dhall
List Integer

[ 1, 2 ]
$ echo '[] : Optional Integer' | dhall
Optional Integer

[] : Optional Integer
$ echo '{ x = 1, y = 2 }' | dhall
{ x : Integer, y : Integer }

{ x = 1, y = 2 }

$ echo '<Number = 1 | Name : Text>' | dhall
< Name : Text | Number : Integer >

< Number = 1 | Name : Text >

Union の値の書き方が煩雑(使わない方の識別子も書かなければならない)ですがこれには解決策が用意されています。 後で述べます。

型定義

複合表現に名前をつけて独自の型を定義することができます。

$ echo 'let Point2d = {x:Integer, y:Integer}: Type in {x=1, y=2}: Point2d' | dhall
{ x : Integer, y : Integer }

{ x = 1, y = 2 }

もちろん型が合っていなければ教えてくれます(出力にあるように dhall の代わりに dhall --explain を使えばより詳細なメッセージを出してくれます)。

$ echo 'let Point2d = {x:Integer, y:Integer}: Type in {x=1, y="2"}: Point2d' | dhall

Use "dhall --explain" for detailed errors

Error: Expression doesn't match annotation

{x=1, y="2"}: Point2d

(stdin):1:47

外部ファイルのインポート

Dhallでは外部のファイルをインポートして使うことができます。

たとえば以下のような Point2d 型を定義する Point2d.dhall を用意します。

$ cat Point2d.dhall
{ x : Integer, y : Integer } : Type
$ cat Point2d.dhall | dhall
Type

{ x : Integer, y : Integer }

これを以下のようにインポートすれば別のファイルで Point2d 型を使って型チェックすることができます。

$ dhall
{ x = 1, y = 2 } : ./Point2d.dhall [Enter]
[Ctrl-d]
{ x : Integer, y : Integer }

{ x = 1, y = 2 }
$ dhall
{ x = "1", y = 2 } : ./Point2d.dhall [Enter]
[Ctrl-d]
Use "dhall --explain" for detailed errors

Error: Expression doesn't match annotation

{ x = "1", y = 2 } : ./Point2d.dhall

(stdin):1:1

なおここではローカルのパスからインポートしましたがURLからインポートすることもできるようです。

関数

関数は無名関数として作ることができます。 以下は Integer の引数を1つ受け取ってそのまま返す関数です。

$ echo 'let id = \(x:Integer) -> x in id 10' | dhall
Integer

10

複数の引数を取る関数は1つの引数を取る関数をネストすることで記述できます。 これは一見面倒にも見えますが、無名 Record を引数に取ることもできるので実際のところ問題にはなりません。

また、Dhallの関数は 型を引数に取る ことができます。 引数に取る型は標準のものだけでなく自作の型でもOKです。 以下のようにすれば前述の id 関数が任意の型の値を取れるようになります。

$ echo 'let id = \(t:Type) -> \(x:t) -> x in id Double 1.0' | dhall
Double

1.0

便利な演算子・標準関数

演算子 // は2つの Record を併合します。 実用上は自作型のデフォルト値を少し書き換えるような場合によく使います。

$ echo '{x=1, y=2} // {y=-1, z=-2}' | dhall
{ y : Integer, z : Integer, x : Integer }

{ y = -1, z = -2, x = 1 }

上の方で Union の値を記述するときは使わない識別子も書かねばならず煩雑であることを述べました。 constructors 関数は Union のコンストラクタを生成して値の記述を楽にしてくれます。

$ echo 'let NN = constructors <Number:Integer | Name:Text> in NN.Number 1' | dhall
< Name : Text | Number : Integer >

< Number = 1 | Name : Text >

merge 関数は Union を何らかの特定の型に変換するときに使います。 実用上は、人間が設定を記述するときは変な値が入らないように型で制限したいけども最終的に欲しいのは文字列であるような場合によく使います({=} は空の Record 値を表します)。

$ dhall[Enter]
let OS = <iOS:{} | Android:{}>
in let OSs = constructors OS
in let handlers = {iOS=\(_:{})->"iOS", Android=\(_:{})->"Android"}
in let osToText = \(o:OS) -> merge handlers o
in osToText (OSs.Android {=}) 
[Ctrl-d]
Text

"Android"

あと Dhall の Text は string interpolation ができます。

$ echo 'let hello = \(name:Text) -> "Hello, ${name}!" in hello "Dhall"' | dhall
Text

"Hello, Dhall!"

Dhall実践編: Kubernetes設定ファイル

Dhall 実践編として、 dhall-kubernetes ではありませんが簡単な KubernetesYAML 設定ファイルを Dhall で作成してみます。 目標とする YAMLhttps://kubernetes.io/docs/concepts/services-networking/service/ にある以下のものです。

kind: Service
apiVersion: v1
metadata:
  name: my-service
spec:
  selector:
    app: MyApp
  ports:
  - protocol: TCP
    port: 80
    targetPort: 9376

まずはこれを型チェックもなにもないシンプルな Dhall で表してみます。

service.dhall:

{ kind =
    "Service"
, apiVersion =
    "v1"
, metadata =
    { name = "my-service" }
, spec =
    { selector =
        { app = "MyApp" }
    , ports =
        [ { protocol = "TCP", port = 80, targetPort = 9376 } ]
    }
}

これを dhall-json に通してみます。

$ cat service.dhall | dhall-to-yaml 
apiVersion: v1
kind: Service
spec:
  selector:
    app: MyApp
  ports:
  - targetPort: 9376
    protocol: TCP
    port: 80
metadata:
  name: my-service

目標とする YAML が得られました。

ただこれだと、例えば "Service" を "Servise" と typo してしまったとしてもエラーを教えてくれたりはしません。

そこで Dhall の持つ静的チェック、関数、インポートの機能を駆使して記述をサポートしていきましょう。 設定ファイルの記述をサポートするためのファイルは k8s_types.dhall という名前で作り、 実際の設定を記述するファイルがそれをインポートするようにします。

k8s_types.dhall:

    let Kind_ = < Service : {} | Deployment : {} | Pod : {} >

in  let kindHandlers =
          { Service =
              λ(_ : {}) → "Service"
          , Deployment =
              λ(_ : {}) → "Deployment"
          , Pod =
              λ(_ : {}) → "Pod"
          }

in  let ApiVersion = < v1 : {} >

in  let apiVersionHandlers = { v1 = λ(_ : {}) → "v1" }

in  let Metadata : Type = { name : Text }

in  let Selector : Type = { app : Text }

in  let Protocol = < TCP : {} | UDP : {} >

in  let protocolHandlers = { TCP = λ(_ : {}) → "TCP", UDP = λ(_ : {}) → "UDP" }

in  let Port
        : Type
        = { protocol : Protocol, port : Integer, targetPort : Integer }

in  let PortMerged
        : Type
        = { protocol : Text, port : Integer, targetPort : Integer }

in  let _mergePort =
            λ(p : Port)
          → { protocol =
                merge protocolHandlers p.protocol
            , port =
                p.port
            , targetPort =
                p.port
            }

in  let Spec : Type = { selector : Selector, ports : List Port }

in  let SpecMerged : Type = { selector : Selector, ports : List PortMerged }

in  let List/map =
          https://raw.githubusercontent.com/dhall-lang/Prelude/35deff0d41f2bf86c42089c6ca16665537f54d75/List/map 

in  let _mergeSpec =
            λ(s : Spec)
          → (   { selector =
                    s.selector
                , ports =
                    List/map Port PortMerged _mergePort s.ports
                }
              : SpecMerged
            )

in  let Config
        : Type
        = { kind :
              Kind_
          , apiVersion :
              ApiVersion
          , metadata :
              Metadata
          , spec :
              Spec
          }

in  let ConfigMerged
        : Type
        = { kind :
              Text
          , apiVersion :
              Text
          , metadata :
              Metadata
          , spec :
              SpecMerged
          }

in  let _mergeConfig =
            λ(c : Config)
          → (   { kind =
                    merge kindHandlers c.kind
                , apiVersion =
                    merge apiVersionHandlers c.apiVersion
                , metadata =
                    c.metadata
                , spec =
                    _mergeSpec c.spec
                }
              : ConfigMerged
            )

in  { Kinds =
        constructors Kind_
    , ApiVersions =
        constructors ApiVersion
    , Protocols =
        constructors Protocol
    , Config =
        Config
    , mergeConfig =
        _mergeConfig
    }

これを使って実際の設定を記述します。 ({=} は空の Record 値を表します)。

service_typed.dhall:

    let k8s = ./k8s_types.dhall 

in  let myService =
            { kind =
                k8s.Kinds.Service {=}
            , apiVersion =
                k8s.ApiVersions.v1 {=}
            , metadata =
                { name = "my-service" }
            , spec =
                { selector =
                    { app = "MyApp" }
                , ports =
                    [ { protocol =
                          k8s.Protocols.TCP {=}
                      , port =
                          80
                      , targetPort =
                          9376
                      }
                    ]
                }
            }
          : k8s.Config

in  k8s.mergeConfig myService

先ほどと同じように dhall-to-yaml に通します。

$ cat service_typed.dhall | dhall-to-yaml
apiVersion: v1
kind: Service
spec:
  selector:
    app: MyApp
  ports:
  - targetPort: 80
    protocol: TCP
    port: 80
metadata:
  name: my-service

結果を変えずに静的なチェックを追加することができました! もちろん "Service" などを typo したらエラーが出て教えてくれます。

なお、今回はあくまで設定ファイルに間違った値が入り込まないようにすることに主眼を置きましたが、 設定ファイルがより長いものになってくると、

  • 型のデフォルト値を定義する
  • 繰り返し使う値を定数にする
  • 雑多なボイラープレートな記述をヘルパー関数に切り出す

など、 Dhall のさらなる恩恵を受けることができます。

参考資料