つきなみGo

CUEによるスキーマやバリデーションのポータビリティ

この記事は、連載つきなみGoの2回目の記事です。

CUEはデータの表現やスキーマ定義やバリデーションなどを行うことができる言語です。元々Google社内で、Borg(現在のKubernetesの前身となったGoogleの社内システム)の設定に使用するために開発された言語が前身となっており、現在はオープンソースとして開発が進んでいる言語です[1]

CUEは現在まだv0.4.3とマイナーバージョンではありますが、すでにistioにおいてProtobufからOpenAPIを生成する部分で使用されていたり、CI/CDの構築に使用するDaggerというツールの設定ファイルとして採用されています。

弊社(メルカリ)でもCUEをKubernetesなどを含むインフラの抽象化に使用しています紹介記事①紹介記事②⁠。

また、CUEはGoと親和性が高く、GoからCUEへの変換や、CUEの定義をGoで使用できる仕組みが用意されています。

この記事では、CUEの基本情報の紹介から、Go APIを使用したポータビリティの高いスキーマ/バリデーション定義の考察などを行います。なお、以下のリポジトリのサンプルコードを使用します。

CUEではブラウザ上で気軽にCUEの振る舞いを確かめられるCUE Playgroundというサイトも存在します。適宜使用してみてください。

CUEの特徴

ここではCUEの特徴をざっくりとだけ紹介します。

まず、データの表現に関してです。CUEはJSONのスーパーセットです。そのため、JSONで表現可能なデータはすべてCUEで表現できます。例えば、電話番号、年齢、アクティブユーザーかどうかを示すユーザーを表してみましょう。

コード1 basic/data.cue
User: #userinfo & {          // #userinfoについては後述
		TEL: "123-1234-1234"
		Age: 10
		Is_activated: true
}

JSONやYAMLにおけるデータ管理の問題点の一つは、JSONやYAMLだけで型などの制約をかけることができない点です。しかしCUEでは型や柔軟な制約を値と似たような形で定義できます。

#userinfo: {
		TEL: string        // TEL は string 型
		Age: <999999       // Age は 999999 より小さい
		Is_activated: true // Is_activated はtrue
}

CUEでは型や制約や値をすべてまとめて同じものとして扱うTypes are valuesというコンセプトがあります。

型というのは「必ずその型である⁠⁠、値というのは「必ずその値である」という風に、どちらも制約を示していると見做せるわけですね。上記のUserの例では「TELはstring型であり、Ageは999999より小さく、Is_activatedは常にtrueの値を取る」といったように、型、制約、値の3つの概念を同じように表現していることが見て取れます(これ以降の説明では、型や制約も含めて「値」と表現します⁠⁠。

そしてCUEでは、同じフィールドに対して値を重ねがけすることができます。

コード2 basic/user.cue
import "strings"
#userinfo: X={
		TEL: string                // TEL は string 型
		Age: <999999               // Age は 999999 より小さい
		Is_activated: true         // Is_activated はtrue
		Age: uint                  // Age は uint 型.
		Age: <200                  // Age は 200 より小さい.
		
		TEL: _ |  *"000-0000-0000" // デフォルト値の定義
		_hyphen_count: strings.Count(X.TEL, "-") & 2 // TEL は二つ"-"を含む.
}

こういった形です。急にややこしい見た目になってきましたね。

CUEでは順番によって値が上書きされることはなく、どのような順番で条件を指定していっても最終的な結果が同じになるという特徴があります。すべての条件は上書きされることはありません[2]。複数の制約を定義していき、条件を徐々に絞っていっていくようなイメージです。上記の例では、例えばAgeに対するフィールドがたくさんあるので、通常のプログラミング言語を想像すると混乱するかもしれません。しかしこれらはすべて、Ageに対する条件を指定しており、上から順番に「Ageは999999以下、かつ型はuint、かつ200以下」というふうにAgeの範囲を狭めていっているのです。

strings.Countのように、標準パッケージとしていくつかの関数等も用意されており、importすることでそれを使用できます[3]。 上記の例ではstrings.Countを用いて、_hyphen_countでは、電話番号内の-の数を取得し、それが2であることを表現しています[4]

CUEの文法としては他にも、別の構造体の値を参照できたり、デフォルト値を指定できたり、標準パッケージ内の便利な関数をimportできたり、if文やfor文を使用できたりします。表現力があると言えるでしょう。

ここまででCUEの基本的な概念を紹介してきました。CUEのことをもっと知りたいという方は以下のサイトを参照することをお勧めします。

CUEによるスキーマやバリデーションのポータビリティ

CUEには様々な使用用途がありますが、その良さの一つはバリデーションに特化した仕組みと表現力のある一つの言語でスキーマやバリデーション、値を表現できる点、そしてそれを様々な形式に変換できる点だと考えています。

CUEのデータはJSON、YAML、OpenAPI、JSON Schema、Protobufなど様々な形式のデータへと変換できます[5]。また逆に、様々な形式のデータからCUEのファイルを生成することもできます。このように様々なデータ形式の種になれるのが一つ大きな強みです。

CUEで値を定義しておけば、OpenAPIやJSON Schemaとして各言語の実装にスキーマやバリデーションを配ったりできるのです。例えば、CUEはKubernetesなどの巨大なYAMLを生成するために使用することもできます。CUEでデータを型や制約と共に表現することで、巨大なYAMLを制約などで安全にバリデーションしながら、行数も抑えてデータを表現できます[6]

またCUEはGoとの親和性が高く、最初に紹介したようにCUEにはGoからCUEの変換や、CUEの定義をGoで使用できる仕組みも用意されています。

昨今のマイクロサービス化の流れ等によって、様々なサービス上で同じデータ構造を扱う必要があるケースが増えています。そのため、CUEにバリデーションなどのスキーマに関する定義をすべて詰めこんでおいて、それぞれのサービスがそのCUE定義を参照することで、バリデーションやスキーマ定義をCUEのみで管理することが可能になれば嬉しいかもしれません。

構造体のタグにCUEの定義を埋め込む

ここからはGo APIを用いて、具体的にGoの上でCUEをどのように扱えるのかを見ていきます。

コード3 go/sample1/user.go
type User struct {
		TEL string
		Age uint `cue:"<200"`
}

GoにおいてCUEを使用する最も簡単な方法です。サンプルコードでは、構造体のタグにて、Ageが200以下であるという制約を定義しています。cuegoパッケージのValidate関数を使用することで、このタグの定義に沿ったバリデーションを行うことができます。

// 通常のGoにおけるバリデーションの実装例
func (i *User) Validate() error {
		if i.Age >= 200 {
				return ErrInvalidUser
		}
		return nil
}

// cueタグに書かれた制約をもとにしたバリデーションの実装例
func (i *User) ValidateWithCUE() error {
		return cuego.Validate(i)
}

テストを実行することで、ValidateWithCUEがきちんと動作していることが確認できます。

cuegoパッケージには、他にもcueの制約やデフォルト値などから不足している値を埋めるComplete関数などがあり、Goの中でcueを様々な用途で使用できます。

GoのコードからCUEの定義を生成する

CUEではGoの構造体からCUEの定義を生成するための方法が提供されています。

サンプルコードgo/sample1では以下のコマンドを実行することで、前項のuser.goで定義されたUserの構造体からCUEのファイルuser_go_gen.cueを生成します。

cue get go github.com/sanposhiho/cue-sample/go/sample1/ --local

生成されるCUEファイルは以下のようになっています。

#User: {
		TEL: string
		Age: uint & <200
}

しっかり型の情報とcueタグの制約が記載されていることが確認できます。

このGoのコードからCUEの定義を生成するというのは、先ほど紹介したユースケースの一つである、KubernetesのマニフェストをCUEから生成する際にも役に立ちます。Kubernetesではすべての型定義がGoの構造体として行われています[7]。そのため、PodなどのKubernetesにおける基本的な型定義をGoの構造体から直接CUEに変換することで、かなりの作業数を減らすことができます。

CUEの定義からGoのコードを生成する

CUEの定義からGoのコードを生成することも可能です。ただし、コマンドとしては提供されていないので、CUEからGoに変換するGoのプログラムを記述する必要があります。

サンプルコードgo/sample2では以下のプログラムによって、user.cueからcue_gen.goを生成しています。

事前にuser.cueと同じフィールドを持ち、同じ名前のUser構造体を定義しておく必要があります。型の確認はCUE側で行われるため、あってもなくても問題ありません(とはいえ、もちろん型があったほうがGoのプログラム上でUser構造体を扱うのが楽になります⁠⁠。

するとcue_gen.go内には、User構造体に対するValidateメソッドが定義されます。

テストを実行することで、Validateがきちんと動作していることが確認できます。

終わりに

この記事ではCUEの紹介とその活用例、Go APIの具体的な使用方法の紹介などを行いました。CUEのみでバリデーションやスキーマ、データを表現でき、そこから様々な形式のデータを作成する橋渡しとなれること、特にGoにおいて柔軟な使用が可能であることが検証できたかなと思います。

CUEはこの記事執筆時でv0.4.3であり、パフォーマンスの面やモジュール管理など課題も抱えています。しかし、Types are valuesなどを始めとするCUEのコンセプトは他の言語とは一線を画しており、実際に弊社でもKubernetesの大量のマニフェストの安全な管理に役立っています。興味のある方は是非今後の動向を追ってみてください。

参考情報

おすすめ記事

記事・ニュース一覧