GolangのgoaでAPIをデザインしよう(Model編)
goaはいいぞ!
Golangのgoaの勉強に役立つ情報まとめ - ぺい
goaの情報をもっと見たい方は、上のリンクから確認してください
Model層をいい感じにしてくれるgorma
goaはAPIの入り口と出口の部分をいい感じにしてくれますが、Modelとのやり取りなどのロジックは手で書く必要があります。正直だるくないっすか?だるいですよね。簡単なRDBMSとのやり取り程度なら実は自動でいい感じにしてくれるプラグインがgormaです。
goaには、goagenというものがあり、これに独自で書いたコード生成ロジックを作成して食わせるといい感じにしてくれます。gormaはそのコード生成ロジックのModel部分のものです。
gormaがやってくれること
では、実際にgormaがやってくれる素晴らしい仕事をざっくりではありますけど、紹介します。
CRUDは秒で終わる
以下によくあるエンドポイントの例があります。これくらいなら、gormaに任せたら実装は終了します。
GET /api/v1/bottles bottlesを複数取得する GET /api/v1/bottles/{id} bottlesから1件取得する POST /api/v1/bottles bottlesを1件作成する DELETE /api/v1/bottles/{id} bottlesを1件削除する PUT/api/v1/bottles/{id} bottlesを1件更新する
ちょっとした入れ子構造はいい感じにしてくれる
bottlesというリソースがあったとします。bottlesのひとつひとつには、所有者であるaccountsのリソースに紐付いています。jsonでいい感じに返してくださいという案件があったとします。以下のようなjsonがほしくなります。
gormaでやると秒で終わります。
[ { "account": { "email": "example@gmail.com", "id": 1, "name": "山田 太郎" }, "id": 1, "name": "シャルドネ", "quantity": 4 }, { "account": { "email": "example@gmail.com", "id": 1, "name": "山田 太郎" }, "id": 1, "name": "シャルドネ", "quantity": 4 } ]
カスタマイズ出来る
gormaってよくわかんないやつ嫌だなーと最初は思ってたんですけど、実はこいつはgormというORMのコードを自動生成してくれてるだけなので、gormの使い方を理解していればカスタマイズはいくらでも出来ます。
さっそくgorma開発
gormaの開発には当然ですけど、RDBMSで使うDDLやMySQLなどの環境構築が必要になります。なので、用意しました。最低限必要なのはDockerが動く環境なので、適宜用意してください。
今回使うサンプル
github.com
$ go get github.com/tikasan/goa-simple-sample または $ ghq get github.com/tikasan/goa-simple-sample
上記のいずれかのコマンドで、ローカルに落としてきてください。
環境構築と実行
Docker立ち上げて、DDL読み込ませて、起動してSwaggerで実行してみます。
$ make docker/build $ make docker/start $ make migrate/up $ make rundb $ make swaggerUI
Bottlesリソースに対してのアクションをしてみましょう。
とりあえず、レスポンスが返ってきました。では、どういう動きだったかについて詳しく説明します。
やったこと
ER図
発行したSQL
SELECT * FROM `bottles` WHERE `bottles`.`deleted_at` IS NULL SELECT * FROM `accounts` WHERE `accounts`.`deleted_at` IS NULL AND ((`id` IN ('1','2','2','3')))
goaコード
MediaType
var Account = MediaType("application/vnd.account+json", func() { Description("celler account") Attributes(func() { Attribute("id", Integer, "id", func() { Example(1) }) Attribute("name", String, "名前", func() { Example("山田 太郎") }) Attribute("email", String, "メールアドレス", func() { Example("example@gmail.com") }) Required("id", "name", "email") }) View("default", func() { Attribute("id") Attribute("name") Attribute("email") }) }) var Bottle = MediaType("application/vnd.bottle+json", func() { Description("celler bottles") Attributes(func() { Attribute("id", Integer, "id", func() { Example(1) }) Attribute("name", String, "ボトル名", func() { Example("シャルドネ") }) Attribute("quantity", Integer, "数量", func() { Example(4) }) // accountのMediaTypeを入れ子構造にする Attribute("account", Account) Required("id", "name", "quantity", "account") }) View("default", func() { Attribute("id") Attribute("name") Attribute("quantity") Attribute("account") }) }) var Category = MediaType("application/vnd.category+json", func() { Description("celler account") Attributes(func() { Attribute("id", Integer, "id", func() { Example(1) }) Attribute("name", String, "名前", func() { Example("ワイン") }) Required("id", "name") }) View("default", func() { Attribute("id") Attribute("name") }) })
エンドポイント
var _ = Resource("bottles", func() { BasePath("/bottles") // --------- さっき実行したのはこれ --------- Action("list", func() { Description("複数") Routing( GET("/"), ) Response(OK, CollectionOf(Bottle)) Response(BadRequest, ErrorMedia) }) // --------- さっき実行したのはこれ --------- Action("show", func() { Description("単数") Routing( GET("/:id"), ) Params(func() { Param("id", Integer, "id", func() { Example(1) }) }) Response(OK, Bottle) Response(NotFound) Response(BadRequest, ErrorMedia) }) Action("add", func() { Description("追加") Routing( POST("/"), ) Params(func() { Param("account_id", Integer, "アカウントID", func() { Example(1) }) Param("name", String, "ボトル名", func() { Default("") Example("赤ワインなにか") }) Param("quantity", Integer, "数量", func() { Example(0) }) Required("account_id", "name", "quantity") }) Response(Created) Response(BadRequest, ErrorMedia) }) Action("delete", func() { Description("削除") Routing( DELETE("/:id"), ) Params(func() { Param("id", Integer, "id", func() { Example(1) }) }) Response(OK) Response(NotFound) Response(BadRequest, ErrorMedia) }) Action("update", func() { Description("更新") Routing( PUT("/:id"), ) Params(func() { Param("id", Integer, "id", func() { Example(1) }) Param("name", String, "ボトル名", func() { Default("") Example("赤ワインなにか") }) Param("quantity", Integer, "数量", func() { Default(0) Minimum(0) Example(0) }) }) Response(OK) Response(NotFound) Response(BadRequest, ErrorMedia) }) })
gormaコード
Model
gormaには、命名規則があります。
種類 | 説明 | gormaでの書き方 |
---|---|---|
テーブル名 | テーブル名は複数形 | bottles,categories |
カラム名 | カラム名はスネークケース | user_id |
Model名 | テーブル名が複数形でみModelは単数形 | Bottle,Category |
PrimaryKey | 必ずid統一する (IntegerかUUID) | id |
timestamp | created_at,updated_at,deleted_atで統一 | --- |
特にModel名とテーブル名が同じにならないのは、結構ハマりポイントだったりする。この他にもあるかもしれませんが、基本的にgormの規則に従っています。
実際に組んだコードを以下に示します。
package design import ( "github.com/goadesign/gorma" . "github.com/goadesign/gorma/dsl" ) var _ = StorageGroup("celler", func() { Description("celler Model") // Mysqlを使う Store("MySQL", gorma.MySQL, func() { Description("MySQLのリレーションナルデータベース") // accountsテーブルのModelなら、Account Model("Account", func() { // MediaTypeで作成したAccountにマッピングする RendersTo(Account) Description("celler account") // PrimaryKeyの設定 Field("id", gorma.Integer, func() { PrimaryKey() }) Field("name", gorma.String) Field("email", gorma.String) // timestamp系の定義 Field("created_at", gorma.Timestamp) Field("updated_at", gorma.Timestamp) Field("deleted_at", gorma.NullableTimestamp) // HasMany(複数形名, 単数形名) HasMany("Bottles", "Bottle") }) Model("Bottle", func() { RendersTo(Bottle) Description("celler bottle") Field("id", gorma.Integer, func() { PrimaryKey() }) Field("name", gorma.String) Field("quantity", gorma.Integer) Field("created_at", gorma.Timestamp) Field("updated_at", gorma.Timestamp) Field("deleted_at", gorma.NullableTimestamp) // BelongTo(単数形) BelongsTo("Account") HasMany("BottleCategories", "BottleCategory") }) Model("Category", func() { RendersTo(Category) Description("celler category") Field("id", gorma.Integer, func() { PrimaryKey() }) Field("name", gorma.String) Field("created_at", gorma.Timestamp) Field("updated_at", gorma.Timestamp) Field("deleted_at", gorma.NullableTimestamp) HasMany("BottleCategories", "BottleCategory") }) Model("BottleCategory", func() { Description("celler bottle category") Field("id", gorma.Integer, func() { PrimaryKey() }) Field("created_at", gorma.Timestamp) Field("updated_at", gorma.Timestamp) Field("deleted_at", gorma.NullableTimestamp) BelongsTo("Category") BelongsTo("Bottle") }) }) })
gormaのコード生成は以下のコマンドです。
$ go get github.com/goadesign/gorma <--- gorma入れてない人はこれする $ goagen --design=$(REPO)/design gen --pkg-path=github.com/goadesign/gorma
models/ ├── account.go ├── account_helper.go ├── bottle.go ├── bottle_helper.go ├── bottlecategory.go ├── bottlecategory_helper.go ├── category.go └── category_helper.go
上記のようなファイル群が出てきたかと思います。それぞれのファイルが持っているコードは以下のようになっています。
ファイル名 | 説明 |
---|---|
model名.go | gormに必要な構造体とCRUDの最低限のメソッド |
model名_helper.go | MediaTypeに合わせたRead系メソッド集 |
では、実際にクエリを投げるために、下準備をします。
gormaでは、外部テーブルからの参照系のクエリをデフォルトでは実装されていません。恐らくなんでもかんでも結合すると、クエリの処理速度が遅くなるからだと思われす。models/bottle_helper.go
// ListBottle returns an array of view: default. func (m *BottleDB) ListBottle(ctx context.Context, accountID int) []*app.Bottle { defer goa.MeasureSince([]string{"goa", "db", "bottle", "listbottle"}, time.Now()) var native []*Bottle var objs []*app.Bottle //err := m.Db.Scopes(BottleFilterByAccount(accountID, m.Db)).Table(m.TableName()).Find(&native).Error // Accountテーブルから、情報を取得したいので、Preloadメソッドを追加する err := m.Db.Scopes(BottleFilterByAccount(accountID, m.Db)).Table(m.TableName()).Preload("Account").Find(&native).Error if err != nil { goa.LogError(ctx, "error listing Bottle", "error", err.Error()) return objs } for _, t := range native { objs = append(objs, t.BottleToBottle()) } return objs }
上記のように、メソッドを直接書き換えるのも良いですが、自動生成されたメソッドをあまり書き換えたくないというのもあるので、私はユーザー定義用のファイルを作成して対応したりしてます。
bottle_defined.go model名_defined.go
そして、modelのデザインに変更が発生して、再生成する時は、以下のMakeコマンドを走らせることで、安全にコードを再生するようにしています。
model: @ls models | grep -v '_defined.go' | xargs rm -f @goagen --design=$(REPO)/design gen --pkg-path=github.com/goadesign/gorma
次にコントローラーを定義します。controller/bottles.go
package controller import ( "github.com/goadesign/goa" "github.com/jinzhu/gorm" "github.com/tikasan/goa-simple-sample/app" "github.com/tikasan/goa-simple-sample/models" ) // BottlesController implements the bottles resource. type BottlesController struct { *goa.Controller db *gorm.DB } // NewBottlesController creates a bottles controller. func NewBottlesController(service *goa.Service, db *gorm.DB) *BottlesController { return &BottlesController{ Controller: service.NewController("BottlesController"), db: db, } } // Add runs the add action. func (c *BottlesController) Add(ctx *app.AddBottlesContext) error { // BottlesController_Add: start_implement // Put your logic here b := &models.Bottle{} b.AccountID = ctx.AccountID b.Name = ctx.Name b.Quantity = ctx.Quantity bdb := models.NewBottleDB(c.db) err := bdb.Add(ctx.Context, b) if err != nil { return ctx.BadRequest(goa.ErrBadRequest(err)) } // BottlesController_Add: end_implement return ctx.Created() } // Delete runs the delete action. func (c *BottlesController) Delete(ctx *app.DeleteBottlesContext) error { // BottlesController_Delete: start_implement // Put your logic here bdb := models.NewBottleDB(c.db) err := bdb.Delete(ctx.Context, ctx.ID) if err != nil { return ctx.BadRequest(goa.ErrBadRequest(err)) } // BottlesController_Delete: end_implement return nil } //--------------------------------- // 例で使った部分 //--------------------------------- // List runs the list action. func (c *BottlesController) List(ctx *app.ListBottlesContext) error { // BottlesController_List: start_implement // Put your logic here bdb := models.NewBottleDB(c.db) b := bdb.ListBottle(ctx.Context, 0) // BottlesController_List: end_implement res := app.BottleCollection{} res = b return ctx.OK(res) } // Show runs the show action. func (c *BottlesController) Show(ctx *app.ShowBottlesContext) error { // BottlesController_Show: start_implement // Put your logic here bdb := models.NewBottleDB(c.db) b, err := bdb.OneBottle(ctx.Context, ctx.ID, 0) if err != nil { return ctx.NotFound() } // BottlesController_Show: end_implement res := &app.Bottle{} res = b return ctx.OK(res) } // Update runs the update action. func (c *BottlesController) Update(ctx *app.UpdateBottlesContext) error { // BottlesController_Update: start_implement // Put your logic here b := &models.Bottle{} b.ID = ctx.ID b.Name = ctx.Name b.Quantity = ctx.Quantity bdb := models.NewBottleDB(c.db) err := bdb.Update(ctx.Context, b) if err == gorm.ErrRecordNotFound { return ctx.NotFound() } else if err != nil { return ctx.BadRequest(goa.ErrBadRequest(err)) } // BottlesController_Update: end_implement return nil }
これで、完成しました。自動的にマッピングも完了し、欲しい情報を簡単に取得することが出来ます。
gormaは多少取っ付きにくさはありますが、理解すると、goaでの開発がさらにスピードアップするので、結構おすすめです。
gormaですんなり出来ない多対多問題
gormaの作成してくれたコードだけでは、当然出来ないことがあります。それは多対多のリレーションの場合です。これは元々のgormの癖が強いというこもあり、かなり苦労しました。
この解決方法が全然日本語情報がない上に、しかも、よく見かけるケースなので、また記事にする予定です。 今回はgormaをさくっと使うところまでということで、ここまでにしておきます。