[C#] 非同期ソケット通信で簡易echoサーバーを作成する

[C#] 非同期ソケット通信で簡易echoサーバーを作成する

非同期ソケット通信

C#で複数のクライアントを同時に接続可能な、簡易echoサーバーを作成してみます。通信には非同期なソケット通信を使用します。次のURLを参考にしています。

サーバー

サーバーは以下のような仕様とします。簡易チャットサーバーみたいなイメージです。

  • 複数のクライアントと同時に接続
  • 非同期で処理を行う
  • 文字列のデータを受け取り、接続中の全クライアントに送信する

サーバーはクライアントからの要求を待機し、要求に応じて処理を行います。今回は複数のクライアントからの接続を管理し、処理を行うので非同期通信が必須になります。

サーバーの処理全体は次のコードです。少し長いですが。使い方は単純にRunメソッドを実行するだけです。以下のコード全体です。

public class Server
{
    // スレッド待機用
    private ManualResetEvent AllDone = new ManualResetEvent(false);

    // サーバーのエンドポイント
    public IPEndPoint IPEndPoint { get; }

    // 接続中のクライアント(スレッドセーフコレクション)
    public SynchronizedCollection<Socket> ClientSockets { get; } = new SynchronizedCollection<Socket>();

    public Server(int port)
    {
        this.IPEndPoint = new IPEndPoint(IPAddress.Loopback, port);
    }

    // サーバー起動
    public void Run()
    {
        using (var listenerSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
        {
            // Ctrl+Cが押された場合はソケットを閉じる
            Console.CancelKeyPress += (sender, args) =>
            {
                foreach (var clientSocket in this.ClientSockets) clientSocket?.Close();
            };

            // ソケットをアドレスにバインドする
            listenerSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
            listenerSocket.Bind(this.IPEndPoint);

            // 接続待機開始
            listenerSocket.Listen(10);
            Console.WriteLine($"サーバーを起動しました ... [{listenerSocket.LocalEndPoint}]");
            Console.WriteLine($"Ctrl + C で中止します ...");

            // 接続待機のループ
            while (true)
            {
                AllDone.Reset();
                listenerSocket.BeginAccept(new AsyncCallback(AcceptCallback), listenerSocket);
                AllDone.WaitOne();
            }
        }
    }

    // 接続受付時のコールバック処理
    private void AcceptCallback(IAsyncResult asyncResult)
    {
        // 待機スレッドが進行するようにシグナルをセット
        AllDone.Set();

        // ソケットを取得
        var listenerSocket = asyncResult.AsyncState as Socket;
        var clientSocket = listenerSocket.EndAccept(asyncResult);

        // 接続中のクライアントを追加
        ClientSockets.Add(clientSocket);
        Console.WriteLine($"接続: {clientSocket.RemoteEndPoint}");

        // StateObjectを作成
        var state = new StateObject();
        state.ClientSocket = clientSocket;

        // 受信時のコードバック処理を設定
        clientSocket.BeginReceive(state.Buffer, 0, StateObject.BufferSize, 0, new AsyncCallback(ReceiveCallback), state);
    }

    private void ReceiveCallback(IAsyncResult asyncResult)
    {
        // StateObjectとクライアントソケットを取得
        var state = asyncResult.AsyncState as StateObject;
        var clientSocket = state.ClientSocket;

        // クライアントソケットから受信データを取得終了
        int bytes = clientSocket.EndReceive(asyncResult);

        if (bytes > 0)
        {
            // 受信した文字列を表示
            var content = Encoding.UTF8.GetString(state.Buffer, 0, bytes);
            Console.WriteLine($"受信データ: {content} [{state.ClientSocket.RemoteEndPoint}]");

            // 受信文字列を接続中全クライアントに送信。
            SendAllClient(content);

            // 受信時のコードバック処理を再設定
            clientSocket.BeginReceive(state.Buffer, 0, StateObject.BufferSize, 0, new AsyncCallback(ReceiveCallback), state);
        }
        else
        {
            // 0バイトデータの受信時は、切断されたとき?
            clientSocket.Close();
            this.ClientSockets.Remove(clientSocket);
        }
    }

    // クライアントへのメッセージ送信処理
    private void Send(Socket clientSocket, String data)
    {
        // 受信データをUTF8文字列に変換し送信
        var bytes = Encoding.UTF8.GetBytes(data);
        clientSocket.BeginSend(bytes, 0, bytes.Length, 0, new AsyncCallback(SendCallback), clientSocket);
    }

    // 送信時のコールバック処理
    private static void SendCallback(IAsyncResult asyncResult)
    {
        try
        {
            // クライアントソケットへのデータ送信処理を完了する
            var clientSocket = asyncResult.AsyncState as Socket;
            var byteSize = clientSocket.EndSend(asyncResult);
            Console.WriteLine($"送信結果: {byteSize}バイト [{clientSocket.RemoteEndPoint}]");
        }
        catch (Exception e)
        {
            Console.WriteLine(e.Message);
        }
    }

    // 全クライアントへの送信処理
    private void SendAllClient(string data)
    {
        foreach (var clientSocket in this.ClientSockets)
        {
            Send(clientSocket, data);
        }
    }
}
public class StateObject
{
    public Socket ClientSocket { get; set; }
    public const int BufferSize = 1024;
    public byte[] Buffer { get; } = new byte[BufferSize];
}

接続待機部分

まずはソケットを作成し、ポート番号をバインドして、クライアントからの接続を待ちます。
接続の待機部分は、無限ループをメインスレッドで行います。BeginAccept で接続を受け付けます。ただし、このメソッドは非同期処理なのですぐに完了して次の処理に移ります。クライアントからの接続が発生したタイミングで、引数で渡したコールバック処理が実行されることになります。

単純に BeginAccept だけを無限ループで実行すると、無限に実行されるので ManualResetEvent で接続があるまで待ちます。

// 接続待機のループ
while (true)
{
    AllDone.Reset();
    listenerSocket.BeginAccept(new AsyncCallback(AcceptCallback), listenerSocket);
    AllDone.WaitOne();
}

接続があると、コールバック処理が実行されます。コールバック処理では、接続されたクライアントのソケットを保持するための StateObject を作成し、データ受信用のコールバック処理を設定します。引数で受信データを保持するためのバッファー領域とサイズ、それからユーザー定義のオブジェクトを設定できます。ここではクライアントのソケットとバッファーなどをひとまとめにした StateObject を作成し、そこにデータを保持するように設定しています。

接続されたクライアントはコレクションに入れて保持しておきます。

// 接続受付時のコールバック処理
private void AcceptCallback(IAsyncResult asyncResult)
{
    // 待機スレッドが進行するようにシグナルをセット
    AllDone.Set();

    // ソケットを取得
    var listenerSocket = asyncResult.AsyncState as Socket;
    var clientSocket = listenerSocket.EndAccept(asyncResult);

    // 接続中のクライアントを追加
    ClientSockets.Add(clientSocket);
    Console.WriteLine($"接続: {clientSocket.RemoteEndPoint}");

    // StateObjectを作成
    var state = new StateObject();
    state.ClientSocket = clientSocket;

    // 受信時のコールバック処理を設定
    clientSocket.BeginReceive(state.Buffer, 0, StateObject.BufferSize, 0, new AsyncCallback(ReceiveCallback), state);
}

データの受信部分

受信時のコールバック処理では、AsyncResult の中から設定した StateObject を取得し、クライアントソケットを取得します。EndReceive メソッドで受信処理を完了し、取得したバイトサイズが得られます。受信データはコールバック設定時の引数で指定したバッファー領域に書き込まれています。この場合、StateObject の Buffer プロパティがそうです。

あとは受信したデータを好きなように処理します。今回の例では、データはUTF8の文字列を想定するので、これをコンソールに出力し、それから接続中の全クライアントに送信する処理を入れています。

さらに次のデータ受信があるかもしれないので、受信時のコールバック処理を再設定しておくことにします。こうしておけば、続けてデータ受信を待機できます。

受信データのサイズが0バイトのときがあるようで、どうもこれはクライアントから切断されたときになるようなので、0バイト受信時にはそのように扱っています。

クライアントから強制的に切断された場合、EndReceive で例外が発生するので try-catch を張っておいたほうがよさそうです。

// 受信時のコールバック処理
private void ReceiveCallback(IAsyncResult asyncResult)
{
    // StateObjectとクライアントソケットを取得
    var state = asyncResult.AsyncState as StateObject;
    var clientSocket = state.ClientSocket;

    // クライアントソケットから受信データを取得終了
    int byteSize = clientSocket.EndReceive(asyncResult);

    if (byteSize > 0)
    {
        // 受信した文字列を表示
        var content = Encoding.UTF8.GetString(state.Buffer, 0, byteSize);
        Console.WriteLine($"受信データ: {content} [{state.ClientSocket.RemoteEndPoint}]");

        // 受信文字列を接続中全クライアントに送信。
        SendAllClient(content);

        // 受信時のコールバック処理を再設定
        clientSocket.BeginReceive(state.Buffer, 0, StateObject.BufferSize, 0, new AsyncCallback(ReceiveCallback), state);
    }
    else
    {
        // 0バイトデータの受信時は、切断されたとき?
        clientSocket.Close();
        this.ClientSockets.Remove(clientSocket);
    }
}

データ送信部分

受信したタイミングで接続中の全クライアントへ内容を送信します。送信には BeginSend メソッドで同じように非同期で処理し、コールバック処理を実装します。これは単純です。

// クライアントへのメッセージ送信処理
private void Send(Socket clientSocket, String data)
{
    // 受信データをUTF8文字列に変換し送信
    var bytes = Encoding.UTF8.GetBytes(data);
    clientSocket.BeginSend(bytes, 0, bytes.Length, 0, new AsyncCallback(SendCallback), clientSocket);
}

// 送信時のコールバック処理
private static void SendCallback(IAsyncResult asyncResult)
{
    try
    {
        // クライアントソケットへのデータ送信処理を完了する
        var clientSocket = asyncResult.AsyncState as Socket;
        var byteSize = clientSocket.EndSend(asyncResult);
        Console.WriteLine($"送信結果: {byteSize}バイト [{clientSocket.RemoteEndPoint}]");
    }
    catch (Exception e)
    {
        Console.WriteLine(e.Message);
    }
}

// 全クライアントへの送信処理
private void SendAllClient(string data)
{
    foreach (var clientSocket in this.ClientSockets)
    {
        Send(clientSocket, data);
    }
}

クライアント

長くなったので、このサーバーに接続するためのクライアントの処理は別の記事に譲ります。

C#カテゴリの最新記事