この記事はアドベントカレンダー2021 21日目の記事です
目次
はじめに
こんにちは、20BのRasです。traPではほのぼのと生きています。
今回はタイトルにも書いた通り、Go製自動生成ツールを紹介します。前回の「VSCodeで手を抜いてGoのテストを手を抜かずに書く」に引き続き、今回もGoのコードを自動で生成します。
最近気づいたのです自動生成がちょっと好きらしいです。
静的解析は初めてでしたが、Goにはgo/ast
やgo/parser
といった公式の静的解析パッケージが充実しているため思っていたよりも早く完成しました。その後修正は入っていますが、今回製作にかかった期間はおよそ3日です。
さて、このブログを理解する前提条件として、Goの基本文法を理解している必要があります。
これからGoを学びたいあなたはA Tour of Goなどを見ながらこの記事を読んでいただけると幸いです。
また、ここでいうConstructor(コンストラクタ)とは以下のようなものを指します。C#などの言語では簡単にコンストラクタが作れるとのことですが(書いたことないのでわからない)、Goではそのようなものは存在しません。そのため、(少なくとも本記事では)NewXXX
のような名前の関数でパラメータを受け取り構造体に詰めてそのポインタを返すものをコンストラクタと定義します。
func NewHoge(a int, b Fuga, c Foo) *Hoge {
return &Hoge{
A: a,
B: b,
C: c,
}
}
3分でわかる紹介と使い方
今日もいそがしいあなたに
今回作ったのはこちらです。
Go Constructor Generator、略して合コン!とかも考えたのですが最近行われたGo Conferenceも略称がgoconだったのでやめました。今回は、gcgと同じくGo製ツールであるjunegunn/fzf: A command-line fuzzy finderやsimeji/jid: json incremental diggerを踏襲して普通に頭文字を取りました。
主な使い方はREADMEに書いてありますが、
$ gcg gen {{構造体があるファイル名}} -o {{出力先のファイル名}}
でコンストラクタがいい感じに生成されます。これを実行すると
example/struct.gopackage example_test
type Hoge struct {
A int
B Fuga
C Foo
}
から
example/gcg_gen.go// Code generated by gcg. DO NOT EDIT.
package example_test
func NewHoge(a int, b Fuga, c Foo) *Hoge {
st := MakeHoge(a, b, c)
return &st
}
func MakeHoge(a int, b Fuga, c Foo) Hoge {
return Hoge{
A: a,
B: b,
C: c,
}
}
といったコードが自動で生成されます。
ほかのオプションは鋭意製作中なので出来たらここらへんに追記しておきます。具体的には
- 引数をポインタ型、値型で受け取る設定
- 返り値を
*Hoge
ではなくHoge
型で返す設定
などを考えています。モチベが続かなかったらごめんなさい。Issue、PRも待ってます。
ここらへん
動機
現在traPortfolioというプロジェクトでバックエンドを担当しているのですが、OpenAPIからリクエストボディ、レスポンスボディなどに対応する構造体を自動生成しよう!というIssueが立っています。これを実装するにあたり、主にテストコードでのポインタ周りでかなり苦戦してしまいました。この問題は構造体のコンストラクタを作って関数内で各フィールドを設定することで解決した、ように見えたのですが、、、
とにかく変更に弱いです。そもそもOpenAPIから構造体を自動生成しているのにコンストラクタは手で書いているので自動生成の恩恵を最大限に受けられていない気がします。
ということで(勝手に)gcgを作ることになりました。
※まだオプションも少なく実用性には欠けるため、プロジェクトにはもう少しいい感じに仕上げてから(もちろんプロジェクトリーダーの了承も得てから)組み込もうかなと考えています。
参考:
Goの静的解析をさっと知る
筆者は静的解析を学ぶにあたって、まず下の「静的解析をはじめよう - Gopherをさがせ!」を参考にしました。この記事は、文字列としてのGopher
、構造体の型としてのGopher
、変数名としてのGopher
など多くのGopher
が存在するファイルの中から、静的解析を用いて構造体の型であるGopher
のみを抽出しよう!という記事になっています。ここでは静的解析のロジック等について詳しくは解説しませんが、以下の記事を見るとかなり理解度が増すと思います。
また、この記事を書かれたtenntennさんは他にも静的解析の記事を多く書いており、そちらも見てみると面白いと思います。Goによる静的解析ツールに関する活動 - tenntenn.dev
参考
今回は先ほども紹介したようにexample/struct.goのような構造体が書かれたファイルをターゲットにコンストラクタを生成します。
以下にexample/struct.goを入力として抽象構文木を出力することができるコードを用意しました。右上のRun
からコードを実行することができます。src
を自由に書き換えて木がどのように変わるか試してみてください。
(parser.ParseFile
は第2引数にファイル名を入れることでファイルからの解析もできますが、Playgroundではファイル分割ができないため文字列で直書きしています。)
実装コードを追う
修正、機能追加により現在のソースコードより多少中身が古い可能性があります。
つらつらと書いたところで、やっとRas96/gcgの中身を見ていきます。
主なディレクトリ構成を以下に記しました。レイヤードアーキテクチャで書いており、依存関係はmain
→cmd
→handler
→service
→model
(左から右に依存する、model
は何にも依存しない)の一方向となっています。
また、外部ツールとしてCLIツールはspf13/cobraを、DIツールはgoogle/wireを使用しています。
$ tree
.
├── cmd # 各コマンドはここから実行される
│ ├── gen.go
│ └── root.go
├── example
│ ├── gcg_gen.go
│ └── struct.go
├── internal
│ ├── handler # 入力を変換して生成コードを出力する
│ │ ├── gen.go
│ │ └── handler.go
│ ├── model # ドメインモデル
│ │ ├── file.go
│ │ └── file_cst.go
│ ├── service # ビジネスロジック
│ │ ├── analyzer.go
│ │ ├── analyzer_impl.go
│ │ ├── generator.go
│ │ ├── generator_impl.go
│ │ └── service.go
│ ├── template
│ │ ├── constructor.tmpl
│ │ ├── main.tmpl
│ │ └── util.tmpl
│ └── util # 汎用ライブラリ
│ ├── injector # DI(インターフェイスへの依存性注入)
│ │ ├── wire.go
│ │ └── wire_gen.go
│ └── tools
│ └── tools.go
├── go.mod
├── go.sum
└── main.go
エントリーポイントはmain.goです。
いつもここから。バカヤロコノヤロオメェ
main.gopackage main
import (
"runtime/debug"
"github.com/Ras96/gcg/cmd"
)
var (
version = ""
revision = ""
)
func main() {
if len(version) > 0 {
cmd.Version = version
} else if info, ok := debug.ReadBuildInfo(); ok {
cmd.Version = info.Main.Version
}
cmd.Revision = revision
cmd.Execute()
}
ここには多くの処理は書かず、バージョン設定などを行った後にcmd.Execute()
を呼び出しています。次はcmd.Execute()
の処理を見てみます。
cmd/root.go// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
rootCmd.Version = fmt.Sprintf("%s %s", Version, Revision)
cobra.CheckErr(rootCmd.Execute())
}
上でも書いた通りCLIの作成にはspf13/cobraを使用しています。rootCmd
は*cobra.Cmd
型の変数で、(*cobra.Cmd).Execute()
を実行するとcobraが†いい感じ†にフラグ設定やヘルプメッセージの作成、コマンドの実行を行ってくれます。
rootCmd
は以下のようなシンプルな設定です。
cmd/root.go// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "gcg",
Version: "UNSET", // Set by Execute()
}
今回はルートコマンドは使い方とバージョンを表示するだけにします。実際にコンストラクタを生成するサブコマンドを実装しているのは以下のgenCmd
となります。
cmd/gen.govar genOpts handler.GenOpts
// genCmd represents the gen command
var genCmd = &cobra.Command{
Use: "gen",
Short: "Generate constructors",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
h := injector.NewHandlers()
if err := h.ExecuteGen(args[0], genOpts); err != nil {
return errors.Wrap(err, "Could not generate constructors")
}
return nil
},
}
func init() {
rootCmd.AddCommand(genCmd)
genCmd.Flags().StringVarP(&genOpts.Output, "output", "o", "", "Output file")
genCmd.Flags().BoolVarP(&genOpts.IsPrivate, "private", "p", false, "Generate private constructors")
}
ここが今回の肝となります。
genCmd
をグローバル変数として定義した後、最下部のinit
関数でgcg起動時にgemCmd
をrootCmd
のサブコマンドとして登録しています。これにより
$ gcg gen ~~~
のようなコマンドを走らせることができます。(これもcobraがいい感じにしてくれる)
また、genCmd.Flags()
にはgcg gen
コマンドに使うフラグを設定することができ、--output
(-o
)や--private
(-p
)の設定はここで行っています。
便利な話
cobraには上記のようなソースコードを自動生成する機能が備わっており、
$ go install github.com/spf13/cobra/cobra@latest
でcobraをインストールしたのち、
$ cobra init github.com/Ras96/gcg
のようにパッケージ名をつけて実行すると先ほどのような雛型が一瞬で生成されます。
また、gcg gen
のようなサブコマンドを生成したい場合には
$ cobra add gen
を実行することで同じように雛型が生成されます。
参考:
さて、gen
コマンドのRunE
ではh := injector.NewHandlers()
で依存性の注入(DI)、internal/handler
のh.ExecuteGen
を呼び出しています。ここに主な処理が書かれています。
次は、NewHandlers
をさっと見てからExecuteGen
に行きます。
internal/util/injector/wire_gen.go// Injectors from wire.go:
func NewHandlers() *handler.Handlers {
generatorService := service.NewGeneratorService()
analyzerService := service.NewAnalyzerService()
services := service.NewServices(generatorService, analyzerService)
handlers := handler.NewHandlers(services)
return handlers
}
ここでは依存性注入(DI: Dependency Injection)を行い、インターフェイス定義とメソッドの実装を結びつけています。
このコードはDIツールのgoogle/wireから自動生成されています。生成には以下のようなコードを書く必要がありますが、ここではリンクを貼るにとどめて、ExecuteGen
に飛びます。
internal/handler/gen.gotype GenOpts struct {
Output string
IsPrivate bool
}
func (h *Handlers) ExecuteGen(in string, opts GenOpts) error {
file, err := h.Srv.Analyzer.AnalyzeFile(in)
if err != nil {
return errors.Wrap(err, "Could not analyze file")
}
res, err := h.Srv.Generator.GenerateConstructors(file, opts.Output, opts.IsPrivate)
if err != nil {
return errors.Wrap(err, "Could not generate constructors")
}
if len(opts.Output) == 0 {
fmt.Fprintln(os.Stdout, string(res))
} else {
if err := ioutil.WriteFile(opts.Output, res, fs.ModePerm); err != nil {
return errors.Wrap(err, "Could not write to file")
}
}
return nil
}
大まかな流れとしては、
h.Srv.Analyzer.AnalyzeFile
: 入力ファイルの解析h.Srv.Generator.GenerateConstructors
: コンストラクタのソースコード生成ioutil.WriteFile
: ファイル出力
といった感じです。1と2のビジネスロジックはservice/
以下に書かれています。
まずは1の「入力ファイルの解析」から見ていきます。実際には上のコードではインターフェイスAnalyzerService
のメソッドを呼び出しているのですが、DIしたことにより構造体analyzerService
と結びついているので定義を飛ばして実装の解説をします。
internal/service/analyzer_impl.gotype analyzerService struct{}
func (s *analyzerService) AnalyzeFile(filename string) (*model.File, error) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, filename, nil, 0)
if err != nil {
return nil, errors.Wrap(err, "Could not parse file")
}
packageName := s.parsePkgName(f.Name)
imports := s.parseImportSpecs(f.Imports)
structs := s.parseObjectsToStructs(f.Scope.Objects)
return model.NewFile(packageName, imports, structs), nil
}
ここではGo標準のgo/ast
、go/parser
、go/token
パッケージを用いて静的解析を行っています。解析についてはGoの静的解析をさっと知るでさっと知ったのでここでは省略します。
その下の
packageName := s.parsePkgName(f.Name)
imports := s.parseImportSpecs(f.Imports)
structs := s.parseObjectsToStructs(f.Scope.Objects)
では抽象構文木から必要な部分を取り出しています。
ちなみに、返り値であるmodel.NewFile
もgcgによって生成されています。中身は以下のようになっています。
internal/model/file_cst.go// Code generated by gcg. DO NOT EDIT.
package model
func NewFile(package_ string, imports []Import, structs []Struct) *File {
st := MakeFile(package_, imports, structs)
return &st
}
func MakeFile(package_ string, imports []Import, structs []Struct) File {
return File{
Package: package_,
Imports: imports,
Structs: structs,
}
}
NewFile
からMakeFile
を呼び出し、*model.File
を返しています。model.File
には自動生成のためのデータが入っており、構造体の定義は以下のようになります。
internal/model/file.go//go:generate go run github.com/Ras96/gcg@latest gen $GOFILE -o file_cst.go
package model
type File struct {
Package string
Imports []Import
Structs []Struct
}
type Import struct {
Name string
Path string
}
type Struct struct {
Name string
Fields []Field
IsPrivate bool
}
type Field struct {
Name Name
Type Type
}
type Name struct {
// フィールド名
Original string
// 変数として使う名称(e.g. ID->id, Name->name, LongName->longName)
Argument string
}
type Type struct {
IsStar bool
Prefix Prefix
Package string
Name string
}
さて、入力ファイルの解析が終わったところで次はコードの生成機能を担っているGenerateConstructors
を見てみます。
internal/service/generator_impl.gotype generatorService struct {
Tmpl *template.Template
Opts *imports.Options
}
func (s *generatorService) GenerateConstructors(file *model.File, output string, isPrivate bool) ([]byte, error) {
s.Tmpl = template.New("main.tmpl").Funcs(fmap(isPrivate))
if _, err := s.Tmpl.ParseFiles(tmplFiles...); err != nil {
return nil, errors.Wrap(err, "Could not parse templates")
}
w := &bytes.Buffer{}
if err := s.writeConstructors(w, file); err != nil {
return nil, errors.Wrap(err, "Could not write constructors")
}
out, err := s.format(w, output)
if err != nil {
return nil, errors.Wrap(err, "Could not format output")
}
return out, nil
}
ここでは
text/template
パッケージを用いてテンプレートファイル群をパースするr.writeConstructors
によりコンストラクタを生成し、w
にバッファを格納するr.format
で生成したGoファイルにフォーマッターを掛け、余分なホワイトスペースやコンマなどを取り除く
といった流れで処理が働いています。
ちなみに1のfmap
は以下のようなテンプレート生成に用いる関数群であり、tmplFiles
はテンプレートが入っているパス(internal/template
以下)の配列です。
internal/service/generator_impl.gofunc fmap(isPrivate bool) template.FuncMap {
return template.FuncMap{
"title": strings.Title,
"funcName": func(funcName string) string {
if isPrivate {
return strings.ToLower(funcName[:1]) + funcName[1:]
} else {
return strings.Title(funcName)
}
},
}
}
順番に従って2を見ます。ロジックは以下の通りです。
internal/service/generator_impl.gofunc (s *generatorService) writeConstructors(w *bytes.Buffer, file *model.File) error {
b := bufio.NewWriter(w)
if err := s.Tmpl.Execute(b, file); err != nil {
return errors.Wrap(err, "Could not execute template")
}
if err := b.Flush(); err != nil {
return errors.Wrap(err, "Could not flush buffer")
}
return nil
}
s.Tmpl.Execute
でfile
をテンプレートに反映させ、ソースコードを生成しています。
生成されたコードは*bufio.Writer
型のb
に格納され、b.Flush
を実行することでw
にコードを書きこんでいます。
いよいよ最後です。3を見ましょう。
internal/service/generator_impl.gofunc (s *generatorService) format(w *bytes.Buffer, filename string) ([]byte, error) {
formatted, err := imports.Process(filename, w.Bytes(), s.Opts)
if err != nil {
if len(filename) == 0 {
fmt.Fprintln(os.Stdout, w.String())
} else {
if err := ioutil.WriteFile(filename, w.Bytes(), fs.ModePerm); err != nil {
return nil, errors.Wrap(err, "Could not write to file")
}
}
fmt.Fprintln(os.Stderr, "Error occurred. Instead, gcg output the unformatted file")
fmt.Fprintln(os.Stderr, "")
return nil, errors.Wrap(err, "Could not format file")
}
return formatted, nil
}
ここでは、生成されたコードを整形し、整形後のコードを返しています。
整形にはgolang.org/x/tools/imports を用いているのですが、これがすごくて、filename
に出力する予定のパスを入れると依存関係や依存パッケージを読み取り、import
に自動で追加してくれます。これのおかげで解析時の依存パッケージ解析がいらなくなりました。
ここまででやっときれいなソースコードが生成されたため、internal/handler/gen.go
に戻ってファイルを出力して処理完了となります。やった~
おわりに
上でも述べた通り静的解析は初めてでしたが、CLIの作成も初めてだったのでいい勉強になりました。
気に入っていただけたら幸いです。使っていただけたら幸い幸いです。
明日の担当者は@anninです、楽しみ~~~
明日からハッカソンです、そっちも楽しみ~~~