テクノロジー
Goのライブラリのjenniferを使ってアーキテクチャに沿ったコードを自動生成する
はじめに
はじめまして!23卒のエンジニアのR.Aといいます。
現在は主に社内システムのデバイス管理ツールの開発を行なっています。
現在はバックエンド側はGoを用いてレイヤードアーキテクチャとDDD(ドメイン駆動設計)に沿って開発を進めています。
開発していく中でレイヤードアーキテクチャやクリーンアーキテクチャなどは綺麗に書けるもののコードの量が多く、時間が取られてしまうという問題点がありました。
その問題を解決するために定型的なコードは自動生成したほうが業務効率化につながると思いコードを生成できるようにしました。
使ったライブラリとしては以下のものになります。
jennifer
概要
自動生成の概要としては以下のようになります。
- プロジェクトのdomain配下に対象のentity構造体を作成しておく
- generator配下のmodelディレクトリに対象のmodelの構造体を配置する(sqlboilerだとスキーマからmodelが自動生成されるの楽です)
- make generate-backend-base-code Entity="任意のエンティティ名" Model="任意のモデル名" というコマンドを実行
- interfase, di, repository, usecaseのコードが自動的に生成される
- レコードの新規作成関数、更新関数が自動生成される
以下が動画になります。
0:04くらいにコマンドを実行しています。
自動生成の中身の説明
各フォルダにソースコードを生成してくれる素となるものが入っています。
generator
┣ di
┃ ┗ gen_di.go // di生成
┣ handler
┃ ┗ gen_handler.go // handler生成
┣ models
┃ ┣ admin.go // 生成したいモデル
┣ repository
┃ ┣ gen_repo_impl.go // レポジトリ実体生成
┃ ┗ gen_repo_interface.go // レポジトリインターフェース生成
┣ usecase
┃ ┗ gen_usease.go // ユースケース生成
┗ generator.go // main関数
コード生成の部分を全て書くと長くなってしまうので生成するときに実行されるmain関数と、ユースケースの生成部分について書きます。
generator.go(main関数)
以下のmain関数を実行することで各フォルダの関数を実行し、生成するという形をとっています。
func main() {
// コマンドライン引数を定義
entity := flag.String("e", "", "Entity name")
model := flag.String("m", "", "Model name")
// コマンドライン引数を解析
flag.Parse()
// エンティティ名を取得
e := *entity
m := *model
fileName := toSnakeCase(e)
// handler作成
genhandler.GenHandler(e, fileName)
// di作成
gendi.GenDi(e, fileName)
// usecase作成
genusecase.GenUsecase(e, fileName)
// repositoryinterface 作成
genrepository.GenRepoInterface(e, fileName)
// repositoryimpl作成
genrepository.GenRepoImpl(e, m, fileName)
}
gen_usease.go (ユースケース生成)
ここでモデル名とエンティティ名を使い、ユースケースのコードを生成しています。
難しそうに見えますがコードの文字を羅列していく感覚に近いので、ライブラリの仕様を理解してしまえばそこまで難しくはないです。
package genusecase
import (
"strings"
"github.com/dave/jennifer/jen"
)
func GenUsecase(e string, fileName string) {
// Usecase interface
E := strings.Title(e) // エンティティ名の先頭を大文字に
abbrUsecase := strings.ToLower(string(e[0])) + "u"
abbrRepository := strings.ToLower(string(e[0])) + "r"
f := jen.NewFile("usecase")
f.ImportName("main/domain/entity", "entity")
f.ImportName("main/domain/repository", "repository")
f.Type().Id(E+"UseCase").Interface(
jen.Id("Add"+E).Params(jen.Id(e).Op("*").Qual("main/domain/entity", E)).Error(),
jen.Id("Update"+E).Params(jen.Id(e).Op("*").Qual("main/domain/entity", E)).Error(),
)
f.Type().Id(E + "UseCaseImpl").Struct(
jen.Id(E+"Repository").Qual("main/domain/repository", E+"Repository"),
)
f.Func().Id("New"+E+"UseCaseImpl").Params(jen.Id(abbrRepository).Qual("main/domain/repository", E+"Repository")).Id(E + "UseCase").Block(
jen.Return(jen.Op("&").Id(E+"UseCaseImpl").Values(jen.Dict{
jen.Id(E + "Repository"): jen.Id(abbrRepository),
})),
)
f.Line()
f.Func().Params(jen.Id(abbrUsecase).Id(E + "UseCaseImpl")).Id("Add"+E).Params(jen.Id(e).Op("*").Qual("main/domain/entity", E)).Error().Block(
jen.List(jen.Id("err")).Op(":=").Id(abbrUsecase + "." + E + "Repository").Dot("Add" + E).Call(jen.Id(e)),
jen.If(jen.Id("err").Op("!=").Nil()).Block(
jen.Return(jen.Id("err")),
),
jen.Return(jen.Nil()),
)
f.Line()
f.Func().Params(jen.Id(abbrUsecase).Id(E + "UseCaseImpl")).Id("Update"+E).Params(jen.Id(e).Op("*").Qual("main/domain/entity", E)).Error().Block(
jen.List(jen.Id("err")).Op(":=").Id(abbrUsecase + "." + E + "Repository").Dot("Update" + E).Call(jen.Id(e)),
jen.If(jen.Id("err").Op("!=").Nil()).Block(
jen.Return(jen.Id("err")),
),
jen.Return(jen.Nil()),
)
// Save to file
f.Save("../application/usecase/" + fileName + ".go")
}
コマンド実行後に生成されるコードは以下のものになります。
package usecase
import (
"main/domain/entity"
"main/domain/repository"
)
type MemberUseCase interface {
AddMember(Member *entity.Member) error
UpdateMember(Member *entity.Member) error
}
type MemberUseCaseImpl struct {
MemberRepository repository.MemberRepository
}
func NewMemberUseCaseImpl(mr repository.MemberRepository) MemberUseCase {
return &MemberUseCaseImpl{MemberRepository: mr}
}
func (mu MemberUseCaseImpl) AddMember(Member *entity.Member) error {
err := mu.MemberRepository.AddMember(Member)
if err != nil {
return err
}
return nil
}
func (mu MemberUseCaseImpl) UpdateMember(Member *entity.Member) error {
err := mu.MemberRepository.UpdateMember(Member)
if err != nil {
return err
}
return nil
}
工夫した点
インフラ層でエンティティからDBモデルに変換するための関数が必要でした(sqlboilerのmodel特有の型があるため)
この関数の中にはフィールド30個ほど存在するものがあり書くのが大変であったため、この関数を生成できるようにしました。
該当の関数は以下のような変換関数です。
func ToDbModelMember(mri *entity.Member) *models.Member {
return &models.Member{
Email: mri.Email,
EntryTime: mri.EntryTime,
Kana: mri.Kana,
MemberID: mri.MemberID,
Name: mri.Name,
Post: mri.Post,
UpdateTime: mri.UpdateTime,
}
}
上記のコードを生成するために以下の手順を立てました。
- ASTを使いmodelの構造体の型情報をループさせentityと比較する
- 比較した上でentityをどの型に変換したらいいか分岐させる
- 上記の情報をもとに関数を生成する
この手順をもとに以下のコードを作成しました。
models, _ := FindModelStruct(fileName, modelName) // ここでmodelを取得
dict := make(map[string]jen.Code) // ここでフィールド名をkey 変換するentityをvalueとして作成
pk := "none"
for _, field := range models.Fields.List { // modelの構造体をループさせます
name := field.Names[0]
if strings.Contains(name.Name, "ID") && strings.Contains(name.Name, E) {
pk = name.Name // 主キー取得
}
// ここでtypeごとに変換関数を記述させるようにしています
// sqlboilerで使われているnull.stringなどを変換できるようにしています
switch typeName := fmt.Sprintf("%s", field.Type); typeName {
case "&{null String}":
dict[name.Name] = jen.Qual("github.com/volatiletech/null", "StringFrom").Call(jen.Id(abbrRepository + "." + name.Name))
case "&{null Int}":
dict[name.Name] = jen.Qual("github.com/volatiletech/null", "IntFrom").Call(jen.Id(abbrRepository + "." + name.Name))
case "&{null Time}":
dict[name.Name] = jen.Qual("github.com/volatiletech/null", "TimeFrom").Call(jen.Id(abbrRepository + "." + name.Name))
default:
// その他の型の場合の処理
dict[name.Name] = jen.Id(abbrRepository + "." + name.Name)
}
}
stmt := jen.Dict{}
for k, v := range dict {
stmt[jen.Id(k)] = v
}
// model関数生成
f.Func().Id("ToDbModel" + E).Params(jen.Id(abbrRepository).Op("*").Id("entity." + E)).Op("*").Qual("main/infrastructure/postgres/sqlboiler", modelName).Block(
jen.Return(
jen.Op("&").Qual("main/infrastructure/postgres/sqlboiler", modelName).Values(stmt),
),
)
model構造体取得関数
上記のFindModelStruct(modelの構造体を取得関数)について解説していきます。
ファイルをパースしてGoの抽象構文木(AST)という標準ライブラリを使用し、取得しています。
func FindModelStruct(fileName, modelName string) (*ast.StructType, error) {
fset := token.NewFileSet()
node, err := parser.ParseFile(fset, "./models/"+fileName+".go", nil, parser.ParseComments)
if err != nil {
log.Println(err)
return nil, err
}
var model *ast.StructType
ast.Inspect(node, func(n ast.Node) bool {
switch t := n.(type) {
case *ast.TypeSpec:
if t.Name.Name == modelName {
if x, ok := t.Type.(*ast.StructType); ok { // modelの構造体を取得
model = x
return false // 構造体が見つかったら探索を終了します
}
}
}
return true
})
if model == nil {
return nil, fmt.Errorf("%s struct not found", modelName)
}
return model, nil
}
今後と課題
- 新規追加と更新処理しかないのでCRUD処理全てを生成できるようにしたい また、不要なものが生成されてしまう場合があるのでカスタムして必要なものだけ生成できた方がいい
- プロジェクトに応じて関数名など微妙に変えないといけないので、プロジェクトに応じた対応が必要 統一化するなどをした方がいい
- entityからmodelに変換する関数部分がsqlboilerに依存してしまっている、また今回のようにDBモデルとエンティティが似ているものにしか使用できない
- DB駆動設計となっているので改良が必要
最後に
自動生成を自ら提案してプロジェクトの業務効率化に貢献できたことは大きな経験となりました。
自主的に行動して、開発スピードを上げていくような工夫をこれからも続けていきたいです。
参考にした記事
https://qiita.com/nghrsss/items/e6c9c95db19640f0f654
https://qiita.com/po3rin/items/a19d96d29284108ad442
※本記事は2024年11月時点の内容です。