溫故之.NET Socket通訊

JameLee發表於2018-07-21

上一篇文章介紹了記憶體對映檔案,這篇文章我們介紹一種用得更加廣泛的方式——Socket 通訊

Socket 介紹

Socket 稱為”套接字”,它分為流式套接字和使用者資料包套接字,分別對應網路中的 TCP 和 UDP 協議。這兩種均可以實現程式間通訊(無論是否是同一機器)

TCP 協議是面向連線的協議,提供穩定的雙向通訊功能,TCP連線的建立是通過三次握手才能完成,穩定性高,建立連線的效率相對UDP較低
UDP協議是面向無連線的,效率高,但不保證資料一定能夠正確傳輸(順序、丟包等)

我們應該選擇 UDP 還是 TCP?

  • 對資料的可靠性要求很高的場景,應該選擇 TCP,比如涉及錢的地方。當然也可以選擇 UDP,這時候需要我們自行來保證資料的可靠性
  • 對速度要求高,但允許資料出現少量錯誤的適合,UDP最合適。比如記錄日誌的場景:一臺機器專用於記錄日誌,其他的機器將日誌傳送給這臺機器即可;還有就是視訊會議的場景

但實際專案中,這樣“純粹”的場景並不是那麼多,因此,往往採用的方案都是 TCP、UDP 相結合的方式來實現。當然為了保證資料的可靠及業務的穩定性,很多框架都不僅僅只有這麼兩種技術

框架的複雜、輕量與否,與其應對的業務場景是相關的。我們需要根據不同的場景,來選擇適合自己專案的框架。在 C# 中,有 FastSocketSuperSocketSocket 框架供大家選擇。其中 SuperSocket 支援 IOCP,它可以實現高效能、高併發。其他語言有 NettyHP-Socket 等,這些也有 .NET 的移植版本

一般情況下,不建議各位朋友自己去寫一個 Socket 框架來支援專案的業務場景,用現有的框架更加穩當。如果不知道選擇什麼框架,可以去 Github 上搜尋相關的開源框架

選擇 Github 中的框架時,我們應該注意

  • 選擇 Star 最多的
  • 看作者上一次維護時間是多久,這個框架的 issue 多不多。更新頻繁的,往往可以選擇,這樣遇到問題也可以及時的處理
  • 文件:有一個詳細的開發文件,可以提高我們開發的速度

Socket 通訊,是市面上很多框架的基礎,因此我們有必要介紹下它的使用方式,及在開發過程中需要注意的事項

使用示例

在 C# 中,無論是 TCP 協議,還是 UDP 協議,都封裝在了 Socket 這個類中。使用時,只需要我們指定不同的引數即可

TCP 與 UDP 區別

  • TCP 面向連線(如打電話要先撥號建立連線); UDP 是無連線的,即傳送資料之前不需要建立連線(扔出去就不用管了)
  • TCP 提供可靠的服務。也就是說,通過 TCP 連線傳送的資料,無差錯,不丟失,不重複,且按序到達;UDP 盡最大努力交付,即不保證可靠交付
  • TCP 面向位元組流,實際上是 TCP 把資料看成一連串無結構的位元組流;UDP 是面向報文的
  • UDP 沒有堵塞控制,因此網路出現堵塞不會使源主機的傳送速率降低(對實時應用很有用,如IP電話,實時視訊會議等)
  • 每一條 TCP 連線只能是點對點的;UDP 支援一對一,一對多,多對一和多對多的互動通訊(群視訊等場景)
  • TCP 首部開銷 20 位元組;UDP 的首部開銷小,只有8個位元組
  • TCP 的邏輯通訊通道是全雙工的可靠通道,UDP 則是不可靠通道

在大部分情況下(針對效能而言),我們無法感覺到這兩者之間的差異;而在高併發的場景下,我們就能很容易體會到(因為訪問量大了之後,任何細小的變化都能累積起來從而造成巨大的影響)

使用 TCP 面臨的一個主要問題就是粘包,業界主流的解決方案可歸納如下

  • 訊息定長:如每個資料包的大小固定為 1024 位元組,如果不足 1024 位元組,使用空格填充剩下的部分
  • 在包尾增加回車換行符進行分隔,比如 FTP 協議
  • 將訊息分為訊息頭、訊息體。訊息頭包含了訊息的總長度,及其他的一些後設資料,訊息體儲存具體的資料包。一般地,訊息頭可以採用定長的方式,比如分配 40 個位元組,其中16位元組用於存放訊息的長度資訊,其餘部分存放其他資料。
  • 自定義應用層協議:這種方式是為具體的業務場景而實現的,比如騰訊就有一套他們自己的通訊框架

另外,如果覺得自定義協議太麻煩,我們也可以根據 MQTT 協議來寫一套符合它的解決方案

針對 TCP 的使用,我們給出一個例子。其中我們採用 Jil 來實現序列化

/// <summary>
/// 傳輸使用的包
/// </summary>
public class Packet {
    public const int TYPE_LOGIN = 10001;
    public const int TYPE_MSG = 10000;
    public const int TYPE_LOGOUT = 10002;
    public const int TYPE_INVALID = 40000;

    /// <summary>
    /// 這個包的型別。在實際業務場景中,一般會使用 int、short 等來表示,而不是 enum
    /// </summary>
    public int Type { get; set; }
    /// <summary>
    /// 具體的業務資料
    /// </summary>
    public string Data { get; set; }
}
複製程式碼

以下為服務端程式碼

using Jil;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace App {
    class Program {
        static void Main(string[] args) {
            TcpListener tcpListener = new TcpListener(new IPEndPoint(IPAddress.Parse("127.0.0.1"), 9999));
            tcpListener.Start();
            /// 此處僅僅用於處理客戶端的連線
            /// 而不涉及具體的業務邏輯
            while (true) {
                TcpClient remoteClient = tcpListener.AcceptTcpClient();
                ClientPacketHandlers packetHandlers = new ClientPacketHandlers(remoteClient);
            }
        }

    }

    /// <summary>
    /// 將業務邏輯處理分開
    /// </summary>
    public class ClientPacketHandlers {
        Dictionary<int, Action<NetworkStream, string>> clientHandlers = new Dictionary<int, Action<NetworkStream, string>>();
        TcpClient remoteClient;
        NetworkStream stream;
        Task processTask;
        CancellationTokenSource cancellationTokenSource;

        public ClientPacketHandlers(TcpClient client) {
            this.remoteClient = client;
            this.stream = remoteClient.GetStream();

            // 這個可以通過配置檔案來新增處理器
            clientHandlers.Add(Packet.TYPE_LOGIN, HandleLogin);
            clientHandlers.Add(Packet.TYPE_MSG, HandleMsg);
            clientHandlers.Add(Packet.TYPE_LOGOUT, HandleLogout);

            cancellationTokenSource = new CancellationTokenSource();

            // 為該客戶端開闢一個 Task,用於與該客戶端通訊
            // 在高併發場景中,往往不會這樣做。而是採用 IOCP 或者其他的高效能的方式
            // 為每個客戶端開闢一個 Task 不合理,也很浪費系統資源(因為不是每個客戶端都會頻繁傳送訊息)
            processTask = Task.Run(() => {
                byte[] buffer = new byte[1024];
                while (true) {
                    int bytesRead = stream.Read(buffer, 0, 1024);
                    if (bytesRead > 0) {
                        byte[] realBytes = new byte[bytesRead];
                        Buffer.BlockCopy(buffer, 0, realBytes, 0, bytesRead);

                        Packet packet = JSON.Deserialize<Packet>(Encoding.UTF8.GetString(realBytes));
                        if (packet != null) {
                            if (clientHandlers.ContainsKey(packet.Type)) {
                                clientHandlers[packet.Type].Invoke(stream, packet.Data);
                            } else {
                                SendPacket(stream, new Packet() { Type = Packet.TYPE_INVALID, Data = "No handlers for your type" });
                            }
                        }
                    }

                    if (cancellationTokenSource == null || cancellationTokenSource.IsCancellationRequested) {
                        break;
                    }
                }
            }, cancellationTokenSource.Token);
        }

        public void HandleLogin(NetworkStream stream, string data) {
            if (stream == null || string.IsNullOrEmpty(data)) return;
            SendPacket(stream, new Packet() { Type = Packet.TYPE_LOGIN, Data = $"Hello, {data}" });
        }

        public void HandleMsg(NetworkStream stream, string data) {
            if (stream == null || string.IsNullOrEmpty(data)) return;
            SendPacket(stream, new Packet() { Type = Packet.TYPE_MSG, Data = $"Received Msg : {data}" });
        }

        public void HandleLogout(NetworkStream stream, string data) {
            if (stream == null || string.IsNullOrEmpty(data)) return;
            SendPacket(stream, new Packet() { Type = Packet.TYPE_LOGOUT, Data = $"Logout, {data}" });
            try {
                if (cancellationTokenSource != null) {
                    cancellationTokenSource.Cancel();
                    cancellationTokenSource.Dispose();
                }
            } catch (Exception e) {
            } finally {
                cancellationTokenSource = null;
            }
        }


        public void SendPacket(NetworkStream stream, Packet packet) {
            byte[] packetBytes = Encoding.UTF8.GetBytes(JSON.Serialize(packet));
            stream.Write(packetBytes, 0, packetBytes.Length);
        }
    }
}
複製程式碼

以下為客戶端程式碼

using Jil;
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;

namespace App {
    class Program {
        static void Main(string[] args) {
            TcpClient tcpClient = new TcpClient();
            tcpClient.Connect(new IPEndPoint(IPAddress.Parse("127.0.0.1"), 9999));
            NetworkStream networkStream = tcpClient.GetStream();

            Task.Run(() => {
                byte[] buffer = new byte[1024];
                while (true) {
                    int bytesRead = networkStream.Read(buffer, 0, 1024);
                    if (bytesRead > 0) {
                        byte[] realBytes = new byte[bytesRead];
                        Buffer.BlockCopy(buffer, 0, realBytes, 0, bytesRead);

                        Packet packet = JSON.Deserialize<Packet>(Encoding.UTF8.GetString(realBytes));
                        if (packet != null) {
                            Console.WriteLine($"RECEIVED DATA: {packet.Data}");
                        }
                    }
                }
            });

            while (true) {
                string line = Console.ReadLine();
                string[] strs = line.Split(':');
                if(strs.Length >= 2) {
                    if(strs[0] == "login") {
                        SendPacket(networkStream, new Packet() { Type = Packet.TYPE_LOGIN, Data = strs[1] });
                    } else if (strs[0] == "msg") {
                        SendPacket(networkStream, new Packet() { Type = Packet.TYPE_MSG, Data = strs[1] });
                    } else if (strs[0] == "logout") {
                        SendPacket(networkStream, new Packet() { Type = Packet.TYPE_LOGOUT, Data = strs[1] });
                    }
                }
            }
        }

        private static void SendPacket(NetworkStream networkStream, Packet packet) {
            byte[] packetBytes = Encoding.UTF8.GetBytes(JSON.Serialize(packet));
            networkStream.Write(packetBytes, 0, packetBytes.Length);
        }
    }
}
複製程式碼

這便是 TCP 通訊的基礎示例了,在更復雜的場景中,系統的設計將會更加複雜。但宗旨都只有一個,提供更加穩定可靠的服務

UDP 的使用與 TCP 類似,因此就不一一舉例了

開發建議

  • 儘量將對客戶端的管理,與具體的業務邏輯分開,這樣可以提高系統的可維護性
  • 如果使用 TCP,除了解決粘包之外,還需要使用心跳包來使連線處於活動狀態
  • 在使用 UDP 的時候,如果需要保證資料的可靠性,此時需要通過其他的方式來輔助
  • 如果要採用 GitHub 上的一些框架,一定要參考前面給出的建議
  • 在不增加系統複雜度的情況下,可以使用微服務來提升系統的擴充套件性。但切記不可濫用,過多的微服務會造成系統的可維護性下降,並且是指數級的下降
  • 在高併發、高效能的場景下,需要採用其他的方式。比如 IOCP 等框架。除了避免系統資源的浪費,更是為了提升系統的響應能力

至此,這篇文章的內容講解完畢。歡迎關注公眾號【嘿嘿的學習日記】,所有的文章,都會在公眾號首發,Thank you~

公眾號二維碼

相關文章