Game Server Services(GS2)でアイテム購入処理を通してトランザクションを試してみた

2024.01.29

こんにちは、ゲームソリューション部の入井です。

今回は、GS2-ExchangeとGS2-Inventoryを組み合わせてゲーム内アイテムの売買機能をUnityを実装してみましたのでその内容をご紹介します。また、こういった複数のマイクロサービスが関係する機能を実装する際に必要になってくるGS2におけるトランザクション機能についても併せてご紹介します。

環境

  • Unity 2022.3.14f1
  • UniTask 2.5.0
  • GS2 C# SDK 2023.12.10
  • GS2 SDK for Unity 2023.12.14

GS2におけるトランザクション

トランザクションが必要な理由

GS2では、様々なマイクロサービスが提供されており、ゲームの機能を作り上げるためには、各サービスのAPIを機能に適した形で組み合わせることが必要となります。例えば、ソーシャルゲームにおけるガチャ機能を実現するために、課金通貨を管理するGS2-Moneyの通貨消費APIの実行後に抽選機能を提供するGS2-Lotteryの抽選実行APIを実行する、といった形です。

しかし、単純に各サービスのAPIを順番に実行するだけでは問題があります。何故なら、各マイクロサービスの環境は独立しており、常に全ての環境が安定稼働しているとは限らないからです。

上記のガチャ機能の例で言えば、通貨消費APIが実行完了できたとしても、ガチャの抽選実行APIの環境に障害が発生していた場合、ユーザーの通貨を消費するだけ消費して、その対価が与えられないという最悪なゲーム体験を与えてしまうことになります。

このような処理の整合性の問題はマイクロサービスのような分散システムを実現する上で様々な解決策が考えられています。

参考:マイクロサービスにおける決済トランザクション管理 | メルカリエンジニアリング

一方でGS2においては、独自のトランザクションシステムを利用することでマイクロサービスを跨いだ処理の整合性が保証されます。

トランザクションを発行するマイクロサービス

GS2の一部のマイクロサービスは、各サービスの処理を連携させるトランザクションを発行するAPIを提供しています。例えば、アイテムなどのユーザーリソースの交換処理を実現するGS2-Exchangeでは、GS2-Moneyでユーザー通貨を消費するAPIの実行と、GS2-Lotteryで抽選処理を実行するAPIの実行を合わせて管理するトランザクションの発行が可能です。

トランザクションの発行にあたっては、どのようなAPIを実行するかの設定をマスタとしてあらかじめ発行元のサービスへ登録しておく必要があります。例えば、このトランザクションではサービスAのAPIを実行した後でサービスBのAPIを実行する、といった形の設定です。これにより、ゲームクライアントからトランザクション発行APIを実行する際は、どのAPIの処理を実行するかを細かく指定する必要はなく、マスタに登録されているトランザクション名を指定するだけで必要な処理が実行されるようになっています。

処理の順序保証やエラー発生時のリトライ処理などは、全てGS2のサーバーサイド内で対応しているため、ゲームクライアントからはトランザクションの発行をリクエストするだけで処理の整合性は保証されるようになっています。

なお、このトランザクションシステムは、以前はスタンプシートという名前が付けられていましたが、現在はシンプルにトランザクションと呼ばれています。

消費アクションと入手アクション

GS2では、トランザクション内で実行される処理は、消費アクション(consumeActions)と入手アクション(acquireAction)に分類されます。

消費アクションはユーザーにとってデメリットがある処理で、例えばGS2-Inventoryにおけるアイテム消費の他、GS2-Staminaにおけるスタミナ消費、GS2-SerialKeyにおけるシリアルキーの消費などがあります。

逆に入手アクションはユーザーにとってメリットがある処理で、GS2-Inventoryにおけるアイテム入手の他、GS2-Experienceにおける経験値獲得、GS2-Moneyにおける残高加算などがあります。GS2-Exchangeのリソース交換処理自体を入手アクションとして定義することも可能です。

これらの用語を踏まえると、GS2-Exchangeのリソース交換トランザクション発行機能は、消費アクションを実行する対価として入手アクションを実行する機能、という形で言い換えることもできます。

今回作成したもの

今回、プレイヤーの所持品を管理するサービスであるGS2-Inventoryとリソース交換機能を持つGS2-Exchangeを組み合わせて、お金を払ってジュースを購入する機能をUnityで作りました。

GS2-Inventory | Game Server Services | Docs

GS2-Exchange | Game Server Services | Docs

画像のように、左側にはプレイヤーが現在所持しているものがリスト表示され、右側のボタンをクリックするとお金を使ってジュースを購入することができるようになっています。

GS2マネジメントコンソールでの設定

GS2の多くのマイクロサービスは、機能を使用する前にあらかじめマスタ設定をしておく必要があります。これは、今回使用したGS2-Inventory、GS2-Exchangeについても同様です。

ここからは、今回作成した仕組みを実行するために必要なマスタ設定の内容を紹介していきます。

なお、マスタの設定にはGS2のマネジメントコンソール上でエディタを使用する方法と、GS2-Deployというサービスでマスタ設定について記載したYAMLテンプレートファイルを読み込ませる方法がありますが、以下の手順ではマネジメントコンソールを使って設定を行っています。

GS2-Inventoryのマスタ設定

GS2-Inventoryでは、プレイヤーがどのようなインベントリ(アイテムを入れる容器)を所持するかについてや、そのインベントリ内で所持できるアイテムの内容についてマスタで設定が可能です。

GS2-Inventoryのマネジメントコンソールにアクセスすると、マスタデータエディタという機能が用意されており、それによってGUIでマスタの内容を編集できるようになっています。

エディタによる編集が完了すると、入力内容を元にしたJSON設定ファイルがエクスポートできるので、一旦ダウンロードした上でそれを専用フォームでアップロードすることでサービスのマスタ内容を設定できる仕組みとなっています。

今回、マネジメントコンソール経由で出力した以下の内容のJSONファイルを使用してマスタを設定しました。my-itemというインベントリ内で、juiceというアイテムとyenというアイテムを所持できるようにしています。なお、yenは円のことで、これによりお金の所持数を管理します。

{
  "version": "2019-02-05",
  "inventoryModels": [
    {
      "inventoryModelId": "grn:gs2:ap-northeast-1:XXXXXXX-Test:inventory:game-001:model:my-item",
      "name": "my-item",
      "initialCapacity": 10,
      "maxCapacity": 10,
      "protectReferencedItem": false,
      "itemModels": [
        {
          "itemModelId": "grn:gs2:ap-northeast-1:XXXXXXX-Test:inventory:game-001:model:my-item:item:juice",
          "name": "juice",
          "stackingLimit": 30,
          "allowMultipleStacks": false,
          "sortValue": 0
        },
        {
          "itemModelId": "grn:gs2:ap-northeast-1:XXXXXXX-Test:inventory:game-001:model:my-item:item:yen",
          "name": "yen",
          "stackingLimit": 10000,
          "allowMultipleStacks": false,
          "sortValue": 0
        }
      ]
    }
  ],
  "simpleInventoryModels": [],
  "bigInventoryModels": []
}

JSONファイルをよく見ると、インベントリにも3つの種類があったり、キャパシティなどについて指定できるプロパティがあることが見て取れますが、今回の実装ではそれらの設定内容は特に重要では無いので解説は省きます。気になる方は、GS2-Inventoryの公式ドキュメントをご参照ください。

GS2-Inventory | Game Server Services | Docs

マスタの設定が完了後、ユーザーデータ設定画面を開き、ゲームクライアント上でログインするユーザーについて、my-itemインベントリ内のyenアイテムを1000持っているように設定します。これにより、対象ユーザーは最初からお金を1000円分所持した状態でゲームを開始する形になります。

GS2-Exchangeのマスタ設定

続いて、リソース交換の内容を定義するためにGS2-Exchangeでもマスタ設定を行います。

GS2-Exchangeでは、リソース交換設定のことを「交換レートモデルマスター」と呼びます。ユーザーが何の消費アクションの対価として何の入手アクションを得るのかについてを設定する点からレートという言葉が使用されています。

こちらについても、マネジメントコンソールのエディタで設定のJSONファイルを出力可能です。今回は、以下の内容のJSONファイルを使用して、お金を消費する代わりにジュースを得る交換レートモデルマスターをbuy-juiceという名前で設定しました。

{
  "version": "2019-08-19",
  "rateModels": [
    {
      "rateModelId": "grn:gs2:ap-northeast-1:XXXXXXX-Test:exchange:game-0001:model:buy-juice",
      "name": "buy-juice",
      "consumeActions": [
        {
          "action": "Gs2Inventory:ConsumeItemSetByUserId",
          "request": "{\n  \"namespaceName\": \"game-001\",\n  \"inventoryName\": \"my-item\",\n  \"userId\": \"#{userId}\",\n  \"itemName\": \"yen\",\n  \"consumeCount\": 150\n}"
        }
      ],
      "timingType": "immediate",
      "lockTime": 0,
      "enableSkip": false,
      "skipConsumeActions": [],
      "acquireActions": [
        {
          "action": "Gs2Inventory:AcquireItemSetByUserId",
          "request": "{\n  \"namespaceName\": \"game-001\",\n  \"inventoryName\": \"my-item\",\n  \"itemName\": \"juice\",\n  \"userId\": \"#{userId}\",\n  \"acquireCount\": 1\n}"
        }
      ]
    }
  ],
  "incrementalRateModels": []
}

consumeActionsacquireActionsでそれぞれ消費アクションと入手アクションの内容が定義されているのですが、上記のJSONでは値が1行にまとまっている上に改行コードなどが入っていて読みづらいため、読みやすくテキストを整理したものを以下でご紹介します。

consumeActions(消費アクション)は以下の通りで、my-itemインベントリ内のyenアイテムを150個消費するよう設定が記載されています。yenを150個消費とは、つまり150円の支払いを表しています。

{
  "namespaceName": "game-001",
  "inventoryName": "my-item",
  "userId": "#{userId}",
  "itemName": "yen",
  "consumeCount": 150
}

acquireAction(入手アクション)は以下の通りで、同じくmy-itemインベントリについて、こちらではjuiceアイテムを1つ入手する設定が記載されています。

{
  "namespaceName": "game-001",
  "inventoryName": "my-item",
  "itemName": "juice",
  "userId": "#{userId}",
  "acquireCount": 1
}

上記の消費アクションと入手アクションにより、この交換レートモデルマスターではジュース1個を150円で購入するというリソース交換が定義されていることが分かります。

Unityの実装

続いて、Unityで作成したゲームプロジェクトの内容をご紹介します。

冒頭の画像のように、UIコンポーネントを活用してプレイヤーの現在の所持品一覧を表示したり、お金を払ってジュースを購入するためのボタンを設定したりしています。

今回の機能の実装については、主に以下のクラスの内容で成り立っています。

using System;
using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq;
using Gs2.Unity.Core;
using TMPro;
using UnityEngine;

public class Gs2Inventory : MonoBehaviour
{
    [SerializeField] UserData userData;

    [SerializeField] GameObject itemListContent;
    [SerializeField] TMP_Text itemListValue;

    private Gs2Domain _gs2;

    async void Start()
    {
        _gs2 = await MyGs2Client.CreateClient();
        await RefreshItemList();
    }

    public async void BuyItem()
    {
        try
        {
            var transaction = await _gs2.Exchange
                .Namespace("game-0001")
                .Me(userData.GameSession)
                .Exchange()
                .ExchangeAsync(rateName: "buy-juice", count: 1, config: null);
            await transaction.WaitAsync();
        }
        catch (Exception e)
        {
            Debug.LogError(e);
        }

        await RefreshItemList();
    }

    async UniTask RefreshItemList()
    {
        foreach (Transform child in itemListContent.transform)
        {
            GameObject.Destroy(child.gameObject);
        }

        try
        {
            var myItems = await _gs2.Inventory.Namespace("game-001")
            .Me(userData.GameSession)
            .Inventory("my-item")
            .ItemSetsAsync()
            .ToListAsync();

            myItems.ForEach(item =>
            {

                switch (item.ItemName)
                {
                    case "juice":
                        itemListValue.text = $"ジュース{item.Count}個";
                        break; 
                    case "yen": 
                        itemListValue.text = $"お金{item.Count}円";
                        break;
                }
                Instantiate(itemListValue, itemListContent.transform);
            });
        }
        catch (Exception e)
        {
            Debug.LogError(e);
        }
    }
}

以下の項目で、クラス内の各メソッドについて具体的に解説します。

現在のインベントリ状況表示処理

RefreshItemList()では、GS2 SDKのメソッドのInventory.Namespace().Me()でゲームセッション情報を受け取ったプレイヤーについて、ItemSetsAsync().ToListAsync()でGS2-Inventoryからアイテム所持状況をリストで取得し、それをUnityのUIコンポーネントに表示する処理を行っています。juiceyenでは分かりづらいので、内容に応じて日本語のテキストを出力するようにしています。

なお、RefreshItemList()はまずゲーム起動時に実行されるよう設定しており、これにより起動時点の所持品の内容がゲーム画面で表示されます。

async UniTask RefreshItemList()
{
    foreach (Transform child in itemListContent.transform)
    {
        GameObject.Destroy(child.gameObject);
    }

    try
    {
        // GS2-Inventoryからプレイヤーのアイテム所持状況取得
        var myItems = await _gs2.Inventory.Namespace("game-001")
        .Me(userData.GameSession)
        .Inventory("my-item")
        .ItemSetsAsync()
        .ToListAsync();

        myItems.ForEach(item =>
        {
            switch (item.ItemName)
            {
                case "juice":
                    itemListValue.text = $"ジュース{item.Count}個";
                    break; 
                case "yen": 
                    itemListValue.text = $"お金{item.Count}円";
                    break;
            }
            // ListView内にText UIコンポーネントを生成
            Instantiate(itemListValue, itemListContent.transform);
        });
    }
    catch (Exception e)
    {
        Debug.LogError(e);
    }
}

アイテムの購入処理

BuyItem()では、GS2 SDKのメソッドのExchange().ExchangeAsync()でGS2-Exchangeでのリソース交換を実行しています。rateNameとして引数に渡しているbuy-juiceは、交換レートモデルマスターの名前です。これにより、マスターで設定したとおりのレートで交換が行われます。

購入処理の完了後は、RefreshItemList()を呼び出すことでアイテム取得状況が最新のものに更新されるようにしています。

public async void BuyItem()
{
    try
    {
        var transaction = await _gs2.Exchange
            .Namespace("game-0001")
            .Me(userData.GameSession)
            .Exchange()
            .ExchangeAsync(rateName: "buy-juice", count: 1, config: null);
        await transaction.WaitAsync();
    }
    catch (Exception e)
    {
        Debug.LogError(e);
    }

    await RefreshItemList();
}

実行結果

ゲームを起動すると、最初に以下の画像のように現在の所持品としてお金1000円だけ持った状態でスタートします。

「150円でジュースを購入」ボタンをクリックすると、GS2-Exchangeでのリソース交換処理によってGS2-Inventoryの情報が更新され、ゲーム画面でも以下のように150円でジュース1個を購入した結果が反映されます。

もう一度ボタンをクリックすると、ジュースを更にもう1個購入した状態にゲーム画面が更新されます

まとめ

GS2のトランザクション機能について、アイテム購入処理の実装を例にしてご紹介しました。

GS2は、様々な機能がシンプルなAPIで利用できるのがメリットの1つですが、適切なトランザクションを発行するようにしないと局所的な障害発生時にユーザーへ思わぬ不利益を与えてしまうことがあります。

GS2のトランザクションシステムは使い方さえ分かれば非常に扱いやすく、またエラー時のリトライや重複実行防止措置もサーバー側でサポートしてくれるようになっているので、積極的に活用していくと良いでしょう。