隨著今年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。
下面的內容中,我會介紹如何在 .NET 中使用 Quic。
下面是 System.Net.Quic 名稱空間下,比較重要的幾個類。
QuicConnection
表示一個 QUIC 連線,本身不傳送也不接收資料,它可以開啟或者接收多個QUIC 流。
QuicListener
用來監聽入站的 Quic 連線,一個 QuicListener 可以接收多個 Quic 連線。
QuicStream
表示 Quic 流,它可以是單向的 (QuicStreamType.Unidirectional),只允許建立方寫入資料,也可以是雙向的(QuicStreamType.Bidirectional),它允許兩邊都可以寫入資料。
下面是一個使用者端和伺服器端應用使用 Quic 通訊的範例。
專案的版本為 .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
掃碼關注【半棧程式設計師】,獲取最新文章。