こちらの記事は以下のブログからの転載となります。
非同期Batch処理がしたいときのREST API設計 - Junks, GC cannot sweep
https://munchkins-diary.hatenablog.com/entry/2019/05/05/202254
こんばんは、日本はまだGWですが、こちらはMayDay以外一切の休みなく働いております。10連休羨ましい!!
お久しぶりです、最近Scalaを全く使わないサービスへの異動になり、ひたすらJavaでScalaっぽいコードを書いては怒られているMunchkinです。
今回はREST API(Restish APIの方が正しいかな)で非同期Batch処理を起動したいっていうお話です。正直自分でもまだ正解がわかっていないので、ガンガンマサカリを投げてください。
先に結論
仮にすでに以下のようなリソース定義が存在するとします。
- API概要:様々なアイテムをドキュメントとして扱うWebDav的なAPI。
- リソース: 様々なドキュメント
- docType: ドキュメントの種類(事務書類・予定・連絡先など)
- documentId: ユニバーサルユニークなドキュメントを特定するID
/api/v1/documents/{docType}/{documentId}
この時、特定のdocTypeに、Facebookの友人リストからまとめて連絡先を追加するなどといった、非同期batch処理を行いたい場合、documentIDの一つ上の階層の
/api/v1/documents/{docType}/
に処理を割り当てると以下のような問題が起こる可能性があります。
- すでにこのパスに対して処理が割り当てられている可能性が高い。(任意IDでのドキュメントの作成や条件指定でのドキュメント検索など)
- 非同期処理を行なうAPIと同期処理を行なうAPIが同一Path上に混在することになりAPIから統一感が失われる(API利用者にとっての利便性が下がる)
そこで、新しくバッチ自体をリソースと捉え、新たにパスを切り、各メソッドに操作を割り当てます。
Pathの設計
/api/v1/documents/bathces/docImporter/{jobId}
メソッドの割当
- POST: ジョブの起動
- GET: ジョブのステータス確認
- PUT: 使用しない
- DELETE: ジョブの停止
このような設計にすることで
- REST原則に則ったAPI提供ができる
- 非同期処理用のAPIを同期APIを切り離すことで既存APIの統一性が保てる
と言ったメリットが出ます。
本題
上記の経緯を読み飛ばした人のために解説させていただきますと、画像埋め込みありのクーポンを千枚単位でAPI経由で発行したく、APIの要件は以下の3点です。
- APIを通じて千枚単位のそれなりに重い処理を行う
- REST原則にできるだけ従う形で非同期Batch APIを提供する
- 非同期のため、ステータスを取得するAPIが必要
そして、問題としては
- 既存Pathの中で最も近いリソースPath(/api/v1/gourmet/{storeId}/coupons)はすでに使用済み
- 非同期処理はその他の同期でレスポンスが返されるAPIとは別の場所に置きたい
があります。
REST原則
聡明なるQiita読者の皆様には確認する必要もないかもしれませんが、一応REST原則を確認しておきます。Wikiからの引用ですが、妥当性は確認してあります。
・ステートレスなクライアント/サーバプロトコル
・すべての情報(リソース)に適用できる「よく定義された操作」のセット
・リソースを一意に識別する「汎用的な構文」
・アプリケーションの情報と状態遷移の両方を扱うことができる「ハイパーメディアの使用」
’’’Wikipedia 「Representational State Transfer」より抜粋・引用’’’
このREST原則のうち、”ステートレスなクライアント/サーバプロトコル”と”アプリケーションの情報と状態遷移の両方を扱うことができる「ハイパーメディアの使用」”は、そもそも僕たちが提供するAPI基盤の担当領域になるため、今回は一旦置いておきます。
したがって、
"すべての情報(リソース)に適用できる「よく定義された操作」のセット"
"リソースを一意に識別する「汎用的な構文」"
をどのように実現するかが今回の肝になります。
リソースはなにか
REST APIの設計に置いて、"リソースを一意に識別する「汎用的な構文」"、つまりURIから一意に特定できるリソースをどう定義するか
は非常に重要な問題です。
今回の場合、普通に考えればリソースは作成される対象のクーポンであり、リソースへのPathは/api/v1/gourmet/{storeId}/coupons
が好ましいと考えられます。
ただ、この形式でリソース定義すると以下の問題が避けて通れなくなります。
- このPathはすでに処理が割り当てられている
- 既存Pathへの変更は後方互換性を破壊するため行えない
- 非同期であることがPathから読み取れない(API利用者にとっての利便性が下がる)
上の2つについては僕たちの設計独自の問題ですが、3つ目の問題はかなりジェネラルで多くの開発者が突き当たる問題ではないかと思います。
ここまで来ると、クーポンをリソースとしたAPIは不適当なのではないか
と思えてきます。
そこで、今度はこの処理そのものをリソースであると考えてみます。
僕はREST APIの設計をする時にフォルダとファイルの関係で考えることが多いのですが、今回はBatch処理そのものをexeやshファイルだと考えて設計し直してみます。
そうすると、以下のようなURIが考えられます。
/api/v1/gourmet/batches/coupons/generator
これでURIを見るだけで、バージョン1のGourmetサービスのAPIで、クーポン作成のバッチ処理を行なう
ということがpathから一見でわかるようになりました。
ただし、このままだとリソース、つまり処理が一意に特定できません。したがって、Batchの処理にIDを通して処理を特定できるようにします。
/api/v1/gourmet/batches/coupons/generator/{batchId}
良さそうに見えます。リソースとURIが決まったので、次はすべての情報(リソース)に適用できる「よく定義された操作」のセットについて考えていきます。
Batch処理に対するRESTfulな操作セット
REST APIは同一のURIに対して異なるHTTPメソッドを投げることで指定されたリソースに対して操作を行います。
以下に挙げるのは一般的な操作とパラメータです。
Method | Action | Request Body | Query Param |
---|---|---|---|
GET | リソースの情報を取得する | N/A | フィルタやソートなどの条件、ページングなど (e.g. ?max=100&order=asc) |
POST | リソースを作成したり処理を実行したりする | 作成するコンテンツやメタデータなど | 実行条件やエラー時の処理など (e.g. ?skipIfExists=true&exclusive=true) |
PUT | リソースを置換・更新する | 変更するコンテンツやメタデータなど | フィルタや更新対象の指定、実行条件など (e.g. ?fields=[location,mapUrl]&filter=[attendance=ATTEND]) |
DELETE | リソースを削除する | N/A | 削除対象の絞り込み、実行条件など (e.g. ?filter=[lastRef<=1507042549298]&exclusive=true) |
ちなみに、POST以外の操作には冪等性、つまり何度叩いても、リソースそのものに第三者から変更が加えられない限り、同じ結果が返ってくることを保証しなくてはいけません。
先程決めたURI、/api/v1/gourmet/batches/coupons/generator/{batchId}
に対してそれぞれの処理を割り当てると、次のような感じでしょうか?
- POST: Batch処理の起動
- GET: 処理中Batchのステータス取得
- PUT: 今回は使わない
- DELETE: 処理中Batchの停止
この操作セットなら、/batches/全てに適用可能な操作セットと言えるのではないかと思います。
Deleteはステータスも含めた削除で良いかなと思わなくも無いのですが、停止したBatchは何回停止させても停止状態も停止した時間も変わらない
ので、べき等性は保証できています。
API利用者からしたら誰かによって停止されたというステータスをGETで取得できたほうが利便性が高いように思われる
ので、DELETEは処理中Batchの停止を行なうという仕様にします。
出来たAPI仕様
さて、上記の結論をまとめると、以下のようなAPI仕様になります。(書式はAPi Blueprintに準拠)
FORMAT: 1A
# Async Batch API
# Async Batch API of Coupon Service[/batches/coupons]
## Coupon Batch Generator [/generator/{batchId}]
This API is to create thousands of coupon in a single call.
You can create multiple coupons ASYNCHRONOUSLY.
You are able to get the status by polling if necessary.
This API is supposed to be used for the case of creating more than 100 tickets.
You need to specify the {batchId} to specify which job you want to operate.
No format restriction, but recommended to use UUID to avoid the conflicts.
+ Parameters
+ batchId: 387396de-1a1c-4376-9403-3e2a4e2d484d (string) - An unique identifier of the batch.
### Launch Batch Coupon Generator [POST]
Launch Coupon generates batch.
Batch will be executed asynchronously.
You will get the response immediately, but it normally not contains any result.
You need to call the GET API to check the status and result.
+ Request Launch Batch Content (application/json)
+ Body
{ "targets": [{
[couponId]:{
"storeId":string,
"desctiption": string,
"validFrom": Date,
"expiredOn": Date,
"discounts": [{
"itemId":string,
"discountType":enum,
"amount": number,
"conditions":[{"conditionType":enum, "value": number}]
}]
}
}]
}
+ Response 200 (application/json)
+ Body
{
"batchId": string,
"status": enum,
"acceptedAt": Date,
"processingItems": number,
"results": {
"success":[{
"couponId": string,
"imageUrl": URL,
"isPublished": boolean
}],
"fail":[{
"couponId": string,
"errorCode": number,
"cause": string
}]
}
}
+ Response 409 (application/json)
+ Body
{"message": "Batch ID already exists and couldn't be launched."}
### Check the Batch Generate status [GET]
You can get the status of the batch you have launched.
Status contains
- ID of batch
- started date time
- status of batch. Either of {WAITING, RUNNING, FINISHED, STOPPING, ABORTED}
- amount of accepted coupons
- result of batch
Created or failed items are added to the response content.
You can process the result of created items even though the batch is still running.
+ Response 200 (application/json)
+ Body
{
"batchId": string,
"status": enum,
"acceptedAt": Date,
"processingItems": number,
"results": {
"success":[{
"couponId": string,
"imageUrl": URL,
"isPublished": boolean
}],
"fail":[{
"couponId": string,
"errorCode": number,
"cause": string
}]
}
}
+ Response 404 (application/json)
+ Body
{"message": "Batch doesn't exist."}
### Kill the batch creation job [DELETE]
You can stop the batch job.
By kicking this job, you can kill the running job.
In order to stop the job safely, we will kill the job when the current processing item finishes its creation.
To make sure that the job has been stopped, you might need to call the GET API several times to check the GET API.
Even though the batch is killed, created items won't be deleted.
You need to delete them manually by checking the coupon Id of success list in the response.
+ Response 200 (application/json)
+ Body
{
"batchId": string,
"status": "STOPPING",
"acceptedAt": Date,
"processingItems": 0,
"results": {
"success":[{
"couponId": string,
"imageUrl": URL,
"isPublished": boolean
}],
"fail":[{
"couponId": string,
"errorCode": number,
"cause": string
}]
}
}
良さそうに見えます。今回は一旦これで行こうかと思います。
まとめ
以上、つらつらとBatch処理をREST APIで提供する方法を書いてきました。
結論としては、Batch処理をAPIで提供する場合、処理そのものをリソースと捉え、処理に対する操作をメソッドに割り当てるのが良さそうだなということでした。
実際、仮に/api/v1/gourmet/{storeId}/coupons
が使えたとしても、同期処理と非同期処理が似たようなPathに混在するのはAPI利用者にとって気持ちの良いものではないと思うので、やはりこちらの方が良い設計なのではないかと思っています。
ちなみに今回はこれでGoサインを出したのですが、正直これでほんとに正しいのか全く自信がなく、もし正しくはこうするべき、もしくはこの設計には問題があるなどコメントがあれば、どしどしいただけると幸いです。
現在もう一本、認可と権限管理に関する記事も書いてるので、また近々お見えすると思います。
今週中に終わらせたいなぁ…それでは今回はこれで!