GS2 Blog

Game Server Services(https://gs2.io/) の最新情報をお届けします

OpenID Connect による引き継ぎ処理の実装がより便利になりました

はじめに

GS2-Account はアカウント管理をするためのマイクロサービスで、匿名アカウントの発行や認証機能を持ちます。
匿名アカウントの引き継ぎ情報の管理機能も用意されています。

OpenID Connect とは Twitter が提唱した OAuth を標準化した技術で、認可処理にかかわるフローを標準化しています。
現在では Google, Apple, Facebook, Yahoo, LINE などさまざまな会社の認証基盤が OpenID Connect に準拠した認可の仕組みを提供しています。

機能追加の背景

GS2-Account が持つ引き継ぎ情報の管理機能は OpenID Connect をはじめとした認証基盤などのもつユーザーIDと、ユーザーしか知り得ないパスワードの組み合わせを匿名アカウントに紐づけておくことで
将来的にスマートフォンを機種変更した時などに、OpenID Connect をはじめとした認証基盤の認証結果を使用して匿名アカウントの認証情報を復元できるようにする仕組みを提供しています。

1つの匿名アカウントに複数の認証基盤のアカウント情報を関連づけられるようになっており、その識別にはスロット番号を使用します。
スロット番号と認証基盤の関連づけはゲーム側で任意に設定し、運用することを想定しており、ユーザー識別子とパスワードとなる文字列の組み合わせさえあれば OpenID Connect であろうが、メールアドレスとパスワードだろうがなんでもいいというスタンスでした。

一方で、引き継ぎ処理を実装するにあたって、多くの場合 Google のアカウントと、Apple のアカウントで引き継ぎを行えるようにすることが一般的で、この一般的なケースを実装しようと思った時に認可フローは GS2 のサポート外となっており、実装の手間が発生するのが課題でした。

追加された機能の詳細

OpenID Connect の認証基盤が発行したIDトークンを使用して引き継ぎ情報の登録・引き継ぎの実行が直接行えるようになりました。
GS2-Account の新機能としては、以下が追加されました。

  • OpenID Connect の認可ページURLの生成
  • OpenID Connect のコールバック受付処理
  • OpenID Connect のIDトークンの発行処理
  • ユーザー識別子 + パスワード の代わりに OpenID Connect の IDトークンを使用した引き継ぎ情報の登録・引き継ぎ実行処理

マスターデータ

これまで GS2-Account にはマスターデータは存在しませんでしたが、OpenID Connect の認証サービスとスロット番号を関連づけるためのマスターデータを登録できるようになりました。

マスターデータはスロット番号ごとに定義し、OpenID Connect Discovery のスペックURLを指定することで認証基盤の種類を設定します。

Google であれば

https://accounts.google.com/.well-known/openid-configuration

Apple であれば

https://appleid.apple.com/.well-known/openid-configuration

Microsoft であれば

https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration

Facebook であれば

https://www.facebook.com/.well-known/openid-configuration

となります。

このほかに認証基盤で発行されたクライアントIDとクライアントシークレットを設定します。

コールバックハンドリング

認証基盤で認証処理が終わったあとのコールバックハンドリングと、認可コードからIDトークンを発行するまでの処理も提供しています。
そのほか代表的なサービスとして Firebase Authenticator などもありますが、これらのサービスを通して受け取ったIDトークンを使って引き継ぎ情報の処理も可能ですので、Firebase Authenticator を使わなくてもIDトークンがとれるんだ と思っていただければ大丈夫です。

ここからしばらくは IDトークンを受け取るまでの処理の流れのため、GS2-Account で IDトークン取得を行わない場合は読み飛ばしてOKです。

認証基盤には以下のコールバックURLを設定してください。
この値はマネージメントコンソールでも確認が可能です。

https://account.{region}.gen2.gs2io.com/{ownerId}/{namespaceName}/type/{type}/callback

プレースホルダを埋めた状態のサンプル値は以下です

https://account.ap-northeast-1.gen2.gs2io.com/aAbBcCdD-project/namespace-0001/type/0/callback

認証処理

(await gs2.Account.Namespace(
    namespaceName
).Me(
    gameSession
).GetAuthorizationUrlAsync(
    type
)).AuthorizationUrl

これで認可ページのURLを取得できます。WebView を使用してこのURLを開いてログインします。

ログインが完了すると、あらかじめ認証基盤に登録したコールバックURLに遷移し、コールバックURLは受け取った認可コードを使用してIDトークンを発行します。
IDトークンの発行が完了すると、以下のURLに遷移します。

https://account.{region}.gen2.gs2io.com/{ownerId}/{namespaceName}/type/{type}/done?id_token={idToken}

WebView のページ遷移イベントをハンドリングして、/done に遷移したらURLの ?id_token= に続く IDトークンを取り出せば、認証プロセスは終了です。

以下に GitHub - gree/unity-webview を使用した実装例を示します。

    public static async UniTask<string> OpenAuthentication(
        WebViewObject webView,
        Gs2Domain gs2,
        string namespaceName,
        IGameSession gameSession,
        int type
    ) {
        string idToken = null;
        webView.Init(
            separated: true,
            ld: url =>
            {
                if (new Uri(url).LocalPath.EndsWith("/done")) {
                    var codeField = new Uri(url).Query.Replace("?", "").Split("&").Select(v => new KeyValuePair<string,string>(v[..v.IndexOf("=", StringComparison.Ordinal)], v[(v.IndexOf("=", StringComparison.Ordinal)+1)..])).FirstOrDefault(v => v.Key == "id_token");
                    idToken = Uri.UnescapeDataString(codeField.Value);
                    webView.SetVisibility(false);
                }
            }
        );
        webView.LoadURL(
            (await gs2.Account.Namespace(
                namespaceName
            ).Me(
                gameSession
            ).GetAuthorizationUrlAsync(
                type
            )).AuthorizationUrl
        );
        webView.SetInteractionEnabled(true);
        webView.SetVisibility(true);

        await UniTask.WaitWhile(() => idToken == null);

        return idToken;
    }

引き継ぎ情報の登録

    var result = await gs2.Account.Namespace(
        namespaceName: "namespace-0001"
    ).Me(
        gameSession: GameSession
    ).TakeOver(
        type: 0
    ).AddTakeOverSettingOpenIdConnectAsync(
        idToken: "id-token"
    );

引き継ぎの実行

    var result = await gs2.Account.Namespace(
        namespaceName: "namespace-0001"
    ).DoTakeOverOpenIdConnectAsync(
        type: 0,
        idToken: "id-token"
    );

    var item = await result.ModelAsync();
    var userId = item.UserId;
    var password = item.Password;

非同期処理とリトライと冪等性

今回の記事は普段の GS2 のアップデート告知とは少し毛色が異なり、技術的なトピックを扱うエントリーです。

gs2.hatenablog.com

こちらで告知した消費アクションの分岐処理を実装するにあたって、どのようなアプローチで課題に向き合ってきたのかを解説しようと思います。

非同期処理とリトライ

まずは、非同期処理とリトライについて考えてみましょう。

非同期処理とは?

API を呼び出すと、処理の結果が返ってくる。処理の途中でエラーが発生したらエラーが返ってくる」というのが同期処理です。
この場合、エラーハンドリングは呼び出し元に委ねられますので、比較的シンプルに処理を行うことができます。

一方で、非同期処理とはどういうものか?というと

API を呼び出すと、処理を動かし、処理IDを応答する」「処理IDを指定して完了を通知」「処理IDを指定して処理結果を取得」
というように呼び出し元へ処理を一旦返すような処理を行います。

「なんで実装が大変そうなのに、そんなややこしいことをするの?」というと、設計者のさまざまな思惑があるわけですが、
一番一般的なのは「処理にかかる時間が長くて、APIの応答をすぐに返せない」ケースでしょう。
近年LLMを使った画像生成や音楽生成、動画生成のような技術が出てきていますが、これらはまさに処理に時間がかかるのでAPIの応答まで数分握り続けるというのは現実的ではないケースがあり、非同期処理を導入するメリットがあります。

GS2 においては少し事情が違っていて、ゲームを複数のデバイスでプレイしていたとしても「操作しているデバイスに限らず、全てのログイン中のデバイスにも結果を反映できるように」という意図があります。
つまり「デバイスAでクエスト開始APIを呼び出す」「処理の完了通知はデバイスAだけでなく、デバイスBにも通知」「デバイスA,Bともにクエストが開始されたことを認識できる」という意図で非同期処理を軸とした設計をしています。

リトライ

さて、次にリトライについてです。
同期処理の場合、処理時間が長くなるとタイムアウトするかもしれないといった、イレギュラーなケースをなるべく無くすために、すぐに結果を返したくなるインセンティブが強いため、エラーが発生したらエラー内容をすぐに結果として返したくなります。
一方で、非同期処理の場合は(UXが許す範囲で)多少時間がかかっても実装上はそれほど困らないという特性が加わります。

結果として、非同期処理の中で自動的にリトライして、クライアントにエラー応答を返すケースを減らしてあげる試みがしやすくなります。
しかし、同期処理にしても、非同期処理にしてもリトライは真面目につくるのは結構難しいのです。

リトライの難しさとは?

GS2 はゲーム用途に特化したサーバーを提供する BaaS ですが、ゲーム開発者は普段ローカルリソースを触ることが多いため、エラーハンドリングがとにかく苦手です。いや、そもそもエラーハンドリングが得意な人なんていないです。
通信処理を隠蔽したり、リトライを隠蔽したりして、なるべく「開発者体験を良くしよう」というのもGS2の目指すところですので、リトライについてもなるべく私たちのレイヤーで処理したいと考えています。

エラーの種類

リトライを考えるにあたって、エラーには大きく2種類あることを認識する必要があります。
1つ目は「リトライする価値のあるエラー」2つ目は「リトライする価値のないエラー」です。

たとえば「アイテムの所持数量が足りなくてアイテムを消費できなかった」というエラーはリトライをする価値がありません。
しかし「データベースが100ms以上応答しなかった」というのはリトライする価値のあるエラーです。

リトライする価値のあるエラーが発生した時に、内部的にリトライをしてあげることでエラーハンドリングをする必要性が減って開発者体験がよくなります。

本当にその処理リトライして大丈夫?

世の中がこんなに簡単だったら楽なのですが、現実はそんなに甘くありません。
たとえば「アイテムの所持数量を+1」するというAPIを呼び出したとしましょう。
この処理を実行した時「APIが500ms以上応答しなかったためタイムアウトした」という状況が発生したとしましょう。

この時「アイテムは加算されているけど、応答が間に合わなかった」という場合を考慮しなければなりません。
何も考えずにリトライをするとアイテムが +2 されてしまう可能性が生まれてしまいます。

冪等性

リトライをする時の強い味方が「冪等性」です。
同じ処理を繰り返し実行しても結果が反映されないという特性です。

この同じ処理というのは「同じリクエスト内容」という意味ではなく「同じコンテキストで呼び出された処理」を指します。
リトライとは「同じコンテキストで処理を再度呼び出す」ということですので、この場合に多重に処理されないように設計する必要があります。

HTTPの標準に「Idempotency-Key」というリクエストヘッダを追加しようという動きがある程度には近年標準的に実装するべきだという認識が高まっているのがこの技術です。
意図するところは「Idempotency-Key」が同じ値を持つリクエストは同じコンテキストで呼び出されているので、多重処理しないようにしなければならない ということです。

冪等性の実現方法

まずロック機構が必要になります。同一 Idempotency-Key のリクエストは並列で動いてはいけません。
処理の結果「正常」「リトライをする価値のないエラー」の場合は Idempotency-Key とリクエストパラメーターのハッシュ値をキーとして結果を一定期間保存しておく必要があります。
処理の開始前に Idempotency-Key とリクエストパラメーターのハッシュ値で作ったキーの処理が過去に終わったことがないかを確認して、過去に実行した結果が保存されている場合はその結果を応答する必要があります。
処理の中で外部リソースを触る場合は、全て冪等性のある呼び出しを行う必要があります。

冪等性を実現するのは単純ではありません。ロック処理や結果の格納などコストもかかります。しかし、冪等性を担保することは非常に大切です。
このエントリーを最後まで読む頃には「実装は大変だったし、お金もかかるんだろうけど GS2 の API に冪等性があってよかったね」と思っていただけているんじゃないかと思います。

実際に 消費アクションの分岐処理を実装するにあたって ここまで説明してきたことがどのように役立ったのか

GS2 はマイクロサービスで設計しており、現在はプレゼントボックスや、アイテムボックス、ガチャといった機能を40を超えるマイクロサービスとして提供しています。

今回追加された機能がどういうものだったのかを説明します。

GS2では「課金通貨を300減らして」「ガチャを引く」というような関連あるプレイヤーのリソース操作を「トランザクション」と呼んでいます。
そして、これらの処理は一般的に複数のマイクロサービスに跨った処理を行うことになります。

「課金通貨の残高を管理するシステム」と「ガチャの抽選をするシステム」は全く別のシステムです。
そして「ガチャを引く」と「キャラクターを入手する」処理が連鎖して非同期処理で動きます。このことを念頭にこれからの説明を読んでください。

トランザクションとは「課金通貨の残高を管理するシステムに通貨の残高を減らす」「ガチャの抽選をするシステムに抽選させる」という処理をひとまとめにして処理します。
この処理がどこかで失敗したときに「トランザクション処理をリトライ」できるようにすることで、開発者はどのシステムを呼び出すといった複雑な思考から解放されます。

トランザクションをリトライする時の課題

一方で複数のシステムを整合性を維持したまま処理させるのはなかなか困難です。マイクロサービスが難しいと言われる理由の99%がここにあります。

「課金通貨の残高を管理するシステムに通貨の残高を減らす」「ガチャの抽選をするシステムに抽選させる」トランザクションのエラーパターンを考えてみましょう。

  • 課金通貨の残高を管理するシステムに通貨の残高を減らす 時に残高が足りない
  • 課金通貨の残高を管理するシステムに通貨の残高を減らす 処理がタイムアウト(すでに減らす処理は終わっていた)
  • 課金通貨の残高を管理するシステムに通貨の残高を減らす 処理がタイムアウト(まだ減らす処理は終わっていない)
  • ガチャの抽選をするシステムに抽選させる 処理がタイムアウト(すでに抽選処理は終わっていた)
  • ガチャの抽選をするシステムに抽選させる 処理がタイムアウト(まだ抽選処理は終わっていない)

ざっとこのくらいのパターンが出てきます。

「課金通貨の残高を管理するシステムに通貨の残高を減らす 時に残高が足りない」
トランザクションの実行前に弾いておくべきケースなので、横に置いておきます。

他のエラーの中で一番厄介なケースは

  • 課金通貨の残高を管理するシステムに通貨の残高を減らす 処理が正常に終わった
  • ガチャの抽選をするシステムに抽選させる 処理がタイムアウト(まだ抽選処理は終わっていない)

のようなケースです。
このトランザクションを正しくリトライするためには

「課金通貨の残高を管理するシステムに通貨の残高を減らす処理」はスキップして「ガチャの抽選をするシステムに抽選させる」部分だけリトライする必要があります。

普通に作っていたら頭が痛いです。
しかし、トランザクションの中で呼び出す処理に冪等性があればリトライ時は何も考えずにそれぞれの処理を呼び直して、両方成功するまでリトライすればよくなり、シンプルになります。

分岐と冪等性

さて、ここまではもともと GS2 では実現できていた処理でした。
今回実装した機能は、トランザクションの中で処理を分岐する仕組みです。

もう少し具体的にいうと「ガチャチケットを持っている場合」は「ガチャチケットを消費」して、持っていなければ「課金通貨を300消費」して「ガチャを引く」というトランザクションです。

この場合、トランザクションの中で分岐条件に影響を与える変化が生じます。
「ガチャチケットを1枚持っている」状況で、このトランザクションを実行したとして「ガチャチケットを消費」した後で「ガチャを引く」のに失敗したのでリトライするとします。
リトライをした時に「ガチャチケットを持っている場合」の条件は満たさなくなっています。それでも「ガチャチケットを持っている場合」の判定処理に冪等性があり「ガチャチケットを持っている」という結果を得ることができます。
続けて「ガチャチケットを消費」を実行しますが、こちらも冪等性によって再度消費されることはありません。
最後に「ガチャを引く」部分だけリトライすることができます。

まとめ

  • 非同期処理は自動的にリトライして開発者体験をあげやすい
  • リトライ処理は冪等性があると実装が簡単になる
  • 冪等性の実現はロックの取得や、結果の保存などデータベースの負担が増えてお金がかかるけどやる価値がある

自社のシステム以外と連携する必要がある時に、冪等性のないAPIを使う必要がある時にとにかく困ります。
マイクロサービス云々は置いておいて、世の中全てのシステムに冪等性が担保される時代になるよう強く望みます。

ギルドマスターが引退した時にギルドメンバーからギルドマスターを選出する仕組みを追加しました

はじめに

GS2-Guild はゲームプレイヤー同士で協力プレイをするためのチームを結成するためのマイクロサービスです。

機能追加の背景

ギルドマスターが一人しかいない状況で、ギルドマスターがゲームを引退してしまった時、ギルドメンバーは脱退する以外の選択肢がありませんでした。
これに新しいギルドマスターをギルドメンバーから選出することで、ギルドの運営を継続できるようにしました。

追加された機能の詳細

ギルドのマスターデータにギルドマスターが引退したと判断するまでの日数を指定できるようになりました。

    var domain = gs2.Guild.Namespace(
        namespaceName: "namespace-0001"
    ).Guild(
        guildModelName: "guild-model-0001",
        guildName: "guild-0001",
        userId: null
    ).LastGuildMasterActivity(
    );
    var item = await domain.ModelAsync();

docs.gs2.io

このAPIでギルドマスター権限を持つプレイヤーが最後にログインした日時を取得できるようになりました。

ギルドマスターが一定期間ログインしていない状況で以下のAPIが呼び出せるようになりました。

    var domain = gs2.Guild.Namespace(
        namespaceName: "namespace-0001"
    ).Guild(
        guildModelName: "guild-model-0001",
        guildName: "guild-0001",
        userId: null
    );
    var result = await domain.PromoteSeniorMemberAsync(
        accessToken: 
    );
    var item = await result.ModelAsync();

docs.gs2.io

このAPIを呼び出すと、ギルドメンバーの中で最も古参のプレイヤーが新しいギルドマスターに昇格します。

その他の詳細な仕様は以下のドキュメントを参照してください。

docs.gs2.io

検証アクションと消費アクションが区別されるようになりました

はじめに

GS2 においてプレイヤーの操作は最終的に「何かのリソースを消費」し「何らかのリソースを得る」という処理として表現されます。
何かのリソースを消費する処理を「消費アクション」と呼び、何かのリソースを得る処理を「入手アクション」と呼び、一連の処理を「トランザクション」と呼んでいます。

機能追加の背景

これまで「アイテムの所持数量が n 個であることを確認」「ミッション x が達成済みであることを確認」というようなトランザクションアクションは「消費アクション」に分類していました。

gs2.hatenablog.com

こちらの対応を進めるにあたって、If 文の condition に指定できる消費アクションと、指定できない消費アクションを区別する必要が生じました。

変更された内容

これまで「消費アクション」に分類していた中で、プレイヤーのパラメーターの内容を検証するアクションを全て「検証アクション」という新しい分類に区別することにしました。

これまでトランザクションを発行するマイクロサービスのマスターデータは 「consumeActions」「acquireActions」が定義できるのが定番のフォーマットでしたが、これからはトランザクションを発行可能かを判定する検証処理を格納する 「verifyActions」が追加されました。

互換性を維持するために引き続き検証アクションに分類されたトランザクションアクションも「consumeActions」に記述が可能です。
しかし、今日より推奨する記述場所は「verifyActions」になります。

この変更に伴い、マネージメントコンソールのマスターデータエディタでは、あたらしく「検証アクション」に分類されたアクションは「consumeActions」のプルダウンでは選択できなくなります。
すでに consumeActions に設定されている検証アクションについては引き続き設定が可能ですが、一度別のアクションに変更すると再度設定することはできなくなります。

GS2-Deploy を使ったオーケストレーションでは特に今回の影響を受けず、今後も consumeActions に検証アクションを設定する記法が利用し続けることができます。

消費アクション内で分岐処理が記述できるようになりました

はじめに

GS2 においてプレイヤーの操作は最終的に「何かのリソースを消費」し「何らかのリソースを得る」という処理として表現されます。
何かのリソースを消費する処理を「消費アクション」と呼び、何かのリソースを得る処理を「入手アクション」と呼び、一連の処理を「トランザクション」と呼んでいます。

機能追加の背景

これまで消費アクションは静的な内容を記述することしかできませんでした。
たとえば、「課金通貨を300消費」して「単発ガチャを引ける」トランザクションを発行する「ゲーム内ストアの商品」があるとします。

ここにガチャチケットの仕様を追加したとしましょう。
「ガチャチケットを1枚消費」して「単発ガチャを引ける」トランザクションを発行する「ゲーム内ストアの商品」を追加することになります。

この仕様の課題は、ゲーム内でガチャを引く時に「ガチャチケットを使うか」「課金通貨を使うか」で購入する商品を呼び分ける必要があることです。

追加された機能の詳細

消費アクションに「Gs2Distributor:IfExpressionByUserId」が追加されました。
この消費アクションは「condition」に指定した検証アクションの検証結果によって「trueActions」に指定した消費アクションか「falseActions」に指定した消費アクションか、どちらかが実行されます。

先ほどのガチャチケットと課金通貨の例に例えるなら以下のようになります。

Name: ガチャを引く商品
ConsumeActions:
  - If:
      Condition: ガチャチケットの所持数量が1以上
      TrueActions: 
        - ガチャチケットを1枚消費
      FalseActions:
        - 課金通貨を300消費
  AcquireActions:
    - 単発ガチャを引く


これでゲーム内からは「ガチャを引く商品」を購入すると、ガチャチケットもしくは課金通貨を消費して単発ガチャを引けるようになります。

ギルドへの参加を拒否するユーザーIDリストを設定できるようになりました

はじめに

GS2-Guild はゲーム内のギルド機能を実現するための機能です。

機能追加の背景

ギルドから除籍したプレイヤーが再度ギルドに参加申請が出せるため、UXが良くない状況がうまれてしまっていました。

追加された機能の詳細

ギルドのパラメーターに ignoreUsers が追加されました。
ここにユーザーIDを設定すると、ギルドへの参加リクエストが失敗するようになります。

これにより、除籍したプレイヤーをここに加えることで発生していた問題を回避することが可能となりました。

GS2のログインアカウントを多要素認証で保護できるようになりました

はじめに

多要素認証とは、ログインIDとパスワードに加えて、追加のなんらかの情報に基づいて本人からのアクセスであることを確認することで、アカウントの保護を強固にする仕組みです。
一般にワンタイムパスワードが用いられることが多く、GS2 の多要素認証でもワンタイムパスワードを採用しています。

機能追加の背景

近年「パスワードリスト攻撃」と呼ばれる、サービスから漏洩したユーザーID・パスワードを使って他のサービスへのログインを試みる不正アクセスが横行しています。
このような攻撃への耐性を強めるために導入が進んでいるのがワンタイムパスワードです。

追加された機能の詳細

ワンタイムパスワードは30秒ごとに新しい6桁の数字からなるコードです。
このワンタイムパスワードGoogle Authenticator や 1Password をはじめとしたさまざまなツールを使って管理・確認が可能です。


GS2においてはマネージメントコンソールでマイアカウントを選択し「多要素認証の追加」から登録手続きを開始できます。

すると、QRコードが表示されますので、こちらのQRコードワンタイムパスワードを管理するアプリケーションで読み取ることで、ワンタイムパスワードを確認できるようになります。

パスワード管理ツールで表示されたワンタイムパスワードをフォームに入力することで、登録手続きは完了です。

次回ログインする時にはワンタイムパスワードの入力が求められるようになります。
これでアカウントを保護することができるようになります。

(C) Game Server Services, Inc.