スタンプシート

Game Server Services のトランザクションシステムである スタンプシート の解説

GS2 には各サービス間を連携させるトランザクションの仕組みとして《スタンプシート》という仕組みを使用します。

GS2内のAPIのうち、プレイヤーにとってデメリットとなる操作を《消費アクション》と呼び、逆にプレイヤーにとってメリットのある操作を《入手アクション》と呼びます。

ゲーム内のストアで《1000ジェムを使用》して《ガチャを10回引く》という処理があったとしましょう。 この場合、《1000ジェムを使用》が消費アクションで、《ガチャを10回引く》を入手アクションと捉えることが可能です。

もう少し例をいくつか並べてみましょう。

  • 《メッセージを既読にする》消費アクションを実行して《メッセージに添付されたジェム100個を受け取る》入手アクションを実行する
  • 《スタミナを10消費する》消費アクションを実行して《クエスト1を開始状態にする》入手アクションを実行する
  • 《クエスト1の開始状態を削除する》消費アクションを実行して《クエスト1のクリア報酬を入手する》入手アクションを実行する
  • 《現在時刻で最終放置報酬受け取り時間を更新する》消費アクションを実行して《放置時間に応じた報酬を入手する》入手アクションを実行する
  • 《毎日リセットされる回数制限カウンターを1上昇させる》消費アクションを実行して《1日1回した受け取れないアイテムを入手する》入手アクションを実行する

このようにGS2において、全てのゲームサイクルは何かを消費して、何かを得ることで表現されています。

スタンプシートの仕組み

スタンプシートは複数の《消費アクション》と1つの《入手アクション》で構成され、 スタンプシートの発行も ストア機能や、クエスト機能といった GS2 のマイクロサービスが行います。 発行するスタンプシートの内容は、ストア機能に登録した商品マスターデータなどから決定しています。

スタンプシートの実行は

《消費アクション》の実行 -> 《入手アクション》の実行

順番で処理されます。

この順番で処理することで、不正に《入手アクション》を何度も実行されることを防いでいます。

《消費アクション》を実行すると、GS2 の各機能を提供するマイクロサービスは実行済みを証明する署名を発行します。 《入手アクション》を実行する際には、全ての《消費アクション》で受け取った署名を一緒に送信します。 《入手アクション》を実行する前に署名検証を行い、全てが実行済みであることを確認できてから《入手アクション》を実行します。

サービスディスカバリ

スタンプシートの《消費アクション》や《入手アクション》の内容に応じて、適切なマイクロサービスに処理を依頼する必要があります。 しかし、GS2 の機能は日々追加されており、《消費アクション》や《入手アクション》の種類は増え続けており、そのような分岐処理をメンテナンスするのは大変です。

そのため、GS2 には GS2-Distributor という、受け取ったスタンプシートを適切なマイクロサービスに転送するマイクロサービスを提供しています。 GS2-Distributor にスタンプシートを送信することで、スタンプシートの《消費アクション》や《入手アクション》の内容に応じて適切なマイクロサービスに転送する役割があります。

重複実行の防止

スタンプシートは重複実行できないように設計されています。 正しくは、GS2の全ての《消費アクション》や《入手アクション》のAPIには重複実行防止の仕組みが用意されています。

《消費アクション》や《入手アクション》のAPIは《Duplication Avoider》パラメーターを受け取れるようになっています。 ここに値を指定して API の処理が正常終了すると、応答内容が GS2 によって一定期間保存されます。 同一リクエストペイロードで、《Duplication Avoider》の指定がある場合、過去に《Duplication Avoider》の値が一致する処理を実行したことがある場合は、その結果を正常処理完了として応答して実際には処理を行わないようになっています。 そして、スタンプシートによる《消費アクション》や《入手アクション》の実行では、《Duplication Avoider》にはスタンプシート固有のIDである《トランザクションID》を指定することになっています。

それでは、重複実行防止 の仕組みがどのようなプロセスで実行されるのかを順を追ってみてみましょう。 まずは、以下のスタンプシートが存在すると仮定します。

消費アクション入手アクション
アイテムを1個消費ジェムを10個入手
回数制限カウンターを1増加

このスタンプシートを実行する場合、まずは《アイテムを1個消費》を実行します。

消費アクション入手アクション
アイテムを1個消費ジェムを10個入手
回数制限カウンターを1増加

その後、《回数制限カウンターを1増加》を実行しますが、ここでサーバーエラーが発生したとします。 この時、アイテムはすでに消費された状態で止まってしまっており、このままにしておくとプレイヤーからクレームが来てしまうでしょう。

そこで、スタンプシートの実行をリトライします。

リトライでは、スタンプシートをまた最初から実行し直します。まずは《アイテムを1個消費》を実行します。 ここで、《Duplication Avoider》が役立ちます。アイテムはすでに消費済みのため、実際には消費を行わず以前成功したときのレスポンスが正常完了としてそのまま応答されます。 スタンプシートを実行する側からすると、ただの成功応答にしか見えないため、次は《回数制限カウンターを1増加》を実行します。

消費アクション入手アクション
アイテムを1個消費ジェムを10個入手
回数制限カウンターを1増加

次は正常に成功しました。

最後に入手アクションである《ジェムを10個入手》を実行します。

消費アクション入手アクション
アイテムを1個消費ジェムを10個入手
回数制限カウンターを1増加

これでスタンプシートの役目は終了です。スタンプシートの実行に失敗した場合はどこまで実行されたかといったことは何も考えずにリトライするだけでいいことを理解してください。

スタンプシートのリトライの自動化

スタンプシートの実行に失敗した場合はリトライすればいい と言いましたが、簡単なようで実際はこれを徹底するのは難しいです。 そこで、GS2 のスタンプシートを発行するマイクロサービスは《スタンプシートの自動実行》というオプションを持っています。 このオプションを有効化すると、何らかの理由でリトライが必要な状況になった場合サーバーサイドで自動的にリトライが行われます。

《消費アクションで消費するアイテムの残量が足りない》というようなリトライで解決しない場合は、リトライが行われないことがあります。

複数の入手アクション

重複実行防止の仕組みがあれば、複数の入手アクションがあってもいいのではないかと思われるかもしれません。それは基本的に正しいです。 しかし、気をつけなければならないのは、重複実行防止のためのレスポンス内容の保持期間は無期限ではないということです。 レスポンス内容の保持期間をすぎると、再度過去に発行されたスタンプシートを実行できるようになってしまいます。 この場合も消費アクションの実行は必要となりますが、消費アクションを実行したとしても再度実行されること自体が望ましくないケースもあります。 このような問題に対処するため、スタンプシートの実行が終わった時にスタンプシート自体を無効化するプロセスを組み込むことで、レスポンス内容の保持期間を過ぎたとしても再度実行することはできないようにしています。

ここで《入手アクション》が複数あると、いつスタンプシートを無効化するべきかが難しくなってしまいます。 そのため、スタンプシートでは《入手アクション》は1つしか設定できないように設計されています。

しかし、現実問題としてゲームを作っていく上で、入手アクションが常に1つで済むわけではありません。 最も一般的なゲームサイクルを構成するクエスト機能ですら、《経験値の付与》と《ドロップアイテムの入手》という2つの入手アクションが同時に発生します。

そのようなケースに対応するために、GS2 ではジョブキューを利用しています。 ジョブキューはプレイヤーごとに用意される遅延実行用のキューです。

スタンプシートに《入手アクション》を設定したい状況では、《ジョブキューに入手アクションを実行するジョブを複数登録する》という1つの《入手アクション》を設定します。 このプロセスは自動化されており、クエストのマスターデータで報酬を設定する際には複数の《入手アクション》が設定できるようになっています。 そして、スタンプシートを発行する際にこれを《入手アクションを実行するジョブを複数登録する》という形に変形してスタンプシートを発行しています。

スタンプシートの実行は非同期処理

これまでの説明でわかったように、スタンプシートの完了を待つのは非常に困難です。 GS2 は多数のマイクロサービスを提供していますが、その1つが停止したとしてもサービス全体が停止しないように設計されています。 しかし、障害が発生した段階でスタンプシートやジョブキューの実行が滞り、結果の反映がすぐに完了することは保証されていないためです。

一方で、障害が復旧した際には皆さんは何もしなくてもスタンプシートやジョブキューのリトライによって処理は流れ始め、やがて正常化されます。 これまでの開発スタイルと異なるため困惑するかもしれません。 しかし、部分的なマイクロサービスの障害によって一時的に不整合な状況が発生したとしても、何も考えなくても障害復旧後にいずれ正常化されるという全体的なメリットをとって GS2 ではこのような設計を採用しています。

スタンプシート発行時のパラメータ設定

各サービスへのリクエストには、どのユーザーのリソースを操作するかという情報が必要ですが、 ゲーム内ストアやクエストのマスターデータに、あらかじめユーザーIDを静的に指定することができません。 そのためスタンプシートのリクエストに変数を埋め込むことができます。

マスターデータのアクションのリクエストに、#{userId} というプレースホルダー文字列を設定すると、 その部分はスタンプシートを発行する際にスタンプシートの発行をおこなったユーザーのユーザーIDに置換されます。

Config

スタンプシートの発行リクエストには Config(EzConfig)というパラメータが渡せるようになっています。 Config(EzConfig) はキー・バリュー形式で、渡したパラメータで #{Config で指定したキー値} のプレースホルダー文字列を置換することができます。

スタンプシートの《消費アクション》の実行結果

アクションのリクエストの記述内容に、例として ${Gs2Money:WithdrawByUserId.price} というプレースホルダー文字列を設定すると、 その部分は《消費アクション》の実行結果に置換され、変数として利用することができます。 例に示したケースでは、実行した《消費アクション》のうち Gs2Money:WithdrawByUserId の実行結果を参照し、戻り値の price を値として使用します。 子要素を参照する場合は ${Gs2Money:WithdrawByUserId.item.paid} のようにドットで繋ぐことで参照できます。

同一のアクションが《消費アクション》として複数登録されている場合に採用される値は不定です。

ウォレットに残高を加算するスタンプシートの例

{
  "name": "currency-120-jpy",
  "metadata": "price: 120 currencyCount: 50",
  "consumeActions": [
    {
      "action": "Gs2Money:RecordReceipt",
      "request": "{\"namespaceName\": \"money-0001\", \"contentsId\": \"io.gs2.sample.currency120\", \"userId\": \"#{userId}\", \"receipt\": \"#{receipt}\"}"
    }
  ],
  "acquireActions": [
    {
      "action": "Gs2Money:DepositByUserId",
      "request": "{\"namespaceName\": \"money-0001\", \"userId\": \"#{userId}\", \"slot\": \"#{slot}\", \"price\": 120, \"count\": 50}"
    }
  ]
}

Unity から Config の値を設定する例


    var result = await gs2.Showcase.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).Showcase(
        showcaseName: "showcase-0001"
    ).BuyAsync(
        displayItemId: "display-item-0001",
        quantity: 1,
        config: new [] {
           new EzConfig
           {
               Key = "slot",
               Value = Slot.ToString(),
           },
           new EzConfig
           {
               Key = "receipt",
               Value = receipt,
           },
       }
    );