こんにちは。フリーランスエンジニアの@dayoshixです。
現在、リンクアンドモチベーションのモチベーションクラウドの開発に、主にフロントエンドエンジニアとしてお手伝いさせて頂いております。
そのようなご縁もありモチベーションクラウドのアドベントカレンダー(3日目)に参加させて頂くことになりましたので宜しくお願いします!!
トップバッターの@ishigeさん、2日目の@HayatoKamonoさんお疲れ様でした!!
お二人の記事はこちら。どちらも力作なので宜しくお願いします。
ということで始めたいと思います。
概要
最近モチベーションクラウドのWebAPI設計ガイドラインが作成されたのですが、それはどのような方針で作成されたのか、その結果どのようなガイドラインが出来上がったのかを紹介します。
背景
これまでの状況
これまで、開発チームではWebAPIの設計に関する明文化されたルールがありませんでした。
WebAPI設計に関して専任の担当者がいるわけではなく、最近では状況にもよりますがフロントエンドエンジニアが設計を行う場面も増えてきました。
また、新たな開発メンバーも徐々に増えていき、迷いが生ずる場面が多くなり小さなストレスになっていました。
その結果かどうかわかりませんが、APIとしての一貫性が徐々に失われているように感じていた人も少なくなかったと思います。
改善しよう!!
開発チームとして技術的負債返済のための改善活動の一環としてWebAPI設計ガイドラインを作ろうという話になりました。
結果、私がガイドライン作成の音頭取りとベース作成を担当させて頂くことになり、開発メンバー全員にレビューして貰いブラッシュアップするようなフローで進めることになりました。
大切にしたこと
ガイドラインを作成するに当たって下記の点を大切にしました。
身の丈に合わせる
ネット上に公開されているガイドラインをそのまま模倣するのではなく、それらを参考にしつつも自分達が開発を通じて実際に迷ったことをもとに、今の自分達に必要な身の丈にあった最小限のルールだけを定義しようと考えました。
また、システムの文脈に依存する一般化できないようなルールがこぼれ落ちないことに注意しました。
基本を意識する
基本の理解を疎かにした状態で応用から入らないよう、基本を崩す場合の具体的な判断例を示すことを意識しました。
例えば、最初から特段の根拠もなく基本を崩しパフォーマンス最適化した設計にしないことを促したいと考えました。
べき論よりやり易さを優先する
セオリーや思想的にこうあるべき、という事が自分達にとってやりにくければ執着せずに無視することを意識しました。
システム概要
システムの特徴によってルールとして考慮する点が異なってくる事から、ガイドラインを紹介する前にモチベーションクラウドのシステム概要をざっくりと説明します。
- SaaS型のBtoBサービス
- バックエンドに
Ruby on Rails
、フロントエンドにVue.js
を使用したいわゆるシングルページアプリケーション - 2018年11月時点でクライアントのプラットフォームはWebのみ
- WebAPIはシステム内で閉じた使われ方をしており、ユーザーに公開するAPIはない
WebAPI設計ガイドライン
それでは実際に作られたWebAPI設計ガイドラインを紹介します。
ガイドライン中に時折なぜこのような選択をしたのかをコメントしています。
概要
本文書はMCSバックエンド〜フロントエンド間のWebAPI設計のガイドラインです。
MCSはモチベーションクラウドの社内での総称になります。
設計指針
RESTful like
基本的にはRESTに従い設計して下さい。
但し、RESTを原則的に従うことでアプリケーションが複雑になる、パフォーマンスに問題が生ずるなどの理由がある場合はRESTの原則に逸れても問題ありません。
RESTful like
な設計を目指して下さい。
RESTの原則に外れる設計パターンについてはこちらを参照して下さい。
画面に最適化されたWebAPIを作るよりもREST APIを作ることを優先する
画面が必要とする情報を取得するための汎用性の無いWebAPIを設計することよりも、その画面に必要なリソースを取得するためのWebAPIを設計することを優先して下さい。
その画面に必要なリソースの種別が多く、API呼び出しのラウンドトリップ過多でパフォーマンスに影響が出た場合に初めて画面向けのAPIを設計することを検討して下さい。
なぜ?
画面向けのWebAPIは画面の表示要素が変更されるたびにバックエンド側にも改修が発生します。
RESTベースでWebAPIを作っている場合、変更対象の表示要素の情報を取得するためのエンドポイントが既に作られている可能性がありますし、新規に作った場合でも将来的に他の用途で再利用できる可能性があります。
画面の文言をバックエンドで管理しない
特別な理由が無い限り、画面に表示する文言リソースをバックエンドで管理しWebAPI経由でフロントエンドに提供するような設計にせず、フロントエンドで持つようにして下さい。
🙅 Bad
GET /menu_items
{
"meta": null,
"data": [{
"id": 1000,
"title": "属性検索",
"description": "登録済みの属性の一覧を検索することができます。"
},{
"meta": null,
"data": [{
"id": 1001,
"title": "ユーザー検索",
"description": "登録済みのユーザーの一覧を検索することができます。"
}]
}
🙆 good
GET /menu_items
{
"meta": null,
"data": [{
"id": 1000,
"type": 1
},{
"meta": null,
"data": [{
"id": 1001,
"type": 2
}]
}
共通ルール
命名
endpoint(URL)
path
snake caseであること
🙅 Bad
/surveySettings/1
/survey-settings/1
🙆 good
/survey_settings/1
railsとの親和性を考慮しました
query paramter
lower camel caseであること
🙅 Bad
/survey_settings?suvey_id=1000
🙆 good
/survey_settings?suveyId=1000
クライアントサイドJSで触るデータに関してはクライアントサイドJSの命名規則と同じlower camel caseに統一しました。
response body
property名
lower camel caseであること
🙅 Bad
{
"meta": null,
"data": {
"id": 1000,
"first_name": "foo"
}
}
🙆 good
{
"meta": null,
"data": {
"id": 1000,
"firstName": "foo"
}
}
ページング
ページング操作に対応したエンドポイントにおいてはページング情報は下記の命名に従って下さい。
request:
フィールド名 | 意味 | 制約 | 備考 |
---|---|---|---|
page | ページ番号。開始番号は1 です。 |
必須指定 | |
limit | 1ページあたりの件数。 | 任意指定 | 1度に全件取得されると問題がある場合は必ずサーバーサイドで制限チェックを行うこと |
sort | ソート対象のフィール名です。 | 任意指定 | |
direction | ソート順です。asc の場合は昇順、desc の場合は降順。 |
任意指定 |
response:
フィールド名 | 意味 | 制約 | 備考 |
---|---|---|---|
total | 検索結果の総件数です。 | 必須指定 | リソースとしてではなくmeta情報として設定して下さい。 |
既存に暗黙の命名ルールがありましたがたまに外れた命名もあったため明文化しました。
HTTP method
POST
、GET
、PUT
、DELETE
のみ使って下さい。
各methodはCRUD
のそれぞれに対応します。
responseのsatus code
正常系
一律 200
を設定して下さい。
異常系
エラーの表現を参照のこと。
response bodyのフォーマット
基本
リソースとリソース以外の情報(リストデータのページング情報、セキュリティトークン等)を分けるために、リソースをdata
プロパティに、リソース以外の情報をmeta
プロパティに分ける下記を基本のフォーマットとする。
{
"meta": { // リソース以外の情報をここに定義
"totalCount": 5000,
"securityToken" "trHqrxxwK8mJhY"
},
"data": [
// リソースはここに定義
{
"id": 100,
"name": "foo"
}
]
}
このルールは意見の別れるところかと思います。
response bodyはあくまでも純粋なリソースとし、meta情報はHTTP Responseの拡張ヘッダーに設定すべきという考え方もあります。
RESTを前提に扱ったツールと相性が悪いなどデメリットもありますが、情報がJSONに集約されていることによる開発・デバッグ時の分かりやすさを優先しました。
HTTP method: GET
フォーマットはendpointごとの任意の形式にして下さい。
HTTP method: POST
フォーマットはリクエストされたデータにID、生成日が設定されているデータにして下さい。
例)ユーザー情報の新規登録が正常に行われた場合のresponse
- 新規登録のrequest情報:
- endpoint:
/users
- requst body:
{ "name": "foo", "gender": 1 }
- endpoint:
🙅 Bad
status code: 200
のみを返却し、response bodyを返却しない。
🙆 good
リソースのIDと生成日が含まれる下記のresponse bodyを返却する。
{
"id": 1001,
"name": "foo",
"gender": 1,
"createdAt": "2018-11-12T06:41:58.898+0900"
}
HTTP method: PUT
フォーマットはリクエストされたデータに更新日が更新されているデータにして下さい。
例)ユーザー情報の更新が正常に行われた場合のresponse
- 更新のrequest情報:
- endpoint:
/users/1001
- requst body:
{ "id": 1001, "name": "bar", "gender": 0 }
- endpoint:
🙅 Bad
status code: 200
のみを返却し、response bodyを返却しない。
🙆 good
リソースのIDと更新日が含まれる下記のresponse bodyを返却する。
{
"id": 1001,
"name": "bar",
"gender": 0,
"updatedAt": "2018-11-12T06:44:34.894+0900"
}
HTTP method: DELETE
bodyを設定せず、status codeのみ返却して下さい。
非同期操作(※)
フォーマットはendpointごとの任意の形式にして下さい。
※ 非同期操作とは、バッチ処理を起動する
などの最終的な結果が即時に出ない操作を意味します。
エラーの表現
種類
エラーはシステム仕様上起こり得る業務エラーと、システム仕様上想定していないシステムエラーでエラーを区別し、適したエラーを定義して下さい。
業務エラー・システムエラーの具体例
業務エラーの例
- ユーザーの入力内容に誤りがあった。
- 所謂バリデーションエラー
- 他のユーザーが削除したデータを参照した。
- 閲覧権限の無いリソースへのアクスセスを検出した。
システムエラーの例
- バグが原因でサーバーサイドで例外が発生した。
- 外部連携システムがダウンした。
responseのstatus code
業務エラー
エラーの種別により下記のstatus codeを設定して下さい。
status code | 意味 | 発生ケース |
---|---|---|
400 | リクエストの内容に誤りがある | 入力内容のバリデーションに引っかかった。 パラメータ改竄されている、等 |
401 | 認証がなされていない | 認証が必要なendpointに認証情報を付加せずにアクセスした。 |
403 | リソースに対してアクセス権がない | 閲覧権限のない情報にアクセスした、等 |
上記は一例になります
システムエラー
エラーの種別により下記のstatus codeを設定して下さい。
status code | 意味 | 発生ケース |
---|---|---|
500 | システム内で想定外のエラーが発生した | システムがダウンしている、 サーバーサイドアプリケーションにバグがある、等 |
status codeの追加について
扱うエラーに対して本ガイドラインで定めているstatus codeに適したものが無い場合、適したstatus codeをガイドラインに追加して下さい。
また、扱わないエラーのstatus codeをあらかじめガイドラインに追加しないようにして下さい。
参考:HTTPステータスコード
response bodyのフォーマット
エラーを表現する場合、基本的にはreponseのstatus codeで表しますが、クライアント側でより詳細なレベルのエラー情報が必要な場合にのみ、reponse bodyで詳細なエラー情報を表現して下さい。
基本
エラーの詳細情報を表す場合、下記の基本フォーマットに従って下さい。
{
"meta": null,
"data": [
{
"code": 400000, // ※ 詳細エラーコード。必須。
"message": "error!!" // 任意
},
// ...
]
}
※ 詳細エラーコードの採番についてはこちらで説明します。
基本フォーマットで表現できない詳細情報を付加したい場合は基本フォーマットを変更しないことを前提に拡張して構いません。
{
"meta": null,
"data": [
{
"code": 400002,
"message": null,
"rowNo": 1, // 拡張部分
"columnNo": 10 // 拡張部分
},
{
"code": 400003,
"message": null,
"rowNo" 1,
"columnNo": 15
}
]
}
詳細エラーコードの採番規則
詳細エラーコードは数値型で、HTTPレスポンスのstatus codeと連番を組み合わせて採番して下さい。
フォーマットは6桁の数値で先頭3桁はstatus code、後続の3桁はエラー種別を表す0
から始まる連番になります。
例)status codeが400
のBad request
で、エラー種別が 3
の場合
{
"meta": null,
"data": [
{
"code": 400003
}
]
}
詳細エラーコード
詳細エラーコードは下記を使用して下さい。
エラーコード | status code | 意味 | 備考 |
---|---|---|---|
400000 | 400 | 既に指定されたemailアドレスがデータベースに存在する |
適したエラーコードが無い場合、上記のテーブルに追加して下さい。
Formのバリデーションエラー
Formのバリデーションエラーにおいて、エラー発生元フィールドとエラー情報を関連付ける必要がある場合は下記のルールに従って下さい。
- エラーレスポンスの詳細エラー情報ごとに
fieldName
という名前のフィールドを追加し、リクエストに含まれているバリデーション対象のフィールド名を設定する
例)メールアドレスに重複エラーとクレジットカード番号に与信エラーのバリデーションエラーが発生した場合
- メールアドレスのフィールド名は
email
、クレジットカードの番号のフィールド名はcardNo
とします。 - メールアドレスの重複エラーの詳細エラーコードは
400001
、クレジットカードの番号の与信エラーの詳細エラーコードを400002
とします。
request:
{
"meta": null,
"data": {
"email": "osumi_kumamon@ggmail.com",
"cardNo": "378282246310005"
}
}
response:
(status codeは400
:bad request)
{
"meta": null,
"data": [
{
"code": 400001,
"fieldName": "email"
},
{
"code": 400002,
"fieldName": "cardNo"
}
]
}
データが無い場合の表現
リソース自体が無い
リソースのデータ形式が配列の場合は空配列([]
)を設定して下さい。
{
"meta": null,
"data": []
}
リソースのデータ形式がObjectの場合はnull
を設定して下さい。
{
"meta": null,
"data": null
}
リソース内の一部のpropertyが無い
リソース自体が無い場合のルールと同じです。
リソースのデータ形式が配列の場合は空配列([]
)を設定して下さい。
{
"meta": null,
"data": {
"id": 1,
"children": []
}
}
リソースのデータ形式がObjectの場合はnull
を設定して下さい。
{
"meta": null,
"data": {
"id": 1,
"firstName": "Thet Win Aung",
"lastName": null
}
}
データのフォーマット
データ型
基本的にデータに適したデータ型で設定して下さい。
🙅 Bad
{
"meta": null,
"data": {
"id": 1,
"score": "60.5" ‼️
}
}
🙆 Good
{
"meta": null,
"data": {
"id": 1,
"score": 60.5 👍
}
}
日時型
ISO8601拡張形式のタイムゾーンJST
で表現して下さい。
🙅 Bad
{
"meta": null,
"data": {
"id": 1,
"createdAt": "2018/10/29 05:38:24"
}
}
🙆 Good
{
"meta": null,
"data": {
"id": 1,
"createdAt": "2018-10-29T05:38:24.486+09:00"
}
}
区分値
区分を表すコード値は数値で指定して下さい。
区分値の例
🙅 Bad
{
"meta": null,
"data": {
"id": 1,
"status": "in_progress"
}
}
🙆 Good
{
"meta": null,
"data": {
"id": 1,
"status": 1
}
}
真偽値
真偽を表すフラグ値はBoolean
で指定して下さい。
フラグ値の例
🙅 Bad
{
"meta": null,
"data": {
"id": 1,
"published": 1
}
}
🙆 Good
{
"meta": null,
"data": {
"id": 1,
"published": true
}
}
サニタイズ
responce bodyをXSS対策のためにサニタイズせず、rawデータで指定して下さい。
🙅 Bad
{
"meta": null,
"data": {
"id": 1,
"name": "<script>alert("foo")</script>"
}
}
🙆 Good
{
"meta": null,
"data": {
"id": 1,
"published": "<script>alert(\"foo\")</script>"
}
}
サニタイズはクライアントサイドのテンプレートシステムで行なっているためです。
query parameter
配列
配列データは<フィールド名>[]
で表現して下さい。
🙅 Bad
GET /suveys?id=1,2,3,4,5
🙆 Good
GET /suveys?id[]=1&id[]=2&id[]=3&id[]=4&id[]=5
railsとの親和性を考慮しました。
構造を持つデータ表現
複数の異なるリソースを含むリソースはリソース単位でネスト構造を持たせて表現して下さい。
🙅 bad
{
"meta": null,
"data": {
"id": 1,
"name": "foo"
"barId": 1,
"barName": "foo"
}
}
🙆 good
{
"meta": null,
"data": {
"id": 1,
"name": "foo"
"bar": {
"id": 1,
"name": "foo"
}
}
}
エンドポイントのネスト
エンドポイントのネストは基本的にせず、path parameter
よりquery parameter
で表現することを優先して下さい。
🙅 bad
/companies/:company_id/survys/:survey_id/foos/:foo_id
🙆 good
/foos/:foo_id?companyId=:company_id&surveyId=:survey_id
REST原則に外れる設計パターン
REST原則に従うことによってアプリケーションとして問題が生ずる場合があります。
下記に示すパターンに沿って設計を行って下さい。
WebAPIコールのN+1問題
問題
複数のリソースで構成されたリソースの一覧を取得する場合、単純にRESTらしい設計を行うとN+1問題
が発生します。
下記に例を示します。
前提
- 画面表示対象の回答者は100人いる
- 回答者として表示する情報は回答数、ユーザー名、性別、生年月日
- 用意されているリソースは下記の2つ
- 回答者リソース
- Endpoint:
/answers
- 回答数とユーザーIDを保持する
- Endpoint:
- ユーザーリソース
- Endpoint:
/users
- ユーザー名、性別、生年月日を保持する
- Endpoint:
- 回答者リソース
制御フロー
- 回答者リソースを全件(100件)取得する
-
/answers
のAPIコール数:1回
- 回答者に紐づくユーザーリソースを取得する
-
/users/:id
のAPIコール数:100回
😱回答者の数だけAPIコールが発生しクライアント側のパフォーマンスやサーバー負荷の問題が発生しうる!!
設計パターン
本問題に対する設計パターンは2つあり、それぞれの状況によって使い分けて下さい。
パターンA: リソースの検索条件として複数のIDを指定できるようにする
対象のリソースのEndpointのフィルタ条件として複数のIDを指定できるように設計します。
これにより複数のリソースで構成された情報を取得する際のAPIコール回数がリソースの種別数分に抑えることができます。
先の例ではユーザーリソースのフィルタ条件に配列型のIDを定義できるようにします。
- 回答者リソースを全件(100件)取得する
-
/answers
のAPIコール数:1回
-
- 回答者に紐づくユーザーリソースを取得する
-
/users?id[]=1&id[]=2&id[]=3....
のAPIコール数:1回
-
😀合計2回のAPIコールで済みました。
基本的にはパターンBよりパターンAを優先します。
パターンB: リソースに異なるリソースを含める
REST原則に違反する形にはなりますが、リソースに異なるリソースを含めます。
これにより複数のリソースで構成された情報を1度のAPIコールで取得することができます。
先の例では回答者のリソースにユーザーのリソースを含めるように拡張します。
{
"meta": null
"data": [
{
"id": 1,
"answerCount": 50,
"user": {
"id": 1,
"name": "foo",
"gender": 1,
"birthDate": "1994-07-30T00:00:00.000+0900"
}
},
{
"id": 2,
"answerCount": 45,
"user": {
"id": 2,
"name": "bar",
"gender": 0,
"birthDate": "1974-10-02T00:00:00.000+0900"
}
},
...
]
}
パターンAとの使い分けの観点としては、このリソースが拡張した異なるリソースとともに複数個所でよく使われるのかどうかになります。
よく使われる場合はパターンAよりパターンBを採用した方が良いでしょう。
そうでなければ、拡張したリソースは多くのケースで余計な情報になるためパターンAを採用すべきでしょう。
また、本パターンを採用する場合、拡張するリソースが3種類以上、及び3階層以上にならないようにして下さい。
🙅 Bad
{
"meta": null
"data": [
{
"id": 1,
"answerCount": 50,
"user": {
"id": 1,
"name": "foo",
"gender": 1,
"birthDate": "1994-07-30T00:00:00.000+0900",
"company": { // 🙆 2階層目のリソース
"id": 100,
"name": "bar",
"address": { // 🙅 3階層目のリソース
"id": 30,
"location": "東京都中央区銀座6丁目10-1"
}
}
},
"answerdQuestion": [{ // 🙅 3種類以上のリソース
"no": 1,
"title": "第一問 あなたの性別は?",
"answer": 1
}]
},
...
]
}
🙆 good
パターンAを使用し複数のリソースを複数回のAPIコールで取得する、もしくは画面に最適化した専用のリソースとして新たに定義しましょう。
最後に
紹介したガイドラインを適用した本格運用をまだ始めたばかりで、今後足り無い部分や余計な部分など色々な問題が出て来ると思います。
しかしこの点に関しては心配はしていません。
ガイドラインはシステムの改変、チームの成長、文化の変化などにより成長していく生き物のようなものです。
チームでこのガイドラインを放置せず大切に育て行くことにこそ意義があります。
ということでレビューをしてくれた全ての開発メンバーの皆様ありがとうございました!!
一緒にガイドラインを大切に育てて行きましょう!!😀
最後の最後に
組織改善にご興味のある方は全ての組織がこれで変わるモチベーションクラウドを是非ご検討して頂ければと思います!!