entityからコード自動生成した話

はじめまして、MF KESSAIでバックエンドエンジニアを担当している近江です。

今回は6月に行ったIKIOI(※)向上合宿で取り組んだ、entityからコード自動生成した話をしようと思います。 ※IKIOIとは、弊社のチーム開発力を示す指標です。(詳細はこちら

なぜコード自動生成しようと思ったか?

MF KESSAIでは新しいentityを追加する場合、

  1. ドメイン層にentity追加
  2. ドメイン層に追加したentityのRepositoryインターフェースを定義
  3. entityのファクトリを追加
  4. インフラストラクチャ層にRepositoryインターフェースの実装を追加
  5. 実装したコードのテストを追加

といったコードを書いています。

ただこの1-5のコード、単にCRUD用のコードだけの実装であればビジネスロジックは関係ないので、1のentityさえ定義されていれば2-5のコードは自動生成できます。 entityのファイルを指定して2-5が自動生成されるツール(※)を作ればIKIOIも向上するだろうと思い、コード生成に取り組みました。

※entityをbaseにコードをgenerateするということで、entity based generator、略してebgen(読み方:えびじぇん🍤)と社内では呼んでいます。

指定されたファイルから欲しい情報を取得する

まず指定されたentityのファイルを読み込み、structで定義されているentityの情報を取得しようと思いました。 この時考えられる方法としては以下のような手段があると思います。

  • 正規表現などの文字列操作系での解析
    • メリット:新しい学習要素がないためすぐに取りかかれる
    • デメリット:うまくパターンマッチさせないと意図しない情報を取得してしまう可能性がある
  • ソースコードをparseしてAST(抽象構文木)にした後で解析
    • メリット:構文木として意味を持った状態で扱えるので、欲しいノードが探しやすい
    • デメリット:普段触らないので学習コストがかかる

ASTは触ったことがありませんでしたが、より正確にstruct名を取得できると判断して後者を選択しました。 例えばファイルを構文木にして、最初に見つかったstruct名を取得する場合は以下のように書けます。

fset := token.NewFileSet()
f, err := parser.ParseFile(fset, p, nil, 0)
if err != nil {
	return "", err
}

// 最初に見つかったstruct名を入れる
var name string
ast.Inspect(f, func(n ast.Node) bool {
	x, ok := n.(*ast.TypeSpec)
	if !ok {
		return true
	}
	if _, ok := x.Type.(*ast.StructType); ok {
		if name == "" {
			name = x.Name.String()
		}
	}
	return true
})

コードを生成する

次は取得したentity名を元にコード生成するステップです。コード生成には以下のような手段があると思います。

  • テンプレートエンジンを使って、変数部分だけ値を渡してレンダリングする
    • メリット:新しい学習要素がないためすぐに取りかかれる
    • デメリット:構文が間違っていてもビルド時に気付けない
  • AST(抽象構文木)を組み立てる
    • メリット:構文木として意味を持った状態で扱えるので、生成したコードの文法が間違うことはない
    • デメリット:普段触らないので学習コストがかかる

ここでもより正しいコードが生成できるように、後者を選択しました。 しかしASTを直接構築していく場合、ただ”hello world”と表示させるだけのコードだとしても結構大変なコードを書く必要があります。。 そこでjenniferというコード生成のためのパッケージを使うことにしました。

Jennifer is a code generator for Go.

jenniferはASTほど大変ではなく、且つ構文の正しさを担保しながらコードを組み立て、出力してくれます。 例えばjenniferで”hello world”を表示させるコードを書く場合、以下のように書くことができます。

package main

import (
    "fmt"

    . "github.com/dave/jennifer/jen"
)

func main() {
	f := NewFile("main")
	f.Func().Id("main").Params().Block(
		Qual("fmt", "Println").Call(Lit("Hello, world")),
	)
	fmt.Printf("%#v", f)
}

出力されるコード:

package main

import "fmt"

func main() {
	fmt.Println("Hello, world")
}

ASTを直接構築する場合と比べると、かなり短い行数で表現できます。 合宿ではほとんど丸1日jenniferを使ってコード生成のためのコードを書いていましたが、goの文法をjenniferでどのように表現できるのかを知っていく過程は割と夢中になれる楽しさがありました。そして後半はすらすら書けるようになりました。

既存ファイルにコードを追加する

jenniferは新規ファイルのコード生成には便利ですが、既存ファイルを修正するケースには向いていません。 そのためここではASTに向き合い、ファイルをparseしてできたASTに対し手を加えることにしました。 ASTに手を加える場合は、goの準標準パッケージの1つであるastutilが便利です。 astutil.Applyを使って条件にあったノードに対し、前後にノードを追加、削除などの処理が可能です。

// fはparser.ParseFileの戻り値*ast.File(抽象構文木)
n := astutil.Apply(f, func(cr *astutil.Cursor) bool {
	if !(cr.Name() == "List" && cr.Index() == lenMethod-1) {
		return true
	}

	// ノード追加
	cr.InsertAfter(&ast.Field{
		...
	})
	return false
}, nil)

おわりに

今回のチャレンジにより、entity追加の度に書いていた約300行のコードが自動生成できるようになりました! 生成したコードが信頼できるものなら、レビューもさっと見るだけなのでレビュアーの工数も削減されてさらにIKIOI向上です。

ビジネスロジックが入りこまない部分はコード自動生成のチャンスです。今後もコード生成できる範囲を広げていきたいと思います。

もっと詳しい話を聞きたい方、MF KESSAIのエンジニアの仕事に興味がある方、(コード自動生成したい方)、ぜひお気軽にMF KESSAIへ遊びにきてください! (ご連絡はこちら