GoでCUE言語(cuelang)使ってみた
感想
CUEいいかも。
前書き
今回は「アプリケーションの設定をより便利に書ける言語」としてのCUE言語(cuelang)をGoで使ってみたお話です。
ユースケース
- GoでAPIなど開発している
- configパッケージを自前で作っているところ
- このパッケージでは設定ファイルを読み込んでGoの構造体を返させる
- CUEで記述された設定ファイルのスキーマがある
- 設定ファイルもCUEで記述し、スキーマを使ってvalidationする
ゴール
- 設定ファイルをcueで書いてみる
- 設定ファイルのスキーマもcueで書いて制約をつけてみる
- 設定ファイルの内容をGoの構造体に取り込む
- 制約違反をGoで検出する
やってみた
環境
$ sw_vers
ProductName: macOS
ProductVersion: 12.6
BuildVersion: 21G115
$ go version
go version go1.19.2 darwin/amd64
$ go list -m cuelang.org/go
cuelang.org/go v0.4.3
スキーマファイル
// https://github.com/cue-lang/cue
salesTaxRate: >=0 & <=100 & int
logLevel: "debug" | *"info" | "warn" | "error" | "critical"
消費税率とログレベルを定義する設定ファイルのスキーマを想定します。
salesTaxRate
)
消費税率(- 0以上で、
- 100以下で、
- 整数(
int
型)
というルールを作り、それぞれを&
で結合して組み立てています。
CUEは「同じ名前のフィールドを複数回定義してもよい」というルールがあり、複数回定義した場合には、それぞれの制約をANDで結合します。つまり上の例のsalesTaxRate
は、
salesTaxRate: >=0
salesTaxRate: <=100
salesTaxRate: int
このように書き換えることもできます。
logLevel
)
ログレベル(- "debug"、"info"、"warn"、"error"、"critical"のいずれか
- 値がない場合は"info"を初期値にする
「いずれか」という条件は、ダブルクォートされた文字列をパイプ(|
)で区切ることで論理和として表現しています。また"info"
の頭に*
を付加して、デフォルト値であることを指示しています。
設定ファイル
salesTaxRate: 10
logLevel: "debug"
消費税率は10
(%)、ログレベルは"debug"
と定義します。
ここで、cueの設定ファイルはただのテキストファイルなので、たとえばsalesTaxRate: じゅっぱーせんと
のように書くこともできてしまいます。このようなトンチンカンな記述はもちろん、150
のように範囲外の数値が書かれていないかどうかも、スキーマと照合してチェックしたいです。
Go実装
2つcueファイルを読み込み、照合し、Goの構造体を生成する実装コードです。一例ですが、このようにしてみました。実装の説明はコメントでつらつらと書いております。
package config
import (
"fmt"
"os"
"path/filepath"
"cuelang.org/go/cue/cuecontext"
cueerrors "cuelang.org/go/cue/errors"
"github.com/pkg/errors"
)
// Config 設定を格納する構造体を定義します。
type Config struct {
SalesTaxRate int `json:"salesTaxRate"`
LogLevel string `json:"logLevel"`
}
func NewConfig() (*Config, error) {
c := cuecontext.New() // cueのコンテキストを生成します。
var config Config // 空のConfig構造体を宣言します。
// 各cueファイルを読み込むためのファイルパスを記述しています。
// 実際の構成に合わせて良きにはからってください。
schemaPath, _ := filepath.Abs(`./config/cue/schema.cue`)
configPath, _ := filepath.Abs(`./config/cue/local.cue`)
// schema.cueを読み込みます。
bSchema, err := os.ReadFile(schemaPath)
if err != nil {
return nil, errors.WithStack(err)
}
// ファイルポインタから読み込む場合はCompileBytesを使います。
// コード中に記述したstringから作るような場合はCompileStringを使います。
// see also) https://cuetorials.com/go-api/basics/context/
cueSchema := c.CompileBytes(bSchema)
// local.cue(config本体)を読み込みます。
bConfig, err := os.ReadFile(configPath)
if err != nil {
return nil, errors.WithStack(err)
}
cueConfig := c.CompileBytes(bConfig)
// ここキモです。
// 「Unify」でスキーマと設定ファイルを統合します。
// 2つのcue定義を統合することで「矛盾がある場合はエラー」が起きます。
// スキーマと実際の設定内容を両方書くことができるcueならではの考え方だと思いました。
u := cueSchema.Unify(cueConfig)
if u.Err() == nil {
// 統合してエラーがなければ、構造体に変換(Decode)した結果を返して終了です。
err = u.Decode(&config)
if err != nil {
// 一応、Decode時にエラーとなる場合もあるので念のためにエラー検知します。
return nil, errors.WithStack(err)
}
fmt.Println(config) // 確認のために構造体の中身をPrintしておきます。
return &config, nil
}
// Unifyでエラーが起きると、これ以降を通ります。
// Unify時のエラー詳細には、エラー原因が複数あっても先頭の1件しか格納されないようです。
// Unifyでエラーがあった場合はなんらかの不整合があることになるので、
// このあとのValidateで不整合をすべて明らかにしてもらいます。
// ここでのPrintはエラー内容の確認のためです。
fmt.Println(cueerrors.Details(u.Err(), nil))
// Validateしてすべてのエラーを取得します。
err = u.Validate()
if err != nil {
fmt.Println(cueerrors.Details(err, nil)) // 確認のためにエラーの詳細をPrintしておきます。
return nil, errors.WithStack(err)
}
// Unifyでエラー、かつValidateでエラーがなかった場合はここでreturnします。
// ただし、いろいろ試しましたが実際にここを通すことはできませんでした。
// UnifyできないのにValidationでエラーが出ないなんてことがあるのかどうか...
return nil, errors.WithStack(u.Err())
}
結果
{10 debug}
設定ファイルの内容がちゃんと構造体に埋まっていることがわかります。
エラーにしてみる
ここで、設定ファイル側を以下のようにしてみます。
salesTaxRate: 150
logLevel: "fatal"
-
salesTaxRate
に範囲外の値を書いてみる(スキーマでは0〜100であることを要求している) -
logLevel
に定義されていないそれっぽい値を書いてみる
その結果
まず、Unifyがエラーとなり、詳細は下記のようになりました。
salesTaxRate: invalid value 150 (out of bound <=100):
2:21
1:15
salesTaxRate
の値が不正ですよ、<=100
の条件を超えていますよ、と教えてくれています。
次の2:21
はスキーマ側の定義位置で「2行目の21文字目」、1:15
は実際に違反している設定ファイル側の定義位置で「1行目の15文字目」を示しています。
ですが、ここではlogLevel
の違反については出力されてきません。
さらにValidateの結果を見てみます。(長いです)
logLevel: 5 errors in empty disjunction:
logLevel: conflicting values "critical" and "fatal":
2:11
3:51
logLevel: conflicting values "debug" and "fatal":
2:11
3:12
logLevel: conflicting values "error" and "fatal":
2:11
3:41
logLevel: conflicting values "info" and "fatal":
2:11
3:23
logLevel: conflicting values "warn" and "fatal":
2:11
3:32
salesTaxRate: invalid value 150 (out of bound <=100):
2:21
1:15
最後のsalesTaxRate:...
はUnifyの結果と同じですが、logLevel
についても詳細に出力してくれています。スキーマ側で論理和により定義されているそれぞれの値と"fatal"
が競合してるよ、というエラーのようです。
おしまい
以上です。今回は冒頭で述べた以下のゴールを達成できたかなと思っています。
- 設定ファイルをcueで書いてみる
- 設定ファイルのスキーマもcueで書いて制約をつけてみる
- 設定ファイルの内容をGoの構造体に取り込む
- 制約違反をGoで検出する
この方法で得られる一番わかりやすいメリットとしては、設定ファイルに書く内容やフィールドの初期値を、コーディングせずに変更したり追加したりできる点かと思います。例えばログレベルの初期値を"debug"
に変えたい場合などは*
の位置を変えるだけで実現できたりしますね。
cueは非常に多機能な言語だそうで、スキーマからデータを自動生成したり、オブジェクトのような書き方ができたり、Goの構造体を自動生成できたり・・・ちょっと脳が追いつきません。そのへんはまた必要に迫られたら試してみようと思います。
ではまた!
参考
更新履歴
執筆時のバージョンを追記しました。(-> 環境)
Discussion