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

我昨天試著用 [TcpClient 類別](http://msdn.microsoft.com/zh-tw/library/system.net.sockets.tcpclient\(VS.80\).aspx) 模擬 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 類別](http://msdn.microsoft.com/zh-tw/library/system.net.sockets.networkstream.aspx)**備註**中發現以下這段話：

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

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

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

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

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

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 回應即可。

**相關連結**

-   [NetworkStream 類別](http://msdn.microsoft.com/zh-tw/library/system.net.sockets.networkstream.aspx)
-   [TcpClient 類別](http://msdn.microsoft.com/zh-tw/library/system.net.sockets.tcpclient.aspx)
-   [Using TcpClient to do a telnet session](http://social.msdn.microsoft.com/Forums/en-US/netfxnetcom/thread/57a15405-6ba3-4522-a4ac-c3913215c7a2/)
-   [Quick tool : A minimalistic Telnet library](http://www.codeproject.com/KB/IP/MinimalisticTelnet.aspx)
-   [Telnet Scripting in C#](http://www.c-sharpcorner.com/uploadfile/tylerkline/telnetscripting11282005001158am/telnetscripting.aspx?articleid=a8e0e439-14df-4d82-82ee-8cb4c110f9a0)
-   [RFC854 - Telnet Protocol Specification](http://www.faqs.org/rfcs/rfc854.html)
