Unity Socket Example

## はじめに
この記事では、Unity 上で UDP/IP データグラム・ソケットを使った簡単なクライアント・サーバー通信の仕組みを構築していきます。

## 動作環境
- macOS Sierra
- Unity 5.6.2f1

## UDP の特徴
TCP と比較して、シンプルで高速な UDP では、小さいデータを頻繁にやりとりするオンラインゲームの用途に適しています。

UDP には、シンプルが故に TCP にはない性質があります。

### メッセージの再送処理がない
パケットロス時の再送処理がありません。ただ、マップ上のプレイヤーの位置情報やモーションの同期など、多少のパケットロスが許容できれば問題なさそうです。今回は、パケットの断片化によるメッセージ全体のロストを最小限に抑えるため、[DNS の仕様](https://www.ietf.org/rfc/rfc1035.txt)に倣い、メッセージの最大サイズを 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](https://github.com/oshinko/unity-messengers)

プロジェクトの全体的な構成は下記のようになります。

- Assets
    - Messengers (通信プロトコルアセット)
        - ISerializer.cs
            - シリアライザのインターフェース
        - Udp
            - Messenger.cs
                - サーバーとの送受信を担当するコンポーネント
                - Send や AddConsumer などのメソッドを利用側に提供する
            - Messenger.prefab
                - Messenger のプレファブ
    - Sockets (ソケットアセット)
        - Poll.cs
            - Socket.Poll のカスタムコルーチン
    - UdpExample (サンプルアセット)
        - Client
            - DevServer.cs
                - 自動起動する開発用サーバー
            - Information.cs
                - 状況を表示する UI コンポーネント
            - Player.cs
                - Messenger を仲介する通信コンポーネント
            - Start.unity (サンプルシーン)
        - Messages
            - FreeMessage.cs
                - 自由なテキストを含むメッセージ
            - FreeMessageSerializer.cs
                - FreeMessage のシリアライザ
            - IMessage.cs
                - メッセージのインターフェース
            - Theme.cs
                - メッセージのテーマ (種類)
        - Server.cs
            - サーバースクリプト
            - executeMethod で Start 可能

### サーバーが受信可能かどうかを判断する処理
定期的に ping のような確認メッセージを送信し、時間内に応答がなければサービス停止状態とします。停止後も確認は継続されるため、実際のゲームでは、停止した時点でホーム画面などに戻してしまってもいいかもしれません。

``` Assets/Messengers/Udp/Messenger.cs
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 カスタムコルーチン](https://github.com/osynco/unity-messengers/tree/master/Assets/Sockets/Poll.cs)でメッセージの到着を待機しています。その後、呼び出し元で設定された Consumer に受信を通知します。

``` Assets/Messengers/Udp/Messenger.cs
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));
            }
        }
    }
}
```

### シリアライザの実装
インターフェースを定義して、フォーマットごとに実装していく形です。

#### インターフェース
``` Assets/Messengers/ISerializer.cs
using System.IO;

namespace Messengers
{
    public interface ISerializer<T>
    {
        void Serialize(Stream stream, T message);

        T Deserialize(Stream stream);
    }
}
```

#### 実装
``` Assets/UdpExample/Messages/FreeMessageSerializer.cs
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) は、エディタでの再生時にのみ、呼び出される想定です。

``` Assets/UdpExample/Server.cs
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 から無効にします。

``` Assets/UdpExample/Client/DevServer.cs
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 エディタは、同じプロジェクトを重複して開けないため、プロジェクトを閉じてから実行します。

``` bash
/Applications/Unity/Unity.app/Contents/MacOS/Unity \
  -batchmode \
  -quit \
  -projectPath ~/Projects/unity-messengers \
  -executeMethod UdpExample.Server.Start \
  -executeMethodArgs 8080
```

ログはファイルに吐かれているので以下のように読み出します。

``` bash
tail -f ~/Library/Logs/Unity/Editor.log
```

## さいごに
実際のゲームに組み込むとしたら、プレイヤーのメッセージを他のプレイヤーに送信する機構が必要になりそうです。そうすると、プレイヤーのエンドポイントをサーバーで管理することにもなるでしょう。また、描画できるオブジェクト数には限りがあるでしょうから、ルーム分けなどをして、同時接続数の管理もしないといけませんね。