Help us understand the problem. What is going on with this article?

綺麗なAPI速習会

More than 3 years have passed since last update.

Wantedly Engineer blogに本速習会資料を閲覧向けに再編しました!
ぜひご覧いただけると幸いです!


本記事は、綺麗なAPI速習会@Wantedlyの資料として作成されたものです。
同時にこちらのコードも参照してください。

マイクロサービス

流行りのマイクロサービス、何がいいのか

  • 各々自由な言語やArchitectureでサービスを立てられる
  • 障害の影響が部分的
  • 変化に強い
  • 個別デプロイ
  • etc...

pre_micro.png

 microservice.png

  • マイクロサービス化をすすめるにあたり、やりとりは全てAPIで行う
  • 内部のAPIであっても外部に公開できるようなクオリティのAPIを作成し、それを元にサービスを作っていくことが重要

APIGatewayとBFF

API Gateway Pattern

kong.png

公式サイトより

「見た目はモノリシック、実装はマイクロサービス」

  • 一箇所見に行けば全てのAPIを見つけられる
  • 細かい権限管理も可能
  • 各APIで何回も実装しないといけない部分を省略できる
    • Authentication
    • Rate Limiting
    • Aggregation
    • アクセス分析
    • ルーティング
    • データ変換
    • etc...

BFF(Backends for Frontends)

APIを小さく細かく立てていく以上Gateにクライアントから一度に複数のAPI呼び出しが送られることは必ず起こる。
たとえばbatchサーバーを通すことでそれらを1つにまとめてクライアント側に返してくれる

bff.png

こうすることでバックエンド側の各APIがRESTを逸脱しない状態を保ちつつクライアントがより使いやすいAPI提供を行うことが出来ます。

今日の流れ

  • ゲートウェイを通して動かすAPIは内部のAPIであっても外部に公開できるようなクオリティのAPIを作成し、それを元にサービスを作っていくことが重要!(再掲)

今回の速習会はそれらのAPIを立てていく上でも共通して認識しておきたいことをgoで実際にServerを立てて叩いて確認していきます。

サーバーを立てる

$ go get github.com/shimastripe/go-api-sokushukai
$ cd $GOPATH/src/github.com/shimastripe/go-api-sokushukai
$ go get ./...
$ go build -o bin/server
$ bin/server
# => http://localhost:8080/api/users?pretty

今回建てたサーバー

叩けるリソース

  • :8080/api/users
  • :8080/api/users/{:id}
  • :8080/api/account_names
  • :8080/api/account_names/{:id}
  • :8080/api/emails
  • :8080/api/emails/{:id}

リレーション

  • user has-one account_name.
  • user has-many emails.

?query

  • pretty
    • formats json
  • fields
    • select response field
  • preload
    • 指定した関連するテーブルをEager loadingする(defaultはloadせずにnullとしています)
    • ?preloads=account_name,emails.id
  • limit, page, last_id, order
    • pagination

資料に書いたコードの叩き方

  • fetch(javascript)
    • chromeのコンソールなどで簡単に叩けます
  • curl(コンソール)

一貫したパス名を使う

リソース名

リソース名には複数形を使う。ただし,要求されるリソースがシステム全体でシングルトンである場合は,単数形を使う(例えば,ほとんどのシステムではユーザはただ1つのアカウントのみを持つ)。これにより、リソースへの参照方法に一貫性を持たせることができます。

アクション名

HTTPメソッドで表現できるのであれば、動詞は含めない。パスの末尾にリソースに対する特別なアクションを必要としないのが望ましいです。
必要な場合は、それを明確にするため、以下のようにアクション名をactionsの後に続けて記述しましょう。

/resources/:resource/actions/:action

例えば,

/runs/{run_id}/actions/stop

オプション名

オプションはパスではなくクエリパラメータで設定しましょう。オプションは名詞ではないので。

パスのネストを最小限にする

ネストした親子関係をもつリソースのデータモデルでは、パスは深くネストすることになります。例えば,

/orgs/{org_id}/apps/{app_id}/dynos/{dyno_id}

ここでappをshowするには/orgs/{org_id}/apps/{app_id}などとしなければならなくなってしまいますが、冗長です。
もしそのappがorgに付随してのみ存在するのであれば、この形でも間違いではありませんが、appのidがorgに依らずuniqueであるなら、/apps/{app_id}で参照出来て然るべきです。

URI設計における冗長さを防ぐために、ルートパスにリソースを配置するようにパスのネストの深さは制限しましょう。あくまでネストはある特定の範囲の集合を示すために使うこと。例えば、上の例では、1つのdynoは1つのappに属し、1つのappは1つのorgに属するようかけます。

/orgs/{org_id}
/orgs/{org_id}/apps
/apps/{app_id}
/apps/{app_id}/dynos
/dynos/{dyno_id}

Wantedlyの場合は子を表示するパスは用意せずに、preloadsクエリで指定したところまでを埋め込む形を用意しました。

/orgs/{org_id}
/orgs/{org_id}?preloads=app
# => /orgs/{org_id}/apps
/orgs/{org_id}?preloads=app,app.dyno
# => /orgs/{org_id}/apps/{app_id}/dynos/{dyno_id}
/apps/{app_id}
/apps/{app_id}?preloads=dyno
# => /apps/{app_id}/dynos/{dyno_id}
/dynos/{dyno_id}
http:localhost:8080/api/users?preloads=account_name,emails.id

version管理

versionはHeaderとQueryで指定する!

現状versionの指定方法はだいたい3通りあります。

  1. URIに埋め込む

    http://localhost:8080/api/v1.0.0/users

  2. クエリ文字列に入れる

    http://localhost:8080/api/users?v=1.0.0

  3. HeaderのAcceptsや独自のkeyで指定する

    Accepts:application/json; version=1.0.0

1の方式は大手の会社のAPI、2はStripe、3はHerokuやGithubが行っています。
この速習会では2と3の複合型を推薦します。

理由は2点あります。

  1. コピペが多発する根っこで分岐(=コントローラ分割)ではなくて、必要最小限のところで限局的に分岐するのがいい

    MVCモデルにおいてパスにバージョンを指定している場合、バージョンを上げるだけでコントローラーが1つ増えてしまいます。(Ex. Rails)
    APIが肥大化し、コピペする量が増えれば増えるほどあとの保守の苦労は大きくなります。
    このような形はとらずに切り替えが必要な箇所で限局的に挙動を変えれたほうがよいでしょう。リソースとコントローラーは別物だということを意識しましょう。

    古い実装を壊したくないという意見もありますが、結局依存ライブラリのアップデートの際など、コードを書き換える作業は必ず発生するため、古い実装をいじる労力は発生すると思います。

  2. URIが純粋にリソースを表すものとして使える

    APIのバージョンというプレゼンテーションレベルの指定がURIに含まれることがなくなり、HTTPの文法にかなりきちんと則った方法でURIを表すことができますね。

クエリ文字列は例えばブラウザで確認をする際に操作がしやすくなり、構造に影響を与えないので複合型として組み込むことを提案しました。

Semantic Versioning

バージョン管理はSemantic Versioningを用いて管理しましょう。Semantic Versioningとはバージョンアップにも一定の規則をつけてあげることで意味のあるバージョン管理ができるようにすることです。

M.M.P(Major.Minor.Patch)の3段階でバージョンを整理することで後方互換のないバージョンアップ、後方互換のあるバージョンアップ、バグ修正でアップデートを区別します。

checkしてみましょう。

fetch
fetch("http://localhost:8080/api/users",
{
    headers: {
      'Accept': 'application/json; version=0.5',
      'Content-Type': 'application/json'
    },
    method: "GET"
})
.then(function(res){ return res.json(); })
.then(function(json){console.log(json);})
.catch(function(res){ console.log(res); })
curl
curl -i -H "Accept: application/json; version=0.5" -X GET http://localhost:8080/api/users

今回はversion<1.0.0だとerrorを返すようにしました。
具体的なコードであげると各controllerの49-54行目付近です。

versioncheck
if version.Range(ver, "<", "1.0.0") {
    // conditional branch by version.
    // this version < 1.0.0 !!
    c.JSON(400, gin.H{"error": "this version (< 1.0.0) is not supported!"})
    return
}

Paging

APIにて取得可能な結果の件数が多い場合、クライアントによっては取得件数が膨大になりDBへの負荷が大きくなってしまいます。
そこで取得件数を制限し、レスポンスには次へのLinkを渡しておくPagingという処理を標準で常にしておきましょう。

事前にPagingを把握しておけないとAPIの設計時にどういうPagingを実装しておくとこのAPIを快適に利用できるのかがわからずに微妙なAPIとなってしまいます。とても大切な要素です。

HeaderにLink形式で返す

基本的にPagingはHeaderにつけるかBodyにつけるかでわかれます。

FacebookPaging
{
  "data": [
     ... Endpoint data is here
  ],
  "paging": {
    "cursors": {
      "after": "MTAxNTExOTQ1MjAwNzI5NDE=",
      "before": "NDMyNzQyODI3OTQw"
    },
    "previous": "https://graph.facebook.com/me/albums?limit=25&amp;before=NDMyNzQyODI3OTQw",
    "next": "https://graph.facebook.com/me/albums?limit=25&amp;after=MTAxNTExOTQ1MjAwNzI5NDE="
  }
}

FacebookはBodyにcursorsとLinkを埋め込む形式でPagingをレスポンスしていますが、本来の受け取りたいレスポンスが"data"の中に入っていて直感的なレスポンスの形とくい違ってしまいます。

基本的には、デフォルトの状態では要素はラップをしていなく、特殊なケースに対応するときのみラップすることを考えましょう。

結果、GithubのようなHeaderに"Link"としてPagingを返す形式がよいでしょう。

GithubPaging
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Link: <https://api.github.com/search/code?q=addClass+user%3Amozilla&page=15>; rel="next",
      <https://api.github.com/search/code?q=addClass+user%3Amozilla&page=34>; rel="last",
      <https://api.github.com/search/code?q=addClass+user%3Amozilla&page=1>; rel="first",
      <https://api.github.com/search/code?q=addClass+user%3Amozilla&page=13>; rel="prev"
Date: Mon, 01 Aug 2016 03:33:50 GMT
Content-Length: 5

checkしてみましょう。

fetch
fetch("http://localhost:8080/api/users?limit=2",
{
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json'
    },
    method: "GET"
})
.then(function(res){ console.log(res.headers.get('Link')); return res.json(); })
.then(function(json){console.log(json);})
.catch(function(res){ console.log(res); })
curl
curl -i -H "Accept: application/json; Content-type: application/json;" -X GET http://localhost:8080/api/users?limit=2

pagingの種類をいくつか紹介します。設計するAPIの性質によってどのpagingが必要になってくるか大きく変わってくるのでpagingはとても大切です。

オフセットベース

オフセットページングは、時系列についてはこだわらず、返された特定のオブジェクトのリストが必要な場合に使います。一般的なページング。

GET http:localhost:8080/api/users?page=2&limit=5
発行されるクエリ
SELECT * FROM samples ORDER BY id LIMIT 5 OFFSET 5*(2-1)
Pagination
Link:   <http://localhost:8080/api/users?limit=5&page=3>; rel="next",
        <http://localhost:8080/api/users?limit=5&page=1>; rel="prev"
fetch
fetch("http://localhost:8080/api/users?limit=2&page=1",
{
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json'
    },
    method: "GET"
})
.then(function(res){ console.log(res.headers.get('Link')); return res.json(); })
.then(function(json){console.log(json);})
.catch(function(res){ console.log(res); })
curl
curl -i -H "Accept: application/json; Content-type: application/json;" -X GET http://localhost:8080/api/users?limit=2&page=1

LIMITが1度に返すitem数、OFFSETを調節して2ページ目、3ページ目と指定した位置をズラス。githubなどで用いられています。結果一覧など複数ページのリストで表示するようなpaginationに利用されます。

id,timeベース

主にfeedです。id, timeの場合はデータのリストで特定の時間を示すUnixタイムスタンプを使って、ソートしたレスポンスを返します。

GET http:localhost:8080/api/users?limit=5&last_id=100&order=desc
発行されるクエリ
SELECT * FROM samples WHERE id < 100 ORDER BY id desc LIMIT 5
Paging
Link:   <http://localhost:8080/api/users?limit=5&last_id=93&order=desc>; rel="next"
fetch
fetch("http://localhost:8080/api/users?limit=2&last_id=6",
{
    headers: {
      'Accept': 'application/json',
      'Content-Type': 'application/json'
    },
    method: "GET"
})
.then(function(res){ console.log(res.headers.get('Link')); return res.json(); })
.then(function(json){console.log(json);})
.catch(function(res){ console.log(res); })
curl
curl -i -H "Accept: application/json; Content-type: application/json;" -X GET http://localhost:8080/api/users?limit=2&last_id=6

性質上、戻ることを想定していないものが多いです。(e.g.InstgramAPI)
結果を順次読み込んでいくようなpagingに利用されます。

自分が作ったpagingではlast_idを起点に昇順か降順のリストをorderで指定して返してます。
Instgramはitemの初めと最後のidをそれぞれmax_idとmin_idとして昇順か降順のリストを返します。

カーソル(リアルタイムベース)

facebookのGraphAPIやTwitterAPIのような多くのSNSはInsertやDeleteがリアルタイムで多く発生するのでpagingが複雑になります。これらはカーソルベースのページネーションを用いています。カーソルは、データリスト内にある特定のアイテムにマークを付けたトークンです。一覧の上からタイムラインを読み込むのではなく、既に処理したものを基点にしてタイムラインを読み込みます。

{
    "ids": [
        333156387,
        333155835,
        ...
        101141469,
        92896225
    ],
    "next_cursor": 1323935095007282836,
    "next_cursor_str": "1323935095007282836",
    "previous_cursor": -1374003371900410561,
    "previous_cursor_str": "-1374003371900410561"
}

https://dev.twitter.com/rest/public/timelines

を参考にどのようにcursorを生成しているのか確認してみます。

paging1.png

例えば、twitterの場合、最新のツイートを取ってくるようなオフセットベースにすると随時新しいツイートが降ってくるためpageがズレてしまい1ページ目と2ページ目で同じitemを表示してしまう恐れがあります。

paging2.png

そこでTwitterはmax_idとsince_idを用意し、max_idで次にどこから読み取るか、since_idでどこまで読み取っているかを記録しています。こうすることでmax_id,since_idの枠内の範囲をcount数以下で問い合わせる命令となるトークンを設定してcursorとしています。

Facebookの場合、下のコードにおいて、afterが返されたデータの最後のアイテム、beforeが返されたデータの最初のアイテムを指しています。

{
  "data": [
     ... Endpoint data is here
  ],
  "paging": {
    "cursors": {
      "after": "MTAxNTExOTQ1MjAwNzI5NDE=",
      "before": "NDMyNzQyODI3OTQw"
    },
    "previous": "https://graph.facebook.com/me/albums?limit=25&before=NDMyNzQyODI3OTQw"
    "next": "https://graph.facebook.com/me/albums?limit=25&after=MTAxNTExOTQ1MjAwNzI5NDE="
  }
}

カーソルベースの特徴

  • オフセットベースは任意の列でソートした結果をページ分割してpagingを設定するのに対し、カーソルベースはユニークなカーソル列のソートに依存しながらページ分割をする
  • オフセットベースが前後と現在のページ番号を提供するのに対してカーソルベースは動的に起点を決定して前後を作るのでページという概念がない
  • 一般にオフセットベースが両方向への移動を想定して使われるのに対してカーソルベースは方向性を持ったものに用いる

pagingに含めないほうがいいもの

  • メタ統計情報(レスポンスの合計数や現在返されたページの特定の絞り込んだ個数など)

時と場合によってはとても有用ですが、基本的に大抵の場合で冗長にDBに負荷をかけてしまいます。

Create(POST)やUpdate(PUT)時にレスポンスコードだけでなく生成されたデータを返す

RailsのScaffoldは標準でこの形で動きます。
これを返さないと、生成・更新がきちんと正しくできているか確認するために、クライアント側がもう一度GETを叩く手間が生まれるので、必ず生成したオブジェクトをbodyに返してあげるようにしましょう。

宣伝「apig: Golang RESTful API Server Generator」

https://github.com/wantedly/apig

Wantedlyで現在開発中のGeneratorです。

gormに即したModelからテスト・ドキュメントを含めたRESTfulなJSON API Serverの雛形を自動生成してくれます。

今回の速習回のリポジトリもModelのみ用意してGenerateしました。

まとめ

  • マイクロサービス化を進めていくにあたって全てのサービスをAPIにしていく必要がある!
  • API GatewayとBFFを用いて共通化できるところを管理し、効率良いメンテナンス環境にする
  • RESTの原則に従うために一貫したパス名やURI設計を心がける
    • URIのネストは最大1つ
  • APIのVersionやPagingはHeaderで管理してbodyにmeta-dataを入れない
    • versionはcontroller内で限局分岐
    • pagingは各APIに合った形式を知っておく必要がある
  • wantedly-apigご期待ください(宣伝)

参考資料

大変参考になりました!ありがとうございます。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした