C#網絡編程系列文章計劃簡單地講述網絡編程方面的基礎知識,因爲本人在這方面功力有限,因此只能提供一些初步的入門知識,但願能對剛開始學習的朋友提供一些幫助。若是想要更加深刻的內容,能夠參考相關書籍。html
本文是該系列第一篇,主要講述了基於套接字(Socket)進行網絡編程的基本概念,其中包括TCP協議、套接字、聊天程序的三種開發模式,以及兩個基本操做:偵聽端口、鏈接遠程服務端;第二篇講述了一個簡單的範例:從客戶端傳輸字符串到服務端,服務端接收並打印字符串,將字符串改成大寫,而後再將字符串回發到客戶端,客戶端最後打印傳回的字符串;第三篇是第二篇的一個強化,講述了第二篇中沒有解決的一個問題,並使用了異步傳輸的方式來完成和第二篇一樣的功能;第四篇則演示瞭如何在客戶端與服務端之間收發文件;第五篇實現了一個可以在線聊天並進行文件傳輸的聊天程序,其實是對前面知識的一個綜合應用。ios
與本文相關的還有一篇文章是:C#編寫簡單的聊天程序,但這個聊天程序不及本系列中的聊天程序功能強大,實現方式也不相同。編程
對於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就是端口。將端口分配給一個應用程序,那麼來自這個端口的數據則老是針對這個應用程序的。有這樣一個很好的例子:能夠將主機地址想象爲電話號碼,而將端口號想象爲分機號。post
在.NET中,儘管咱們能夠直接對套接字編程,可是.NET提供了兩個類將對套接字的編程進行了一個封裝,使咱們的使用可以更加方便,這兩個類是TcpClient和TcpListener,它與套接字的關係以下:
從上面圖中能夠看出TcpClient和TcpListener對套接字進行了封裝。從中也能夠看出,TcpListener位於接收流的位置,TcpClient位於輸出流的位置(實際上TcpListener在收到一個請求後,就建立了TcpClient,而它自己則持續處於偵聽狀態,收發數據均可以由TcpClient完成。這個圖有點不夠準確,而我暫時沒有想到更好的畫法,後面看到代碼時會更加清楚一些)。
咱們考慮這樣一種狀況:兩臺主機,主機A和主機B,起初它們誰也不知道誰在哪兒,當它們想要進行對話時,老是須要有一方發起鏈接,而另外一方則須要對本機的某一端口進行偵聽。而在偵聽方收到鏈接請求、並創建起鏈接之後,它們之間進行收發數據時,發起鏈接的一方並不須要再進行偵聽。由於鏈接是全雙工的,它可使用現有的鏈接進行收發數據。而咱們前面已經作了定義:將發起鏈接的一方稱爲客戶端,另外一段稱爲服務端,則如今能夠得出:老是服務端在使用TcpListener類,由於它須要創建起一個初始的鏈接。
實現一個網絡聊天程序本應是最後一篇文章的內容,也是本系列最後的一個程序,來做爲一個終結。可是我想後面更多的是編碼,講述的內容應該不會太多,因此仍是把講述的東西都放到這裏吧。
當採用這種模式時,便是所謂的徹底點對點模式,此時每臺計算機自己也是服務器,由於它須要進行端口的偵聽。實現這個模式的難點是:各個主機(或終端)之間如何知道其它主機的存在?此時一般的作法是當某一主機上線時,使用UDP協議進行一個廣播(Broadcast),經過這種方式來「告知」其它主機本身已經在線並說明位置,收到廣播的主機發回一個應答,此時主機便知道其餘主機的存在。這種方式我我的並不喜歡,但在 C#編寫簡單的聊天程序 這篇文章中,我使用了這種模式,惋惜的是我沒有實現廣播,因此還很不完善。
第二種方式較好的解決了上面的問題,它引入了服務器,由這個服務器來專門進行廣播。服務器持續保持對端口的偵聽狀態,每當有主機上線時,首先鏈接至服務器,服務器收到鏈接後,將該主機的位置(地址和端口號)發往其餘在線主機(綠色箭頭標識)。這樣其餘主機便知道該主機已上線,並知道其所在位置,從而能夠進行鏈接和對話。在服務器進行了廣播以後,由於各個主機已經知道了其餘主機的位置,所以主機之間的對話就再也不經過服務器(黑色箭頭表示),而是直接進行鏈接。所以,使用這種模式時,各個主機依然須要保持對端口的偵聽。在某臺主機離線時,與登陸時的模式相似,服務器會收到通知,而後轉告給其餘的主機。
第三種模式是我以爲最簡單也最實用的一種,主機的登陸與離線與第二種模式相同。注意到每臺主機在上線時首先就與服務器創建了鏈接,那麼從主機A發往主機B發送消息,就能夠經過這樣一條路徑,主機A --> 服務器 --> 主機B,經過這種方式,各個主機不須要在對端口進行偵聽,而只須要服務器進行偵聽就能夠了,大大地簡化了開發。
而對於一些較大的文件,好比說圖片或者文件,若是想由主機A發往主機B,若是經過服務器進行傳輸效率會比較低,此時能夠臨時搭建一個主機A至主機B之間的鏈接,用於傳輸大文件。當文件傳輸結束以後再關閉鏈接(桔紅色箭頭標識)。
除此之外,因爲消息都通過服務器,因此服務器還能夠緩存主機間的對話,便是說當主機A發往主機B時,若是主機B已經離線,則服務器能夠對消息進行緩存,當主機B下次鏈接到服務器時,服務器自動將緩存的消息發給主機B。
本系列文章最後採用的便是此種模式,不過沒有實現過多複雜的功能。接下來咱們的理論知識告一段落,開始下一階段――漫長的編碼。
接下來咱們開始編寫一些實際的代碼,第一步就是開啓對本地機器上某一端口的偵聽。首先建立一個控制檯應用程序,將項目名稱命名爲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」退出那段代碼是必要的,下面的每段程序都會含有這個代碼段,但爲了節省空間,我都省略掉了。
當服務器開始對端口偵聽以後,即可以建立客戶端與它創建鏈接。這一步是經過在客戶端建立一個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
從這裏咱們能夠得出幾個重要信息:一、端口8500和端口4761創建了鏈接,這個4761端口即是客戶端用來與服務端進行通訊的端口;二、8500端口在與客戶端創建起一個鏈接後,仍然繼續保持在監聽狀態。這也就是說一個端口能夠與多個遠程端口創建通訊,這是顯然的,你們衆所周之的HTTP使用的默認端口爲80,可是一個Web服務器要經過這個端口與多少個瀏覽器通訊啊。
那麼既然一個服務器端口能夠應對多個客戶端鏈接,那麼接下來咱們就看一下,如何讓多個客戶端與服務端鏈接。如同咱們上面所說的,一個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()方法是一個異步方法。
上面服務端、客戶端的代碼已經創建起了鏈接,這經過使用「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
如今咱們再接着考慮,若是有多個客戶端發動對服務器端的鏈接會怎麼樣,爲了不你將瀏覽器向上滾動,來查看上面的代碼,我將它拷貝了下來,咱們先看下客戶端的關鍵代碼:
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
本篇文章到此就結束了,接下來一篇咱們來看看如何在服務端與客戶端之間收發數據。
出處:http://www.cnblogs.com/JimmyZhang/archive/2008/09/07/1286300.html