這篇文章我們將前進一大步,使用異步的方式來對服務端編程,以使它成為一個真正意義上的服務器 :可以為多個客戶端的多次請求服務。但是開始之前,我們需要解決上一節中遺留的一個問題。
消息發送時的問題
這個問題就是:客戶端分兩次向流中寫入數據(比如字符串)時,我們主觀上將這兩次寫入視為兩次 請求;然而服務端有可能將這兩次合起來視為一條請求,這在兩個請求間隔時間比較短的情況下尤其如此 。同樣,也有可能客戶端發出一條請求,但是服務端將其視為兩條請求處理。下面列出了可能的情況,假 設我們在客戶端連續發送兩條“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],但是後面的長度卻不匹配,此時程序就會出錯。可選的解決辦法是對“[”和“]”進行編 碼,當客戶端有意輸入這兩個字符時,我們將它替換成“\[”和“\]”或者別的字符,在讀取後再將它還 原 。
關於這個范例就到此結束了,剩下的兩個范例都將采用異步傳輸的方式,並且會加入更多的協議內容 。下一篇我們將介紹如何向服務端發送或接收文件。
本文配套源碼