我昨天試著用 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() 方法。
有了這層體悟,讓我對網路底層的掌握程度又更加精準了。接著我修改程式的寫法,比原本我寫的第一版程式更穩定、更簡潔,執行的流程如下:
- 建立 TcpClient 連線。
- 取得 TcpClient 的 NetworkStream 物件。
NetworkStream netStream = tcpClient.GetStream();
- 一次寫入所有要執行的指令,並等待 3 秒確保指令有時間可寫入到遠端主機。
- 最後再一次回傳所有從 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 回應即可。
相關連結