C#で「TCPサーバー」を実装する(ルーティング[TTL]対応版)

2020年2月5日

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. メンバ変数

リスナーは「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のクローズにてクライアントと切断し、再度接続待ち状態に戻る。

メイン側のサンプル(コンソールアプリの例)

コンソールアプリケーションの場合の例です。
ここは使用用途によって変えて下さい。GUIを設けるなら、「通信開始」等のボタン押下時に「ServerStart()」を呼ぶようにしましょう。

まとめ

Readは受信タイムアウト時に例外IOExceptionを吐くから気を付けよう。

間違いなどあれば、受け付けます。