feature image

2021年12月3日 | ブログ記事

Goファイルを静的解析して構造体のコンストラクタを自動生成するCLIツールを作った【AdC2021 21日目】

この記事はアドベントカレンダー2021 21日目の記事です

目次

はじめに

こんにちは、20BのRasです。traPではほのぼのと生きています。

今回はタイトルにも書いた通り、Go製自動生成ツールを紹介します。前回の「VSCodeで手を抜いてGoのテストを手を抜かずに書く」に引き続き、今回もGoのコードを自動で生成します。
最近気づいたのです自動生成がちょっと好きらしいです。

VSCodeで手を抜いてGoのテストを手を抜かずに書く
この記事は夏のブログリレー2021 49日目の記事です。 目次 0. はじめに 1. Goのテストについて 小噺🤫 2. VSCodeでGoのテスト環境を整える 小噺🤫 3. 【ちょっと発展】gotestsをカスタマイズする gotestsをもっとくわしく testify/assertを使う t.Parallelで並列化する 事前処理を追加する もっと 追記 21/10/04 0. はじめに こんにちは。20BのRasと申します。はじめましての方は過去の記事をご覧ください。 過去記事 現在、traPortfolioというプロジェクトでバックエンドを担当しています。このプロジェクトでテス…

静的解析は初めてでしたが、Goにはgo/astgo/parserといった公式の静的解析パッケージが充実しているため思っていたよりも早く完成しました。その後修正は入っていますが、今回製作にかかった期間はおよそ3日です。

wakatime

さて、このブログを理解する前提条件として、Goの基本文法を理解している必要があります。
これからGoを学びたいあなたはA Tour of 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分でわかる紹介と使い方

今日もいそがしいあなたに

今回作ったのはこちらです。

GitHub - Ras96/gcg: Go Constructor Generator
Go Constructor Generator. Contribute to Ras96/gcg development by creating an account on GitHub.

Go Constructor Generator、略して合コン!とかも考えたのですが最近行われたGo Conferenceも略称がgoconだったのでやめました。今回は、gcgと同じくGo製ツールであるjunegunn/fzf: A command-line fuzzy findersimeji/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,
	}
}

といったコードが自動で生成されます。

ほかのオプションは鋭意製作中なので出来たらここらへんに追記しておきます。具体的には

などを考えています。モチベが続かなかったらごめんなさい。Issue、PRも待ってます。

ここらへん

動機

現在traPortfolioというプロジェクトでバックエンドを担当しているのですが、OpenAPIからリクエストボディ、レスポンスボディなどに対応する構造体を自動生成しよう!というIssueが立っています。これを実装するにあたり、主にテストコードでのポインタ周りでかなり苦戦してしまいました。この問題は構造体のコンストラクタを作って関数内で各フィールドを設定することで解決した、ように見えたのですが、、、

とにかく変更に弱いです。そもそもOpenAPIから構造体を自動生成しているのにコンストラクタは手で書いているので自動生成の恩恵を最大限に受けられていない気がします。

ということで(勝手に)gcgを作ることになりました。
※まだオプションも少なく実用性には欠けるため、プロジェクトにはもう少しいい感じに仕上げてから(もちろんプロジェクトリーダーの了承も得てから)組み込もうかなと考えています。

参考:

Goの静的解析をさっと知る

筆者は静的解析を学ぶにあたって、まず下の「静的解析をはじめよう - Gopherをさがせ!」を参考にしました。この記事は、文字列としてのGopher、構造体の型としてのGopher、変数名としてのGopherなど多くのGopherが存在するファイルの中から、静的解析を用いて構造体の型であるGopherのみを抽出しよう!という記事になっています。ここでは静的解析のロジック等について詳しくは解説しませんが、以下の記事を見るとかなり理解度が増すと思います。

また、この記事を書かれたtenntennさんは他にも静的解析の記事を多く書いており、そちらも見てみると面白いと思います。Goによる静的解析ツールに関する活動 - tenntenn.dev

静的解析をはじめよう - Gopherをさがせ!

参考

今回は先ほども紹介したようにexample/struct.goのような構造体が書かれたファイルをターゲットにコンストラクタを生成します。
以下にexample/struct.goを入力として抽象構文木を出力することができるコードを用意しました。右上のRunからコードを実行することができます。srcを自由に書き換えて木がどのように変わるか試してみてください。
(parser.ParseFileは第2引数にファイル名を入れることでファイルからの解析もできますが、Playgroundではファイル分割ができないため文字列で直書きしています。)

Go Playground - go.dev

実装コードを追う

修正、機能追加により現在のソースコードより多少中身が古い可能性があります。

つらつらと書いたところで、やっとRas96/gcgの中身を見ていきます。

主なディレクトリ構成を以下に記しました。レイヤードアーキテクチャで書いており、依存関係はmaincmdhandlerservicemodel(左から右に依存する、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起動時にgemCmdrootCmdのサブコマンドとして登録しています。これにより

$ 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/handlerh.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に飛びます。

gcg/wire.go at main · Ras96/gcg
Go Constructor Generator. Contribute to Ras96/gcg development by creating an account on GitHub.
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
}

大まかな流れとしては、

  1. h.Srv.Analyzer.AnalyzeFile: 入力ファイルの解析
  2. h.Srv.Generator.GenerateConstructors: コンストラクタのソースコード生成
  3. 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/astgo/parsergo/tokenパッケージを用いて静的解析を行っています。解析についてはGoの静的解析をさっと知るでさっと知ったのでここでは省略します。

その下の

packageName := s.parsePkgName(f.Name)
imports := s.parseImportSpecs(f.Imports)
structs := s.parseObjectsToStructs(f.Scope.Objects)

では抽象構文木から必要な部分を取り出しています。

ちなみに、返り値であるmodel.NewFileもgcgによって生成されています。中身は以下のようになっています。

gcg/analyzer_impl.go at main · Ras96/gcg
Go Constructor Generator. Contribute to Ras96/gcg development by creating an account on GitHub.
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
}

ここでは

  1. text/templateパッケージを用いてテンプレートファイル群をパースする
  2. r.writeConstructorsによりコンストラクタを生成し、wにバッファを格納する
  3. 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)
			}
		},
	}
}
gcg/internal/template at main · Ras96/gcg
Go Constructor Generator. Contribute to Ras96/gcg development by creating an account on GitHub.

順番に従って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.Executefileをテンプレートに反映させ、ソースコードを生成しています。
生成されたコードは*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です、楽しみ~~~
明日からハッカソンです、そっちも楽しみ~~~

Ras icon
この記事を書いた人
Ras

20B。アライグマです。

この記事をシェア

このエントリーをはてなブックマークに追加
共有

関連する記事

2021年8月12日
CPCTFを支えたWebshell
mazrean icon mazrean
2021年5月19日
CPCTF2021を実現させたスコアサーバー
xxpoxx icon xxpoxx
2024年6月21日
ハッカソン参加記 4班"Slide Center"
Alt--er icon Alt--er
2024年3月15日
個人開発として2週間でWebサービスを作ってみた話 〜「LABEL」の紹介〜
Natsuki icon Natsuki
2023年10月20日
DIGI-CON HACKATHON 参加記事「Comic DoQ」
mehm8128 icon mehm8128
2023年6月23日
2023 春ハッカソン 26班 『traP Mission』
Ras icon Ras
記事一覧 タグ一覧 Google アナリティクスについて 特定商取引法に基づく表記