非同期Batch処理がしたいときのREST API設計

 こんばんは、日本はまだGWですが、こちらはMayDay以外一切の休みなく働いております。10連休羨ましい!!

 お久しぶりです、最近Scalaを全く使わないサービスへの異動になり、ひたすらJavaでScalaっぽいコードを書いては怒られているMunchkinです。

 今回はREST API(Restish APIの方が正しいかな)で非同期Batch処理を起動したいっていうお話です。正直自分でもまだ正解がわかっていないので、ガンガンマサカリを投げてください。

先に結論

この先の考察の結果として、僕は以下のような結論に至りました。

仮にすでに以下のようなリソース定義が存在するとします。

/api/v1/documents/{docType}/{documentId}

この時、特定のdocType全体に署名を追記する非同期batch処理を行いたい場合、batch処理自体をリソースと捉え、新たにパスを切り、各メソッドに操作を割り当てます。

/api/v1/documents/bathces/signingAppender/{jobId}
  • POST: ジョブの起動
  • GET: ジョブのステータス確認
  • PUT: 使用しない
  • DELETE: ジョブの停止

こういう設計にすることで

  • 非同期処理用のAPIを同期APIを切り離すことでAPIに統一性が出る
  • REST原則に則ったAPI提供ができる

と言ったメリットが出ます。

徹底的に改ざんされた、読まなくても差し支えない経緯

(以下の文章は、情報漏えいないよう、大幅に事実を変更された余り本題に関係ない経緯です。本題はコチラへ)

 日本は21世紀初の改元と10連休に湧いているらしい。結局、今年の春も桜を見る事なく過ぎていった。

 眼に映るのは、ヤシなんだかバナナなんだかわからない、やたら南国チックな街路樹と、昨晩のスコールで出来た水たまりを懸命に駐輪場から掻き出そうと汗を流す警備員のおじさんの背中だけである。

 僕はおじさんが普段座っている駐輪場の椅子で朝の一服を終えると、重たい腰をあげてオフィスのある13階へ向かった。

 すでに始業時間は過ぎているが、同僚はまだ数えるほどしか出社しておらず、また誰も作業を始めているようには見えない。そこで、僕も通勤途中に露店で買った、やたら甘いコーヒーとパクチが山盛りに入ったサンドウィッチで朝食にする事にした。

 僕がサンドウィッチからやたら甘い謎の肉塊を取り除いていると、チームリーダーのマオが話しかけにきた。

マオ「ねえ、Munchkin。君が一昨日上げてくれたREST APIの設計書なんだけど、ペンディングになってるチェーン各店舗のクーポンをまとめて作成して作成されたクーポンのmeta dataと画像のURLを返すやつ、そろそろ出来た?」
僕「あぁ、あのフランチャイズ店舗が好きに割引率や内容を決められるように、バラバラのクーポン作って配布したいってやつか。設計自体は終わったんだけど、ちょっと悩んでることあってね。」
マオ「へぇ、一応聞かせてもらっても良い?」
僕「前にF社さんからの要望で作った20件までまとめて作れるBatch APIがそのまま使えるかなと思ったんだけど、今回のT社さん、確か1000店舗以上だろ? QRコードの埋め込みもあるからいくら並列で流しても10分はかかるし、フルでそこにリソース回したら他が使えなくなるから、できれば非同期でBatch処理にしたいんだよね。」
マオ「それでいいと思うけど、なんか問題があるの?」
僕「いや、凄いくだらないんだけど、Pathをどうしようかなって。/api/v1/gourmet/{storeId}/coupons@POST使いたいなぁと思ったんだけど、これもうF社様の時の同期BatchAPIで使ってるし。そもそも僕非同期のREST APIなんて作った事ないからさ。」
マオ「なるほどね。(笑)まぁ、設計ができてるなら問題ないけど早めに決めてね。」

 さて、この非同期で行われるbatch処理を実行するAPIを、どのような仕様でどのようなURIに割り当てるべきかというのが今僕の頭を悩ませる大きな問題なのである。

本題

 上記の経緯を読み飛ばした人のために解説させていただきますと、画像埋め込みありのクーポンを千枚単位で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仕様になります。

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サインを出したのですが、正直これでほんとに正しいのか全く自信がなく、もし正しくはこうするべき、もしくはこの設計には問題があるなどコメントがあれば、どしどしいただけると幸いです。

 現在もう一本、認可と権限管理に関する記事も書いてるので、また近々お見えすると思います。  今週中に終わらせたいなぁ…それでは今回はこれで!