【Golang】Value Objectを生成するvogenライブラリをお試しで作った話
前書き:GolangでValue Objectを作りづらい
2025年の抱負で「ブログのアウトプットを増やす(リンク先の末尾を参照)」と宣言したので、早速アウトプットします。
今回取り扱うValue Objectとは、主に以下のような特徴を持ちます(本記事の本題と関係ない要素は意図的に省略しています)
- 不変(Immutable)
- オブジェクトは、値が同値の時に等しい
Golangで不変の仕様を満たすには、構造体の中にプライベートフィールドを用意し、プライベートフィールドにアクセスするGetterメソッドを準備する必要があります。以下に、サンプルコードを示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
type Person struct { name string } func NewPerson(name string) Person { return Person{name: name} } func (o Person) Name() string { return o.name } func (o Person) Equal(other Person) bool { return o.Name() == other.Name() } |
Person構造体が持つname
フィールドは可視性がプライベートなため、NewPerson()
した後は不変です。Getterメソッドで経由で値にアクセスします。値が同値かどうかは、Equal()
でチェックします。
このような実装は、ハッキリ言って面倒くさいです。また、Getter()
やEqual()
にユニットテストを書くのも馬鹿らしいです。私は「Kotlinのvalue class
やdata class
がGolangにもあればな〜」と無い物ねだりをしていました。
仕組みがなければ作ればいいじゃない。
ということで、nao1215/vogenパッケージを2時間ぐらいで作りました。
vogenは、メタデータからValue Objectを生成
nao1215/vogenパッケージは、New()
、Getter
、Equal()
を持つValue Objectコードを自動生成するライブラリです。Value Object Generatorの略です。
GolangでValue Objectのメタデータを書き、そのメタデータを元にコードを自動生成します。この仕様の元ネタは、shogo82148/myddlmaker(メタデータからDB DDLを生成するライブラリ)です。
想定の利用方法としては、value_object/gen/main.go
にメタデータを定義し、go generate ./...
でvalue_object/value_object.go
ファイルを生成します。出力先を複数のファイルに分散することもできます。
以下、value_object/gen/main.go
に実装例です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
package main import ( "fmt" "path/filepath" "github.com/nao1215/vogen" ) //go:generate go run main.go func main() { // Step 1: Create a Vogen instance with custom file path and package name. // By default, the file path is "value_objects.go" and the package name is "vo". gen, err := vogen.New( vogen.WithFilePath(filepath.Join("testdata", "example_output.go")), vogen.WithPackageName("vo_example"), ) if err != nil { fmt.Printf("Failed to create Vogen instance: %v\n", err) return } // Step 2: Append the ValueObject definition if err := gen.AppendValueObjects( vogen.ValueObject{ StructName: "Person", Fields: []vogen.Field{ {Name: "Name", Type: "string", Comments: []string{"Name is the name of the person."}}, {Name: "Age", Type: "int", Comments: []string{"Age is the age of the person."}}, }, Comments: []string{ "Person is a Value Object to describe the feature of vogen.", "This is sample comment.", }, }, // Use auto generated comments. vogen.ValueObject{ StructName: "Address", Fields: []vogen.Field{ {Name: "City", Type: "string"}, }, }, ); err != nil { fmt.Printf("Failed to append ValueObject: %v\n", err) return } // Step 3: Generate the code if err := gen.Generate(); err != nil { fmt.Printf("Failed to generate code: %v\n", err) return } } |
vogen.New()
では、生成先のファイルパスやパッケージ名を指定していますが、省略できます。省略した場合は、value_objects.go
ファイルが vo
パッケージとして生成されます。
vogen.ValueObject()
がメタデータに相当します。構造体コメントや構造体フィールドコメントは省略可能です。省略した場合は、魂のこもっていない英文コメントが出力されます。型にはDefined Type(ユーザーが定義した型)を指定できますが、その場合はモジュールパスを指定する必要があります。Defined Type絡みはテストしていないので、意図的にExampleコードからその存在を隠しています(後日テスト予定)
上記のサンプルコードを利用して自動生成されたコードを以下に示します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
// Code generated by vogen. DO NOT EDIT. package vo_example import ( "fmt" ) // Person is a Value Object to describe the feature of vogen. // This is sample comment. type Person struct { // Name is the name of the person. name string // Age is the age of the person. age int } // NewPerson creates a new instance of Person. func NewPerson(name string, age int) Person { return Person{name: name, age: age} } // Name returns the name field. func (o Person) Name() string { return o.name } // Age returns the age field. func (o Person) Age() int { return o.age } // Equal checks if two Person objects are equal. func (o Person) Equal(other Person) bool { return o.Name() == other.Name() && o.Age() == other.Age() } // Address represents a value object. type Address struct { city string } // NewAddress creates a new instance of Address. func NewAddress(city string) Address { return Address{city: city} } // City returns the city field. func (o Address) City() string { return o.city } // Equal checks if two Address objects are equal. func (o Address) Equal(other Address) bool { return o.City() == other.City() } |
微妙な点1:大して楽ではない
勢いで作ったので、利便性の低さが気になっています。
「自動生成コードは、テストカバレッジの計測対象外にしよう」というルールがあったとしても、vogenパッケージに便利さを感じていません。「メタデータを書く量」と「自動生成されるコード量」が見合ってません。”自分で書いた方が早インパラ(元ネタ)”という気持ちになる可能性があります。
まず、厳密なValue Objectをプロダクションコードに導入したことがないので、「今までValue Objectがなくても上手く動いていたのに、今更こんな厳密な仕組みを導入するんですか?」という気持ちが前にでてきてしまいます。
微妙な点2:New()時にバリデーションできない
「バリデーションされた値を保持している」という状態は、安心感があります。しかし、vogenパッケージはそのような機能を導入できていません。構造体生成時に受け取る値を無条件で信用します。ピュアな奴め。
メタデータにバリデーション用関数を追加する案が思い浮かびましたが、その場合は実装が素直になりません。また、メタデータの記述量が増えるので、「vogenパッケージを使わず、素直に自分でValue Objectを実装したほうが楽」と考えてしまいます(少なくとも私はそのように考えます)
2025年1月1日追記
バリデーション機能を追加してみました。以下のように、vogen.ValueObject.Validators
に任意のバリデーションを追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
if err := gen.AppendValueObjects( vogen.ValueObject{ StructName: "Person", Fields: []vogen.Field{ { Name: "Name", Type: "string", Comments: []string{"Name is the name of the person."}, Validators: []vogen.Validator{ vogen.NewStringLengthValidator(0, 120), // バリデーション1 }, }, { Name: "Age", Type: "int", Comments: []string{"Age is the age of the person."}, Validators: []vogen.Validator{ vogen.NewPositiveValueValidator(), // バリデーション2 vogen.NewMaxValueValidator(120), // バリデーション3 }}, }, Comments: []string{ "Person is a Value Object to describe the feature of vogen.", "This is sample comment.", }, }, ); err != nil { fmt.Printf("Failed to append ValueObject: %v\n", err) return } |
以下のようなNewPersonStrictly()
が自動生成されます。バリデーションのないNewPerson()
も自動生成されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// NewPerson creates a new instance of Person. func NewPerson(name string, age int) Person { return Person{name: name, age: age} } // NewPersonStrictly creates a new instance of Person with validation. func NewPersonStrictly(name string, age int) (Person, error) { o := Person{name: name, age: age} if len(o.name) < 0 || len(o.name) > 120 { return fmt.Errorf("struct 'Person' field 'Name' length is out of range: %d", len(o.name)) } if o.age < 0 { return fmt.Errorf("struct 'Person' field 'Age' value is negative: %d", age) } if o.age > 120 { return fmt.Errorf("struct 'Person' field 'Age' value exceeds the maximum value: %d", age) } return o, nil } |
最後に
最近はDDD(Domain-Driven Design)やアーキテクチャを勉強していることもあり、「理論に合わせてなるべく厳密に実装してみよう」とトライしている最中です。その一環で、vogenパッケージを施策してみました。
「Golangを使わない」、「Golangで厳密なValue Objectを採用しない」、「GolangでもキッチリValue Objectを適用する」など、色々な判断ができます。手探りで色々試していきたい所存です。
ロシア人と国際結婚した地方エンジニア。
小学〜大学院、就職の全てが新潟。
大学の専攻は福祉工学だったのに、エンジニアとして就職。新卒入社した会社ではOS開発や半導体露光装置ソフトを開発。現在はサーバーサイドエンジニアとして修行中。HR/HM(メタル)とロシア妻が好き。サイトに関するお問い合わせやTwitterフォローは、お気軽にどうぞ。