状況
あなたはいま新たなWEBサービスの開発に, サーバサイドのプログラマとして参加しています.
このサービスはJavascriptで動くクライアントサイドと, REST形式のAPIを提供するサーバサイドで構成されています.
初期の打ち合わせの結果, サービスのビジネスロジックは最大限クライアントサイドに実装し,
サーバサイドはDBの薄いラッパー程度の簡単なものとして実装していく方針でまとまりました.
サーバサイドの開発は順調に進んでいっていました, ある日クライアントサイドのプログラマからこんな相談を受けるまでは.
「この画面の処理で3種類のリソースを保存する必要があるのだけれど, いずれかの処理に失敗した場合は
残り2つのリソースに対する変更も取り消したいたいんだ. どうやったらいいと思う?」
当然ながら, DBMSが提供するトランザクション機能が使えれば特に問題のない話ではある.
しかし, 複数のHTTPリクエスト間にまたがった話となると、話が違ってきます.
さて、どうしたらよいでしょうか?
4つの実現方法
その1 クライアント側ですべてのトランザクションを制御する
この方法では, APIにDBトランザクションを意味するリソースを追加し, それを利用してクライアントが直接DBトランザクションを管理します.
処理の流れ
以下に, 銀行口座間での現金振り込みを例に, 処理の流れを簡単に書いていきます.
まずトランザクションを利用したいクライアントは, 一連の処理開始前にトランザクションリソースを取得します.
POST /transactions HTTP/1.1
HOST: example.com
HTTP/1.1 201 Created
Location: /transactions/ee61
DBのトランザクションはこの時点で開始します.
トランザクション間で処理したリソースはすべて取得したトランザクションリソースの子リソースのように登録編集削除を行っていきます.
PUT /transactions/ee61/account/10 HTTP/1.1
HOST: example.com
balance=150
PUT /transactions/ee61/account/20 HTTP/1.1
HOST: example.com
balance=250
一連の処理が完了した時点で, クライアントはDBトランザクションリソースにコミット命令をだします.
PUT /transactions/ee61 HTTP/1.1
HOST: example.com
commited=true
このイクエストを受けて, DBのトランザクションはコミットされます.
処理を破棄したい場合は, 単純にトランザクションリソースを削除します.
DELETE /transactions/ee61 HTTP/1.1
HOST: example.com
コミット前であるならば, トランザクションはロールバックされ, このDBトランザクションリソースも無効化されます.
この例では, トランザクションリソースのIDをURIに含めましたが, リクエストボディに含める形でも
問題はないかと思われます.
実装方法
クライアント側から見た処理の流れは上記の通りですが, サーバ側としてはどうやってこれを実現していくのでしょうか?
DBトランザクションそのものをリソースとして扱う, と述べているわけなので素直に考えると
- トランザクション開始したコネクションを開いたままどこかに保持しておく
- 同一トランザクション内のHTTPリクエストが来るたびに同じコネクションを割り当てていく
という方法が思いつきます
しかし, 本当にこんな方法を採用したら, おそらくDBのコネクションはすぐに使いつくしてしまい
サービスとしては使い物にならなくなってしまうでしょう.
では別の方法として, 最初はDBへの書き込みを行らず, DBへ行うべき変更をファイルか何かに保存しておき
コミットが呼び出された時点でトランザクションを開始してすべての変更を一気に適用するという方法はどうでしょうか?
これならばうまくいくような気もするのですが, これって要するにDBMSが提供しているトランザクション機能と同じものを
自前でもう一個作ろうって言っているわけで
現実的にそんなものを作ることができるのかといわれると, GoogleやAmazonなら可能としか言えないのではないでしょうか?
メリット/デメリット
この方法を採用するメリットとしては, トランザクションとそれ以外のリソースがそれぞれ独立したものとして
定義できるため, リソース間の依存関係をシンプルに保つことができることが考えられます.
また, トランザクションとそれ以外のリソースはそれぞれ独立しているため, API設計の段階では
どのリソース間でトランザクションが必要になるかなどを考えなくてもよい点も上げることができるかもしれません.
デメリットについては, 実装が難しすぎることこれにつきます
その2 特定リソースにのみ対応したトランザクションリソースを定義する
RESTの概念について最もまとまった説明されている本といえば, オライリーから出ている「RESTful Webサービス」だと思いますが
この本でもほんの僅か(1ページに満たないくらい)ですがトランザクションについて言及されています.
その手法を2番目の方法とすることにします.
この方法でも, 1の時と同じようにトランザクションを意味するリソースを定義します.
ただし, 1.の場合とは異なり, この方法で定義されるトランザクションリソースは,
DBMが提供するDBトランザクションそのものではなく, 単に一連のリクエストをグループ化するための何か程度の仮想的なリソースです.
また, 1.の場合はトランザクションリソースがそれ以外のすべてのリソースに対して独立しており, 利用に関しても制限はないものでしたが,
この方法においては, トランザクションリソースは特定のリソース群と依存関係を持ち, 利用方法に関しても
制限が加えられているという違いもあります.
処理の流れ
1.と同様に, 銀行口座間の振り込みを例に処理の流れを見ていきます
まず最初に, クライアントは口座リソース用のトランザクションリソースを取得します
POST /account-transactions HTTP/1.1
HOST: example.com
HTTP/1.1 201 Created
Location: /account-transactions/ee61
次に, 取得した口座トランザクションリソースを使って, 一連のリクエストを送信します
PUT /account-transactions/ee61/account/10 HTTP/1.1
HOST: example.com
balance=150
PUT /account-transactions/ee61/account/20 HTTP/1.1
HOST: example.com
balance=250
一連の処理が完了した時点で, トランザクションをコミット状態にします.
PUT /account-transactions/ee61 HTTP/1.1
HOST: example.com
commited=true
このリクエストを受け取ったサーバはDBトランザクションを開始し, 登録された口座への変更を実行します
処理を破棄したい場合は, 1.の場合と同様にトランザクションリソースを削除します.
DELETE /account-transactions/ee61 HTTP/1.1
HOST: example.com
コミット前であるならば, トランザクションはロールバックされ, 口座トランザクションリソースも無効化されます.
実装方法
クライアント側からみた処理の流れは1.と対して変わりませんが, サーバ側はだいぶ変わってきます
1.の場合は完全に汎用的なトランザクションを目指していたため, コネクションの保存だのファイルへの変更保存だのといった複雑な話になってしまいましたが
こちらの方法では対象とするリソースも操作も限定されているため, 以下のようなテーブルを用意するだけで
トランザクションを実現することができます
account-transactionsリソースが生成された段階で, account_transactionsテーブルにレコードが生成され,
以後のaccountsリソースに対するPUTはそれぞれaccount_transaction_processesテーブルにレコードとして生成されていく.
account-transactionsリソースがコミットされた時点で, account_transactionsレコードに紐付く
account_transaction_processesレコードがorderの順にUPDATEもしくはINSERTされていきます.
この例ではPUTメソッドにしか対応していませんが, 仮にDELETEメソッドに対応することになっても
変更は局所的なものとなると思われます.
もっとも, より厳密な話をすると, 上記のような実装で本当に問題がないかというと疑問が残ります.
例えば, トランザクション中に別のユーザからのリクエストで該当の口座リソースが変更されていた場合はどうるべきでしょうか?
単にコミットに失敗させるべきでしょうか? あるいは何とかうまいことマージする方法を探すべきでしょうか?
そもそもこういったコンフリクトが起こらないようにロック機構を用意するべきでしょうか?
このような簡単な例であっても実際に実装するとなると心配事は次々と思い浮かびます
(ひょっとして例がお金を扱うという極めてナイーブな問題を扱っているからかもしれませんが)
実際に使う際にはよくよく考えないと思わぬ事故を引き起こすことになるかもしれないので
十分な注意は必要だと思われます.
メリット/デメリット
この方法のメリットは, RESTらしいシンプルさを保ちながらも, トランザクション機能を提供できること
そして, 実装もそれほど難しくはないことです
デメリットとしては,
見た目ほど実装も簡単には済まないかもしれないことです
それと, トランザクション対応しないといけないリソースが増えるたびに, それ用のトランザクションリソースを
定義しないといけないため, サービスの成長とともにAPIがトランザクションリソースだらけになってしまう可能性があることです
これについては適宜リファクタリングをしていくなどして対応することは可能かと思います
実装についていろいろと問題があるように書きましたが, 総合的にみると非常にRESTらしい良い方法であると思います.
その3 専用のリソースを用意する
相当以前の話になりますが, Javaのデザインパターン本を読んだ際にSessionFacadeというパターンが紹介されていました.
このパターンの概要は, 複数のエンティティへの操作が必要となるビジネスロジックを実装するさいに,
クライアントにそれらエンティティ群のメソッドを直接呼び出させる代わりに, 一連の処理を抽象化したFacadeを定義し
そのメソッドを呼び出すというものでした.
この方法では, 複数のリソースへのアクセスをまとめたリソースFacedeというべきものを導入し
トランザクションの関わる処理の流れを単純化します.
処理の流れ
やはり銀行口座間での振り込みを例に処理の流れを見ていきます
まず最初に, クライアントは口座間の現金の移動
を意味するリソース account-transfer
に必要な情報をPOSTします
POST /account-transfer HTTP/1.1
HOST: example.com
from_account_id=10&to_account_id=20&amount=100
以上でクライアント側の処理は終了です
実装方法
クライアント側の処理が単純なことは見ての通りですが, サーバ側の処理も単純になります.
というのも, 今までの方法で問題が複雑のなったのは, 時間的に離れた複数のHTTPリクエストにまたがる
トランザクションが存在するという事実があったためです.
対してこの方法では, すべてのトランザクションは常に一つのHTTPリクエストの中で完結しているため
今までの議論に出てきた問題は起こりません.
サーバプログラムはただ, DBトランザクションを開始し, リクエストとして送られてきたパラメータを使い処理を行い
DBトランザクションを終了という当たり前のコードを書けばよいだけになります.
まったくもって単純な話です.
メリット/デメリット
この手法のメリットは, クライアント/サーバともに実装が簡単であることです.
デメリットは, この手法そのものがREST-APIの思想から外れているかもしれない, という点です.
「RESTful Webサービス」では「RESTful-APIとはURLという形でWeb上に公開されたリソースに対して, HTTPメソッドで定義されたアクセスをおこなうことであって, リモートサーバの処理を呼び出すこと(RPC)ではない」といったようなことが説明されており,
POST /account/create
や POST /account/delete
などといった処理そのものを
リソースとして扱ってしまっているものをダメなAPIの例として挙げていました
その観点で見て POST /account-transfer
あるいは POST /account/transfer
というAPIはRESTの理念に適合しているかというと,
やっぱりこれはどちらかというとRPCスタイルのAPIなんじゃないかと思います.
より統一感あるAPIを目指すとなる場合, この点が問題になるかもしれません
4 何もしない
このような要求に対して何もしないというのも一つの方法かもしれません.
つまり, それぞれのAPIを個々に呼び出し, もし何らかのエラーが起こった場合はその都度手動でデータを修正する,
という方法です.
この方法は, 一見馬鹿らしく, 使い物にならないように感じるかもしれませんが
- 扱っているデータに緊急性はない
- 開発しているソフトウェアは, 完全に社内で使用するためのものである
などといった場合で事後的な対応で問題ないという話が運営チームと付くのであれば, 実は一番現実的な方法かもしれません.
どれを選ぶべきか
REST-APIでトランザクションを扱うための, 4つの方法を説明してきましたが, これらの中から
どの方法を実際に実装していくべきなのでしょうか?
まず1は除外しておきます. 1を実現するにはハードルが高すぎるためです.
次に4の方法ですが, 実はこの方法は__選択することが許されるのであるならば__一番のおすすめの方法です.
もちろん許されないことの方が多いと思うので, やっぱり除外することにします.
残るは2と3になりますが, これらの手法はどちらも十分に現実的な実装方法だと思うので, 好きに選んでしまっていいと思います.
多少苦労してでも統一感ある美しいAPI体系を作っていきたいというならば2ですし,
多少ダサくなっても実装がより楽でバグが出にくい方法が良いというのでしたら3を選んでゆけばよいかと思います.