地方エンジニアの学習日記

興味ある技術の雑なメモだったりを書いてくブログ。たまに日記とガジェット紹介。

【CUE】入門してみる

cuelang.org

CNDTの懇親会の二次会とセッションでも触れられていたCUE言語ですが名前しか知らないが1日に2回も聞くってことはどこかで役に立つかも?ということでメモを置いておきます。

CUE言語とは?

CUE is an open source language, with a rich set of APIs and tooling, for defining, generating, and validating all kinds of data: configuration, APIs, database schemas, code, … you name it.

公式より引用すると上記のように書かれており設定ファイル、データ検証、およびデータ定義タスクに適したオープンソースの言語という理解をしました。JSONのスーパーセットとして設計されており、YAMLやTOMLなど他のデータ記述言語よりも表現力が高く、精密なデータ構造の定義と検証が可能です。CUEは、コード生成、データ検証、アプリケーションの設定、およびAPIスキーマの記述に利用されているらしいです。実装自体はGoで行われているようです。

github.com

これも公式のドキュメントにあるのですが特徴は以下の4つがあります。

  • 強力な型システム:静的な型付けを提供し、複雑なデータ構造を正確に定義できる。
  • 制約に基づく検証:データ構造に対する制約を定義することで、データの整合性を保証できる
  • コードの再利用と組み合わせ:共通のデータ構造や検証ルールをモジュール化し、再利用可能なコンポーネントとして組み合わせることができる
  • 簡潔な構文:JSONに似た構文を持ちながら、より簡潔で読みやすい形式を提供している

導入事例だとメルカリさんのKubernetes Configuration Management with CUEあたりが有名でしょうか(私はこれで知った)、他にはDaggerというCI/CDツールなどで使われているらしいです。

インストール

asdfがあったので私はそちらを使いますがGoで実装されているのでwgetなりgo installなりでよさそうです。

$ asdf plugin-add cue https://github.com/asdf-community/asdf-cue.git
$ asdf install cue latest
$ cue version
cue version v0.7.0

go version go1.21.5
      -buildmode exe
       -compiler gc
       -trimpath true
     CGO_ENABLED 0
          GOARCH arm64
            GOOS darwin

completionも実装されているので入れておきます。

$ cue completion zsh > ~/.zsh/site-functions/_cue

サンプルコード

// 人物のデータ構造を定義する
#Person: {
    name: string
    age:  int & >= 0 & <= 120
    email: string | *""
}

// 実際の人物データ
person: #Person & {
    name: "John Doe"
    age:  30
    email: "johndoe@example.com"
}

この例では、#Personという名前のデータ構造(またはスキーマ)を定義しています。この構造にはname(文字列型)、age(整数で0以上120以下)、email(文字列型、デフォルトは空文字列)というフィールドがあります。その後、このスキーマを使用して具体的な人物データを定義しています。

$ cue eval test.cue

と実行するとエラーもなく終了します。ここでageを121とかにすると

$ cue eval test.cue
person.age: invalid value 121 (out of bound <=120):
    ./test.cue:4:24
    ./test.cue:11:11

expressionsを使うことでif文なんかもかけます。結果をexportで見ることができます。

#user: {
    name: string
    age:  int
    if age <= 50 {
        isOld: false
    }
    if age > 50 {
        isOld: true
    }
}
hoge: {
    name:  "fuga"
    isOld: false
    age:   20
}

この場合にexportをすると以下のようになります。

{
    "hoge": {
        "name": "fuga",
        "isOld": false,
        "age": 20
    }
}

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

cuelang.org

CUEのAPIを使って統合して使うことができます。構造体を以下のように定義します。その状態でcue get goを打つことでスキーマファイルが生成されます。以下の例ではageが120以下にしているので121などを構造体に入れようとするとvalidatePerson()でValidation failed: #Person.Age: invalid value 121 (out of bound <120)というエラーが返るようになります。

package main

import (
    "fmt"
    "log"

    "cuelang.org/go/cue"
    "cuelang.org/go/cue/cuecontext"
    "cuelang.org/go/cue/load"
)

// Person はGoの構造体で、CUEスキーマに準拠する必要があります。
type Person struct {
    Name string
    Age  int `cue:"<120"`
}

func main() {
    // CUEコンテキストを作成
    ctx := cuecontext.New()

    // CUEファイルをロード
    instances := load.Instances([]string{"./cue.mod/gen/main/"}, nil)

    // インスタンスをビルド
    inst := ctx.BuildInstance(instances[0])
    if inst.Err() != nil {
        log.Fatalf("CUE build error: %v", inst.Err())
    }

    // CUEスキーマに基づいてGoの構造体を検証
    person := Person{"John Doe", 121}
    err := validatePerson(ctx, inst, person)
    if err != nil {
        log.Fatalf("Validation failed: %v", err)
    }

    fmt.Println("Validation succeeded:", person)
}

// validatePerson は、GoのPerson構造体をCUEスキーマに対して検証します。
func validatePerson(ctx *cue.Context, value cue.Value, p Person) error {
    personValue := ctx.Encode(p)

    // CUEスキーマを取得
    personCue := value.LookupDef("#Person")
    if personCue.Err() != nil {
        return personCue.Err()
    }

        // 検証
    unified := personCue.Unify(personValue)
    return unified.Validate()
}

PlayGround

ブラウザ上で気軽にCUEの動作を確かめられるプレイグラウンドもあったのでそちらで試してみるのもよさそうです。

cuelang.org

パッと使えそうなもの

メルカリさんの事例にあるようなk8syamlの生成なんかはシュッと使えるのかなぁと思った。サービスとかポートのスキーマだけを用意しているが本番だったらHPAとかSAとかPVCとかそれぞれを最小の記述でプロダクションレディにするとかそういう感じのやつ。今はOPAを使ってapplyするタイミングでバリデートされているがそもそも最低限の設定は自動生成させてアプリケーションエンジニアそれに沿ってyamlを書いていくだけで良いという流れ。便利そうに思えるが例えば何か設定を足したい場合なんかはCUEの柔軟性があるが故に書き方が色々あってレビューが大変そうになる未来が見える。

// Serviceの定義
#Service: {
    apiVersion: "v1"
    kind:       "Service"
    metadata: {
        name: string
        namespace?: string
        labels?: [string]: string
    }
    spec: {
        type: "ClusterIP" | "NodePort" | "LoadBalancer" | "ExternalName"
        selector?: [string]: string
        ports: [...#Port]
    }
}

// ポートの定義
#Port: {
    name?: string
    protocol: "TCP" | "UDP" | *"TCP"
    port: int
    targetPort?: int
    nodePort?: int
}

// 実際のServiceインスタンス
exampleService: #Service & {
    metadata: {
        name: "my-service"
        labels: {
            "app": "my-app"
        }
    }
    spec: {
        type: "ClusterIP"
        selector: {
            "app": "my-app"
        }
        ports: [{
            name: "http"
            port: 80
        }, {
            name: "https"
            port: 443
        }]
    }
}

これでexportするとyamlが生成されるというやつ。helmよりも柔軟に色々できるがどっちが良いとかは正直決められないかなぁ。(helmくらい制限がある方がレビューしやすいと思ってしまったが果たして...)

exportすればapplyが可能になる。

$ cue export test.cue --out yaml
exampleService:
  apiVersion: v1
  kind: Service
  metadata:
    name: my-service
    labels:
      app: my-app
  spec:
    type: ClusterIP
    selector:
      app: my-app
    ports:
      - name: http
        protocol: TCP
        port: 80
      - name: https
        protocol: TCP
        port: 443

終わりに

今回はどういうものかや活用事例とチュートリアルの触りをやってみましたが色々便利に使えるんだろうなと思う反面JSON/YAMLのスーパセット以上の使い方が無数に存在していそうだなと感じました。

qiita.com

参考

gihyo.jp

future-architect.github.io

dev.classmethod.jp