Wantedly Engineer blogに本速習会資料を閲覧向けに再編しました!
ぜひご覧いただけると幸いです!
本記事は、綺麗なAPI速習会@Wantedlyの資料として作成されたものです。
同時にこちらのコードも参照してください。
マイクロサービス
流行りのマイクロサービス、何がいいのか
- 各々自由な言語やArchitectureでサービスを立てられる
- 障害の影響が部分的
- 変化に強い
- 個別デプロイ
- etc...
- マイクロサービス化をすすめるにあたり、やりとりは全てAPIで行う
- 内部のAPIであっても外部に公開できるようなクオリティのAPIを作成し、それを元にサービスを作っていくことが重要
APIGatewayとBFF
API Gateway Pattern
公式サイトより
「見た目はモノリシック、実装はマイクロサービス」
- 一箇所見に行けば全てのAPIを見つけられる
- 細かい権限管理も可能
- 各APIで何回も実装しないといけない部分を省略できる
- Authentication
- Rate Limiting
- Aggregation
- アクセス分析
- ルーティング
- データ変換
- etc...
BFF(Backends for Frontends)
APIを小さく細かく立てていく以上Gateにクライアントから一度に複数のAPI呼び出しが送られることは必ず起こる。
たとえばbatchサーバーを通すことでそれらを1つにまとめてクライアント側に返してくれる
こうすることでバックエンド側の各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通りあります。
-
URIに埋め込む
-
クエリ文字列に入れる
-
HeaderのAcceptsや独自のkeyで指定する
Accepts:application/json; version=1.0.0
1の方式は大手の会社のAPI、2はStripe、3はHerokuやGithubが行っています。
この速習会では2と3の複合型を推薦します。
理由は2点あります。
-
コピペが多発する根っこで分岐(=コントローラ分割)ではなくて、必要最小限のところで限局的に分岐するのがいい
MVCモデルにおいてパスにバージョンを指定している場合、バージョンを上げるだけでコントローラーが1つ増えてしまいます。(Ex. Rails)
APIが肥大化し、コピペする量が増えれば増えるほどあとの保守の苦労は大きくなります。
このような形はとらずに切り替えが必要な箇所で限局的に挙動を変えれたほうがよいでしょう。リソースとコントローラーは別物だということを意識しましょう。古い実装を壊したくないという意見もありますが、結局依存ライブラリのアップデートの際など、コードを書き換える作業は必ず発生するため、古い実装をいじる労力は発生すると思います。
-
URIが純粋にリソースを表すものとして使える
APIのバージョンというプレゼンテーションレベルの指定がURIに含まれることがなくなり、HTTPの文法にかなりきちんと則った方法でURIを表すことができますね。
クエリ文字列は例えばブラウザで確認をする際に操作がしやすくなり、構造に影響を与えないので複合型として組み込むことを提案しました。
Semantic Versioning
バージョン管理はSemantic Versioningを用いて管理しましょう。Semantic Versioningとはバージョンアップにも一定の規則をつけてあげることで意味のあるバージョン管理ができるようにすることです。
M.M.P(Major.Minor.Patch)の3段階でバージョンを整理することで後方互換のないバージョンアップ、後方互換のあるバージョンアップ、バグ修正でアップデートを区別します。
checkしてみましょう。
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 -i -H "Accept: application/json; version=0.5" -X GET http://localhost:8080/api/users
今回はversion<1.0.0だとerrorを返すようにしました。
具体的なコードであげると各controllerの49-54行目付近です。
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につけるかでわかれます。
{
"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="
}
}
FacebookはBodyにcursorsとLinkを埋め込む形式でPagingをレスポンスしていますが、本来の受け取りたいレスポンスが"data"の中に入っていて直感的なレスポンスの形とくい違ってしまいます。
基本的には、デフォルトの状態では要素はラップをしていなく、特殊なケースに対応するときのみラップすることを考えましょう。
結果、GithubのようなHeaderに"Link"としてPagingを返す形式がよいでしょう。
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("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 -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)
Link: <http://localhost:8080/api/users?limit=5&page=3>; rel="next",
<http://localhost:8080/api/users?limit=5&page=1>; rel="prev"
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 -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
Link: <http://localhost:8080/api/users?limit=5&last_id=93&order=desc>; rel="next"
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 -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"
}
を参考にどのようにcursorを生成しているのか確認してみます。
例えば、twitterの場合、最新のツイートを取ってくるようなオフセットベースにすると随時新しいツイートが降ってくるためpageがズレてしまい1ページ目と2ページ目で同じitemを表示してしまう恐れがあります。
そこで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」
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ご期待ください(宣伝)
参考資料
大変参考になりました!ありがとうございます。
- HerokuのAPIデザイン | SOTA
- APIのバージョニングは限局分岐でやるのが良い - Hidden in Plain Sight
- Paginating Real-Time Data with Cursor Based Pagination — SitePoint
- Graph APIの利用 - 開発者向けFacebook
- Working with Timelines — Twitter Developers
- マイクロサービスアーキテクチャにおけるAPIコールの仕方とHTMLレンダリング - Qiita
- Kong を使った Microservices Architecture API Gateway Pattern の実現方法 - Qiita