マイクロサービスのための綺麗なAPI設計
こんにちは! Wantedlyインターン生の高木です。
先日 Wantedly では 綺麗なAPI速習会@Wantedlyを開催しました。
本記事は配布資料を閲覧向けに再編したものです。
マイクロサービス
Wantedlyでは現在マイクロサービス化をすすめることで、システム全体が大きくなっても変化に強く、新規プロジェクトを打ち出しやすいインフラ環境を用意することを目指しています。
マイクロサービス化するとWantedlyの環境は次のようになります。
1. マイクロサービス化をすすめるにあたり、やりとりは全てAPIで行う
2. APIのRequestは一括でGateway(Kong)が受けとり、Gatewayが内部のAPIに渡す
3. 内部のAPIであっても外部に公開できるようなクオリティのAPIを作成し、サービスを構成する
今回は、この方針を推し進めるにあたり、現在多くの媒体で主流なRESTfulなJSONのAPIをどう設計していくかについてまとめました。
GatewayとBFFで効率的なメンテナンス
各言語でAPIを実装する上で、同じ処理を共通化することで効率良いメンテナンス環境にすることが大切です。それを実現するためのパターンとして、API GatewayとBackends for Frontends (BFF)を紹介します。WantedlyではKongというミドルウェアを利用する予定です。
API Gateway Pattern
「見た目はモノリシック、実装はマイクロサービス」
APIのリクエストは全てKongを経由して受け付けることで複数の言語で立ち上げたマイクロサービスを一元に管理することが可能となります。
公式サイトより
これによって以下が実現します。
・一箇所見に行けば全てのAPIを見つけられる
・細かい権限管理も可能
・各APIで共通実装部分を省略できる
・Authentication
・Rate Limiting
・Aggregation
・アクセス分析
・ルーティング
・データ変換
・etc...
BFF(Backends for Frontends)
・ リクエストにBFFを介すことでRESTAPIをFrontend側に適した形にして返す
・ RESTサーバー側はDBのクエリ結果をそのままJSONとして返すようなシンプルな構成にする
こうすることでバックエンド側の各APIがRESTを逸脱しない状態を保ちつつ、クライアントがより使いやすいAPI提供を行うことが出来ます。
最適なAPI設計
ゲートウェイ越しで動かすAPIは内部のAPIであっても外部に公開できるようなクオリティのAPIを作成!!
今回の本題として、各言語でAPIを設計していく上で共通して認識しておきたいことをまとめました。また、実際にコードで実装もしたので合わせて御覧ください。
サーバーを立てる
今回は高速化/省メモリの観点からgo言語でサーバーを建てました。
$ 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.
クエリ
?pretty - formats json
?fields - select response field
?preloads - eager load relations (default: not load)
?limit, page, last_id, order - pagination
以降、実際にAPIを叩くためにfetchとcurlのコードを掲載しました。
fetch(javascript) - chromeのコンソールなどで簡単に叩けます
curl(terminal) - terminalで叩けます
一貫したパス名を使う
リソース名
参照方法に一貫性を持たせましょう。原則リソース名には複数形を使う。
ただし,要求されるリソースがシステム全体でシングルトンである場合は、単数形を使う。
(例えば,ほとんどのシステムではユーザはただ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の方式は大手の会社、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段階でバージョンを整理することで後方互換のないバージョンアップ、後方互換のあるバージョンアップ、バグ修正でアップデートを区別します。
確認してみましょう。
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行目付近です。
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を埋め込む形式でPagingをレスポンスしていますが、受け取りたいレスポンスがdataの中に入っていて直感的なレスポンスの形とくい違ってしまいます。
基本的には、デフォルトは要素は結果をそのままJSONとして返し、特殊なケースに対応するときのみラップすることを検討したほうがよいと思います。
結果、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
確認してみましょう。
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の種類をいくつか紹介します。
オフセットベース
オフセットページングは時系列についてはこだわらず、返された特定のオブジェクトのリストが必要な場合に使います。一般的なページング。
例
GET http:localhost:8080/api/users?page=2&limit=5
発行されるクエリ
SELECT * FROM samples ORDER BY id LIMIT 5 OFFSET 5*(2-1)
PagingLink
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
PagingLink
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"
}
twitterを参考にどのようにcursorを生成しているのか確認してみます。
twitterの場合、最新のツイートを取ってくるようなオフセットベースにすると随時新しいツイートが降ってくるためpageがズレてしまい1ページ目と2ページ目で同じitemを表示してしまう恐れがあります。(この場合はTweet6,7)
そこで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に負荷をかけてしまいます。
POSTやPUTはレスポンスコードだけでなくデータも返す
RailsのScaffoldは標準でこの形で動きます。
これを返さないと、生成・更新がきちんと正しくできているか確認するために、クライアント側がもう一度GETを叩く手間が生まれるので、必ず生成したオブジェクトをbodyに返してあげるようにしましょう。
宣伝「apig: Golang RESTful API Server Generator」
Wantedlyで現在開発中のGeneratorです!
gormに即したModelからテスト・ドキュメントを含めたRESTfulJSON 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ご期待ください(宣伝)
参考資料
大変参考になりました!ありがとうございます。
http://deeeet.com/writing/2014/06/02/heroku-api-design
http://kenn.hatenablog.com/entry/2014/03/06/105249
https://www.sitepoint.com/paginating-real-time-data-cursor-based-pagination
https://developers.facebook.com/docs/graph-api/using-graph-api
https://dev.twitter.com/rest/public/timelines
http://qiita.com/kawasima/items/356d54e253c54d730fb0
http://qiita.com/awakia/items/235cf6fd299634391ce6
最後までお読みくださりありがとうございました!
是非「はてブ」お願いします∠( ゚д゚)/