C#で「TCPサーバー」を実装する(ルーティング[TTL]対応版)
C#でTCPサーバーを安定して動作出来るように実装したので、その時のメモ。
(2020/02/07追記)ルーティングで激ハマリしたので、その対応を追加。
(2020/04/09修正)ソースコード全文を公開、どのソースにも組み込めるように汎用的にしました。
Cに比べると滅茶苦茶分かりにくい。ネットで調べるとSocketとかTcpListenerとかNetworkStreamとか似たようなのが一杯出てきて、何が正解なのか分からんかったのと、Cでは分かりやすかったSelect(受信タイムアウト)の仕組みが、C#ではかなり分かりにくい。受信タイムアウトも「例外」扱いとか違和感がすごいよね・・・。どう考えても「例内」なんだが…。
あとは、ネットのサンプルでは「一回受信できれば終わり」みたいなのが多くて、常時稼働させる場合の例が無く、再接続(リトライ)とか、その辺の仕組みが全然見当たらなかったので書いた。
ちなみにC#でのTCPクライアントはこちらから。「TcpClient」を利用したクライアントクラスの実装をしています。
開発環境
- VisualStudio2017
- C#
- .Net4.7(Windows7と10が対応しているので)
ソフトウェア構成
実装しようとしているのは以下の様な構成です。(UMLとか知らんのでツッコまないでね)

本記事で解説するのは主に赤枠破線部(C#版TCPサーバー)です。
メイン画面からの起動→送受信スレッド起動→送受信処理あたりを記載します。
ゆくゆくは、破線部以外、特に分かりにくかった、クラスインスタンス側から画面への通知(コールバック)もまとめて記事にしておこうと思います。
(2020/02/07追記)ルーティングに対応。サーバとクライアントが異なるネットワークでも通信可能にした。
基本方針
- 送受信スレッドは画面とは非同期で動かすため、受信処理は気にせずブロッキングさせる。非同期受信処理等も色々見つかったが、使いづらく、異常処理に弱かった。ただし画面からの送受信終了指示は、少々強引に割り込ます。
- 送信と受信はスレッドを分けて、送信・受信間は同期させない。受信とは関係なく、定周期に送信をおこなえるようにするため。
キーワード
- TcpListener
- TcpClient
- NetworkStream
- ブロッキング
- Read
- IOException
- TTL
【C#】ソースコード(TcpServer.cs)解説
TCPサーバー(TcpServer.cs)
TCPサーバーとして送受信スレッドを起動し、送受信処理を行うクラスです。メイン側はこのクラスをインスタンス化して使用します。ソース全文をまるっと記載します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 |
using System; using System.Threading; using System.Net; using System.Net.Sockets; namespace TcpServer { class TcpServer { /* TCP操作 */ private TcpListener m_listener = null; private TcpClient m_client = null; private NetworkStream m_tcpStream = null; /* 送受信スレッド */ private Thread m_serverRecvTh = null; private Thread m_serverSendTh = null; /* 状態フラグ */ public bool m_running = false; public bool m_connected = false; private bool m_stopCommand = false; /* ログ表示用 */ private enum LogType { MESSAGE, SEND, RECV }; /// <summary> /// TCPサーバー開始処理 /// </summary> /// <param name="port">接続ポート番号</param> /// <returns>処理結果(0=正常 / -1=失敗)</returns> public int ServerStart(int port) { // サーバー状態の初期化 m_running = true; m_connected = false; m_stopCommand = false; // listenerの初期化 try { // 全てのIPアドレスを許可 m_listener = new TcpListener(IPAddress.Any, port); // IPv4のみ m_listener.Server.SetSocketOption(SocketOptionLevel.IPv6, SocketOptionName.IPv6Only, 0); // ソケット再利用許可 m_listener.Server.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.ReuseAddress, true); // TTLの初期値を設定 m_listener.Server.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.IpTimeToLive, 255); // listen m_listener.Start(); } catch (Exception e) { // 主にポート番号重複によるバインドエラー m_running = false; return -1; } // サーバー(受信スレッド)起動 m_serverRecvTh = new Thread(ServerRecvThread); m_serverRecvTh.Start(); // サーバー(送信スレッド)起動 m_serverSendTh = new Thread(ServerSendThread); m_serverSendTh.Start(); return 0; } /// <summary> /// サーバー受信スレッド /// </summary> private void ServerRecvThread() { // 終了指令を受信するまで継続 while (!m_stopCommand) { try { // 未接続 if (!m_connected) { // 接続待ち(ブロッキング) // ※ 画面より切断時(m_listener.Stop())は例外句に飛ぶ LogWrite(LogType.MESSAGE, $"接続待ち..."); m_client = m_listener.AcceptTcpClient(); // 接続完了 LogWrite(LogType.MESSAGE, $"接続されました クライアント[{m_client.Client.RemoteEndPoint.ToString()}]"); m_connected = true; // 送受信用stream(送受信タイムアウト)の設定 m_tcpStream = m_client.GetStream(); m_tcpStream.ReadTimeout = 5000; m_tcpStream.WriteTimeout = 5000; } // 接続済み if (m_connected) { // 受信処理 ProcessRecv(); } } catch (Exception e) { // 受信待ち中に画面から切断された場合 break; } } // クライアント終了処理 if (m_client != null) { m_tcpStream.Close(); m_client.Close(); m_client = null; } } /// <summary> /// データ受信処理 /// </summary> public void ProcessRecv() { try { // 受信処理 byte[] recvBuffer = new byte[256]; int recvLength = m_tcpStream.Read(recvBuffer, 0, recvBuffer.Length); // クライアントから切断された場合 if (recvLength == 0) { ServerReset(); return; } // データ解析処理 LogWrite(LogType.RECV, "", recvBuffer, recvLength); // ParseRecvData(m_recv_buffer, m_recv_offset); } catch (System.IO.IOException e) { // 受信タイムアウト LogWrite(LogType.MESSAGE, $"受信タイムアウト"); } catch (Exception e) { // その他の例外 ServerReset(); } } /// <summary> /// サーバー送信スレッド /// </summary> private void ServerSendThread() { // 送信中の切断実施のキャッチ try { while (!m_stopCommand) { // 1s周期 System.Threading.Thread.Sleep(1000); // 未接続 if (!m_connected) { // 接続待ち continue; } // ここに送信処理を書く // byte[] sendData = new byte[1024]; // int sendSize = XXX; // m_tcp_stream.Write(sendData, 0, sendSize); } } catch (Exception e) { } } /// <summary> /// TCPサーバーリセット処理 /// </summary> private void ServerReset() { LogWrite(LogType.MESSAGE, "切断されました"); // 「未接続」に更新 m_connected = false; // クライアントクローズ if (m_client != null) { m_tcpStream.Close(); m_client.Close(); m_client = null; } } /// <summary> /// 通信ログ表示 /// </summary> /// <param name="log">ログ(文字列)</param> /// <param name="logBuff">ログ(電文)</param> /// <param name="size">ログ(電文サイズ)</param> private void LogWrite(LogType type, string log, byte[] logBuff = null, int size = 0) { string result = DateTime.Now.ToString("[yyyy/MM/dd hh:mm:ss]"); switch (type) { // ログ(文字列) case LogType.MESSAGE: result += " " + log; Console.WriteLine(result); break; // ログ(送受信電文) case LogType.SEND: case LogType.RECV: if (logBuff != null && size > 0) { result += type == LogType.SEND ? "[S]" : "[R] "; for (int i = 0; i < size; i++) { result += String.Format("{0,2:X2}", logBuff[i]); } Console.WriteLine(result); } break; default: break; } } } } |
1. メンバ変数
リスナーは「TCPListener」、接続されるクライアントハンドルは「TCPClient」、TCP送受信は「NetworkStream」を使うと、通信が出来る。
受信用バッファ「m_recv_buffer」は、通信相手とのプロトコルが決まっていない場合は、メンバにする必要はない。毎回の受信で受信バッファを用意すればよい。
筆者の場合、受信電文のフォーマットが規定されていた(ヘッダ部12byte+データ部[可変])だったので、ヘッダ部受信とデータ部受信の2回に分けて受信する必要がある。そういう場合はメンバにした方が簡単。
2. 起動処理:ServerStart()
サーバースレッドを起動する処理。すなわち初期化処理。
気を付ける点は、「new TcpListener」をtry-catchで囲むこと。CのTCPサーバで組むbindがこれに当たるらしく、bindに失敗するとCなら「-1」が返るが、C#だと例外を吐いて死ぬ。
また、クライアントと切断後に同じソケットを使ってすぐに再接続を行う場合は、Cの場合はsetsockopt()で「SO_REUSEADDR」を指定するが、C#の場合はSetSocketOption()で「SocketOptionName.ReuseAddress」を指定する。
(2020/02/07追記)そして、クライアント→サーバへの接続が、L3SWやルータ等を介して異なるネットワークになっている場合は、クライアントに対してSYN-ACKを返すことが出来ない現象が発生した。どうやらC#ではTTL(Time-To-Live)を明示的に設定してやらんとデフォルト「1」になるらしい(試した所、Cのwinsockではデフォルト128)。これは「SetSocketOption(SocketOptionLevel.IP, SocketOptionName.IpTimeToLive, 255)」で指定する。ネットに転がっているほとんどのサンプルにも書いておらず、苦戦した。WireSharkでSYN-ACKのパケットを見ることで解決できた。
スレッドのハンドルは、メイン画面側からコントロールする必要があるため、内部に保持っておく。よく知らん人が好きにいじれないようにprivateにしておいて、ハンドル操作を行う関数を別途作るべし。(ServerStartがそうだね)
「m_listener.Start();」でリッスン開始する。
3. 受信スレッド:ServerRecvThread()
サーバー受信スレッドの実体。
ポイントは、「m_listener.AcceptTcpClient()」でAcceptを行うが、クライアントからconnectされるまではブロックするということ。基本方針に書いたが、画面の処理、送信処理とは別スレッドのため、本スレッドがブロッキングしようが問題ないが、connect待ちの状態の時に停止させようとすると、割り込ませる必要があり、その方法が「m_listener.Stop()」である。ブロッキング中に割り込んでcatch句の方に飛ぶことが出来るため、そこで受信ループを抜けて、終了処理を行えばよい。
(別に受信タイムアウトを待ってから終了でもいいけどね。その場合は、終了指示関数内でm_stop_commandをtrueにして、breakの先でlistener.StopすればOK)
ちなみにノンブロッキングのBeginAcceptTcpClientというものがあるが、Accept時にイベントハンドルして処理を行うため、タイミングが制御しにくく、やめた方がよい。素直にスレッド化した方が安心。
接続状態「m_connected」は、自前で管理した方がよい。m_clientに「connected」というメンバがいるが、closeした後もtrueのままだったり?、とてもじゃないけど仕様が謎で使い道がよく分からなかった。
受信処理は別関数にしておいた。そんなに処理が大きくない場合は一緒でもいいけど。あと、受信バッファの初期化は、プロトコルが決まっていない場合は不要な処理。
4. 受信処理:ProcessRecv()
受信処理の実体。
ポイントはNetworkStreamの「Read()」である。これは接続時に指定した「ReadTimeout」の時間、受信を待ち(ブロッキング)、タイムアウトすると例外(IOException)を吐く。これがC言語ユーザには違和感満載である。
また、本処理は「ヘッダ部受信」→「ヘッダ部から残りサイズ計算」→「残りサイズ受信」という流れで組んでいるが、プロトコルが不明な場合は、Readの第三引数(最大受信byte数)を大きな値にしてしまえばよい。
5. 送信スレッド:ServerSendThread()
送信スレッド。何秒周期かに送信電文を投げる様なガワだけ作っています。
任意のタイミング(画面からのボタン操作)で投げたい場合は、送信処理をpublicで作成し、それをメイン画面から呼ぶ仕組みにするとよい。
6. サーバーリセット:ServerReset()
受信異常時等のリセット処理。m_client、m_tcp_streamのクローズにてクライアントと切断し、再度接続待ち状態に戻る。
メイン側のサンプル(コンソールアプリの例)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
using System.Threading; namespace TcpServer { class Program { static void Main(string[] args) { TcpServer tcpServer = new TcpServer(); // サーバー開始 tcpServer.ServerStart(1234); // とりあえずメイン側は動かし続ける while (true) { Thread.Sleep(5000); } } } } |
コンソールアプリケーションの場合の例です。
ここは使用用途によって変えて下さい。GUIを設けるなら、「通信開始」等のボタン押下時に「ServerStart()」を呼ぶようにしましょう。
まとめ
Readは受信タイムアウト時に例外IOExceptionを吐くから気を付けよう。
間違いなどあれば、受け付けます。
ディスカッション
コメント一覧
まだ、コメントがありません