C# 優雅的處理TCP資料(心跳,超時,粘包斷包,SSL加密 ,資料處理等)

BruceNeter發表於2024-03-21

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

image
上圖來自微軟官方部落格: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

image

image

image

相關文章