RESTful APIで関連リソースをまとめて作成・更新するデータモデリング
はじめに
RESTful APIの設計において、データモデリングは非常に重要な要素です。単一のリソースを操作するAPI(例えば、特定のユーザー情報を取得する GET /users/{id}
や、単一の商品情報を作成する POST /products
など)の設計は比較的 straightforward です。しかし、システムを構築していく中で、関連する複数のリソースを一度にまとめて作成したり更新したりしたい、という要求が出てくることは少なくありません。
例えば、
- 新しいプロジェクトを作成する際に、同時にいくつかの初期タスクも登録したい。
- 顧客からの注文を受け付ける際に、注文情報本体と複数の注文明細を一度に登録したい。
- ユーザーの設定情報を更新する際に、基本設定、通知設定、プライバシー設定など、複数の設定項目をまとめて更新したい。
このようなケースでは、単一リソース操作のAPIを複数回呼び出す方法も考えられますが、クライアント側の実装が複雑になったり、ネットワークの負荷が増えたり、トランザクション性が失われたりといった問題が発生する可能性があります。
この記事では、RESTful APIで関連するリソースをまとめて作成・更新する際のデータモデリングに焦点を当て、その設計の考え方、具体的なパターン、そして設計上の注意点について解説します。
なぜ関連リソースをまとめて操作したいのか?
関連リソースをまとめて操作したいという要求の背景には、主に以下の理由があります。
- 効率性: クライアントがサーバーと何度も通信することなく、一度のリクエストで必要な操作を完了できるため、APIコールの回数を減らし、レスポンス時間を短縮できます。
- トランザクション性: 関連する一連の操作(例: 注文本体の作成と注文明細の作成)をアトミック(不可分)な処理として扱いたい場合、サーバー側でまとめて処理することで、一部だけが成功して不整合が発生するリスクを減らせます。
- UI/UXへの対応: ユーザーインターフェース上で複数の項目が関連付けられており、それらを一つのフォームとして入力・送信する場合など、UIの操作感に合わせてAPIも設計したいという要求があります。
設計上の課題
関連リソースをまとめて操作するAPIを設計する際には、いくつかの課題が生じます。
- リクエストボディの構造: 複数のリソースのデータをどのように一つのリクエストボディに含めるか。リスト構造を使うか、ネスト構造にするか。
- エンドポイントの選択: どのリソースの配下に、どのような形式のURIでAPIエンドポイントを設計するか。
- 操作のセマンティクス: API呼び出しが具体的にどのようなデータ操作(新規作成、更新、削除)を引き起こすのかを明確にする必要があり、特に更新(PATCH)操作では複雑になりがちです。
- エラーハンドリング: 一部の操作が失敗した場合に、全体をどう扱うか、クライアントにどのようにエラーを通知するか。
- 冪等性(Idempotency): 同じリクエストを複数回送信した場合に、副作用なく同じ結果が得られるか。
これらの課題を解決するために、データモデリングの視点から、APIのリクエスト・レスポンスの構造を設計していくことが重要です。
具体的な設計パターン
関連リソースをまとめて作成・更新するためのデータモデリングには、いくつかのパターンが考えられます。ここでは代表的なものを紹介します。
パターン1: 親リソースの操作としてネストした子リソースを含める
これは、作成または更新対象となる中心的なリソース(親リソース)の表現の中に、関連するリソース(子リソース)のリストをネストして含めるパターンです。特に、親リソースと子リソースが強い親子関係にあり、子リソースが親リソースなしでは独立して存在しないような場合に適しています(例: 注文と注文明細、プロジェクトとタスク)。
作成操作 (POST)
新しい親リソースを作成する際に、同時に初期の子リソースリストも作成します。
- エンドポイント例:
POST /projects
- リクエストボディ例 (JSON):
{
"name": "新しいプロジェクト",
"description": "今年の目標達成プロジェクト",
"tasks": [
{
"description": "要件定義をまとめる",
"status": "todo",
"due_date": "2023-11-15"
},
{
"description": "設計を進める",
"status": "todo",
"due_date": "2023-11-30"
}
]
}
サーバーは、このリクエストを受け取り、新しいプロジェクトとそのプロジェクトに関連付けられたタスクをアトミックに(まとめて)作成します。
更新操作 (PATCHまたはPUT)
既存の親リソースを更新する際に、関連する子リソースのリスト全体を更新または置き換えるように設計します。PATCHを使う場合は、差分更新のセマンティクスを定義します。リストの更新では、以下のような操作が考えられます。
- 既存の子リソースの更新
- 新しい子リソースの追加
- 既存の子リソースの削除
PATCHリクエストでこれらの操作をどのように表現するかは重要な設計判断です。一般的なアプローチとしては、以下のような方法があります。
- リスト全体を置き換える: リクエストボディに含まれる子リソースリストが、サーバー上のリストを完全に置き換えるものとみなす。これはPUT操作に近いセマンティクスになります。
-
リスト要素のIDに基づいて差分を適用する: リクエストボディ中の子リソースリストに含まれる要素のIDを見て、
- IDがある要素は既存の子リソースの更新
- IDがない要素は新規の子リソースの追加
- リクエストボディのリストに含まれないがサーバー上に存在する子リソースは削除 というセマンティクスで処理します。この方法は柔軟ですが、冪等性の確保や削除意図の明確化に注意が必要です。
-
エンドポイント例:
PATCH /projects/{id}
- リクエストボディ例 (IDに基づいて差分を適用する場合のJSON):
{
"name": "更新されたプロジェクト名",
"tasks": [
{
"id": "task-abc", // 既存タスクのID
"description": "要件定義(修正)",
"status": "in_progress"
},
{
"id": "task-xyz", // 既存タスクのID
"status": "done" // ステータスのみ更新
},
{
// 新規タスクのためIDなし
"description": "テスト計画を作成する",
"status": "todo",
"due_date": "2023-12-10"
}
// ここにID "task-123" のタスクが含まれていなければ、サーバー側で削除する
]
}
このパターンのメリット:
- 単一のAPIエンドポイントで関連操作を完結できる。
- UIのフォーム構造とAPIリクエストボディの構造を合わせやすい。
- サーバー側でのアトミックなトランザクション処理を実現しやすい。
このパターンのデメリット:
- リクエストボディの構造が複雑になりがち。
- 特にPATCH操作の場合、リストの要素の追加・更新・削除のセマンティクスを明確に定義し、ドキュメント化する必要がある。
- リストの一部分だけを効率的に更新・削除するのが難しい場合がある(例えば、1000件あるタスクリストのうち1件だけを削除したい場合でも、ID差分方式ならリスト全体を送る必要がある)。
パターン2: 専用の複合リソースを作成・更新する
これは、複数の独立した、あるいは緩やかに結合したリソースに対する操作を、一つの「複合リソース」としてモデル化するパターンです。パターン1が強い親子関係に適しているのに対し、このパターンは複数の異なる種類のリソースをまとめて扱いたい場合や、操作そのものをリソースとして捉えたい場合に有効なことがあります。
- エンドポイント例:
POST /batch/operations
あるいはPOST /compound/requests
- リクエストボディ例 (JSON):
{
"operations": [
{
"method": "POST",
"path": "/projects",
"body": {
"name": "プロジェクトA",
"description": "詳細"
}
},
{
"method": "POST",
"path": "/tasks",
"body": {
"project_id": "(前の操作で作成されたプロジェクトのID)",
"description": "タスク1",
"status": "todo"
}
},
{
"method": "PATCH",
"path": "/users/user-id-1",
"body": {
"status": "active"
}
}
]
}
この例はやや汎用的なバッチ処理の形式ですが、より特定のユースケースに特化した複合リソースとして設計することも可能です。例えば、「プロジェクトと関連タスクの作成」という操作そのものを一つのリソースとして定義し、そのリソースに対してPOSTする、といった考え方です。
- エンドポイント例:
POST /project-creations
- リクエストボディ例 (JSON):
{
"project_details": {
"name": "新しいプロジェクト",
"description": "詳細"
},
"initial_tasks": [
{
"description": "タスクA",
"status": "todo"
},
{
"description": "タスクB",
"status": "todo"
}
]
}
このリクエストを受け取ったサーバーは、project_details
と initial_tasks
の情報を使って、プロジェクトとタスクを関連付けて作成します。
このパターンのメリット:
- 操作の意図がより明確になる場合がある。
- 複数の異なる種類のリソース操作を組み合わせやすい。
- 特に新規作成系の複合操作に適している。
このパターンのデメリット:
- RESTfulな「リソース指向」の考え方から少し離れる可能性がある(エンドポイントが操作名に近くなる)。
- リソースとしてGETできる実体があるのか、ないのか、設計思想を明確にする必要がある。
パターン3: アクションとしてモデル化する
厳密にはデータモデリングというよりはエンドポイント設計の側面が強いですが、RESTful原則(リソース指向)にこだわりすぎず、特定のビジネスオペレーションを表現するために、動詞を含むURIや、リソースの特定の状態遷移を表すURIを使うことがあります。
- エンドポイント例:
POST /projects/{id}/add-tasks
,POST /orders/{id}/finalize
- リクエストボディ例 (POST /projects/{id}/add-tasks):
{
"tasks_to_add": [
{
"description": "追加タスク1",
"status": "todo"
},
{
"description": "追加タスク2",
"status": "todo"
}
]
}
このパターンは、リストの置き換えや差分更新ではなく、「既存のプロジェクトにタスクを『追加する』」という特定の操作を表現する場合に有効です。
このパターンのメリット:
- 操作の意図が最も明確に表現される。
- 複雑なビジネスロジックを含む操作に適している。
このパターンのデメリット:
- URIがリソース名(名詞)だけでなく操作名(動詞)を含むことになり、純粋なRESTful原則から外れるとされることがある。
- 操作ごとにエンドポイントが増える可能性がある。
どのパターンを選択するかは、操作の性質、リソース間の関係性、システム全体の設計思想などを考慮して判断する必要があります。強い親子関係で子リソースが親なしで存在しえない場合はパターン1が自然ですし、複数の既存リソースに対して横断的な処理を行いたい場合はパターン2や、ユースケースによってはパターン3が適する場合もあります。
アンチパターン
関連リソースをまとめて操作しようとして陥りがちなアンチパターンも存在します。
- アンチパターン1: クエリパラメータでの複雑な操作指定
- 例:
PATCH /projects/{id}?addTaskId=task-new&removeTaskId=task-old&updateTask=task-modified-id,...
- 問題点: URIが非常に長くなり可読性が低下します。構造化されたデータを渡すためのリクエストボディを使用すべきです。パラメータの順序依存性などが発生しやすく、設計が破綻しやすくなります。
- 例:
- アンチパターン2: リクエストボディのフィールド名で操作を制御
- 例:
PATCH /projects/{id}
のリクエストボディに{"add_tasks": [...], "update_tasks": [...], "delete_task_ids": [...]}
のように、操作の種類を示すフィールドを含める。 - 問題点:これはパターン3の「アクションとしてのモデル化」と似ていますが、PATCHのようなリソースの「部分更新」のためのHTTPメソッドのセマンティクスと合いません。PATCHはリソース表現そのものの差分を適用するのに使われるべきです。また、フィールド名の種類が多くなるとボディ構造が複雑になり、拡張性も低くなります。パターン3としてPOSTメソッドで専用エンドポイントを切る方が、操作の意図が明確になります。
- 例:
これらのアンチパターンは、リソース指向から離れ、RPC(Remote Procedure Call)的な考え方に偏りすぎている場合に発生しやすいため注意が必要です。
考慮事項と実践的なヒント
関連リソースをまとめて操作するAPIを設計・実装する際には、以下の点を考慮すると良いでしょう。
- 冪等性の確保: 特に更新操作において、同じリクエストを複数回実行しても結果が変わらないように設計することは重要です。例えば、パターン1でリスト全体を置き換えるセマンティクスを採用すれば冪等性は担保されやすいですが、ID差分方式の場合は、サーバー側でどのように差分を計算し適用するかを冪等になるように慎重に設計する必要があります。
- トランザクション処理: サーバー側では、関連するリソース操作全体をデータベーストランザクションなどで囲み、アトミックに処理することが望ましいです。一部の操作が失敗した場合には、全体をロールバックして不整合を防ぎます。
- 詳細なエラーレスポンス: リクエスト全体が失敗した場合だけでなく、例えばリスト中の特定の子リソースのバリデーションに失敗した場合など、部分的なエラーの情報をクライアントに分かりやすく伝える必要があります。エラーが発生したリソースのIDや、具体的なエラー内容を示すフィールドを含めるなど、標準的(例: Problem Details for HTTP APIs - RFC 7807)または独自の構造を定義して対応します。
- パフォーマンス: 一度に処理する関連リソースの数が非常に多くなる可能性がある場合は、リクエスト・レスポンスボディのサイズや、サーバー側の処理負荷を考慮する必要があります。あまりに巨大なリクエストになる場合は、分割して操作するか、非同期処理を検討する必要があるかもしれません。
- ドキュメント化: 設計したリクエストボディの構造、各フィールドの意味、そしてAPIが引き起こす操作(追加、更新、削除など)のセマンティクスを、APIスキーマ定義(OpenAPIなど)を用いて明確に記述することが極めて重要です。これにより、クライアント開発者はAPIの挙動を正確に理解し、正しく利用できます。
まとめ
RESTful APIで関連リソースをまとめて作成・更新するデータモデリングは、APIの効率性、トランザクション性、そしてクライアント開発の容易性に大きく影響します。単一リソースの操作APIを組み合わせるだけでなく、関連するデータを一つのまとまりとして表現し、APIで操作できるように設計することは、現実世界の複雑なビジネスロジックをAPIにマッピングする上で避けられない課題です。
この記事で紹介した、親リソース操作としてのネスト構造、専用複合リソース、あるいはアクションとしてのモデル化といったパターンは、それぞれ異なるユースケースや設計思想に適しています。どのパターンを選択するかに正解は一つではありませんが、操作の性質、リソース間の関係性、そしてシステム全体の設計原則を考慮し、最も適切で保守性の高い設計を選択することが重要です。
設計を進めるにあたっては、リクエスト・レスポンスの具体的なJSON構造を定義し、それが意図する操作を明確にし、エラーハンドリングや冪等性といった非機能要件も合わせて考慮に入れることが、堅牢で使いやすいAPIを構築するための鍵となります。そして、その設計をOpenAPIなどで正確にドキュメント化することで、APIは開発チーム内外で共有される「契約」として、より高い価値を発揮するでしょう。