RESTじゃないAPIってどうしたらいいんだっけ?
改めて RESTful API とは
- RESTとは「Representational State Transfer」の略称。
- クライアント/サーバーモデルに基づくアーキテクチャスタイル。
- HTTPメソッド(GET, POST, PUT, DELETE など)を使って、リソースの表現を転送する。
- リソースは一意のURIで識別され、リソースの状態遷移を表現する。
- ステートレスな通信で、各リクエストには必要な情報が含まれている。
- JSONやXMLなどの形式で、データをリソースの表現として転送する。
- RESTの設計原則には、クライアント/サーバー分離、統一インターフェイス、階層的な構造、キャッシュ可能などがある。
RESTfulなデザインを採用することで、以下のようなメリットがある。
- 単純で軽量な設計で、耐障害性が高い。
- HTTPの標準機能を活用できるため、ツールやミドルウェアが充実している。
- ブラウザーでもデータにアクセスしやすい。
- スケーリングが容易で、拡張性が高い。
一方、以下のようなデメリットもある。
- RESTの制約に従うため、複雑なアプリケーションには適さない場合がある。
- 大量のデータ転送が必要な場合は効率が悪くなる可能性がある。
- 状態管理が難しく、クライアント側でケアが必要になる。
このように、RESTfulなAPIアーキテクチャは軽量でスケーラブルという長所がある一方、制約もある。
「小さく始める」プロダクトではREST使いがち
RESTアーキテクチャはシンプルで始めやすいので、初期はRESTで作りがち。
プロダクトの成熟に伴い、本来のRESTの設計原則から外れてしまうことがよくある。
副作用のあるAPI、非同期実行などなど。
機能拡張や要件変更に対応するため、URIの構造がフラットでなくなったり、HTTPメソッドの使い分けが適切でなくなったりする。
リソース指向のデザインから逸脱し、手続き型のエンドポイントが増えてくるなど、RESTらしさが失われていく。
アプリケーションが複雑になるにつれ、RESTfulなAPIを維持することは難しい。
では、RESTfulではないAPIはどう定義してあげたらいいのだろうか?
CQRS という解決策
CQRSとは Command and Query Responsibility Segregation の略称。
アプリケーションのモデルを読み取り側(Query)と更新側(Command)に分離する。
読み取り側は主にデータの提示を目的とした参照モデルで構成し、更新側は状態を変更するためのモデルで構成される。
メリットは以下。
- 責務を分離できる
- 参照モデルと更新モデルの構造が異なっても問題ない
- 参照モデルは読み取り最適化、更新モデルは書き込み最適化ができる
- 参照側とコマンド側のスケーリングを個別に行える
リソース指向のエンドポイントはREST、手続き指向のエンドポイントはCQRSにするとよい。
例えば、更新側のコマンドをRPCのようなスタイルで提供したり、特定のユースケースに特化したエンドポイントを用意したりすることができる。
大幅な変更を加えずに、手続き指向のエンドポイントのみ適用出来ることが大きなメリット。
TypeScript, express での例
REST での単純な GET
RESTfulなリソース指向のAPI
GET /user/:id
指定したユーザーIDに対応するユーザーデータを取得するためのクエリとして機能する
// ユーザークエリ
interface GetUserQuery {
userId: string;
}
// クエリハンドラー
const handleGetUserQuery = (query: GetUserQuery): UserViewModel => {
// イベントソースからユーザーデータを再構築する処理...
const userViewModel: UserViewModel = { /* ユーザーデータ */ };
return userViewModel;
};
// Expressルーター
router.get('/user/:id', (req, res) => {
const query: GetUserQuery = {
userId: req.params.id,
};
const userViewModel = handleGetUserQuery(query);
res.json(userViewModel);
});
CQRS での更新、かつ更新後の状態によって副作用が起きる
想定するケースは以下
契約のステータスを更新する。この更新でユーザーが契約しているプランが0になったら、ユーザーを休止会員にする。
// コマンドの定義
interface UpdateContractStatusCommand {
contractId: string;
newStatus: string;
}
// イベントの定義
interface ContractStatusUpdatedEvent {
contractId: string;
newStatus: string;
userId: string;
}
// コマンドハンドラー
const handleUpdateContractStatusCommand = (command: UpdateContractStatusCommand): ContractStatusUpdatedEvent => {
// コントラクトステータス更新ロジック
// ユーザーの有効なコントラクトが0件になれば、ユーザーをisActive=falseに更新
const event: ContractStatusUpdatedEvent = { /* イベントデータ */ };
// イベントを永続化する処理...
return event;
};
// Expressルーター
router.post('/contracts/:id/status', (req, res) => {
const command: UpdateContractStatusCommand = {
contractId: req.params.id,
newStatus: req.body.status,
};
const event = handleUpdateContractStatusCommand(command);
res.json(event);
});
非同期の副作用を実行するエンドポイント
想定するケースは以下。
顧客に支払い手段の登録案内メールを送信し、送信した記録をDBに保存する
// コマンドの定義
interface SendPaymentRegistrationReminder {
accountId: string;
}
// イベントの定義
interface PaymentRegistrationReminderSentEvent {
accountId: string;
reminderId: string;
}
// コマンドハンドラー
const handleSendPaymentRegistrationReminder = (command: SendPaymentRegistrationReminder): PaymentRegistrationReminderSentEvent => {
// メール送信ロジック
const reminderId = '新しいリマインダーID';
const event: PaymentRegistrationReminderSentEvent = {
accountId: command.accountId,
reminderId,
};
// イベントを永続化する処理...
return event;
};
// Expressルーター
router.post('/accounts/:id/payment-registration-reminder', (req, res) => {
const command: SendPaymentRegistrationReminder = {
accountId: req.params.id,
};
const event = handleSendPaymentRegistrationReminder(command);
res.json(event);
});
既存の命名規則を汚さない設計
上のコード例をChatGPTに書いてもらったらちょっと微妙だった。
実際には例えば /{リソース名}/:id
を使いたくないパターンはかなり多いと思われる。
そういった場合はRPCライクな命名をするのがよい。
具体的には /commands/{commandType}
といったURI。
もしくは /commands/:event-name
など。
これらの命名規則をあわせることは重要であるものの、/commands/*
であればCQRSが必要なAPIであることがわかる。
参照系は集約(Aggregates)を使うといいかも
1つのエンドポイントで複数のリソース(テーブル)を跨ぐ情報を返したいなら、集約を使う。
例えば顧客情報、注文情報、配送先住所を返したいと仮定する。
このとき、RESTを厳密に守ろうとすると3つのAPIを叩く必要がある。
/orders/{orderId}
- 注文情報のみを返す
/users/{userId}
- ユーザー情報のみを返す
/shippingAddresses/{addressId}
- 配送先住所情報のみを返す
でも1リクエストで関連データを取ってきたい...ならそれ用のものを作る。
/order-details/:orderId
{
"order": {
"id": "ord_01234567890",
"status": "SHIPPED",
"createdAt": "2023-05-14T10:30:00Z",
"items": [
{
"productId": "prod_abc",
"name": "Product 1",
"quantity": 2,
"price": 19.99
},
{
"productId": "prod_xyz",
"name": "Product 2",
"quantity": 1,
"price": 29.99
}
],
"total": 69.97
},
"user": {
"id": "usr_fedcba",
"name": "John Doe",
"email": "john@example.com"
},
"shippingAddress": {
"addressLine1": "123 Main St",
"addressLine2": "Apt 4B",
"city": "New York",
"state": "NY",
"zipCode": "10001",
"country": "US"
}
}
これで order-detail
という集約に対してはRESTの原則を守っている。
参照系にとどまらず、集約とCQRSの両立も可能。
例えば注文の発送後に配送先情報を変更したいなら、/commands/order-details/change-shipping-address-after-shipped
とか。
まとめ
- CQRSではRESTfulなリソース指向のAPIだけでなく、手続き指向のイベントを定義することができる
- RESTの命名規則から離れて、イベント名(コマンド名)を付けることが重要
- 複数テーブル関係を一度に参照したいなら集約を使う
Discussion