前書き:GolangでValue Objectを作りづらい
2025年の抱負で「ブログのアウトプットを増やす(リンク先の末尾を参照)」と宣言したので、早速アウトプットします。
今回取り扱うValue Objectとは、主に以下のような特徴を持ちます(本記事の本題と関係ない要素は意図的に省略しています)
- 不変(Immutable)
- オブジェクトは、値が同値の時に等しい
Golangで不変の仕様を満たすには、構造体の中にプライベートフィールドを用意し、プライベートフィールドにアクセスするGetterメソッドを準備する必要があります。以下に、サンプルコードを示します。
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に実装例です。
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コードからその存在を隠しています(後日テスト予定)
上記のサンプルコードを利用して自動生成されたコードを以下に示します。
// 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に任意のバリデーションを追加します。
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()も自動生成されます。
// 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を適用する」など、色々な判断ができます。手探りで色々試していきたい所存です。
