引言
C#網絡編程系列文章計劃簡單地講述網絡編程方面的基礎知識,由於本人在這方面功力有限,所以只 能提供一些初步的入門知識,希望能對剛開始學習的朋友提供一些幫助。如果想要更加深入的內容,可以 參考相關書籍。
本文是該系列第一篇,主要講述了基於套接字(Socket)進行網絡編程的基本概念,其中包括TCP協議 、套接字、聊天程序的三種開發模式,以及兩個基本操作:偵聽端口、連接遠程服務端;第二篇講述了一 個簡單的范例:從客戶端傳輸字符串到服務端,服務端接收並打印字符串,將字符串改為大寫,然後再將 字符串回發到客戶端,客戶端最後打印傳回的字符串;第三篇是第二篇的一個強化,講述了第二篇中沒有 解決的一個問題,並使用了異步傳輸的方式來完成和第二篇同樣的功能;第四篇則演示了如何在客戶端與 服務端之間收發文件;第五篇實現了一個能夠在線聊天並進行文件傳輸的聊天程序,實際上是對前面知識 的一個綜合應用。
與本文相關的還有一篇文章是:C#編寫簡單的聊天程序,但這個聊天程序不及本系列中的聊天程序功 能強大,實現方式也不相同。
網絡編程基本概念
1.面向連接的傳輸協議:TCP
對於TCP協議我不想說太多東西,這屬於大學課程,又涉及計算機科學,而我不是“學院派”,對於這 部分內容,我覺得作為開發人員,只需要掌握與程序相關的概念就可以了,不需要做太艱深的研究。
我們首先知道TCP是面向連接的,它的意思是說兩個遠程主機(或者叫進程,因為實際上遠程通信是進 程之間的通信,而進程則是運行中的程序),必須首先進行一個握手過程,確認連接成功,之後才能傳輸 實際的數據。比如說進程A想將字符串“It's a fine day today”發給進程B,它首先要建立連接。在這 一過程中,它首先需要知道進程B的位置(主機地址和端口號)。隨後發送一個不包含實際數據的請求報 文,我們可以將這個報文稱之為“hello”。如果進程B接收到了這個“hello”,就向進程A回復一個 “hello”,進程A隨後才發送實際的數據“It's a fine day today”。
關於TCP第二個需要了解的,就是它是全雙工的。意思是說如果兩個主機上的進程(比如進程A、進程B ),一旦建立好連接,那麼數據就既可以由A流向B,也可以由B流向A。除此以外,它還是點對點的,意思 是說一個TCP連接總是兩者之間的,在發送中,通過一個連接將數據發給多個接收方是不可能的。TCP還有 一個特性,就是稱為可靠的數據傳輸,意思是連接建立後,數據的發送一定能夠到達,並且是有序的,就 是說發的時候你發了ABC,那麼收的一方收到的也一定是ABC,而不會是BCA或者別的什麼。
編程中與TCP相關的最重要的一個概念就是套接字。我們應該知道網絡七層協議,如果我們將上面的應 用程、表示層、會話層籠統地算作一層(有的教材便是如此劃分的),那麼我們編寫的網絡應用程序就位 於應用層,而大家知道TCP是屬於傳輸層的協議,那麼我們在應用層如何使用傳輸層的服務呢(消息發送 或者文件上傳下載)?大家知道在應用程序中我們用接口來分離實現,在應用層和傳輸層之間,則是使用 套接字來進行分離。它就像是傳輸層為應用層開的一個小口,應用程序通過這個小口向遠程發送數據,或 者接收遠程發來的數據;而這個小口以內,也就是數據進入這個口之後,或者數據從這個口出來之前,我 們是不知道也不需要知道的,我們也不會關心它如何傳輸,這屬於網絡其它層次的工作。
舉個例子,如果你想寫封郵件發給遠方的朋友,那麼你如何寫信、將信打包,屬於應用層,信怎麼寫 ,怎麼打包完全由我們做主;而當我們將信投入郵筒時,郵筒的那個口就是套接字,在進入套接字之後, 就是傳輸層、網絡層等(郵局、公路交管或者航線等)其它層次的工作了。我們從來不會去關心信是如何 從西安發往北京的,我們只知道寫好了投入郵筒就OK了。可以用下面這兩幅圖來表示它:
注意在上面圖中,兩個主機是對等的,但是按照約定,我們將發起請求的一方稱為客戶端,將另一端 稱為服務端。可以看出兩個程序之間的對話是通過套接字這個出入口來完成的,實際上套接字包含的最重 要的也就是兩個信息:連接至遠程的本地的端口信息(本機地址和端口號),連接到的遠程的端口信息(遠 程地址和端口號)。注意上面詞語的微妙變化,一個是本地地址,一個是遠程地址。
這裡又出現了了一個名詞端口。一般來說我們的計算機上運行著非常多的應用程序,它們可能都需要 同遠程主機打交道,所以遠程主機就需要有一個ID來標識它想與本地機器上的哪個應用程序打交道,這裡 的ID就是端口。將端口分配給一個應用程序,那麼來自這個端口的數據則總是針對這個應用程序的。有這 樣一個很好的例子:可以將主機地址想象為電話號碼,而將端口號想象為分機號。
在.NET中,盡管我們可以直接對套接字編程,但是.NET提供了兩個類將對套接字的編程進行了一個封 裝,使我們的使用能夠更加方便,這兩個類是TcpClient和TcpListener,它與套接字的關系如下:
從上面圖中可以看出TcpClient和TcpListener對套接字進行了封裝。從中也可以看出,TcpListener用 於接受連接請求,而TcpClient則用於接收和發送流數據。這幅圖的意思是TcpListener持續地保持對端口 的偵聽,一旦收到一個連接請求後,就可以獲得一個TcpClient對象,而對於數據的發送和接收都有 TcpClient去完成。此時,TcpListener並沒有停止工作,它始終持續地保持對端口的偵聽狀態。
我們考慮這樣一種情況:兩台主機,主機A和主機B,起初它們誰也不知道誰在哪兒,當它們想要進行 對話時,總是需要有一方發起連接,而另一方則需要對本機的某一端口進行偵聽。而在偵聽方收到連接請 求、並建立起連接以後,它們之間進行收發數據時,發起連接的一方並不需要再進行偵聽。因為連接是全 雙工的,它可以使用現有的連接進行收發數據。而我們前面已經做了定義:將發起連接的一方稱為客戶端 ,另一段稱為服務端,則現在可以得出:總是服務端在使用TcpListener類,因為它需要建立起一個初始 的連接。
2.網絡聊天程序的三種模式
實現一個網絡聊天程序本應是最後一篇文章的內容,也是本系列最後的一個程序,來作為一個終結。 但是我想後面更多的是編碼,講述的內容應該不會太多,所以還是把講述的東西都放到這裡吧。
當采用這種模式時,即是所謂的完全點對點模式,此時每台計算機本身也是服務器,因為它需要進行 端口的偵聽。實現這個模式的難點是:各個主機(或終端)之間如何知道其它主機的存在?此時通常的做 法是當某一主機上線時,使用UDP協議進行一個廣播(Broadcast),通過這種方式來“告知”其它主機自 己已經在線並說明位置,收到廣播的主機發回一個應答,此時主機便知道其他主機的存在。這種方式我個 人並不喜歡,但在 C#編寫簡單的聊天程序 這篇文章中,我使用了這種模式,可惜的是我沒有實現廣播, 所以還很不完善。
第二種方式較好的解決了上面的問題,它引入了服務器,由這個服務器來專門進行廣播。服務器持續 保持對端口的偵聽狀態,每當有主機上線時,首先連接至服務器,服務器收到連接後,將該主機的位置( 地址和端口號)發往其他在線主機(綠色箭頭標識)。這樣其他主機便知道該主機已上線,並知道其所在 位置,從而可以進行連接和對話。在服務器進行了廣播之後,因為各個主機已經知道了其他主機的位置, 因此主機之間的對話就不再通過服務器(黑色箭頭表示),而是直接進行連接。因此,使用這種模式時, 各個主機依然需要保持對端口的偵聽。在某台主機離線時,與登錄時的模式類似,服務器會收到通知,然 後轉告給其他的主機。
第三種模式是我覺得最簡單也最實用的一種,主機的登錄與離線與第二種模式相同。注意到每台主機 在上線時首先就與服務器建立了連接,那麼從主機A發往主機B發送消息,就可以通過這樣一條路徑,主機 A --> 服務器 --> 主機B,通過這種方式,各個主機不需要在對端口進行偵聽,而只需要服務器進 行偵聽就可以了,大大地簡化了開發。
而對於一些較大的文件,比如說圖片或者文件,如果想由主機A發往主機B,如果通過服務器進行傳輸 效率會比較低,此時可以臨時搭建一個主機A至主機B之間的連接,用於傳輸大文件。當文件傳輸結束之後 再關閉連接(桔紅色箭頭標識)。
除此以外,由於消息都經過服務器,所以服務器還可以緩存主機間的對話,即是說當主機A發往主機B 時,如果主機B已經離線,則服務器可以對消息進行緩存,當主機B下次連接到服務器時,服務器自動將緩 存的消息發給主機B。
本系列文章最後采用的即是此種模式,不過沒有實現過多復雜的功能。接下來我們的理論知識告一段 落,開始下一階段――漫長的編碼。
基本操作
1.服務端對端口進行偵聽
接下來我們開始編寫一些實際的代碼,第一步就是開啟對本地機器上某一端口的偵聽。首先創建一個 控制台應用程序,將項目名稱命名為ServerConsole,它代表我們的服務端。如果想要與外界進行通信, 第一件要做的事情就是開啟對端口的偵聽,這就像為計算機打開了一個“門”,所有向這個“門”發送的 請求(“敲門”)都會被系統接收到。在C#中可以通過下面幾個步驟完成,首先使用本機Ip地址和端口號 創建一個System.Net.Sockets.TcpListener類型的實例,然後在該實例上調用Start()方法,從而開啟對 指定端口的偵聽。
using System.Net; // 引入這兩個命名空間,以下同
using System.Net.Sockets;
using ... // 略
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 ...");
Console.WriteLine("\n\n輸入\"Q\"鍵退出。");
ConsoleKey key;
do {
key = Console.ReadKey(true).Key;
} while (key != ConsoleKey.Q);
}
}
// 獲得IPAddress對象的另外幾種常用方法:
IPAddress ip = IPAddress.Parse("127.0.0.1");
IPAddress ip = Dns.GetHostEntry("localhost").AddressList[0];
上面的代碼中,我們開啟了對8500端口的偵聽。在運行了上面的程序之後,然後打開“命令提示符” ,輸入“netstat-a”,可以看到計算機器中所有打開的端口的狀態。可以從中找到8500端口,看到它的 狀態是LISTENING,這說明它已經開始了偵聽:
TCP jimmy:1030
0.0.0.0:0 LISTENING
TCP jimmy:3603
0.0.0.0:0 LISTENING
TCP jimmy:8500
0.0.0.0:0 LISTENING
TCP jimmy:netbios-ssn
0.0.0.0:0 LISTENING
在打開了對端口的偵聽以後,服務端必須通過某種方式進行阻塞(比如Console.ReadKey()),使得程 序不能夠因為運行結束而退出。否則就無法使用“netstat -a”看到端口的連接狀態,因為程序已經退出 ,連接會自然中斷,再運行“netstat -a”當然就不會顯示端口了。所以程序最後按“Q”退出那段代碼 是必要的,下面的每段程序都會含有這個代碼段,但為了節省空間,我都省略掉了。
2.客戶端與服務端連接
2.1單一客戶端與服務端連接
當服務器開始對端口偵聽之後,便可以創建客戶端與它建立連接。這一步是通過在客戶端創建一個 TcpClient的類型實例完成。每創建一個新的TcpClient便相當於創建了一個新的套接字Socket去與服務端 通信,.Net會自動為這個套接字分配一個端口號,上面說過,TcpClient類不過是對Socket進行了一個包 裝。創建TcpClient類型實例時,可以在構造函數中指定遠程服務器的地址和端口號。這樣在創建的同時 ,就會向遠程服務端發送一個連接請求(“握手”),一旦成功,則兩者間的連接就建立起來了。也可以 使用重載的無參數構造函數創建對象,然後再調用Connect()方法,在Connect()方法中傳入遠程服務器地 址和端口號,來與服務器建立連接。
這裡需要注意的是,不管是使用有參數的構造函數與服務器連接,或者是通過Connect()方法與服務器 建立連接,都是同步方法(或者說是阻塞的,英文叫block)。它的意思是說,客戶端在與服務端連接成 功、從而方法返回,或者是服務端不存、從而拋出異常之前,是無法繼續進行後繼操作的。這裡還有一個 名為BeginConnect()的方法,用於實施異步的連接,這樣程序不會被阻塞,可以立即執行後面的操作,這 是因為可能由於網絡擁塞等問題,連接需要較長時間才能完成。網絡編程中有非常多的異步操作,凡事都 是由簡入難,關於異步操作,我們後面再討論,現在只看同步操作。
創建一個新的控制台應用程序項目,命名為ClientConsole,它是我們的客戶端,然後添加下面的代碼 ,創建與服務器的連接:
class Client {
static void
Main(string[] args) {
Console.WriteLine("Client Running ...");
TcpClient client = new TcpClient();
try {
client.Connect("localhost", 8500); // 與服務器連接
} catch (Exception ex) {
Console.WriteLine(ex.Message);
return;
}
// 打印連接到的服務端信息
Console.WriteLine("Server Connected!{0} --> {1}",
client.Client.LocalEndPoint, client.Client.RemoteEndPoint);
// 按Q退出
}
}
上面帶代碼中,我們通過調用Connect()方法來與服務端連接。隨後,我們打印了這個連接消息:本機 的Ip地址和端口號,以及連接到的遠程Ip地址和端口號。TcpClient的Client屬性返回了一個Socket對象 ,它的LocalEndPoint和RemoteEndPoint屬性分別包含了本地和遠程的地址信息。先運行服務端,再運行 這段代碼。可以看到兩邊的輸出情況如下:
// 服務端:
Server is running ...
Start Listening ...
// 客戶端:
Client Running ...
Server Connected!127.0.0.1:4761 --> 127.0.0.1:8500
我們看到客戶端使用的端口號為4761,上面已經說過,這個端口號是由.NET隨機選取的,並不需要我 們來設置,並且每次運行時,這個端口號都不同。再次打開“命令提示符”,輸入“netstat -a”,可以 看到下面的輸出:
TCP jimmy:8500
0.0.0.0:0 LISTENING
TCP jimmy:8500 localhost:4761 ESTABLISHED
TCP jimmy:4761 localhost:8500 ESTABLISHED
從這裡我們可以得出幾個重要信息:1、端口8500和端口4761建立了連接,這個4761端口便是客戶端用 來與服務端進行通信的端口;2、8500端口在與客戶端建立起一個連接後,仍然繼續保持在監聽狀態。這 也就是說一個端口可以與多個遠程端口建立通信,這是顯然的,大家眾所周之的HTTP使用的默認端口為80 ,但是一個Web服務器要通過這個端口與多少個浏覽器通信啊。
2.2 多個客戶端與服務端連接
那麼既然一個服務器端口可以應對多個客戶端連接,那麼接下來我們就看一下,如何讓多個客戶端與 服務端連接。如同我們上面所說的,一個TcpClient就是一個Socket,所以我們只要創建多個TcpClient, 然後再調用Connect()方法就可以了:
class Client {
static void
Main(string[] args) {
Console.WriteLine("Client Running ...");
TcpClient client;
for (int i = 0; i <= 2; i++) {
try {
client = new TcpClient();
client.Connect("localhost", 8500); // 與服務器連 接
} catch (Exception ex) {
Console.WriteLine(ex.Message);
return;
}
// 打印連接到的服務端信息
Console.WriteLine("Server Connected!{0} --> {1}",
client.Client.LocalEndPoint, client.Client.RemoteEndPoint);
}
// 按Q退出
}
}
上面代碼最重要的就是client = new TcpClient()這句,如果你將這個聲明放到循環外面,再循環的 第二趟就會發生異常,原因很顯然:一個TcpClient對象對應一個Socket,一個Socket對應著一個端口, 如果不使用new操作符重新創建對象,那麼就相當於使用一個已經與服務端建立了連接的端口再次與遠程 建立連接。
此時,如果在“命令提示符”運行“netstat -a”,則會看到類似下面的的輸出:
TCP jimmy:8500
0.0.0.0:0 LISTENING
TCP jimmy:8500 localhost:10282 ESTABLISHED
TCP jimmy:8500 localhost:10283 ESTABLISHED
TCP jimmy:8500 localhost:10284 ESTABLISHED
TCP jimmy:10282 localhost:8500 ESTABLISHED
TCP jimmy:10283 localhost:8500 ESTABLISHED
TCP jimmy:10284 localhost:8500 ESTABLISHED
可以看到創建了三個連接對,並且8500端口持續保持偵聽狀態,從這裡以及上面我們可以推斷出 TcpListener的Start()方法是一個異步方法。
3.服務端獲取客戶端連接
3.1 獲取單一客戶端連接
上面服務端、客戶端的代碼已經建立起了連接,這通過使用“netstat -a”命令,從端口的狀態可以 看出來,但這是操作系統告訴我們的。那麼我們現在需要知道的就是:服務端的程序如何知道已經與一個 客戶端建立起了連接?
服務器端開始偵聽以後,可以在TcpListener實例上調用AcceptTcpClient()來獲取與一個客戶端的連 接,它返回一個TcpClient類型實例。此時它所包裝的是由服務端去往客戶端的Socket,而我們在客戶端 創建的TcpClient則是由客戶端去往服務端的。這個方法是一個同步方法(或者叫阻斷方法,block method),意思就是說,當程序調用它以後,它會一直等待某個客戶端連接,然後才會返回,否則就會一 直等下去。這樣的話,在調用它以後,除非得到一個客戶端連接,不然不會執行接下來的代碼。一個很好 的類比就是Console.ReadLine()方法,它讀取輸入在控制台中的一行字符串,如果有輸入,就繼續執行下 面代碼;如果沒有輸入,就會一直等待下去。
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 ...");
// 獲取一個連接,中斷方法
TcpClient remoteClient = listener.AcceptTcpClient();
// 打印連接到的客戶端信息
Console.WriteLine("Client Connected!{0} <-- {1}",
remoteClient.Client.LocalEndPoint, remoteClient.Client.RemoteEndPoint);
// 按Q退出
}
}
運行這段代碼,會發現服務端運行到listener.AcceptTcpClient()時便停止了,並不會執行下面的 Console.WriteLine()方法。為了讓它繼續執行下去,必須有一個客戶端連接到它,所以我們現在運行客 戶端,與它進行連接。簡單起見,我們只在客戶端開啟一個端口與之連接:
class Client {
static void
Main(string[] args) {
Console.WriteLine("Client Running ...");
TcpClient client = new TcpClient();
try {
client.Connect("localhost", 8500); // 與服務器連接
} catch (Exception ex) {
Console.WriteLine(ex.Message);
return;
}
// 打印連接到的服務端信息
Console.WriteLine("Server Connected!{0} --> {1}",
client.Client.LocalEndPoint, client.Client.RemoteEndPoint);
// 按Q退出
}
}
此時,服務端、客戶端的輸出分別為:
// 服務端
Server is running ...
Start Listening ...
Client Connected!127.0.0.1:8500 <-- 127.0.0.1:5188
// 客戶端
Client Running ...
Server Connected!127.0.0.1:5188 --> 127.0.0.1:8500
3.2 獲取多個客戶端連接
現在我們再接著考慮,如果有多個客戶端發動對服務器端的連接會怎麼樣,為了避免你將浏覽器向上 滾動,來查看上面的代碼,我將它拷貝了下來,我們先看下客戶端的關鍵代碼:
TcpClient client;
for (int i = 0; i <=2; i++) {
try {
client = new TcpClient();
client.Connect("localhost", 8500); // 與服務器連接
} catch (Exception ex) {
Console.WriteLine(ex.Message);
return;
}
// 打印連接到的服務端信息
Console.WriteLine("Server Connected!{0} --> {1}",
client.Client.LocalEndPoint, client.Client.RemoteEndPoint);
}
如果服務端代碼不變,我們先運行服務端,再運行客戶端,那麼接下來會看到這樣的輸出:
// 服務端
Server is running ...
Start Listening ...
Client Connected!127.0.0.1:8500 <-- 127.0.0.1:5226
// 客戶端
Client Running ...
Server Connected!127.0.0.1:5226 --> 127.0.0.1:8500
Server Connected!127.0.0.1:5227 --> 127.0.0.1:8500
Server Connected!127.0.0.1:5228 --> 127.0.0.1:8500
就又回到了本章第2.2小節“多個客戶端與服務端連接”中的處境:盡管有三個客戶端連接到了服務端 ,但是服務端程序只接收到了一個。這是因為服務端只調用了一次listener.AcceptTcpClient(),而它只 對應一個連往客戶端的Socket。但是操作系統是知道連接已經建立了的,只是我們程序中沒有處理到,所 以我們當我們輸入“netstat -a”時,仍然會看到3對連接都已經建立成功。
為了能夠接收到三個客戶端的連接,我們只要對服務端稍稍進行一下修改,將AcceptTcpClient方法放 入一個do/while循環中就可以了:
Console.WriteLine("Start Listening ...");
while (true) {
// 獲取一個連接,同步方法
TcpClient remoteClient = listener.AcceptTcpClient();
// 打印連接到的客戶端信息
Console.WriteLine("Client Connected!{0} <-- {1}",
remoteClient.Client.LocalEndPoint, remoteClient.Client.RemoteEndPoint);
}
這樣看上去是一個死循環,但是並不會讓你的機器系統資源迅速耗盡。因為前面已經說過了, AcceptTcpClient()再沒有收到客戶端的連接之前,是不會繼續執行的,它的大部分時間都在等待。另外 ,服務端幾乎總是要保持在運行狀態,所以這樣做並無不可,還可以省去“按Q退出”那段代碼。此時再 運行代碼,會看到服務端可以收到3個客戶端的連接了。
Server is running ...
Start Listening ...
Client Connected!127.0.0.1:8500 <-- 127.0.0.1:5305
Client Connected!127.0.0.1:8500 <-- 127.0.0.1:5306
Client Connected!127.0.0.1:8500 <-- 127.0.0.1:5307
本篇文章到此就結束了,接下來一篇我們來看看如何在服務端與客戶端之間收發數據。
本文配套源碼