Dhallによるリッチな設定ファイル体験
(この記事のレギュレーション: lts-11.9)
Dhall という設定記述用言語があり、使ってみたところ良い感じだったので紹介します。
なお、この記事は先日某所で発表したものの拡大版になります。
Dhallとは何か
Dhallについて短かく表現するなら公式サイトの以下の説明が分かりやすいです。
You can think of Dhall as: JSON + functions + types + imports
データ表現にプログラマブルさと静的な検査とファイルのインポートを加えたものというわけです。
まだ開発中のためかあまりアピールされていませんがツールチェインも充実しており、 ちょっとした処理を確かめるためのREPLや、 今どきの言語らしく公式フォーマッタもあります。
あと大事なのはチューリング完全ではないということです。 具体的にはループなどは書けません。
設定ファイルの役割というものはソフトウェアの成長に従って肥大化していく宿命にあり、 最初は単純なJSONやYAMLで済んでいたものの上に独自形式のマクロが追加され、 単純な文字列置き換えで済んでいるうちはいいものの要求の向上に従ってマクロの役割は増えていき、 屋上屋を重ねた末にはシンタックスハイライトが効かない・実行時にならないとどういう設定になるか分からない・下手すれば無限ループして停止しない設定ファイルができあがってしまいます。
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-format
と dhall-hash
が dhall
のサブコマンドになりました
(issue)。
この変更は今回採用している lts-11.9 にはまだ入っていないので、この記事ではこれらはまだ独立したコマンドとして扱います。)
dhall
コマンドは Dhall ファイルの評価をするもので、関数の展開や型チェックをします
(つまり、アプリケーションに投入する前にどのような設定になるか&形式が間違っていないかが分かる!)。
dhall-format
コマンドは Dhall ファイルのフォーマッタです。
Dhall ファイルの保存時にこれが走るようにエディタを設定しておくのがおすすめです。
Emacsであれば dhall-mode を入れておけば勝手にそのような設定にしてくれます。
Dhallアプリケーション
現在のところ Dhall を利用したアプリケーションとしては以下のものがあるようです。
dhall-json
Dhall ファイルを JSON や YAML に変換します。
既に JSON や YAML の設定ファイルを使用しているのであれば、
これを使えば静的検査や関数のある Dhall を本番フローに導入しやすいかもしれません。
stack install dhall-json --resolver=lts-11.9
とすると
dhall-to-json
と dhall-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 ではありませんが簡単な Kubernetes の YAML 設定ファイルを Dhall で作成してみます。 目標とする YAML は https://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 のさらなる恩恵を受けることができます。
参考資料
- dhall-lang: 公式サイト
- Dhall in production: Dhall導入事例集
- dhall-json: DhallファイルをJSON/YAMLに変換
- dhall-to-cabal: Dhall本格利用のためのノウハウの宝庫
- dhall-kubernetes: DhallファイルでKubernetesの設定を記述
- dhall-mode: 自動フォーマットもしてくれるEmacsモード
- dhall-lang Cheatsheet: チートシート
- Dhall Tutorial: チュートリアル