goaの特徴
goaの最大の特徴はAPIデザインを書くとモック、クライアントツール、ドキュメントなどを自動生成できるところだ。
開発は以下のような手順になる。
- DSLでAPIデザインを書く
- goagen(コードジェネレーター)で自動生成する
- クライアントのスケルトン
- テストコード
- CLIツール
- OpenAPI仕様のドキュメント
- APIを実装する
- ビルドして実行する
このように人の手が入るのは「1.DLSでAPIデザインを書く」と「3.APIを実装する」だけだ。それ以外はgoagenというコードジェネレーターが自動的にやってくれる。
golang初心者の私でも、はじめて触ってから3時間くらいで動くAPIサーバーをつくることができた。
以下にgoaを使ってAPIサーバーを開発する手順をまとめていく。
0. goa/goagenをインストールする
筆者の開発環境は以下のとおり。
- macOS
- go@1.8.3
- goadesign/goa@1.3.0
- 執筆時点での最新版は@1.3.1だが、goa内で使っているパッケージに破壊的な変更がされたものがありエラーがでるので1.3.0を使う
- goa@1.3.1で開発するときのワークアラウンドも紹介する
- dep@0.2.0
- パッケージ管理ツールを使っているがなくてもOK
golangの開発環境構築方法は以下の記事を参照してほしい。
さっそくgoaをインストールする。
パッケージ管理ツール(dep)を使っている場合は、以下のコマンドを実行する。
# 開発プロジェクトのディレクトリに移動(learning-goa)
$ cd /path/to/dir/learning-goa
# depの初期化
$ dep init
$ ls
Gopkg.toml Gopkg.lock vendor/
# goファイルを作成
# goファイルが1つもないとdepを実行したときに「all dirs lacked any go code」というエラーがでるため。このファイルはあとで削除する
$ touch tmp.go
# goa/goagenをインストール
$ dep ensure -add github.com/goadesign/goa/goagen
# dep ensure -add でインストールできない場合は、tmp.go内でgoagenをimportしてdep ensureをすれば良い
前述のとおりgoa@1.3.1ではビルドできないことがあるため、Gopkg.tomlでバージョンを1.3.0に固定する
# Gopkg.toml
[[constraint]]
name = "github.com/goadesign/goa"
version = "=1.3.0"
depを使わない場合は、
go get -u github.com/goadesign/goa/goagen
でインストールする。1. DSLでAPIデザインを書く
最初にすることはDSLでAPIデザインを書くこと。
慣例として
./design
というディレクトリの中にgoファイルを入れる。エンドポイントが増えたときのために以下のような構成にする。
./design
├── api.go // API全体の定義(APIで定義)
├── mediatypes // レスポンスのスキーマ(MediaTypeで定義)
│ └── task.go
├── usertypes // リクエストのpayloadなどのスキーマ(Typeで定義)
│ └── task.go
└── resources // エンドポイントの定義(Resourceで定義)
└── task.go
./design/api.go
APIサーバーの全体的な定義を書く。
バージョンやスキーム、ホスト、ベースパス、メディアタイプなど。
// ./design/api.go
package design
// golint:should not use dot imports と怒られるので名前付きでimportする
import (
goa "github.com/goadesign/goa/design"
dsl "github.com/goadesign/goa/design/apidsl"
// API Resources
_ "github.com/BcRikko/learning-goa/design/resources"
)
// APIの定義(http://localhost:8080/api)
var _ = dsl.API("Task", func() {
dsl.Title("タスク管理API")
dsl.Description("タスク管理のAPIです。")
dsl.Version("0.1")
dsl.Scheme("http")
dsl.Host("localhost:8080")
dsl.BasePath("/api")
dsl.Consumes("application/json") // リクエストのメディアタイプ
dsl.Produces("application/json") // レスポンスのメディアタイプ
// 202:Createdのときのレスポンステンプレートを定義
dsl.ResponseTemplate(goa.Created, func(pattern string) {
dsl.Description("タスク作成が完了しました。")
dsl.Status(201)
dsl.Headers(func() {
dsl.Header("Location", goa.String, "作成したタスクのリンク", func() {
dsl.Pattern(pattern)
})
})
})
})
goaのチュートリアルにはドットインポートで書かれているのだが、golintで怒られるので名前付きでインポートしている。
github.com/goadesign/goa/design
にはフィールドの型の定義などが含まれている。github.com/goadesign/goa/design/apidsl
にはAPIデザインするためのDSLが含まれている。./design/resources/task.go
http://example.com/api/tasks
以下のエンドポイントを定義する。
# ./design/resources/task.go
package resources
import (
"github.com/BcRikko/learning-goa/design/mediatypes"
"github.com/BcRikko/learning-goa/design/usertypes"
goa "github.com/goadesign/goa/design"
dsl "github.com/goadesign/goa/design/apidsl"
)
var _ = dsl.Resource("Tasks", func() {
dsl.DefaultMedia(mediatypes.Task)
dsl.BasePath("/tasks")
// GET http://localhost:8080/api/tasks
dsl.Action("list", func() {
dsl.Routing(dsl.GET(""))
dsl.Description("タスク一覧を取得する。")
dsl.Response(goa.OK, dsl.CollectionOf(mediatypes.Task))
})
// GET http://localhost:8080/api/tasks/:taskID
dsl.Action("show", func() {
dsl.Routing(dsl.GET("/:taskID"))
dsl.Description("指定IDのタスクを取得する。")
dsl.Params(func() {
dsl.Param("taskID", goa.Integer, "タスクID")
})
dsl.Response(goa.OK)
dsl.Response(goa.NotFound)
dsl.Response(goa.BadRequest, goa.ErrorMedia)
})
// POST http://localhost:8080/api/tasks
dsl.Action("create", func() {
dsl.Routing(dsl.POST(""))
dsl.Description("タスクを作成する。")
dsl.Payload(usertypes.TaskPayload)
dsl.Response(goa.Created, "/tasks/[0-9]+")
dsl.Response(goa.BadRequest, goa.ErrorMedia)
})
// PUT http://localhost:8080/api/tasks/:taskID
dsl.Action("update", func() {
dsl.Routing(dsl.POST("/:taskID"))
dsl.Description("指定IDのタスクを更新する。")
dsl.Params(func() {
dsl.Param("taskID", goa.Integer, "タスクID")
})
dsl.Payload(usertypes.TaskPayload)
dsl.Response(goa.NoContent)
dsl.Response(goa.NotFound)
dsl.Response(goa.BadRequest, goa.ErrorMedia)
})
// DELETE http://localhost:8080/api/tasks/:taskID
dsl.Action("delete", func() {
dsl.Routing(dsl.DELETE("/:taskID"))
dsl.Description("指定IDのタスクを削除する。")
dsl.Params(func() {
dsl.Param("taskID", goa.Integer, "タスクID")
})
dsl.Response(goa.NoContent)
dsl.Response(goa.NotFound)
dsl.Response(goa.BadRequest, goa.ErrorMedia)
})
})
ここでは以下の5つのエンドポイントを定義している。
- GET /api/tasks … タスク一覧を取得する
- GET /api/tasks/:taskID … 指定IDのタスクを取得する
- POST /api/tasks … タスクを作成する
- PUT /api/tasks/:taskID … 指定IDのタスクを更新する
- DELETE /api/tasks/:taskID … 指定IDのタスクを削除する
./design/mediatypes/task.go
GET /api/tasks
などのレスポンススキーマを定義する。
# ./design/mediatypes/task.go
package mediatypes
import (
goa "github.com/goadesign/goa/design"
dsl "github.com/goadesign/goa/design/apidsl"
)
// Task はタスクリソースのメディアタイプ
var Task = dsl.MediaType("application/x-learning-goa+json", func() {
dsl.Description("タスク")
dsl.Attributes(func() {
dsl.Attribute("id", goa.Integer, "タスクID", func() {
dsl.Example(12345)
})
dsl.Attribute("title", goa.String, "タスクのタイトル", func() {
dsl.Example("my task")
})
dsl.Attribute("done", goa.Boolean, "タスクの完了状態", func() {
dsl.Example(true)
})
dsl.Attribute("created_at", goa.DateTime, "タスクの作成日時")
dsl.Attribute("updated_at", goa.DateTime, "タスクの更新日時")
dsl.Required("id", "title", "done", "created_at", "updated_at")
})
dsl.View("default", func() {
dsl.Attribute("id")
dsl.Attribute("title")
dsl.Attribute("done")
dsl.Attribute("created_at")
dsl.Attribute("updated_at")
})
})
レスポンスに「id」「title」「done」「created_at」「updated_at」のフィールドを含めることを定義している。
./design/usertypes/task.go
POST /api/tasks
のリクエストボディなどの定義をする。
# ./design/usertypes/task.go
package usertypes
import (
goa "github.com/goadesign/goa/design"
dsl "github.com/goadesign/goa/design/apidsl"
)
// TaskPayload はリクエストパラメータの定義
var TaskPayload = dsl.Type("TaskPayload", func() {
dsl.Attribute("title", goa.String, "タスクのタイトル", func() {
dsl.MinLength(2)
dsl.MaxLength(128)
dsl.Example("example task title")
})
dsl.Attribute("done", goa.Boolean, "タスクの完了状態", func() {
dsl.Default(false)
dsl.Example(false)
})
dsl.Required("title")
})
POSTやPUTで必要なリクエスト時のパラメータを定義している。
これでAPIデザインは終了。次にgoagenを使って各種コードやドキュメントを自動生成する。
2. goagen(コードジェネレーター)で自動生成する
DSLで書いたAPIデザインをもとにコードを自動生成する。
depを使ってベンダリングしている場合は、goagenをビルドする必要があるので注意。
# ベンダリングしている場合
$ cd ./vendor/github.com/goadesign/goa/goagen
$ go build
$ cd ../../../../
# GOPATH配下のプロジェクトディレクトリの/designディレクトリを指定して生成する
$ ./vendor/github.com/goadesign/goa/goagen/goagen bootstrap -d github.com/BcRikko/learning-goa/design
# go getでグローバルにインストールしている場合
$ goagen bootstrap -d github.com/BcRikko/learning-goa/design
app // HTTPルータに関するコード
app/contexts.go // - HTTPのコンテキストデータ構造
app/controllers.go // - リソースごとのインターフェース
app/hrefs.go // - hrefを構築するための関数
app/media_types.go // - レスポンスのメディアタイプのデータ構造
app/user_types.go // - DSL#Typeで定義されたデータ構造
app/test // コントロールをテストする用のヘルパー
app/test/tasks_testing.go
main.go
tasks.go // tasksのスケルトンコントローラ(ここに実装する)
tool // CLIツール(←この行は表示されない)
tool/task-cli
tool/task-cli/main.go
tool/cli
tool/cli/commands.go
client // クライアントのGoパッケージ
client/client.go
client/tasks.go
client/user_types.go
client/media_types.go
swagger // OpenAPI仕様のjson/yaml
swagger/swagger.json
swagger/swagger.yaml
上記のようにいろいろ生成されるので、あとはルートディレクトリにある
./tasks.go
というスケルトンコントローラに処理を実装していくだけだ。3. APIを実装する
ルートディレクトリに生成された./tasks.goに実装していくのだが、このコントローラには以下のようなコードが吐かれている。
// ./tasks.go
// List runs the list action.
func (c *TasksController) List(ctx *app.ListTasksContext) error {
// TasksController_List: start_implement
// Put your logic here
res := app.XLearningGoaCollection{}
return ctx.OK(res)
// TasksController_List: end_implement
}
Put your logic here
のコメント部分に実際の処理を書いていく。package main
import (
"time"
"github.com/BcRikko/learning-goa/app"
"github.com/goadesign/goa"
)
// TasksController implements the Tasks resource.
type TasksController struct {
*goa.Controller
}
// NewTasksController creates a Tasks controller.
func NewTasksController(service *goa.Service) *TasksController {
return &TasksController{Controller: service.NewController("TasksController")}
}
// Create runs the create action.
func (c *TasksController) Create(ctx *app.CreateTasksContext) error {
// TasksController_Create: start_implement
ctx.ResponseData.Header().Set("Location", app.TasksHref(999))
return ctx.Created()
// TasksController_Create: end_implement
}
// Delete runs the delete action.
func (c *TasksController) Delete(ctx *app.DeleteTasksContext) error {
// TasksController_Delete: start_implement
return ctx.NoContent()
// TasksController_Delete: end_implement
}
// List runs the list action.
func (c *TasksController) List(ctx *app.ListTasksContext) error {
// TasksController_List: start_implement
res := app.XLearningGoaCollection{
{
ID: 1,
Title: "task1",
Done: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
{
ID: 2,
Title: "task2",
Done: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
{
ID: 3,
Title: "task3",
Done: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
},
}
return ctx.OK(res)
// TasksController_List: end_implement
}
// Show runs the show action.
func (c *TasksController) Show(ctx *app.ShowTasksContext) error {
// TasksController_Show: start_implement
if ctx.TaskID == 0 {
return ctx.NotFound()
}
res := &app.XLearningGoa{
ID: ctx.TaskID,
Title: "example task title",
Done: false,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
return ctx.OK(res)
// TasksController_Show: end_implement
}
// Update runs the update action.
func (c *TasksController) Update(ctx *app.UpdateTasksContext) error {
// TasksController_Update: start_implement
return ctx.NoContent()
// TasksController_Update: end_implement
}
本来ならDB接続したり、いろんな処理をしたりするのだが、今回はgoaの勉強が目的だったのでモックデータを返すように実装している。
4. ビルドして実行する
あとはビルドして実行するだけ。
# ビルドする(apiという名前の実行ファイルを出力する)
$ go build -o api
# 実行
$ ./api
ビルド時に「package not found」エラーがでた場合
package not found系のエラーが出た場合は、goagenで生成したコードの中で使っているパッケージがインストールされていないのが原因なので、dep ensureなどで依存パッケージをインストールする。ビルド時に「uuid」に関するエラーがでた場合
goa@1.3.1を使っていると以下のようなuuidのエラーがでる。それはgo.uuidという依存パッケージが破壊的な変更されたためだ。
$ go build -o api
# github.com/BcRikko/learning-goa/vendor/github.com/goadesign/goa/uuid
vendor/github.com/goadesign/goa/uuid/uuid.go:18: not enough arguments in call to uuid.Must
have (uuid.UUID)
want (uuid.UUID, error)
回避策としては3つある。
1つ目は、goa@1.3.1のまま、uuidのバージョンだけ変更前のリビジョンを指定すること。(Breaking API Change · Issue #66 · satori/go.uuid)
// Gopkg.toml
[override]]
name = "github.com/satori/go.uuid"
revision = "063359185d32c6b045fa171ad7033ea545864fa1"
2つ目はインストールするgoaのバージョンを1.3.0に固定すること。
// Gopkg.toml
[[constraint]]
name = "github.com/goadesign/goa"
version = "=1.3.0"
3つ目はgoa自体を書き換えること。ただvendorディレクトリもgit管理しなきゃいけなくなるのでちょっとツライかも。
// ./vendor/github.com/goadesign/goa/uuid/uuid.go
// NewV4 Wrapper over the real NewV4 method
func NewV4() UUID {
//return UUID(uuid.Must(uuid.NewV4())) ←uuid.Must部分を削除する
return UUID(uuid.NewV4())
}
curlコマンドでAPIを叩いてみる
$ curl localhost:8080/api/tasks | jq .
[
{
"created_at": "2018-05-25T08:52:19.781588776+09:00",
"done": false,
"id": 1,
"title": "task1",
"updated_at": "2018-05-25T08:52:19.78158881+09:00"
},
{
"created_at": "2018-05-25T08:52:19.781588847+09:00",
"done": false,
"id": 2,
"title": "task2",
"updated_at": "2018-05-25T08:52:19.781588882+09:00"
},
{
"created_at": "2018-05-25T08:52:19.781588919+09:00",
"done": true,
"id": 3,
"title": "task3",
"updated_at": "2018-05-25T08:52:19.781588953+09:00"
}
]
$ curl localhost:8080/api/tasks/12345 | jq .
{
"created_at": "2018-05-23T11:35:59.55470746+09:00",
"done": false,
"id": 123,
"title": "example task title",
"updated_at": "2018-05-23T11:35:59.554707482+09:00"
}
golang初心者がgoaを使ってみた感想
Node.js(TypeScript) + Express + OpenAPIで実装していたときより格段に楽に実装できた。当記事では紹介しなかったがCLIツールも生成されていたり、なによりドキュメントの整備をしなくてよいのが助かる。
あとgolang初心者なのだが、フレームワークに全乗っかりして開発することで学習スピートがあがるような気がした。おそらくAOJで100本ノックをするより身についてるような…。
DSL覚えるのは面倒だが、大規模なAPIになればなるほどgoaの本領が発揮されると思う。
参考サイト
以上
written by @bc_rikko
0 件のコメント :
コメントを投稿