goa

マイクロサービス開発を支援するフレームワーク

github
https://github.com/goadesign/goa

日本語doc
https://goa.design/ja/

install

依存管理はdepで行いました

$ brew install dep
$ dep
dep is a tool for managing dependencies for Go projects

Usage: dep <command>

Commands:

  init    Initialize a new project with manifest and lock files
  status  Report the status of the project's dependencies
  ensure  Ensure a dependency is safely vendored in the project
  prune   Prune the vendor tree of unused packages

Examples:
  dep init                          set up a new project
  dep ensure                        install the project's dependencies
  dep ensure -update                update the locked versions of all dependencies
  dep ensure github.com/pkg/errors  add a dependency to the project

Use "dep help [command]" for more information about a command.
# 設定ファイル作成
$ dep init

goaの追加

goファイルがないと下記のように怒られるので気をつけてください
私はひとまずmain.goを作成することで解決しました

$ dep ensure -add github.com/goadesign/goa/goagen
all dirs lacked any go code

使ってみる

チュートリアルの作成物としては
ワインボトルを扱うセラーサービスをAPIとして提供する

goaデザイン言語を書く

design/design.goの作成

design/design.go
package design                               

import (
    . "github.com/goadesign/goa/design"        // Use . imports to enable the DSL
    . "github.com/goadesign/goa/design/apidsl"
)

// API defines the microservice endpoint and
// other global properties. There should be one
// and exactly one API definition appearing in the design.
var _ = API("cellar", func() {
    Title("The virtual wine cellar")
    Description("A simple goa service")
    Scheme("http")
    Host("localhost:8080")
})

// Resources group related API endpoints
// together. They map to REST resources for REST services.
var _ = Resource("bottle", func() {
    BasePath("/bottles")
    DefaultMedia(BottleMedia)

    // Actions define a single API endpoint together
    // with its path, parameters (both path
    // parameters and querystring values) and payload
    // (shape of the request body).
    Action("show", func() {
        Description("Get bottle by id")
        Routing(GET("/:bottleID"))
        Params(func() {
            Param("bottleID", Integer, "Bottle ID")
        })
        // Responses define the shape and status code of HTTP responses.
        Response(OK)
        Response(NotFound)
    })
})

// BottleMedia defines the media type used to render bottles.
var BottleMedia = MediaType("application/vnd.goa.example.bottle+json", func() {
    Description("A bottle of wine")
    // Attributes define the media type shape.
    Attributes(func() {
        Attribute("id", Integer, "Unique bottle ID")
        Attribute("href", String, "API href for making requests on the bottle")
        Attribute("name", String, "Name of wine")
        Required("id", "href", "name")
    })
    // View defines a rendering of the media type.
    // Media types may have multiple views and must
    // have a "default" view.
    View("default", func() {
        Attribute("id")
        Attribute("href")
        Attribute("name")
    })
})
  • MediaTypeの第一引数には任意の値を入れる
  • デフォではjsonになる

  • default viewの設定は必須

  • Attributesの中にはMediaType内で使われる可能性のあるものを定義する

  • グローバルに置いているMediaTypeはOKレスポンスのときに参照される

codeを生成する

depで入れた場合は、goagenをbuildする必要がある

$ cd vendor/github.com/goadesign/goa/goagen
$ go build # goagenが作られる
$ cd -

code生成

$ ./vendor/github.com/goadesign/goa/goagen/goagen bootstrap -d path/design
app
app/contexts.go
app/controllers.go
app/hrefs.go
app/media_types.go
app/user_types.go
app/test
app/test/bottle_testing.go
main.go
bottle.go
tool/cellar-cli
tool/cellar-cli/main.go
tool/cli
tool/cli/commands.go
client
client/client.go
client/bottle.go
client/user_types.go
client/media_types.go
swagger
swagger/swagger.json
swagger/swagger.yaml

path/は、$GOPATH/src/pathだと考えてください

作成されたものについて

main.goはapiのエンドポイント
bottle.goはbottleリソースに対応するコントローラ

上記は、既に存在する場合には再生成されない

コントローラであるbottle.goにロジックを実装すれば完成する

bottle.go
package main

import (
    "github.com/goadesign/goa"
    "project/goa-tutorial/app"
)

// BottleController implements the bottle resource.
type BottleController struct {
    *goa.Controller
}

// NewBottleController creates a bottle controller.
func NewBottleController(service *goa.Service) *BottleController {
    return &BottleController{Controller: service.NewController("BottleController")}
}

// Show runs the show action.
func (c *BottleController) Show(ctx *app.ShowBottleContext) error {

        //ここにロジックを実装する

    res := &app.GoaExampleBottle{}
    return ctx.OK(res)
}

BottleControllerはmain.goのapp.MountBottleController()でmountされている

main.go
package main

import (
    "github.com/goadesign/goa"
    "github.com/goadesign/goa/middleware"
    "project/goa-tutorial/app"
)

func main() {
    // Create service
    service := goa.New("cellar")

    // Mount middleware
    service.Use(middleware.RequestID())
    service.Use(middleware.LogRequest(true))
    service.Use(middleware.ErrorHandler(service, true))
    service.Use(middleware.Recover())

    // Mount "bottle" controller
    c := NewBottleController(service)
    app.MountBottleController(service, c)

    // Start service
    if err := service.ListenAndServe(":8080"); err != nil {
        service.LogError("startup", "err", err)
    }
}

app.MountBottleController()にルーティングが記載されている

app/controllers.go
package app

import (
    "context"
    "github.com/goadesign/goa"
    "net/http"
)

// initService sets up the service encoders, decoders and mux.
func initService(service *goa.Service) {
    // Setup encoders and decoders
    service.Encoder.Register(goa.NewJSONEncoder, "application/json")
    service.Encoder.Register(goa.NewGobEncoder, "application/gob", "application/x-gob")
    service.Encoder.Register(goa.NewXMLEncoder, "application/xml")
    service.Decoder.Register(goa.NewJSONDecoder, "application/json")
    service.Decoder.Register(goa.NewGobDecoder, "application/gob", "application/x-gob")
    service.Decoder.Register(goa.NewXMLDecoder, "application/xml")

    // Setup default encoder and decoder
    service.Encoder.Register(goa.NewJSONEncoder, "*/*")
    service.Decoder.Register(goa.NewJSONDecoder, "*/*")
}

// BottleController is the controller interface for the Bottle actions.
type BottleController interface {
    goa.Muxer
    Show(*ShowBottleContext) error
}

// MountBottleController "mounts" a Bottle resource controller on the given service.
func MountBottleController(service *goa.Service, ctrl BottleController) {
    initService(service)
    var h goa.Handler

    h = func(ctx context.Context, rw http.ResponseWriter, req *http.Request) error {
        // Check if there was an error loading the request
        if err := goa.ContextError(ctx); err != nil {
            return err
        }
        // Build the context
        rctx, err := NewShowBottleContext(ctx, req, service)
        if err != nil {
            return err
        }
        return ctrl.Show(rctx)
    }
    service.Mux.Handle("GET", "/bottles/:bottleID", ctrl.MuxHandler("Show", h, nil))
    service.LogInfo("mount", "ctrl", "Bottle", "action", "Show", "route", "GET /bottles/:bottleID")
}

BottleControllerのShowメソッドが受け取るcontextの実装は下記

bottle.go
// Show runs the show action.
func (c *BottleController) Show(ctx *app.ShowBottleContext) error {

        //ここにロジックを実装する

    res := &app.GoaExampleBottle{}
    return ctx.OK(res)
}
app/context.go
package app

import (
    "context"
    "github.com/goadesign/goa"
    "net/http"
    "strconv"
)

// ShowBottleContext provides the bottle show action context.
type ShowBottleContext struct {
    context.Context
    *goa.ResponseData
    *goa.RequestData
    BottleID int
}

// NewShowBottleContext parses the incoming request URL and body, performs validations and creates the
// context used by the bottle controller show action.
func NewShowBottleContext(ctx context.Context, r *http.Request, service *goa.Service) (*ShowBottleContext, error) {
    var err error
    resp := goa.ContextResponse(ctx)
    resp.Service = service
    req := goa.ContextRequest(ctx)
    req.Request = r
    rctx := ShowBottleContext{Context: ctx, ResponseData: resp, RequestData: req}
    paramBottleID := req.Params["bottleID"]
    if len(paramBottleID) > 0 {
        rawBottleID := paramBottleID[0]
        if bottleID, err2 := strconv.Atoi(rawBottleID); err2 == nil {
            rctx.BottleID = bottleID
        } else {
            err = goa.MergeErrors(err, goa.InvalidParamTypeError("bottleID", rawBottleID, "integer"))
        }
    }
    return &rctx, err
}

// OK sends a HTTP response with status code 200.
func (ctx *ShowBottleContext) OK(r *GoaExampleBottle) error {
    ctx.ResponseData.Header().Set("Content-Type", "application/vnd.goa.example.bottle+json")
    return ctx.ResponseData.Service.Send(ctx.Context, 200, r)
}

// NotFound sends a HTTP response with status code 404.
func (ctx *ShowBottleContext) NotFound() error {
    ctx.ResponseData.WriteHeader(404)
    return nil
}

OKとNotFoundのようにcontextがレスポンス結果の振る舞いを持つようになっている
コントローラを仮に実装して動かしてみる

bottle.go
// Show implements the "show" action of the "bottles" controller.
func (c *BottleController) Show(ctx *app.ShowBottleContext) error {
        if ctx.BottleID == 0 {
                // Emulate a missing record with ID 0
                return ctx.NotFound()
        }
        // Build the resource using the generated data structure
        bottle := app.GoaExampleBottle{
                ID:   ctx.BottleID,
                Name: fmt.Sprintf("Bottle #%d", ctx.BottleID),
                Href: app.BottleHref(ctx.BottleID),
        }

        // Let the generated code produce the HTTP response using the
        // media type described in the design (BottleMedia).
        return ctx.OK(&bottle)
}

ビルドして実行

$ go build -o cellar
$ ./celler
2017/08/20 00:10:26 [INFO] mount ctrl=Bottle action=Show route=GET /bottles/:bottleID
2017/08/20 00:10:26 [INFO] listen transport=http addr=:8080
・
・
・

リクエストのテスト

$ curl -i localhost:8080/bottles/1
HTTP/1.1 200 OK
Content-Type: application/vnd.goa.example.bottle+json
Date: Sat, 19 Aug 2017 16:55:47 GMT
Content-Length: 48

{"href":"/bottles/1","id":1,"name":"Bottle #1"}

不正なID(非整数)のバリデーションテスト(goagenによって生成されている)

$ curl -i localhost:8080/bottles/n
HTTP/1.1 400 Bad Request
Content-Type: application/vnd.goa.error
Date: Sat, 19 Aug 2017 16:58:41 GMT
Content-Length: 194

{"id":"n2OmVVJH","code":"invalid_request","status":400,"detail":"invalid value \"n\" for parameter \"bottleID\", must be a integer","meta":{"expected":"integer","param":"bottleID","value":"n"}}

該当コード

app/contexts.go
func NewShowBottleContext(ctx context.Context, r *http.Request, service *goa.Service) (*ShowBottleContext, error) {
    var err error
    resp := goa.ContextResponse(ctx)
    resp.Service = service
    req := goa.ContextRequest(ctx)
    req.Request = r
    rctx := ShowBottleContext{Context: ctx, ResponseData: resp, RequestData: req}
    paramBottleID := req.Params["bottleID"]
    if len(paramBottleID) > 0 {
        rawBottleID := paramBottleID[0]
        if bottleID, err2 := strconv.Atoi(rawBottleID); err2 == nil {
            rctx.BottleID = bottleID
        } else {
            err = goa.MergeErrors(err, goa.InvalidParamTypeError("bottleID", rawBottleID, "integer"))
        }
    }
    return &rctx, err
}

curlの代わりに生成されているCLIツールを使う

サーバーは起動しておいてください

$ cd tool/cellar-cli
$ go build -o cellar-cli
$ ./cellar-cli
$ ./cellar-cli show bottle /bottles/1
2017/08/20 02:05:55 [INFO] started id=6Q+3ari3 GET=http://localhost:8080/bottles/1
2017/08/20 02:05:55 [INFO] completed id=6Q+3ari3 status=200 time=5.34273ms
{"href":"/bottles/1","id":1,"name":"Bottle #1"}

cliツールは該当APIのhelpにもアクセスできます

$ ./cellar-cli show bottle --help
Usage:
  cellar-cli show bottle ["/bottles/BOTTLEID"] [flags]

Flags:
      --bottleID int   Bottle ID
  -h, --help           help for bottle
      --pp             Pretty print response body

Global Flags:
      --dump               Dump HTTP request and response.
  -H, --host string        API hostname (default "localhost:8080")
  -s, --scheme string      Set the requests scheme
  -t, --timeout duration   Set the request timeout (default 20s)

感想

goa wayとでもいうのでしょうか

コードが自動生成されて、それを使うっていうのは現状抵抗ありますが
開発が早くなってドキュメントも残るのであれば慣れていきたいなーと思いました

もう少しAPIを増やして使いづらい点や良い点を見つけていければと

frameworkはいつ切り捨てられるかわからないので
なるべく薄くてコミッターが多いものを使っていきたいです