Goaの example コマンドを使いやすくするカスタマイズ手法
Goaフレームワークには example コマンドがあり、API設計(DSL)から実行可能なGoコードの例を自動生成できます。
これを使うと、cmd ディレクトリ配下に main.go や http.go などのエントリポイントが生成され、各サービスの実装を記述するためのサンプルコードも出力されます。
しかし、実際の開発で使おうとすると不便な点があります。
example コマンドをより実用的にする方法を紹介します。
example コマンドの課題
-
既存ファイルの上書きができない
goa exampleは一度出力したファイルを上書きしません。
そのため、サービスやメソッドを追加した際には、以下のような手作業が必要になります- 新しいメソッドを追加した場合:
- 既存のサービス実装ファイルに手動で追記する必要があります。
- 新しいサービスを追加した場合:
-
main.goやhttp.goに手動でサービス登録コードを追加しなければなりません。
-
これらは、プロジェクトが大きくなるほど面倒で、メンテナンスミスの温床になります。
- 新しいメソッドを追加した場合:
-
出力ディレクトリとインポートパスの扱いが不便
デフォルトではexampleコマンドの出力がプロジェクトのルートディレクトリに展開されます。
--outputオプションを使えば出力場所を変更できますが、インポートパスに自動的にexampleが含まれてしまうため、そのままでは正しく動作しません。たとえば、
adapterディレクトリ配下にサービス実装ファイルをまとめたい場合、
exampleが生成するcmd配下のmain.goのサービス実装ファイル参照先は<プロジェクト名>/examplesとなるため<プロジェクト名>/adapterにしたいです。
また、サービス実装を記述するためのサンプルコードのgenへの参照は<プロジェクト名>/examples/gen/...を参照してしまいます。
理想的には<プロジェクト名>/gen/...にしたいところです。
解決策:example コマンドをカスタマイズする
Goaはカスタムプラグインを登録することで、生成処理を自由に拡張できます。
ここでは custom/generate.go に独自のプラグインを追加し、example 出力時のインポートパスを自動で書き換えるようにします。
前提
go version go1.24.9 linux/amd64
Goa version v3.2.5
goa-test
├── adapter // exampleで吐き出されたサービス実装ファイルを格納するディレクトリ
├── custom // goaをカスタムするコードを格納する
│ └── generate.go
├── design // goaのデザインファイル
│ └── design.go
├── go.mod
└── go.sum
package custom
import (
"fmt"
"strings"
"goa.design/goa/v3/codegen"
"goa.design/goa/v3/eval"
)
func init() {
// Goaの標準コード生成が終わった最後に goatestExample が呼ばれるよう登録します
codegen.RegisterPluginLast("goa-test-example", "example", nil, goatestExample)
}
func goatestExample(genpkg string, roots []eval.Root, files []*codegen.File) ([]*codegen.File, error) {
for _, f := range files {
// import文の書き換え
st := f.SectionTemplates[0]
stData, ok := st.Data.(map[string]any)
if !ok {
fmt.Printf("invalid section template data: %T\n", st.Data)
continue
}
importSpecs, ok := stData["Imports"].([]*codegen.ImportSpec)
if !ok {
fmt.Printf("invalid imports data: %T\n", stData["Imports"])
continue
}
for _, is := range importSpecs {
if is.Name == "goatest" {
// import文の "goa-test/examples" --> "goa-test/adapter"
is.Path = "goa-test/adapter"
} else {
// import文の "goa-test/examples/gen/..." --> "goa-test/gen/..."
is.Path = strings.ReplaceAll(is.Path, "examples/", "")
}
}
}
return files, nil
}
このプラグインを読み込むために、designファイル の先頭で custom パッケージを _ インポートします。
import (
_ "goa-test/custom" // 生成時にカスタムプラグインを読み込む
)
Makefileの整備
毎回手で削除・コピーを行うのは手間なので、コマンドをMakefileでまとめます。
_gen:
goa gen goa-test/design
example:
rm -rf examples
rm -rf cmd
goa example goa-test/design --output=examples
cp -r ./examples/cmd ./
run:
go run goa-test/cmd/goa_test --http-port=8080
build:
go build -o bin/goa_test goa-test/cmd/goa_test
build-cli:
go build -o bin/goa_test-cli goa-test/cmd/goa_test-cli
これにより、
-
make _genで goaの自動生成 -
make exampleでクリーンなexample生成+cmd更新 - サービスを追加した場合は
examplesディレクトリ から対象のファイルをコピーしてくる - メソッドを追加した場合は、
examplesのメソッド部分だけコピぺしてくる -
make runで即時起動
という開発サイクルを実現できます。
最終ディレクトリ構造
goa-test
├── Makefile // コマンド群を記載
├── cmd
│ ├── goa_test
│ │ ├── http.go
│ │ └── main.go
│ └── goa_test-cli
│ ├── http.go
│ └── main.go
├── adapter // サービス実装ファイルを格納する (サービスを追加するごとにexampleからコピペしてくる)
│ ├── health_check.go
│ └── hello.go
│ └── ...サービス単位で作成
├── custom // goaをカスタムする
│ └── generate.go
├── design
│ ├── api.go
│ └── design.go
├── examples // goaサンプルコード の出力先
├── gen // goa自動生成
├── go.mod
└── go.sum
package design
import (
. "goa.design/goa/v3/dsl"
)
var _ = Service("health_check", func() {
Description("サービスのヘルスチェックを行うサービス。")
Method("health_check", func() {
Result(String, "サービスの状態を示すメッセージ")
HTTP(func() {
GET("/health")
})
})
Files("./openapi3.json", "./http/openapi3.json")
Files("./openapi3.yaml", "./http/openapi3.yaml")
})
var _ = Service("hello", func() {
Description("シンプルな挨拶を返すサービス。")
Error("NotFound", String, "指定されたリソースが見つからない場合に返されるエラー。")
HTTP(func() {
Path("/hello")
Response("NotFound", StatusNotFound)
})
Method("sayHello", func() {
Payload(String, "挨拶する相手の名前")
Result(String, "挨拶メッセージ")
HTTP(func() {
GET("/{name}")
})
})
})
package design
import (
_ "goa-test/custom" // goaの自動生成のカスタムを登録するためにインポート
cors "goa.design/plugins/v3/cors/dsl"
. "goa.design/goa/v3/dsl"
)
var _ = API("goa-test", func() {
Title("Go Test API")
Description("これはGoaフレームワークAPIの例です。")
Version("1.0")
cors.Origin("*", func() {
cors.Headers("X-Authorization", "X-Time", "X-Api-Version",
"Content-Type", "Origin", "Authorization")
cors.Methods("GET", "POST", "PUT", "PATCH", "OPTIONS", "DELETE")
cors.Expose("Content-Type", "Origin")
cors.MaxAge(100)
cors.Credentials()
})
})
package custom
import (
"fmt"
"strings"
"goa.design/goa/v3/codegen"
"goa.design/goa/v3/eval"
)
func init() {
// Goaの標準コード生成が終わった最後に goatestExample が呼ばれるよう登録します
codegen.RegisterPluginLast("goa-test-example", "example", nil, goatestExample)
}
func goatestExample(genpkg string, roots []eval.Root, files []*codegen.File) ([]*codegen.File, error) {
for _, f := range files {
// import文の書き換え
st := f.SectionTemplates[0]
stData, ok := st.Data.(map[string]any)
if !ok {
fmt.Printf("invalid section template data: %T\n", st.Data)
continue
}
importSpecs, ok := stData["Imports"].([]*codegen.ImportSpec)
if !ok {
fmt.Printf("invalid imports data: %T\n", stData["Imports"])
continue
}
for _, is := range importSpecs {
if is.Name == "goatest" {
// import文の "goa-test/examples" --> "goa-test/adapter"
is.Path = "goa-test/adapter"
} else {
// import文の "goa-test/examples/gen/..." --> "goa-test/gen/..."
is.Path = strings.ReplaceAll(is.Path, "examples/", "")
}
}
}
return files, nil
}