上一篇文章介紹了內(nèi)存映射文件,這篇文章我們介紹一種用得更加廣泛的方式——Socket 通信
Socket 介紹
Socket 稱為”套接字”,它分為流式套接字和用戶數(shù)據(jù)報(bào)套接字,分別對(duì)應(yīng)網(wǎng)絡(luò)中的 TCP 和 UDP 協(xié)議。這兩種均可以實(shí)現(xiàn)進(jìn)程間通信(無論是否是同一機(jī)器)
TCP 協(xié)議是面向連接的協(xié)議,提供穩(wěn)定的雙向通信功能,TCP連接的建立是通過三次握手才能完成,穩(wěn)定性高,創(chuàng)建連接的效率相對(duì)UDP較低
UDP協(xié)議是面向無連接的,效率高,但不保證數(shù)據(jù)一定能夠正確傳輸(順序、丟包等)
我們應(yīng)該選擇 UDP 還是 TCP?
- 對(duì)數(shù)據(jù)的可靠性要求很高的場景,應(yīng)該選擇 TCP,比如涉及錢的地方。當(dāng)然也可以選擇 UDP,這時(shí)候需要我們自行來保證數(shù)據(jù)的可靠性
- 對(duì)速度要求高,但允許數(shù)據(jù)出現(xiàn)少量錯(cuò)誤的適合,UDP最合適。比如記錄日志的場景:一臺(tái)機(jī)器專用于記錄日志,其他的機(jī)器將日志發(fā)送給這臺(tái)機(jī)器即可;還有就是視頻會(huì)議的場景
但實(shí)際項(xiàng)目中,這樣“純粹”的場景并不是那么多,因此,往往采用的方案都是 TCP、UDP 相結(jié)合的方式來實(shí)現(xiàn)。當(dāng)然為了保證數(shù)據(jù)的可靠及業(yè)務(wù)的穩(wěn)定性,很多框架都不僅僅只有這么兩種技術(shù)
框架的復(fù)雜、輕量與否,與其應(yīng)對(duì)的業(yè)務(wù)場景是相關(guān)的。我們需要根據(jù)不同的場景,來選擇適合自己項(xiàng)目的框架。在 C# 中,有 FastSocket、SuperSocket 等 Socket 框架供大家選擇。其中 SuperSocket 支持 IOCP,它可以實(shí)現(xiàn)高性能、高并發(fā)。其他語言有 Netty、HP-Socket 等,這些也有 .NET 的移植版本
一般情況下,不建議各位朋友自己去寫一個(gè) Socket 框架來支持項(xiàng)目的業(yè)務(wù)場景,用現(xiàn)有的框架更加穩(wěn)當(dāng)。如果不知道選擇什么框架,可以去 Github 上搜索相關(guān)的開源框架
選擇 Github 中的框架時(shí),我們應(yīng)該注意
- 選擇 Star 最多的
- 看作者上一次維護(hù)時(shí)間是多久,這個(gè)框架的 issue 多不多。更新頻繁的,往往可以選擇,這樣遇到問題也可以及時(shí)的處理
- 文檔:有一個(gè)詳細(xì)的開發(fā)文檔,可以提高我們開發(fā)的速度
Socket 通信,是市面上很多框架的基礎(chǔ),因此我們有必要介紹下它的使用方式,及在開發(fā)過程中需要注意的事項(xiàng)
使用示例
在 C# 中,無論是 TCP 協(xié)議,還是 UDP 協(xié)議,都封裝在了 Socket 這個(gè)類中。使用時(shí),只需要我們指定不同的參數(shù)即可
TCP 與 UDP 區(qū)別
- TCP 面向連接(如打電話要先撥號(hào)建立連接); UDP 是無連接的,即發(fā)送數(shù)據(jù)之前不需要建立連接(扔出去就不用管了)
- TCP 提供可靠的服務(wù)。也就是說,通過 TCP 連接傳送的數(shù)據(jù),無差錯(cuò),不丟失,不重復(fù),且按序到達(dá);UDP 盡最大努力交付,即不保證可靠交付
- TCP 面向字節(jié)流,實(shí)際上是 TCP 把數(shù)據(jù)看成一連串無結(jié)構(gòu)的字節(jié)流;UDP 是面向報(bào)文的
- UDP 沒有堵塞控制,因此網(wǎng)絡(luò)出現(xiàn)堵塞不會(huì)使源主機(jī)的發(fā)送速率降低(對(duì)實(shí)時(shí)應(yīng)用很有用,如IP電話,實(shí)時(shí)視頻會(huì)議等)
- 每一條 TCP 連接只能是點(diǎn)對(duì)點(diǎn)的;UDP 支持一對(duì)一,一對(duì)多,多對(duì)一和多對(duì)多的交互通信(群視頻等場景)
- TCP 首部開銷 20 字節(jié);UDP 的首部開銷小,只有8個(gè)字節(jié)
- TCP 的邏輯通信信道是全雙工的可靠信道,UDP 則是不可靠信道
在大部分情況下(針對(duì)性能而言),我們無法感覺到這兩者之間的差異;而在高并發(fā)的場景下,我們就能很容易體會(huì)到(因?yàn)樵L問量大了之后,任何細(xì)小的變化都能累積起來從而造成巨大的影響)
使用 TCP 面臨的一個(gè)主要問題就是粘包,業(yè)界主流的解決方案可歸納如下
- 消息定長:如每個(gè)數(shù)據(jù)包的大小固定為 1024 字節(jié),如果不足 1024 字節(jié),使用空格填充剩下的部分
- 在包尾增加回車換行符進(jìn)行分隔,比如 FTP 協(xié)議
- 將消息分為消息頭、消息體。消息頭包含了消息的總長度,及其他的一些元數(shù)據(jù),消息體存儲(chǔ)具體的數(shù)據(jù)包。一般地,消息頭可以采用定長的方式,比如分配 40 個(gè)字節(jié),其中16字節(jié)用于存放消息的長度信息,其余部分存放其他數(shù)據(jù)。
- 自定義應(yīng)用層協(xié)議:這種方式是為具體的業(yè)務(wù)場景而實(shí)現(xiàn)的,比如騰訊就有一套他們自己的通信框架
另外,如果覺得自定義協(xié)議太麻煩,我們也可以根據(jù) MQTT 協(xié)議來寫一套符合它的解決方案
針對(duì) TCP 的使用,我們給出一個(gè)例子。其中我們采用 Jil 來實(shí)現(xiàn)序列化
/// <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>
/// 這個(gè)包的類型。在實(shí)際業(yè)務(wù)場景中,一般會(huì)使用 int、short 等來表示,而不是 enum
/// </summary>
public int Type { get; set; }
/// <summary>
/// 具體的業(yè)務(wù)數(shù)據(jù)
/// </summary>
public string Data { get; set; }
}
以下為服務(wù)端代碼
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();
/// 此處僅僅用于處理客戶端的連接
/// 而不涉及具體的業(yè)務(wù)邏輯
while (true) {
TcpClient remoteClient = tcpListener.AcceptTcpClient();
ClientPacketHandlers packetHandlers = new ClientPacketHandlers(remoteClient);
}
}
}
/// <summary>
/// 將業(yè)務(wù)邏輯處理分開
/// </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();
// 這個(gè)可以通過配置文件來添加處理器
clientHandlers.Add(Packet.TYPE_LOGIN, HandleLogin);
clientHandlers.Add(Packet.TYPE_MSG, HandleMsg);
clientHandlers.Add(Packet.TYPE_LOGOUT, HandleLogout);
cancellationTokenSource = new CancellationTokenSource();
// 為該客戶端開辟一個(gè) Task,用于與該客戶端通信
// 在高并發(fā)場景中,往往不會(huì)這樣做。而是采用 IOCP 或者其他的高性能的方式
// 為每個(gè)客戶端開辟一個(gè) Task 不合理,也很浪費(fèi)系統(tǒng)資源(因?yàn)椴皇敲總€(gè)客戶端都會(huì)頻繁發(fā)送消息)
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 通信的基礎(chǔ)示例了,在更復(fù)雜的場景中,系統(tǒng)的設(shè)計(jì)將會(huì)更加復(fù)雜。但宗旨都只有一個(gè),提供更加穩(wěn)定可靠的服務(wù)
UDP 的使用與 TCP 類似,因此就不一一舉例了
開發(fā)建議
- 盡量將對(duì)客戶端的管理,與具體的業(yè)務(wù)邏輯分開,這樣可以提高系統(tǒng)的可維護(hù)性
- 如果使用 TCP,除了解決粘包之外,還需要使用心跳包來使連接處于活動(dòng)狀態(tài)
- 在使用 UDP 的時(shí)候,如果需要保證數(shù)據(jù)的可靠性,此時(shí)需要通過其他的方式來輔助
- 如果要采用 GitHub 上的一些框架,一定要參考前面給出的建議
- 在不增加系統(tǒng)復(fù)雜度的情況下,可以使用微服務(wù)來提升系統(tǒng)的擴(kuò)展性。但切記不可濫用,過多的微服務(wù)會(huì)造成系統(tǒng)的可維護(hù)性下降,并且是指數(shù)級(jí)的下降
- 在高并發(fā)、高性能的場景下,需要采用其他的方式。比如
IOCP等框架。除了避免系統(tǒng)資源的浪費(fèi),更是為了提升系統(tǒng)的響應(yīng)能力
至此,這篇文章的內(nèi)容講解完畢。歡迎關(guān)注公眾號(hào)【嘿嘿的學(xué)習(xí)日記】,所有的文章,都會(huì)在公眾號(hào)首發(fā),Thank you~
