てぃってぃの楽しい副業日記!

ワイと嫁(てぃってぃ)夫婦の、副業、育児、プログラミング、筋トレ、ゲームを綴ったブログです☆

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

C#でTCPサーバーを安定して動作出来るように実装したので、その時のほぼ個人用メモ。
(2020/02/07追記)ルーティングで激ハマリしたので、その対応を追加。

Cに比べると滅茶苦茶分かりにくい。ネットで調べるとSocketとかTcpListenerとかNetworkStreamとか似たようなのが一杯出てきて、何が正解なのか分からんかったのと、Cでは分かりやすかったSelect(受信タイムアウト)の仕組みが、C#ではかなり分かりにくい。受信タイムアウトも「例外」扱いとか違和感がすごいよね・・・。どう考えても「例内」なんだが…。

あとは、ネットのサンプルでは「一回受信できれば終わり」みたいなのが多くて、常時稼働させる場合の例が無く、再接続(リトライ)とか、その辺の仕組みが全然見当たらなかったので書いた。


ちなみにC#でのTCPクライアントはこちらから。「TcpClient」を利用したクライアントクラスの実装をしています。
www.kt2525family.com


開発環境

  • VisualStudio2017
  • C#
  • .Net4.7(Windows7と10が対応しているので)

ソフトウェア構成

実装しようとしているのは以下の様な構成です。(UMLとか知らんのでツッコまないでね)

C# TCPサーバー (TcpListener, NetworkStream, TcpClient, ルーティング)
C# TCPサーバー

本記事で解説するのは主に赤枠破線部(C#版TCPサーバー)です。
メイン画面からの起動→送受信スレッド起動→送受信処理あたりを記載します。
ゆくゆくは、破線部以外、特に分かりにくかった、クラスインスタンス側から画面への通知(コールバック)もまとめて記事にしておこうと思います。
(2020/02/07追記)ルーティングに対応。サーバとクライアントが異なるネットワークでも通信可能にした。

基本方針

  • 送受信スレッドは画面とは非同期で動かすため、受信処理は気にせずブロッキングさせる。非同期受信処理等も色々見つかったが、使いづらく、異常処理に弱かった。ただし画面からの送受信終了指示は、少々強引に割り込ます。
  • 送信と受信はスレッドを分けて、送信・受信間は同期させない。受信とは関係なく、定周期に送信をおこなえるようにするため。

キーワード

  • TcpListener
  • TcpClient
  • NetworkStream
  • ブロッキング
  • Read
  • IOException
  • TTL

【C#】ソースコード(TcpServer.cs)

1. クラスのメンバ

using System;
using System.Threading;
using System.Net;
using System.Net.Sockets;

namespace TCPサーバー
{
    public class TcpServer
    {
        /* TCP操作 */
        private TcpListener m_listener      = null;
        private TcpClient m_client          = null;
        private NetworkStream m_tcp_stream  = null;

        /* 送受信スレッド */
        private Thread m_server_recv_t      = null;
        private Thread m_server_send_t      = null;

        /* 状態フラグ */
        public bool m_running               = false;
        public bool m_connected             = false;
        private bool m_stop_command         = false;

        /* 受信用バッファ */
        private byte[] m_recv_buffer        = new byte[1024];
        private int m_recv_offset           = 0;
        private int m_next_expect_length    = 0;
        :


リスナーは「TCPListener」、接続されるクライアントハンドルは「TCPClient」、TCP送受信は「NetworkStream」を使うと、通信が出来る。
受信用バッファ「m_recv_buffer」は、通信相手とのプロトコルが決まっていない場合は、メンバにする必要はない。毎回の受信で受信バッファを用意すればよい。
筆者の場合、受信電文のフォーマットが規定されていた(ヘッダ部12byte+データ部[可変])だったので、ヘッダ部受信とデータ部受信の2回に分けて受信する必要がある。そういう場合はメンバにした方が簡単。

2. サーバー起動処理

/// <summary>
/// TCPサーバー開始処理
/// </summary>
/// <param name="port">接続ポート番号</param>
/// <returns>処理結果(0=正常 / -1=失敗)</returns>
public int ServerStart()
{
    // サーバー状態の初期化
    m_running      = true;
    m_connected    = false;
    m_stop_command = false;

    // listenerの初期化
    try
    {
        // 全てのIPアドレスを許可
        m_listener = new TcpListener(IPAddress.Any, m_config.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_server_recv_t = new Thread(ServerRecvThread);
    m_server_recv_t.Start();

    // サーバー(送信スレッド)起動
    m_server_send_t = new Thread(ServerSendThread);
    m_server_send_t.Start();

    return 0;
}


サーバースレッドを起動する処理。すなわち初期化処理。
気を付ける点は、「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. 受信スレッド

/// <summary>
/// サーバー受信スレッド
/// </summary>
/// <param name="_port"></param>
private void ServerRecvThread()
{
    // 終了指令を受信するまで継続
    while (!m_stop_command)
    {
        try
        {
            // 未接続
            if (!m_connected)
            {
                // 接続待ち(ブロッキング)
                // ※ 画面より切断時(m_listener.Stop())は例外句に飛ぶ
                m_client = m_listener.AcceptTcpClient();

                // 接続完了
                m_connected = true;

                // 送受信用stream(送受信タイムアウト)の設定
                m_tcp_stream = m_client.GetStream();
                m_tcp_stream.ReadTimeout  = 1000;
                m_tcp_stream.WriteTimeout = 1000;

                // 受信バッファの初期化
                m_recv_offset = 0;
                m_next_expect_length = HDR_SIZE;
                Array.Clear(m_recv_buffer, 0, m_recv_buffer.Length);
            }

            // 接続済み
            if (m_connected)
            {
                // 受信処理
                ProcessRecv();
            }
        }
        catch (Exception e)
        {
            // 受信待ち中に画面から切断された場合
            break;
        }
    }

    // クライアント終了処理
    if (m_client != null)
    {
        m_tcp_stream.Close();
        m_client.Close();
        m_client = null;
    }
}


サーバー受信スレッドの実体。
ポイントは、「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. 受信処理

/// <summary>
/// データ受信処理
/// </summary>
public void ProcessRecv()
{
    try
    {
        // ヘッダ部 or データ部の受信を行う
        int recv_length = m_tcp_stream.Read(m_recv_buffer, m_recv_offset, m_next_expect_length);
        m_recv_offset += recv_length;

        // クライアントから切断された場合
        if (recv_length == 0)
        {
            ServerReset();
            return;
        }

        // ヘッダ部(データサイズ)解析
        int frame_length = m_recv_buffer[0] * 0x100 + m_recv_buffer[1];

        // 受信サイズ異常
        if (frame_length > m_recv_buffer.Length)
        {
            ServerReset();
            return;
        }

        // 残りの期待する受信データ数を算出
        int expect_length = frame_length - m_recv_offset;

        // 残りの受信が必要で、受信可能データがある場合
        if (expect_length > 0 && m_tcp_stream.DataAvailable)
        {
            try
            {
                int recv2_length = m_tcp_stream.Read(m_recv_buffer, m_recv_offset, expect_length);
                m_recv_offset += recv2_length;
            }
            catch (Exception e)
            {
                ServerReset();
            }
        }

        // データをフレーム長分受信した場合
        if (frame_length == m_recv_offset)
        {
            // データ解析処理
            ParseRecvData(m_recv_buffer, m_recv_offset);

            // 次回はまたヘッダ部から受信開始する
            m_recv_offset = 0;
            Array.Clear(m_recv_buffer, 0, m_recv_buffer.Length);
            m_next_expect_length = HDR_SIZE;
        }
        // 不足している場合
        else if (frame_length > m_recv_offset)
        {
            // 不足分のデータを待つ
            m_next_expect_length = frame_length - m_recv_offset;
        }
        // フレーム長以上受信した場合
        else
        {
            // 異常処理
            ServerReset();
        }
    }
    catch (System.IO.IOException e)
    {
        // 受信タイムアウト
    }
    catch (Exception e)
    {
        // その他の例外
    }
}


受信処理の実体。
ポイントはNetworkStreamの「Read()」である。これは接続時に指定した「ReadTimeout」の時間、受信を待ち(ブロッキング)、タイムアウトすると例外(IOException)を吐く。これがC言語ユーザには違和感満載である。

また、本処理は「ヘッダ部受信」→「ヘッダ部から残りサイズ計算」→「残りサイズ受信」という流れで組んでいるが、プロトコルが不明な場合は、Readの第三引数(最大受信byte数)を大きな値にしてしまえばよい。

5. 送信スレッド

/// <summary>
/// サーバー送信スレッド
/// </summary>
private void ServerSendThread()
{
    DateTime old_time = DateTime.Now;
    DateTime health_send_time = DateTime.Now.AddSeconds(-m_config.health_cycle);

    // 送信中の切断実施のキャッチ
    try
    {
        while (!m_stop_command)
        {
            // 1s周期(時計と同期)
            DateTime next_time  = DateTime.Now.AddSeconds(1).AddMilliseconds(-DateTime.Now.Millisecond);
            TimeSpan sleep_time = next_time - DateTime.Now;
            System.Threading.Thread.Sleep((int)sleep_time.TotalMilliseconds);

            // 未接続
            if (!m_connected)
            {
                // 接続待ち
                continue;
            }

            // 自動送信:ヘルスチェック
            if (m_config.auto_health > 0)
            {
                TimeSpan health_passed_time = DateTime.Now - health_send_time;
                if (health_passed_time.TotalSeconds >= m_config.health_cycle)
                {
                    // 送信
                    SendData03();
                    health_send_time = DateTime.Now.AddMilliseconds(-DateTime.Now.Millisecond);
                }
            }

            // 前回送信時刻を更新
            old_time = DateTime.Now;
        }
    }
    catch (Exception e)
    {
        // 異常処理
    }
}


送信スレッド。何秒周期かにヘルスチェック(生存確認)を投げるだけ。
任意のタイミング(画面からのボタン操作)で投げたい場合は、送信処理をpublicで作成し、それをメイン画面から呼ぶ仕組みにするとよい。

6. サーバーリセット

        /// <summary>
        /// TCPサーバーリセット処理
        /// </summary>
        private void ServerReset()
        {
            // 「未接続」に更新
            m_connected = false;

            // クライアントクローズ
            if (m_client != null)
            {
                m_tcp_stream.Close();
                m_client.Close();
                m_client = null;
            }

            // 受信バッファ初期化
            m_recv_offset = 0;
            m_next_expect_length = HDR_SIZE;
            Array.Clear(m_recv_buffer, 0, m_recv_buffer.Length);
        }


受信異常時等のリセット処理。m_client、m_tcp_streamのクローズにてクライアントと切断し、再度接続待ち状態に戻る。

まとめ

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

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