基於 .NET 7 的 QUIC 實現 Echo 服務

前言

隨着今年6月份的 HTTP/3 協議的正式發佈,它背後的網絡傳輸協議 QUIC,憑藉其高效的傳輸效率和多路併發的能力,也大概率會取代我們熟悉的使用了幾十年的 TCP,成爲互聯網的下一代標準傳輸協議。

在去年 .NET 6 發佈的時候,已經可以看到 HTTP/3 和 Quic 支持的相關內容了,但是當時 HTTP/3 的 RFC 還沒有定稿,所以也只是預覽功能,而 Quic 的 API 也沒有在 .NET 6 中公開。

在最新的 .NET 7 中,.NET 團隊公開了 Quic API,它是基於 MSQuic 庫來實現的 , 提供了開箱即用的支持,命名空間爲 System.Net.Quic。

Quic API

下面的內容中,我會介紹如何在 .NET 中使用 Quic。

下面是 System.Net.Quic 命名空間下,比較重要的幾個類。

QuicConnection

表示一個 QUIC 連接,本身不發送也不接收數據,它可以打開或者接收多個QUIC 流。

QuicListener

用來監聽入站的 Quic 連接,一個 QuicListener 可以接收多個 Quic 連接。

QuicStream

表示 Quic 流,它可以是單向的 (QuicStreamType.Unidirectional),只允許創建方寫入數據,也可以是雙向的(QuicStreamType.Bidirectional),它允許兩邊都可以寫入數據。

小試牛刀

下面是一個客戶端和服務端應用使用 Quic 通信的示例。

  1. 分別創建了 QuicClient 和 QuicServer 兩個控制檯程序。

項目的版本爲 .NET 7, 並且設置 EnablePreviewFeatures = true。

下面創建了一個 QuicListener,監聽了本地端口 9999,指定了 ALPN 協議版本。


Console.WriteLine("Quic Server Running...");

// 創建 QuicListener
var listener = await QuicListener.ListenAsync(new QuicListenerOptions
{ 
    ApplicationProtocols = new List<SslApplicationProtocol> { SslApplicationProtocol.Http3  },
    ListenEndPoint = new IPEndPoint(IPAddress.Loopback,9999), 
    ConnectionOptionsCallback = (connection,ssl, token) => ValueTask.FromResult(new QuicServerConnectionOptions()
    {
        DefaultStreamErrorCode = 0,
        DefaultCloseErrorCode = 0,
        ServerAuthenticationOptions = new SslServerAuthenticationOptions()
        {
            ApplicationProtocols = new List<SslApplicationProtocol>() { SslApplicationProtocol.Http3 },
            ServerCertificate = GenerateManualCertificate()
        }
    }) 
});  

因爲 Quic 需要 TLS 加密,所以要指定一個證書,GenerateManualCertificate 方法可以方便地創建一個本地的測試證書。

X509Certificate2 GenerateManualCertificate()
{
    X509Certificate2 cert = null;
    var store = new X509Store("KestrelWebTransportCertificates", StoreLocation.CurrentUser);
    store.Open(OpenFlags.ReadWrite);
    if (store.Certificates.Count > 0)
    {
        cert = store.Certificates[^1];

        // rotate key after it expires
        if (DateTime.Parse(cert.GetExpirationDateString(), null) < DateTimeOffset.UtcNow)
        {
            cert = null;
        }
    }
    if (cert == null)
    {
        // generate a new cert
        var now = DateTimeOffset.UtcNow;
        SubjectAlternativeNameBuilder sanBuilder = new();
        sanBuilder.AddDnsName("localhost");
        using var ec = ECDsa.Create(ECCurve.NamedCurves.nistP256);
        CertificateRequest req = new("CN=localhost", ec, HashAlgorithmName.SHA256);
        // Adds purpose
        req.CertificateExtensions.Add(new X509EnhancedKeyUsageExtension(new OidCollection
        {
            new("1.3.6.1.5.5.7.3.1") // serverAuth

        }, false));
        // Adds usage
        req.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, false));
        // Adds subject alternate names
        req.CertificateExtensions.Add(sanBuilder.Build());
        // Sign
        using var crt = req.CreateSelfSigned(now, now.AddDays(14)); // 14 days is the max duration of a certificate for this
        cert = new(crt.Export(X509ContentType.Pfx));

        // Save
        store.Add(cert);
    }
    store.Close();

    var hash = SHA256.HashData(cert.RawData);
    var certStr = Convert.ToBase64String(hash);
    //Console.WriteLine($"\n\n\n\n\nCertificate: {certStr}\n\n\n\n"); // <-- you will need to put this output into the JS API call to allow the connection
    return cert;
}

阻塞線程,直到接收到一個 Quic 連接,一個 QuicListener 可以接收多個 連接。

var connection = await listener.AcceptConnectionAsync();

Console.WriteLine($"Client [{connection.RemoteEndPoint}]: connected");

接收一個入站的 Quic 流, 一個 QuicConnection 可以支持多個流。

var stream = await connection.AcceptInboundStreamAsync();

Console.WriteLine($"Stream [{stream.Id}]: created");

接下來,使用 System.IO.Pipeline 處理流數據,讀取行數據,並回復一個 ack 消息。

Console.WriteLine();

await ProcessLinesAsync(stream);

Console.ReadKey();      

// 處理流數據
async Task ProcessLinesAsync(QuicStream stream)
{
    var reader = PipeReader.Create(stream);  
    var writer = PipeWriter.Create(stream);

    while (true)
    {
        ReadResult result = await reader.ReadAsync();
        ReadOnlySequence<byte> buffer = result.Buffer;

        while (TryReadLine(ref buffer, out ReadOnlySequence<byte> line))
        {
            // 讀取行數據
            ProcessLine(line);

            // 寫入 ACK 消息
            await writer.WriteAsync(Encoding.UTF8.GetBytes($"Ack: {DateTime.Now.ToString("HH:mm:ss")} \n"));
        } 
      
        reader.AdvanceTo(buffer.Start, buffer.End);
 
        if (result.IsCompleted)
        {
            break;
        } 
    }

    Console.WriteLine($"Stream [{stream.Id}]: completed");

    await reader.CompleteAsync();  
    await writer.CompleteAsync();    
} 

bool TryReadLine(ref ReadOnlySequence<byte> buffer, out ReadOnlySequence<byte> line)
{ 
    SequencePosition? position = buffer.PositionOf((byte)'\n');

    if (position == null)
    {
        line = default;
        return false;
    } 
    
    line = buffer.Slice(0, position.Value);
    buffer = buffer.Slice(buffer.GetPosition(1, position.Value));
    return true;
} 

void ProcessLine(in ReadOnlySequence<byte> buffer)
{
    foreach (var segment in buffer)
    {
        Console.WriteLine("Recevied -> " + System.Text.Encoding.UTF8.GetString(segment.Span));
    }

    Console.WriteLine();
} 

以上就是服務端的完整代碼了。

接下來我們看一下客戶端 QuicClient 的代碼。

直接使用 QuicConnection.ConnectAsync 連接到服務端。

Console.WriteLine("Quic Client Running...");

await Task.Delay(3000);

// 連接到服務端
var connection = await QuicConnection.ConnectAsync(new QuicClientConnectionOptions
{
    DefaultCloseErrorCode = 0,
    DefaultStreamErrorCode = 0,
    RemoteEndPoint = new IPEndPoint(IPAddress.Loopback, 9999),
    ClientAuthenticationOptions = new SslClientAuthenticationOptions
    {
        ApplicationProtocols = new List<SslApplicationProtocol> { SslApplicationProtocol.Http3 },
        RemoteCertificateValidationCallback = (sender, certificate, chain, errors) =>
        {
            return true;
        }
    }
});  

創建一個出站的雙向流。

// 打開一個出站的雙向流
var stream = await connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional); 

var reader = PipeReader.Create(stream);
var writer = PipeWriter.Create(stream);  

後臺讀取流數據,然後循環寫入數據。

// 後臺讀取流數據
_ = ProcessLinesAsync(stream);

Console.WriteLine(); 

// 寫入數據
for (int i = 0; i < 7; i++)
{
    await Task.Delay(2000);

    var message = $"Hello Quic {i} \n";

    Console.Write("Send -> " + message);  

    await writer.WriteAsync(Encoding.UTF8.GetBytes(message)); 
}

await writer.CompleteAsync(); 

Console.ReadKey(); 

ProcessLinesAsync 和服務端一樣,使用 System.IO.Pipeline 讀取流數據。

async Task ProcessLinesAsync(QuicStream stream)
{
    while (true)
    {
        ReadResult result = await reader.ReadAsync();
        ReadOnlySequence<byte> buffer = result.Buffer;

        while (TryReadLine(ref buffer, out ReadOnlySequence<byte> line))
        { 
            // 處理行數據
            ProcessLine(line);
        }
     
        reader.AdvanceTo(buffer.Start, buffer.End); 
     
        if (result.IsCompleted)
        {
            break;
        }
    }

    await reader.CompleteAsync();
    await writer.CompleteAsync();

} 

bool TryReadLine(ref ReadOnlySequence<byte> buffer, out ReadOnlySequence<byte> line)
{ 
    SequencePosition? position = buffer.PositionOf((byte)'\n');

    if (position == null)
    {
        line = default;
        return false;
    }
 
    line = buffer.Slice(0, position.Value);
    buffer = buffer.Slice(buffer.GetPosition(1, position.Value));
    return true;
}

void ProcessLine(in ReadOnlySequence<byte> buffer)
{
    foreach (var segment in buffer)
    {
        Console.Write("Recevied -> " + System.Text.Encoding.UTF8.GetString(segment.Span));
        Console.WriteLine();
    }

    Console.WriteLine();
}

到這裏,客戶端和服務端的代碼都完成了,客戶端使用 Quic 流發送了一些消息給服務端,服務端收到消息後在控制檯輸出,並回復一個 Ack 消息,因爲我們創建了一個雙向流。

程序的運行結果如下

我們上面說到了一個 QuicConnection 可以創建多個流,並行傳輸數據。

改造一下服務端的代碼,支持接收多個 Quic 流。

var cts = new CancellationTokenSource();

while (!cts.IsCancellationRequested)
{
    var stream = await connection.AcceptInboundStreamAsync();

    Console.WriteLine($"Stream [{stream.Id}]: created");

    Console.WriteLine();

    _ = ProcessLinesAsync(stream); 
} 

Console.ReadKey();  

對於客戶端,我們用多個線程創建多個 Quic 流,並同時發送消息。

默認情況下,一個 Quic 連接的流的限制是 100,當然你可以設置 QuicConnectionOptions 的 MaxInboundBidirectionalStreams 和 MaxInboundUnidirectionalStreams 參數。

for (int j = 0; j < 5; j++)
{
    _ = Task.Run(async () => {

        // 創建一個出站的雙向流
        var stream = await connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional); 
      
        var writer = PipeWriter.Create(stream); 

        Console.WriteLine();
 
        await Task.Delay(2000);
        
        var message = $"Hello Quic [{stream.Id}] \n";

        Console.Write("Send -> " + message);

        await writer.WriteAsync(Encoding.UTF8.GetBytes(message));

        await writer.CompleteAsync(); 
    });  
} 

最終程序的輸出如下

完整的代碼可以在下面的 github 地址找到,希望對您有用!

https://github.com/SpringLeee/PlayQuic

掃碼關注【半棧程序員】,獲取最新文章。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章