Tcp是一個面向連接的流數據傳輸協議,用人話說就是傳輸是一個已經建立好連接的管道,數據都在管道里像流水一樣流淌到對端。那麼數據必然存在幾個問題,比如數據如何持續的讀取,數據包的邊界等。
Nagle's算法
Nagle 算法的核心思想是,在一個 TCP 連接上,最多隻能有一個未被確認的小數據包(小於 MSS,即最大報文段大小)
優勢
減少網絡擁塞:通過合併小數據包,減少了網絡中的數據包數量,降低了擁塞的可能性。
提高網絡效率:在低速網絡中,Nagle 算法可以顯著提高傳輸效率。
劣勢
增加延遲:在交互式應用中,Nagle 算法可能導致顯著的延遲,因爲它等待 ACK 或合併數據包。
C#中如何配置?
var _socket = new Socket(IPAddress.Any.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
_serverSocket.NoDelay = _options.NoDelay;
連接超時
在調用客戶端Socket連接服務器的時候,可以設置連接超時機制,具體可以傳入一個任務的取消令牌,並且設置超時時間。
CancellationTokenSource connectTokenSource = new CancellationTokenSource();
connectTokenSource.CancelAfter(3000); //3秒
await _socket.ConnectAsync(RemoteEndPoint, connectTokenSource.Token);
SSL加密傳輸
TCP使用SSL加密傳輸,通過非對稱加密的方式,利用證書,保證雙方使用了安全的密鑰加密了報文。
在C#中如何配置?
服務端配置
//創建證書對象
var _certificate = _certificate = new X509Certificate2(_options.PfxCertFilename, _options.PfxPassword);
//與客戶端進行驗證
if (allowingUntrustedSSLCertificate) //是否允許不受信任的證書
{
SslStream = new SslStream(NetworkStream, false,
(obj, certificate, chain, error) => true);
}
else
{
SslStream = new SslStream(NetworkStream, false);
}
try
{
//serverCertificate:用於對服務器進行身份驗證的 X509Certificate
//clientCertificateRequired:一個 Boolean 值,指定客戶端是否必須爲身份驗證提供證書
//checkCertificateRevocation:一個 Boolean 值,指定在身份驗證過程中是否檢查證書吊銷列表
await SslStream.AuthenticateAsServerAsync(new SslServerAuthenticationOptions()
{
ServerCertificate = x509Certificate,
ClientCertificateRequired = mutuallyAuthenticate,
CertificateRevocationCheckMode = checkCertificateRevocation ? X509RevocationMode.Online : X509RevocationMode.NoCheck
}, cancellationToken).ConfigureAwait(false);
if (!SslStream.IsEncrypted || !SslStream.IsAuthenticated)
{
return false;
}
if (mutuallyAuthenticate && !SslStream.IsMutuallyAuthenticated)
{
return false;
}
}
catch (Exception)
{
throw;
}
//完成驗證後,通過SslStream傳輸數據
int readCount = await SslStream.ReadAsync(buffer, _lifecycleTokenSource.Token)
.ConfigureAwait(false);
客戶端配置
var _certificate = new X509Certificate2(_options.PfxCertFilename, _options.PfxPassword);
if (_options.IsSsl) //如果使用ssl加密傳輸
{
if (_options.AllowingUntrustedSSLCertificate)//是否允許不受信任的證書
{
_sslStream = new SslStream(_networkStream, false,
(obj, certificate, chain, error) => true);
}
else
{
_sslStream = new SslStream(_networkStream, false);
}
_sslStream.ReadTimeout = _options.ReadTimeout;
_sslStream.WriteTimeout = _options.WriteTimeout;
await _sslStream.AuthenticateAsClientAsync(new SslClientAuthenticationOptions()
{
TargetHost = RemoteEndPoint.Address.ToString(),
EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12,
CertificateRevocationCheckMode = _options.CheckCertificateRevocation ? X509RevocationMode.Online : X509RevocationMode.NoCheck,
ClientCertificates = new X509CertificateCollection() { _certificate }
}, connectTokenSource.Token).ConfigureAwait(false);
if (!_sslStream.IsEncrypted || !_sslStream.IsAuthenticated ||
(_options.MutuallyAuthenticate && !_sslStream.IsMutuallyAuthenticated))
{
throw new InvalidOperationException("SSL authenticated faild!");
}
}
KeepAlive
keepAlive不是TCP協議中的,而是各個操作系統本身實現的功能,主要是防止一些Socket突然斷開後沒有被感知到,導致一直浪費資源的情況。
其基本原理是在此機制開啓時,當長連接無數據交互一定時間間隔時,連接的一方會向對方發送保活探測包,如連接仍正常,對方將對此確認迴應
C#中如何調用操作系統的KeepAlive?
/// <summary>
/// 開啓Socket的KeepAlive
/// 設置tcp協議的一些KeepAlive參數
/// </summary>
/// <param name="socket"></param>
/// <param name="tcpKeepAliveInterval">沒有接收到對方確認,繼續發送KeepAlive的發送頻率</param>
/// <param name="tcpKeepAliveTime">KeepAlive的空閒時長,或者說每次正常發送心跳的週期</param>
/// <param name="tcpKeepAliveRetryCount">KeepAlive之後設置最大允許發送保活探測包的次數,到達此次數後直接放棄嘗試,並關閉連接</param>
internal static void SetKeepAlive(this Socket socket, int tcpKeepAliveInterval, int tcpKeepAliveTime, int tcpKeepAliveRetryCount)
{
socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, tcpKeepAliveInterval);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, tcpKeepAliveTime);
socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, tcpKeepAliveRetryCount);
}
具體的開啓,還需要看操作系統的版本以及不同操作系統的支持。
粘包斷包處理
Pipe & ReadOnlySequence
上圖來自微軟官方博客:https://devblogs.microsoft.com/dotnet/system-io-pipelines-high-performance-io-in-net/
TCP面向應用是流式數據傳輸,所以接收端接到的數據是像流水一樣從管道中傳來,每次取到的數據取決於應用設置的緩衝區大小,以及套接字本身緩衝區待讀取字節數。
C#中提供的Pipe就如上圖一樣,是一個管道
Pipe有兩個對象成員,一個是PipeWriter,一個是PipeReader,可以理解爲一個是生產者,專門往管道里灌輸數據流,即字節流,一個是消費者,專門從管道里獲取字節流進行處理。
可以看到Pipe中的數據包是用鏈表關聯的,但是這個數據包是從Socke緩衝區每次取到的數據包,它不一定是一個完整的數據包,所以這些數據包連接起來後形成了一個C#提供的另外一個抽象的對象ReadOnlySequence。
但是這裏還是沒有提供太好的處理斷包和粘包的辦法,因爲斷包粘包的處理需要兩方面
1.業務數據包的定義
2.數據流切割出一個個完整的數據包
假設業務已經定義好了數據包,那麼我們如何從Pipe中這些數據包根據業務定義來從不同的數據包中切割出一個完整的包,那麼就需要ReadOnlySequence,它提供的操作方法,非常方便我們去切割數據,主要是頭尾數據包的切割。
假設我們業務層定義了一個數據包結構,數據包是不定長的,包體長度每次都寫在包頭裏,我們來實現一個數據包過濾器。
//收到消息
while (!_receiveDataTokenSource.Token.IsCancellationRequested)
{
try
{
//從pipe中獲取緩衝區
Memory<byte> buffer = _pipeWriter.GetMemory(_options.BufferSize);
int readCount = 0;
readCount = await _sslStream.ReadAsync(buffer, _lifecycleTokenSource.Token).ConfigureAwait(false);
if (readCount > 0)
{
var data = buffer.Slice(0, readCount);
//告知消費者,往Pipe的管道中寫入了多少字節數據
_pipeWriter.Advance(readCount);
}
else
{
if (IsDisconnect())
{
await DisConnectAsync();
}
throw new SocketException();
}
FlushResult result = await _pipeWriter.FlushAsync().ConfigureAwait(false);
if (result.IsCompleted)
{
break;
}
}
catch (IOException)
{
//TODO log
break;
}
catch (SocketException)
{
//TODO log
break;
}
catch (TaskCanceledException)
{
//TODO log
break;
}
}
_pipeWriter.Complete();
//消費者處理數據
while (!_lifecycleTokenSource.Token.IsCancellationRequested)
{
ReadResult result = await _pipeReader.ReadAsync();
ReadOnlySequence<byte> buffer = result.Buffer;
ReadOnlySequence<byte> data;
do
{
//通過過濾器得到一個完整的包
data = _receivePackageFilter.ResolvePackage(ref buffer);
if (!data.IsEmpty)
{
OnReceivedData?.Invoke(this, new ClientDataReceiveEventArgs(data.ToArray()));
}
}
while (!data.IsEmpty && buffer.Length > 0);
_pipeReader.AdvanceTo(buffer.Start);
}
_pipeReader.Complete();
/// <summary>
/// 解析數據包
/// 固定報文頭解析協議
/// </summary>
/// <param name="headerSize">數據報文頭的大小</param>
/// <param name="bodyLengthIndex">數據包大小在報文頭中的位置</param>
/// <param name="bodyLengthBytes">數據包大小在報文頭中的長度</param>
/// <param name="IsLittleEndian">數據報文大小端。windows中通常是小端,unix通常是大端模式</param>
/// </summary>
/// <param name="sequence">一個完整的業務數據包</param>
public override ReadOnlySequence<byte> ResolvePackage(ref ReadOnlySequence<byte> sequence)
{
var len = sequence.Length;
if (len < _bodyLengthIndex) return default;
var bodyLengthSequence = sequence.Slice(_bodyLengthIndex, _bodyLengthBytes);
byte[] bodyLengthBytes = ArrayPool<byte>.Shared.Rent(_bodyLengthBytes);
try
{
int index = 0;
foreach (var item in bodyLengthSequence)
{
Array.Copy(item.ToArray(), 0, bodyLengthBytes, index, item.Length);
index += item.Length;
}
long bodyLength = 0;
int offset = 0;
if (!_isLittleEndian)
{
offset = bodyLengthBytes.Length - 1;
foreach (var bytes in bodyLengthBytes)
{
bodyLength += bytes << (offset * 8);
offset--;
}
}
else
{
foreach (var bytes in bodyLengthBytes)
{
bodyLength += bytes << (offset * 8);
offset++;
}
}
if (sequence.Length < _headerSize + bodyLength)
return default;
var endPosition = sequence.GetPosition(_headerSize + bodyLength);
var data = sequence.Slice(0, endPosition);//得到完整數據包
sequence = sequence.Slice(endPosition);//緩衝區中去除取到的完整包
return data;
}
finally
{
ArrayPool<byte>.Shared.Return(bodyLengthBytes);
}
}
以上就是實現了固定數據包頭實現粘包斷包處理的部分代碼。
關於TCP的連接還有一些,比如客戶端連接限制,空閒連接關閉等。如果大家對於完整代碼感興趣,可以看我剛寫的一個TCP庫:EasyTcp4Net:https://github.com/BruceQiu1996/EasyTcp4Net