ぺい

大阪の専門学生の落書き。主にエンジニア寄りの話。

goのgoaでAPIをデザインしよう(Model編)

goaはいいぞ!

f:id:tikasan0804:20170505212036p:plain

  1. goのgoaでAPIをデザインしよう(基本編) - ぺい
  2. goのgoaでAPIをデザインしよう(バリデーション編) - ぺい
  3. goのgoaでAPIをデザインしよう(レスポンス編) - ぺい
  4. goのgoaでAPIをデザインしよう(クライアント編) - ぺい
  5. goのgoaでAPIをデザインしよう(エンドポイント編) - ぺい
  6. goのgoaでAPIをデザインしよう(ハマりポイント編) - ぺい
  7. goのgoaでAPIをデザインしよう(Model編) - ぺい
  8. goのgoaでAPIをデザインしよう(Model編②) - ぺい

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の使い方を理解していればカスタマイズはいくらでも出来ます。

github.com

さっそくgorma開発

gormaの開発には当然ですけど、RDBMSで使うDDLMySQLなどの環境構築が必要になります。なので、用意しました。最低限必要なのは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リソースに対してのアクションをしてみましょう。 f:id:tikasan0804:20170512152749p:plain f:id:tikasan0804:20170512152559p:plain f:id:tikasan0804:20170512152600p:plain

とりあえず、レスポンスが返ってきました。では、どういう動きだったかについて詳しく説明します。

やったこと

ER図
f:id:tikasan0804:20170512153959p:plain

発行した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の癖が強いというこもあり、かなり苦労しました。

f:id:tikasan0804:20170515171457p:plain

この解決方法が全然日本語情報がない上に、しかも、よく見かけるケースなので、また記事にする予定です。 今回はgormaをさくっと使うところまでということで、ここまでにしておきます。