🐙

GoでCUE言語(cuelang)使ってみた

2022/10/17に公開

感想

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

スキーマファイル

schema.cue
// 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"の頭に*を付加して、デフォルト値であることを指示しています。

設定ファイル

local.cue
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}

設定ファイルの内容がちゃんと構造体に埋まっていることがわかります。

エラーにしてみる

ここで、設定ファイル側を以下のようにしてみます。

local.cue
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