なべひろBlog

プログラミングをメインに仕事に関するアレコレを発信しています。

C#で今時な書き方の非同期なTCPクライアントを作ってみる

前回からだいぶ時間が経ってしまいましたが、クライアント側の今時な(C# Ver8)書き方を紹介します。
尚、サーバと比較すると名称に一貫性がないかもしれませんがご了承ください。
基となるマイクロソフトのコードは
ソースはこちらになります。
尚、今回はBeginxxxメソッドを使用していますがxxxAsyncを使用した非同期TCPクライアントの事例は下記リンクとなります。
TCPClient
コンストラクタです。
internal TCPClient(LogDelegate Log = null, bool IsMessage = false)
{
    isMessage = IsMessage;
    // ログを記録する設定ならLog Classのインスタンスを生成
    if (Log != null)
    {
        logDelegate = Log;
    }
}
引数
Log
不具合が起きた場合、ログとして記録を残すメソッドを予め作っておき、そのメソッドにエラー内容を書き込みます。
IsMessage
不具合が起きた場合、メッセージを表示するか否かを決めます。
何故こんなメッセージの表示を抑制するかと言えば、私が作るアプリは無人で稼働しているケースが多くメッセージボックが出て次へ進まないというのは致命的な問題となります。
なので無人状態で不具合が起きても止まらないアプリが要求されます。
とはいえ今まで幾つかのアプリで使っていますが不具合が起きた事例はありません。
~TCPClient
デストラクタです。
~TCPClient()
{
    // usingやDispose忘れがあるかもしれないので念のため
    connectDone?.Dispose();
    connectDone = null;
    sendDone?.Dispose();
    sendDone = null;
    receiveDone?.Dispose();
    receiveDone = null;
}
自分が作る場合必ずusingを使用しますので問題ありませんが、念のためデストラクタでリソース解放の処理を記述しておきます。
connectDone?.Dispose();の?はconnectDoneがnullでなければDisposeメソッドを実行する処理を簡潔に書く記述です。
Dispose
public void Dispose()
{
    // 事後処理
    connectDone?.Dispose();
    connectDone = null;
    sendDone?.Dispose();
    sendDone = null;
    receiveDone?.Dispose();
    receiveDone = null;
    GC.SuppressFinalize(this);
}
通常usingのスコープを抜けた時点でリソース解放を自動的に行うためのメソッドです。
意図的にメソッドを実行してもかまいませんが、普通はusingを使います。
ConnectCallback
private void ConnectCallback(IAsyncResult ar)
{
    // これが非同期なConnectの完了
    ((Socket)ar.AsyncState).EndConnect(ar);
    // 接続完了のイベントをセット
    connectDone.Set();
}
接続が完了した時に自動的に実行されるコールバック関数です。
接続が完了したらconnectDone.Set();を実行し、実際の処理部で次のステップであるデータ送信を行います。
例は分かりやすいように必要な部分だけを抜粋してますが、実際は例外をキャッチできるようtry,catchを使用しています。
SendCallback
private void SendCallback(IAsyncResult ar)
{
	// 送信完了のイベントをセット
	sendDone.Set();
}
送信が完了した時にに自動的に実行されるコールバック関数です。
送信が完了したらsendDone.Set();を実行し、実際の処理部で次のステップであるデータ受信待ちになります。
ReceiveCallback
private void ReceiveCallback(IAsyncResult ar)
{
    // 保留中の非同期読み込みを終了
    _ = ((StateObject)ar.AsyncState).workSocket.EndReceive(ar);
    // 送信完了のイベントをセット
    receiveDone.Set();
}
受信が完了した時に自動的に実行されるコールバック関数です。
受信が完了したらreceiveDone.Set();を実行し、実際の処理部では送受信が完了します。
SendReceive
internal bool SendReceive(string address, int port, byte[] sendData, ref byte[] receiveData)
{
    using Socket client = new Socket(serverAddress.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
    try
    {
        // サーバへ接続
        _ = client.BeginConnect(new IPEndPoint(serverAddress, port), new AsyncCallback(ConnectCallback), client);
        // 接続が完了するまで待機
        if (connectDone.Wait(timeOut) == true)
        {
            // データ送信開始
            _ = client.BeginSend(sendData, 0, sendData.Length, 0, new AsyncCallback(SendCallback), client);	
            // 送信が完了するまで待機
            if (sendDone.Wait(timeOut) == true)
            {
                // データ受信用Object作成
                StateObject state = new StateObject(ref receiveData) { workSocket = client };
                // 非同期受信開始
                _ = client.BeginReceive(state.buffer, 0, state.buffer.Length, SocketFlags.None, new AsyncCallback(ReceiveCallback), state);
                // 受信が完了するまで待機
                if (receiveDone.Wait(timeOut) == true)
                {
                    result = true;
                }
            }
        }
    }
    finally
    {
        //Socketでの送受信を無効にする
        client.Shutdown(SocketShutdown.Both);
        // ソケット接続を閉じ、ソケットを再利用できるようにする。再利用できる場合は true それ以外の場合は false
        client.Disconnect(true);
        connectDone.Reset();
        sendDone.Reset();
        receiveDone.Reset();
    }
    return result;
}
実際に送受信を行う本体になります。
このクラスで唯一他コードから実行するメソッドとなります。
接続後、各コールバック関数でセットされるManualResetEventSlimを基に順に処理を行います。
例では分かりやすいようにタイムアウト処理や例外を省いてますが実際はエラーに対する処理の記述があります。
引数
address
接続するサーバのIPアドレスの文字列です。
例には書いてありませんが実際は文字列が正しくIPアドレスに変換できるかをチェックしています。
port
接続するサーバのポート番号です。
sendData
サーバへ送信するbyte型の配列です。
receiveData
サーバから送られて来たデータを格納する参照先です。
使用方法は以下となります。
サーバのレスポンスが悪いとUIが固まるので別タスクで実行する例となっています。
private async void TcpTransmission()
{
    using TCPClient Client = new TCPClient();
    byte[] sendData = Encoding.ASCII.GetBytes(SendData.Value);
    // 想定される受信データサーズから受信データを入れる入れ物のサイズを決めておく
    byte[] receiveData = new byte[1024];
    // 戻り値がTrueなら正常に送受信完了
    await Task.Run(() =>
    {
        if (Client.SendReceive("127.0.0.1", 50000, sendData, ref receiveData) == true)
        {
            // 受信した文字列データは用意している入れ物より少ない場合もあるので「.TrimEnd('\0')」する。
            ReceptionData.Value = Encoding.ASCII.GetString(receiveData).TrimEnd('\0');
        }
    });
}
文字列の場合ですが、用意した受信用変数より受信サイズが小さいと残りのエリアは全て0になります。
受信データを文字列で表示する場合は0の排除が必要ですので注意してください。
関連記事