goa
について
Go
を使ってREST APIを用意したいときに利用できるマイクロサービス用のフレームワークの一つです。
ビジネスロジック中心で開発ができる、swagger
のドキュメントが自動生成されるなどの利点があります。
goa
のGitHubはこちらにあります。
サンプルについて
今回用意したサンプルはこちらです。
環境設定等
mercurial
のインストール
依存関係の解決にあたり、dep
を使ったが、Mac
でdep ensure
をすると、なぜかフリーズしたので、pstree
で確認すると下記の箇所で固まっていました。
$ pstree 69752
-+= 69752 xxx dep ensure -v
\-+= 71086 xxx /Applications/Xcode.app/Contents/Developer/usr/bin/git ls-remote ssh://git@bitbucket.org/pkg/inflect
\--- 71087 xxx /usr/bin/ssh git@bitbucket.org git-upload-pack '/pkg/inflect'
調べたところ、mercurial
が必要とのことだったので、brew
を実行してインストールしました。
brew install mercurial
goa
とdep
のインストール
goa
とdep
をgo get
する。
$ go get -u github.com/goadesign/goa/...
$ go get -u github.com/golang/dep/cmd/dep
goa
を使ったREST API設計
API、Resource、MediaTypeの定義
design
の定義
1つのgoファイルで集約しても問題ないのですが、便宜上複数のファイルの分割しました。
APIのベースとなる基本的な定義です。
package design
import (
. "github.com/goadesign/goa/design/apidsl"
)
var _ = API("goa-sample", func() {
Title("The Sample API")
Description("A simple goa service")
Version("v1")
Scheme("http", "https")
BasePath("/api/v1")
Consumes("application/json")
Produces("application/json")
Host("localhost:8080")
Origin("http://localhost:8080/swagger", func() {
Expose("X-Time")
Methods("GET", "POST", "PUT", "PATCH", "DELETE")
MaxAge(600)
Credentials()
})
})
swagger
の定義です。
この例では、http://localhost:8080/swagger-ui
にアクセスすると、swaggerのドキュメント
を閲覧できます。
なお、swagger-uiをダウンロードし、public/swagger-ui/dist
に配置する必要があります。
package design
import (
. "github.com/goadesign/goa/design/apidsl"
)
// Swagger routing
var _ = Resource("swagger", func() {
Origin("*", func() {
Methods("GET")
})
Files("/swagger.json", "swagger/swagger.json")
Files("/swagger.yaml", "swagger/swagger.yaml")
Files("/swagger-ui/*filepath", "public/swagger-ui/dist")
})
続いて、レスポンスデータの型を定義します。
package design
import (
"time"
. "github.com/goadesign/goa/design"
. "github.com/goadesign/goa/design/apidsl"
)
var MediaSamples = MediaType("application/vnd.samples+json", func() {
Description("sample list")
Attribute("id", Integer, "id", func() {
Example(1)
})
Attribute("name", String, "名前", func() {
Example("サンプル1")
})
Attribute("created_at", DateTime, "作成日", func() {
loc, _ := time.LoadLocation("Asia/Tokyo")
Example(time.Date(2019, 01, 31, 0, 0, 0, 0, loc).Format(time.RFC3339))
})
Attribute("updated_at", DateTime, "更新日", func() {
loc, _ := time.LoadLocation("Asia/Tokyo")
Example(time.Date(2019, 01, 31, 12, 30, 50, 0, loc).Format(time.RFC3339))
})
Required("id", "name", "created_at", "updated_at")
View("default", func() {
Attribute("id")
Attribute("name")
Attribute("created_at")
Attribute("updated_at")
})
})
var MediaSample = MediaType("application/vnd.sample+json", func() {
Description("sample detail")
Attribute("id", Integer, "sample id", func() {
Example(1)
})
Attribute("user_id", Integer, "user id", func() {
Example(1)
})
Attribute("name", String, "名前", func() {
Example("サンプル1")
})
Attribute("detail", String, "詳細", func() {
Example("サンプル1の詳細")
})
Attribute("created_at", DateTime, "作成日", func() {
loc, _ := time.LoadLocation("Asia/Tokyo")
Example(time.Date(2019, 01, 31, 0, 0, 0, 0, loc).Format(time.RFC3339))
})
Attribute("updated_at", DateTime, "更新日", func() {
loc, _ := time.LoadLocation("Asia/Tokyo")
Example(time.Date(2019, 01, 31, 12, 30, 50, 0, loc).Format(time.RFC3339))
})
Required("id", "user_id", "name", "detail", "created_at", "updated_at")
View("default", func() {
Attribute("id")
Attribute("user_id")
Attribute("name")
Attribute("detail")
Attribute("created_at")
Attribute("updated_at")
})
})
最後にAPIの引数とレスポンスの内容を記載します。
package design
import (
. "github.com/goadesign/goa/design"
. "github.com/goadesign/goa/design/apidsl"
)
var _ = Resource("samples", func() {
BasePath("/samples")
Action("list", func() {
Description("複数")
Routing(
GET("/"),
)
Params(func() {
Param("user_id", Integer, "user id", func() {
Example(1)
})
})
Response(OK, CollectionOf(MediaSamples))
Response(NotFound)
Response(BadRequest, ErrorMedia)
})
Action("show", func() {
Description("単数")
Routing(
GET("/:id"),
)
Params(func() {
Param("user_id", Integer, "user id", func() {
Example(1)
})
Param("id", Integer, "sample data id", func() {
Example(123)
})
})
Response(OK, CollectionOf(MediaSample))
Response(NotFound)
Response(Unauthorized)
Response(BadRequest, ErrorMedia)
})
Action("add", func() {
Description("追加")
Routing(
POST("/"),
)
Payload(func() {
Attribute("user_id", String, "user id", func() {
Example("12345")
})
Attribute("name", String, "name of sample", func() {
Example("sample1")
})
Attribute("detail", String, "detail of sample", func() {
Example("sample1の詳細")
})
Required("user_id", "name", "detail")
})
Response(OK, CollectionOf(MediaSample))
Response(NotFound)
Response(Unauthorized)
Response(BadRequest, ErrorMedia)
})
Action("delete", func() {
Description("削除")
Routing(
DELETE("/:id"),
)
Params(func() {
Param("id", Integer, "sample id", func() {
Example(1)
})
})
Response(NoContent)
Response(NotFound)
Response(Unauthorized)
Response(BadRequest, ErrorMedia)
})
Action("update", func() {
Description("更新")
Routing(
PUT("/:id"),
)
Params(func() {
Param("id", Integer, "sample id")
})
Payload(func() {
Param("name", String, "name of sample", func() {
Example("sample1")
})
Param("detail", String, "detail of sample", func() {
Example("sample1")
})
Required("name", "detail")
})
Response(NoContent)
Response(NotFound)
Response(BadRequest, ErrorMedia)
})
})
goagen
の実行
下記コマンドを実行すると諸々ファイルが自動生成されます。
$ goagen bootstrap -d github.com/hiroykam/goa-sample/design
sample.go
とswagger.go
を./controller
に移動させます。
Model
の定義
自動生成する場合
まずは、./design/models.go
を用意します。
id
をstring
にしたい場合、gorma.UUID
とします。
deleted_at
を指定すると、gorm
でdelete
を実行した際、論理削除になります。
package design
import (
"github.com/goadesign/gorma"
. "github.com/goadesign/gorma/dsl"
)
var _ = StorageGroup("goa-sample", func() {
Description("Sample Model")
Store("MySQL", gorma.MySQL, func() {
Description("MySQL models")
Model("Sample", func() {
RendersTo(MediaSample)
Description("sample table")
Field("id", gorma.Integer)
Field("user_id", gorma.Integer)
Field("name", gorma.String)
Field("detail", gorma.String)
Field("created_at", gorma.Timestamp)
Field("updated_at", gorma.Timestamp)
Field("deleted_at", gorma.NullableTimestamp)
})
})
})
$ goagen --design=github.com/hiroykam/goa-sample/design gen --pkg-path=github.com/goadesign/gorma
models/sample.go
models/sample_helper.go
実行すると下記ファイルが生成されますが、上位層にエラーをそのまま伝搬している、このサンプルの例ですとuser_id
で絞り込むGet
とList
を別に用意する必要があるなどの手間は発生します。
またmodelを追加し、上記コマンドを実行しますとsample.go
が新規に作成されてしまいますので、./model/sample_extention.go
といったように別ファイルで定義する必要があります。
// Code generated by goagen v1.3.1, DO NOT EDIT.
//
// API "goa-sample": Models
//
// Command:
// $ goagen
// --design=github.com/hiroykam/goa-sample/design
// --out=$(GOPATH)/src/github.com/hiroykam/goa-sample
// --version=v1.3.1
package models
import (
"context"
"github.com/goadesign/goa"
"github.com/hiroykam/goa-sample/app"
"github.com/jinzhu/gorm"
"time"
)
// sample table
type Sample struct {
ID int `gorm:"primary_key"`
Detail string
Name string
UserID int
CreatedAt time.Time // timestamp
DeletedAt *time.Time // nullable timestamp (soft delete)
UpdatedAt time.Time // timestamp
}
// TableName overrides the table name settings in Gorm to force a specific table name
// in the database.
func (m Sample) TableName() string {
return "samples"
}
// SampleDB is the implementation of the storage interface for
// Sample.
type SampleDB struct {
Db *gorm.DB
}
// NewSampleDB creates a new storage type.
func NewSampleDB(db *gorm.DB) *SampleDB {
return &SampleDB{Db: db}
}
// DB returns the underlying database.
func (m *SampleDB) DB() interface{} {
return m.Db
}
// SampleStorage represents the storage interface.
type SampleStorage interface {
DB() interface{}
List(ctx context.Context) ([]*Sample, error)
Get(ctx context.Context) (*Sample, error)
Add(ctx context.Context, sample *Sample) error
Update(ctx context.Context, sample *Sample) error
Delete(ctx context.Context) error
ListSample(ctx context.Context) []*app.Sample
OneSample(ctx context.Context) (*app.Sample, error)
}
// TableName overrides the table name settings in Gorm to force a specific table name
// in the database.
func (m *SampleDB) TableName() string {
return "samples"
}
// CRUD Functions
// Get returns a single Sample as a Database Model
// This is more for use internally, and probably not what you want in your controllers
func (m *SampleDB) Get(ctx context.Context) (*Sample, error) {
defer goa.MeasureSince([]string{"goa", "db", "sample", "get"}, time.Now())
var native Sample
err := m.Db.Table(m.TableName()).Where("").Find(&native).Error
if err == gorm.ErrRecordNotFound {
return nil, err
}
return &native, err
}
// List returns an array of Sample
func (m *SampleDB) List(ctx context.Context) ([]*Sample, error) {
defer goa.MeasureSince([]string{"goa", "db", "sample", "list"}, time.Now())
var objs []*Sample
err := m.Db.Table(m.TableName()).Find(&objs).Error
if err != nil && err != gorm.ErrRecordNotFound {
return nil, err
}
return objs, nil
}
// Add creates a new record.
func (m *SampleDB) Add(ctx context.Context, model *Sample) error {
defer goa.MeasureSince([]string{"goa", "db", "sample", "add"}, time.Now())
err := m.Db.Create(model).Error
if err != nil {
goa.LogError(ctx, "error adding Sample", "error", err.Error())
return err
}
return nil
}
// Update modifies a single record.
func (m *SampleDB) Update(ctx context.Context, model *Sample) error {
defer goa.MeasureSince([]string{"goa", "db", "sample", "update"}, time.Now())
obj, err := m.Get(ctx)
if err != nil {
goa.LogError(ctx, "error updating Sample", "error", err.Error())
return err
}
err = m.Db.Model(obj).Updates(model).Error
return err
}
// Delete removes a single record.
func (m *SampleDB) Delete(ctx context.Context) error {
defer goa.MeasureSince([]string{"goa", "db", "sample", "delete"}, time.Now())
var obj Sample
err := m.Db.Delete(&obj).Where("").Error
if err != nil {
goa.LogError(ctx, "error deleting Sample", "error", err.Error())
return err
}
return nil
}
// Code generated by goagen v1.3.1, DO NOT EDIT.
//
// API "goa-sample": Model Helpers
//
// Command:
// $ goagen
// --design=github.com/hiroykam/goa-sample/design
// --out=$(GOPATH)/src/github.com/hiroykam/goa-sample
// --version=v1.3.1
package models
import (
"context"
"github.com/goadesign/goa"
"github.com/hiroykam/goa-sample/app"
"github.com/jinzhu/gorm"
"time"
)
// MediaType Retrieval Functions
// ListSample returns an array of view: default.
func (m *SampleDB) ListSample(ctx context.Context) []*app.Sample {
defer goa.MeasureSince([]string{"goa", "db", "sample", "listsample"}, time.Now())
var native []*Sample
var objs []*app.Sample
err := m.Db.Scopes().Table(m.TableName()).Find(&native).Error
if err != nil {
goa.LogError(ctx, "error listing Sample", "error", err.Error())
return objs
}
for _, t := range native {
objs = append(objs, t.SampleToSample())
}
return objs
}
// SampleToSample loads a Sample and builds the default view of media type Sample.
func (m *Sample) SampleToSample() *app.Sample {
sample := &app.Sample{}
sample.CreatedAt = m.CreatedAt
sample.Detail = m.Detail
sample.ID = m.ID
sample.Name = m.Name
sample.UpdatedAt = m.UpdatedAt
sample.UserID = m.UserID
return sample
}
// OneSample loads a Sample and builds the default view of media type Sample.
func (m *SampleDB) OneSample(ctx context.Context) (*app.Sample, error) {
defer goa.MeasureSince([]string{"goa", "db", "sample", "onesample"}, time.Now())
var native Sample
err := m.Db.Scopes().Table(m.TableName()).Where("").Find(&native).Error
if err != nil && err != gorm.ErrRecordNotFound {
goa.LogError(ctx, "error getting Sample", "error", err.Error())
return nil, err
}
view := *native.SampleToSample()
return &view, err
}
手動で頑張る場合
サンプルではmodels
とservice
を用意しました。
di
も用意すべきかもしれません。
Controllerの実装
下記コマンドで雛形が作成されます。
コマンド再実行により、ここに追記したコードは削除されません。
$ goagen controller -d github.com/hiroykam/goa-sample/design --out=./controller
Resource、MediaTypeの変更
Resource、MediaTypeを変更した際、下記を実行します。
$ goagen app -d github.com/hiroykam/goa-sample/design
$ goagen swagger -d github.com/hiroykam/goa-sample/design
$ goagen controller -d github.com/hiroykam/goa-sample/design --out=./controller
goa
の起動
make
サンプルで下記のコマンドで、docker-compose build
します。
$ make docker-build
ビルド完了後、下記のコマンドで起動できます。
$ make docker-up
swagger-ui
起動後、http://localhost:8080/swagger-ui/
にアクセスすると下記が表示されます。

REST API
Postman
を使いました。

その他
goagen
に失敗する
goagen
を実行すると、下記のようなエラーが発生する時があります。
exit status 1
missing API definition, make sure design is properly initialized
make: *** [_controller] Error 1
github
のissue
で散見されていましたが、簡単な解決方法はvendor
ディレクトリを一旦削除すると解決しました。
dep
を使っている場合、goagen
に失敗する
issueを拝見する限りですと、dep
の問題といった記載があり、Gopkg.toml
に下記を追記すると解決できる。
dep
を使わないほうが良いのでしょうか・・・
[[override]]
name = "github.com/satori/go.uuid"
revision = "master"
エラーの内容が分かりづらい
例えば、./design/media_types.go
で下記のような属性を定義します。
Attribute("created_at", DateTime, "作成日", func() {
Example("2019-01-01")
})
そこで、goagen
しても、下記のようなエラーが表示されますが、内容からRFC3339
フォーマットに準拠していないのが原因であることが分かりませんでした。
$ goagen app -d github.com/hiroykam/goa-sample/design
exit status 1
[design/media_types.go:24] example value "2019-01-01" is incompatible with attribute of type string
log出力が扱いづらい
main.go
でこのようにstodout
にJSON
形式のログ出力をしたかったが、reqest ID
などの情報まで出力することができませんでした。
また、goa
内部で出力するログは別のlogger
が使われているため、思うようなログ出力が期待できませんでした。
logger := log15.New(log15.Ctx{"module": "goa-sample"})
logger.SetHandler(log15.StreamHandler(os.Stdout, log15.JsonFormat()))
service.WithLogger(goalog15.New(logger))
そのため、サンプルではjson
出力するlogger
を別途用意しました。
trailing slashの扱いについて
URLの末尾に/
ありとなしでAPIを実行すると、意図していないAPIが呼び出されるケースがありました(調査中)。
参考
こちらの記事をベースに勉強をさせていただきました。
今後
- テストコードを書く
- CircleCIなどの環境を整える
- jwtで認証
- Makefile、READMEの修正・更新
- React/ReduxからAPIを呼び出す
-
gorm.ErrRecordNotFound
が検出できない原因調査