フューチャー技術ブログ

CUEを試して見る

前回のエントリー、一周回って、人間が読み書きする設定ファイルはJSONが良いと思ったの続きです。

設定ファイルをどうするか問題はいろいろ悩ましい問題であります。そんな中、設定ファイル用言語という触れ込みのCUEがリリースされました。

すでに、NSSOLの方が、Linterとして使うという紹介記事をすでに書かれています。これはユースケースの一部(これでも有用ですが)です。まだ使い込んだわけではなく、チュートリアルとドキュメントを一通り読みつつ軽く試した程度ですが、全体的な紹介をしようと思います。

CUEによってできるようになること

主な特徴としては次のような感じです。

  • 人が書く設定ファイル(JSON/YAML/TOML/iniあたりがライバル)のための言語
  • スキーマ定義が書けて、バリデーションが可能
  • テンプレート機能で一部が違うデータを大量に生成みたいなのがしやすい
  • 外部プログラムを起動してその結果を取り出したり、文字列演算や数値演算が可能(実行しているホスト名を取ってくるとか)
  • バリデーションのコードを生成できる(Goのみ)
  • GoやProtocol BuffersからCUEの定義ファイルを生成できる
  • JSON/YAMLに変換できるし、JSON/YAMLから読み込むことも可能
  • パーサーや、読み込んだデータを問い合わせるクエリーのAPIがある(Goのみ)

XMLやJSONはバリデーション側がXMLスキーマ・JSONスキーマとは別の規格でしたが、CUEは最初からスキーマやバリデータが仕様に入っています。NSSOLさんのブログ記事は、cueコマンドを使って、YAMLを読み込んでバリデーションでしたが、メインストリームの利用方法は、.cueという言語の形式で設定ファイルを記述し、スキーマも記述し、バリデーションしつつ、Goのプログラムに読み込んだり、他の形式に出力したり、という感じでしょう。

ドキュメントを見る感じだと、ツールやファイルの流れはこんな感じでしょう。

今までできなかったがCUEによってできるようになったこととしてはロジックが内部に書けるようになったことです。JSONやXMLで数式とかちょっとしたロジックを書いたとしても、それを読み込んで評価する処理は別に書く必要がありました。

例えば、環境変数を展開したい、とかシェル的に処理を実行してその結果を取り込みたいとか、そういうのはJSONやXMLだけではできません。そういうリッチな設定ファイルが欲しい場合は、Groovyとか、PythonとかRubyとかJavaScriptとか、汎用プログラミング言語を設定ファイルとして使う、ということが行われてきました。CUEを使えば、ちょっとリッチな設定ファイルが使えるようになります。まあできなかったかというと、AWKみたいなのはあったわけで、それがモダンになって木構造データに対応して登場した、みたいな感じですかね。

レベル1: ベターJSONとしてのCUE

このチュートリアルの基礎を読むのが手っ取り早いです

https://github.com/cuelang/cue/blob/master/doc/tutorial/basics/json.md

最初の方に出てくるのが次のようなサンプルを組み合わせたのが次のコードです。JSONと似ていますが、ハイフンなどの記号がなければキーのダブルクオートが省略できます。JSONでキー名をいちいち括るのはめんどくさいですよね?前回のエントリーではてブとかでコメントや末尾のカンマについて触れている人が多数見られましたが、CUEではコメントも入れられるし、末尾のカンマは書いても書かなくてもいいです(ただし、リストでは省略はできない)。

即値の記述方法も多彩です。例えば、1Mは1000*1000ですね。数字もセパレータを入れたりできます。文字列も複数行のテキストが入れられます。

{
// コメント
one: 1
two: 2
mega: 1M

"two-and-a-half": 2.5

multiline: """
Hello
World!
"""
}

さらにトップレベルの波かっこは省略可能です。YAMLユーザー大歓喜ですね。

// コメント
one: 1
two: 2

1つのキーのみを持つオブジェクトは、スペース区切りで並べることで表現できます。

a b c: 10

これは次のJSONと等価です。

{
"a": {
"b": {
"c": 10
}
}
}

以上が書きやすくなったJSONとしてのCUEの説明です。

レベル2: CUEと型定義

CUEはJSONと違って、同じキーの定義が複数あってもエラーになりません。登場するたびに、制約が掛け合わされていくような感じです。これを応用して型定義を行なっていきます。複数の条件をかけあわせていく中で矛盾が出ると「コンフリクトがあった」といった感じでエラーになります。

次のコードは最初にnumberと定義しています。これで数字のどれか、という定義になります。intで整数なども指定できます。int/floatが区別されているのはJS由来のJSONにはない嬉しい点ですよね。次に同じキーに対して1を入れています。最初の制約と両方が満たされるので、これはOKです。

a: number
a: 1

次の定義は2つめに出てくる制約が最初の制約とマッチしないのでエラーになります。

a: string
a: 1

次の書き方で、値がなかった場合のデフォルト値(ここでは1)を設定することもできます。

i: int | *1

コロン二つを使って、オブジェクトの型定義もできます。

Member :: {
age: > 10
name: string
}

me: Member
me: {
age: 39
name: "Shibukawa"
}

これらの定義はパッケージとしてまとめて定義しておいて、インポートして使うという使い方もできるようです。また、go getっぽい感じでcue getでGoのパッケージを指定すると、そこから定義を抽出するという方法も紹介されています。本当かよ。

NSSOLさんのQiita記事でも書かれていましたが、型も値です。ノリとしてはTypeScriptに近い感じですね。逆にいえば、値を使った制約も可能です。次の例はどちらかの文字列のみが入るという制約なります。

// 文字列は複数の選択肢のみ許容が可能
conn: "tcp" | "udp"
conn: "tcp"

// 空文字列は許容しない
name: !=""
name: "tako"

// 数値も範囲指定が可能
ri: >=3 & <8 & int
ri: 7

レベル3: 宣言的でプログラマブルなCUE

ここからだんだんヤバくなってきます。CUEにはReactの仮想DOM的な、木構造を効率的に作り出すためのプログラマブルな機能がいろいろあります。

チュートリアルから持ってきた例です。CUE用語の名前は「テンプレート」です。React用語でいうとコンポーネントです。引数が<Name>です。中でもNameという参照が使えるので、これを値として利用するのも可能です。

job <Name>: {
name: Name
replicas: uint | *1
command: string
}

job list command: "ls"

job nginx: {
command: "nginx"
replicas: 2
}

これは次のように展開されます。ちょっと複雑なので解説すると、スペース区切りでオブジェクトの階層になる表記と、同じキーがあったら合成される(この場合、jobというキーのオブジェクトが二つあって合成されている)、の組み合わせになっています。テンプレートでは引数の設定も可能だし、上書きしたい値を後から書くこともできます。ホットスポットだけを書き換えてたくさん定義を量産することが可能です。

{
"job": {
"list": {
"name": "list",
"replicas": 1,
"command": "ls"
},
"nginx": {
"name": "nginx",
"replicas": 2,
"command": "nginx"
}
}
}

もし金額が100万円超えたら稟議が必要なのでneedCheck: trueを足したい、という条件分岐で要素を追加もできます。

price: 10M
if price > 1M {
needCheck: true
}

Python的なリスト内包表記も使えます。remというのは割り算の余りを算出する演算子です。

[ x*x for x in items if x rem 2 == 0]
items: [ 1, 2, 3, 4, 5, 6, 7, 8, 9 ]

forループで要素を量産することもできます。また、iterpolationという機能は、他の言語でいうところのフォーマット文字列(Python用語)、テンプレート文字列(JavaScript用語)、 式展開(Ruby/PHP用語)です。変数の値を埋め込んだ文字列の生成もできます。下記のサンプルは配列の要素を取り出してそれを小文字に変換したものをキーとしたオブジェクトを3セット作っています。やばいですね。

import "strings"

a: [ "Barcelona", "Shanghai", "Munich" ]

{
for k, v in a {
"\( strings.ToLower(v) )": {
pos: k + 1
name: v
nameLen: len(v)
}
}
}

レベル4: 最終形のCUE

もうこのあたりは僕も理解できていません。CUEから使えるパッケージを見ると、JSONのテキストのパースやらシリアライズができるようですし、CSVの読み書きも可能っぽいです。ファイルの読み書き、外部プログラムの実行、HTTP、ハッシュの計算・・・・

http.Serveでサーバーも作れちゃうのかな?もう理解不能です。tool以下のパッケージは少し特殊なやつらっぽいですが・・・

現時点でのCUEの欠点

現時点でGo以外のパーサーが用意されていないのは、Go以外のユーザーにとっては大きな欠点でしょう。代替策としては、.cueファイルに対してコマンドラインツールを使って.jsonや.yamlファイルに変換してしまう、という方法はあります。これにより、.cueの機能を使いつつ、プログラムで読み込んで利用する部分の断絶を補うことができ、Go以外の言語でも利用できるようになります。外部ツール依存は増えてしまいますが、CUEがデータをクレンジングしてくれる部分はやってくれるので、読み込む側ではエラーチェックやら何やらを省略できます。

機能案の中にまだ作業中とされているのが、Goコードの生成です。これはJSON-to-Goなどのようにファイルからそれを扱うGoの構造体を一発生成する機能かと思われます。クエリーのAPI呼び出しでアクセスする機能は提供されていますが、やはり構造体になってしまった方が、コード補完との相性は良く実装時には捗るでしょう。

個人的に大きいのが、エディタ拡張がまだ存在しない、という点です。コードハイライトやコード補完ですね。これも脳のメモリ使用量を削減してくれるため、大変助かる機能ではあります。また、その場でバリデーションしてもらえると、試行錯誤のループが小さくなるので、アウトプットが出来上がる時間が大幅に短くなります。このため、小さめの設定ファイルであれば、前回紹介したJSONスキーマ中心のフローのほうがまだまだ便利だと思っています。

CUEのユースケース

このような欠点はありつつも、CUEは魅力的なツールです。公式ドキュメントでもKubernetesやらOpenAPI(Swaggerの後継と言われているがなかなかエコシステムが揃ってこないやつ)での利用例が紹介されていますが、このように、超巨大なJSONやらYAMLやらを書く人は、サポートツールとしてCUEを使うと良いでしょう。例えば、AWSのよくある巨大なJSONやら、長大なdocker-compose.yamlを書くときに、一部をパラメータ化しておきたいとか、冗長な表現をまとめたい、というときに.cueで書いてYAMLを生成するという、コードジェネレータとして利用すると生産性が上がるでしょう。

もちろん、既存のYAMLとJSONのバリデーションツールとして使うというNSSOLさんのQiitaで紹介されていた方法も有用ですが、プログラマブルな機能を活用するとデータ生成ツールとして活用できるため、せっかく使うなら入力データはJSONやYAMLよりも、.cueを使う方が良いと思います。

また、CUEの欠点でGo以外のパーサーがないのでCUEのツールを使って変換してから読み込む、というのを紹介しました。こうなると「JSON5とか他のサードパーティ製のパーサー使うのと変わらないじゃん」と思うかもしれませんが、CUEの方が「設定ファイルを書く人のデバッグのしやすさ」は大きく前進するでしょう。ほとんどのこの手のパーサーはトークンの出現位置などはパース時に消えてしまいますし、プログラム言語で読み込んでからあとからデータ整合性チェックを行って、問題のデータを指摘してもオリジナルのファイル上の行と桁情報って出せないんですよね。JSON変換してからJSONスキーマでチェックしようとすると、オリジナルとは違う行・桁でのエラーになる可能性すらあって、良かれと思って導入したヒューマンリーダブルな便利フォーマットがかえって不便になります。これはPythonやRubyやJavaScriptのプログラミング言語自身を使ったDSL表現の設定ファイルでもほぼ同様です(スタックをたどってオリジナルの位置情報を保存までやればできるはずですが、そういう実装例は見たことがない)。

CUEの場合は型チェックやらデータの整合性チェックやらをすべてCUE上で定義して行なえます。正規表現も使えますし、enum的な特定の文字列しか許さない、といったことも表現できます。CUE上にチェックロジックのほとんどを持ち込むことができれば、データ作成者側はファイル上のエラー行数を見てデータ修正ができるようになります。そのため、例えCUEを外部コマンド呼び出しをして呼び出してJSON変換したとしても、他のパーサーでは得られないメリットがあると言えます。

設定ファイルのまとめ

2回に渡って設定ファイルについて説明しました。僕としての問題設定というか、設定ファイルのフォーマットについての考慮点は次の4点でした。

  • パーサーがある
  • スキーマがある
  • エディタでコードハイライトを行ったり、文法チェックや、スキーマによるチェックやコード補完が利用可能(設定データを書く人の苦労が少ない)
  • データ作成、スキーマ作成、読み込み用コード作成など、複数の作業の手間が省けること(読み込み側アプリ実装者の苦労が少ない)

JSONに関しては色々不満はありつつも、ほぼ満たしていると思っています。CUEは出たばかりですが、エディタサポートさえ入ったら、Goユーザーとしては不満はほとんどないです。今後に期待ですね。今作りたいものが一段落したら自分でも作って見たいところですが。

今回は、構成管理的な設定ファイルについて考えていたので、Twelve-Factor App的な設定の受け渡しを便利にする方法とかは範囲外としました。.envファイルにまとめるにはどうするか、とか、Dockerビルドする時にプライベートリポジトリにアクセスさせたいけどgithubの秘密鍵どうしようとか、AWSやGCPのSecure Managerとの連携とか、そっちはそっちでいろいろ楽しい世界ではありますし、誰かがまとめてくれるのを期待しています。

続きの記事は、GoでCUEのバリデーション機能を利用しつつ、.cue/.json/.yaml形式の設定ファイルを読み込む です。