ぺい

渋谷系アドテクエンジニアの落書き

GolangのgoaでAPIをデザインしよう(基本編)

goaはいいぞ!

f:id:tikasan0804:20170505212036p:plain

Golangのgoaの勉強に役立つ情報まとめ - ぺい
goaの情報をもっと見たい方は、上のリンクから確認してください

goでAPIを作成する場合は、必ずといっていいくらいgoaでやっている私ですが、日本ではあまり使っている人が居ません。恐らくこの理由は日本語の情報があまり無いことやシンプルなサンプル集がないからかもしれない・・・と思いまして、軽量なサンプルを定期的に紹介していくことにしました。

API設計フェーズ

goaは最初に設計を行ってから、その設計書を元に実装を行っていきます。設計はDSLと呼ばれるもので、最初はこの定義に慣れないですが、読み方さえ分かれば容易にAPIを設計出来ます。

何が嬉しいの?

例えば、GET /users/:IDというエンドポイントがあるとします。どのようなロジックを組む必要があるでしょう?

curl http://localhost/users/1でリクエストする

  1. /users/1の1の部分を取得する
  2. :IDから取得したものが文字なのか数字なのかチェックする
  3. 取得した:IDを使って、データベースなりにSELECT句などで検索する
  4. 該当するものがなかったら、404ステータスを返す
  5. 正しく取得できたら、200ステータスとレコードを返す。

goaだとどうなるか?

  1. /users/1の1の部分を取得する
  2. :IDから取得したものが文字なのか数字なのかチェックする
  3. 取得した:IDを使って、データベースなりにSELECT句などで検索する
  4. 該当するものがなかったら、404ステータスを返す
  5. 正しく取得できたら、200ステータスとレコードを返す。

1,2に当たる値チェックなどが一切必要なくなります。上記のように少ないパラメータならそこまでの労力ではありませんが、正規表現が必要だったり、複数だったりすると・・・面倒ですよね?
つまり、goaはビズネスロジックオンリーの開発が出来るようになるので、人が組むコードの部分が非常にシンプルになります。
しかも、swaggerのドキュメントが自動生成されるので、ドキュメント更新地獄から解放されます。

フォルダ構成

以下のようなファイル構成を構築してください。
※designフォルダ内のファイルは実際は一つでもokですが、分ける方が私は好きです。

.
├── LICENSE
├── Makefile
├── README.md
├── design
│   ├── api_definition.go
│   ├── media_types.go
│   └── resources.go
└── public
    └── swagger

goaはコード生成する時に少し長めのコマンドが必要になるので、Makefileを作っておくと良いと思います。 ちなみに、コマンドやフォルダ構成は毎回同じものを使うので、テンプレートを作成しました。よろしければ利用してください。
もし、もっと便利なフォーマットがあればPRをください。

go get github.com/tikasan/goa-stater
または
ghq get github.com/tikasan/goa-stater

デザインする

準備が整ったので、さっそくデザインをしていきたいと思います。
今回はとりあえず動かそうということで、サンプルソースをひたすら貼ります。
細かい説明は順を追って記事を作成して、紹介致します。

APIの基本情報(api_definition.go)

APIの実際の動作ではなく、どういったAPIか?やグローバルな設定などを行います。ここに紹介しているもの以外にも関数は存在します。

package design

import (
    . "github.com/goadesign/goa/design"
    . "github.com/goadesign/goa/design/apidsl"
)

var _ = API("goa simple sample", func() {
    // APIのタイトル
    Title("tikasan/goa-simple-sample")
    // APIの説明
    Description("goaのサンプルです")
    // 作成者へのコンタクト情報
    Contact(func() {
        Name("pei")
        Email("satak47cpc@gmail.com")
        URL("https://github.com/tikasan/goa-simple-sample/issues")
    })
    // APIのライセンス
    License(func() {
        Name("MIT")
        URL("https://github.com/tikasan/eventory/blob/master/LICENSE")
    })
    // APIのドキュメント
    Docs(func() {
        Description("wiki")
        URL("https://github.com/tikasan/goa-simple-sample/wiki")
    })
    // ホストの設定
    Host("localhost:8080")
    // 対応しているプロトコル定義、httpかhttpsまたはその両方
    Scheme("http", "https")
    // 全てのエンドポイントのベースパス
    // /usersというエンドポイントがあったら、/api/v1/usersとなる
    BasePath("/api/v1")

    // CORSポリシーの定義
    Origin("http://localhost:8080/swagger", func() {
        //クライアントに公開された1つ以上のヘッダー
        Expose("X-Time")
        // 1つまたは複数の許可されたHTTPメソッド
        Methods("GET", "POST", "PUT", "DELETE")
        //プリフライト要求応答をキャッシュする時間
        MaxAge(600)
        // Access-Control-Allow-Credentialsヘッダーを設定する
        Credentials()
    })
})

MediaType(media_types.go)

レスポンスデータの形式を定義します。(詳しいことは別記事を紹介予定)

package design

import (
    . "github.com/goadesign/goa/design"
    . "github.com/goadesign/goa/design/apidsl"
)

// レスポンスデータの定義
// MediaTypeに名前をつけます
var IntegerMedia = MediaType("application/vnd.integer+json", func() {
    // 説明
    Description("example")
    // どのような値があるか(複数定義出来る)
    Attributes(func() {
        // idはInteger型
        Attribute("id", Integer, "id", func() {
            // 返すレスポンスの例
            Example(1)
        })
        // レスポンスに必須な要素(基本は全て必須にした方が楽)
        Required("id")
    })
    // 返すレスポンスのフォーマット(別記事で紹介予定)
    View("default", func() {
        Attribute("id")
    })
})

リソースへの操作(resources.go)

リソースへの操作を定義します。(詳しいことは別記事を紹介予定)

package design

import (
    . "github.com/goadesign/goa/design"
    . "github.com/goadesign/goa/design/apidsl"
)

// /actionsの定義をする
var _ = Resource("actions", func() {
    // actionsリソースのベースパス
    BasePath("/actions")
    /*
       リソースに対してどういった操作を行うか定義する
       add リソースを追加する
       list    リソースをリストで取得する
       delete  リソースを削除する
       上記のような感じで定義すればおkです。
   */
    Action("ping", func() {
        // アクションの説明
        Description("サーバーとの導通確認")
        Routing(
            // エンドポイント -> GET http://localhost/api/v1/actions/pingになる
            GET("/ping"),
        )
        // 返したいレスポンス
        // 200 OK + MessageTypeで定義しているMediaType
        Response(OK, MessageType)
        // 400 BadRequest + ErrorMediaというデフォルトで容易されているMediaType
        // 足りないパラメーターなどがあれば自動的に返される
        Response(BadRequest, ErrorMedia)
    })
    Action("hello", func() {
        Description("挨拶する")
        Routing(
            GET("/hello"),
        )
        // リクエストで付加出来るパラメーター
        Params(func() {
            // nameという名前でパラメーターをStringでなげれる
            Param("name", String, "名前", func() {
                // もし、空だった場合は空文字を格納する
                Default("")
            })
            // 必ず設定されるべきパラメーター(デフォルト値があるので、存在しなければ空になる)
            Required("name")
        })
        Response(OK, MessageType)
        Response(BadRequest, ErrorMedia)
    })
    Action("ID", func() {
        Description("複数アクション(:id)")
        Routing(
            // エンドポイントにリソースを指定出来る
            // GET http://localhost:8080/api/v1/actions/1になる
            GET("/:id"),
        )
        Params(func() {
            // :IDはIntegert型でなければならない。
            Param("id", Integer, "id")
            // Requiredはリソースを含めたエンドポイントになるので、定義しなくても良い
            //Required("id")
        })
        Response(OK, IntegerType)
        // 指定したリソースが無ければNotFoundを返す可能生がある
        Response(NotFound)
        Response(BadRequest, ErrorMedia)
    })
})

// Swaggerをローカルで実行するめの定義
var _ = Resource("swagger", func() {
    Origin("*", func() {
        Methods("GET")
    })
    Files("/swagger.json", "swagger/swagger.json")
    Files("/swagger/*filepath", "public/swagger/")
})

コードを生成する

インストール方法やコマンドの説明はikawahaさんの記事がわかりやすいので、紹介しておきます。
ikawaha.hateblo.jp

今回はgithub.com/tikasan/goa-staterのrepoをgo getした想定で説明します。

goagen bootstrap -d github.com/tikasan/goa-stater/design

コマンドを実行すると以下のようなログが流れます。 実行出来ない場合は、designパッケージ(フォルダ)の指定が間違っています。

app/contexts.go
app/controllers.go
.....
.....
swagger
swagger/swagger.json
swagger/swagger.yaml

出来上がる構成は以下のような感じです。
実際に作業をするファイルは一部です。 app,client,tool,swagger周りは自動で生成されたもので、変更することはないです。

.
├── LICENSE
├── Makefile
├── README.md
├── actions.go <--------- actionsリソース
├── app
├── client
├── design
│   ├── api_definition.go
│   ├── media_types.go
│   ├── resources.go
│   └── security.go
├── main.go <------------ Middlewareとかそこらへんやる
├── public
│   └── swagger
├── security.go
├── swagger
├── swagger.go
└── tool

このままだと少しごちゃごちゃしているので、私はcontrollerというパッケージにまとめたりしています。

.
├── LICENSE
├── Makefile
├── README.md
├── actions.go <--------- controllerフォルダへ移動させる
├── app
├── client
├── controller
├── design
│   ├── api_definition.go
│   ├── media_types.go
│   ├── resources.go
│   └── security.go
├── main.go
├── public
│   └── swagger
├── security.go
├── swagger
├── swagger.go <------------ controllerフォルダへ移動させる
└── tool

main.goを若干記述を変える必要があるので、調整だけしてください。

//go:generate goagen bootstrap -d github.com/tikasan/goa-stater/design

package main

import (
    "github.com/goadesign/goa"
    "github.com/goadesign/goa/middleware"
    "github.com/tikasan/goa-stater/app"
    "github.com/tikasan/goa-stater/controller" <-------importする
)

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

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

    // Mount "actions" controller
    c := controller.NewActionsController(service) <-----controllerパッケージからメソッドを呼ぶ
    app.MountActionsController(service, c)

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

これで準備完了しました。ここまでの作業は正直テンプレートなので、慣れればほとんど時間がかかりません。

ビジネスロジックを組んでみよう

package controller

import (
    "github.com/goadesign/goa"
    "github.com/tikasan/goa-stater/app"
)

// ActionsController implements the actions resource.
type ActionsController struct {
    *goa.Controller
}

// NewActionsController creates a actions controller.
func NewActionsController(service *goa.Service) *ActionsController {
    return &ActionsController{Controller: service.NewController("ActionsController")}
}

// Action ID
func (c *ActionsController) ID(ctx *app.IDActionsContext) error {

    /*
       Paramsで定義したものはctx.hogeで受け取れる
       Params(func() {
           Param("ID", Integer, "ID")
       })
   */
    if ctx.ID == 0 {
        // Response(NotFound)
        // 0の場合は、404エラーを返す
        return ctx.NotFound()
    }

    // Response(OK, IntegerType)
    // MediaType IntegerTypeでレスポンス
    res := &app.Integer{}
    /*
       IDはInteger型で
       Attribute("ID", Integer, "ID", func() {
           Example(1)
       })
   */
    res.ID = ctx.ID
    return ctx.OK(res)
}

// Hello runs the hello action.
func (c *ActionsController) Hello(ctx *app.HelloActionsContext) error {
    // ActionsController_Ping: start_implement

    // Put your logic here
    name := ctx.Name

    // ActionsController_Ping: end_implement
    res := &app.Message{}
    res.Message = "Hello " + name
    return ctx.OK(res)
}

// Ping runs the ping action.
func (c *ActionsController) Ping(ctx *app.PingActionsContext) error {
    // ActionsController_Ping: start_implement

    // Put your logic here
    message := "pong"

    // ActionsController_Ping: end_implement
    res := &app.Message{}
    res.Message = message
    return ctx.OK(res)
}

以上でAPIビジネスロジックの実装完了です。簡単でしょう?
では、実行してみましょう。

go run main.go

エラーが起きた場合は、importミスやポートが空いてないや何かしらのミスが考えられます。

curl -v http://localhost:8080/api/v1/actions/1
*   Trying ::1...
* Connected to localhost (::1) port 8080 (#0)
> GET /api/v1/actions/1 HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.43.0
> Accept: */*
> 
< HTTP/1.1 200 OK
< Content-Type: application/vnd.integer+json
< Date: Fri, 05 May 2017 11:49:59 GMT
< Content-Length: 9
< 
{"id":1}
* Connection #0 to host localhost left intact

上記のような感じでレスポンスが返ってきました。
試しに間違ったリクエストを投げてみましょう。

curl -v http://localhost:8080/api/v1/actions/hoge
*   Trying ::1...
* Connected to localhost (::1) port 8080 (#0)
> GET /api/v1/actions/hoge HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.43.0
> Accept: */*
> 
< HTTP/1.1 400 Bad Request
< Content-Type: application/vnd.goa.error
< Date: Fri, 05 May 2017 12:01:54 GMT
< Content-Length: 188
< 
{"id":"Iwzpk08B","code":"invalid_request","status":400,"detail":"invalid value \"hoge\" for parameter \"id\", must be a integer","meta":{"expected":"integer","param":"ID","value":"hoge"}}
* Connection #0 to host localhost left intact

エラーのフォーマットはカスタム出来ますが、いまはデフォルトで定義されているフォーマットで返ってきます。特にこだわりがなければこれで完了します。

SwaggerUIを使ってみよう

swaggerのドキュメントも出来上がっているので、github.com/tikasan/goa-staterを使っている場合は、以下のコマンドでAPIのドキュメントの参照とテストが出来ます!!!

open http://localhost:8080/swagger/index.html

f:id:tikasan0804:20170505214106p:plain

初めの方に説明した。ビズネスロジックだけを書けば実装が完了していることが実感出来たと思います。他にもヘッダーのチェックやmodel定義が簡単に出来るgormaなど色々あるので、どんどん紹介していきたいと思います。

goa紹介で使うソース達は、以下のリポジトリに随時更新していきます。
github.com