?
源碼下載:http://www.tracefact.net/SourceCode/Network-Part3.rar
C#網絡編程(異步傳輸字符串) - Part.3
這篇文章我們將前進一大步,使用異步的方式來對服務端編程,以使它成為一個真正意義上的服務器:可以為多個客戶端的多次請求服務。但是開始之前,我們需要解決上一節中遺留的一個問題。
消息發送時的問題
這個問題就是:客戶端分兩次向流中寫入數據(比如字符串)時,我們主觀上將這兩次寫入視為兩次請求;然而服務端有可能將這兩次合起來視為一條請求,這在兩個請求間隔時間比較短的情況下尤其如此。同樣,也有可能客戶端發出一條請求,但是服務端將其視為兩條請求處理。下面列出了可能的情況,假設我們在客戶端連續發送兩條“Welcome to Tracefact.net!”,則數據到達服務端時可能有這樣三種情況:
NOTE:在這里我們假設采用ASCII編碼方式,因為此時上面的一個方框正好代表一個字節,而字符串到達末尾后為持續的0(因為byte是值類型,且最小為0)。
上面的第一種情況是最理想的情況,此時兩條消息被視為兩個獨立請求由服務端完整地接收。第二種情況的示意圖如下,此時一條消息被當作兩條消息接收了:
而對于第三種情況,則是兩條消息被合并成了一條接收:
如果你下載了上一篇文章所附帶的源碼,那么將Client2.cs進行一下修改,不通過用戶輸入,而是使用一個for循環連續的發送三個請求過去,這樣會使請求的間隔時間更短,下面是關鍵代碼:
string msg = "Welcome to TraceFact.Net!";
for (int i = 0; i <= 2; i++) {
??? byte[] buffer = Encoding.Unicode.GetBytes(msg);???? // 獲得緩存
??? try {
??????? streamToServer.Write(buffer, 0, buffer.Length); // 發往服務器
??????? Console.WriteLine("Sent: {0}", msg);
??? } catch (Exception ex) {
??????? Console.WriteLine(ex.Message);
??????? break;
??? }
}
運行服務端,然后再運行這個客戶端,你可能會看到這樣的結果:
可以看到,盡管上面將消息分成了三條單獨發送,但是服務端卻將后兩條合并成了一條。對于這些情況,我們可以這樣處理:就好像HTTP協議一樣,在實際的請求和應答內容之前包含了HTTP頭,其中是一些與請求相關的信息。我們也可以訂立自己的協議,來解決這個問題,比如說,對于上面的情況,我們就可以定義這樣一個協議:
[length=XXX]:其中xxx是實際發送的字符串長度(注意不是字節數組buffer的長度),那么對于上面的請求,則我們發送的數據為:“[length=25]Welcome to TraceFact.Net!”。而服務端接收字符串之后,首先讀取這個“元數據”的內容,然后再根據“元數據”內容來讀取實際的數據,它可能有下面這樣兩種情況:
NOTE:我覺得這里借用“元數據”這個術語還算比較恰當,因為“元數據”就是用來描述數據的數據。
- “[“”]”中括號是完整的,可以讀取到length的字節數。然后根據這個數值與后面的字符串長度相比,如果相等,則說明發來了一條完整信息;如果多了,那么說明接收的字節數多了,取出合適的長度,并將剩余的進行緩存;如果少了,說明接收的不夠,那么將收到的進行一個緩存,等待下次請求,然后將兩條合并。
- “[”“]”中括號本身就不完整,此時讀不到length的值,因為中括號里的內容被截斷了,那么將讀到的數據進行緩存,等待讀取下次發送來的數據,然后將兩次合并之后再按上面的方式進行處理。
接下來我們來看下如何來進行實際的操作,實際上,這個問題已經不屬于C#網絡編程的內容了,而完全是對字符串的處理。所以我們不再編寫服務端/客戶端代碼,直接編寫處理這幾種情況的方法:
public class RequestHandler {
??? private string temp = string.Empty;
??? public string[] GetActualString(string input) {
??????? return GetActualString(input, null);
??? }
??? private string[] GetActualString(string input, List<string> outputList) {
??????? if (outputList == null)
??????????? outputList = new List<string>();
??????? if (!String.IsNullOrEmpty(temp))
??????????? input = temp + input;
??????? string output = "";
??????? string pattern = @"(?<=^\[length=)(\d+)(?=\])";
??????? int length;
???????????????????
??????? if (Regex.IsMatch(input, pattern)) {
??????????? Match m = Regex.Match(input, pattern);
??????????? // 獲取消息字符串實際應有的長度
??????????? length = Convert.ToInt32(m.Groups[0].Value);
??????????? // 獲取需要進行截取的位置
??????????? int startIndex = input.IndexOf(']') + 1;
??????????? // 獲取從此位置開始后所有字符的長度
??????????? output = input.Substring(startIndex);
??????????? if (output.Length == length) {
??????????????? // 如果output的長度與消息字符串的應有長度相等
??????????????? // 說明剛好是完整的一條信息
??????????????? outputList.Add(output);
??????????????? temp = "";
??????????? } else if (output.Length < length) {
??????????????? // 如果之后的長度小于應有的長度,
??????????????? // 說明沒有發完整,則應將整條信息,包括元數據,全部緩存
??????????????? // 與下一條數據合并起來再進行處理
??????????????? temp = input;
??????????????? // 此時程序應該退出,因為需要等待下一條數據到來才能繼續處理
??????????? } else if (output.Length > length) {
??????????????? // 如果之后的長度大于應有的長度,
??????????????? // 說明消息發完整了,但是有多余的數據
??????????????? // 多余的數據可能是截斷消息,也可能是多條完整消息
??????????????? // 截取字符串
??????????????? output = output.Substring(0, length);
??????????????? outputList.Add(output);
??????????????? temp = "";
??????????????? // 縮短input的長度
??????????????? input = input.Substring(startIndex + length);
??????????????? // 遞歸調用
??????????????? GetActualString(input, outputList);
??????????? }
??????? } else {??? // 說明“[”,“]”就不完整
??????????? temp = input;
??????? }
??????? return outputList.ToArray();
??? }
}
這個方法接收一個滿足協議格式要求的輸入字符串,然后返回一個數組,這是因為如果出現多次請求合并成一個發送過來的情況,那么就將它們全部返回。隨后簡單起見,我在這個類中添加了一個靜態的Test()方法和PrintOutput()幫助方法,進行了一個簡單的測試,注意我直接輸入了length=13,這個是我提前計算好的。
public static void Test() {
??? RequestHandler handler = new RequestHandler();
??? string input;
??? // 第一種情況測試 - 一條消息完整發送
??? input = "[length=13]明天中秋,祝大家節日快樂!";
??? handler.PrintOutput(input);
??? // 第二種情況測試 - 兩條完整消息一次發送
??? input = "明天中秋,祝大家節日快樂!";
??? input = String.Format
??????? ("[length=13]{0}[length=13]{0}", input);
??? handler.PrintOutput(input);
??? // 第三種情況測試A - 兩條消息不完整發送
??? input = "[length=13]明天中秋,祝大家節日快樂![length=13]明天中秋";
??? handler.PrintOutput(input);
??? input = ",祝大家節日快樂!";
??? handler.PrintOutput(input);
??? // 第三種情況測試B - 兩條消息不完整發送
??? input = "[length=13]明天中秋,祝大家";
??? handler.PrintOutput(input);
??? input = "節日快樂![length=13]明天中秋,祝大家節日快樂!";
??? handler.PrintOutput(input);
???
??? // 第四種情況測試 - 元數據不完整
??? input = "[leng";
??? handler.PrintOutput(input);???? // 不會有輸出
??? input = "th=13]明天中秋,祝大家節日快樂!";
??? handler.PrintOutput(input);
}
// 用于測試輸出
private void PrintOutput(string input) {
??? Console.WriteLine(input);
??? string[] outputArray = GetActualString(input);
??? foreach (string output in outputArray) {
??????? Console.WriteLine(output);
??? }
??? Console.WriteLine();
}
運行上面的程序,可以得到如下的輸出:
OK,從上面的輸出可以看到,這個方法能夠滿足我們的要求。對于這篇文章最開始提出的問題,可以很輕松地通過加入這個方法來解決,這里就不再演示了,但在本文所附帶的源代碼含有修改過的程序。在這里花費了很長的時間,接下來讓我們回到正題,看下如何使用異步方式完成上一篇中的程序吧。
異步傳輸字符串
在上一篇中,我們由簡到繁,提到了服務端的四種方式:服務一個客戶端的一個請求、服務一個客戶端的多個請求、服務多個客戶端的一個請求、服務多個客戶端的多個請求。我們說到可以將里層的while循環交給一個新建的線程去讓它來完成。除了這種方式以外,我們還可以使用一種更好的方式――使用線程池中的線程來完成。我們可以使用BeginRead()、BeginWrite()等異步方法,同時讓這BeginRead()方法和它的回調方法形成一個類似于while的無限循環:首先在第一層循環中,接收到一個客戶端后,調用BeginRead(),然后為該方法提供一個讀取完成后的回調方法,然后在回調方法中對收到的字符進行處理,隨后在回調方法中接著調用BeginRead()方法,并傳入回調方法本身。
由于程序實現功能和上一篇完全相同,我就不再細述了。而關于異步調用方法更多詳細內容,可以參見 C#中的委托和事件(續)。
1.服務端的實現
當程序越來越復雜的時候,就需要越來越高的抽象,所以從現在起我們不再把所有的代碼全部都扔進Main()里,這次我創建了一個RemoteClient類,它對于服務端獲取到的TcpClient進行了一個包裝:
public class RemoteClient {
??? private TcpClient client;
??? private NetworkStream streamToClient;
??? private const int BufferSize = 8192;
??? private byte[] buffer;
??? private RequestHandler handler;
???
??? public RemoteClient(TcpClient client) {
??????? this.client = client;
??????? // 打印連接到的客戶端信息
??????? Console.WriteLine("\nClient Connected!{0} <-- {1}",
??????????? client.Client.LocalEndPoint, client.Client.RemoteEndPoint);
??????? // 獲得流
??????? streamToClient = client.GetStream();
??????? buffer = new byte[BufferSize];
??????? // 設置RequestHandler
??????? handler = new RequestHandler();
??????? // 在構造函數中就開始準備讀取
??????? AsyncCallback callBack = new AsyncCallback(ReadComplete);
??????? streamToClient.BeginRead(buffer, 0, BufferSize, callBack, null);
??? }
??? // 再讀取完成時進行回調
??? private void ReadComplete(IAsyncResult ar) {
??????? int bytesRead = 0;
??????? try {
??????????? lock (streamToClient) {
??????????????? bytesRead = streamToClient.EndRead(ar);
??????????????? Console.WriteLine("Reading data, {0} bytes ...", bytesRead);
??????????? }
??????????? if (bytesRead == 0) throw new Exception("讀取到0字節");
??????????? string msg = Encoding.Unicode.GetString(buffer, 0, bytesRead);
??????????? Array.Clear(buffer,0,buffer.Length);??????? // 清空緩存,避免臟讀
???????
??????????? string[] msgArray = handler.GetActualString(msg);?? // 獲取實際的字符串
??????????? // 遍歷獲得到的字符串
??????????? foreach (string m in msgArray) {
??????????????? Console.WriteLine("Received: {0}", m);
??????????????? string back = m.ToUpper();
??????????????? // 將得到的字符串改為大寫并重新發送
??????????????? byte[] temp = Encoding.Unicode.GetBytes(back);
??????????????? streamToClient.Write(temp, 0, temp.Length);
??????????????? streamToClient.Flush();
??????????????? Console.WriteLine("Sent: {0}", back);
??????????? }??????????????
??????????? // 再次調用BeginRead(),完成時調用自身,形成無限循環
??????????? lock (streamToClient) {
??????????????? AsyncCallback callBack = new AsyncCallback(ReadComplete);
??????????????? streamToClient.BeginRead(buffer, 0, BufferSize, callBack, null);
??????????? }
??????? } catch(Exception ex) {
??????????? if(streamToClient!=null)
??????????????? streamToClient.Dispose();
??????????? client.Close();
??????????? Console.WriteLine(ex.Message);????? // 捕獲異常時退出程序?????????????
??????? }
??? }
}
隨后,我們在主程序中僅僅創建TcpListener類型實例,由于RemoteClient類在構造函數中已經完成了初始化的工作,所以我們在下面的while循環中我們甚至不需要調用任何方法:
class Server {
??? static void Main(string[] args) {
??????? Console.WriteLine("Server is running ... ");
??????? IPAddress ip = new IPAddress(new byte[] { 127, 0, 0, 1 });
??????? TcpListener listener = new TcpListener(ip, 8500);
??????? listener.Start();?????????? // 開始偵聽
??????? Console.WriteLine("Start Listening ...");
??????? while (true) {
??????????? // 獲取一個連接,同步方法,在此處中斷
??????????? TcpClient client = listener.AcceptTcpClient();?????????????
??????????? RemoteClient wapper = new RemoteClient(client);
??????? }
??? }
}
好了,服務端的實現現在就完成了,接下來我們再看一下客戶端的實現:
2.客戶端的實現
與服務端類似,我們首先對TcpClient進行一個簡單的包裝,使它的使用更加方便一些,因為它是服務端的客戶,所以我們將類的名稱命名為ServerClient:
public class ServerClient {
??? private const int BufferSize = 8192;
??? private byte[] buffer;
??? private TcpClient client;
??? private NetworkStream streamToServer;
??? private string msg = "Welcome to TraceFact.Net!";
??? public ServerClient() {
??????? try {
??????????? client = new TcpClient();
??????????? client.Connect("localhost", 8500);????? // 與服務器連接
??????? } catch (Exception ex) {
??????????? Console.WriteLine(ex.Message);
??????????? return;
??????? }
??????? buffer = new byte[BufferSize];
??????? // 打印連接到的服務端信息
??????? Console.WriteLine("Server Connected!{0} --> {1}",
??????????? client.Client.LocalEndPoint, client.Client.RemoteEndPoint);
??????? streamToServer = client.GetStream();
??? }
??? // 連續發送三條消息到服務端
??? public void SendMessage(string msg) {
??????? msg = String.Format("[length={0}]{1}", msg.Length, msg);
??????? for (int i = 0; i <= 2; i++) {
??????????? byte[] temp = Encoding.Unicode.GetBytes(msg);?? // 獲得緩存
??????????? try {
??? ??????????? streamToServer.Write(temp, 0, temp.Length); // 發往服務器
??????????????? Console.WriteLine("Sent: {0}", msg);
??????????? } catch (Exception ex) {
??????????????? Console.WriteLine(ex.Message);
??????????????? break;
??????????? }
??????? }
??????? lock (streamToServer) {
??????????? AsyncCallback callBack = new AsyncCallback(ReadComplete);
??????????? streamToServer.BeginRead(buffer, 0, BufferSize, callBack, null);
??????? }
??? }
??? public void SendMessage() {
??????? SendMessage(this.msg);
??? }
??? // 讀取完成時的回調方法
??? private void ReadComplete(IAsyncResult ar) {
??????? int bytesRead;
??????? try {
??????????? lock (streamToServer) {
??????????????? bytesRead = streamToServer.EndRead(ar);
??????????? }
??????????? if (bytesRead == 0) throw new Exception("讀取到0字節");
??????????? string msg = Encoding.Unicode.GetString(buffer, 0, bytesRead);
??????????? Console.WriteLine("Received: {0}", msg);
??????????? Array.Clear(buffer, 0, buffer.Length);????? // 清空緩存,避免臟讀
??????????? lock (streamToServer) {
??????????????? AsyncCallback callBack = new AsyncCallback(ReadComplete);
??????????????? streamToServer.BeginRead(buffer, 0, BufferSize, callBack, null);
??????????? }
??????? } catch (Exception ex) {
??????????? if(streamToServer!=null)
??????????????? streamToServer.Dispose();
??????????? client.Close();
??????????? Console.WriteLine(ex.Message);
??????? }
??? }
}
在上面的SendMessage()方法中,我們讓它連續發送了三條同樣的消息,這么僅僅是為了測試,因為異步操作同樣會出現上面說過的:服務器將客戶端的請求拆開了的情況。最后我們在Main()方法中創建這個類型的實例,然后調用SendMessage()方法進行測試:
class Client {
??? static void Main(string[] args) {
??? ??? ConsoleKey key;
??????? ServerClient client = new ServerClient();
??????? client.SendMessage();
???????
??????? Console.WriteLine("\n\n輸入\"Q\"鍵退出。");
??????? do {
??????????? key = Console.ReadKey(true).Key;
??????? } while (key != ConsoleKey.Q);
??? }
}
是不是感覺很清爽?因為良好的代碼重構,使得程序在復雜程度提高的情況下依然可以在一定程度上保持良好的閱讀性。
3.程序測試
最后一步,我們先運行服務端,接著連續運行兩個客戶端,看看它們的輸出分別是什么:
大家可以看到,在服務端,我們可以連接多個客戶端,同時為它們服務;除此以外,由接收的字節數發現,兩個客戶端均有兩個請求被服務端合并成了一條請求,因為我們在其中加入了特殊的協議,所以在服務端可以對這種情況進行良好的處理。
在客戶端,我們沒有采取類似的處理,所以當客戶端收到應答時,仍然會發生請求合并的情況。對于這種情況,我想大家已經知道該如何處理了,就不再多費口舌了。
使用這種定義協議的方式有它的優點,但缺點也很明顯,如果客戶知道了這個協議,有意地輸入[length=xxx],但是后面的長度卻不匹配,此時程序就會出錯。可選的解決辦法是對“[”和“]”進行編碼,當客戶端有意輸入這兩個字符時,我們將它替換成“\[”和“\]”或者別的字符,在讀取后再將它還原。
關于這個范例就到此結束了,剩下的兩個范例都將采用異步傳輸的方式,并且會加入更多的協議內容。下一篇我們將介紹如何向服務端發送或接收文件。