The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

使用 TcpClient 與 NetworkStream 類別開發時的注意事項

我昨天試著用 TcpClient 類別 模擬 telnet 指令企圖登入一台公司用的網路設備,想透過 .NET 直接下 Command Line 指令修改參數與取得設備狀態,但寫到一半卻遇到一個奇怪的狀況,狀況說明如下:

基本上我的程式開發的動作如下:

1. 建立 TcpClient 連線

TcpClient tcpClient = new TcpClient();
tcpClient.Connect(ipAddress, port);
tcpClient.NoDelay = false;

2. 先讀取 NetworkStream 的回應,目的是取得登入提示字元

NetworkStream netStream = tcpClient.GetStream();
if (netStream.CanRead)
{
    byte[] bytes = new byte[tcpClient.ReceiveBufferSize];
    int numBytesRead = netStream.Read(bytes, 0, (int)tcpClient.ReceiveBufferSize);
    byte[] bytesRead = new byte[numBytesRead];
    Array.Copy(bytes, bytesRead, numBytesRead);
    string returndata = Encoding.ASCII.GetString(bytesRead);
}

3. 接著我輸入帳號

if (netStream.CanWrite)
{
    byte[] writeData = Encoding.ASCII.GetBytes(write);
    netStream.Write(writeData, 0, writeData.Length);
}

4. 然後我再次讀取 NetworkStream 的回應,企圖取得密碼提示字元。但這次我卻讀到的是我剛剛 write 的內容,而且只取得第一個字元而已,並沒有得到任何密碼提示字元,然後就卡在這裡了。

我是用非常直覺的方式在開發 TcpClient 程式,基本上就像是我們在用 telnet 指令登入時的動作是一樣的,我原本以為在第 3 步驟寫入資料到 NetworkStream 時會等寫入完成,但感覺卻完全沒有等我寫入完成就直接執行下一步了,導致我第 4 步驟太快執行,只抓到我 write 的第一個字元而已。

原本想像如此簡單的應用可以很快的完成,但美好的感覺往往事與願違!當我們在開發網路底層的應用時,要考慮的因素還蠻多的,有時後直覺也有可能是錯的。

由於 MSDN 上的範例都十分的簡單,簡單到我直接套用都會出問題,因為我的設備是在遠端,所以會有網路延遲的問題,這導致我在讀寫的過程中會發生一些無法預期的錯誤。

我在 MSDN 文件 NetworkStream 類別備註中發現以下這段話:

讀取和寫入作業可以在 NetworkStream 類別的執行個體上同步執行,而不需要進行同步處理。只要 "寫入作業和讀取作業各自有專用的執行緒" ,讀取和寫入執行緒之間就不會互相干擾,因此不需要同步處理。

我第一次看這份文件時還看不太懂,但仔細咀嚼、思考後,中與瞭解他的意思了,也因此確立了一些我從以前到現在都存在的錯誤觀念:我以為 TCP 網路之間的傳輸是同步的 ( 這是錯誤觀念 )

我以前一直以為 TCP 是同步的,因為我常常會用 Telnet 工具測試 HTTP / SMTP 等 TCP-based 通訊協定,都是 Request / Response 的過程互動,但如今我終於懂了,原來 TCP 網路之間的傳輸是非同步的,所有的 NetworkStrem 物件的「讀」與「寫」可以完全分開來寫,完全不會互相影響,會影響的僅僅是你寫程式的判斷邏輯而已。

例如說如果你若要先讀取 Server 端的回應進行判斷,判斷後才會決定什麼資料要寫入 NetworkStream 的話,就必須先等 Server 把封包全部送過來後才可以判斷 Read 已經暫時結束。所以在讀資料前必須先利用 System.Threading.Thread.Sleep(1000); 讓程式暫停執行一下,等 Server 把封包送完 (這個等待時間你必須要自行判斷,沒有一定的標準),再執行 NetworkStream.Read() 方法

有了這層體悟,讓我對網路底層的掌握程度又更加精準了。接著我修改程式的寫法,比原本我寫的第一版程式更穩定、更簡潔,執行的流程如下:

  1. 建立 TcpClient 連線。
  2. 取得 TcpClient 的 NetworkStream 物件。
    NetworkStream netStream = tcpClient.GetStream();
  3. 一次寫入所有要執行的指令,並等待 3 秒確保指令有時間可寫入到遠端主機。
  4. 最後再一次回傳所有從 Server 回傳的所有資料。

我也在此分享我寫的測試程式,這程式很,請見諒,純粹是測試用途而已:

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

namespace ConsoleApplication1
{
    class Program
    {
        static void Main(string[] args)
        {
            string ipAddress = "127.0.0.1";
            int port = 23;

            TcpClient tcpClient = new TcpClient();

            tcpClient.Connect(ipAddress, port);

            tcpClient.NoDelay = false;

            NetworkStream netStream = tcpClient.GetStream();

            string output = "";

            string[] commands = new string[] {
                "username",
                "password",
                "commandName param1 param2",
                "exit"
            };

            WriteLine(netStream, String.Join("\n", commands) + "\n");

            output = ReadLine(tcpClient, netStream, output);

            tcpClient.Close();
        }

        private static void WriteLine(NetworkStream netStream, string write)
        {
            if (netStream.CanWrite)
            {
                byte[] writeData = Encoding.ASCII.GetBytes(write);

                netStream.Write(writeData, 0, writeData.Length);

                // 需等待資料真的已寫入 NetworkStream
                Thread.Sleep(3000);

                Console.WriteLine();
                Console.WriteLine("Write: " + write);
                Console.WriteLine("-------------------------");
            }
        }

        private static string ReadLine(TcpClient tcpClient, NetworkStream netStream, 
            string output)
        {
            if (netStream.CanRead)
            {
                byte[] bytes = new byte[tcpClient.ReceiveBufferSize];

                int numBytesRead = netStream.Read(bytes, 0,
                    (int)tcpClient.ReceiveBufferSize);

                byte[] bytesRead = new byte[numBytesRead];
                Array.Copy(bytes, bytesRead, numBytesRead);

                string returndata = Encoding.ASCII.GetString(bytesRead);

                output = String.Format("Read: Length: {0}, Data: \r\n{1}",
                    returndata.Length, returndata);
            }

            Console.WriteLine();
            Console.WriteLine(output);
            Console.WriteLine("-------------------------");

            return output.Trim();
        }
    }
}

最後的注意事項與心得就是:

  • 讀取和寫入作業可以在 NetworkStream 類別的執行個體上同步執行,而且不需要進行同步處理。
  • 若要「同步」處理 Request / Response 指令,不管是都必須要讓程式一下,確保資料已完整寫入 NetworkStream,否則可能會得到不可預期的錯誤。
  • 開發時可參考 MSDN 上的建議,將「寫入作業」和「讀取作業」各自使用「專屬的執行緒(Thread)」 進行開發,因為「讀取執行緒」和「寫入執行緒」之間本來就不會互相干擾。
  • 若要開發「自動化」下指令的程式,對伺服器要下的指令執行順序已經確定的話,可以把所有指令一次都給全部送出,然後再等 Server 回應即可。

相關連結