minimoにおけるコード自動生成について

MIXI DevRel Team
MIXI DEVELOPERS
Published in
12 min readJun 12, 2023

--

はじめに

minimoでは、既存のPerlのコードをGoへと置き換える移行作業を行っています。
その移行の際、現状の運用やGoの特性に合わせて個別で改修や対応を行うことがあり、そのうちの一つとしては過去に「ミクシィにおける Go 活用事例 〜 #gocon 2022 Spring 前夜祭(非公式)〜」で登壇した「Go移行におけるJSON-RPC対応」があります。

この登壇資料では、後半に少しコード自動生成について触れていますが、この記事ではさらにコード自動生成を深ぼってみようと思います。

コード自動生成

現在、minimoで自動生成する仕組みがあるものは以下の通りです(補助ツールも含む)。

  • OpenAPIベースで生成されたリクエスト及びレスポンス型のコード
  • google/wire用のコード
  • テスト用mockのコード
  • テーブル定義から生成される構造体のコード
  • テストコードそのもの
  • その他細かいもの(今回は触れません)

これらをそれぞれ深ぼっていければと思います。

OpenAPIベースで生成されたリクエスト及びレスポンス型のコード

こちらは過去の登壇資料にも含まれていますが、deepmap/oapi-codegenを用いています。

  • OpenAPI 3.0をサポートしている
  • テンプレート機能がある
  • リクエスト及びレスポンスの型を生成できる
  • Structタグを仕込むことができる
  • etc…

などの利点が存在し、GoにてOpenAPIを検討する際に、一度は触れることになるのではないかと思われるメジャーどころです。

google/wire用のコード

google/wireとは、依存性注入(DI)用のコードを自動生成するツールとなっています。

一例を挙げてみます。

package main

// importは省略

func InitializeHogeUseCase() *hoge.HogeUseCase {
oneRepository := one.NewOneRepository()
hogeUsecase := hoge.NewHogeUsecase(oneRepository)
return hogeUsecase
}

上記はUseCaseを初期化するため、その中で使用するRepository(DB等に対しCRUDな処理を行うなど)を引数として渡しています。
これだけ見るとシンプルに見えます。
しかし、UseCaseが内部で依存するRepositoryが増えるとどうでしょうか?

package main

// importは省略

func InitializeHogeUseCase() *hoge.HogeUseCase {
oneRepository := one.NewOneRepository()
twoRepository := two.NewTwoRepository()
threeRepository := three.NewThreeRepository()
fourRepository := four.NewFourRepository()
fiveRepository := five.NewFiveRepository()
hogeUsecase := hoge.NewHogeUsecase(oneRepository, twoRepository, threeRepository, fourRepository, fiveRepository)
return hogeUsecase
}

少し複雑になってきましたね。
実際には、UseCase自体も複数あることに加え、Repository側も何かしらに依存を持っているため、さらに複雑になります。

wireでは、これらのコードを自動生成してくれます。

package main

// importは省略

func InitializeHogeUseCase() *hoge.HogeUseCase {
wire.Build(
hoge.NewHogeUsecase,
one.NewOneRepository,
two.NewTwoRepository,
three.NewThreeRepository,
four.NewFourRepository,
five.NewFiveRepository,
)
return nil
}

上記のように、 wire.Build 内にコンストラクタを列挙することで、自動で先ほど書いていたコードと同等のものが生成される形となっています。

minimoでは当初、このwireの形式で記述し、DI周りを生成していました。
ただ、段々とwire形式での記述を重ねていくにつれ、この記述ですら手間に感じるようになってきました。

そこで行ったのが、wire形式のコード自体の自動生成です。
AST(抽象構文木)で自動生成を行っており、全体的な流れとしては以下の通りです。

  1. 各UseCaseごとで依存しているRepositoryをリスト化
  2. そのリスト内のRpositoryのコンストラクタ関数を wire.Build 内に出力

(流れをまとめたものの、大して書くことはなかったです)

結果、現状ではwireを触ることがなくなり、新しくJoinするメンバーに関しては、wireを知らない状態でも開発が進められる形となっています。

テスト用mockのコード

mockする際にgomockを利用しています。

mockgen \
-package ${package} \
-source ${path} \
-destination ./$(dirname ${path})/mock_$(basename ${path})

テーブル定義から生成される構造体のコード

今回は移行プロジェクトということもあり、すでにテーブル定義は用意されています。
そこで、go-gorm/genを用いて型を生成するようにしています。

こちらを使用するCLIツールとしては、Gen Toolが提供されていますが、このツールには型の取り扱いを変更する機能は生えていませんでした。
そこで、Goのコードから直接genを利用する形を取っています。

package main

import (
"gorm.io/driver/mysql"
"gorm.io/gen"
"gorm.io/gorm"
)
// NOTE: 生成される型を変更したい場合は、ここに記述する
/****************************************************************************************************************/
var dataMap = map[string]func(detailType gorm.ColumnType) (dataType string){
// あれば記述
}
/****************************************************************************************************************/
func main() {
db, err := gorm.Open(mysql.Open("XXXXX"))
if err != nil {
panic(err)
}
g := gen.NewGenerator(gen.Config{
OutPath: "path/to",
ModelPkgPath: "model",
WithUnitTest: false,
FieldNullable: true,
FieldWithTypeTag: true,
FieldSignable: true,
})
g.UseDB(db)
g.WithDataTypeMap(dataMap)
tablesList, err := db.Migrator().GetTables()
if err != nil {
panic(err)
}
for _, tableName := range tablesList {
g.GenerateModel(tableName)
}
g.Execute()
}

テストコードそのもの

最後はテストコードそのものになります。
こちらは、社内で提供されているAzure OpenAI Serviceを利用した生成ツールとなっており、内容としては以下の通りです。

  • GPT-4
  • temperature = 0
  • 手法としてはminimoのテストコードの例示を提供する形式(Few-shot)
  • DBやRedis、AWSサービスの使用など考えられるケースをおさえた、厳密には実在しないが限りなくminimo風のテストコードを例示として使用

実際に生成されたコードを例として載せます(文字列を一部置き換えています)。

package user_test

import (
"context"
"errors"
"testing"
"github.com/golang/mock/gomock"
"github.com/google/go-cmp/cmp"
...
)
func TestUserRepository_GetByUserID(t *testing.T) {
db, closeFunc := test.TestDB(t) // テスト用のDBを立てる関数を想定
defer closeFunc()
ctrl := gomock.NewController(t)
defer ctrl.Finish()
ctx := context.Background()
userID := "user_id"
prepareData := User{
UserID: userID,
...
}
if err := db.Create(&prepareData).Error; err != nil {
t.Fatal(err)
}
type args struct {
userID string
}
tests := []struct {
name string
args args
want *user.User
wantErr error
}{
{
name: "success case",
args: args{
userID: userID,
},
want: &user.User{
UserID: userID,
...
},
wantErr: nil,
},
{
name: "failure case: empty userID",
args: args{
userID: "",
},
want: nil,
wantErr: ErrEmptyString, // 専用で定義したエラー想定
},
{
name: "failure case: user not found",
args: args{
userID: "nonexistent_user_id",
},
want: nil,
wantErr: ErrNotFound, // 専用で定義したエラー想定
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := user.NewUserRepository(db)
got, err := repo.GetByUserID(ctx, tt.args.userID)
if !errors.Is(err, tt.wantErr) {
t.Errorf("UserRepository.GetByUserID() error = %v, wantErr %v", err, tt.wantErr)
return
}
if diff := cmp.Diff(got, tt.want); diff != "" {
t.Errorf("value is mismatch (-got +expect):\n%s", diff)
}
})
}
}

いわゆるTableDrivenTestsの形式で、多少は記事用に加工してしまっていますが、実際に生成されたものも精度が非常に高く、モノによっては一切修正せずともテストが通ることもあります(Azure OpenAI Service様様ですね)。

なお、早い段階で、Azure OpenAI Serviceを利用してテストコードの生成が可能となったことから、簡易ツールとして作成しました。

まとめ

minimoにおけるコード生成についてまとめてみました。
引き続き開発環境の効率化を行いつつ、Goへと置き換える作業を進めていければと思います。

--

--