はじめに
モチベーション
異なるエンドポイントで、大体似たようなレスポンスなのに、
- キー名が違う...
- キーが多かったり少なかったりする。。
というような、Web API (主にjsonを返す) を作っていてよく問題になる現象。
このような問題に対してのアプローチとしてはいくつかある。
例えば、formatする関数を用意して、必ずレスポンス返却前にそれを呼んで返す、のような運用的な解決手段。
が、
- あまり強制力がない
- 宣言的ではなくどうしても手続きっぽくなる
というところで、ややイケてなさが残る。
そこで、API全体としてもっと強力な制約を課し、宣言的にやることで、レスポンスの形を完全に安定させたい。
そのために導入したのが、 (厳密な)リソース指向API と呼んでいるもの。
それ自体は何の新規性もないのだが、2ヶ月くらい運用してみて割と上手く行っているので、その気持ちと実装のことを書く。
リソース指向 Web API
リソースとは
ここでいうリソースは、だいたい以下のようなものだとしておく。
- 名前が付いている。
-
形式が定まっている(key名, 各valueの型など)。
- jsonであればjson schemaで定められるような感じ。
- 複数のエンドポイント間で使い回されることが想定されている。
リソース指向 Web API に課する制約
制約
次の3つの制約を課す。
- 制約1. APIのレスポンスボディ全体は、必ず、リソースであるか、その配列である。
- 制約2. リソース中に出てくる、ネストされたオブジェクト(またはその配列)は、それもまたリソース(またはその配列)である。
-
制約3. リソースは、(ソースコード上の) モデル と対応している。
- つまり、リソースは、あるモデルをシリアライズする方法(の1つ)である。
- モデル 1:n リソース である。(あるモデルのシリアライズ方法が複数あってもかまわない)
- ここでいうモデルは、アプリケーション上のデータに振る舞いを持たせているオブジェクトを想定している。(ここでは一旦、そういうプログラミングスタイルのみ考慮している)
- O/Rマッパーを利用している場合は、それがモデルになることもある。もしORMを利用していて、複数のモデルにまたがるようなリソースを作りたくなったら、複数のORMモデルをまとめるような新しいモデル(ViewModelに近い場合もある)を作ればよい。
例
以下の例で、これらの制約を説明する。
GET /users/1
を叩くと以下のようなレスポンスが返るとする。
{
id: 1,
name: 'qsona',
gender: 'male',
setting: {
// ...
}
}
この時、
- トップレベルのレスポンス全体は、 Userリソース である。
- Userリソースは id(integer), name(string), gender(enum string), settingsのキーを持つ、と型が定まっている。
- settingキーに入ってくるネストされたオブジェクトは、 Settingリソース である。
- ソースコード上には、Userリソースに対応する Userモデル が存在する。
リソース指向を適用した Web API の例
上の制約は意外と強いように思う。以下に例示する。
例1. 「お知らせ」API (マスタデータ+それに紐づくユーザ情報)
"運営からのお知らせ" という機能を考える。
このお知らせを表すリソース Notification
は以下のような形だとする。
// Notification Resource
{
id: 1,
title: 'サービス終了のお知らせ',
message: 'サービス終了します。ご愛顧ありがとうございました。',
start_at: '2016-11-13Z00:00:00'
}
さて、これはこれでリソースとして必要であるが、
ユーザ向けには合わせて、既読かどうかを表す is_read フラグが必要となったとする。
まず、制約1 (レスポンスボディ全体はリソース) から、これに対しても新しいリソースを定義する必要がある。そのリソースの名前を UserNotification
とする。
UserNotificationのリソースの形は、以下の2つが考えられる:
パターンA
// UserNotification パターンA
{
is_read: true,
notification: { // ここはNotificationリソースである(制約2を満たす)
id: 1,
title: 'サービス終了のお知らせ',
message: 'サービス終了します。ご愛顧ありがとうございました。',
start_at: '2016-11-13Z00:00:00'
}
}
パターンB
// UserNotification パターンB
{
is_read: true,
id: 1,
title: 'サービス終了のお知らせ',
message: 'サービス終了します。ご愛顧ありがとうございました。',
start_at: '2016-11-13Z00:00:00'
}
形的には、パターンAは委譲(delegate), パターンBは継承に近い。
現在自分は、 パターンA を採用している。Bでいくとやはり似たようなリソースが増えすぎる問題があるので、Aのほうがしっくりくる。
また、ソースコード上では、まず Notification
モデルが存在し、それへの参照とis_readフラグを持つような UserNotification
モデルを作成する(制約3)。
# Ruby on Rails でのクラス実装例
class Notification < ActiveRecord::Base
# ActiveRecordは、RailsでのO/R マッパーである
# ここではnotificationsテーブルが :id, :title, :message, :start_at のカラムを持つ
# 自動で同名のGetter / Setter メソッドが定義される
end
class UserNotification
include ActiveModel::Model
include ActiveModel::Serialization
# Getter / Setterの定義
attr_accessor :is_read, :notification
end
そして、それに対して、上記のリソースにするための シリアライザ を定義する。
# active_model_serializer での実装例
class UserNotificationSerializer < ActiveModel::Serializer
attribute :is_read
has_one :notification, serializer: NotificationSerializer
end
class NotificationSerializer < ActiveModel::Serializer
attributes :id, :title, :message, :start_at
end
URIは、/users/{user_id}/notifications/{id}
あるいは /me/notifications/{id}
といった感じになりそう。
例2. ページング
ページングのAPI設計は度々話題に上る。たとえば、配列のレスポンスの返し方で、
- ページングしたい時にキーが追加できなくて困るから、ページングしないときもレスポンスボディのトップレベルに配列は常にまずいよ派
- レスポンスボディは配列で返して、ページングに関する情報はヘッダーに入れるよ派
というような議論がある。
他にも、ページング情報は何を返すのか(pageとoffset、limitとoffset、そのページのソートされたキーの最大値、etc)という議論もある。
これは今回はあまり関係ないのでとりあえずpageとoffsetを返すとする。
厳密リソース指向APIを忠実に進めると、実際のデータのリソースを内包している、「ページング」を表す新たなリソースを定義する必要が生じる。
// Paging Resource
{
page: 2,
offset: 10,
data: [ // Some Other Resources
{ id: 1, name: 'a' },
{ id: 2, name: 'b' }
]
}
データとしては以下のようなイメージである。
class Paging<T> {
int page;
int offset;
T[] data;
}
そのような定義が難しければ、dataのリソースの種類ごとにXxxPagingリソースを定義してもよいだろう。
これらの制約を課す理由
一貫性
初めのモチベーションにも書いたように、APIのレスポンスが全てリソースで表されるという制約は、API設計に一貫性をもたらす。これにより以下のような利点がある。
- 似たようなレスポンス構造なのに微妙に形が違う、という問題を激減できる。
- クライアントが、レスポンスをクラスにマッピングしやすい。
- クライアントアプリケーションも複雑さが増しており、jsonを雑に扱うのではなく、クラスとして扱う必然性が出ている。
- 特に近年多いクライアントであるAndroidはJava、iOSはSwiftと、JavaScriptと比べてずっとかっちりした言語を利用している。
- JavaScriptもES2015でclass構文が導入されるなど、同じ路線になっている。
- クライアント側でのキャッシュもしやすくなる。
- リソースであることで、同一性が担保できるため。(同じリソース種類+同じIDであれば同じもの)
GraphQL compatible
今後流れが来そうなGraphQLも、強い型システムを持っている。初めからREST APIをこのように作っておけば移行もそこまで難しくないと推察。
トップレベルにリソース (またはその配列) を置く理由
レスポンスのトップに配列を置くと、あとからキー追加の拡張ができないため、トップレベルはオブジェクトにしておくべき、という意見がある。
たとえば後からページングしたくなった場合など。
しかし厳密リソース指向APIでは、そもそも配列と別途キーを追加する必要がある場合は別リソースになると捉えられるので、あえてそのような余地は残さないという立場をとる。
必要が生じたら、バージョンをあげるなり、別のエンドポイントにするなりで対応する。
ページングの例をとって言えば、クライアントにとって、「今までは全件返していたが、途中からページングされた結果が返る」場合、仮にjson上破壊的変更がなかったとしても、意味的には破壊的な変更なので、同じAPIで互換性を維持するのはいずれにせよ無理があるのでは、とも思う。