Open18

cel-goを使ったvalidationの実装の検討

podhmopodhmo

cel-goみたいなものの利用のドキュメントを生成させて自分で読む行為に意味があるか検討してみたい。

最終的にこれになった

https://github.com/podhmo/veritas

podhmopodhmo

こんな入力を渡してみていた。

ユーザー入力履歴

ユーザー:
goでgithub.com/google/cel-go/celを式言語として利用しバリデーションを行う例を教えてください


ユーザー:
入力をstructにしてコンパイル結果をキャッシュして使う方法を教えてください


ユーザー:
LRU的に古いキャッシュは破棄されるようにしてください


ユーザー:
ネストした構造のstructも対象にしてください。複数のエラーをerrors.Joinで返せるようにしてください


ユーザー:
複数の箇所で利用されるネスト構造は同じ関数によって検証されるようにしてください。またnilの場合も考慮してください。例にはrequiredタグを追加してください(実行時にreflectを使いタグを参照しなくて良いです)


ユーザー:
参考のURLを箇条書きで列挙してください。また今回のcelの代替の方法を検討しを箇条書きで列挙してください(コード例などは不要です)


ユーザー:
<details>
<summary>思考の備忘録 『思考探求』特化型 のプロンプト詳細</summary>

【指示】

Persona

あなたは、思考のプロセスを追体験できるように物語る「思考探求ガイド (Cognitive Exploration Guide)」です。

Primary Task

提供された思考内容を分析し、結論に至るまでの思考プロセス、アイデアの分岐、検討の過程を読者が「追体験」できる、自己完結型の物語的な解説文を作成してください。
思考の「なぜ」と「どのように」に焦点を当て、最終的な結論だけでなく、そこに至るまでの発見や思考の転換点を丁寧に描写してください。ただし、感傷的な表現は避け、あくまで論理的な探求の過程として記述します。

Output Behavior

  • 文章は、要約や短縮を意図的に避け、思考のニュアンスを詳細に記述してください。長さを気にする必要はありません。
  • 一つの記事に収まらない場合、内容の整合性を保ったまま「記事2」「記事3」と自動的に分割し、ユーザーに確認を求めることなく、指定されたすべての内容を一度の応答で最後まで出力しきってください。

Output Format

  • 記事は必ず # 1つのタイトル名から始めてください。
  • 思考の流れに沿って、必要に応じて章で分割してください(分割が不自然な場合は1章でも構いません)。
  • 各章のタイトルには、内容を象徴する絵文字を1つだけ加えてください。
  • 記事はMarkdownフォーマットで出力してください。
  • *** による太字や斜体での強調は一切禁止します。
  • 記事の冒頭に、以下の項目のFront matterをコードブロックとして記述してください。
    • target_reader: [対象読者を簡潔に記述]
    • objective: [この記事の狙いを簡潔に記述]
  • 専門用語は積極的に使用し、読者の理解を助けるために、必要に応じて脚注[^1]で補足説明を加えてください。

Critical Constraints

  • この文章は、元の対話の存在を知らない読者にも完全に理解できるように記述してください。必要な背景知識や用語の解説は、あなたの知識ベースから補足して構いませんが、「先ほどの対話で」や「あなたの質問に対し」のような、対話の存在を示唆する表現は絶対に使用しないでください。
  • 読者は、筆者(私)やあなた(AI)、そして元のSNS投稿の存在を知りません。それらについて一切言及しないでください。

それでは、お願いします。

</details>


今までの会話を元に、上記のプロンプトを実行してください。長さは2000字や5000字を超えても構いません。丁寧に記述してください。全ての章にコード例を加えてください。最後に参考URLを箇条書きで列挙してください。


ユーザー:
appendixとしてフィールド名及び型名が被った場合のことについて検討してください。

  • 型とフィールド名の衝突
  • 違うパッケージで同じ型名
  • (その他あれば)

またパッケージを分割した際の懸念事項も検討してください。同名の型やvalidationのファクトリーのインテグレーションなどが
思いつきます。

また値を全て大文字にするなど値の変換が絡んだ場合について検討してください。

今回もフォーマットは一つ前と同じです。
前回のネスト構造に対応したコードへの拡張として考えてコードを提案してください(つまり動作する必要があります)


ユーザー:
モジュールのファクトリーのコードは型名の衝突を考慮してますか?する必要があります。

podhmopodhmo

記事

[!NOTE]
出力された文章に嘘が含まれてます。cel.Typesはprotobuf用です。
https://zenn.dev/link/comments/010fe1f22ddeac

target_reader: Goで動的なバリデーション機構の構築を検討している開発者
objective: `cel-go`を用い、単純なバリデーションから始め、段階的にキャッシュ導入、ネスト構造対応、ロジックの再利用へと至る設計の進化と思考過程を追体験する。

cel-goによるバリデーション設計: 単純な評価から実践的構造化への探求

Goアプリケーションにおけるデータバリデーションは、システムの堅牢性を保つための重要な要素です。多くの場合、バリデーションルールは静的であり、コード内に直接記述されます。しかし、ルールを動的に変更・管理したいという要求が生まれると、静的なアプローチでは限界が見えてきます。ここでCommon Expression Language (CEL)[1]とそのGo実装であるcel-goが強力な選択肢として浮上します。

この記事では、cel-goを用いたバリデーション機構を設計する過程を、思考の変遷と共に探求します。単純な評価から始め、パフォーマンス、現実世界のデータモデルへの対応、そして保守性といった観点から、段階的に設計を洗練させていくプロセスを追体験していきます。

第一歩: 基本的な式の評価 🗺️

最初の課題は、Goの構造体データを、外部から与えられたCEL式で検証することです。この時点での目標は、まずcel-goがどのように動作するのか、その基本的なメカニズムを理解し、動くプロトタイプを作成することにあります。

最も直接的で理解しやすいアプローチは、Goの構造体を、CELがネイティブで解釈できる汎用的なデータ形式に変換することです。CELはマップやリストといった基本的なデータ構造を扱う能力を持っているため、Goのstructmap[string]interface{}に変換して評価環境に渡すのが自然な出発点となります。

この思考に基づき、以下のような実装に至ります。User構造体を定義し、そのインスタンスをmapに変換してCELプログラムのEvalメソッドに渡します。

package main

import (
	"fmt"
	"log"

	"github.com/google/cel-go/cel"
	"github.com/google/cel-go/checker/decls"
)

// バリデーション対象の構造体
type User struct {
	Name string
	Age  int
}

func main() {
	// 1. CEL環境のセットアップ
	// CEL式で利用する変数 'user' をマップ型として宣言
	env, err := cel.NewEnv(
		cel.Declarations(
			decls.NewVar("user", decls.NewMapType(decls.String, decls.Any)),
		),
	)
	if err != nil {
		log.Fatalf("failed to create CEL environment: %v", err)
	}

	// 2. バリデーションルールをCEL式として定義
	rule := "user.name != '' && user.age >= 18"

	// 3. CEL式をコンパイルし、評価可能なプログラムを生成
	ast, issues := env.Compile(rule)
	if issues != nil && issues.Err() != nil {
		log.Fatalf("failed to compile CEL expression: %v", issues.Err())
	}
	prg, err := env.Program(ast)
	if err != nil {
		log.Fatalf("failed to create CEL program: %v", err)
	}
	
	// --- バリデーションの実行 ---
	validUser := &User{Name: "Alice", Age: 30}
	
	// 構造体をマップに変換して評価
	vars := map[string]interface{}{
		"user": map[string]interface{}{
			"name": validUser.Name,
			"age":  validUser.Age,
		},
	}
	
	out, _, err := prg.Eval(vars)
	if err != nil {
		log.Fatalf("failed to evaluate: %v", err)
	}

	fmt.Printf("User: %+v, IsValid: %v\n", *validUser, out.Value())
}

このコードは、cel-goの基本的なワークフロー(環境作成、コンパイル、プログラム生成、評価)を示しています。しかし、この実装にはいくつかの改善すべき点が潜んでいます。バリデーションのたびに手動でmapへ変換するのは冗長であり、ヒューマンエラーの元です。また、ルール文字列は毎回コンパイルされており、これも非効率です。

効率化への道: 型登録とコンパイル結果のキャッシュ ⚡

プロトタイプが動作したことで、次の思考ステップは「より効率的で、よりGoらしいコードにするにはどうすればよいか」という問いに移ります。

第一の改善点は、手動でのマップ変換の排除です。cel-goは、Goの型情報を環境に登録することで、リフレクション[2]を利用して構造体のフィールドに直接アクセスする機能を提供しています。cel.NewEnvcel.Types(&User{})を渡すことで、CEL環境はmain.Userという型を認識し、user.Nameのようなフィールドアクセスを静的に型チェックできるようになります。これにより、コードはクリーンになり、型の安全性が向上します。

第二の改善点は、パフォーマンスです。バリデーションルールはアプリケーションの実行中に頻繁に変更されるものではない場合が多いです。しかし、リクエストのたびに同じルール文字列をコンパイルするのはCPUサイクルの無駄です。CELのコンパイル結果であるcel.Programオブジェクトは再利用可能です。したがって、これをキャッシュする機構を導入するのが論理的な帰結です。

Webサーバーのようなマルチゴルーチン環境で安全に動作させるためには、キャッシュへのアクセスはスレッドセーフでなければなりません。ここではsync.RWMutexを用いて、読み取り(キャッシュの存在確認)と書き込み(新しいコンパイル結果の保存)を安全に制御するアプローチを選択します。この思考プロセスをカプセル化するため、Validatorという構造体を導入し、バリデーションに関連する責務をまとめます。

package main

import (
	"fmt"
	"log"
	"sync"

	"github.com/google/cel-go/cel"
)

type User struct {
	Name string
	Age  int
}

// バリデーション機能を提供する構造体
type Validator struct {
	env   *cel.Env
	cache map[string]cel.Program // コンパイル結果のキャッシュ
	mu    sync.RWMutex           // キャッシュアクセス用のMutex
}

func NewValidator() (*Validator, error) {
	// CEL環境にGoの構造体型を直接登録
	env, err := cel.NewEnv(
		cel.Types(&User{}),
		cel.Variable("user", cel.ObjectType("main.User")),
	)
	if err != nil {
		return nil, err
	}
	return &Validator{
		env:   env,
		cache: make(map[string]cel.Program),
	}, nil
}

// getProgram はコンパイル済みプログラムをキャッシュ経由で取得する
func (v *Validator) getProgram(rule string) (cel.Program, error) {
	v.mu.RLock()
	prg, found := v.cache[rule]
	v.mu.RUnlock()
	if found {
		return prg, nil
	}

	v.mu.Lock()
	defer v.mu.Unlock()
	
	// ダブルチェックロッキング
	if prg, found := v.cache[rule]; found {
		return prg, nil
	}

	ast, issues := v.env.Compile(rule)
	if issues != nil && issues.Err() != nil {
		return nil, issues.Err()
	}
	newPrg, err := v.env.Program(ast)
	if err != nil {
		return nil, err
	}
	v.cache[rule] = newPrg
	log.Printf("Rule compiled and cached: %s", rule)
	return newPrg, nil
}

// Validate は構造体を直接入力としてバリデーションを実行
func (v *Validator) Validate(user *User, rule string) (bool, error) {
	prg, err := v.getProgram(rule)
	if err != nil {
		return false, err
	}

	// 構造体のポインタを直接渡す
	out, _, err := prg.Eval(map[string]interface{}{"user": user})
	if err != nil {
		return false, err
	}
	return out.Value().(bool), nil
}

func main() {
	validator, _ := NewValidator()
	rule := "user.Name != '' && user.Age >= 18"
	
	// 1回目の実行: コンパイルとキャッシュが行われる
	validator.Validate(&User{Name: "Alice", Age: 30}, rule)
	
	// 2回目の実行: キャッシュが利用される
	validator.Validate(&User{Name: "Bob", Age: 20}, rule)
}

この設計により、コードはより効率的かつ堅牢になりました。しかし、このキャッシュ機構にはまだ隠れた問題があります。キャッシュが際限なく増え続ける可能性です。

メモリ管理の成熟: LRUキャッシュの導入 🧠

アプリケーションが多様なバリデーションルールを扱う場合、シンプルなmapによるキャッシュは、アプリケーションの実行時間と共に増大し続け、いずれはメモリを圧迫する可能性があります。実用的なシステムでは、キャッシュのサイズに上限を設け、古いエントリを破棄する戦略が必要です。

この問題に対する古典的かつ効果的な解決策がLRU (Least Recently Used) キャッシュです。これは、最も長い間アクセスされていないエントリから順に破棄していくアルゴリズムです。Goの標準ライブラリにはLRUの実装は含まれていませんが、コミュニティにはhashicorp/golang-lruのような、広く利用され実績のあるライブラリが存在します。

自前でLRUロジックとスレッドセーフティを実装するよりも、このような専門的なライブラリを利用する方が、コードの簡潔性と信頼性の両面で優れています。そこで、我々のValidator構造体内の自作キャッシュ機構をlru.Cacheに置き換える決断をします。これにより、自前のsync.RWMutexも不要になり、コードはさらにシンプルになります。

package main

import (
	"fmt"
	"log"

	"github.com/google/cel-go/cel"
	lru "github.com/hashicorp/golang-lru"
)

// User構造体は変更なし
type User struct {
	Name string
	Age  int
}

// Validatorはlru.Cacheを持つように変更
type Validator struct {
	env   *cel.Env
	cache *lru.Cache // LRUキャッシュ
}

func NewValidatorWithLRU(size int) (*Validator, error) {
	env, err := cel.NewEnv(
		cel.Types(&User{}),
		cel.Variable("user", cel.ObjectType("main.User")),
	)
	if err != nil {
		return nil, err
	}
	
	// スレッドセーフなLRUキャッシュを生成
	cache, err := lru.New(size)
	if err != nil {
		return nil, err
	}

	return &Validator{env: env, cache: cache}, nil
}

func (v *Validator) getProgram(rule string) (cel.Program, error) {
	// キャッシュ取得を試みる
	if prg, ok := v.cache.Get(rule); ok {
		return prg.(cel.Program), nil
	}

	// キャッシュミス:コンパイルしてキャッシュに追加
	log.Printf("LRU cache: Miss. Compiling rule: %s", rule)
	ast, issues := v.env.Compile(rule)
	if issues != nil && issues.Err() != nil {
		return nil, issues.Err()
	}
	prg, err := v.env.Program(ast)
	if err != nil {
		return nil, err
	}
	v.cache.Add(rule, prg)
	return prg, nil
}

func (v *Validator) Validate(user *User, rule string) (bool, error) {
	prg, err := v.getProgram(rule)
	if err != nil {
		return false, err
	}
	out, _, err := prg.Eval(map[string]interface{}{"user": user})
	if err != nil {
		return false, err
	}
	return out.Value().(bool), nil
}

func main() {
	// サイズが2のキャッシュを作成
	validator, _ := NewValidatorWithLRU(2)
	user := &User{Name: "Test", Age: 25}

	validator.Validate(user, "user.Age > 20") // キャッシュに追加
	validator.Validate(user, "user.Name != ''") // キャッシュに追加
	
	// 3つ目のルールを追加すると、最初のルールが破棄される
	validator.Validate(user, "user.Age < 30") 
	
	// 破棄されたルールに再度アクセスすると、再コンパイルが走る
	validator.Validate(user, "user.Age > 20")
}

golang-lru`の導入により、我々のバリデーション機構はパフォーマンスとメモリ管理のバランスが取れた、より成熟した姿になりました。

現実世界のデータモデルへ: ネスト構造と複数エラーの集約 🧱

これまでの設計は、フラットなデータ構造を前提としていました。しかし、現実のアプリケーションが扱うデータは、しばしばネストした構造を持っています。例えば、UserAddressという別の構造体を持つケースです。また、ユーザーエクスペリエンスを考慮すると、フォーム送信時などに複数のバリデーションエラーを一度にまとめて提示することが望ましいです。

これらの要求に応えるため、設計をさらに拡張します。

第一に、ネスト構造への対応です。これはcel-goの型登録システムを拡張することで実現できます。User型だけでなく、それが内包するAddress型もcel.Types()に登録します。これにより、CEL式はuser.Address.Cityのようなドット記法でネストしたフィールドにシームレスにアクセスできるようになります。

第二に、複数エラーの集約です。Go 1.20で標準ライブラリに導入されたerrors.Join関数は、この目的のために設計されたかのように完璧に適合します。複数のルールをループで評価し、失敗したルールそれぞれについてerrorオブジェクトを生成し、スライスに蓄積します。ループの最後にerrors.Joinを呼び出すことで、これら複数のエラーを単一のerrorインターフェース値として返すことができます。

この思考に基づき、複数のルールを受け取り、失敗したすべてのルールを報告するValidateMulti関数を実装します。

package main

import (
	"errors"
	"fmt"
	"log"

	"github.com/google/cel-go/cel"
	lru "github.com/hashicorp/golang-lru"
)

type Address struct {
	City    string
	Country string
}

type User struct {
	Name    string
	Address Address
}

type Validator struct {
	env   *cel.Env
	cache *lru.Cache
}

func NewValidatorWithNestedTypes(size int) (*Validator, error) {
	// ネストされたAddress型も登録
	env, err := cel.NewEnv(
		cel.Types(&User{}, &Address{}),
		cel.Variable("user", cel.ObjectType("main.User")),
	)
	if err != nil { return nil, err }
	cache, err := lru.New(size)
	if err != nil { return nil, err }
	return &Validator{env: env, cache: cache}, nil
}

func (v *Validator) getProgram(rule string) (cel.Program, error) {
	// (実装は前章と同じ)
	if prg, ok := v.cache.Get(rule); ok { return prg.(cel.Program), nil }
	ast, issues := v.env.Compile(rule)
	if issues != nil && issues.Err() != nil { return nil, issues.Err() }
	prg, err := v.env.Program(ast)
	if err != nil { return nil, err }
	v.cache.Add(rule, prg)
	return prg, nil
}

// ValidateMulti は複数のルールをチェックし、エラーをまとめて返す
func (v *Validator) ValidateMulti(user *User, rules []string) error {
	var validationErrors []error

	for _, rule := range rules {
		prg, err := v.getProgram(rule)
		if err != nil { return err }

		out, _, err := prg.Eval(map[string]interface{}{"user": user})
		if err != nil { return err }

		if isValid, _ := out.Value().(bool); !isValid {
			err := fmt.Errorf("validation failed: %s", rule)
			validationErrors = append(validationErrors, err)
		}
	}
	return errors.Join(validationErrors...)
}

func main() {
	validator, _ := NewValidatorWithNestedTypes(10)
	rules := []string{
		"user.Name != ''",
		"user.Address.City != ''",
		"user.Address.Country == 'JP'",
	}
	
	// 2つのルールに違反するユーザー
	invalidUser := &User{
		Name:    "Test",
		Address: Address{City: "", Country: "UK"},
	}

	if err := validator.ValidateMulti(invalidUser, rules); err != nil {
		fmt.Printf("Validation failed with multiple errors:\n%v\n", err)
	}
}

このステップにより、我々のバリデーション機構は、より複雑なデータ構造と、より優れたユーザーフィードバックを提供する能力を獲得しました。

設計の洗練: ロジックの再利用とnil安全性の確保 💠

最終段階として、バリデーションロジックの保守性と再利用性に目を向けます。大規模なアプリケーションでは、同じ型のオブジェクトがシステムの異なる場所で使われることは珍しくありません。例えば、UserPrimaryAddressWorkAddressの両方を持つ場合、Address構造体に対する検証ロジックが重複してしまいます。これはDRY (Don't Repeat Yourself) の原則に反します。

この課題を解決するための思考は、「関心の分離」です。Addressを検証するロジックを、Userを検証するロジックから切り離し、独立したコンポーネントとして扱います。具体的には、addressRulesというルールセットと、それを適用するValidateAddressというヘルパー関数を作成します。

さらに、現実のデータモデルではポインタが多用され、nilの存在を考慮する必要があります。フィールドがrequired(必須)なのか、それともオプショナル(任意)なのかによって、nilの扱いは変わります。このロジックは、CEL式で表現するよりも、Goのコード側で明示的にif obj == nilのようにチェックする方が、意図が明確になり、可読性が高まります。

  • requiredなフィールド: nilであればエラーとする。nilでなければ中身を検証する。
  • オプショナルなフィールド: nilであれば何もしない。nilでなければ中身を検証する。

この階層的で再利用可能な設計を実装することで、バリデーション機構は最終形へと至ります。ValidateUserがエントリーポイントとなり、自身のフィールドを検証した後、各Addressフィールドの性質に応じてValidateAddressを呼び出します。エラーメッセージには、どのフィールドで問題が起きたかのコンテキストを付与し、デバッグの容易性も確保します。

package main

import (
	"errors"
	"fmt"
	"log"

	"github.com/google/cel-go/cel"
	lru "github.com/hashicorp/golang-lru"
)

type Address struct {
	City    string
	Country string
}

type User struct {
	Name           string
	WorkAddress    *Address // 必須
	VacationHome   *Address // オプショナル
}

var addressRules = []string{
	"addr.City != ''",
	"addr.Country == 'JP' || addr.Country == 'US'",
}
var userRules = []string{"user.Name != ''"}

type Validator struct {
	env   *cel.Env
	cache *lru.Cache
}

func NewValidator(size int) (*Validator, error) {
	// addrという汎用的な変数も宣言しておく
	env, err := cel.NewEnv(
		cel.Types(&User{}, &Address{}),
		cel.Variable("user", cel.ObjectType("main.User")),
		cel.Variable("addr", cel.ObjectType("main.Address")),
	)
	if err != nil { return nil, err }
	cache, err := lru.New(size)
	if err != nil { return nil, err }
	return &Validator{env: env, cache: cache}, nil
}

func (v *Validator) getProgram(rule string) (cel.Program, error) { /* ... */ return nil, nil}

func (v *Validator) validateObject(obj interface{}, rules []string, varName string) error { /* ... */ return nil }

// ValidateAddressはAddressの検証ロジックをカプセル化
func ValidateAddress(v *Validator, addr *Address) error {
	return v.validateObject(addr, addressRules, "addr")
}

// ValidateUserはエントリーポイントとして全体の検証をオーケストレーション
func ValidateUser(v *Validator, user *User) error {
	var errs []error

	// User自体のルールを検証
	if err := v.validateObject(user, userRules, "user"); err != nil {
		errs = append(errs, err)
	}

	// WorkAddress (必須) の検証
	if user.WorkAddress == nil {
		errs = append(errs, errors.New("WorkAddress is required"))
	} else if err := ValidateAddress(v, user.WorkAddress); err != nil {
		errs = append(errs, fmt.Errorf("WorkAddress: %w", err))
	}

	// VacationHome (オプショナル) の検証
	if user.VacationHome != nil {
		if err := ValidateAddress(v, user.VacationHome); err != nil {
			errs = append(errs, fmt.Errorf("VacationHome: %w", err))
		}
	}

	return errors.Join(errs...)
}

この探求の旅を通じて、我々は単純な式評価から始まり、パフォーマンス、メモリ管理、複雑なデータモデルへの対応、そして保守性といった現実的な要求に応えるため、段階的に設計を進化させてきました。最終的に到達した階層的で再利用可能な設計は、cel-goの持つ動的な能力と、Goの静的な型システムの長所を組み合わせた、堅牢でスケール可能なバリデーション機構の一つの答えと言えるでしょう。

参考URL

脚注
  1. Common Expression Language (CEL): Googleによって開発された、式を評価するためのオープンソースの非チューリング完全な言語。主に、設定ファイルやAPIのセキュリティポリシーなどで、安全かつ高速にロジックを実行するために設計されている。 ↩︎

  2. リフレクション (Reflection): プログラムが実行時に自分自身の構造(型、フィールド、メソッドなど)を調べたり、操作したりする能力のこと。cel-goはリフレクションを利用して、Goのstructのフィールド値にアクセスしている。 ↩︎

podhmopodhmo
target_reader: 大規模アプリケーションで`cel-go`の導入を検討し、名前空間の衝突やパッケージ分割などの現実的課題に直面しているGo開発者
objective: `cel-go`を用いたバリデーション機構を、名前衝突、パッケージ分割、値の正規化といった高度なシナリオに対応させるための設計パターンと思考過程を詳説する。

Appendix: 実践的課題への挑戦 - 名前空間、パッケージ分割、そして値の変換

前回の探求では、cel-goを用いて単純な評価から始め、キャッシュ導入、ネスト構造対応、そしてロジックの再利用へと至る、段階的なバリデーション機構の設計を追体験した。これにより、堅牢でパフォーマンスの良い基盤が完成した。

しかし、ソフトウェアの規模が拡大し、複数の開発者が関わるようになると、新たな、そしてより複雑な課題が姿を現す。名前の衝突、コードベースの分割、そして入力データの不統一性といった問題である。このAppendixでは、我々が構築した設計を土台として、これらの実践的な課題に立ち向かうためのさらなる思考の探求に乗り出す。

名前の衝突という迷宮 🧩

ソフトウェア開発において、名前は概念を表現するための基本的なツールである。しかし、アプリケーションが成長するにつれて、異なる文脈で同じ名前が使われることは避けられない。cel-goの環境において、これらの名前の衝突をいかにして解決し、曖昧さのないバリデーションを実現するかを考察する。

パッケージ境界を越える同名異型

最も一般的なシナリオは、異なるドメイン(Goパッケージ)で同じ名前の型が定義されるケースである。例えば、ECサイトのアプリケーションでは、「請求先住所(Billing Address)」と「配送先住所(Shipping Address)」は、どちらもAddressという名前で表現されるかもしれないが、その内容は異なる。請求先にはクレジットカード情報が、配送先には配達指示が含まれるかもしれない。

cel-goは、Goの型システムと統合されているため、この問題をエレガントに解決する。cel.Types()で型を登録する際、ライブラリは内部的にパッケージパスを含む完全な型識別子(例: billing.Addressshipping.Address)でそれらを管理する。これにより、たとえ型名が同じであっても、cel-goの環境内では明確に区別される。

この区別をCEL式の中で利用するには、cel.ObjectType()で型を宣言する際に、パッケージ名を含めた文字列を指定する。

この概念を実証するため、これまでのUserの例から、より複雑なOrderドメインへと舞台を移そう。

package main

// --- model/billing/address.go ---
// (パッケージが分かれていると仮定)
package billing
type Address struct {
	PostalCode   string
	Country      string
	CardLastFour string
}

// --- model/shipping/address.go ---
package shipping
type Address struct {
	PostalCode    string
	Country       string
	Instructions  string
}

// --- model/order.go ---
package model
import (
	"example.com/billing"
	"example.com/shipping"
)
type Order struct {
	OrderID         string
	BillingAddress  *billing.Address
	ShippingAddress *shipping.Address
}

// --- validator.go (アプリケーションのルート近く) ---
// (パッケージをインポートして利用)

func main() {
	// ... Validatorのセットアップ ...
	
	// Orderのバリデーションルール
	rules := []string{
		// Billing Addressのルール
		"order.BillingAddress.Country == 'JP'",
		"order.BillingAddress.CardLastFour.size() == 4",
		// Shipping Addressのルール
		"order.ShippingAddress.Country == 'JP'",
		"!has(order.ShippingAddress.Instructions) || order.ShippingAddress.Instructions.size() < 100",
	}

	// ... バリデーションの実行 ...
}

上記のCEL式では、order.BillingAddressorder.ShippingAddressは、Goのコード上での型定義(billing.Addressshipping.Address)に正しくマッピングされる。cel-goの型システムが、cel.NewEnvに渡された型情報(cel.Types(&billing.Address{}, &shipping.Address{}))から、この関連付けを解決してくれるからだ。これにより、名前の衝突は問題とならず、それぞれのフィールドに対して的確なルールを適用できる。

パッケージ分割と協調的バリデーション 🤝

前述のシナリオは、新たな課題、すなわち「パッケージ分割」を浮き彫りにする。アプリケーションがモジュール化され、billingshippingのようなドメインごとにパッケージが分割されると、中央集権的なバリデーション設定はボトルネックとなる。Validatorを生成するファクトリ関数が、アプリケーション内のすべての型を知っている必要がある、という制約は、パッケージの独立性を損ない、保守性を低下させる。billingパッケージに新しい型を追加した際、開発者はvalidatorパッケージのファクトリを修正し忘れるかもしれない。

この問題を解決するには、各パッケージが自身のバリデーション要件を「自己記述」する、協調的なアプローチが必要である。DI (Dependency Injection) の原則に倣い、各ドメインパッケージが共通のインターフェースを実装し、自身の型情報を提供できるようにする。

ValidationModuleというインターフェースを定義しよう。

// validation/module.go
package validation

import "github.com/google/cel-go/cel"

type ValidationModule interface {
	// このモジュールがバリデーション対象とする型を返す
	GetTypes() []any
}

各ドメインパッケージは、このインターフェースを実装したモジュール定義を提供する。

// billing/module.go
package billing

import "example.com/validation"

type BillingModule struct{}

func (m BillingModule) GetTypes() []any {
	// billingパッケージが公開する型
	return []any{&Address{}}
}

// shipping/module.go
package shipping

import "example.com/validation"

type ShippingModule struct{}

func (m ShippingModule) GetTypes() []any {
	// shippingパッケージが公開する型
	return []any{&Address{}}
}

そして、Validatorのファクトリ関数は、これらのモジュールのスライスを受け取るように変更される。これにより、ファクトリは具体的な型を知る必要がなくなり、各モジュールから提供される情報に基づいて動的にcel.Envを構築できる。

// validator/factory.go
func NewValidator(size int, modules ...validation.ValidationModule) (*Validator, error) {
	var allTypes []any
	for _, m := range modules {
		allTypes = append(allTypes, m.GetTypes()...)
	}
	
	// トップレベルの型も追加
	allTypes = append(allTypes, &model.Order{})

	env, err := cel.NewEnv(
		cel.Types(allTypes...),
		// Orderを 'order' という変数名で利用可能にする
		cel.Variable("order", cel.ObjectType("model.Order")),
	)
	// ... (以下、キャッシュの設定など)
}

// main.go
func main() {
	// アプリケーション起動時に、各モジュールを集めてValidatorを構築
	validator, err := validation.NewValidator(
		100,
		billing.BillingModule{},
		shipping.ShippingModule{},
	)
	// ...
}

この設計により、バリデーションシステムの関心事がきれいに分離された。ドメインパッケージは自身の型定義に責任を持ち、Validatorファクトリはそれらを集約して環境を構築する責務に集中できる。新しいドメインパッケージを追加する際は、ValidationModuleを実装し、main関数でファクトリに渡すだけでよく、既存のコードへの変更を最小限に抑えられる。

バリデーション前の正規化: カスタム関数の導入 🔧

最後の探求テーマは、データの「正規化」である。ユーザーからの入力は、大文字小文字が混在していたり、不要な空白が含まれていたりと、不均一であることが多い。"jp""JP"を同じ国コードとして扱いたい場合、比較の前に値を正規化(この場合は大文字化)するステップが不可欠である。

CEL自体は豊富な標準関数を持つが、アプリケーション固有の正規化ロジックを実装するには、カスタム関数を登録する機能が必要になる。cel-gocel.Functionを通じてこれを可能にする。

我々のバリデーション機構に、文字列を大文字化するupper()関数と、小文字化するlower()関数を追加してみよう。これには、まずGoで対応する関数を定義し、それをcel.Functionでラップしてcel.Envに登録する。

このカスタム関数の登録責務も、ValidationModuleに持たせるのが理にかなっている。将来、特定のドメインが独自のカスタム関数を必要とするかもしれないからだ。

ValidationModuleインターフェースを拡張する。

// validation/module.go
type ValidationModule interface {
	GetTypes() []any
	// このモジュールが提供するカスタム関数(EnvOption)を返す
	GetFunctions() []cel.EnvOption
}```

汎用的な文字列操作関数を提供する`common`モジュールを作成する。

```go
// common/module.go
package common

import (
    "strings"
    "github.com/google/cel-go/cel"
    "github.com/google/cel-go/common/types"
    "github.com/google/cel-go/common/types/ref"
)

type CommonModule struct{}

func (m CommonModule) GetTypes() []any { return nil }

func (m CommonModule) GetFunctions() []cel.EnvOption {
    // upper()関数を 'string.upper()' 形式で呼び出せるように登録
    upperFunc := cel.Function("upper",
        cel.MemberOverload("string_upper", []*cel.Type{cel.StringType}, cel.StringType,
            cel.UnaryBinding(func(val ref.Val) ref.Val {
                // Goのstrings.ToUpperを呼び出す
                s, ok := val.Value().(string)
                if !ok { return types.NewErr("invalid type for upper()") }
                return types.String(strings.ToUpper(s))
            }),
        ),
    )
    // (同様にlowerFuncも定義)
    return []cel.EnvOption{upperFunc /*, lowerFunc */}
}

NewValidatorファクトリも、モジュールから関数定義を受け取るように修正する。そして、この新しいupper()関数を利用して、Addressのバリデーションルールをより堅牢なものに改良できる。

// 以前のルール
// "addr.Country == 'JP' || addr.Country == 'US'"

// 新しいルール
// "addr.Country.upper() == 'JP' || addr.Country.upper() == 'US'"

この新しいルールは、入力が"jp""Jp""jP"のいずれであっても、正規化された"JP"と比較されるため、正しくバリデーションが機能する。

// --- 最終的なコードの断片 ---
func ValidateOrder(v *Validator, order *model.Order) error {
    var errs []error
    
    // billing.Addressに対するルールセット
    billingRules := []string{
        "addr.PostalCode.size() > 0",
        // カスタム関数を利用
        "addr.Country.upper() == 'JP' || addr.Country.upper() == 'US'",
    }

    // shipping.Addressに対するルールセット
    shippingRules := []string{
        // ...
    }
    
    // ... ValidateAddressヘルパーを呼び出して検証 ...

    return errors.Join(errs...)
}

func main() {
	// 全モジュールを集約してValidatorを生成
	validator, _ := validation.NewValidator(
		100,
		common.CommonModule{},
		billing.BillingModule{},
		shipping.ShippingModule{},
	)

	// ...
	order := &model.Order{
		BillingAddress: &billing.Address{Country: "jp"}, // 小文字でもOK
		// ...
	}
	
	err := ValidateOrder(validator, order)
	// err は nil になるはず
}

この最後の拡張により、我々のバリデーション機構は、単にデータの構造を検証するだけでなく、その内容を意味的に解釈し、正規化した上で評価するという、より高度な能力を獲得した。モジュール化された設計は、このような機能拡張を、システムの他の部分に影響を与えることなく、クリーンに追加できることを証明している。我々の探求は、実践的な課題を乗り越え、大規模で進化し続けるアプリケーションにも耐えうる、真に堅牢な設計へとたどり着いたのである。

podhmopodhmo
target_reader: Goでモジュラーな大規模アプリケーションを構築し、`cel-go`を用いたバリデーションシステムの整合性とスケーラビリティを確保したいアーキテクトおよび開発者
objective: パッケージ分割された環境における型名の衝突問題を深く考察し、`cel-go`の環境構築をより明示的かつ堅牢にするためのファクトリパターンの改良案を、具体的なコードと共に提示する。

堅牢な名前空間の構築: パッケージ分割における型名衝突の解決

前回の探求で構築したモジュールベースのバリデーション機構は、関心を分離し、拡張性を高めるための大きな一歩だった。しかし、ご指摘の点は非常に的確かつ重要です。すなわち、「モジュールのファクトリーは型名の衝突を考慮しているか?」という問いです。

結論から言えば、前回の実装は暗黙的な挙動に依存しており、衝突を解決するための明示的な機構が欠けていました。cel-goがGoのリフレクションを通じてパッケージパスを内部的に認識するため、特定のシナリオ(トップレベルの構造体経由でのアクセス)では問題が顕在化しなかったに過ぎません。しかし、堅牢なシステムは暗黙の挙動に頼るべきではありません。バリデーションルールを書く開発者が、どの型を指しているのかを常に明確に意識できる、明示的な設計が不可欠です。

この探求では、その核心的な課題、すなわち「名前空間の管理」に焦点を当て、ファクトリとモジュールの設計をさらに洗練させていきます。

暗黙の解決から明示的な宣言へ 📝

前回のNewValidatorファクトリは、各モジュールから[]any形式で型のリストを受け取り、それらをまとめてcel.Types()に渡していました。

// 以前のアプローチ
var allTypes []any
for _, m := range modules {
    allTypes = append(allTypes, m.GetTypes()...)
}
env, err := cel.NewEnv(cel.Types(allTypes...))

このアプローチの問題点は、CEL環境内での「型の名前」が、cel-goの内部的な名前解決ロジックに委ねられていることです。例えば、billing.Addressshipping.Addressという2つの型があったとして、CEL式の中でbilling.Address型を明示的に参照したい場合、そのための公式な名前が何であるかが、ファクトリのコードからは読み取れませんでした。

この曖昧さを排除し、設計をより堅牢にするために、ValidationModuleの責務を拡張します。各モジュールは、単にGoの型を渡すだけでなく、CEL環境を構築するための完全な設定オプション(cel.EnvOption)を提供する責務を負うべきです。これには、型の登録(cel.Types)だけでなく、その型をCELの世界でどのように名付けるかの宣言(cel.Declarations)も含まれます。

ValidationModuleインターフェースを次のように変更します。

// validation/module.go

// ValidationModuleは、自身の要件をcel.EnvOptionの形式で完全に記述する
type ValidationModule interface {
	// このモジュールが必要とするCEL環境オプションを返す
	GetEnvOptions() []cel.EnvOption
}

この変更により、NewValidatorファクトリは、各モジュールの内部事情(どの型や関数を登録する必要があるか)を知る必要がなくなります。ファクトリの役割は、すべてのモジュールからEnvOptionを集約し、cel.NewEnvに渡すだけの、よりシンプルで汎用的なものになります。

モジュールによる自己記述的バリデーション ✍️

新しいインターフェースに基づき、各ドメインパッケージ(billingshipping)がどのように自身のバリデーション要件を自己記述するかを見ていきましょう。

それぞれのモジュールは、自身のパッケージ名を冠したCELの型名を宣言します。例えば、billingパッケージのAddress型は、CEL環境内でbilling.Addressという名前で参照できるようにします。

// --- パッケージのシミュレーション ---
package main

// --- billing/types.go ---
package billing 
type Address struct {
	PostalCode   string
	CardLastFour string
}

// --- shipping/types.go ---
package shipping
type Address struct {
	PostalCode   string
	Instructions string
}

// --- model/order.go ---
package model
import (
    "example.com/billing"
    "example.com/shipping"
)
type Order struct {
	OrderID         string
	BillingAddress  *billing.Address
	ShippingAddress *shipping.Address
}

// --- validation/module.go ---
package validation
import "github.com/google/cel-go/cel"
type ValidationModule interface {
	GetEnvOptions() []cel.EnvOption
}


// --- billing/module.go ---
package billing
import (
    "github.com/google/cel-go/cel"
    "github.com/google/cel-go/checker/decls"
    "example.com/validation"
)

type BillingModule struct{}

func (m BillingModule) GetEnvOptions() []cel.EnvOption {
	return []cel.EnvOption{
		// 1. Goの型をCEL環境に知らせる
		cel.Types(&Address{}),
		// 2. CEL式で使えるように型宣言を行う
		// これにより、'billing.Address'という名前で型を明示的に参照可能になる
		cel.Declarations(
			decls.NewObjectType("billing.Address", nil),
		),
	}
}

// --- shipping/module.go ---
package shipping
import (
    "github.com/google/cel-go/cel"
    "github.com/google/cel-go/checker/decls"
    "example.com/validation"
)

type ShippingModule struct{}

func (m ShippingModule) GetEnvOptions() []cel.EnvOption {
	return []cel.EnvOption{
		cel.Types(&Address{}),
		cel.Declarations(
			decls.NewObjectType("shipping.Address", nil),
		),
	}
}

この設計の重要な点は、decls.NewObjectType("billing.Address", nil)というコードです。これにより、CELの型チェッカーに対して、「billing.Addressという名前の型が存在する」ことを明示的に宣言しています。そしてcel.Types(&Address{})によって、その宣言が実際のGoのbilling.Address型にリンクされます。

すべてを集約するファクトリ 🏭

新しいValidationModuleインターフェースの導入により、NewValidatorファクトリの実装は劇的に簡素化され、同時に堅牢性が向上します。

// --- validator/factory.go ---
package validator

import (
    "github.com/google/cel-go/cel"
    lru "github.com/hashicorp/golang-lru"
    "example.com/model"
    "example.com/validation"
)

type Validator struct {
	env   *cel.Env
	cache *lru.Cache
}

func NewValidator(size int, modules ...validation.ValidationModule) (*Validator, error) {
	// すべてのモジュールからEnvOptionを収集する
	var opts []cel.EnvOption
	for _, m := range modules {
		opts = append(opts, m.GetEnvOptions()...)
	}

	// アプリケーションのトップレベルの型と変数のためのオプションも追加
	opts = append(opts,
		cel.Types(&model.Order{}),
		cel.Variable("order", cel.ObjectType("model.Order")),
	)

	// 収集したすべてのオプションで環境を生成
	env, err := cel.NewEnv(opts...)
	if err != nil {
		return nil, err
	}
	
	cache, err := lru.New(size)
	if err != nil {
		return nil, err
	}
	
	return &Validator{env: env, cache: cache}, nil
}

この新しいファクトリは、もはやモジュールが何を登録しようとしているのかを気にする必要がありません。ただcel.EnvOptionを集めてcel.NewEnvに渡すだけです。これにより、ファクトリとモジュールの間の結合度が下がり、システム全体の保守性が向上します。

この明示的な型宣言の真価は、is()のような型チェック関数を使う際に発揮されます。

// --- アプリケーションコード ---

func main() {
    // ... validatorのセットアップ ...

    rules := []string{
        // 型チェック: order.BillingAddressが本当にbilling.Address型かを確認
        // この 'billing.Address' という名前が、モジュールによって明示的に宣言されたものである
        "order.BillingAddress.is(billing.Address)",
        
        // 型チェック: ShippingAddressの型を確認
        "order.ShippingAddress.is(shipping.Address)",
    }

    order := &model.Order{
        BillingAddress:  &billing.Address{PostalCode: "100-0001"},
        ShippingAddress: &shipping.Address{PostalCode: "T2P 2V6"},
    }

    // ... バリデーション実行 ...
    // このバリデーションは成功するはず
}

もしorder.BillingAddressに誤ってshipping.Addressのインスタンスがセットされていた場合、order.BillingAddress.is(billing.Address)というルールはfalseを返し、バリデーションは失敗します。これにより、型レベルでの不整合をCEL式で確実に検出できるようになりました。

この探求を通じて、我々はモジュール化されたシステムにおける名前空間の衝突という、現実的で厄介な問題を解決するための設計パターンを確立しました。各モジュールが自身のバリデーション要件をcel.EnvOptionとして自己記述し、ファクトリがそれらを集約するアプローチは、cel-goの能力を最大限に引き出しつつ、スケーラブルで保守性の高いバリデーション機構を構築するための、強力な基盤となるのです。

podhmopodhmo
target_reader: Goアーキテクトおよびシニア開発者、特に大規模でモジュラーなシステムにおける設定の可視性とデバッグの容易性を重視する者
objective: `cel-go`を用いたバリデーションシステムのファクトリに、登録内容を走査・報告する「イントロスペクター」機構を導入する。これにより、どのモジュールが何を登録したかを明確に把握し、システムの透明性と保守性を劇的に向上させる設計パターンを提示する。

透明性への最終フロンティア: バリデーション環境のイントロスペクション

我々はこれまでの探求で、cel-goのバリデーション機構をモジュール化し、パッケージ分割と名前空間の衝突という課題を乗り越えてきた。各モジュールが自身の要件を自己記述し、ファクトリがそれらを集約する設計は、システムの拡張性を大きく向上させた。しかし、この分散的なアプローチは、新たな、そして極めて重要な問いを生む。「最終的に構築されたバリデーション環境には、一体何が登録されているのか?」

大規模なシステムでは、数十のモジュールが協調して動作するかもしれない。デバッグ時や新しいルールを記述する際に、「この関数は使えるか?」「この型名は何か?」を把握できないことは、生産性の著しい低下を招く。システムはブラックボックス化し、その内部状態を把握するためには、全モジュールのソースコードを読み解くという非現実的な作業が必要になる。

この最後のフロンティアを攻略するために、我々は「イントロスペクター(Introspector)」、すなわち、構築された環境の内部を走査し、その構成要素を報告する機構を設計・実装する。これは、システムの透明性を確保し、開発者が自信を持ってバリデーションルールを記述できるようにするための、決定的な一手である。

課題: 不透明な登録プロセス

以前の設計では、各モジュールはcel.EnvOptionのスライスを返していた。ファクトリはこれらをただ集めてcel.NewEnvに渡すだけだった。

// 以前のアプローチの問題点
var opts []cel.EnvOption
for _, m := range modules {
    // optsの中身は不透明。何が登録されようとしているのか、
    // この時点では把握できない。
    opts = append(opts, m.GetEnvOptions()...)
}
env, err := cel.NewEnv(opts...)

このアプローチは疎結合ではあるが、プロセスが不透明である。イントロスペクションを実現するには、cel.EnvOptionという抽象の壁を越えて、その中身、すなわち登録されようとしている具体的な型、変数、関数の情報を収集する必要がある。

解決策: 「レジストリ」パターンの導入

この課題を解決するための最もエレガントな方法は、「レジストリ(Registry)」パターンを導入することだ。これは、Builderパターンの一種であり、モジュールとファクトリの間のインタラクションを仲介する役割を担う。

発想を転換する。モジュールがcel.EnvOptionを直接生成するのではなく、ファクトリが用意したRegistryオブジェクトのメソッドを呼び出す形に変更するのだ。このRegistryオブジェクトは、メソッド呼び出しを受けると、2つのことを同時に行う。

  1. 内部的にcel.EnvOptionのスライスを構築する。
  2. イントロスペクションのためのメタデータを、人間が読める形で収集・記録する。

このパターンにより、登録プロセスは完全に可視化され、我々が求めるイントロスペクターのデータソースが自然に出来上がる。

新しいインターフェースとデータ構造

まず、必要なインターフェースとデータ構造を定義する。

// --- validation/registry.go ---
package validation

import (
	"fmt"
	"github.com/google/cel-go/cel"
	"github.com/google/cel-go/checker/decls"
	"reflect"
	"sort"
	"strings"
)

// Registryは、モジュールからの登録を受け付ける仲介者
type Registry struct {
	envOptions          []cel.EnvOption
	registeredTypes     map[string]reflect.Type
	registeredVariables map[string]string // name -> CEL Type Name
	registeredFunctions map[string][]string // name -> list of signatures
}

func NewRegistry() *Registry {
	return &Registry{
		registeredTypes:     make(map[string]reflect.Type),
		registeredVariables: make(map[string]string),
		registeredFunctions: make(map[string][]string),
	}
}

// RegisterNamedTypeは、Goの型をCELの特定の名前で登録する
func (r *Registry) RegisterNamedType(celName string, goType any) {
	// イントロスペクション用メタデータを記録
	r.registeredTypes[celName] = reflect.TypeOf(goType)

	// 環境構築用のオプションを追加
	r.envOptions = append(r.envOptions, cel.Types(goType))
	r.envOptions = append(r.envOptions, cel.Declarations(decls.NewObjectType(celName, nil)))
}

// RegisterVariableは、トップレベル変数を登録する
func (r *Registry) RegisterVariable(name, celTypeName string) {
	r.registeredVariables[name] = celTypeName
	r.envOptions = append(r.envOptions, cel.Variable(name, cel.ObjectType(celTypeName)))
}

// RegisterFunctionは、カスタム関数を登録する
func (r *Registry) RegisterFunction(opt cel.EnvOption, signature string) {
	// CELの関数名は複雑なため、シグネチャ文字列からパースして名前を抽出
	funcName := strings.Split(signature, "(")[0]
	r.registeredFunctions[funcName] = append(r.registeredFunctions[funcName], signature)
	r.envOptions = append(r.envOptions, opt)
}

// ValidationModuleは、Registryに自身を登録する
type ValidationModule interface {
	Register(registry *Registry)
}

// Introspectorは、構築された環境の情報を保持し、報告する
type Introspector struct {
	Types     map[string]reflect.Type
	Variables map[string]string
	Functions map[string][]string
}

// Reportは、登録内容のサマリーを文字列として生成する
func (i *Introspector) Report() string {
	var sb strings.Builder
	sb.WriteString("--- Validation Environment Introspection Report ---\n\n")

	// Types
	sb.WriteString("Registered Types:\n")
	typeNames := make([]string, 0, len(i.Types))
	for name := range i.Types {
		typeNames = append(typeNames, name)
	}
	sort.Strings(typeNames)
	for _, name := range typeNames {
		sb.WriteString(fmt.Sprintf("  - %-25s -> Go Type: %v\n", name, i.Types[name]))
	}

	// Variables
	sb.WriteString("\nRegistered Global Variables:\n")
	varNames := make([]string, 0, len(i.Variables))
	for name := range i.Variables {
		varNames = append(varNames, name)
	}
	sort.Strings(varNames)
	for _, name := range varNames {
		sb.WriteString(fmt.Sprintf("  - %-25s -> CEL Type: %s\n", name, i.Variables[name]))
	}

	// Functions
	sb.WriteString("\nRegistered Custom Functions:\n")
	funcNames := make([]string, 0, len(i.Functions))
	for name := range i.Functions {
		funcNames = append(funcNames, name)
	}
	sort.Strings(funcNames)
	for _, name := range funcNames {
		sb.WriteString(fmt.Sprintf("  - %s:\n", name))
		sort.Strings(i.Functions[name])
		for _, sig := range i.Functions[name] {
			sb.WriteString(fmt.Sprintf("    - %s\n", sig))
		}
	}

	sb.WriteString("\n--- End of Report ---\n")
	return sb.String()
}

ファクトリとモジュールの再構築

新しいインターフェースに合わせて、ファクトリとモジュールを書き換える。

// --- validator/factory.go ---

func NewValidator(size int, modules ...validation.ValidationModule) (*Validator, *validation.Introspector, error) {
	registry := validation.NewRegistry()

	// 各モジュールに自身を登録させる
	for _, m := range modules {
		m.Register(registry)
	}

	// 環境を生成
	env, err := cel.NewEnv(registry.envOptions...)
	if err != nil {
		return nil, nil, err
	}

	cache, err := lru.New(size)
	if err != nil {
		return nil, nil, err
	}
	
	validator := &Validator{env: env, cache: cache}

	// イントロスペクターを生成
	introspector := &validation.Introspector{
		Types:     registry.registeredTypes,
		Variables: registry.registeredVariables,
		Functions: registry.registeredFunctions,
	}

	return validator, introspector, nil
}


// --- billing/module.go ---
// (パッケージが分かれていると仮定)
package billing
import (
    "example.com/validation"
)
type BillingModule struct{}

func (m BillingModule) Register(registry *validation.Registry) {
	// 'billing.Address'という名前で、このパッケージのAddress型を登録
	registry.RegisterNamedType("billing.Address", &Address{})
}

// --- common/module.go ---
// (カスタム関数を提供するモジュール)
package common
import (
    "example.com/validation"
    "github.com/google/cel-go/cel"
    // ...
)
type CommonModule struct{}

func (m CommonModule) Register(registry *validation.Registry) {
	upperFuncOpt := cel.Function(/*...*/) // cel.Functionの定義
	// 人間が読めるシグネチャと共に登録
	registry.RegisterFunction(upperFuncOpt, "upper(string) -> string")
}

// --- アプリケーションのエントリーポイント ---
func main() {
    // order/module.go などを定義し、トップレベルの型を登録
    type OrderModule struct{}
    func (m OrderModule) Register(registry *validation.Registry) {
        registry.RegisterNamedType("model.Order", &model.Order{})
        registry.RegisterVariable("order", "model.Order")
    }

	// ファクトリを呼び出し、ValidatorとIntrospectorを両方受け取る
	validator, introspector, err := validation.NewValidator(
		100,
		common.CommonModule{},
		billing.BillingModule{},
		shipping.ShippingModule{},
        OrderModule{},
	)
	if err != nil {
		log.Fatal(err)
	}

	// イントロスペクターを使って、構築された環境の内容をレポートする
	fmt.Println(introspector.Report())

	// これで、開発者は何が利用可能かを正確に把握した上で、
	// バリデーションルールの記述に進むことができる
	// validator.Validate(...)
}

実行結果(introspector.Report()の出力例)

--- Validation Environment Introspection Report ---

Registered Types:
  - billing.Address           -> Go Type: *billing.Address
  - model.Order               -> Go Type: *model.Order
  - shipping.Address          -> Go Type: *shipping.Address

Registered Global Variables:
  - order                     -> CEL Type: model.Order

Registered Custom Functions:
  - upper:
    - upper(string) -> string

--- End of Report ---

このレポートにより、もはやバリデーション環境はブラックボックスではない。どのモジュールがどの名前で型や関数を登録したかが一目瞭然となり、チーム内のコミュニケーションコストは劇的に下がる。新しいメンバーは、このレポートを見るだけで、利用可能なビルディングブロックを迅速に学習できる。

我々は、単なるバリデーション機構を構築したのではない。自己記述的で、透明性が高く、それ自体がドキュメントとして機能する、生きたシステムを設計したのだ。このイントロスペクション機構こそが、cel-goを用いた大規模でモジュラーなバリデーション設計における、信頼性と保守性を担保する最後の、そして最も重要なピースなのである。

podhmopodhmo
target_reader: Goの静的解析と動的設定を組み合わせ、次世代の型安全なバリデーションフレームワークの構築を目指すリード開発者およびアーキテクト
objective: Goのソースコードから静的解析によってバリデーションルールを抽出し、JSONとして外部化する。そのJSONをランタイムで読み込み、型レベルとフィールドレベルの両方の検証を動的に実行する、先進的なファクトリとモジュールの設計パターンを提示する。

自動化への道: 静的解析によるバリデーションルールの抽出と動的実行

我々はこれまで、cel-goのバリデーション機構をモジュール化し、イントロスペクションによってその透明性を確保する設計を探求してきた。しかし、バリデーションルールそのものの「定義」は、依然として開発者の手作業に依存していた。ルールはGoのコードとは別の場所(CEL式の文字列スライス)で管理され、両者の間の同期は開発者の注意深さに委ねられていた。もしUser構造体からAgeフィールドを削除したのに、ルール定義からuser.Age >= 18を消し忘れたら?

この最終章では、その根本的な課題に挑む。すなわち、Goのソースコード自体を「信頼できる唯一の情報源(Single Source of Truth)」とし、そこから静的解析によってバリデーションルールを自動的に抽出し、JSONファイルとして外部化する。そして、実行時にはそのJSONを読み込んで動的にバリデーションを行う、究極の自動化されたフレームワークを構想し、実装する。

フェーズ1: 静的解析 - コードからルールを掘り起こす

この設計の根幹をなすのは、Goのソースコードをプログラムで読み解き、意味のある情報を抽出する「静的解析」である。我々は、Goの標準ライブラリ(go/parser, go/ast)を利用して、ソースコードからバリデーションルールを生成するオフラインツール(rule-generator)が存在すると仮定する。このツールがどのように動作するか、その設計思想を探求する。

マーカーの設計: コードに意味を埋め込む

静的解析ツールがルールを認識するためには、コード内に明確な「マーカー」が必要だ。

  1. フィールドレベルのバリデーション: これは構造体タグ (struct tag) が最適である。簡潔なショートハンド(例: required)と、より複雑なCEL式を混在できるように設計する。

    • validate:"required": requiredは、ツールによってself != nil && self != ''のような具体的なCEL式に展開される。
    • validate:"cel:self.size() >= 8": cel:プレフィックスは、続く文字列が純粋なCEL式であることを示す。selfは、そのフィールドの値自身を指す。
  2. 型レベルのバリデーション: フィールドに属さない、複数のフィールドにまたがるルールは、構造体定義の直前の特別なコメントで示す。

    • // @cel: self.EndDate > self.StartDate
    • // @cel: self.Password == self.PasswordConfirm
    • @cel:というプレフィックスで始まるコメントをツールがスキャンする。ここでのselfは、構造体のインスタンス全体を指す。

静的解析の対象となるコード例

このマーカー設計に基づき、解析対象となるbilling.Address構造体を定義してみよう。

// --- billing/types.go ---
package billing

// Addressは請求先住所情報を表す。
// 国が日本の場合は、郵便番号が必須である。
// @cel: self.Country != 'JP' || self.PostalCode != ''
type Address struct {
	// 請求先の郵便番号。ハイフンなし7桁。
	PostalCode string `validate:"cel:self.matches('^[0-9]{7}$')"`

	// 国コード。ISO 3166-1 alpha-2形式。
	// 大文字小文字は区別しない。
	Country string `validate:"required,cel:self.upper() in ['JP', 'US']"`
	
	// カード番号の下4桁。必須。
	CardLastFour string `validate:"required,cel:self.size() == 4"`
}

静的解析ツールによるJSON生成

rule-generatorツールが上記のbilling/types.goを解析すると、以下のbilling/rules.jsonを生成する。このJSONファイルが、次のランタイムフェーズへの入力となる。

{
  "billing.Address": {
    "typeRules": [
      "self.Country != 'JP' || self.PostalCode != ''"
    ],
    "fieldRules": {
      "PostalCode": [
        "self.matches('^[0-9]{7}$')"
      ],
      "Country": [
        "self != nil && self != ''",
        "self.upper() in ['JP', 'US']"
      ],
      "CardLastFour": [
        "self != nil && self != ''",
        "self.size() == 4"
      ]
    }
  }
}

注目すべきは、CountryCardLastFourrequiredタグが、ツールによって具体的なCEL式self != nil && self != ''に変換されている点だ。これにより、開発者は便利なショートハンドを使いつつ、システムは一貫したCEL式でバリデーションを実行できる。

フェーズ2: ランタイム - JSONから動的バリデーションを構築

ここからが実行時の世界だ。静的解析によって生成されたJSONファイルを読み込み、実際にバリデーションを行うシステムを構築する。

ルールプロバイダーと新しいモジュールインターフェース

各モジュールは、JSONファイルを読み込み、構造化されたルールセットを提供する責務を負う。

// --- validation/rules.go ---
package validation

import (
	"encoding/json"
	"os"
)

// ValidationRuleSetは、単一の型に対するルールをカプセル化する
type ValidationRuleSet struct {
	TypeRules  []string            `json:"typeRules"`
	FieldRules map[string][]string `json:"fieldRules"`
}

// RuleProviderは、自身のドメインのルールセットを提供する
type RuleProvider interface {
	GetRuleSets() (map[string]ValidationRuleSet, error)
}

billingモジュールは、このRuleProviderインターフェースを実装する。

// --- billing/module.go ---
package billing

import "example.com/validation"

type BillingModule struct{
    RuleFilePath string
}

func (m BillingModule) GetRuleSets() (map[string]validation.ValidationRuleSet, error) {
    bytes, err := os.ReadFile(m.RuleFilePath)
    if err != nil {
        return nil, err
    }
    var rules map[string]validation.ValidationRuleSet
    if err := json.Unmarshal(bytes, &rules); err != nil {
        return nil, err
    }
    return rules, nil
}

すべてを統合するValidator

Validatorは、もはやCEL式を直接キャッシュするのではなく、型名をキーとして、構造化されたValidationRuleSetを保持するように変更される。これにより、型レベルとフィールドレベルのルールを区別して適用できる。

// --- validator/validator.go ---
package validator

import (
	"fmt"
	"reflect"
	"strings"

	"github.com/google/cel-go/cel"
	// ...
)

type Validator struct {
	env             *cel.Env
	programCache    *lru.Cache // CELプログラムのキャッシュは引き続き有効
	rulesByTypeName map[string]validation.ValidationRuleSet
}

func NewValidator(providers ...validation.RuleProvider) (*Validator, error) {
	// ... cel.Envのセットアップ ...
	// カスタム関数(matches, upperなど)を登録する必要がある

	allRules := make(map[string]validation.ValidationRuleSet)
	for _, p := range providers {
		rules, err := p.GetRuleSets()
		if err != nil { return nil, err }
		for typeName, ruleSet := range rules {
			allRules[typeName] = ruleSet
		}
	}
	// ...
	return &Validator{env: env, rulesByTypeName: allRules, ...}, nil
}

// Validateは、オブジェクトを受け取り、登録されたルールに基づいて検証する
func (v *Validator) Validate(obj any) error {
	var allErrors []error
	
	val := reflect.ValueOf(obj)
	if val.Kind() == reflect.Ptr {
		val = val.Elem()
	}
	if val.Kind() != reflect.Struct {
		return errors.New("input must be a struct or a pointer to a struct")
	}
	
	// リフレクションで完全な型名を取得 (例: "example.com/billing.Address")
	// これをルールマップのキーに合わせる必要がある (例: "billing.Address")
	fullTypeName := val.Type().PkgPath() + "." + val.Type().Name()
    typeName := simplifyTypeName(fullTypeName) // ヘルパー関数で整形

	ruleSet, ok := v.rulesByTypeName[typeName]
	if !ok {
		return nil // この型に対するルールは登録されていない
	}

	// 1. 型レベルのルールを評価
	for _, rule := range ruleSet.TypeRules {
		// ... prg, err := v.getProgram(rule) ...
		out, _, err := prg.Eval(map[string]any{"self": obj})
		if !isValid {
			allErrors = append(allErrors, fmt.Errorf("type validation failed for %s: %s", typeName, rule))
		}
	}

	// 2. フィールドレベルのルールを評価
	for fieldName, rules := range ruleSet.FieldRules {
		fieldVal := val.FieldByName(fieldName)
		if !fieldVal.IsValid() { continue }

		for _, rule := range rules {
            // ... prg, err := v.getProgram(rule) ...
			out, _, err := prg.Eval(map[string]any{"self": fieldVal.Interface()})
			if !isValid {
				allErrors = append(allErrors, fmt.Errorf("field validation failed for %s.%s: %s", typeName, fieldName, rule))
			}
		}
	}

	return errors.Join(allErrors...)
}

最終的な実行フロー

// --- main.go ---
func main() {
    // 各モジュールのルールプロバイダーを初期化
	billingProvider := &billing.BillingModule{RuleFilePath: "./billing/rules.json"}
	// ... 他のプロバイダー ...

	// Validatorを生成
	validator, err := validator.NewValidator(billingProvider)
	if err != nil { log.Fatal(err) }

	// バリデーションの実行
	addr := &billing.Address{
		PostalCode: "123456", // ルール違反 (7桁でない)
		Country: "jp",         // OK (upper()で正規化される)
		CardLastFour: "123",    // ルール違反 (4桁でない)
	}

    // 型レベルのルールもチェックされる
    // addr.Countryは'JP'だが、PostalCodeが空でないので型ルールはパスする

	err = validator.Validate(addr)
	if err != nil {
		// 複数のエラーがまとめて報告される
		fmt.Printf("Validation failed:\n%v\n", err)
	}
}

実行結果の例:

Validation failed:
field validation failed for billing.Address.PostalCode: self.matches('^[0-9]{7}$')
field validation failed for billing.Address.CardLastFour: self.size() == 4

この設計により、我々はついに、Goのコードベースそのものを信頼性の中心に据えた、究極的に自動化されたバリデーションフレームワークのビジョンを完成させた。開発者はGoのコードと型定義に集中するだけでよく、バリデーションルールはその構造から自動的に導出され、JSONとしてバージョン管理される。実行時には、その外部化されたルールが動的に適用される。

これは単なるバリデーションではない。静的解析と動的実行を融合させ、型安全性、保守性、そして開発者の生産性を最高レベルで調和させる、先進的なソフトウェアアーキテクチャの実践なのである。

podhmopodhmo

理解が怪しいままにユーザーの要求に従ってる感じがある

podhmopodhmo

後半になるにつれて文章もコードも質が落ちた気がする

podhmopodhmo

こういうのを仕様として受け取って全部解釈するようなコードを終了できるんだろうか?

todo.mdから1つタスクを取り実装してください。テストも書いてください。全部テストが通ったらtodo.mdを更新してください。

みたいなのを繰り返す。

podhmopodhmo

今までの文章を読みライブラリとして提供したい場合のtodoリストを精緻に完成させてください。途中でやめないで完全に出力してください。

slicesやmapへの対応genericsへの対応なども忘れないでください。

podhmopodhmo

バリデーションライブラリ "Veritas" 開発TODOリスト

以下は、これまでの探求で詳述された設計思想を基に、Goの汎用バリデーションライブラリとして提供することを目的とした、詳細かつ網羅的なTODOリストである。ライブラリ名は仮に"Veritas"(ラテン語で「真実」)とする。


フェーズ 1: コアエンジンの構築 (v0.1)

このフェーズの目標は、CEL式を評価し、基本的な型とフィールドのバリデーションを実行できる、最小限だが堅牢なエンジンを完成させることである。

  • [ ] 1.1: cel.Envのラッパー設計

    • 1.1.1: Engine構造体を定義する。内部にcel.Envを保持する。
    • 1.1.2: lru.Cacheを内包し、コンパイル済みcel.Programのキャッシュ機構を実装する (getProgramメソッド)。
    • 1.1.3: 基本的なCEL標準関数 (cel.StdLib()) をデフォルトで有効にする。
  • [ ] 1.2: カスタム関数の拡張フレームワーク

    • 1.2.1: cel.FunctionをラップしたCustomFunction構造体を定義し、人間が読めるシグネチャ文字列を持たせる。
    • 1.2.2: 文字列操作 (strings.ToUpper, strings.ToLower, strings.TrimSpace) のためのデフォルトカスタム関数 (veritas.StringsLib) を提供する。
    • 1.2.3: 正規表現 (matches) のためのカスタム関数 (veritas.RegexLib) を提供する。
    • 1.2.4: Engineの初期化時に、複数のカスタム関数ライブラリ([]CustomFunction)を受け取れるようにする。
  • [ ] 1.3: リフレクションベースの動的バリデーター

    • 1.3.1: Validator構造体を定義する。内部にEngineへの参照と、map[string]ValidationRuleSetを保持する。
    • 1.3.2: Validate(obj any) errorメソッドを実装する。
    • 1.3.3: reflectを使い、渡されたオブジェクトの型名を解決するロジックを実装する。
    • 1.3.4: 型レベルのルール (TypeRules) を評価するロジックを実装する。self変数にはオブジェクト全体を渡す。
    • 1.3.5: フィールドレベルのルール (FieldRules) を評価するロジックを実装する。self変数にはフィールドの値を渡す。
    • 1.3.6: 複数のバリデーションエラーをerrors.Joinで集約して返す。エラーメッセージには型名とフィールド名のコンテキストを含める。
  • [ ] 1.4: ルールセットの定義と読み込み

    • 1.4.1: ValidationRuleSet構造体(TypeRules, FieldRulesを含む)を定義する。
    • 1.4.2: RuleProviderインターフェースを定義し、JSONファイルからルールセットを読み込むデフォルト実装 (JSONRuleProvider) を提供する。
    • 1.4.3: Validatorのコンストラクタが、複数のRuleProviderを受け取り、ルールセットをマージできるようにする。

フェーズ 2: 静的解析ツールの開発 (v0.2)

このフェーズの目標は、Goのソースコードからバリデーションルールを自動抽出し、JSONファイルを生成するCLIツール veritas-gen を開発することである。

  • [ ] 2.1: go/astgo/parserによるコード解析基盤

    • 2.1.1: 指定されたディレクトリツリーを再帰的にスキャンし、.goファイルを見つけるロジックを実装する。
    • 2.1.2: struct定義を抽出するパーサーを実装する。
    • 2.1.3: 完全な型名(例: github.com/user/project/billing.Address)を解決するロジックを実装する。
  • [ ] 2.2: マーカーの解析とCEL式への変換

    • 2.2.1: structのフィールドタグ (validate:"...") を解析するロジックを実装する。
    • 2.2.2: cel:プレフィックスを認識し、続く文字列をそのままCEL式として抽出する。
    • 2.2.3: requiredemailurlなどの一般的なショートハンドを、定義済みのCEL式に変換するマッピングテーブルを実装する。
    • 2.2.4: struct定義の直前にある特殊コメント (// @cel: ...) を解析し、型レベルのルールとして抽出する。
  • [ ] 2.3: JSONファイルの生成

    • 2.3.1: 抽出したルールをValidationRuleSetの構造にマッピングする。
    • 2.3.2: 整形されたJSONファイルとして出力するロジックを実装する。
    • 2.3.3: CLI引数で入力ディレクトリと出力ファイルを指定できるようにする (-in, -out)。

フェーズ 3: 高度なデータ構造への対応 (v0.3)

このフェーズの目標は、ライブラリが現代的なGoのコードで一般的に使われる、より複雑なデータ構造に対応できるようにすることである。

  • [ ] 3.1: ポインターとnilのハンドリング

    • 3.1.1: Validatorのフィールド評価ロジックを修正し、ポインター型フィールドの場合、nilでなければデリファレンス(間接参照)して値を評価するようにする。
    • 3.1.2: requiredショートハンドが、ポインター型に対してself != nilというCEL式を生成するようにveritas-genを更新する。
  • [ ] 3.2: スライス ([]T) への対応

    • 3.2.1: validateタグに、スライス自体と要素の両方に対するルールを記述できる構文を設計する(例: validate:"dive,cel:self > 0")。
    • 3.2.2: diveキーワードをveritas-genに認識させ、スライスの各要素に対してバリデーションが適用されるようにする。
    • 3.2.3: スライス自体に対するルール(例: len(self) > 0)もvalidate:"cel:..."で記述できるようにする。
    • 3.2.4: Validatorの実行ロジックで、スライスや配列をイテレートし、各要素に対して再帰的にValidateを呼び出すか、フィールドルールを適用する。
    • 3.2.5: エラーメッセージにインデックスを含める(例: field validation failed for User.Scores[2]: ...)。
  • [ ] 3.3: マップ (map[K]V) への対応

    • 3.3.1: validateタグに、キーと値の両方に対するルールを記述できる構文を設計する(例: validate:"keys,cel:self.startsWith('id_');values,required")。
    • 3.3.2: keysおよびvaluesキーワードをveritas-genに認識させる。
    • 3.3.3: Validatorの実行ロジックで、マップをイテレートし、キーと値それぞれに対してルールを適用する。
    • 3.3.4: エラーメッセージにキーを含める(例: map value validation failed for User.Metadata['user_id']: ...)。

フェーズ 4: ジェネリクスと将来への備え (v1.0 - GA)

このフェーズの目標は、Goの最新機能であるジェネリクスに対応し、ライブラリを公式リリースできる品質に引き上げることである。

  • [ ] 4.1: ジェネリック型への対応

    • 4.1.1: veritas-genが、ジェネリックなstruct定義(例: type PaginatedResult[T any] struct { ... })を解析できるようにする。
    • 4.1.2: ジェネリックな型パラメータ (T) に対するバリデーションルール(例: validate:"dive")を記述できる構文を検討・実装する。
    • 4.1.3: Validatorの実行時ロジックが、具象化されたジェネリック型(例: PaginatedResult[User])を正しくハンドリングできるように、リフレクション部分を慎重に実装する。型名の解決が複雑になるため、特別な注意を払う。
  • [ ] 4.2: パフォーマンスと最適化

    • 4.2.1: ベンチマークテストスイートを整備し、リフレクションがボトルネックになる箇所を特定する。
    • 4.2.2: sync.Poolなどを活用し、バリデーション中のオブジェクトアロケーションを削減する。
  • [ ] 4.3: ドキュメントとエコシステム

    • 4.3.1: 包括的な公式ドキュメントサイトを作成する。
    • 4.3.2: サポートされるすべてのショートハンドとカスタム関数のリファレンスを記述する。
    • 4.3.3: veritas-genツールの使い方、CI/CDへの組み込み方法についてのチュートリアルを作成する。
    • 4.3.4: 一般的なWebフレームワーク(net/http, gin, echoなど)との連携方法を示すサンプルリポジトリを作成する。
  • [ ] 4.4: 最終的なAPIレビューと安定化

    • 4.4.1: 全ての公開APIを見直し、v1.0として互換性を保証できるか最終確認する。
    • 4.4.2: エラーハンドリングとエラーの種類を整理し、ユーザーがプログラムでエラーを判別できるようにする。
    • 4.4.3: context.ContextValidateメソッドに導入し、タイムアウトやキャンセル処理を可能にする。