はじめに
この記事では、AWSの機能を用いてUnityでリアルタイムマルチプレイを実現するための実装について解説します。
具体的には、AWS AppSync の AWS AppSync Events という機能を用いてWebSocket通信を構築します。
サンプルコードによる動作確認まで行いますので、良かったら実践していただけると幸いです。
前提条件
- AWSアカウントを持っていること
1. 概念解説
1-1. WebSocket通信とは?
WebSocketは、クライアント(例:Unityアプリ)とサーバー間で、双方向かつリアルタイムにデータをやり取りできる通信プロトコルです。
HTTPは1回の通信ごと接続・切断するのに対して、WebSocketは一度接続が確立されると、サーバーからクライアントへも自由にデータを送信できるため、チャットやオンラインゲームなど即時性が求められるアプリケーションに最適です。
HTTPとWebSocketの違い
特徴 | HTTP | WebSocket |
---|---|---|
通信方式 | リクエスト/レスポンス型 | 双方向・常時接続 |
接続の維持 | リクエストごとに接続/切断 | 一度接続したら維持 |
サーバーからの通知 | 不可(ポーリングが必要) | 可能(プッシュ通知) |
通信の効率 | オーバーヘッド大きい | 軽量・低レイテンシ |
主な用途 | Webページ、API | チャット、ゲーム、IoTなど |
通信イメージ
HTTP通信の流れ
WebSocket通信の流れ
クライアント同士のデータの流れ(WebSocket経由)
この図のように、各クライアントはWebSocketでサーバー(AppSync Events)に接続し、自分の操作や状態を送信します。サーバーは受け取ったデータを他のクライアントにリアルタイムで配信することで、マルチプレイの同期が実現されます。
サブプロトコルについて
サブプロトコルはWebSocketにて「どんなルールで会話するか」を最初に決める仕組みです。
HTTPでいうヘッダーやリクエストボディのようにリクエストに情報を付加させてサーバに送り、その後はサブプロトコルに載せた会話のルールを決めてから話す、といったイメージです。
AppSync Eventsではサブプロトコルの設定値がやや特殊で、実装時に気を付けるポイントの一つになります。
1-2. AWS AppSync Eventsとは?
AWS AppSync Eventsは、サーバーレスでスケーラブルなWebSocket APIを簡単に作れる新機能です。
従来のAppSync(GraphQLベース)よりも、イベント駆動型のリアルタイム通信に特化しています。
また、従来のAppSync(GraphQLベース)と同様に、LambdaなどAWSサービスとの連携や、認証やアクセス制御も標準で対応しています。
AppSync Eventsと従来のGraphQL APIの違い(比較表)
特徴・用途 | AppSync GraphQL API | AppSync Events API |
---|---|---|
主な用途 | データ取得・更新(CRUD) | イベントのリアルタイム配信 |
通信方式 | HTTP/HTTPS(クエリ/ミューテーション) WebSocket(サブスクリプション) |
WebSocket(イベント駆動) |
スキーマ設計 | 必須(GraphQLスキーマ) | 不要(イベント名のみ) |
学習コスト | やや高い | 低い(直感的) |
認証・認可 | 多様な認証方式対応 | 多様な認証方式対応 |
代表的な操作 | GraphQLのクエリ・ミューテーション、 サブスクリプション |
イベントのpublish/subscribe |
WebSocketプロトコルの概要
AppSync EventsのWebSocket通信は、クライアントとAppSyncリアルタイムエンドポイント間で、以下のような流れでやり取りが行われます。
各ステップの解説
-
WebSocket接続確立:
クライアントがAppSyncリアルタイムエンドポイントにWebSocketで接続します。 -
接続初期化(任意):
必要に応じてconnection_init
メッセージを送信し、AppSyncからconnection_ack
の応答を受け取ります。 -
キープアライブ(ka):
AppSyncは定期的に「ka」メッセージを送信し、クライアントはこれを受信して接続が維持されているか確認します。 -
サブスクライブ登録:
クライアントはsubscribe
メッセージで購読したいチャネルを指定し、subscribe_success
で購読開始が通知されます。 -
イベント受信:
サーバー側でイベントが発生すると、クライアントにリアルタイムでevent
メッセージが届きます。 -
サブスクライブ解除:
クライアントはunsubscribe
メッセージで購読を解除し、unsubscribe_success
で解除完了が通知されます。 -
イベント公開(publish):
クライアントはpublish
メッセージでイベントを送信し、publish_success
で送信完了が通知されます。 -
切断:
すべてのサブスクリプションを解除し、未送信メッセージがなければWebSocket接続を切断します。
2. 実装
2-1. AWS AppSync Events APIの構築
AWSマネジメントコンソールでAppSyncの新規API作成にて、「Event API を作成」を選択します。
API名を入力して、「作成」を押下します。(なんとAPI構築はこれだけでOK!)
APIが作成できたら、設定タブから以下の3つの情報を控えます。
- DNS エンドポイント(HTTP)
- DNS エンドポイント(リアルタイム)
- APIキー
2-2. Unity側の実装-必要なライブラリをインストール
本チュートリアルにおいて、UnityでWebSocket通信を行うために以下の2つのライブラリを用います。
1. NativeWebSocket(Unity用WebSocketクライアント)
- 導入意図: UnityでWebSocket通信を行うためのクライアントライブラリです。また、AWS AppSync Eventsと通信を行うには、サブプロトコルを任意に設定できる必要があるためこのライブラリを使用しています。
-
インストール方法: Unityの「Package Manager」で「Add package from git URL…」を選択し、以下のURLを入力します。
https://github.com/endel/NativeWebSocket.git#upm
2. Newtonsoft.Json(JSONの変換用)
- 導入意図: WebSocketでやり取りするデータはJSON形式が一般的です。C#のオブジェクトとJSON文字列の相互変換(シリアライズ/デシリアライズ)に使用します。必須ではありませんが、可読性のため使用しています。
-
インストール方法: Unityの「Package Manager」で「Add package from git URL…」を選択し、以下のURLを入力します。
com.unity.nuget.newtonsoft-json
2-3. Unity側の実装-通信構築
AWS AppSync EventsのWebSocket通信は、以下のステップで進みます。各ステップごとにUnityでの実装例とポイントを解説します。
1. WebSocket接続の初期化
AppSync Eventsのリアルタイムエンドポイント(wss://.../event/realtime
)にWebSocketで接続します。
このとき、サブプロトコルとして「aws-appsync-event-ws」と、AppSync Eventsに合わせた形式の認証情報を指定する必要があります。
private string RealtimeEndpoint => $"wss://{realtimeDomain}/event/realtime";
var subprotocols = new Liststring>
{
"aws-appsync-event-ws",
GetEncodedHeaderInfo()
};
_websocket = new WebSocket(RealtimeEndpoint, subprotocols);
_websocket.OnOpen += ...;
_websocket.OnMessage += ...;
_websocket.OnError += ...;
_websocket.OnClose += ...;
await _websocket.Connect();
認証情報には、DNS エンドポイント(HTTP)とAPIキーを使用します。
認証情報(host, x-api-key)をJSON化→Base64エンコード→一部記号変換した文字列を作成します。(詳細は公式ドキュメントに記載されています)
private string GetEncodedHeaderInfo(string httpDomain, string apiKey)
{
var header = new Dictionarystring, string>
{
{ "host", httpDomain },
{ "x-api-key", apiKey }
};
string jsonHeader = JsonConvert.SerializeObject(header);
byte[] headerBytes = Encoding.UTF8.GetBytes(jsonHeader);
string base64Header = Convert.ToBase64String(headerBytes)
.Replace('+', '-')
.Replace("https://zenn.dev/", '_')
.TrimEnd('=');
return $"header-{base64Header}";
}
2. 接続初期化メッセージ(connection_init)の送信
接続が開いたら、まずconnection_init
メッセージを送信します。
これによりAppSync側で接続が初期化されます。
送信データ
{ "type": "connection_init" }
実装例
var request = new ConnectionInitRequest();
string json = JsonConvert.SerializeObject(request);
await _websocket.SendText(json);
3. サブスクライブ登録(subscribe)
上記動作にて、connection_ack
を受信したら、購読したいチャンネル名(例: /default/test
)を指定してsubscribe
メッセージを送信します。
idはクライアントごとに異なる値である必要があります。
認証情報(DNS エンドポイント(HTTP)とAPIキー)も含めます。
送信データ
{
"type": "subscribe",
"id": "ee849ef0-cf23-4cb8-9fcb-152ae4fd1e69",
"channel": "/default/test",
"authorization": {
"x-api-key": "da2-12345678901234567890123456",
"host": "example1234567890000.appsync-api.us-east-1.amazonaws.com"
}
}
実装例
var request = new SubscribeRequest
{
Id = Guid.NewGuid().ToString(),
Channel = channel,
Authorization = new Dictionarystring, string>
{
{ "host", httpDomain },
{ "x-api-key", apiKey }
}
};
string json = JsonConvert.SerializeObject(request);
await _websocket.SendText(json);
4. イベント受信
サブスクライブ中のチャンネルにて、他クライアントやサーバーからイベントが発行されるとdata
タイプのメッセージが届きます。
ペイロードはJSON文字列として格納されているので、パースして利用します。
受信データ
{
"type": "data",
"id": "ee849ef0-cf23-4cb8-9fcb-152ae4fd1e69",
"event": ["\"my event content\""]
}
実装例
void OnMessageReceived(byte[] bytes)
{
var message = Encoding.UTF8.GetString(bytes);
var json = JObject.Parse(message);
if ((string)json["type"] == "data")
{
string eventStr = (string)json["event"];
var eventObj = JObject.Parse(eventStr);
Debug.Log($"受信メッセージ: {eventObj["message"]}");
}
}
5. イベントの発行(publish)
クライアントからイベントを発行する場合は、publish
メッセージを送信します。events
プロパティはJSON文字列のリストで、認証情報(APIキー)も含めます。
送信データ
{
"type": "publish",
"id": "ee849ef0-cf23-4cb8-9fcb-152ae4fd1e69",
"channel": "/namespaceA/subB/subC",
"events": [ "{ \"msg\": \"Hello World!\" }" ],
"authorization": {
"x-api-key": "da2-12345678901234567890123456",
}
}
実装例
var payload = new EventPayload
{
Message = "Hello from Unity!",
Timestamp = DateTime.UtcNow.ToString("o")
};
string innerJson = JsonConvert.SerializeObject(payload);
var request = new PublishRequest
{
Id = Guid.NewGuid().ToString(),
Channel = channel,
Events = new Liststring> { innerJson },
Authorization = new Dictionarystring, string> { { "x-api-key", apiKey } }
};
string json = JsonConvert.SerializeObject(request);
await _websocket.SendText(json);
6. サブスクライブ解除(unsubscribe)
購読を解除したい場合は、unsubscribe
メッセージを送信します。
idはサブスクライブ時に用いたidです。
送信データ
{
"type": "unsubscribe",
"id": "ee849ef0-cf23-4cb8-9fcb-152ae4fd1e69"
}
実装例
var request = new UnsubscribeRequest { Id = subscriptionId };
string json = JsonConvert.SerializeObject(request);
await _websocket.SendText(json);
7. Keep-Alive(ka)メッセージの受信
AppSync Eventsは60秒ごとにka
(Keep-Alive)メッセージを送信してきます。
これを受信したら、接続維持のためにタイマーをリセットするなどの処理を行うと良いでしょう。
受信データ
実装例
if ((string)json["type"] == "ka")
{
Debug.Log("Keep-Alive受信");
}
このように、各ステップごとにメッセージの送受信を行うことで、UnityからAWS AppSync Eventsを使ったリアルタイム通信が実現できます。
3. 動作確認
3-1. サンプルソースコードで実装
ここでは、サンプルソースコードを用いて実際にUnityで実装する方法について説明します。
- Unityに新しいシーンを用意します。
- 空のオブジェクトを作成します。
- 空のオブジェクトに、「AppSyncClient.cs」をアタッチします。
- インスペクターからAppSync EventsのエンドポイントおよびAPIキーを入力します。
- デバッグ実行します。
- 起動すると、自動でWebSocketの接続、AppSync Eventsの初期化、チャンネルのサブスクライブを行います。
- キーボードで「P」キーを押すと、テストメッセージのPublishが確認できます。
- キーボードで「U」キーを押すと、チャンネルの購読解除が確認できます。
AppSyncClient.cs
AppSyncClient.cs
using UnityEngine;
using System;
using System.Collections;
using NativeWebSocket;
using System.Text;
using System.Threading.Tasks;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
#region DataModels
namespace AppSync.Messages
{
public class WssHeader
{
[JsonProperty("host")]
public string Host { get; set; }
[JsonProperty("x-api-key")]
public string ApiKey { get; set; }
}
public class ConnectionInitRequest
{
[JsonProperty("type")]
public string Type { get; } = "connection_init";
}
public class SubscribeRequest
{
[JsonProperty("type")]
public string Type { get; } = "subscribe";
[JsonProperty("id")]
public string Id { get; set; }
[JsonProperty("channel")]
public string Channel { get; set; }
[JsonProperty("authorization")]
public Dictionarystring, string> Authorization { get; set; }
}
public class PublishRequest
{
[JsonProperty("type")]
public string Type { get; } = "publish";
[JsonProperty("id")]
public string Id { get; set; }
[JsonProperty("channel")]
public string Channel { get; set; }
[JsonProperty("events")]
public Liststring> Events { get; set; }
[JsonProperty("authorization")]
public Dictionarystring, string> Authorization { get; set; }
}
public class EventPayload
{
[JsonProperty("message")]
public string Message { get; set; }
[JsonProperty("timestamp")]
public string Timestamp { get; set; }
}
public class UnsubscribeRequest
{
[JsonProperty("type")]
public string Type { get; } = "unsubscribe";
[JsonProperty("id")]
public string Id { get; set; }
}
}
#endregion
public class AppSyncClient : MonoBehaviour
{
[Header("AWS AppSync 設定")]
[Tooltip("例: xxx.appsync-api.us-east-1.amazonaws.com")]
[SerializeField] private string httpDomain;
[Tooltip("例: xxx.appsync-realtime-api.us-east-1.amazonaws.com")]
[SerializeField] private string realtimeDomain;
[Tooltip("AppSync認可用APIキー")]
[SerializeField] private string apiKey;
[Tooltip("publish・subscribeするチャンネル")]
[SerializeField] private string channel = "/default/test";
private WebSocket _websocket;
private bool _isConnected = false;
private string _currentSubscriptionId;
private string RealtimeEndpoint => $"wss://{realtimeDomain}/event/realtime";
private string GetEncodedHeaderInfo()
{
var header = new AppSync.Messages.WssHeader
{
Host = httpDomain,
ApiKey = apiKey
};
string jsonHeader = JsonConvert.SerializeObject(header);
byte[] headerBytes = Encoding.UTF8.GetBytes(jsonHeader);
string base64Header = Convert.ToBase64String(headerBytes)
.Replace('+', '-')
.Replace("https://zenn.dev/", '_')
.TrimEnd('=');
return $"header-{base64Header}";
}
async void Start()
{
if (string.IsNullOrEmpty(apiKey) || string.IsNullOrEmpty(httpDomain) || string.IsNullOrEmpty(realtimeDomain))
{
Debug.LogError("AppSyncの設定が不足しています。インスペクターで設定してください。");
return;
}
await InitializeConnection();
}
private async Task InitializeConnection()
{
try
{
Debug.Log($"AppSyncに接続します: {RealtimeEndpoint}");
var subprotocols = new Liststring>
{
"aws-appsync-event-ws",
GetEncodedHeaderInfo()
};
Debug.Log($"subprotocolsを使用: [{string.Join(", ", subprotocols)}]");
_websocket = new WebSocket(RealtimeEndpoint, subprotocols);
_websocket.OnOpen += () =>
{
_isConnected = true;
Debug.Log("AppSyncへの接続が確立されました。");
SendConnectionInit();
};
_websocket.OnMessage += OnMessageReceived;
_websocket.OnError += (e) =>
{
Debug.LogError($"AppSyncエラー: {e}");
_isConnected = false;
};
_websocket.OnClose += (e) =>
{
Debug.Log($"AppSync接続が閉じられました: {e}");
_isConnected = false;
};
await _websocket.Connect();
}
catch (Exception e)
{
Debug.LogError($"AppSync接続の初期化に失敗: {e.Message}\n{e.StackTrace}");
}
}
private void OnMessageReceived(byte[] bytes)
{
var message = Encoding.UTF8.GetString(bytes);
Debug.Log($"メッセージ受信: {message}");
try
{
var json = JObject.Parse(message);
string type = (string)json["type"];
switch (type)
{
case "connection_ack":
Debug.Log("接続が確認されました。チャンネルをsubscribeします...");
SubscribeToChannel();
break;
case "subscribe_success":
Debug.Log("チャンネルをsubscribeしました。");
break;
case "publish_success":
Debug.Log("イベントをpublishしました。");
break;
case "data":
HandleDataMessage(json);
break;
case "ka":
Debug.Log("Keep-aliveメッセージを受信しました。");
break;
case "unsubscribe_success":
Debug.Log("チャンネルをunsubscribeしました。");
break;
default:
Debug.LogWarning($"未処理のメッセージタイプ '{type}': {json.ToString()}");
break;
}
}
catch (JsonException e)
{
Debug.LogError($"JSONメッセージの解析に失敗: {e.Message}");
}
}
private void HandleDataMessage(JObject json)
{
if (json["event"] == null) return;
string eventStr = (string)json["event"];
try
{
var eventObj = JObject.Parse(eventStr);
Debug.Log($"イベントデータ受信: {eventObj.ToString()}");
if (eventObj["message"] != null)
{
string message = (string)eventObj["message"];
Debug.Log($"イベントメッセージ: {message}");
}
}
catch (JsonException)
{
Debug.Log($"イベントデータがJSONオブジェクトではありません: {eventStr}");
}
}
private async void SendConnectionInit()
{
try
{
var request = new AppSync.Messages.ConnectionInitRequest();
string json = JsonConvert.SerializeObject(request);
await _websocket.SendText(json);
Debug.Log("connection_init メッセージを送信しました。");
}
catch (Exception e)
{
Debug.LogError($"connection_init の送信に失敗: {e.Message}\n{e.StackTrace}");
}
}
private async void SubscribeToChannel()
{
try
{
_currentSubscriptionId = Guid.NewGuid().ToString();
var request = new AppSync.Messages.SubscribeRequest
{
Id = _currentSubscriptionId,
Channel = channel,
Authorization = new Dictionarystring, string>
{
{ "host", httpDomain },
{ "x-api-key", apiKey }
}
};
string json = JsonConvert.SerializeObject(request);
await _websocket.SendText(json);
Debug.Log($"subscribe リクエストを送信しました (ID: {_currentSubscriptionId})");
}
catch (Exception e)
{
Debug.LogError($"subscribeに失敗: {e.Message}\n{e.StackTrace}");
}
}
public async void PublishEvent(string message)
{
if (!_isConnected)
{
Debug.LogWarning("接続されていません。イベントを発行できません。");
return;
}
try
{
var payload = new AppSync.Messages.EventPayload
{
Message = message,
Timestamp = DateTime.UtcNow.ToString("o")
};
string innerJson = JsonConvert.SerializeObject(payload);
var request = new AppSync.Messages.PublishRequest
{
Id = Guid.NewGuid().ToString(),
Channel = channel,
Events = new Liststring> { innerJson },
Authorization = new Dictionarystring, string> { { "x-api-key", apiKey } }
};
string json = JsonConvert.SerializeObject(request);
await _websocket.SendText(json);
Debug.Log($"イベントを発行しました: {json}");
}
catch (Exception e)
{
Debug.LogError($"イベントの発行に失敗: {e.Message}\n{e.StackTrace}");
}
}
public async Task Unsubscribe()
{
if (!_isConnected)
{
Debug.LogWarning("接続されていません。subscribe解除できません。");
return;
}
if (string.IsNullOrEmpty(_currentSubscriptionId))
{
Debug.LogWarning("unsubscribeするアクティブなsubscriptionがありません。");
return;
}
try
{
var request = new AppSync.Messages.UnsubscribeRequest { Id = _currentSubscriptionId };
string json = JsonConvert.SerializeObject(request);
await _websocket.SendText(json);
Debug.Log($"unsubscribe リクエストを送信しました (ID: {_currentSubscriptionId})");
_currentSubscriptionId = null;
}
catch (Exception e)
{
Debug.LogError($"unsubscribeに失敗: {e.Message}\n{e.StackTrace}");
}
}
async void Update()
{
#if !UNITY_WEBGL || UNITY_EDITOR
_websocket?.DispatchMessageQueue();
#endif
if (Input.GetKeyDown(KeyCode.P))
{
PublishEvent("Hello from Unity!");
}
if (Input.GetKeyDown(KeyCode.U))
{
await Unsubscribe();
}
}
}
3-2. 動作確認結果
AWSのコンソールにて、Pub/Sub エディタを開いて確認します。
1. イベント受信(subscribe)
Pub/Sub エディタにて、パブリッシュのチャンネルを/default/test
に設定してデフォルトで入っているJSONを送信します。
HTTP,WebSocketどちらでも確認できます。
Pub/Sub エディタから送信したイベントをUnityで受信することができました!
2. イベントの発行(publish)
Pub/Sub エディタにて、サブスクライブの「接続」を押下します。
チャンネルを/default/test
にしてサブスクライブします。
Unityからキーボードの「P」キーを押してイベントを発行します。
Unityから送信したイベントをPub/Sub エディタで受信することができました!
(チャンネルをサブスクライブ中なら、そのイベントを再度受信します)
4. ステップアップ
4-1. より発展的な実装アイデア
AWS AppSync Eventsは、他のAWSサービスや外部要素と連携することで、さらに高度なリアルタイム機能を実現できます。
連携サービス・機能例 | 具体的な活用内容・メリット |
---|---|
Lambda | 受信イベントに対してサーバーレスで柔軟な処理(バリデーション、通知、集計など)を追加できる |
DynamoDB | リアルタイムでデータの永続化、ランキング、履歴管理などが可能 |
外部API連携 | 他の外部サービスと組み合わせて、より多様なリアルタイム体験を提供できる |
このように、AppSync Eventsは単体でも強力ですが、他のサービスと組み合わせることで、さまざまな発展的なリアルタイムアプリケーションの基盤として活用できます。
4-2. おまけ
記事に対して元も子もない話ではありますが求める通信品質レベルが低い場合は、AWSを用いなくともWebSocketサーバをユーザーに立てさせることでコストをゼロにすることができます。
websocket-sharpというライブラリならばWebSocketサーバを立てることもできます。
もちろん、セキュリティやスケーラビリティはAWSの方が優れています。
まとめ
AWS AppSync Eventsを使えば、Unityでサーバーレスかつスケーラブルなリアルタイム通信が簡単に実現できます。
今後のゲーム開発やリアルタイムアプリの参考になれば幸いです。
参考資料
Views: 0