フューチャー技術ブログ

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

人間が読み書きする設定ファイルについて、2つのエントリーを紹介してきました。今回は第3段です。

前回のエントリーで、CUEはテキストファイルのフォーマットでありつつもJSON/YAMLと同等の表現力(階層が持てて、文字列や数値などを扱える)を持ち、なおかつCUEコマンドやライブラリでは.cue/.json/.yamlを同列に入力ファイルとして扱えるということを紹介しました。また、.cueファイルにスキーマを定義して、バリデーションが行えることも紹介しました。

今回は、Goのパッケージを利用して、.cue/.json/.yaml形式の設定ファイル読み込み機能をアプリケーションに組み込んでみます。本家の一次資料としては次のサイトになりますので、細かい機能やAPIを知りたい場合はこちらを参照してください。

10/31修正 @apstndbさんに構造体へのタグのマッピングの部分で指摘をいただいて修正しました

全体の流れと利用するパッケージ

次の処理をまとめて行ってみます。

  • アプリケーションは設定ファイルを読み込みます。
    • 設定ファイルのフォーマットは.cue/.json/.yamlのすべてのフォーマットに対応するものとします。
  • 読み込んだあとにスキーマによるチェック、デフォルト値の補完を行い、エラーがなければ構造体に値をマップしてアプリケーション本体に結果を渡します。

Goのencoding/jsonなどはフォーマットさえ合っていればエラーを返すことはありません。アプリケーション側で必須な値が省略されてもゼロ値になるだけで、エラーにしたりしてくれませんし、省略時にデフォルト値を入れることもできません。それらはすべてアプリケーションコードで行う必要がありました。

CUEのGo用のライブラリを使うと、バリデーションと補完をすべてスキーマファイル任せにできますので、プログラムはシンプルになります。

CUEのAPIはいくつかのパッケージに分かれています。今回紹介するのは次の4つです。

スキーマの定義

テスト用の設定のスキーマは.cueで書きます。リアルなユースケースではもっと多くなるでしょうが、とりあえず必須属性のポート番号と、ログレベル(省略時は”info”)の2つだけにしておきます。

schema.cue
port:      uint16
logLevel: "debug" | *"info" | "warn" | "error" | "critical"

プログラムには、文字列型としてそのまま取り込んでおきます。

const schema = `
port: uint16
logLevel: "debug" | *"info" | "warn" | "error" | "critical"
`

処理の流れ

それでは実装していきます。

.cueファイルを読み込んでバリデーション

前回のエントリーで、CUEのバリデーションの基本的な戦術について次のように紹介しました。

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

GoのAPIも流れとしては同じです。アプリケーションの設定ファイルと、スキーマ定義の両方のインスタンスを作成し、その2つのインスタンスをマージします。そうすると実際の値とスキーマの条件が両方定義されたインスタンスが作成できるので、バリデーションを行います。

スキーマも同じフォーマットに従っているので、アプリケーションの設定フォーマットに.cueを使えば、利用者側で新しく制約を追加することも可能です。例えば、デプロイ用の環境はproduction/staging/performancetest/developmentから選ぼう、それから外れたものはエラーにする、みたいなことが可能です。

// ランタイムの宣言(ゼロ初期化でOK)
var r cue.Runtime

// スキーマのインスタンスを作成
schemaInstance, err := r.Compile("schema.cue", schema)
if err != nil {
panic(err)
}

// 設定ファイルの方のインスタンスを作成
valueInstance, err := r.Compile(filePath, reader)
if err != nil {
return nil, fmt.Errorf("Parse CUE file error: %w", err)
}

// マージすると、また同じ形式(cue.Instance)の合成されたインスタンスが作られる
merged := cue.Merge(schemaInstance, valueInstance)
// バリデーション
err = merged.Value().Validate()

r.Compile()の最後の引数は、ドキュメントを見るとinterface{}型の引数ですが、io.Readerでも、[]byteでも、stringでも動くというAPIになっています。他には見ない設計で面白いですね。

JSON/YAMLも読み込む

JSON/YAMLファイルを読み込み、.cueと同様にcue.Instanceのインスタンスを作成してみます。最初のコードは.cueしか読みませんが、拡張子を見て他の形式も読み込めるようにします。

var valueInstance *cue.Instance

switch filepath.Ext(filePath) {
case ".cue":
valueInstance, err = r.Compile(filePath, reader)
if err != nil {
return nil, fmt.Errorf("Parse CUE file error: %w", err)
}
case ".json":
decoder := json.NewDecoder(&r, filePath, reader)
valueInstance, err = decoder.Decode()
if err != nil {
return nil, fmt.Errorf("Parse JSON file error: %w", err)
}
case ".yaml":
fallthrough
case ".yml":
valueInstance, err = yaml.Decode(&r, filePath, reader)
if err != nil {
return nil, fmt.Errorf("Parse YAML file error: %w", err)
}
default:
return nil, fmt.Errorf("file extension should be .cue, .json, .yaml or .yml, but %s", filepath.Ext(filePath))
}

要注意ポイントとしては、.cueも.jsonも.yamlも、それぞれのパッケージで定義されている関数を読み込むだけですが、それぞれ、関数の形が結構違っていて、パッケージ名だけを書き換えればOKとはいかない点です。

構造体へのマッピング

encoding/jsonなどの標準パッケージ同様に、タグが付与されている構造体に設定ファイル内部の値を読み込んで割り当てていくことが可能です。そのために構造体を定義します。このマッピング用の構造体は基本的にJSON用のものとほぼ同じです。JSON用のタグがそのまま利用できます。

type Config struct {
Port uint16 `json:"port"`
LogLevel string `json:"logLevel"`
}

読み込むコードはencoding/jsonパッケージを使ったことがあればおなじみですが、JSONの場合はjsonからGoの構造体に値を入れるのをDecode、Goの構造体からシリアライズしてio.Writerを使ってファイルに変換したりする方をEncodeと呼びましたが、cueはなぜか逆転しています。

var config Config
codec := gocodec.New(&r, &gocodec.Config{})
// 最初紹介した、スキーマと設定ファイルをマージしたcueのインスタンスを受け取り、構造体にマップ
err = codec.Encode(merged.Value(), &config)
if err != nil {
return nil, fmt.Errorf("Encode error: %w", err)
}

また、cue タグを使うことで、構造体の中に制約を書くことができます。プログラムと近いところにスキーマ定義を置いておきたい場合にはこちらの方がスムーズでしょう。

完成したコード

今まで紹介してきた処理をまとめたのが次のコードです。io.Reader(ファイルでも何でも)と、フォーマット識別のためのファイル名を受け取ると、バリデーション・補完をした結果を構造体にマップして返します。

package main

import (
"fmt"
"io"
"path/filepath"

"cuelang.org/go/cue"
"cuelang.org/go/encoding/gocode/gocodec"
"cuelang.org/go/encoding/json"
"cuelang.org/go/encoding/yaml"
"go.pyspa.org/brbundle"
)

const schema = `
port: uint16
logLevel: "debug" | *"info" | "warn" | "error" | "critical"
`

type Config struct {
Port uint16 `json:"port"`
LogLevel string `json:"logLevel"`
}

func getConfig(filePath string, reader io.Reader) (*Config, error) {
var r cue.Runtime

schemaInstance, err := r.Compile("schema", schema)
if err != nil {
panic(err)
}

var valueInstance *cue.Instance
switch filepath.Ext(filePath) {
case ".cue":
valueInstance, err = r.Compile(filePath, reader)
if err != nil {
return nil, fmt.Errorf("Parse CUE file error: %w", err)
}
case ".json":
decoder := json.NewDecoder(&r, filePath, reader)
valueInstance, err = decoder.Decode()
if err != nil {
return nil, fmt.Errorf("Parse JSON file error: %w", err)
}
case ".yaml":
fallthrough
case ".yml":
valueInstance, err = yaml.Decode(&r, filePath, reader)
if err != nil {
return nil, fmt.Errorf("Parse YAML file error: %w", err)
}
default:
return nil, fmt.Errorf("file extension should be .cue, .json, .yaml or .yml, but %s", filepath.Ext(filePath))
}

merged := cue.Merge(schemaInstance, valueInstance)
err = merged.Value().Validate()
if err != nil {
return nil, fmt.Errorf("Validation error: %w", err)
}
var config Config
codec := gocodec.New(&r, &gocodec.Config{})
err = codec.Encode(merged.Value(), &config)
if err != nil {
return nil, fmt.Errorf("Encode error: %w", err)
}
return &config, nil
}

利用する側のコードはこんな感じですかね。

package main

import (
"flag"
"log"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)


func main() {
var configPath = flag.String("conf", "config.cue", "Config file")
flag.Parse()
c, err := os.Open(*configPath)
if err != nil {
log.Fatalf("Can't open config file %s: %v", *configPath, err)
}
config, err := getConfig(*configPath, c)
if err != nil {
log.Fatalf("Can't read config file %s: %v", *configPath, err)
}
// ↓ここから先は正しい設定ファイルが読み込めて設定済みの状態でロジックを書き始められる

// ロガーのZapのログレベル設定
var level zapcore.Level
var zc zap.Config
zc.Level.SetLevel(level.Set(config.LogLevel))

// HTTPサーバーのポートの設定
http.HandleFunc("/bar", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello, %q", html.EscapeString(r.URL.Path))
})

log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", config.Port), nil))
}

まとめ

CUEのGoのAPIを使ってみました。

構造体へのマップ機能を使ってしまえば、アプリケーション側での準備はJSONとそれほど変わりませんし(読み込み部分は今回のサンプルのほぼコピペで毎回カバーできるはず)、アプリケーションコード側で設定を利用するときにコード補完も利用できて実装は捗るでしょう。その構造体も、前々回のエントリーで紹介したように、最初にJSONの設定ファイルのサンプルを作ればJSON-to-Goを利用してマッピング用の構造体もかんたんに作れることも紹介しました。

Goは文法がシンプルが故に、設定値の異常チェックやら、値が設定されてなかったらデフォルト値を利用するというコード(JSやRubyの config.port || 8080とかPythonのconfig.port or 8080みたいなやつ)が冗長になりがちです。特にライブラリとして不特定多数から利用されるのを想定して丁寧にエラー処理をしようとすると手間暇が多くなります。

CUEのissueにも上がっているように、まだエラーメッセージが少々わかりにくいというのはありますが、CUE経験値が上がれば素早く問題を見つけられるようになるでしょうし、今後勝手に改善されていくでしょう。

今回のサンプルでかんたんに説明するためにログレベルだとかポートだとかを設定するという例にしましたが、本来、これらは環境変数でも設定できるようにするのが筋ですし、どちらかというとdocker-compose並に複雑な設定ファイルが必要となるような構成情報の記述とかをすると、俄然CUEの能力が発揮されてくるでしょう。個人的にもそのようなケースでちょうど設定ファイルを作る要件があったのでCUEを利用し始めていたところです。今すぐCUEを全面的に使おう、というのをおすすめするわけではなく、大規模な設定ファイルが必要な案件があったときのために、ツールボックスに備えておくと憂いなしかな、と思いました。