LoginSignup
24

More than 3 years have passed since last update.

はじめてのgoa

Last updated at Posted at 2019-02-03

goaについて

Goを使ってREST APIを用意したいときに利用できるマイクロサービス用のフレームワークの一つです。
ビジネスロジック中心で開発ができる、swaggerのドキュメントが自動生成されるなどの利点があります。

goaのGitHubはこちらにあります。

サンプルについて

今回用意したサンプルはこちらです。

環境設定等

mercurialのインストール

依存関係の解決にあたり、depを使ったが、Macdep 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

goadepのインストール

goadepgo 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のベースとなる基本的な定義です。

./design/api_base.go
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に配置する必要があります。

./design/swagger.go
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")
})

続いて、レスポンスデータの型を定義します。

design/media_types.go
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.goswagger.go./controllerに移動させます。

Modelの定義

自動生成する場合

まずは、./design/models.goを用意します。
idstringにしたい場合、gorma.UUIDとします。
deleted_atを指定すると、gormdeleteを実行した際、論理削除になります。

./design/models.go
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で絞り込むGetListを別に用意する必要があるなどの手間は発生します。
またmodelを追加し、上記コマンドを実行しますとsample.goが新規に作成されてしまいますので、./model/sample_extention.goといったように別ファイルで定義する必要があります。

models/sample.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
}
models/sample_helper.go
// 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
}

手動で頑張る場合

サンプルではmodelsserviceを用意しました。
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/にアクセスすると下記が表示されます。

スクリーンショット 2019-02-03 15.06.17.png

REST API

Postmanを使いました。

スクリーンショット 2019-02-03 18.04.15.png

その他

goagenに失敗する

goagenを実行すると、下記のようなエラーが発生する時があります。

exit status 1
missing API definition, make sure design is properly initialized
make: *** [_controller] Error 1

githubissueで散見されていましたが、簡単な解決方法は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 でこのようにstodoutJSON形式のログ出力をしたかったが、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が検出できない原因調査

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24