はじめに
この記事では、Unity 上で UDP/IP データグラム・ソケットを使った簡単なクライアント・サーバー通信の仕組みを構築していきます。
動作環境
- macOS Sierra
- Unity 5.6.2f1
UDP の特徴
TCP と比較して、シンプルで高速な UDP では、小さいデータを頻繁にやりとりするオンラインゲームの用途に適しています。
UDP には、シンプルが故に TCP にはない性質があります。
メッセージの再送処理がない
パケットロス時の再送処理がありません。ただ、マップ上のプレイヤーの位置情報やモーションの同期など、多少のパケットロスが許容できれば問題なさそうです。今回は、パケットの断片化によるメッセージ全体のロストを最小限に抑えるため、DNS の仕様に倣い、メッセージの最大サイズを 512 バイトに制限していきます。
メッセージの到着順序が保証されない
send(a); send(b); が a = recv(); b = recv(); の順で受信できるとは限りません。順序立てられた複数回の送信を一度に行わなければ、問題なさそうです。
接続の概念がない
送信前に相手が受信可能かどうかがわからないため、サーバーが返信不能に陥った際などに困ります。これだけは、代替案を用意する必要がありそうです。
その他、実現したいこと
非同期受信処理のマルチスレッドプログラミングを回避したい
受信のためだけに別のスレッドを起動するのは、なるべく避けたいものです。
通信データメッセージは、様々なフォーマットを考慮したい
今回は、自前のバイナリフォーマットの実装ですが、JSON や CSV, MessagePack, Protocol Buffers などのフォーマットの変更だけでなく、暗号化や難読化にも備えたいです。
メッセージの送受信と振る舞いを明確に分けたい
メッセージの送信タイミングと受信時のアクションは、呼び出し元によって異なります。後から追加する任意のコンポーネントの存在を考慮したいです。
サーバーを手軽に起動できるようにしたい
同じ言語で実装を使いまわせれば開発が楽です。さらに楽をするため、エディタで再生した時に自動で起動したいです。
機能要件と実装方針
1. サーバーが受信可能かどうかを判断したい
- ping のような確認メッセージを定期的に送信する
- 時間内に返信がなければ、サービス停止状態とする
- サービス停止状態後も、確認メッセージの送信は継続され、返信があれば復帰する
2. 非同期受信処理のマルチスレッドプログラミングを回避したい
Socket.Poll (Socket.Select) をコルーチンにします。つまり、各フレームでタイムアウト 0 の Poll を行い、受信を確認します。
3. 通信データメッセージは、様々なフォーマットを考慮したい
シリアライズ・デシリアライズの処理を分離します。今回は、パフォーマンスを優先して、シンプルで小さなバイナリメッセージを実装します。
4. メッセージの送受信と振る舞いを明確に分けたい
任意の呼び出し元から通信結果に応じたコールバックを設定できるようにします。受信時だけでなく、通信が不安定になった際のコールバックも含めます。
5. サーバーを手軽に起動できるようにしたい
エディタの再生時に自動で起動させます。また、サイレントモード (batchmode) でも起動できるようにします。
プロジェクトの作成
最新のソースコードはこちらです。
https://github.com/oshinko/unity-messengers
プロジェクトの全体的な構成は下記のようになります。
- Assets
- Messengers (通信プロトコルアセット)
- ISerializer.cs
- シリアライザのインターフェース
- Udp
- Messenger.cs
- サーバーとの送受信を担当するコンポーネント
- Send や AddConsumer などのメソッドを利用側に提供する
- Messenger.prefab
- Messenger のプレファブ
- Messenger.cs
- ISerializer.cs
- Sockets (ソケットアセット)
- Poll.cs
- Socket.Poll のカスタムコルーチン
- Poll.cs
- UdpExample (サンプルアセット)
- Client
- DevServer.cs
- 自動起動する開発用サーバー
- Information.cs
- 状況を表示する UI コンポーネント
- Player.cs
- Messenger を仲介する通信コンポーネント
- Start.unity (サンプルシーン)
- DevServer.cs
- Messages
- FreeMessage.cs
- 自由なテキストを含むメッセージ
- FreeMessageSerializer.cs
- FreeMessage のシリアライザ
- IMessage.cs
- メッセージのインターフェース
- Theme.cs
- メッセージのテーマ (種類)
- FreeMessage.cs
- Server.cs
- サーバースクリプト
- executeMethod で Start 可能
- Client
- Messengers (通信プロトコルアセット)
サーバーが受信可能かどうかを判断する処理
定期的に ping のような確認メッセージを送信し、時間内に応答がなければサービス停止状態とします。停止後も確認は継続されるため、実際のゲームでは、停止した時点でホーム画面などに戻してしまってもいいかもしれません。
IEnumerator Check()
{
while (true)
{
if (Application.internetReachability == NetworkReachability.NotReachable)
{
// ネットワークが無効な場合.
Available = false;
}
else if (Time.realtimeSinceStartup - LastReceived >= CheckIntervalSeconds)
{
// メッセージをしばらく受信していない場合.
var ok = false;
// 確認メッセージに対する返信の Consumer を定義.
Action<byte[]> replyConsumer =
a =>
{
if (a.Length == 1 && a[0] == 0)
{
// 正式な返信なら OK.
ok = true;
}
};
AddConsumer(replyConsumer);
// 確認メッセージを送信.
Send(new byte[] { 0 });
// 返信を待機.
yield return new WaitForSeconds(CheckTimeoutSeconds);
if (Time.realtimeSinceStartup - LastReceived >= CheckIntervalSeconds)
{
// 引き続きメッセージを受信していない場合、結果を反映.
Available = ok;
}
Consumers.Remove(replyConsumer);
}
yield return null;
}
}
Socket.Poll カスタムコルーチンと Message Consumer
Socket.Poll カスタムコルーチンでメッセージの到着を待機しています。その後、呼び出し元で設定された Consumer に受信を通知します。
IEnumerator Receive()
{
IPEndPoint endpoint = null;
while (true)
{
// Socket.Poll で到着を待機.
yield return new Poll(Client.Client);
if (Client.Available > 0)
{
// 以下、受信処理.
byte[] message = Client.Receive(ref endpoint);
if (message.Length > MaxDataSize)
{
Debug.LogError("Data size is too large.");
}
else if (endpoint.Equals(Remote))
{
LastReceived = Time.realtimeSinceStartup;
Available = true;
// Consume messages.
Consumers.ForEach(a => a(message));
}
}
}
}
シリアライザの実装
インターフェースを定義して、フォーマットごとに実装していく形です。
インターフェース
using System.IO;
namespace Messengers
{
public interface ISerializer<T>
{
void Serialize(Stream stream, T message);
T Deserialize(Stream stream);
}
}
実装
using Messengers;
using System.IO;
using System.Text;
namespace UdpExample.Messages
{
public class FreeMessageSerializer : ISerializer<FreeMessage>
{
Encoding Encoding = Encoding.UTF8;
public void Serialize(Stream stream, FreeMessage message)
{
stream.WriteByte((byte)message.Theme);
byte[] buffer = Encoding.GetBytes(message.Text);
stream.Write(buffer, 0, buffer.Length);
}
public FreeMessage Deserialize(Stream stream)
{
if (stream.CanRead)
{
var theme = (Theme)stream.ReadByte();
if (theme == Theme.Free)
{
var result = new FreeMessage();
using (var reader = new StreamReader(stream, Encoding))
{
result.Text = reader.ReadToEnd();
}
return result;
}
}
return null;
}
}
}
サーバーの作成
Start() は、サイレントモードでのみ、Start(IPAddress, int) は、エディタでの再生時にのみ、呼び出される想定です。
using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using UdpExample.Messages;
using UnityEngine;
namespace UdpExample
{
public class Server
{
const int DefaultPort = 8080;
// サイレントモードでのみ呼び出される想定の起動処理.
public static void Start()
{
int port = DefaultPort;
string[] args = Environment.GetCommandLineArgs();
for (var i = 0; i < args.Length; i++)
{
if (args[i] == "-executeMethodArgs" &&
i + 1 < args.Length &&
int.TryParse(args[++i], out port) &&
port < 0)
{
// 独自に定義した引数のポート番号が不正だった場合.
Debug.LogError("Invalid port number");
port = DefaultPort;
break;
}
}
Start(IPAddress.Any, port);
}
// エディタでの再生時のみ呼び出される想定の起動処理.
public static void Start(IPAddress addr, int port)
{
Debug.LogFormat("[Server] Service is available at {0}:{1}", addr, port);
var udp = new UdpClient(new IPEndPoint(addr, port));
IPEndPoint from = null;
var freeMessageSerializer = new FreeMessageSerializer();
while (true)
{
byte[] message = udp.Receive(ref from);
if (message.Length > 0)
{
var theme = (Theme)message[0];
if (theme == Theme.Check)
{
// 確認メッセージの返信.
Debug.Log("[Server] Received: ping");
udp.Send(new byte[] { (byte)Theme.Check }, 1, from);
}
else if (theme == Theme.Free)
{
// 自由メッセージの返信.
var stream = new MemoryStream(message);
FreeMessage deserialized = freeMessageSerializer.Deserialize(stream);
Debug.Log("[Server] Received: " + deserialized.Text);
Debug.Log("[Server] Length: " + message.Length);
deserialized.Text = "Thanks!";
stream = new MemoryStream();
freeMessageSerializer.Serialize(stream, deserialized);
byte[] buffer = stream.ToArray();
udp.Send(buffer, buffer.Length, from);
}
}
else
{
Debug.LogError("[Server] Received message size is zero.");
}
}
}
}
}
開発用サーバー
エディタの再生時にサーバーを自動で起動するコンポーネントです。停止させたい場合は、Inspector から無効にします。
using Messengers.Udp;
using System.Net;
using System.Threading;
using UnityEngine;
namespace UdpExample.Client
{
public class DevServer : MonoBehaviour
{
public Messenger Messenger;
#if UNITY_EDITOR
Thread Service;
void StartServer()
{
Server.Start(IPAddress.Parse(Messenger.Host), Messenger.Port);
}
void OnEnable()
{
// アクティブ時にスレッドを開始.
Service = new Thread(new ThreadStart(StartServer));
Service.Start();
}
void OnDisable()
{
// 非アクティブ時にスレッドを終了.
Service.Abort();
}
#endif
}
}
サイレントモードによるサーバーの起動
Unity エディタは、同じプロジェクトを重複して開けないため、プロジェクトを閉じてから実行します。
/Applications/Unity/Unity.app/Contents/MacOS/Unity \
-batchmode \
-quit \
-projectPath ~/Projects/unity-messengers \
-executeMethod UdpExample.Server.Start \
-executeMethodArgs 8080
ログはファイルに吐かれているので以下のように読み出します。
tail -f ~/Library/Logs/Unity/Editor.log
さいごに
実際のゲームに組み込むとしたら、プレイヤーのメッセージを他のプレイヤーに送信する機構が必要になりそうです。そうすると、プレイヤーのエンドポイントをサーバーで管理することにもなるでしょう。また、描画できるオブジェクト数には限りがあるでしょうから、ルーム分けなどをして、同時接続数の管理もしないといけませんね。