26
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Unity Socket Example

Last updated at Posted at 2017-07-01

はじめに

この記事では、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 のプレファブ
    • 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 カスタムコルーチンでメッセージの到着を待機しています。その後、呼び出し元で設定された 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 エディタは、同じプロジェクトを重複して開けないため、プロジェクトを閉じてから実行します。

/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

さいごに

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

26
22
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26
22

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?