天天看點

C#網絡程式設計(訂立協定和發送檔案) - Part.4C#網絡程式設計(訂立協定和發送檔案) - Part.4

C#網絡程式設計(訂立協定和發送檔案) - Part.4

檔案傳輸

前面兩篇文章所使用的範例都是傳輸字元串,有的時候我們可能會想在服務端和用戶端之間傳遞檔案。比如,考慮這樣一種情況,假如用戶端顯示了一個菜單,當我們輸入S1、S2或S3(S為Send縮寫)時,分别向服務端發送檔案Client01.jpg、Client02.jpg、Client03.jpg;當我們輸入R1、R2或R3時(R為Receive縮寫),則分别從服務端接收檔案Server01.jpg、Server02.jpg、Server03.jpg。那麼,我們該如何完成這件事呢?此時可能有這樣兩種做法:

  • 類似于FTP協定,服務端開辟兩個端口,并持續對這兩個端口偵聽:一個用于接收字元串,類似于FTP的控制端口,它接收各種指令(接收或發送檔案);一個用于傳輸資料,也就是發送和接收檔案。
  • 服務端隻開辟一個端口,用于接收字元串,我們稱之為控制端口。當接到請求之後,根據請求内容在用戶端開辟一個端口專用于檔案傳輸,并在傳輸結束後關閉端口。

現在我們隻關注于上面的資料端口,回憶一下在第二篇中我們所總結的,可以得出:當我們使用上面的方法一時,服務端的資料端口可以為多個用戶端的多次請求服務;當我們使用方法二時,服務端隻為一個用戶端的一次請求服務,但是因為每次請求都會重新開辟端口,是以實際上還是相當于可以為多個用戶端的多次請求服務。同時,因為它隻為一次請求服務,是以我們在資料端口上傳輸檔案時無需采用異步傳輸方式。但在控制端口我們仍然需要使用異步方式。

從上面看出,第一種方式要好得多,但是我們将采用第二種方式。至于原因,你可以回顧一下

Part.1(基本概念和操作)

中關于聊天程式模式的講述,因為接下來一篇文章我們将建立一個聊天程式,而這個聊天程式采用第三種模式,是以本文的練習實際是對下一篇的一個鋪墊。

1.訂立協定

1.1發送檔案

我們先看一下發送檔案的情況,如果我們想将檔案client01.jpg由用戶端發往用戶端,那麼流程是什麼:

  1. 用戶端開辟資料端口用于偵聽,并擷取端口号,假設為8005。
  2. 假設用戶端輸入了S1,則發送下面的控制字元串到服務端:[file=Client01.jpg, mode=send, port=8005]。
  3. 服務端收到以後,根據用戶端ip和端口号與該用戶端建立連接配接。
  4. 用戶端偵聽到服務端的連接配接,開始發送檔案。
  5. 傳送完畢後用戶端、服務端分别關閉連接配接。

此時,我們訂立的發送檔案協定為:[file=Client01.jpg, mode=send, port=8005]。但是,由于它是一個普通的字元串,在上一篇中,我們采用了正規表達式來擷取其中的有效值,但這顯然不是一種好辦法。是以,在本文及下一篇文章中,我們采用一種新的方式來編寫協定:XML。對于上面的語句,我們可以寫成這樣的XML:

<protocol><file name="client01.jpg" mode="send" port="8005" /></protocol>

這樣我們在服務端就會好處理得多,接下來我們來看一下接收檔案的流程及其協定。

NOTE:這裡說發送、接收檔案是站在用戶端的立場說的,當用戶端發送檔案時,對于伺服器來收,則是接收檔案。

1.2接收檔案

接收檔案與發送檔案實際上完全類似,差別隻是由用戶端向網絡流寫入資料,還是由服務端向網絡流寫入資料。

  1. 用戶端開辟資料端口用于偵聽,假設為8006。
  2. 假設用戶端輸入了R1,則發送控制字元串:<protocol><file name="Server01.jpg" mode="receive" port="8006" /></protocol>到服務端。
  3. 用戶端建立起與服務端的連接配接,服務端開始網絡流中寫入資料。
  4. 傳送完畢後服務端、用戶端分别關閉連接配接。

2.協定處理類的實作

和上面一章一樣,在開始編寫實際的服務端用戶端代碼之前,我們首先要編寫處理協定的類,它需要提供這樣兩個功能:1、友善地幫我們擷取完整的協定資訊,因為前面我們說過,服務端可能将用戶端的多次獨立請求拆分或合并。比如,用戶端連續發送了兩條控制資訊到服務端,而服務端将它們合并了,那麼則需要先拆開再分别處理。2、友善地擷取我們所想要的屬性資訊,因為協定是XML格式,是以還需要一個類專門對XML進行處理,獲得字元串的屬性值。

2.1 ProtocalHandler輔助類

我們先看下ProtocalHandler,它與上一篇中的RequestHandler作用相同。需要注意的是必須将它聲明為執行個體的,而非靜态的,這是因為每個TcpClient都需要對應一個ProtocalHandler,因為它内部維護的patialProtocal不能共享,在協定發送不完整的情況下,這個變量用于臨時儲存被截斷的字元串。

public class ProtocolHandler {

    private string partialProtocal; // 儲存不完整的協定

    public ProtocolHandler() {

        partialProtocal = "";      

    }

    public string[] GetProtocol(string input) {

        return GetProtocol(input, null);

    // 獲得協定

    private string[] GetProtocol(string input, List<string> outputList) {

        if (outputList == null)

            outputList = new List<string>();

        if (String.IsNullOrEmpty(input))

            return outputList.ToArray();

        if (!String.IsNullOrEmpty(partialProtocal))

            input = partialProtocal + input;

        string pattern = "(^<protocol>.*?</protocol>)";

        // 如果有比對,說明已經找到了,是完整的協定

        if (Regex.IsMatch(input, pattern)) {

            // 擷取比對的值

            string match = Regex.Match(input, pattern).Groups[0].Value;

            outputList.Add(match);

            partialProtocal = "";

            // 縮短input的長度

            input = input.Substring(match.Length);

            // 遞歸調用

            GetProtocol(input, outputList);

        } else {

            // 如果不比對,說明協定的長度不夠,

            // 那麼先緩存,然後等待下一次請求

            partialProtocal = input;

        }

        return outputList.ToArray();

}

因為現在它已經不是本文的重點了,是以我就不示範對于它的測試了,本文所附帶的代碼中含有它的測試代碼(我在ProtocolHandler中添加了一個靜态類Test())。

2.2 FileRequestType枚舉和FileProtocol結構

因為XML是以字元串的形式在進行傳輸,為了友善使用,我們最好建構一個強類型來對它們進行操作,這樣會友善很多。我們首先可以定義FileRequestMode枚舉,它代表是發送還是接收檔案:

public enum FileRequestMode {

    Send = 0,

    Receive

接下來我們再定義一個FileProtocol結構,用來為整個協定字元串提供強類型的通路,注意這裡覆寫了基類的ToString()方法,這樣在用戶端我們就不需要再手工去編寫XML,隻要在結構值上調用ToString()就OK了,會友善很多。

public struct FileProtocol {

    private readonly FileRequestMode mode;

    private readonly int port;

    private readonly string fileName;

    public FileProtocol

        (FileRequestMode mode, int port, string fileName) {

        this.mode = mode;

        this.port = port;

        this.fileName = fileName;

    public FileRequestMode Mode {

        get { return mode; }

    public intPort {

        get { return port; }

    public string FileName {

        get { return fileName; }

    public override string ToString() {

        return String.Format("<protocol><file name=\"{0}\" mode=\"{1}\" port=\"{2}\" /></protocol>", fileName, mode, port);

2.3 ProtocolHelper輔助類

這個類專用于将XML格式的協定映射為我們上面定義的強類型對象,這裡我沒有加入try/catch異常處理,因為協定對使用者來說是不可見的,而且用戶端應該總是發送正确的協定,我覺得這樣可以讓代碼更加清晰:

public class ProtocolHelper {

    private XmlNode fileNode;

    private XmlNode root;

    public ProtocolHelper(string protocol) {

        XmlDocument doc = new XmlDocument();

        doc.LoadXml(protocol);

        root = doc.DocumentElement;

        fileNode = root.SelectSingleNode("file");

    // 此時的protocal一定為單條完整protocal

    private FileRequestMode GetFileMode() {

        string mode = fileNode.Attributes["mode"].Value;

        mode = mode.ToLower();

        if (mode == "send")

            return FileRequestMode.Send;

        else

            return FileRequestMode.Receive;

    // 擷取單條協定包含的資訊

    public FileProtocol GetProtocol() {

        FileRequestMode mode = GetFileMode();

        string fileName = "";

        int port = 0;

        fileName = fileNode.Attributes["name"].Value;

        port = Convert.ToInt32(fileNode.Attributes["port"].Value);

        return new FileProtocol(mode, port, fileName);

OK,我們又耽誤了點時間,下面就讓我們進入正題吧。

3.用戶端發送資料

3.1 服務端的實作

我們還是将一個問題分成兩部分來處理,先是發送資料,然後是接收資料。我們先看發送資料部分的服務端。如果你從第一篇文章看到了現在,那麼我覺得更多的不是技術上的問題而是思路,是以我們不再将重點放到代碼上,這些應該很容易就看懂了。

class Server {

    static void Main(string[] args) {

        Console.WriteLine("Server is running ... ");

        IPAddress ip = IPAddress.Parse("127.0.0.1");

        TcpListener listener = new TcpListener(ip, 8500);

        listener.Start();           // 開啟對控制端口 8500 的偵聽

        Console.WriteLine("Start Listening ...");

        while (true) {

            // 擷取一個連接配接,同步方法,在此處中斷

            TcpClient client = listener.AcceptTcpClient();             

            RemoteClient wapper = new RemoteClient(client);

            wapper.BeginRead();

public class RemoteClient {

    private TcpClient client;

    private NetworkStream streamToClient;

    private const int BufferSize = 8192;

    private byte[] buffer;

    private ProtocolHandler 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];

        handler = new ProtocolHandler();

    // 開始進行讀取

    public void BeginRead() {      

        AsyncCallback callBack = new AsyncCallback(OnReadComplete);

        streamToClient.BeginRead(buffer, 0, BufferSize, callBack, null);

    // 再讀取完成時進行回調

    private void OnReadComplete(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);        // 清空緩存,避免髒讀

            // 擷取protocol數組

            string[] protocolArray = handler.GetProtocol(msg);

            foreach (string pro in protocolArray) {

                // 這裡異步調用,不然這裡可能會比較耗時

                ParameterizedThreadStart start =

                    new ParameterizedThreadStart(handleProtocol);

                start.BeginInvoke(pro, null, null);

            // 再次調用BeginRead(),完成時調用自身,形成無限循環

                AsyncCallback callBack = new AsyncCallback(OnReadComplete);

                streamToClient.BeginRead(buffer, 0, BufferSize, callBack, null);

        } catch(Exception ex) {

            if(streamToClient!=null)

                streamToClient.Dispose();

            client.Close();

            Console.WriteLine(ex.Message);      // 捕獲異常時退出程式

    // 處理protocol

    private void handleProtocol(object obj) {

        string pro = obj as string;

        ProtocolHelper helper = new ProtocolHelper(pro);

        FileProtocol protocol = helper.GetProtocol();

        if (protocol.Mode == FileRequestMode.Send) {

            // 用戶端發送檔案,對服務端來說則是接收檔案

            receiveFile(protocol);

        } else if (protocol.Mode == FileRequestMode.Receive) {

            // 用戶端接收檔案,對服務端來說則是發送檔案

            // sendFile(protocol);

    private void receiveFile(FileProtocol protocol) {

        // 擷取遠端用戶端的位置

        IPEndPoint endpoint = client.Client.RemoteEndPoint as IPEndPoint;

        IPAddress ip = endpoint.Address;

        // 使用新端口号,獲得遠端用于接收檔案的端口

        endpoint = new IPEndPoint(ip, protocol.Port);

        // 連接配接到遠端用戶端

        TcpClient localClient;

            localClient = new TcpClient();

            localClient.Connect(endpoint);

        } catch {

            Console.WriteLine("無法連接配接到用戶端 --> {0}", endpoint);

            return;

        // 擷取發送檔案的流

        NetworkStream streamToClient = localClient.GetStream();

        // 随機生成一個在目前目錄下的檔案名稱

        string path =

            Environment.CurrentDirectory + "/" + generateFileName(protocol.FileName);

        byte[] fileBuffer = new byte[1024]; // 每次收1KB

        FileStream fs = new FileStream(path, FileMode.CreateNew, FileAccess.Write);

        // 從緩存buffer中讀入到檔案流中

        int bytesRead;

        int totalBytes = 0;

        do {

            bytesRead = streamToClient.Read(buffer, 0, BufferSize);            

            fs.Write(buffer, 0, bytesRead);

            totalBytes += bytesRead;

            Console.WriteLine("Receiving {0} bytes ...", totalBytes);

        } while (bytesRead > 0);

        Console.WriteLine("Total {0} bytes received, Done!", totalBytes);

        streamToClient.Dispose();

        fs.Dispose();

        localClient.Close();

    // 随機擷取一個圖檔名稱

    private string generateFileName(string fileName) {

        DateTime now = DateTime.Now;

        return String.Format(

            "{0}_{1}_{2}_{3}", now.Minute, now.Second, now.Millisecond, fileName

        );

這裡應該沒有什麼新知識,需要注意的地方有這麼幾個:

  • 在OnReadComplete()回調方法中的foreach循環,我們使用委托異步調用了handleProtocol()方法,這是因為handleProtocol即将執行的是一個讀取或接收檔案的操作,也就是一個相對耗時的操作。
  • 在handleProtocol()方法中,我們深切體會了定義ProtocolHelper類和FileProtocol結構的好處。如果沒有定義它們,這裡将是不堪入目的處理XML以及類型轉換的代碼。
  • handleProtocol()方法中進行了一個條件判斷,注意sendFile()方法我屏蔽掉了,這個還沒有實作,但是我想你已經猜到它将是後面要實作的内容。
  • receiveFile()方法是實際接收用戶端發來檔案的方法,這裡沒有什麼特别之處。需要注意的是檔案存儲的路徑,它儲存在了目前程式執行的目錄下,檔案的名稱我使用generateFileName()生成了一個與時間有關的随機名稱。

3.2用戶端的實作

我們現在先不着急實作用戶端S1、R1等使用者菜單,首先完成發送檔案這一功能,實際上,就是為上一節SendMessage()加一個姐妹方法SendFile()。

class Client {

        ConsoleKey key;

        ServerClient client = new ServerClient();

        string filePath = Environment.CurrentDirectory + "/" + "Client01.jpg";

        if(File.Exists(filePath))

            client.BeginSendFile(filePath);

        Console.WriteLine("\n\n輸入\"Q\"鍵退出。");

            key = Console.ReadKey(true).Key;

        } while (key != ConsoleKey.Q);

public class ServerClient {

    private NetworkStream streamToServer;

    public ServerClient() {

        try {

            client = new TcpClient();

            client.Connect("localhost", 8500);      // 與伺服器連接配接

        } catch (Exception ex) {

            Console.WriteLine(ex.Message);

        // 列印連接配接到的服務端資訊

        Console.WriteLine("Server Connected!{0} --> {1}",

        streamToServer = client.GetStream();

    // 發送消息到服務端

    public void SendMessage(string msg) {

        byte[] temp = Encoding.Unicode.GetBytes(msg);   // 獲得緩存

            lock (streamToServer) {

                streamToServer.Write(temp, 0, temp.Length); // 發往伺服器

            Console.WriteLine("Sent: {0}", msg);

    // 發送檔案 - 異步方法

    public void BeginSendFile(string filePath) {

        ParameterizedThreadStart start =

            new ParameterizedThreadStart(BeginSendFile);

        start.BeginInvoke(filePath, null, null);

    private void BeginSendFile(object obj) {

        string filePath = obj as string;

        SendFile(filePath);

    // 發送檔案 -- 同步方法

    public void SendFile(string filePath) {

        TcpListener listener = new TcpListener(ip, 0);

        listener.Start();

        // 擷取本地偵聽的端口号

        IPEndPoint endPoint = listener.LocalEndpoint as IPEndPoint;

        int listeningPort = endPoint.Port;

        // 擷取發送的協定字元串

        string fileName = Path.GetFileName(filePath);

        FileProtocol protocol =

            new FileProtocol(FileRequestMode.Send, listeningPort, fileName);

        string pro = protocol.ToString();

        SendMessage(pro);       // 發送協定到服務端

        // 中斷,等待遠端連接配接

        TcpClient localClient = listener.AcceptTcpClient();

        Console.WriteLine("Start sending file...");

        NetworkStream stream = localClient.GetStream();

        // 建立檔案流

        FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read);          

        byte[] fileBuffer = new byte[1024];     // 每次傳1KB

        // 建立擷取檔案發送狀态的類

        SendStatus status = new SendStatus(filePath);

        // 将檔案流轉寫入網絡流

            do {

                Thread.Sleep(10);           // 為了更好的視覺效果,暫停10毫秒:-)

                bytesRead = fs.Read(fileBuffer, 0, fileBuffer.Length);                 

                stream.Write(fileBuffer, 0, bytesRead);

                totalBytes += bytesRead;            // 發送了的位元組數

                status.PrintStatus(totalBytes); // 列印發送狀态

            } while (bytesRead > 0);

            Console.WriteLine("Total {0} bytes sent, Done!", totalBytes);

            Console.WriteLine("Server has lost...");

        stream.Dispose();

        listener.Stop();

接下來我們來看下這段代碼,有這麼兩點需要注意一下:

  • 在Main()方法中可以看到,圖檔的位置為應用程式所在的目錄,如果你跟我一樣處于調試模式,那麼就在解決方案的Bin目錄下的Debug目錄中放置三張圖檔Client01.jpg、Client02.jpg、Client03.jpg,用來發往服務端。
  • 我在用戶端提供了兩個SendFile()方法,和一個BeginSendFile()方法,分别用于同步和異步傳輸,其中私有的SendFile()方法隻是一個輔助方法。實際上對于發送檔案這樣的操作我們幾乎總是需要使用異步操作。
  • SendMessage()方法中給streamToServer加鎖很重要,因為SendFile()方法是多線程通路的,而在SendFile()方法中又調用了SendMessage()方法。
  • 我另外編寫了一個SendStatus類,它用來記錄和列印發送完成的狀态,已經發送了多少位元組,完成度是百分之多少,等等。本來這個類的内容我是直接寫入在Client類中的,後來我覺得它執行的工作已經不屬于Client本身所應該執行的領域之内了,我記得這樣一句話:當你覺得類中的方法與類的名稱不符的時候,那麼就應該考慮重新建立一個類。我覺得用在這裡非常恰當。

下面是SendStatus的内容:

// 即時計算發送檔案的狀态

public class SendStatus {

    private FileInfo info;

    private long fileBytes;

    public SendStatus(string filePath) {

        info = new FileInfo(filePath);

        fileBytes = info.Length;

    public void PrintStatus(int sent) {

        string percent = GetPercent(sent);

        Console.WriteLine("Sending {0} bytes, {1}% ...", sent, percent);

    // 獲得檔案發送的百分比

    public string GetPercent(int sent){    

        decimal allBytes = Convert.ToDecimal(fileBytes);

        decimal currentSent = Convert.ToDecimal(sent);

        decimal percent = (currentSent / allBytes) * 100;

        percent = Math.Round(percent, 1);   //保留一位小數

        if (percent.ToString() == "100.0")

            return "100";

            return percent.ToString();

3.3程式測試

接下裡我們運作一下程式,來檢查一下輸出,首先看下服務端:

C#網絡程式設計(訂立協定和發送檔案) - Part.4C#網絡程式設計(訂立協定和發送檔案) - Part.4

接着是用戶端,我們能夠看到發送的位元組數和進度,可以想到如果是圖形界面,那麼我們可以通過擴充SendStatus類來建立一個進度條:

C#網絡程式設計(訂立協定和發送檔案) - Part.4C#網絡程式設計(訂立協定和發送檔案) - Part.4

最後我們看下服務端的Bin\Debug目錄,應該可以看到接收到的圖檔:

C#網絡程式設計(訂立協定和發送檔案) - Part.4C#網絡程式設計(訂立協定和發送檔案) - Part.4

本來我想這篇文章就可以完成發送和接收,不過現在看來沒法實作了,因為如果繼續下去這篇文章就太長了,我正嘗試着盡量将文章控制在15頁以内。那麼我們将在下篇文章中再完成接收檔案這一部分。