C#網路程式設計(基本概念和操作) - Part.1

deeply發表於2021-09-09

C#網路程式設計(基本概念和操作) - Part.1

引言

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完成。這個圖有點不夠準確,而我暫時沒有想到更好的畫法,後面看到程式碼時會更加清楚一些)。

我們考慮這樣一種情況:兩臺主機,主機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 voidMain(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("nn輸入"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 voidMain(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 voidMain(string[] args) {

        Console.WriteLine("Client Running ...");
        TcpClient client;

        for (int i = 0; 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 voidMain(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}            remoteClient.Client.LocalEndPoint, remoteClient.Client.RemoteEndPoint);

        // 按Q退出
    }
}

執行這段程式碼,會發現服務端執行到listener.AcceptTcpClient()時便停止了,並不會執行下面的Console.WriteLine()方法。為了讓它繼續執行下去,必須有一個客戶端連線到它,所以我們現在執行客戶端,與它進行連線。簡單起見,我們只在客戶端開啟一個埠與之連線:

class Client {
    static voidMain(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
// 客戶端
Client Running ...
Server Connected!127.0.0.1:5188 --> 127.0.0.1:8500

3.2獲取多個客戶端連線

現在我們再接著考慮,如果有多個客戶端發動對伺服器端的連線會怎麼樣,為了避免你將瀏覽器向上滾動,來檢視上面的程式碼,我將它複製了下來,我們先看下客戶端的關鍵程式碼:

TcpClient client;

for (int i = 0; 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
// 客戶端
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}         remoteClient.Client.LocalEndPoint, remoteClient.Client.RemoteEndPoint);
}

這樣看上去是一個死迴圈,但是並不會讓你的機器系統資源迅速耗盡。因為前面已經說過了,AcceptTcpClient()再沒有收到客戶端的連線之前,是不會繼續執行的,它的大部分時間都在等待。另外,服務端幾乎總是要保持在執行狀態,所以這樣做並無不可,還可以省去“按Q退出”那段程式碼。此時再執行程式碼,會看到服務端可以收到3個客戶端的連線了。

Server is running ...
Start Listening ...
Client Connected!127.0.0.1:8500 Client Connected!127.0.0.1:8500 Client Connected!127.0.0.1:8500

本篇文章到此就結束了,接下來一篇我們來看看如何在服務端與客戶端之間收發資料。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/1978/viewspace-2811708/,如需轉載,請註明出處,否則將追究法律責任。

相關文章