はじめに
DDDを採用しているとあるプロジェクトでは、structのfieldをprivateにしているため、手動で定義したGetterを介して各fieldにアクセスしています。CopilotによってGetterを生成できるとはいえ、ドメインモデルの変更に伴うfieldの変更に追従するのが面倒という課題があります。
そこで、accessorを自動生成するパッケージを使って
go generate
でaccessorを自動生成を試してみます。
Accessorを自動生成する
生成処理を自前で書いてもよいですが、まずはGitHubに公開されているパッケージを使って自動生成を試してみます。
今回使用するパッケージは https://github.com/masaushi/accessory です。
基本的な使い方
まず初めに、シンプルにaccessorを自動生成する方法を説明します。対象とするstructの定義は以下のとおりです。
1 2 3 4 5 6 7 8 9 | type User1 struct { firstname string `accessor:"getter"` lastname string `accessor:"getter"` email string `accessor:"getter:EmailAddress"` memo string `accessor:"getter,setter:WriteMemo"` age *int `accessor:"getter,setter"` extras map[string]string `accessor:"-"` createdAt time.Time } |
accessorを自動生成するためには、structのfieldにaccessor tagを書く必要があります。
accessor:getter
と書くと、このfieldのgetterが生成されます。同様に
accessor:setter
と書くと、このfieldのsetterが生成されます。
1 2 3 4 5 6 7 8 9 10 11 12 13 | func (u *User1) Age() *int { if u == nil { return nil } return u.age } func (u *User1) SetAge(val *int) { if u == nil { return } u.age = val } |
また、
getter:EmailAddress
のように生成されるメソッド名を指定することもできます。
1 2 3 4 5 6 | func (u *User1) EmailAddress() string { if u == nil { return "" } return u.email } |
特定のfieldのaccessorを生成したくない場合は、accessor tagを書かないか、明示的に
accessor:"-"
と書きます。
go generate
で自動生成するには、コード上に以下のように記述したうえでコマンドを実行します。
1 | //go:generate go run github.com/masaushi/accessory@latest -type User1 |
自動生成するには、typeに生成するstruct名を渡す必要があります。typeが指定されていない場合や、複数指定されている場合は生成できません。
receiver変数を変更する場合
コマンドの引数に特定のパラメータを渡すことで、自動生成の挙動をカスタマイズすることができます。
手始めに、receiver変数を指定してみます。
1 2 3 | //go:generate go run github.com/masaushi/accessory@latest -type User2 -receiver u2 type User2 User1 |
自動生成されたファイルでは以下のようにreceiver変数が
u2
になっています。
1 2 3 4 5 6 | func (u2 *User2) Firstname() string { if u2 == nil { return "" } return u2.firstname } |
生成するファイル名を変える場合
自動生成するファイル名を変えたい場合は、
-output
で指定します。
1 | //go:generate go run github.com/masaushi/accessory@latest -type User2 -output user2_acc.go |
排他制御を行い場合
accessorの排他制御を行いたい場合は、structにmutexを追加して、
-lock
でfield名を指定します。
1 2 3 4 5 6 | //go:generate go run github.com/masaushi/accessory@latest -type User3 -lock mu type User3 struct { firstname string `accessor:"getter,setter"` mu sync.RWMutex } |
生成されたファイルは以下のとおりです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | // Code generated by accessory; DO NOT EDIT. package types func (u *User3) Firstname() string { if u == nil { return "" } u.mu.Lock() defer u.mu.Unlock() return u.firstname } func (u *User3) SetFirstname(val string) { if u == nil { return } u.mu.Lock() defer u.mu.Unlock() u.firstname = val } |
どのように生成しているのか
DDDでの開発において、accessorのほかにも自動生成したい対象はいくつかあります。それらの自動生成を行うツールを作る参考として、このパッケージがどのようにファイルを生成しているのか、コードを見てみたいと思います。
エントリーポイント
main.go
は以下のように非常にシンプルです。
1 2 3 4 5 6 7 8 9 10 11 12 13 | package main import ( "os" "github.com/spf13/afero" "github.com/masaushi/accessory/cmd" ) func main() { cmd.Execute(afero.NewOsFs(), os.Args) } |
cmd.Execute
を実行するに当たり、引数として
afero.Fs
と
args
を渡しています。
afero.Fs は名前の通り、ファイル操作を便利にするためのパッケージのようです。
生成対象のファイルをどのように見つけているか
cmd.Execute()
関数を見てみると、以下のようになっています。
1 2 3 4 5 6 7 8 9 10 11 12 13 | var dir string if cliArgs := flags.Args(); len(cliArgs) > 0 { dir = cliArgs[0] } else { // Default: process whole package in current directory. dir = "." } if !isDir(dir) { fmt.Fprintln(os.Stderr, "Specified argument is not a directory.") flags.Usage() os.Exit(1) } |
コマンドの引数に相対パスが渡されていればそのディレクトリを見に行き、渡されていなければ同一ディレクトリを見に行くようです。
structの解析
structの解析は golang.org/x/tools/go/packages の
packages.Load()
関数で行っています。
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 | // ParsePackage parses the specified directory's package. func ParsePackage(dir string) (*Package, error) { const mode = packages.NeedName | packages.NeedFiles | packages.NeedImports | packages.NeedTypes | packages.NeedSyntax dir, err := filepath.Abs(dir) if err != nil { return nil, err } cfg := &packages.Config{ Mode: mode, Tests: false, } pkgs, err := packages.Load(cfg, dir) if err != nil { return nil, err } if len(pkgs) != 1 { return nil, fmt.Errorf("error: %d packages found", len(pkgs)) } return &Package{ Package: pkgs[0], Dir: dir, Structs: parseStructs(pkgs[0]), }, nil } |
その後、
*package.Packgage
を自前の構造体に変換しています。
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 56 57 58 59 60 61 62 63 64 65 | func parseStructs(pkg *packages.Package) []*Struct { scope := pkg.Types.Scope() structs := make([]*Struct, 0, len(scope.Names())) for _, name := range scope.Names() { st, ok := scope.Lookup(name).Type().Underlying().(*types.Struct) if !ok { continue } structs = append(structs, &Struct{ Name: name, Fields: parseFields(pkg.Fset, st), }) } return structs } func parseFields(fset *token.FileSet, st *types.Struct) []*Field { fields := make([]*Field, st.NumFields()) for i := 0; i < st.NumFields(); i++ { tag := parseTag(st.Tag(i)) field := st.Field(i) fields[i] = &Field{ Name: field.Name(), Type: field.Type(), Tag: tag, } } return fields } func parseTag(tag string) *Tag { tagStr, ok := reflect.StructTag(strings.Trim(tag, "`")).Lookup(accessorTag) if !ok { return nil } var getter, setter *string var noDefault bool tags := strings.Split(tagStr, tagSep) for _, tag := range tags { keyValue := strings.Split(tag, tagKeyValueSep) var value string if len(keyValue) == 2 { if v := strings.TrimSpace(keyValue[1]); v != ignoreTag { value = v } } switch strings.TrimSpace(keyValue[0]) { case tagKeyGetter: getter = &value case tagKeySetter: setter = &value case tagKeyNoDefault: noDefault = true } } return &Tag{Setter: setter, Getter: getter, NoDefault: noDefault} } |
accessorを生成
accessorの生成はtemplateパッケージを使って行われます。
1 2 3 4 5 6 7 8 9 10 11 12 | func (g *generator) generateSetter( params *methodGenParameters, ) (string, error) { t := template.Must(template.New("setter").Parse(templates.Setter)) buf := new(bytes.Buffer) if err := t.Execute(buf, params); err != nil { return "", err } return buf.String(), nil } |
上記の例では、
template.Template
の
Parse(text string)
メソッドでテンプレートファイルを読み込み、
Execute(wr io.Writer, data any) error
メソッドで生成したsetterを
bytes.Buffer
に書き出しています。
accessorをファイル出力
ファイルへの出力では、
fmt.Fprintf()
で
bytes.Buffer
に書き込んだものを
afero.WriteFile()
でファイルに書き出しています。
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 | func newWriter(fs afero.Fs, outputFile string) *writer { return &writer{ buf: new(bytes.Buffer), fs: fs, outputFile: outputFile, } } func (w *writer) printf(format string, args ...interface{}) { fmt.Fprintf(w.buf, format, args...) } func (w *writer) write(pkgName string, imports []string, accessors []string) error { w.printf("// Code generated by accessory; DO NOT EDIT.\n") w.printf("\n") w.printf("package %s\n", pkgName) w.printf("\n") if len(imports) > 0 { w.printf("import (\n") for i := range imports { w.printf("\t\"%s\"\n", imports[i]) } w.printf(")\n") } for i := range accessors { w.printf("%s\n", accessors[i]) } content, err := w.format() if err != nil { return err } return afero.WriteFile(w.fs, w.outputFile, content, 0644) } |
さいごに
accessorを自動生成するパッケージの紹介と、そのパッケージがどのようにファイルを生成しているのかを調べてみました。