C# UdpClientの使い方解説


こんばんはー。いつまでたってもわかりやすい記事を書く能力がつかないGaneDevです。

今回はUnityでFPSサーバーをデプロイするにあたって、UnityでUDPを実装する必要があったのでUdpClientを使いました。
そのうえで分かりやすい網羅的なページが無かったので、同じような人のために作ります。

公式ドキュメントもあるのでそれで分かる方はそちらを参照してください。僕は言葉の小さな違いに気づくのに苦労しました。

よく使うもののみをピックアップしてるため、カバーできてない部分もあるのでご了承を。

この記事を読むとできるようになること

C#(特にUnity)にてUDP通信が実装できます

これにより例えば僕のように、FPSのUDPサーバーを作って、大規模マルチプレイヤーの通信部分を最初から最後まで作ることが可能になります。

UDP?何それ?という方はお勧めできません。

想定読者

  • C#の基本的な使い方が分かる人
  • UdpClientを使って実装しようと思っている人
  • 特にUnityのためのテンプレート付き

この記事で使うライブラリ

using System.Collections;
using System.Collections.Generic; //サーバー側ではIPEndPointのリストを作ります。
using UnityEngine;
using System.Net.Sockets; //UdpClient
using System.Net; //IPEndPoint等
using System; 
using System.Threading; //スレッド処理に使います
using System.Linq; //byte[]のconcatに使えます

コンストラクタ

コンストラクタでは自分側のポートを指定できます。ここで指定しないと動的ポートになるので注意。

(a) UdpClient client = new UdpClient();

UDPClientを任意のポートで初期化します。これが使えるのは基本的に送信側のみです。受け取り側が任意のポートだと送信側がどのポートに送ればいいか分からないためです。これを用いた場合、自分側の受信/送信ポートは動的ポートの任意の値になります。

(b) const int port = 50000;
UdpClient client = new IPEndPoint(IPAddress.Any, port);

UDPClientをport番のポートで初期化します。受信側はこれで受信するポートを決める必要があります。これを用いた場合、自分側の受信/送信ポートは指定した値に固定されます。

Connect

string hostIP = "127.0.0.1";
const int hostPort = 9000;
IPEndPoint Ep = new IPEndPoint(IPAddress.Parse(hostIP), hostPort);
client.Connect(Ep)

リモートエンドポイントにUdpClientを接続します。といってもこの後の送信、受信でもリモートエンドポイントを指定できるので使わないこともできます。特定のエンドポイントのみと通信することを明示するときに使います。これを用いた場合、送信や受信でエンドポイントの省略が可能となります。

Send、SendAsync、BeginSend

送信メソッド/受信メソッドともに3つの種類があります。送信メソッドは理由がない限りSendAsyncのみ使えばいいと思います。

Send

IPEndPoint Ep = new IPEndPoint("127.0.0.1".Parse(hostIP), 9000);
byte[] message = BitConverter.GetBytes(100);

(a) client.Send(message, message.Length, Ep);
(b) client.Send(message, message.Length);

Sendメソッドは送信完了まで処理をブロックします(同期処理)。逐次処理風に次の処理を書くことができます。
(a)では指定したリモートエンドポイントに対して送信を行います。(b)ではConnect()済みのエンドポイントに対して送信を行います。

SendAsync

(a) client.SendAsync(message, message.Length, EP);
(b) client.SendAsync(message, message.Length);

SendAsyncは送信時に処理をブロックしません(非同期処理)。

BeginSend

void OnSend(IAsyncResult ar){
  A a = (A)ar.AsyncState;
  clinet.EndSend(ar)
  print(a.message); //→"aaa"
  //処理
}

A a = new A();
a.message = "aaa";
(a) client.BeginSend(message, message.Length, EP, new AsyncCallback(OnSend), a);
(b) client.BeginSend(message, message.Length, new AsyncCallback(OnSend), a)

非同期に送信後に任意のコールバックを実行します。コールバックではEndSendを呼び出す必要があることに注意してください。コールバックには任意の型の変数を渡すことができます。後の処理に必要なメンバーを持つクラスを宣言して渡しましょう。

Receive、ReceiveAsync、BeginReceive

送信メソッド同様に同期、非同期、非同期コールバックが用意されています。それぞれの特徴は送信メソッドを参照してください。Connectしている場合はリモートエンドポイントの指定は不必要です。基本的にはReceiveを使えばいいと思います。

Receive

IPEndPoint senderEP = null;
byte[] receivedBytes = client.Receive(ref senderEP);
print(senderEP.Address);
print(senderEP.Port);

senderEPには送信元の情報が入ります。

BeginReceive

void OnReceive(IAsyncResult ar){
  IPEndPoint ep = (IPEndPoint)ar.AsyncState;
  byte[] message = clinet.EndReceive(ar, ref ep)
  //処理
}

IPEndPoint ep = new IPEndPoint(IPAddress.Any, 9000);
client.BeginReceive(new AsyncCallback(OnReceive), ep);

BeginReceiveでもEndReceiveを用いてメッセージを受け取る必要があります。

テンプレート(Unity)

最後に実際に使う際のひな形です。
Unityと書いてますがUnity以外にも使えると思います。

UDPApp.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System.Net.Sockets;
using System.Net;
using System;
using System.Threading;

public class UDPApp: MonoBehaviour
{
    UdpClient udpClient;
    int tickRate = 10;
    Thread receiveThread;
    IPEndPoint receiveEP = new IPEndPoint(IPAddress.Any, 9000);;
    void Awake()
    {
        udpClient = new UdpClient(receiveEP);

        receiveThread= new Thread(new ThreadStart(ThreadReceive));
        receiveThread.Start();
        print("受信セットアップ完了");

        StartCoroutine(SendMessage());
    }

    void ThreadReceive()
    {
        while (true)
        {
            IPEndPoint senderEP = null;
            byte[] receivedBytes = udpClient.Receive(ref senderEP);
            Parse(senderEP, receivedBytes);
        }
    }

    void Parse(IPEndPoint senderEP, byte[] message)
    {
        //受信時の処理
    }

    IEnumerator SendMessage()
    {
        yield return new WaitForSeconds(1f / tickRate);
        //送信処理
    }
}

Unityの方はとりあえずコピペしてください。

受信の処理は別のスレッドで、送信の処理は送信間隔の調整のためにコルーチンを使っています。受信後には処理するためにParseが呼び出されますが、Unityはメインスレッドでないと呼び出せないメソッドがあるので気を付けてください。

サーバー側のクライアント情報は受信時にIPEndPointをリストに保存しておくことで新規接続か接続済みのクライアントかなど識別が可能です。

おまけ

実際に使うときのおまけです。

例えば以下のようにenumでメッセージ内容を表す数字を作っておけば簡易的にパケットの種類を識別することができます。

omake.cs
public enum UDPMessage
{
    Register = 10001,
    UserRemove = 10002,
    UpdatePos = 20001,
}

static class enum_ext
{
    public static UDPMessage ToUDPMessage(this byte[] bytes, int startIndex=0)
    {
        return (UDPMessage)Enum.ToObject(typeof(UDPMessage), BitConverter.ToInt32(bytes, startIndex));
    }

    public static byte[] ToByte(this UDPMessage msg)
    {
        return BitConverter.GetBytes((int)msg);
    }
}

    void Parse(byte[] msg)
    {
        UDPMessage msgType = msg.ToUDPMessage();
        switch (msgType)
        {
            case UDPMessage.Register:
                {
                    byte[] content = UDPMessage.Register.ToByte();
                    client.SendAsync(content, content.Length);
                    break;
                }
            case UDPMessage.UpdatePos:
                {
                    break;
                }
        }
    }