なべひろBlog

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

C#で今時な書き方の非同期なTCPサーバを作ってみる 詳細編

もくじ
前回紹介したTCPサーバのメソッドを少しだけ細かく紹介したいと思います。
まあ、紹介したところで触る部分は少ないのであまり意味がないかもしれませんが、自分なりにアレンジして新規で作りたい方には役に立つかと思います。
尚、今回はBeginxxxメソッドを使用していますがxxxAsyncを使用した非同期TCPサーバの事例は下記リンクとなります。
Server
コンストラクタです。
internal Server(bool ismessage, bool islog, Func<string, string> method)
{
    isMessage = ismessage;
    isLog = islog;
    Method = method;
        // Encoding.GetEncoding("shift_jis")で例外回避に必要
        Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
        // エラーログを記録するならログ書き込みタスクを動作させる
        if (isLog == true)
        {
            logWrite(AppDomain.CurrentDomain.BaseDirectory + "TCP Server Error.csv");
        }
}
引数
ismessage
trueなら問題がある場合にメッセージを表示します。
今回はTCPサーバのIPアドレスに問題がある場合にメッセージを表示させています。
通信中に異常が発生した場合も表示させていいのですが、無人で稼働している場合メッセージが出たままプログラムが次の動作をできないという問題が起きますので使い方に注意が必要です。
例外は知りたいが稼働はそのまま続けて欲しい場合は次に紹介するログ取得の方が良いと思います。
islog
trueなら例外が発生した時に内容をテキストファイルに保存します。
これで突発的に問題が発生しても後で不具合を確認できます。
今まで使っていて例外が発生した事例はないのでお呪いレベルではあります。
method
データを受信した時にデータを処理するメソッドを引数として渡します。
string型の引数とstring型の戻り値を持っています。
但し、受信したデータを扱うのはどんな手法でもかまいません。
引数をReactivePropertySlimにしてSubscribeでメソッドを指定するなんて方法もできるかもしれません。(未検証)
また、外部のメソッドを使わずに受信コールバック関数内部で処理してもいいです。
~Server
デストラクタです。
~Server()
{
    Close();
    msg.Dispose();
}
特に説明は不要かと思いますが、デストラクタで念のためTCPポートを閉じてます。
Closeメソッドで意図してTCPポートを閉じる方が管理しやすいと思いますが、念のためにここでも閉じる動作を入れています。
とはいえ既に閉じられている時はCloseメソッドでは何もしませんので何度呼び出しても問題ありません。
また、ログ情報のBlockingCollectionのリソース解放も最後の最後ので行っています。
message
ログを追記します。
private void message(string message)
{
    _ = msg.TryAdd(DateTime.Now.ToString("yyyy/M/d HH:mm:ss,") + message, Timeout.Infinite);
}
正確にはBlockingCollectionに文字列を追加するだけで、実際のファイル書き込みは別タスクで行います。
ファイルアクセスは非常に遅い動作なので他に影響を与えないよう、ここでは書き込む内容を追加するだけにしています。
スレッドセーフなのでログ書き込み中に追加しても問題ありません。
引数
message
書き込む文字列
logWrite
受け取ったログ用データを書き込むメソッドです。
private void logWrite(string fileName)
{
    // 最初にファイルの有無を確認
    if (File.Exists(fileName) == false)
    {
        using FileStream hStream = File.Create(fileName);
        // 作成時に返される FileStream を利用して閉じる
        hStream?.Close();
    }
    _ = Task.Run(async () =>
    {
        while (true)
        {
            try
            {
                _ = msg.TryTake(out string log, Timeout.Infinite);
                while (true)
                {
                    try
                    {
                        // ファイルがロックされている場合例外が発生して以下の処理は行わずリトライとなる
                        using FileStream stream = new FileStream(fileName, FileMode.Open);
                        // ログ書き込み
                        using FileStream fs = new FileStream(fileName, FileMode.Append, FileAccess.Write, FileShare.ReadWrite);
                        using StreamWriter sw = new StreamWriter(fs, Encoding.GetEncoding("Shift-JIS"));
                        sw.WriteLine(log);
                        break;
                    }
                    catch (Exception)
                    {
                        await Task.Delay(1000);
                    }
                }
            }
            catch (Exception)
            {
            }
        }
    });
}
最初にログファイルの有無をチェックして、ファイルが存在してなければファイルを生成します。
「msg.TryTake」はMicrosoftの解説だと「項目の削除を試みます。」となっています。
つまりコレクションが存在している場合はそのコレクションを削除する訳で、コレクションがない場合は削除できるコレクションがやって来るまで待ちます。
そして1つめの引数には削除した内容なので、この内容をログファイルに保存すればOKです。
2つめの引数はタイムアウト時間ですが、今回は無限に待つので-1を示す「Timeout.Infinite」となります。
コレクションが削除されると以降のファイルアクセスが実行されますが、もしかしてファイルが利用者によって開かれいるかもしれません。
ファイルが開かれているとロックされ書き込めませんのでその例外を利用してロックをチェックし解除されるまで待ちます。
そしてロックされてない状態になったらログ情報をファイルに書き込みます。
引数
fileName
記録するフルパスのログファイル名。
Open
ここでTCPサーバのポートを開きます。
internal bool Open(string ipAddress, int port, int listen, int buffersize)
{
    // まだポートがオープンしけなければ処理
    if (isOpen == false)
    {
        bufferSize = buffersize;
        _ = AllDone.Set();
        // 指定されたIPアドレスが正しい値かチェック
        if (IPAddress.TryParse(ipAddress, out IPAddress result) == true)
        {
            IP = result;
        }
        else
        {
            if (isLog == true)
                message("IPアドレス文字列が不適切です");
            if (isMessage == true)
                _ = MessageBox.Show("IPアドレス文字列が不適切です", "TCP Server オープン", MessageBoxButton.OK, MessageBoxImage.Error);
            return false;
        }

        // 引数のIPアドレスがPCに存在しているか確認(127.0.0.1は除く)
        if (ipAddress != "127.0.0.1")
        {
            if (new List<IPAddress>(Dns.GetHostAddresses(Dns.GetHostName())).ConvertAll(x => x.ToString()).Any(l => l == ipAddress) == false)
            {
                if (isLog == true)
                    message("指定されたIPアドレスは存在しません。");
                if (isMessage == true)
                    _ = MessageBox.Show("指定されたIPアドレスは存在しません。", "TCP Server オープン", MessageBoxButton.OK, MessageBoxImage.Error);
                return false;
            }
        }

        Sock = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        Sock.Bind(new IPEndPoint(IP, port));
        Sock.Listen(listen);

        isOpen = true;
        accept();
    }

    return true;
}
自分のIPアドレスは文字列として渡されるので念のためIPアドレスに変換可能かと、そのIPアドレス自体がPCに存在しているかをチェックします。
問題なければSocketの実体が生成された後、Socket をローカル エンドポイントと関連付けされます。
Socket処理が完了した後は接続待ちのメソッド「accept」を動作させ終了となります。
引数
ipAddress
TCPサーバが使用するLANポートのIPアドレスの文字列。
port
TCPサーバが使用するポート番号。
listen
接続可能なクライアント数。
buffersize
データ受信時の最大サイズ。
accept
クライアントの接続を待ちます。
private void accept()
{
    _ = Task.Run(() =>
    {
        while (true)
        {
            AllDone.Reset();
            try
            {
                // 受信接続の試行を受け入れる非同期操作を開始
                _ = Sock.BeginAccept(new AsyncCallback(acceptCallback), Sock);
            }
            catch (ObjectDisposedException)
            {
                // オブジェクトが閉じられていれば終了
                break;
            }
            catch (Exception e)
            {
                if (isLog == true)
                {
                    message(e.Message);
                    continue;
                }
            }
            AllDone.Wait();
        }
    });
}
クライアントは複数接続される可能性があるので、クライアントの接続要求処理(ここではSock.BeginAccept)が完了したら繰り返し接続要求待ち状態になります。
但し、次に説明しているacceptCallbackメソッドとの間でManualResetEventSlimを使いタイミングを調整しています。
また接続待ち状態でTCPポートが閉じると例外ObjectDisposedExceptionが発生しますので、これをキャッチしてこの接続待ちメソッドを終了させます。
acceptCallback
TCPクライアントから接続要求が来た時に実行されるコールバック関数です。
private void acceptCallback(IAsyncResult asyncResult)
{
    // 待機スレッドが進行するようにシグナルをセット
    AllDone.Set();
    // StateObjectを作成しソケットを取得
    StateObject state = new StateObject(bufferSize);
    try
    {
        state.ClientSocket = ((Socket)asyncResult.AsyncState).EndAccept(asyncResult);
    }
    catch (ObjectDisposedException)
    {
        return;
    }
    catch (Exception e)
    {
        if (isLog == true)
            message(e.Message);
        return;
    }

    // 接続中のクライアントを追加
    ClientSockets.Add(state.ClientSocket);
    // 受信時のコードバック処理を設定
    _ = state.ClientSocket.BeginReceive(state.Buffer, 0, bufferSize, 0, new AsyncCallback(receptionCallback), state);
}
実行されるとまずStateObjectクラスのインスタンスを生成します。
このStateObjectクラスにあるBufferが受信したデータが入る入れ物になります。
ClientSocketsに接続してきたクライアント一覧を保持しておき、クライアント接続中にこちらが切断しなければいけない事態になった時にCloseメソッドで必要な処理を行います。
そして最後に受信時に起動するコールバック関数などをセットし受信待ち状態にして終了します。
引数
asyncResult
接続してきたクライアント情報
receptionCallback
これが本件でメインになる受信時のコールバック関数です。
private void receptionCallback(IAsyncResult asyncResult)
{
    // acceptCallbackで生成されたStateObjectインスタンスを取得
    StateObject state = (StateObject)asyncResult.AsyncState;
    // クライアントソケットから受信データを取得
    try
    {
        // 受信サイズが0以上なら何かしらデータが送られてきたので処理を行う
        if (state.ClientSocket.EndReceive(asyncResult) > 0)
        {
            // TCPで受信したデータは「state.Buffer」にあるのでstring文字列に変換しつつ残バッファの分だけ不要な0があるので削除
            string receivestr = Encoding.GetEncoding("shift_jis").GetString(state.Buffer).TrimEnd('\0');

            /***** ここに受信したデータに対する処理を記述する *****/
            // 外部メソッドへ受信文字列を引数として渡し、string型の戻り値を送信データByte配列に変換
            byte[] senddata = Encoding.GetEncoding("shift_jis").GetBytes(Method(receivestr));
            // クライアントに非同期送信
            _ = state.ClientSocket.BeginSend(senddata, 0, senddata.Length, 0, new AsyncCallback(transmissionCallback), state);
        }
        else
        {
            // 受信サイズが0の場合は切断(相手が切断した)
            state.ClientSocket.Close();
            _ = ClientSockets.Remove(state.ClientSocket);
        }
    }
    catch (SocketException)
    {
        // 強制的に切断された
        state.ClientSocket.Close();
        _ = ClientSockets.Remove(state.ClientSocket);
    }
    catch (Exception e)
    {
        if (isLog == true)
            message(e.Message);
    }
}
接続時のコールバック関数「acceptCallback」や送信完了コールバック関数「transmissionCallback」にある「state.ClientSocket.BeginReceive」の引数で渡されているコールバック関数です。
このコールバック関数の引数には受信データなどの情報が含まれていますので、これを処理してTCPクライアントにデータを返すなどを行います。
処理としては受信データサーズが0の場合クライアントが切断したと判断しソケットを閉じ、SynchronizedCollectionリストから削除します。
そしてこのプログラムで一番重要な部分が
if (state.ClientSocket.EndReceive(asyncResult) > 0)
以下です。
ここで受信したデータを読み出して、応答を返すなどを行います。
送受信の仕様に合わせた記述をしてください。
例題では最後に「state.ClientSocket.BeginSend」でデータの送信をしつつ送信コールバック関数を指定しています。
これは必ず受信コールバック関数内でやる必要はありません。
データ受信時に何かしらメソッドを実行し、そこで「state.ClientSocket.BeginSend」を実行してもいいです。
引数
asyncResult
受信したクライアント情報や受け取ったデータ
transmissionCallback
送信が完了した時に実行されるコールバック関数です。
private void transmissionCallback(IAsyncResult asyncResult)
{
    try
    {
        StateObject state = (StateObject)asyncResult.AsyncState;
        // リモートデバイスへのデータ送信を完了
        _ = state.ClientSocket.EndSend(asyncResult);
        // 受信時のコードバック処理を設定
        _ = state.ClientSocket.BeginReceive(state.Buffer, 0, bufferSize, 0, new AsyncCallback(receptionCallback), state);
    }
    catch (Exception e)
    {
        if (isLog == true)
            message(e.Message);
    }
}
非同期送信を終了し「state.ClientSocket.BeginReceive」で次の受信に備えます。
引数
asyncResult
送信したクライアント情報
Close
全てのTCP通信を終了します。
internal void Close()
{
    // 接続されているTCPクライアントがあれば切断する
    foreach (Socket Cl in ClientSockets)
    {
        Cl.Shutdown(SocketShutdown.Both);
        Cl.Close();
    }
    ClientSockets.Clear();
    Sock?.Close();
    isOpen = false;
}
接続中のTCPクライアントがあれば切断処理を行います。
その後自分のソケットを閉じソケットのリソースは破棄します。
StateObject
TCPクライアントから接続されたら都度インスタンスが生成されるクラスです。
private class StateObject
{
    internal Socket ClientSocket { get; set; }
        internal byte[] Buffer;

    internal StateObject(int buffersize)
    {
        Buffer = new byte[buffersize];
    }
}
ここにクライアントソケットや受信バッファがあり、TCPクライアントからデータが送信されると受信バッファにデータが入ります。
さいごに
TCPサーバはそれなりにボリュームのあるコードですが、全て必要があるのは理解できるかと思います。
しかし汎用性を持たせればアプリごとに編集すべきは受信コールバック関数だけで済みますので自分で使いやすいようにアレンジしてみてください。
関連記事