goa が返すエラーレスポンスの Content-Type を変更する

Web API がエラーレスポンスを返す際にはレスポンスボディでエラーの詳細を示すのが一般的です。 goa には goa.ErrorResponse という型が組み込まれており、 controller に到達する前の処理でエラーが発生した際のレスポンスに使われています。また controller の中で goa.ErrorResponse を使うこともできます。

controller 到達前のエラーレスポンス

例えば /foo というエンドポイントが存在しないのに GET /foo というリクエストが来たときには以下の形のレスポンスが返ります。

{
  "id": "XXXXXXXX",
  "code": "not_found",
  "status" :404,
  "detail": "/foo"
}

この際、レスポンスヘッダの Content-Typeapplication/vnd.goa.error となります。この値は goa.ErrorMediaIdentifier として定義されているため変更することが可能です。例えば Content-Typeapplication/vnd.myservice.error に変える場合は以下のコードを main パッケージに追加します。

package main

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

func init() {
    // Change the media type identifier used for error responses.
    goa.ErrorMediaIdentifier = "application/vnd.myservice.error"
}

controller が返すエラーレスポンス

goa.ErrorResponse には、対応する design 定義として design.ErrorMedia が用意されています。これを design の中で使うことで、あるレスポンスが goa.ErrorResponse のフォーマットであることを明示できます。以下の例では BadRequestErrorMedia を指定することで、不正なリクエストが来たときのエラーレスポンスが goa.ErrorResponse のフォーマットであることを表しています。

Response(BadRequest, ErrorMedia)

controller の実装では ctx.BadRequest() を呼び出すことで Bad Request を返却することができます。その際、引数に *goa.ErrorResponse を渡すとそれがレスポンスボディに設定されます。

ctx.BadRequest(&goa.ErrorResponse{
    Detail: "this is a error message",
})

こちらの Content-Typeapplication/vnd.goa.error ですが、controller 到達前のエラーレスポンスとは違い design.ErrorMedia.Identifier が参照されています。このフィールドには design.ErrorMediaIdentifier が設定されているため、まず design.ErrorMediaIdentifier を変更した上で design.ErrorMedia.Identifier に再設定しておくことで実際の Content-Type を変更することができます。

package design

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

func init() {
    // Change the media type identifier used for error responses.
    design.ErrorMediaIdentifier = "application/vnd.myservice.error"
    design.ErrorMedia.Identifier = design.ErrorMediaIdentifier
}

Swagger UI

goa は Swagger 定義を出力できるので Swagger UI と組み合わせて使われることも多いと思います。しかし application/vnd.goa.error のようにサフィックスが付いていない形式だと Try it out ボタンでリクエストを試すときにエラーレスポンスが Unknown response type となり表示されません。

f:id:tchssk:20180922113030p:plain

これを表示させるには、プロジェクトに以下のコードを追加して Content-Typeサフィックス付きの application/vnd.goa.error+json に変更します。

package main

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

func init() {
    // Change the media type identifier used for error responses.
    goa.ErrorMediaIdentifier = goa.ErrorMediaIdentifier + "+json"
}
package design

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

func init() {
    // Change the media type identifier used for error responses.
    design.ErrorMediaIdentifier = design.ErrorMediaIdentifier + "+json"
    design.ErrorMedia.Identifier = design.ErrorMediaIdentifier
}

これで Swagger UI でもレスポンスが表示されるようになります。

f:id:tchssk:20180922113527p:plain

goa で multipart/form-data を扱う (ファイルアップロード編)

前回 goa で multipart/form-data を扱う方法についてご紹介しました。

tchssk.hatenablog.com

multipart/form-data に関連してよく取り上げられるのがファイルアップロードですね。 HTML フォームを介したサーバへのファイル送信は多くのウェブサービスで利用されています。しかし前回の記事ではその話については言及しませんでした。理由は「ファイル」を表すデータ型がまだ goa に存在しなかったからです。

goa では Type DSL を使ってユーザ型を定義します。また、ユーザ型の各要素を定義するには Attribute DSL を使用します。

var ProfilePayload = Type("ProfilePayload", func() {
    Attribute("name", String, "Name")
    Attribute("birthday", DateTime, "Birthday")
    Required("name", "birthday")
})

Attribute の 2 番目の引数がデータ型です。上記StringDateTime を含む、以下 7 種類が用意されています

  • Boolean
  • Integer
  • Number
  • String
  • DateTime
  • UUID
  • Any

そして今回、新たに 8 種類目の File 型が追加されました。

github.com

では先ほどの design に File 型の要素を追加してみましょう。

var ProfilePayload = Type("ProfilePayload", func() {
    Attribute("name", String, "Name")
    Attribute("birthday", DateTime, "Birthday")
    Attribute("icon", File, "Icon")
    Required("name", "birthday", "icon")
})

ここから生成される Go の構造体は以下の形になります。

// ProfilePayload user type.
type ProfilePayload struct {
    // Birthday
    Birthday time.Time `form:"birthday" json:"birthday" xml:"birthday"`
    // Icon
    Icon *multipart.FileHeader `form:"icon,omitempty" json:"icon,omitempty" xml:"icon,omitempty"`
    // Name
    Name string `form:"name" json:"name" xml:"name"`
}

Icon の型が *multipart.FileHeader になっていますね。これは標準パッケージの mime/multipart で提供されている構造体で、ファイル名、 MIME ヘッダ、ファイルサイズを含んでいます。

type FileHeader struct {
        Filename string
        Header   textproto.MIMEHeader
        Size     int64
}

また、この構造体には Open() というメソッドが用意されており、ファイルを multipart.File として取得することが可能です。 multipart.Fileio.Reader などを実装しているので通常のファイルと同じようにように操作することができます。

以上を踏まえて、アップロードされたファイルをサーバのローカルディレクトリに保存する処理を controller に実装してみましょう。

func (c *ProfilesController) Submit(ctx *app.SubmitProfilesContext) error {
    res := &app.ResultMedia{
        Name:     ctx.Payload.Name,
        Birthday: ctx.Payload.Birthday,
    }
    file, err := ctx.Payload.Icon.Open()
    if err != nil {
        return err
    }
    defer file.Close()
    f, err := os.OpenFile("./icons/"+ctx.Payload.Icon.Filename, os.O_WRONLY|os.O_CREATE, 0666)
    if err != nil {
        return fmt.Errorf("failed to save file: %s", err) // causes a 500 response
    }
    defer f.Close()
    io.Copy(f, file)
    return ctx.OK(res)
}

前述の通り ctx.Payload.Icon*multipart.FileHeader となっているので Open()multipart.File を取得しています。あとはアップロードされたものと同じ名前のファイルを os.OpenFile() で作成し io.Copy() で書き込むだけです。

この例は既に公式サンプルにも収録されているので以下からご覧ください。

github.com

ファイルアップロードにも対応した goa が益々便利ですね!

goa で multipart/form-data を扱う

multipart/form-data という MIME タイプがあります。これは主に HTML フォームの内容をサーバに対して POST する際に用いられます。

goa の Request Context には http.Request が埋まっているので、そこから MultipartReader()ParseMultipartForm() を呼び出すことで multipart/form-data を扱うことができます。以下は goa の公式サンプル収録されているコードです。

func (c *ImageController) Upload(ctx *app.UploadImageContext) error {
    // Assumes the image is under multipart section named "file"
    reader, err := ctx.MultipartReader()
    if err != nil {
        return goa.ErrBadRequest("failed to load multipart request: %s", err)
    }
    if reader == nil {
        return goa.ErrBadRequest("not a multipart request")
    }
    var images []*app.ImageMedia
    for {
        p, err := reader.NextPart()
        if err == io.EOF {
            break
        }
        if err != nil {
            return goa.ErrBadRequest("failed to load part: %s", err)
        }
        f, err := os.OpenFile("./images/"+p.FileName(), os.O_WRONLY|os.O_CREATE, 0666)
        if err != nil {
            return fmt.Errorf("failed to save file: %s", err) // causes a 500 response
        }
        defer f.Close()
        io.Copy(f, p)
        data := c.saveImage(p.FileName())
        images = append(images, &app.ImageMedia{ID: data.ID, Filename: data.Filename, UploadedAt: data.UploadedAt})
    }
    return ctx.OK(images)
}

multipart.Reader を介して、各 part の内容を取り出しています。

このように、従来から goa では multipart/form-data を扱うことができたのですが、これはあくまでサーバ実装の話です。 design (設計) の中で multipart/form-data であることを明示するための DSL は用意されていなかったので、 Swagger にも formData として定義を出力することはできませんでした。

しかし今回、この機能に関する MultipartForm という新しい DSL が追加になりました。今後は design レベルで multipart/form-data を取り扱うことができます。

github.com

MultipartForm の使用例について、まずは design から見てみましょう。

var _ = Resource("profiles", func() {
    Action("submit", func() {
        Routing(POST("profiles"))
        Payload(ProfilePayload)
        MultipartForm()
        Description("Post accepts a multipart form encoded request")
        Response(OK, ResultMedia)
    })
})

var ProfilePayload = Type("ProfilePayload", func() {
    Attribute("name", String, "Name")
    Attribute("birthday", DateTime, "Birthday")
    Required("name", "birthday")
})

MultipartFormAction DSL の中で使用します。これによって goa は当該 Action の Payload を multipart/form-data として扱うようになります。

これを元に出力される Swagger 定義は以下のようになります。

  /profiles:
    post:
      description: Post accepts a multipart form encoded request
      operationId: profiles#submit
      parameters:
      - description: Birthday
        in: formData
        name: birthday
        required: true
        type: string
      - description: Name
        in: formData
        name: name
        required: true
        type: string
      produces:
      - application/vnd.goa.example.form
      responses:
        "200":
          description: OK
          schema:
            $ref: '#/definitions/ResultMedia'
      schemes:
      - http
      summary: submit profiles
      tags:
      - profiles

parameters に書かれている各パラメータが in: formData になっています。これは OpenAPI Specification Version 2 で規定されている form-data を表す定義になります。

最後に controllers の実装です。冒頭に記述した通り、従来は multipart をパースして各 part をひとつずつ取り出す必要がありました。今回の機能追加後、コードは以下の形になります。

func (c *ProfilesController) Submit(ctx *app.SubmitProfilesContext) error {
    res := &app.ResultMedia{
        Name:     ctx.Payload.Name,
        Birthday: ctx.Payload.Birthday,
    }
    return ctx.OK(res)
}

これだけです。 multipart のパースは、このコードに到達する前に goa が自動で行います。各 part の内容は既に ctx.Payload のフィールドにセットされているので、それを使ったコードを書くだけで良いのです。

より詳細な例は以下をご覧ください。

github.com

2017 年振り返り

OSS

昨年に引き続き goa にコミットしていました。 2018 年は v2 が形になるといいですね。

tchssk.hatenablog.com

勉強会

goa 勉強会で発表もしました。

tchssk.hatenablog.com

転職

2 年半勤めた前職を辞めて 11 月から Pacific Porter で働いています。引き続き仕事でも Go を (goa も) 書く毎日です。前職では新規開発に関わることが多かったので、既に動いているシステムに関わるのは久しぶりです。だんだん仕組みがわかっていくのが楽しいですね。

総括

10 月あたりから解けるように時間が過ぎていました。どう考えてもスプラトゥーンのせいです。本当にありがとうございました。

Go で書かれた API Gateway "KrakenD"

この記事は Go3 Advent Calendar 2017 の 10 日目の記事です。

小さな役割を持つサービス群でアプリケーションを構成する、いわゆる Microservices が流行して久しいですね。サービスが細かく分割されるとき、それらを取りまとめるサービスが必要になることがあります。一般に API GatewayAPI Aggregator と呼ばれるものです。

そんな API Gateway のひとつに Go で書かれた KrakenD というものがあります。

f:id:tchssk:20171206184251p:plain KrakenD: API Gateway and Manager

今回はこれを試してみます。

仕様

複数のバックエンド API を集約 (aggregate) する動きを見るため、以下のような仕様でサービスを構築してみます。

  • バックエンドに GitHub と Qiita の Web API を使用する
  • KrakenD に /users/:id というエンドポイントを定義する
  • そこにリクエストが来たらバックエンドの GitHub と Qiita の API にリクエストする
    • GitHubGET https://api.github.com/users/:id
    • Qiita は GET https://qiita.com/api/v2/users/:id
  • それぞれからのレスポンスを "github""qiita" というフィールドに入れてクライアントにレスポンスする

シーケンス図にすると以下のような感じですね。

client     krakend    github      qiita
   |          |          |          |
   +--------->|          |          |    GET /users/:id
   | request  |          |          |
   |          |          |          |
   |          +--------->|          |    GET https://api.github.com/users/:id
   |          | request  |          |
   |          |          |          |
   |          +----------^--------->|    GET https://qiita.com/api/v2/users/:id
   |          |          | request  |
   |          |          |          |
   |          |<---------+          |
   |          | response |          |
   |          |          |          |
   |          |<---------^----------+
   |          |          | response |
   |          |          |          |
   |<---------+          |          |
   | response |          |          |
   |          |          |          |

KrakenD の設定ファイル

まず上記の仕様を KrakenD の設定ファイル (JSON) として用意する必要があります。なんと設定ファイルを作るための Web エディタが用意されているのでそれを使わせてもらいましょう。

KrakenDesigner - KrakenD: API Gateway and Manager

見た目はこんな感じ。

f:id:tchssk:20171206174522p:plain

UI もなかなか悪くなく、サクッと設定ファイルを作ることができました。

{
  "version": 1,
  "default_max_rate": 0,
  "client_max_rate": 0,
  "throttling_header": "",
  "timeout": "3000ms",
  "cache_ttl": "300s",
  "name": "My service",
  "host": [
    "https://api.github.com",
    "https://qiita.com"
  ],
  "endpoints": [
    {
      "endpoint": "/users/{id}",
      "method": "GET",
      "concurrent_calls": 1,
      "client_max_rate": 0,
      "querystring_params": [],
      "backend": [
        {
          "url_pattern": "/api/v2/users/{id}",
          "encoding": "json",
          "host": [
            "https://qiita.com"
          ],
          "group": "qiita"
        },
        {
          "url_pattern": "/users/{id}",
          "encoding": "json",
          "host": [
            "https://api.github.com"
          ],
          "group": "github"
        }
      ]
    }
  ]
}

KrakenD サービスの立ち上げ

最近のソフトウェアらしく Docker Hub でイメージが提供されているので、それを試してみましょう。

$ docker pull devopsfaith/krakend
$ docker run -p 8080:8080 -v $PWD:/etc/krakend/ devopsfaith/krakend

簡単ですね。ではリクエストを送ってみます。 HTTP クライアントは HTTPie です。

$ http localhost:8000/users/tchssk

{
    "github": {
        "avatar_url": "https://avatars3.githubusercontent.com/u/12257128?v=4",
        "bio": null,
        "blog": "https://tchssk.github.io",
        "company": null,
        "created_at": "2015-05-05T15:43:31Z",
        "email": null,
        "events_url": "https://api.github.com/users/tchssk/events{/privacy}",
        "followers": 6,
        "followers_url": "https://api.github.com/users/tchssk/followers",
        "following": 3,
        "following_url": "https://api.github.com/users/tchssk/following{/other_user}",
        "gists_url": "https://api.github.com/users/tchssk/gists{/gist_id}",
        "gravatar_id": "",
        "hireable": null,
        "html_url": "https://github.com/tchssk",
        "id": 12257128,
        "location": "Tokyo, Japan",
        "login": "tchssk",
        "name": "Taichi Sasaki",
        "organizations_url": "https://api.github.com/users/tchssk/orgs",
        "public_gists": 0,
        "public_repos": 33,
        "received_events_url": "https://api.github.com/users/tchssk/received_events",
        "repos_url": "https://api.github.com/users/tchssk/repos",
        "site_admin": false,
        "starred_url": "https://api.github.com/users/tchssk/starred{/owner}{/repo}",
        "subscriptions_url": "https://api.github.com/users/tchssk/subscriptions",
        "type": "User",
        "updated_at": "2017-12-05T10:08:00Z",
        "url": "https://api.github.com/users/tchssk"
    },
    "qiita": {
        "description": "A web developer who loves Ginger Ale !!",
        "facebook_id": "",
        "followees_count": 0,
        "followers_count": 2,
        "github_login_name": "tchssk",
        "id": "tchssk",
        "items_count": 11,
        "linkedin_id": "",
        "location": "Tokyo, Japan",
        "name": "Taichi Sasaki",
        "organization": "",
        "permanent_id": 83093,
        "profile_image_url": "https://qiita-image-store.s3.amazonaws.com/0/83093/profile-images/1473702659",
        "twitter_screen_name": "tchssk",
        "website_url": "https://tchssk.github.io"
    }
}

ちゃんと "github""qiita" にそれぞれのレスポンスが入った形で返ってきました。

レスポンスの操作

KrakenD は、バックエンドから受け取った情報をクライアントにレスポンスする前に以下の方法で操作することができます。

  • Merging
  • Filtering
  • Grouping
  • Mapping
  • Capturing

では順番に試してみましょう。

Merging

複数のバックエンドからのレスポンスをマージすることができます。

  "endpoints": [
    {
      "endpoint": "/users/{id}",
      "method": "GET",
      "concurrent_calls": 1,
      "client_max_rate": 0,
      "querystring_params": [],
      "backend": [
        {
          "url_pattern": "/api/v2/users/{id}",
          "encoding": "json",
          "host": [
            "https://qiita.com"
          ]
        },
        {
          "url_pattern": "/users/{id}",
          "encoding": "json",
          "host": [
            "https://api.github.com"
          ]
        }
      ]
    }
  ]

上記のように設定すると GitHub と Qiita からのレスポンスがマージされます。

{
  "avatar_url": "https://avatars3.githubusercontent.com/u/12257128?v=4",
  "bio": null,
  "blog": "https://tchssk.github.io",
  "company": null,
  "created_at": "2015-05-05T15:43:31Z",
  "description": "A web developer who loves Ginger Ale !!",
  "email": null,
  "events_url": "https://api.github.com/users/tchssk/events{/privacy}",
  "facebook_id": "",
  "followees_count": 0,
  "followers": 6,
  "followers_count": 2,
  "followers_url": "https://api.github.com/users/tchssk/followers",
  "following": 3,
  "following_url": "https://api.github.com/users/tchssk/following{/other_user}",
  "gists_url": "https://api.github.com/users/tchssk/gists{/gist_id}",
  "github_login_name": "tchssk",
  "gravatar_id": "",
  "hireable": null,
  "html_url": "https://github.com/tchssk",
  "id": 12257128,
  "items_count": 11,
  "linkedin_id": "",
  "location": "Tokyo, Japan",
  "login": "tchssk",
  "name": "Taichi Sasaki",
  "organization": "",
  "organizations_url": "https://api.github.com/users/tchssk/orgs",
  "permanent_id": 83093,
  "profile_image_url": "https://qiita-image-store.s3.amazonaws.com/0/83093/profile-images/1473702659",
  "public_gists": 0,
  "public_repos": 33,
  "received_events_url": "https://api.github.com/users/tchssk/received_events",
  "repos_url": "https://api.github.com/users/tchssk/repos",
  "site_admin": false,
  "starred_url": "https://api.github.com/users/tchssk/starred{/owner}{/repo}",
  "subscriptions_url": "https://api.github.com/users/tchssk/subscriptions",
  "twitter_screen_name": "tchssk",
  "type": "User",
  "updated_at": "2017-12-05T10:08:00Z",
  "url": "https://api.github.com/users/tchssk",
  "website_url": "https://tchssk.github.io"
}

フィールド名が重複している場合はどちらか片方のレスポンスが出力されるようです。

Filtering

2 種類のフィルタリングが利用できます。

Blacklisting

blacklist に指定されたフィールドを除外することができます。

  "endpoints": [
    {
      "endpoint": "/users/{id}",
      "method": "GET",
      "concurrent_calls": 1,
      "client_max_rate": 0,
      "querystring_params": [],
      "backend": [
        {
          "url_pattern": "/users/{id}",
          "encoding": "json",
          "host": [
            "https://api.github.com"
          ],
          "blacklist": [
            "avatar_url",
            "bio",
            "blog",
            "company"
          ]
        }
      ]
    }
  ]

上記のように設定すると avatar_urlbioblogcompany が除外されます。

{
  "created_at": "2015-05-05T15:43:31Z",
  "email": null,
  "events_url": "https://api.github.com/users/tchssk/events{/privacy}",
  "followers": 6,
  "followers_url": "https://api.github.com/users/tchssk/followers",
  "following": 3,
  "following_url": "https://api.github.com/users/tchssk/following{/other_user}",
  "gists_url": "https://api.github.com/users/tchssk/gists{/gist_id}",
  "gravatar_id": "",
  "hireable": null,
  "html_url": "https://github.com/tchssk",
  "id": 12257128,
  "location": "Tokyo, Japan",
  "login": "tchssk",
  "name": "Taichi Sasaki",
  "organizations_url": "https://api.github.com/users/tchssk/orgs",
  "public_gists": 0,
  "public_repos": 33,
  "received_events_url": "https://api.github.com/users/tchssk/received_events",
  "repos_url": "https://api.github.com/users/tchssk/repos",
  "site_admin": false,
  "starred_url": "https://api.github.com/users/tchssk/starred{/owner}{/repo}",
  "subscriptions_url": "https://api.github.com/users/tchssk/subscriptions",
  "type": "User",
  "updated_at": "2017-12-05T10:08:00Z",
  "url": "https://api.github.com/users/tchssk"
}

Whitelisting

whitelist に指定されたフィールドだけを出力することができます。

  "endpoints": [
    {
      "endpoint": "/users/{id}",
      "method": "GET",
      "concurrent_calls": 1,
      "client_max_rate": 0,
      "querystring_params": [],
      "backend": [
        {
          "url_pattern": "/users/{id}",
          "encoding": "json",
          "host": [
            "https://api.github.com"
          ],
          "whitelist": [
            "subscriptions_url",
            "type",
            "updated_at",
            "url"
          ]
        }
      ]
    }
  ]

上記のように設定すると subscriptions_urltypeupdated_aturl だけが出力されます。

{
  "subscriptions_url": "https://api.github.com/users/tchssk/subscriptions",
  "type": "User",
  "updated_at": "2017-12-05T10:08:00Z",
  "url": "https://api.github.com/users/tchssk"
}

Grouping

グルーピングを行うことができます。冒頭で扱った GitHub と Qiita の例が正にこれですね。

Mapping

バックエンドからのレスポンスを別名のフィールドにマッピングすることができます。

  "endpoints": [
    {
      "endpoint": "/users/{id}",
      "method": "GET",
      "concurrent_calls": 1,
      "client_max_rate": 0,
      "querystring_params": [],
      "backend": [
        {
          "url_pattern": "/users/{id}",
          "encoding": "json",
          "host": [
            "https://api.github.com"
          ],
          "mapping": {
            "blog": "personal_blog"
          }
        }
      ]
    }
  ]

上記のように設定すると blogpersonal_blogマッピングされます。

{
  "avatar_url": "https://avatars3.githubusercontent.com/u/12257128?v=4",
  "bio": null,
  "company": null,
  "created_at": "2015-05-05T15:43:31Z",
  "email": null,
  "events_url": "https://api.github.com/users/tchssk/events{/privacy}",
  "followers": 6,
  "followers_url": "https://api.github.com/users/tchssk/followers",
  "following": 3,
  "following_url": "https://api.github.com/users/tchssk/following{/other_user}",
  "gists_url": "https://api.github.com/users/tchssk/gists{/gist_id}",
  "gravatar_id": "",
  "hireable": null,
  "html_url": "https://github.com/tchssk",
  "id": 12257128,
  "location": "Tokyo, Japan",
  "login": "tchssk",
  "name": "Taichi Sasaki",
  "organizations_url": "https://api.github.com/users/tchssk/orgs",
  "personal_blog": "https://tchssk.github.io",
  "public_gists": 0,
  "public_repos": 33,
  "received_events_url": "https://api.github.com/users/tchssk/received_events",
  "repos_url": "https://api.github.com/users/tchssk/repos",
  "site_admin": false,
  "starred_url": "https://api.github.com/users/tchssk/starred{/owner}{/repo}",
  "subscriptions_url": "https://api.github.com/users/tchssk/subscriptions",
  "type": "User",
  "updated_at": "2017-12-05T10:08:00Z",
  "url": "https://api.github.com/users/tchssk"
}

Caputuring

特定フィールドのオブジェクトだけを抽出することができます。ここでは GitHub APIリポジトリを取得するエンドポイントで試してみます。

$ http https://api.github.com/repos/golang/go

{
    "id": 23096959,
    "name": "go",
    "full_name": "golang/go",
    "owner": {
        "login": "golang",
        "id": 4314092,
        "avatar_url": "https://avatars3.githubusercontent.com/u/4314092?v=4",
        "gravatar_id": "",
        "url": "https://api.github.com/users/golang",
        "html_url": "https://github.com/golang",
        "followers_url": "https://api.github.com/users/golang/followers",
        "following_url": "https://api.github.com/users/golang/following{/other_user}",
        "gists_url": "https://api.github.com/users/golang/gists{/gist_id}",
        "starred_url": "https://api.github.com/users/golang/starred{/owner}{/repo}",
        "subscriptions_url": "https://api.github.com/users/golang/subscriptions",
        "organizations_url": "https://api.github.com/users/golang/orgs",
        "repos_url": "https://api.github.com/users/golang/repos",
        "events_url": "https://api.github.com/users/golang/events{/privacy}",
        "received_events_url": "https://api.github.com/users/golang/received_events",
        "type": "Organization",
        "site_admin": false
    },
    "private": false,
    "html_url": "https://github.com/golang/go",
    "description": "The Go programming language",
    "fork": false,
    "url": "https://api.github.com/repos/golang/go",
    "forks_url": "https://api.github.com/repos/golang/go/forks",
    "keys_url": "https://api.github.com/repos/golang/go/keys{/key_id}",
    "collaborators_url": "https://api.github.com/repos/golang/go/collaborators{/collaborator}",
    "teams_url": "https://api.github.com/repos/golang/go/teams",
    "hooks_url": "https://api.github.com/repos/golang/go/hooks",
    "issue_events_url": "https://api.github.com/repos/golang/go/issues/events{/number}",
    "events_url": "https://api.github.com/repos/golang/go/events",
    "assignees_url": "https://api.github.com/repos/golang/go/assignees{/user}",
    "branches_url": "https://api.github.com/repos/golang/go/branches{/branch}",
    "tags_url": "https://api.github.com/repos/golang/go/tags",
    "blobs_url": "https://api.github.com/repos/golang/go/git/blobs{/sha}",
    "git_tags_url": "https://api.github.com/repos/golang/go/git/tags{/sha}",
    "git_refs_url": "https://api.github.com/repos/golang/go/git/refs{/sha}",
    "trees_url": "https://api.github.com/repos/golang/go/git/trees{/sha}",
    "statuses_url": "https://api.github.com/repos/golang/go/statuses/{sha}",
    "languages_url": "https://api.github.com/repos/golang/go/languages",
    "stargazers_url": "https://api.github.com/repos/golang/go/stargazers",
    "contributors_url": "https://api.github.com/repos/golang/go/contributors",
    "subscribers_url": "https://api.github.com/repos/golang/go/subscribers",
    "subscription_url": "https://api.github.com/repos/golang/go/subscription",
    "commits_url": "https://api.github.com/repos/golang/go/commits{/sha}",
    "git_commits_url": "https://api.github.com/repos/golang/go/git/commits{/sha}",
    "comments_url": "https://api.github.com/repos/golang/go/comments{/number}",
    "issue_comment_url": "https://api.github.com/repos/golang/go/issues/comments{/number}",
    "contents_url": "https://api.github.com/repos/golang/go/contents/{+path}",
    "compare_url": "https://api.github.com/repos/golang/go/compare/{base}...{head}",
    "merges_url": "https://api.github.com/repos/golang/go/merges",
    "archive_url": "https://api.github.com/repos/golang/go/{archive_format}{/ref}",
    "downloads_url": "https://api.github.com/repos/golang/go/downloads",
    "issues_url": "https://api.github.com/repos/golang/go/issues{/number}",
    "pulls_url": "https://api.github.com/repos/golang/go/pulls{/number}",
    "milestones_url": "https://api.github.com/repos/golang/go/milestones{/number}",
    "notifications_url": "https://api.github.com/repos/golang/go/notifications{?since,all,participating}",
    "labels_url": "https://api.github.com/repos/golang/go/labels{/name}",
    "releases_url": "https://api.github.com/repos/golang/go/releases{/id}",
    "deployments_url": "https://api.github.com/repos/golang/go/deployments",
    "created_at": "2014-08-19T04:33:40Z",
    "updated_at": "2017-12-07T12:12:01Z",
    "pushed_at": "2017-12-07T05:10:23Z",
    "git_url": "git://github.com/golang/go.git",
    "ssh_url": "git@github.com:golang/go.git",
    "clone_url": "https://github.com/golang/go.git",
    "svn_url": "https://github.com/golang/go",
    "homepage": "https://golang.org",
    "size": 166440,
    "stargazers_count": 35170,
    "watchers_count": 35170,
    "language": "Go",
    "has_issues": true,
    "has_projects": false,
    "has_downloads": true,
    "has_wiki": true,
    "has_pages": false,
    "forks_count": 4781,
    "mirror_url": null,
    "archived": false,
    "open_issues_count": 3158,
    "license": {
        "key": "bsd-3-clause",
        "name": "BSD 3-clause \"New\" or \"Revised\" License",
        "spdx_id": "BSD-3-Clause",
        "url": "https://api.github.com/licenses/bsd-3-clause"
    },
    "forks": 4781,
    "open_issues": 3158,
    "watchers": 35170,
    "default_branch": "master",
    "organization": {
        "login": "golang",
        "id": 4314092,
        "avatar_url": "https://avatars3.githubusercontent.com/u/4314092?v=4",
        "gravatar_id": "",
        "url": "https://api.github.com/users/golang",
        "html_url": "https://github.com/golang",
        "followers_url": "https://api.github.com/users/golang/followers",
        "following_url": "https://api.github.com/users/golang/following{/other_user}",
        "gists_url": "https://api.github.com/users/golang/gists{/gist_id}",
        "starred_url": "https://api.github.com/users/golang/starred{/owner}{/repo}",
        "subscriptions_url": "https://api.github.com/users/golang/subscriptions",
        "organizations_url": "https://api.github.com/users/golang/orgs",
        "repos_url": "https://api.github.com/users/golang/repos",
        "events_url": "https://api.github.com/users/golang/events{/privacy}",
        "received_events_url": "https://api.github.com/users/golang/received_events",
        "type": "Organization",
        "site_admin": false
    },
    "network_count": 4781,
    "subscribers_count": 2583
}

Goリポジトリですね。これを KrakenD を通して取得します。

  "endpoints": [
    {
      "endpoint": "/repos/{owner}/{repo}",
      "method": "GET",
      "concurrent_calls": 1,
      "client_max_rate": 0,
      "querystring_params": [],
      "backend": [
        {
          "url_pattern": "/repos/{owner}/{repo}",
          "encoding": "json",
          "host": [
            "https://api.github.com"
          ],
          "target": "owner"
        }
      ]
    }
  ]

上記のように設定すると owner のオブジェクトだけが抽出されます。

{
  "avatar_url": "https://avatars3.githubusercontent.com/u/4314092?v=4",
  "events_url": "https://api.github.com/users/golang/events{/privacy}",
  "followers_url": "https://api.github.com/users/golang/followers",
  "following_url": "https://api.github.com/users/golang/following{/other_user}",
  "gists_url": "https://api.github.com/users/golang/gists{/gist_id}",
  "gravatar_id": "",
  "html_url": "https://github.com/golang",
  "id": 4314092,
  "login": "golang",
  "organizations_url": "https://api.github.com/users/golang/orgs",
  "received_events_url": "https://api.github.com/users/golang/received_events",
  "repos_url": "https://api.github.com/users/golang/repos",
  "site_admin": false,
  "starred_url": "https://api.github.com/users/golang/starred{/owner}{/repo}",
  "subscriptions_url": "https://api.github.com/users/golang/subscriptions",
  "type": "Organization",
  "url": "https://api.github.com/users/golang"
}

その他の機能

今回は試しませんでしたが、一般的な API Gateway にあるような以下の機能も備えているようです。

  • 複数のバックエンドへリクエストを分散する Load balancing
  • 一定時間あたりのリクエスト数を制限する Rate limits
  • 障害時などにバックエンドへのリクエストを遮断する Circuit breaker

性能

ベンチマークによると秒間処理リクエスト数は 3500 程度で、 OSSAPI Gateway として有名な Kong の約 2 倍だそうです。

f:id:tchssk:20171208122904p:plain

利用プラン

公式ページに書かれている以下の 3 種類のプランがあるようです。

  • Open Source
  • Free
  • Enterprise

KrakenD のコア機能は OSS として GitHub で公開されていて、好きな Web framework と組み合わせて使えるようになっています。それがおそらく Open Source プラン。 DockerHub のイメージや RPM などを介してビルド済みのものを利用するのが Free プランでしょう。 Enterprise には mailto: のボタンが置かれているだけなので、まだ本格的なマネタイズは行なっていないのかもしれません。

今回は少し触ってみた程度ですが、 API Gateway が必要になったときはもう少し詳しく検証して利用を検討してみたいと思いました。

goa で静的ファイル配信に go-bindata を使う

goa には静的ファイル配信のための DSL として Files が用意されており、以下のような定義を行うと FileServer を生成してくれます。

Files("/swagger/*filepath", "public/swagger/")

生成される FileServerhttp.FileSystem という interface を通してファイルオープンを行うのですが、従来は http.Dir という実装を固定で使う仕様になっていました。 http.Dir は実行環境のネイティブファイルシステムからファイルオープンを行います。これは、ビルドしたバイナリを実行環境にデプロイする際に Files で定義した静的ファイルもそのファイルシステム上に配置する必要がある、ということを意味します。

Go は、ビルドしたバイナリひとつをデプロイすれば動作する、いわゆるシングルバイナリが魅力のひとつですが、 goa の FileServer でそれを実現するためには http.Dir 以外の http.FileSystem 実装を使えた方が都合がいいです。という訳で変更できるようにしました。

github.com

使い方は簡単です。例として go-bindata を使って実装してみましょう。あらかじめ go-bindata で目的の静的ファイルを Go のソースコード化しておき、該当の controller をマウントする前にそのコードを使用するよう FileSystem というフィールドを差し替えます。

c1 := controllers.NewSwaggerController(service)
c1.FileSystem = func(dir string) http.FileSystem {
    return &assetfs.AssetFS{
        Asset:     Asset,
        AssetDir:  AssetDir,
        AssetInfo: AssetInfo,
        Prefix:    dir,
    }
}
app.MountSwaggerController(service, c1)

go-bindata で生成されたコードを http.FileSystem にラップするライブラリとして go-bindata-assetfs を使用しています。これで該当の静的ファイルは Go の内部でオープンされるようになるのでデプロイする必要がなくなります。ちなみに、記事のタイトルと例では go-bindata を取り上げましたが、 FileSystem フィールドの関数は http.FileSystem を返せば良いので他のライブラリを使うこともできます。

より詳細な例は以下をご覧ください。

github.com

goa へのプルリクエストの送り方 (バックポート編)

goa へのプルリクエストの送り方 (バックポート編) です。

goa では互換性維持のため master と v1 を別ブランチで管理しています。そのため、プルリクエストが master にマージされたあと、その変更を v1 ブランチにバックポートするようお願いされることがあります。本編ではその手順について解説します。

1. リポジトリに移動

コンソールで以下のコマンドを実行します。

$ cd $GOPATH/src/github.com/goadesign/goa

2. v1 から新しいブランチをチェックアウト

コンソールで以下のコマンドを実行します。

$ git fetch
$ git checkout -b $BRANCHNAME origin/v1

$BRANCHNAME には任意のブランチ名を入れてください。例えばブランチ名を foo-bar-v1 とする場合は以下の様になります。

$ git checkout -b foo-bar-v1 origin/v1

3. master にマージされたコミットのハッシュを取得

コンソールで以下のコマンドを実行し、 master にマージされたブランチから対象コミットのハッシュを取得します。

$ git log --oneline $TOPICBRANCHNAME

$TOPICBRANCHNAME にはプルリクエスト編の 2. で指定したブランチ名を入れてください。今回の例ではブランチ名を foo-bar としていたため以下の様になります。

$ git log --oneline foo-bar

コマンドを実行すると以下のようにコミットログが出力されます。

a5e8203 Use os.Getwd instead of filepath.Abs
2e47e58 Delete a debug print
f54c587 Smart package name for gen_controller

先頭カラム (a5e8203 の部分) がコミットハッシュなのでこれを控えておきます。

4. master にマージされたコミットをチェリーピック

コンソールで以下のコマンドを実行し、 3. で取得したコミットをチェリーピックします。

$ git cherry-pick $COMMITHASH

$COMMITHASH には 3. で取得したコミットハッシュを入れてください。コミットが複数ある場合はチェリーピックもその回数行います。

5. make できることを確認

コンソールで以下のコマンドを実行します。

$ make

これにはプログラムのビルドやテストコードの実行が含まれています。何らかのエラーが発生した場合はコードを修正しそれらを解決します。

6. リモートリポジトリにプッシュ

コンソールで以下のコマンドを実行します。

$ git push -u mine $BRANCHNAME

$BRANCHNAME には 2. で指定したブランチ名を入れてください。今回の例ではブランチ名を foo-bar-v1 としていたため以下の様になります。

$ git push -u mine foo-bar-v1

7. GitHub でプルリクエストを作成

プルリクエスト編の「 7. GitHub でプルリクエストを作成」を参考にプルリクエストを作成します。

これで完了です。じきにプルリクエストがマージされるでしょう。 v1 ブランチはセマンティックバージョニングされており、数ヶ月毎に新しいマイナーバージョンがリリースされています。そのルールに従って、今回の修正も次のマイナーバージョンに含まれることになるでしょう。