REST と API 設計

23 分で読了
このページの目次

API契約は、パスやJSONフィールドだけでなく、操作がいつ有効になるか、失敗時に再試行できるか、並行変更をどう処理するか、そしてクライアントが次のステップをどう判断するかを定義します。RESTはアーキテクチャ制約を提供し、HTTPはメソッドやステータスなどのセマンティクスを提供し、OpenAPIなどのツールはインターフェースを記述します。これらは互いに代替できません。

本稿では、注文リソースを通じて作成、更新、非同期処理、ページネーションを貫き、実際の失敗や並行発生時に呼び出し元がどうすべきかを優先して回答します。

質問重点メカニズム
リクエストがタイムアウトした場合、注文は本当に作成されたのか操作のアイデンティティ、冪等キー、結果照会
2人が同時に注文を変更する場合強力なETag、If-Match、アトミックな比較更新
ページ送り時に重複や漏れが発生する安定したソート、複合カーソル、スナップショット境界
202 の後、いつ成功と判断できるか照会可能な操作リソースと終端ステータス
フィールドを追加しただけでなぜ旧クライアントが失敗するのかコンシューマの互換性仮定と契約テスト
REST、GraphQL、gRPCの選択は?相互作用、進化、およびツールの要件

RESTの制約はURL命名の採点表ではない

RESTには、クライアント/サーバー分離、ステートレスな相互作用、キャッシュ可能性、統一インターフェース、階層化、およびオプショナルなコード・オン・デマンドが含まれます。統一インターフェースには、リソースの識別、表現を通じてリソースへの操作、自己説明的なメッセージ、およびハイパーメディアによるアプリケーション状態の駆動が含まれ、単にURL中の動詞を削除するだけではありません。

ステートレスとは、リクエストがサーバー側の暗黙的に保存された対話セッションのコンテキストに依存せずに理解できることを指します。これはデータベース、ビジネスリソース、キャッシュ、認証情報の使用を禁止するものではありません。また、JWTの使用を必須とするものでもありません。認証情報の形式と、その対話制約を満たすかどうかは別の問題です。

リソースは、注文、支払い試行、またはエクスポートタスクなど可以是、必ずしもデータベーステーブルと1対1でマッピングする必要はありません。あるリソースのJSONは表現の一つであり、リソースそのものではありません。JSON CRUDのみを提供するHTTP APIは実用的ですが、それをもってRESTの全制約を満たしたと断言することはできません。Fielding: RESTアーキテクチャスタイル

完全なビジネスアクションでリソース境界を決定する

操作例: インターフェース契約のポイント
注文の作成POST /orders201と新しいリソースのLocation、または明確な非同期受領を返す
注文の読み取りGET /orders/42表現、ETag、およびキャッシュ戦略を返す
書き込み可能な表現の置換PUT /orders/42サーバー生成フィールドと欠落フィールドの処理方法を定義する
部分的な変更PATCH /orders/42patchメディアタイプとアトミック性を明確にする
返金の開始POST /orders/42/refundsGETによる副作用ではなく、返金試行、金額、ステータスをモデル化する
エクスポートタスクの照会GET /operations/export-7実行ステータス、終端結果、または失敗情報を返す

パスに名詞を使用することは一貫性に役立ちますが、POSTは作成のみを意味するものではなく、アクション指向のインターフェースも本質的に誤りではありません。本当に危険なのは、GETが削除や課金をトリガーすることです。キャッシュ、プリフェッチ、再試行はその安全なセマンティクスに依存するためです。

リクエスト契約には、アイデンティティとテナントのスコープ、フィールドの欠如とnullの違い、金額の単位、時刻の形式、および境界値を明確にする必要があります。例えば、amount=100が100元なのか100セントなのかは、パスに動詞が含まれているかどうかよりも、正しさに影響を与えます。

メソッドの冪等性、ビジネスの冪等性、および再試行性

安全なメソッドの期待されるセマンティクスは読み取りであり、ログなどの付随する効果が全くないことを要求するものではありません。冪等なメソッドの複数回の同じリクエストは、1回と同じ期待効果を持ちますが、毎回同じレスポンスステータス、タイムスタンプ、または監査記録であることを要求するものではありません。DELETEが初回に204、2回目に404を返すことは、自動的に冪等性に違反するわけではありません。

GET、HEAD、PUT、DELETEなどはそれぞれ冪等なセマンティクスを持ちます。POSTには一般的な冪等性の保証はありませんが、具体的な操作は契約を通じて冪等性を達成できます。PATCHは全体として冪等性が保証されず、操作内容に依存します。ネットワーク失敗後に再試行できるかどうかは、メソッド名だけでなく、リクエストボディの再再生可能性、実行ステータス、および操作の前提条件を考慮する必要があります。RFC 9110: メソッドの性質

冪等キーはコミットとクラッシュの間の隙間をカバーする必要がある

冪等キーはビジネス操作のアイデンティティを記録するものであり、単なるメモリ上のsetにしてはならない 操作キー K を受信 呼び出し元と操作範囲を限定 アトミックな占有 / 実行 入力ダイジェストとビジネスレコードをバインド 完了して結果を保存 同じ K の繰り返しに対しては同じ操作結果を返す 同じキーで異なる入力:衝突;同じキーで実行中:契約に従って待機またはステータスを返す プロセスがクラッシュした後でも、永続的なステータスに基づいて何を行ったかを確認できる データベースや外部決済などの副作用を伴う場合、追加のトランザクションプロトコル、下流の冪等性、または回復可能なワークフローが必要。

クライアントが注文を送信した後タイムアウトし、同じKを持って再試行すると仮定します。サーバーは、契約された呼び出し元/テナントおよび操作範囲内でKを認識し、正規化されたリクエストダイジェストをバインドし、同じ入力の繰り返しに対しては既存の操作結果を返す必要があります。同じキーで異なる金額の場合は拒否し、前の注文を黙って再利用してはいけません。

まず注文を作成し、次に冪等記録を作成する場合、その間にクラッシュすると、注文が重複して作成されます。まず「処理済み」のレコードのみを作成し、次に注文を作成する場合、実行されていない操作が永遠に飲み込まれる可能性があります。重複排除の占有、ビジネス変更、および完了記録を、適切なトランザクションまたは回復可能なステートマシンに組み込み、並行する同じキーの要求を処理する必要があります。

さらに外部決済システムを呼び出す必要がある場合、ローカルデータベーストランザクションは外部の副作用を自動的にカバーしません。下流側で再利用可能な操作キー、結果照会、または永続的なステータスを持つワークフローを使用して不確実性を解決する必要があります。記録の有効期限が切れた後にKを再使用する場合の処理も契約の一部であり、有限のTTLキャッシュが永久的なexactly-onceを提供すると主張することはできません。

並行更新:ETagは装飾的なバージョン番号ではない

If-Matchはアトミック更新に参加して、更新の喪失を防ぐ必要がある クライアントAが v7 を読み取る 両者が同じリソースの編集を開始 クライアントBも v7 を読み取る 両者が同じリソースの編集を開始 A: If-Match v7 → 更新成功 ストレージ条件がヒットし、バージョンが v8 になる B: If-Match v7 → 412 古いバージョンはヒットせず、Aを上書きしない アプリケーションでまずバージョンを読み取り、その後無条件に書き込むことはできない。バージョン比較と更新は、同じ並行制御アクションを形成する必要がある。

クライアントAとBはどちらもバージョンv7を読み取ります。AがIf-Match: "v7"を持って更新に成功した後、Bが依然としてv7を持っている場合、412を返すべきです。そして再読み取りするか、ユーザーに競合処理を依頼します。If-Matchは強力な比較を使用する必要があり、弱いETagで書き込みの並行制御を偽装することはできません。

検証はアトミックな書き込みに参加する必要があります。以下のメモリ上のSQLiteの例では、条件付きUPDATEを使用して比較と更新を表現します。これは、実際のネットワークやサービスに依存せず、2人のクライアントが同じ古いバージョンを保持していることを示しています。

import sqlite3

db = sqlite3.connect(':memory:')
db.execute('CREATE TABLE orders(id INTEGER PRIMARY KEY, note TEXT, version INTEGER NOT NULL)')
db.execute('INSERT INTO orders VALUES (42, ?, 7)', ('initial',))

def update(expected, note):
    changed = db.execute(
        'UPDATE orders SET note=?, version=version+1 WHERE id=42 AND version=?',
        (note, expected)).rowcount
    db.commit()
    return 204 if changed == 1 else 412

assert update(7, 'from A') == 204
assert update(7, 'from B') == 412
assert db.execute('SELECT note, version FROM orders WHERE id=42').fetchone() == ('from A', 8)
print('古いバージョンの更新は拒否され、Aの変更が保持される')

実際のインターフェースでは、リソースが存在しないこと、呼び出し元に権限がないこと、および前提条件の失敗を区別する必要があります。この例ではこれらの層を省略し、更新の喪失の核心メカニズムのみを検証しています。レスポンス表現がネゴシエーションまたは変換を経る場合、ETagが指すバージョンと保護された更新対象の間に明確な関係があることを確保する必要があります。RFC 9110: If-Match

PATCHのメディアタイプが変更方法を決定する

JSON Merge Patchはオブジェクトメンバをマージし、nullはメンバの削除に使用され、配列は通常全体が置換されます。したがって、合意なしに「メンバの値をJSON nullに設定する」ことを表現するには適していません。JSON Patchはop/path/valueなどの操作を使用し、配列の末尾に同じ値を2回addすると2回追加され、すべての配列操作が冪等ではないわけではありません。RFC 7396、RFC 6902

PATCHは、仕様通りに変更セット全体をアトミックに適用することを要求し、途中で失敗して部分的なパッチを残してはいけません。古い表現に基づいて構築されたパッチの場合、If-Matchを組み合わせることで、配列の位置や古い値の仮定が変更されたリソースに適用されるのを防ぐことができます。RFC 5789

2人のクライアントがどちらもv7を持っている場合、どちらが上書きするのか?

Aを先にコミットし、次にBをコミットすると、Bの古い条件は通過できず、サーバーはAを保持します。コミット順序を逆にすると対称になります。Bが再読み取りした後、新しいバージョンに基づいてコミットできます。相互作用はユーザーに自動的に「競合を解決」するものではなく、マージ方法はビジネス判断によるものです。

図を準備しています
2人のクライアントがどちらもv7を持っている場合、どちらが上書きするのか?

古いバージョンのコミットは412を返し、すでにコミットされた新しいコンテンツを上書きしません。再読み取りして新しいバージョンを取得した後、コミットすることは、明確な新しい決定です。

エラーと非同期ステータスはプログラムを駆動できるべきである

application/problem+jsonは、typeで問題カテゴリを表現し、titleで短い名前を提供し、detailで今回の状況を説明し、instanceで今回の問題インスタンスを識別します。拡張メンバーはAPIによって定義されます。クライアントは、安定したカテゴリとフィールドに基づいて意思決定を行い、変化する可能性のあるまたはローカライズされたdetailテキストを解析してはいけません。

{
  "type": "https://api.example.com/problems/order-state-conflict",
  "title": "Order state conflict",
  "status": 409,
  "detail": "A shipped order cannot be cancelled.",
  "instance": "/problem-instances/req-123",
  "order_id": "42",
  "current_state": "shipped"
}

ここでHTTPステータスも409である必要があります。body内のstatusは説明情報であり、実際のHTTPステータスを置き換えるものではありません。order_idなどは本APIの拡張であり、Problem Detailsの必須フィールドではありません。内部スタックトレースやデータベースステートメントをdetailとして返してはいけません。RFC 9457

202はリクエストの受領を意味するだけで、処理の成功を保証するものではありません。エクスポートインターフェースは操作リソースへのリンクを返し、クライアントはポーリングしてpending/running/succeeded/failedを取得し、成功後に結果を取得できます。契約では、推奨されるポーリング間隔、キャンセルがベストエフォートかどうか、タスクと結果の保存期間、および再実行の識別方法を説明する必要があります。

401は通常、認証チャレンジを伴い、403は履行拒否を表現し、必ずしもユーザーが識別されたことを証明するものではありません。404は、リソースが存在するかどうかを隠すためにも使用されることがあります。409はビジネスステータスの競合に使用され、412は条件の失敗に使用され、すべての競合を一律に500に圧縮してはいけません。

カーソルページネーション:安定したソートは固定スナップショットではない

created_at DESC, id DESCをソート順序とし、idで同じタイムスタンプ内のタイブレークを行います。最初のページの最後の項目が(100,8)の場合、次のページではキーが(100,8)より厳密に小さいレコードを取得する必要があります。created_atのみのカーソルを使用すると、同じタイムスタンプ内の他のレコードが漏れる可能性があります。

rows = [(101, 10), (100, 9), (100, 8), (100, 7), (99, 6)]
first = sorted(rows, reverse=True)[:3]
cursor = first[-1]
assert cursor == (100, 8)
# 最初のページの後に、より前方の項目を挿入する。
now = sorted(rows + [(102, 11)], reverse=True)
offset_page = now[3:6]
keyset_page = [r for r in now if r < cursor][:3]
assert (100, 8) in offset_page  # offsetのドリフトにより、最初のページの最後の項目が重複
assert keyset_page == [(100, 7), (99, 6)]
assert [r for r in now if r[0] < cursor[0]] == [(99, 6)]  # 誤った単一フィールドカーソルにより(100,7)が漏れる
print('offset:', offset_page, '複合カーソル:', keyset_page)

データベースクエリには対応するインデックスが必要です。カーソルには、ソートキー、フィルタ条件のバージョン、および必要なスナップショット識別子をエンコードできますが、base64は暗号化や改ざん防止ではありません。各ページでも、カーソルがサーバーから来たからといって認証を回避することはできず、テナントと権限をチェックする必要があります。

Keysetは、前方への挿入によるドリフトを減らしますが、一貫したスナップショットを自動的に提供するものではありません。レコードが削除されたり、ソートフィールドが変更されたりすると、結果が変わる可能性があります。特定の固定時刻の全体的なセットをエクスポートする必要がある場合は、スナップショットまたは他の明確な一貫性スキームが必要です。ページジャンプの要件もシナリオに基づいて設計する必要があり、カーソルを「絶対にページジャンプができない」と絶対的に定義してはいけません。

ハイパーメディアとOpenAPI:次のステップの発見と固定契約の記述

レスポンスはnextリンク、または現在許可されている操作とそのメディアタイプのセマンティクスを提供できます。取消しリンクを隠すことはクライアントの提示に役立ちますが、サーバー側の実行時には、読み取り後にステータスが変更される可能性があるため、注文ステータスと権限をチェックする必要があります。適当に_linksオブジェクトを追加しただけでは、合意なしにクライアントが任意のアクションを理解できることを意味しません。

OpenAPIはHTTPインターフェースを記述し、厳格なRESTに限定されません。以下では、3.1.1構文を固定して、独立して読める最小限の読み取り専用インターフェースを示します。これは最新バージョンであることを主張するものではなく、本篇の注文契約全体をカバーするものでもありません。

openapi: 3.1.1
info:
  title: Order Example
  version: 1.0.0
paths:
  /orders/{id}:
    get:
      operationId: getOrder
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Order found
          headers:
            ETag:
              schema:
                type: string
          content:
            application/json:
              schema:
                type: object
                required: [id, state]
                properties:
                  id:
                    type: string
                  state:
                    type: string
        '404':
          description: Order not found or not visible

クライアントの生成、対話型ドキュメント、および構造化検証は手作業の偏りを減らしますが、課金がちょうど1回行われるかどうかを検証したり、フィールドタイプからフィールド間のビジネス制約を推論したりするものではありません。契約テストは、成功したJSONが解析できるかどうかだけでなく、状態変化、旧バージョンのクライアント、および典型的なエラーをカバーする必要があります。OpenAPI 3.1.1

スタイルの選択:具体的な相互作用を比較し、固定のパフォーマンスランキングを与えない

案コアインタラクション承担する必要がある設計作業
リソース型HTTP APIリソースと汎用メソッドを通じて表現を交換グレニュラリティ、条件付きリクエスト、キャッシュ、および進化
GraphQLスキーマに基づいてフィールドを選択し、クエリ/ミューテーションを実行クエリコスト、フィールド認証、N+1、および部分的なエラー
gRPCサービスメソッドとメッセージ契約に基づいて呼び出しデッドライン、ステータス、ストリーミングバックプレッシャ、およびプロトコルアクセス

RESTはJSONに限定されません。GraphQLの言語仕様はHTTPに限定されず、「単一のPOSTのみを定義できるためキャッシュできない」と直接定義することもできません。HTTPデプロイメントはGETクエリと永続的クエリをサポートし、適切なキャッシュを利用できますが、ユーザー固有の結果分離を処理する必要があります。GraphQL over HTTPの説明

gRPCはProtobufとHTTP/2を一般的に使用し、異なるストリーミング呼び出し形式をサポートしますが、すべてのインターフェースが自動的に低レイテンシになるわけではありません。ブラウザは通常、gRPC-Webなどのアダプターソリューションを通じてアクセスし、プロキシは必ずしもEnvoyである必要はなく、プロトコルをサポートするサーバー側で直接処理することもできます。ストリーミング機能は、選択した実装に基づいて確認する必要があります。gRPCの概念、gRPC-Webプロジェクト

互換性:フィールドの追加にもコンシューマ契約が必要

未知のフィールドを無視することに合意したクライアントでは、新しいオプションのレスポンスフィールドの追加は通常互換性があります。厳格な逆シリアル化、閉じたスキーマ、列挙型の網羅、または署名入力ルールは、これにより失敗する可能性があります。必須のリクエストフィールドの追加、デフォルト値の変更、検証の厳格化、ソートまたはページネーションセマンティクスの変更は、実際には破壊的な変更となる可能性があります。

バージョンはパスやネゴシエーションされたメディアタイプなどの場所に配置できます。重要なのは、ルーティング、キャッシュキー、およびクライアント生成で合意することです。単にv1をv2に変更するだけでは、移行説明、旧バージョンの観測、および明確なサポート期間に取って代わるものではありません。

Deprecationは廃止状態を表現し、Sunsetは予定されたサービス終了時間を表現し、これらは異なります。現在のDeprecationフィールドはStructured Field Date形式を使用します。例えば@<unix秒数>。SunsetはHTTP日付を使用し、同じ文字列を両方にコピーすることはできません。RFC 9745、RFC 8594

参照先