1168
1126

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

「WebAPI 設計のベストプラクティス」に対する所感

Last updated at Posted at 2016-03-28

翻訳: WebAPI 設計のベストプラクティス」を読んで色々と思うところがあったので書きました。
上記の記事は訳文でありますので、正しくは「Best Practices for Designing a Pragmatic RESTful API」に対する所感と述べた方が良いのかもしれませんが、日本語で通して読めるよう Qiita に投稿された訳文に対する所感として書いています。

以下では「翻訳: WebAPI 設計のベストプラクティス」並びに「Best Practices for Designing a Pragmatic RESTful API」は「当該記事」と表現します。

観点

当該記事では「○○とした方がよい」との意見に対してそうすべき理由が明らかになっていないか、もしくは表現が曖昧な場合が目立っていると感じました。設計は実装のようにプログラム言語仕様が制約を与えられないため、意図を明確にすることが何よりも重要です。
WebAPI の設計でも同様にあらゆる仕様に対して「何故そうしているのか」という意図を明確にすべきです。極端な事を言えば要件が同じであれば設計者が異なっていても同じ API が設計される、というのが理想的な状態であると僕は考えています。
本記事では、僕自身が普段設計をするときに念頭においている事柄を当該記事の内容を補足する形で書いています。

エンドポイントの名前が複数形であるべき理由

エンドポイントの名前は単数形と複数形のどちらを使うのが適切でしょうか。KISS の原則に従えば、答えは「一貫して複数形を使う」です。

とありますが、KISS の原則はシンプルさをキープすべきという原則であって、エンドポイントを複数形とする理由としては曖昧です。KISS の原則だけで考えるならば「複数形だけ」でもいいですし「単数形だけ」でも良いはずです。
実際、エンドポイントの命名規則を KISS の原則だけで評価してしまうと、設計者が想定する実装方法によって結果が異なります。

僕自身もエンドポイントの名前は複数形であるべきと考えていますが、それは KISS の原則を考えてのことではありません。
WebAPI におけるエンドポイントはリソースを示しているからです。リソースとは API が定義する任意の事象の集合です。
任意の事象の集合とは、言葉を変えれば”概念”です。それ故何か特定の一つの事象を表すエンドポイントというものはありませんし、作るべきではありません。

超乱暴に言い換えれば「任意の数の事象が当てはまる概念」がエンドポイントですので、(複数になりうる可能性を持っている)単数と複数を包括的に複数形で表現するのが合理的です。

特定の事象を示すエンドポイントを作るべきではない理由

上記の説明だけを見れば「じゃあ特定の事象だけを示す特別なエンドポイントがあってもいいんじゃないか」と思うかもしれません。
それは間違いではないかもしれませんが、WebAPI の特性を著しく損なうので悪手です。
例えば犬を示すエンドポイント /dogs があったとして、/inu_no_john というエンドポイントを新たに定義すると /dogs とは異なるエンドポイントとなります。
つまり、/inu_no_john ∉ /dogs となるわけです。この時点で /inu_no_john/dogs というエンドポイントが示す犬としての性質を持つことができなくなります。何故ならば /inu_no_john というエンドポイントには /dogs と同じ性質を持っていることを示す情報が抜けているからです。

設計者は「inu_no_john って書いてあるんだから普通犬だってわかるだろ」と考えるかもしれません。
このように、URI が示している事以外の暗黙的な知識(以下、暗黙知)を必要とする性質を依存性と言います。
後述しますが、今の WebAPI の大きな役割の一つが疎結合に依る依存性の排除ですので、エンドポイントを定義する際には上記のような暗黙知を必要としない表記を心がけるべきで、そのような性質を自己記述性と言います。

URI としてのエンドポイント

前段で自己記述性に言及しましたが、これは URI が持つ性質でもあります。HTTP はステートレスですので http://example.com/lover のような URI が「今リクエストするとどんな情報が返ってくるのか」を示すことは出来ません。あくまでもポインタのように任意の事象を示すだけで、事象そのもの状態を URI に含めることができません。言い換えれば、事象は URI の内容にとらわれず変化することができるというわけです。
このポインタのような仕組みをハイパーリンクと言い、ウェブの本質はハイパーリンクです。ハイパーリンクで接続されたあらゆる事象はお互いの状態にとらわれず存在することが出来ます。この性質が今のウェブアプリケーションでは非常に重要なものとなっています。

エンドポイントの名前が名詞であるべき(動詞を使うべきではない)理由

一言で言えばエンドポイントが示すのが事象だからです。事象とはそれ単体で存在するものを指します。
反面動詞は主語目的語が必要です。例えば「投げる」という動詞だけでは事象を指し示すことが出来ません。

また、WebAPI ではエンドポイント(URI)が示す事象(目的語)に対してどのようにはたらきかけるのかを、リクエストメソッドとして表現します。
入場券を示すエンドポイント /tickets があったとします。GET /tickets ならば「入場券を得る」、POST /tickets/1 ならば「自分が持っている1という入場券を差し出す」、DELETE /tickets/2 ならば「自分が持っている2という入場券を捨てる」などという具合に「リクエストメソッドと URI の組み合わせ」で「自分が何を要求(リクエスト)しているのか」を示します。

つまり、エンドポイントを主語目的語、リクエストメソッドを述語として要求を伝えているわけです。
動詞は主語目的語足りえませんので、エンドポイントの名前に動詞を使うべきではないのです。

CRUD の概念にフィットしない場合

当該記事では2つのアプローチを紹介されています。前者に関しては良い方法だと言えますが、後者はとるべきではない悪手です。

一方で、REST の構造にマッチさせられないアクションもあると思います。例えば、複数のリソースを横断的に検索するようなアクションについては、特定リソースのエンドポイントに紐付けるのは、なんとも無理やりな感じがします。このような場合は、/search というエンドポイントを作ることで解決します。ちょっとルール違反な気もしますが、API 利用者から見ておかしくなく、混乱がないようにドキュメントにしっかりと書かれていれば問題ないのです。

/search というエンドポイントは作るべきではありません。仮にドキュメントにしっかりと書いたとしてもです。
WebAPI が動作するウェブアプリケーションは元来継続的に変化し続けるものです。一度だけ認めた例外が必ず一度だけである保証はどこにもありません。しかも WebAPI はその性質上一旦リリースしたエンドポイントの名前を変えることは非常に難しいです。

それ故例外は認めるべきではありません。このような例外を認める前に設計に論理的な破綻が存在すると仮定した検証を行うべきです。

エンドポイントが示す粒度を一定にする

/search のようなエンドポイントを作らざるをえないような場合、そもそもとしてそのエンドポイントで示したい事象がほかと比べて大き(複雑)すぎるか具体的すぎる可能性があります。そのような場合には複数のエンドポイントに分割するか、もっと抽象的な設計とすべきです。

例えば「車で家族を駅まで迎えに行く」という行動をエンドポイントとして考える場合「家族を得るわけだから GET だろ」という解釈と「駅まで車を持っていくのだから POST だろ」という解釈のどちらが正解なのでしょうか。どちらも正しいと言えますし、どちらも無理やりこじつけているとも言えます。また、人によってどちらを正解とするかも異なるでしょう。

車で家族を駅まで迎えに行くという行動は非常に具体的且つ、複雑なプロセスを内包しています。簡単に言えば GET も POST も含まれています。
このような場合には車で家族を駅まで迎えに行くという行為を示すことのできるエンドポイントの組み合わせを考えるべきです。つまり、車で家族を駅まで迎えに行くという行為は複数のエンドポイントを組み合わせたトランザクションと解釈すべきです。

  1. GET /cars をリクエストして自分の車の ID を得る
  2. POST /cars/1(リクエストボディは item=/me)をリクエストして車に乗る
  3. GET /families?location=station をリクエストして駅にいる家族の ID を得る
  4. GET /families/2/locations をリクエストして家族のいる場所を得る
  5. POST /locations/3(リクエストボディは item=/cars/1)をリクエストして家族が待っている場所に車を向かわせる
  6. PUT /cars/1(リクエストボディは item=/families/2)をリクエストして家族を車にのせる

そもそもとして「車で家族を駅まで迎えに行く」という行為を単一のエンドポイントで示した場合、可用性が非常に低くなります。
API は出来る限り事象を抽象的に指し示し、組み合わせによって具体性を制御できるようにすべきです。
「車で|家族を|駅まで|迎えに行く」のように、行為を構成する要素に分割することで、どのように API を抽象化すべきかを判断できます。

リクエストが示す方向を一定にする

また、位置のように相対的な情報を扱う場合、位置を変える方法は一定にしましょう。場合によって「行く」のか「来る」のかを変えてしまうと組み合わせに齟齬が出てしまう可能性があります。
例えば上記の例ではエンドポイントが主体となっています。リクエストパラメータで指定された事象がエンドポイント側に向かうという方向に一定させています。これに POST /cars/1(リクエストボディは destination=station)などという API が加わった場合を想像しますと途端に事象同士の関係性をリクエストで示すことが難しくなります。

仕様書はモックサーバとして動くようにする

API の仕様書はそれそのものがモックサーバとして動くべきです。API 自体のライフサイクルを維持するためにも有用ですが、テスト用の API サーバを用意するような手間をかけずに済むからです。更に言えば API の仕様書は他と違って網羅的であるべきなのですが、普通の仕様書の体裁では網羅的に記述するのは難しいです。

API-Mock は定義されたフォーマットに従って書かれた API 仕様書を食わせることで Node.js の Express でモックサーバとして立ち上がります。普通にブラウザからアクセスすることも出来ますが、どのような言語のテストプログラムでも HTTP が喋れればこのモックサーバを API との結合テストのテストサーバとして利用出来ます。

また、API 仕様書が実際に API を適切に表現出来ているか、もしくは API が仕様書通りに正しく動作しているのかは Dredd によって評価可能です。メジャーなプログラム言語用のフックが用意されていますので、API 仕様書をフィクスチャ代わりに利用してテストプログラムを実装することも可能です。

これらのツールを使うことで API 利用者と提供者の間でテストサーバを運用する必要はなくなりますし、仕様書と実際の API の整合性を保つことも容易になります。
かっこよくデザインされた仕様書よりも API を使うアプリケーションの開発の負担を減らすような仕様書である方が合理的です。

バージョンを URL に含めるべきではない理由

一言で言えば「どのバージョンを使うべきか」という暗黙知が依存性となるためです。
また、API の性質的に一旦 URL にバージョンを含めてしまうと簡単に取り除くことはできません。言い換えれば一旦リリースしたバージョンをシャットダウンさせることは難しいです。これは時間の推移に比例して運用コストが増大する事を意味しますので、そんなことが明らかな手段をとるべきではありません。

誰が何を使うべきかを誰が知っているべきか

平たく言えば、API の利用者が「俺はこのバージョンの API を使うべきだ」という知識を持っているべきかどうかという話です。
僕はバージョンのようにシステムの開発体制に深く関わる要素を利用者に見せる合理的な理由はないと思っています。

そもそもとしてバージョニングしたいと思うのはどのような場面でしょうか。
「既存 API の利用者側で API の変更に対する改修をすぐに行うことは難しいが、他の利用者から既存 API への機能追加が求められているので任意の API に対して異なるバージョンを与えて挙動を変えられるようにしたい」というケースが殆どではないでしょうか。
つまり、利用者個別に「この利用者はこのバージョンの API を使っています」という情報があれば良いわけで、URL に含まれている必然性はありません。そのような情報はどこで得るのが良いでしょうか。僕は認証フェーズだと考えています。
認証をすることにより利用者が誰であるかは特定されます。利用者が持つ属性の一つとして「バージョン○○の API を使う」という情報を得られるようにすれば、利用者個々が異なるバージョンの API を利用できます。このような仕組みをオーケストレーションと言います。

API の認証フェーズでオーケストレーションすることは ACL のはたらきによく似ています。
「誰がどのデータにアクセス可能であるか」と「誰がどのバージョンの API にアクセス可能であるか」という情報は同列に扱うことができますし、ACL とは異なり利用者自身が情報の内容(利用するバージョン)を変更できるようにしても良いでしょう。

フィルタ・ソート・検索をリクエストパラメータでやるべき理由

フィルタ・ソート・検索はリクエストパラメータでやろう

との記載がありますが、何故そうすべきかが言及されていませんでしたので補足します。リクエストパラメータでやるべきなのはそうですが、フィルタや検索といった行為は、エンドポイントとして定義されていない事象の集合を、エンドポイントとして定義されている集合の部分集合として取り出す事を指します。つまり、フィルタや検索で得たい集合はそもそもとしてエンドポイントとして存在していません。

また、/books?author=john&title=my_book という URI が任意の本をフィルタすると考えた場合、仮にフィルタやソートのリクエストパラメータをエンドポイントに加えるとして /books/john/my_book/books/my_book/john はどう異なるのでしょうか。
同一の事象を指し示すのであるならばエンドポイントも同一であるべきです。事象の属性間に順序を見出すことは難しいですが、URI で表現されるエンドポイントには順序があります。

これらがフィルタ・ソート・検索をリクエストパラメータでやるべき理由です。

HATEOAS を採用すべき理由

(API のレスポンスを踏まえて)次に進むべきリンクを API 利用者が構築するか、はたまた API が提供するかについては、賛否両論の様々な意見が飛び交っています。REST を拡張する形で提案された HETEOAS という概念では、エンドポイント同士のインタラクションは API のレスポンスに含めるべきだとされています。
確かに、いわゆる Web サイトはこの HATEOAS の原則通りに動いているように見えます。例えば、私たちはウェブサイトのトップページにアクセスしたら、そこに表示されているリンクを押しますよね。ただ、私はこれを API の世界で展開するには時期尚早だと思います。ウェブサイトではどのような遷移がされるかどうかの判断をアプリ自体ができます。一方、API の場合は次にどのようなリクエストが来るかどうかはその API を使ったアプリに依存するため、API レベルで行うことは難しいのです。もちろん、どうにか工夫をしてその判断を遅延させることはできるかもしれませんが、そこまでして得られるメリットはさほどないと思います。このことから、私は HATEOAS はとても前途有望ではあるものの、現時点ではまだ使うには早すぎると思うのです。これが標準化され、メリットが最大限に生かされるようになるためには、まだまだ議論が必要です。

とありますが、これはエンドポイントが機能を示していると考えてしまっているがためにこのような主張になっているのだと考えられます。
第一にエンドポイントが示すのは事象です。機能ではありません。事象が関連する他の事象はハイパーリンクで参照できるかたちとなっているべきです。事象が他の事象に対してリンク出来ないのは、API の奥にあるシステムが管理しているデータ・モデルの設計と API の設計に齟齬があるからで、その関係性についての知識を持っていない利用者側への依存はありません。一定のルールに基づいてハイパーリンクを構成すれば良いだけです。

逆に、ハイパーリンクの存在しない API では、利用者が API が提供するデータ・モデルに関する深い知識を持っていなければなりません。このような依存性を持たせてしまうと、またもや API の可用性は下がります。API 自身が記述する以外の一切の情報は依存性として考えるべきです。API のレスポンスにハイパーリンクを加えることが出来ない場合、まずはデータモデリングに関する論理破綻を疑うべきです。

HATEOAS の具体的な実装に HAL(Hypertext Application Language)があります。以下のようなフォーマットでハイパーリンクと情報を構成します。

GET /orders/523 HTTP/1.1
Host: example.org
Accept: application/hal+json

HTTP/1.1 200 OK
Content-Type: application/hal+json

{
  "_links": {
    "self": { "href": "/orders/523" },
    "warehouse": { "href": "/warehouse/56" },
    "invoice": { "href": "/invoices/873" }
  },
  "currency": "USD",
  "status": "shipped",
  "total": 10.20
}

ページング情報をレスポンスボディに含めるべき理由

ページング情報はレスポンスヘッダに入れよう

とありますが、HAL には _links でページング情報をハイパーリンクとして表現する方法が定義されていますのでそちらを使いましょう。

関連データを埋め込む手段

関連データを埋め込む手段を作ろう

とありますが、HAL には埋め込みデータを意味する _embedded が定義されていますのでそちらを使いましょう。

リクエストボディとレスポンスボディのデータ構造について

一旦リリースした API はその仕様を変更するのが非常に難しいという性質を考えるならば、最も考慮すべきは可用性で、平たく言えばスタンダードを選ぶべきであり、プレーンな状態を保つべきです。
これは RFC で一般化された仕様を採用しましょうと言う意見と同じ観点によるものです。

レスポンスデータを整形すべきではない理由

JSON はデフォルトで整形しよう

とありますが、これも「レスポンスデータが整形されている」という情報を API が自己記述していないかぎりは暗黙知であり依存性となるため、整形すべきではありません。jq の URL を伝えましょう。
どうしても採用したい場合は /users.formatted のような拡張子で形式のメタ情報を表現するようなアプローチとするのが良いでしょう。

レスポンスデータをラップすべき理由

要素はラップせずに返そう

とありますが、エンドポイントが事象を示す以上メタデータのあり方について考えなければなりません。JSONP で得ることを考えた場合レスポンスヘッダにメタデータを含めることは得策ではなく、従ってレスポンスデータは必然的に事象そのものとメタデータで構成すべきものとなります。

リクエストボディに JSON を使うべきではない理由

追加・更新時のリクエストボディには JSON を使おう

とありますが、利用者に特別な実装を求めるべきではありません。通常とは異なる方法でのリクエストを求める時点で、その要求を満たすことができるライブラリしか使えなくなります。そのように可用性を落としてまで得られるメリットがデータの表現力増強でしかないならば、リスクの方がよっぽど大きいと僕は思います。システム側はパラメータに意図する型を予め知っていますので、型が自動的に与えられる仕組みが不可欠というわけでもありません。

重箱の隅

記事中では JSONP に Bearer トークンは使えないとありますが、誤りです。
推奨はされていませんが、Bearer トークンをリクエストパラメータに含めることは許されています。
http://openid-foundation-japan.github.io/draft-ietf-oauth-v2-bearer-draft11.ja.html#query-param

まとめ

WebAPI はシステム間の依存性を排除するのが存在理由の一つです。"Web"API だからといって必ずしもインターネットを介した複数のウェブアプリケーションを繋がなければならないわけではなく、一つのシステムを機能単位に区切って分割管理する用途にも使えます。API で結合された機能同士はお互いの内部仕様に関心を持つ必要がありません。全ては URI とリクエストメソッドが示す事象によって抽象化されています。
このように「関係を持つのに必要な情報を最低限に抑える」という関係性を疎結合と言います。
システム内部の機能同士が疎結合になることでそれぞれ異なるライフサイクルを持つことが出来ます。
これは、業務単位で機能を分割すればそれぞれ異なるリリースサイクルで運用できる事を意味します。これこそがシステム内部に WebAPI を持つ最も大きな理由であると僕は思います。

また、システム内部に WebAPI を持てば必然的にモデルの構造も CRUD に準じるようになります。旧来的な MVC でコントローラーが肥大化しやすかったり、モデルの定義が画一化されにくいのは実装上のバイアスが存在しない点にあります。
開発者それぞれが「俺が考えるスマートな実装」で開発していけば、とあるモデルにはゲッターとセッターがプロパティ単位で実装されていたり、様々な構造のリレーションデータを取得するためのメソッドが大量に実装されていたりと、便利かもしれないけど一貫性が無いために全体像がぼやっとしてしまい、新機能追加の度に八丁味噌のように濃度の高いソースを熟読しなければならないようなことになってしまいます。

これを防ぐためには「クラスの構造を複雑化させにくい状況」が必要です。
そのためにあるのが上述した疎結合と、もう一つが依存性の注入です。依存性の注入は WebAPI からは少し遠い位置にありますが、疎結合の度合いをより高めるために有効なアプローチです。

従来プログラムではメソッドに対して引数として様々なデータを渡すことで複数の処理を組み合わせています。
しかしこの手法には弱点があります。それは「呼び出す側が呼び出される側が受け入れる引数についての知識を持っていなければならない」という点です。この弱点はこれまで書いてきた暗黙知の一種です。同じシステム内にあるメソッド同士で何を…と思うかもしれませんが、これが例えばロガーだったらどうでしょう。ロガーには状況に応じてログファイルですとか、Fluentd ですとか、なんかまあ色々とログデータを送りたい先があるはずです。それら個々に送るためのメソッドを単一のロガークラスに実装するのは非常に煩雑です。送り先が増える度にロガークラスが肥大化していくわけです。

こうやって一つのところに集中させていくことをモノリシックな実装と言うのですが、これはシステムの透明性と一貫性を損ないます。
これを防ぐ手段としてあるのが依存性の注入です。上記のロガークラスで言えば、ロガークラスが持つのは「ログストレージに接続する」「ログを送る」という2つのメソッドだけで、ロガークラス自体は”どうやって”接続し、ログを送るのかを知りません。
ロガークラスを使う際に、例えばログファイルの依存性を注入するわけです。
ログファイルの依存性とは、ログファイルを開く方法であり、ログファイルに書き込む方法です。つまり、fopen() のような関数を実行するクラスをロガークラスのインスタンス生成時に渡すわけです。
こうすることでロガークラスはどれだけ接続先の種類が増えても複雑化はしません。ロガークラスは相手が誰で、どんな処理が必要なのかという情報すら知る必要がなく、単に自分の役割だけを理解しています。

これはオブジェクト指向プログラミングにおけるメッセージの仕組み1であり、ウェブの本質(ハイパーリンク)でもあります。

良い設計はシステムの透明性を自律的に保ちます。WebAPI は非常にシンプルなアプローチでそれを与えてくれます。しかしながらプログラム言語のように言語仕様として制約を与えることが出来ません。そのため、シンプルさを損なわないためにも「何故それが必要なのか」という理由を意識し、周りとそれを共有するよう心がけるのが肝要であるかと思います。

2016-03-29 追記1

「/searchというエンドポイントを使うな」なのに「検索はパラメータでやるべき」とか、やっぱしっかりやろうとすると矛盾がでるよね。ある程度テキトーが好きかも。

というご意見を拝見しました。これは僕の言葉選びに起因していますが誤解です。矛盾はありません。
本記事で述べている検索とは、フィルタの一つの在り方を示しているに過ぎません。つまり、任意の集合に含まれる集合を得ることを検索と称しているだけです。
例えば /cars というエンドポイントに対して色や形といったパラメータを与えて GET リクエストすることは「車という集合の中から白いセダンの車という集合を得る」という事であり、これを行為として表現するならばフィルタや検索という名前になります。

2016-03-29 追記2

結局「複数のリソースを横断的に検索するようなアクション」はどうすれば…

というご意見を拝見しました。答えは簡単で、複数のリソースを包含的に示すリソースを定義すれば良いと思います。
例えばビジネスマッチングのシステムを考えた場合、発注者と受注者がいますが、両方を包含する利用者というリソースを定義することで横断的な検索が可能です。
リソースはシステム内に存在するモデルとその関係に従う必要はありません。リソースはあくまでも事象を指し示しているだけに過ぎません。
事象とは、それそのものだけで成立する概念というだけで、何かプリミティブなデータと一定の関係を持っていなければならないようなことはありません。
しかしながら、横断的に検索したい複数のリソースを包含するリソースが思いつかないような場合は、それらのリソースを横断的に検索するというアプローチ自体に問題がある可能性を考えた方が良いかもしれません。

2017-04-07 追記3

指摘を受けて一部修正しました。

  1. 主題から外れるのでメッセージの説明は割愛します。

1168
1126
15

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1168
1126

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?