メインコンテンツに移動
デベロッパーのためのクラウド活用方法

Amazon DynamoDB で作るサーバーレスなゲーム用フレンドマイクロサービス

2022-09-02 | Author : 石井 宇大

はじめに

ゲームな皆さんこんにちは、Game Developers Relations の石井宇大こと Taka (@takahiroishii_) です。

この投稿では 前回 に続き、ゲームバックエンドサーバーをマイクロサービス化させ、負荷分散や開発速度に繋げるべく、フレンドのマイクロサービスという例を取り上げて、今回も具体的にバックエンドコードの書き方までサンプルを使って解説したいと思います。

シリーズ化しつつあるこのゲーム用サーバーレスマイクロサービスですが、是非今後もこんなのが見たい等、リクエストがありましたらハッシュタグ「#AWSウェブマガジン」でフィードバックください !

ご注意

本記事で紹介する AWS サービスを起動する際には、料金がかかります。builders.flash メールメンバー特典の、クラウドレシピ向けクレジットコードプレゼントの入手をお勧めします。

*ハンズオン記事およびソースコードにおける免責事項 »

builders.flash メールメンバー登録

毎月提供されるクラウドレシピのアップデート情報とともに、クレジットコードを受け取ることができます。 
今すぐ特典を受け取る »

はじめに

前回同様、今回もよりアドバンスドで実用的な Amazon DynamoDB の特徴を生かしたマイクロサービスを目指します。ゲームのバックエンドのサーバーレス化というテーマに入門したばかりという方は是非 AWS GameKit もチェックしてください !

また今回のサンプルもすでにお持ちの AWS アカウントに短時間でデプロイできる AWS Cloud Development Kit (AWS CDK) を採用しています。本記事を読みサンプルをダウンロードしてみて、実際に手元で確認してみてください !

サンプルコードはこちら »

ゲームにおけるフレンド機能

マルチプレイヤーゲームが中心の昨今の流行からも、フレンド機能はゲーム内に不可欠な機能の 1 つになっています。

フレンド機能とは本来マッチメイキングなどを通して不特定多数のプレーヤーと一緒に遊ぶ所を、各々のプレーヤーがフレンド申請を行い、双方の了解を得てフレンド状態になったプレーヤー同士のみ特別な遊び方ができるようにする機能の事を指しています。例をあげると、フレンドとの閉鎖された空間で遊ぶフレンドマッチや、フレンド間でのみ行うフレンドチャットなどがあるでしょう。

フレンド機能はギルド機能などと同じくゲームにソーシャルな部分を組み込む事で、ゲームの活性化や UGC (User-Generated Content) と呼ばれるプレーヤーが独自で新しい遊び方を見つけ共有するなど、本来想定していた以上のポテンシャルをゲームに与えてくれるとても大事な機能と言えます。

フレンド機能は基本的にフレンド情報の管理システムの事を指します。フレンド情報とは、どのプレーヤーとどのプレーヤーが今どういった状態なのか、具体的にはこちらの図のようにプレーヤー A とプレーヤー B がフレンド関係にある、プレーヤー C はプレーヤー A からのフレンド申請を受けた状態にある、などといった情報です。

こういったフレンド情報を管理し、2 プレーヤーの状態を確認する API や、1 人のプレーヤーのフレンドリストを受け取る API などのを携えたものがゲームにおけるフレンド機能と言えるでしょう。そして前回のアチーブメント機能同様、完全に独立したフレンド機能は単独でスケールするマイクロサービスに向いているという事も分かると思います。

ソーシャルメディア等で必要になってくるグラフを利用したフレンド機能は、フレンドのリコメンデーション等が必要のない多くのゲームにおいては対象にならないので、この記事では単純なフレンド状態のみを管理する事にフォーカスします。

Diagram illustrating friend relationships and friend requests in a serverless application, showing user A and user B in a mutual friend relationship, and user A sending a friend request to user C. The diagram uses Japanese labels and visual icons to represent user connections.

フレンドのデータ

フレンド機能を実装するに辺りデータベース目線で大きく 2 種類のデータの所持方法があると言えます。一つはデータベースの中にはフレンドの関係の状態を示すデータが 1 つしかない関係基準のデータと、各プレーヤー毎に相手のプレーヤーに対してのフレンドの関係の状態を示すデータがあるプレーヤー基準のデータです。

前者の利点はデータに重複が無くデータの書き込みに必要なトランザクションが少なくなり、後者の利点はプレーヤー基準なのでプレーヤー毎のフレンドリストなどを取得する事が容易であり、またプレーヤー基準で機能する UI/UX との相性などもよい所があります。

今回のサンプルでは後者のプレーヤー基準のデータで作成し、本来なら数が多くなりがちなトランザクションを DynamoDB Streams を使っていかに少なく作る事ができるかを見ていきたいと思います。

Diagram comparing relationship-based and player-based data models for a friend microservice using DynamoDB serverless architecture, with labels and content in Japanese.

フレンドの状態 (state)とアクション

このフレンドマイクロサービスではフレンドの状態を 3 つの state として分けて考える事にします。今回はプレーヤー基準でデータを所持するのでそれぞれの state はそのプレーヤーの相手のプレーヤーに対する状態を指す事になります。以下がその 3 つの state です。

  1. Requested
  2. Pending
  3. Friends

「Requested」は プレーヤーが相手プレーヤーに対してフレンド申請を行い返事を待っている状態、「Pending」はプレーヤーが相手のプレーヤーからフレンド申請を受け取って返事を行うまでの状態、「Friends」はプレーヤーが相手のプレーヤーとフレンド状態でにあることをそれぞれ示します。

一見少なく見えるかもしれませんが、この 3 つのフレンドの状態 (state が無い状態も合わせて 4 つ) を使って以下の 4 つのプレーヤー主体のアクションをサポートする事ができます。

  1. Request : プレーヤーが特定の相手プレーヤーに向けフレンド申請を送る
  2. Accept : プレーヤーが特定の相手プレーヤーからのフレンド申請を承諾する
  3. Reject : プレーヤーが特定の相手プレーヤーからのフレンド申請を拒否する
  4. Unfriend : プレーヤーが特定の相手プレーヤーとのフレンド関係を解消する

更に今回はプレーヤー基準のデータとなるので、DynamoDB のテーブルは下記のようにシンプルにデザインしていきます。

Friend Table

PK : player_id SK: friend_id state last_updated
string : プレーヤーの ID string : 相手プレーヤーの ID フレンドの状態 タイムスタンプ

上記でも話したようにプレーヤー基準でデータ管理を行うと、一般的にはアクションを試みる度にトランザクションが必要になります。例えば Request というアクションをプレーヤー A が行う場合、プレーヤー A のデータには Requested を、プレーヤー B のデータには Pending をそれぞれ Atomic に書き込む事が必要となるのです。

もちろんトランザクションを使う事にシステム上問題は無く、普段この方法を課題と感じる開発者の方は少ないでしょう。一方でより柔軟にスケールするバックエンドをデザインしたいと考えシステムデザインを行うと、このトランザクション部分は必ずしも同期的である必要は無い事に気づきます。

つまりプレーヤー A がプレーヤー B にフレンド申請を行った時、プレーヤー B から見ると必ずしも同期的にフレンド状態が変わっている必要がないのです。もし AWS サービスを上手く活用して非同期的に行う事ができ、なおフレンドの状態管理が Atomic に行えるなら、プレーヤー基準でデータを管理してもトランザクションを極限まで減らし、より柔軟にスケールするフレンドサービスを作る事が可能です。

今回はそんな課題を DynamoDB Streams と 昨年リリースした AWS Lambda の機能である イベントソースフィルタリング を使う事で解消して行きたいと思います。なおイベントソースフィルタリングの詳しい説明に関しては こちらのブログ を参考にしてみて下さい。

アーキテクチャ

まずは全体のアーキテクチャです。全体図としては以下のようになります。構成は至ってシンプルですが State Handlers の部分のみ中身は複数の Lambda Function で構築されています。 このアーキテクチャを元に説明していきます。 以降は混乱を避ける為に、各アクションを行ったプレーヤーをプレーヤー A、その相手のプレーヤーをプレーヤー B として話します。申請時には申請を送ったプレーヤーがプレーヤー A、受け取ったプレーヤーがプレーヤー B となります。

state の流れ

各アクションの実行時にプレーヤー A とプレーヤー B のそれぞれの state がどのように変化するのか見ていきましょう。

プレーヤー A プレーヤー B
申請時 何も無し → Requested 何も無し → Pending
承認時 RequestedFriends PendingFriends
拒否時 Requested → 何も無し Pending → 何も無し
解消時  Friends → 何も無し Friends → 何も無し

この変化を確認しどう 2 人のプレーヤーの状態を非同期に変更して行くか考えましょう。

DynamoDB Streams とイベントソースフィルタリング

まずは事前準備として今回使う Friend Table で DynamoDB Streams を起動させるために新旧両イメージが表示される StreamViewType の NEW_AND_OLD_IMAGE という設定をしておきます。前回に引き続き本サンプルも AWS CDK を使っています。

const friendTable = new Table(this, "Friend", { tableName: friendTableName, ... // StreamViewType を明記する事で DynamoDB Streams を利用する事ができる stream: StreamViewType.NEW_AND_OLD_IMAGES, });

例えばフレンド申請時を例に考えると、アクションを起こすのはプレーヤー A なので最初にプレーヤー A の状態を書き換えます。DynamoDB の Friend Table 中にプレーヤー A の ID (player_id の値) とプレーヤー B の ID (friend_id の値)、そして Requested (state の値) の組み合わせで新規 item が作成されます。この時 DynamoDB Streams には下記のようなイベントデータが送られてきます。

{ "eventID": "380271641178688a170beb2fabd6c401", "eventName": "INSERT", "eventVersion": "1.1", "eventSource": "aws:dynamodb", "awsRegion": "us-east-1", "dynamodb": { "ApproximateCreationDateTime": 1659342437, "Keys": { "friend_id": { "S": "playerB" }, "player_id": { "S": "playerA" } }, "NewImage": { "friend_id": { "S": "playerB" }, "last_updated": { "N": "1659342437340" }, "player_id": { "S": "playerA" }, "state": { "S": "Requested" } }, "SequenceNumber": "23469600000000008634076814", "SizeBytes": 98, "StreamViewType": "NEW_AND_OLD_IMAGES" }, "eventSourceARN": "arn:aws:dynamodb:us-east-1:123456789012:table/Friend/stream/2022-07-27T07:25:17.774"}

申請時にはプレーヤーAの書き込みによって発生したイベントのみを指定の Lambda で受け取り、プレーヤー B の状態を書き換えたいのでこのイベントをイベントソースフィルタリングを使用してピンポイントに受け取ります。 以下が AWS CDK 内に定義された申請時イベント用のフィルター例です。

// 複数の Handler に対して使うイベントソースの共通プロパティを定義 const streamEventSourceProps: StreamEventSourceProps = { startingPosition: StartingPosition.LATEST, batchSize: 5, retryAttempts: 1, onFailure: stateHandleDLQ, reportBatchItemFailures: true, }; // player B に変更を行う Handler に紐付け requestStateHandler.addEventSource( // friendTable から流れる Streams にフィルターを付ける new DynamoEventSource(friendTable, { filters: [ // フィルターの中身を定義 FilterCriteria.filter({ eventName: FilterRule.isEqual("INSERT"), dynamodb: { NewImage: { state: { S: FilterRule.isEqual(State.Requested) }, }, }, }), ], ...streamEventSourceProps, }) );

このイベントソースフィルタリングでは、eventNameINSERT であり、stateRequested の時のみ requestStateHandler という Lambda Function が起動するように定義されています。つまり何度同じプレーヤーAからの申請のアクションが届いても、プレーヤー B のデータの書き換えは一度 (at least one) に抑えられ、入り口の冪等性を保った事になります。ここでの冪等性の確保は Amazon SQS の Standard Queues を使用した際のメッセージの重複の対応にもなるので、今回のサンプルのように入り口に SQS を配置し非常にスケーラブルなマイクロサービスを構築する事が容易にできます。

更に、今サンプルでは DLQ (Dead Letter Queue) も使用しているので、もし万が一 DynamoDB Streams によって起動した Handler がクラッシュしたり、何らかの理由で上手く処理されなかったとしても、DynamoDB Streams に送られたイベントは DLQ にSequenceNumber などのメタデータとして保存されます。実際の運営ではモニタリングをしつつ、予期せぬ出来事が起きてもこの DLQ を使って再処理を行う事ができるのでログの解析などの運営負荷を軽減する事ができます。

非同期の特性

ここまでは DynamoDB Streams とイベントソースフィルタリングによるトランザクション無しの冪等性を保った非同期処理への変換を説明してきました。ここからは非同期ならではの考え方と、特性を理解し細かな Edge Case の対応を考えていきます。今回非同期で走る処理はアクションをされた相手プレーヤー、プレーヤー B のデータ変更です。アクション毎にこのデータ変更に Edge Case がないか確認していきましょう。

まずは解消時です、解消時にはプレーヤー B が行うデータ変更は以下のように ConditionExpression を利用した、現在の stateFriends の時のみ Delete といったロジックになります。

const rejectParam: DocumentClient.DeleteItemInput = { TableName: friendTableName, // PK はプレーヤー B、SK はプレーヤー A Key: { "player_id": playerBId, "friend_id": playerAId, }, // state=Friends というコンディションの時のみ Delete ConditionExpression: "#state = :friends", ExpressionAttributeNames: { "#state": "state", }, ExpressionAttributeValues: { ":friends": Friends, }, };

これが失敗する Edge Case は、state の流れからも確認できるように、すでにプレーヤー B 主体で解消のアクションを行ったか、DynamoDB Streams から同じイベントが複数回来た場合のみになります。

つまりどちらであってもレースコンディションに負けた事となり、冪等性を保つためにも解消時の Edge Case (ConditionalCheckFailedException) は無視でき、エラーハンドルは以下のようになります。

const rejectParam: DocumentClient.DeleteItemInput = { TableName: friendTableName, ... // 上記のパラメータの内容 }; try { await db.delete(rejectParam).promise(); } catch (e: any) { if (e.name == "ConditionalCheckFailedException") { // 想定しているエラーなのでここを無視する。 return; } throw e; }

拒否時や承認時も解消時と同じ流れになるので省きます。最後に申請時です、申請時にはプレーヤー B が行うデータ変更はデータが何も無い状態から Pending のデータの入力になります。条件は何も無いことになるので以下のような ConditionExpression を使います。

const friendParam: DocumentClient.PutItemInput = { TableName: friendTableName, Item: { "player_id": playerBId, "friend_id": playerAId, state: Pending, last_updated: timeStamp, }, // 申請時にはプレーヤー B のデータが無い事を条件にする ConditionExpression: `attribute_not_exists(player_id)`, };

これが失敗する Edge Case は、すでにプレーヤー B がプレーヤー A に対して申請を行った後になります。この場合問題なのがプレーヤー A とプレーヤー B の両方が Requested になっている可能性があるという事です。両プレーヤーが Requested になってしまうと承認が不可能になるので、この Edge Case だけは無視する事ができません。

この課題を解決する方法はいくつかあります。一つは上の ConditionExpression に OR を足しプレーヤー B の state が Requested 状態も含む事です。この方法だと後から申請した方が勝ち、先に申請したプレーヤーからは承認待ちの用に見える状態になります。もう一つの方法が上記の ConditionExpression から出るエラー ConditionalCheckFailedException 後にトランザクションを使い、両プレーヤーの state を Friends に更新する方法です。後者の方法では先に申請したプレーヤーには承認されフレンドになったように見えるので自然です。

本サンプルではこの後者の方法を採用しました。以下がトランザクションの中身になります。

const updateReceiverParams: DocumentClient.TransactWriteItem = { Update: { TableName: friendTableName, Key: { "player_id": playerAId, "friend_id": playerBId, }, // プレーヤー A も申請をしているので state=Requested ConditionExpression: "#state = :requested", // 両プレーヤーが申請しているので Friends に変更 UpdateExpression: "SET #state = :friends, #last_updated = :last_updated", ExpressionAttributeNames: { "#state": "state", "#last_updated": "last_updated", }, ExpressionAttributeValues: { ":requested": Requested, ":friends": Friends, ":last_updated": timeStamp, }, }, }; const updateRequesterParam: DocumentClient.TransactWriteItem = { Update: { TableName: friendTableName, Key: { "player_id": playerBId, "friend_id": playerAId, }, // プレーヤー B も申請しているので state=Requested ConditionExpression: "#state = :requested", // 両プレーヤーが申請しているので Friends に変更 UpdateExpression: "SET #state = :friends, #last_updated = :last_updated", ExpressionAttributeNames: { "#state": "state", "#last_updated": "last_updated", }, ExpressionAttributeValues: { ":requested": Requested, ":friends": Friends, ":last_updated": timeStamp, }, }, };

このトランザクションが失敗する理由は大きく 2 つあり、一つは TransactionConflict という、同時に同じアイテムに対して複数のトランザクションが行われているケースと、もう一つは条件である state = Requested が失敗しているケースです。前者のケースはリトライにより解決でき、後者のケースはこれまでに話したレースコンディションと似た理由で無視する事ができます。

このように非同期に処理を行う事で Edge Case への対応が必要な場面は訪れます。ですが、上手く ConditionExpression を活用する事で対応できるのも DynamoDB の面白い所だと思います。

まとめ

今回はフレンド機能、特にフレンドの状態管理の部分を DynamoDB Streams とイベントソースフィルタリングを上手く活用してマイクロサービスを完成させました。このサンプルには他にも基本的なフレンド情報の読み取りができる Read Handler も含まれます。テストする際にも簡単に SQS に Message を送る事で一連の流れを確認できるようになっていますので是非お試しください。

前回のサンプルも含め是非 DynamoDB と Lambda を使ったスケーラブルなゲームバックエンドを体感して、これからのゲーム開発にお役立てください !

筆者プロフィール

石井 宇大
アマゾン ウェブ サービス ジャパン合同会社
ゲームデベロッパーリレーションズ

カナダ🇨🇦 で 10 年以上生活していたゲームで遊ぶのも作るのも好きな元ゲームエンジニア。趣味は奥さんと季節を感じながら美味しい物を食べ大好きな赤🍷 を嗜む事。
普段は楽しいゲーム作りに繋がるよう、様々なソリューションを提供するお仕事をしています。

A man standing outdoors in front of a tree, wearing a white and gray striped t-shirt, smiling at the camera.