Share via


QUIC-protocol

QUIC is een protocol voor netwerktransportlagen dat is gestandaardiseerd in RFC 9000. Het maakt gebruik van UDP als een onderliggend protocol en is inherent veilig omdat tls 1.3-gebruik wordt vereist. Zie RFC 9001 voor meer informatie. Een ander interessant verschil van bekende transportprotocollen zoals TCP en UDP is dat het multiplexing-multiplexing heeft ingebouwd op de transportlaag. Hierdoor kunnen meerdere, gelijktijdige, onafhankelijke gegevensstromen worden gebruikt die geen invloed op elkaar hebben.

QUIC zelf definieert geen semantiek voor de uitgewisselde gegevens, omdat het een transportprotocol is. Het wordt eerder gebruikt in protocollen voor toepassingslagen, bijvoorbeeld in HTTP/3 of in SMB via QUIC. Het kan ook worden gebruikt voor elk aangepast gedefinieerd protocol.

Het protocol biedt veel voordelen ten opzichte van TCP met TLS, hier volgen enkele:

  • Snellere verbinding tot stand gebracht omdat er niet zoveel retouren nodig zijn als TCP met TLS bovenaan.
  • Vermijd het blokkeren van hoofd-of-line-problemen waarbij één verloren pakket geen gegevens van alle andere streams blokkeert.

Aan de andere kant zijn er potentiële nadelen om rekening mee te houden bij het gebruik van QUIC. Als nieuwer protocol groeit de acceptatie ervan nog steeds en beperkt. Afgezien daarvan kan QUIC-verkeer zelfs worden geblokkeerd door sommige netwerkonderdelen.

QUIC in .NET

De QUIC-implementatie is geïntroduceerd in .NET 5 als bibliotheek System.Net.Quic . Tot .NET 7 was de bibliotheek echter strikt intern en diende deze alleen als implementatie van HTTP/3. Met .NET 7 werd de bibliotheek openbaar gemaakt, waardoor de API's worden weergegeven.

Notitie

In .NET 7.0 en 8.0 zijn de API's gepubliceerd als preview-functies. Vanaf .NET 9 worden deze API's niet langer beschouwd als preview-functies en worden ze nu als stabiel beschouwd.

Vanuit het perspectief System.Net.Quic van de implementatie hangt af van MsQuic, de systeemeigen implementatie van het QUIC-protocol. Als gevolg hiervan System.Net.Quic worden platformondersteuning en afhankelijkheden overgenomen van MsQuic en gedocumenteerd in de sectie Platformafhankelijkheden . Kortom, de MsQuic-bibliotheek wordt geleverd als onderdeel van .NET voor Windows. Maar voor Linux moet u handmatig installeren libmsquic via een geschikt pakketbeheer. Voor de andere platforms is het nog steeds mogelijk om MsQuic handmatig te bouwen, of het nu gaat om SChannel of OpenSSL, en deze te gebruiken met System.Net.Quic. Deze scenario's maken echter geen deel uit van onze testmatrix en onvoorziene problemen kunnen zich voordoen.

Platformafhankelijkheden

In de volgende secties worden de platformafhankelijkheden voor QUIC in .NET beschreven.

Windows

  • Windows 11, Windows Server 2022 of hoger. (In eerdere Windows-versies ontbreken de cryptografische API's die vereist zijn om QUIC te ondersteunen.)

In Windows wordt msquic.dll gedistribueerd als onderdeel van de .NET-runtime en zijn er geen andere stappen vereist om deze te installeren.

Linux

Notitie

.NET 7+ is alleen compatibel met 2.2+ versies van libmsquic.

Het libmsquic pakket is vereist in Linux. Dit pakket wordt gepubliceerd in de officiële Linux-pakketopslagplaats https://packages.microsoft.com van Microsoft en is ook beschikbaar in sommige officiële opslagplaatsen, zoals alpine packages - libmsquic.

Installeren libmsquic vanuit de officiële Linux-pakketopslagplaats van Microsoft

U moet deze opslagplaats toevoegen aan uw pakketbeheerder voordat u het pakket installeert. Zie De Linux-softwareopslagplaats voor Microsoft-producten voor meer informatie.

Let op

Het toevoegen van de Microsoft-pakketopslagplaats kan conflicteren met de opslagplaats van uw distributie wanneer de opslagplaats van uw distributie .NET en andere Microsoft-pakketten biedt. Raadpleeg .NET-fouten met betrekking tot ontbrekende bestanden in Linux om pakketmixen te voorkomen of op te lossen.

Voorbeelden

Hier volgen enkele voorbeelden van het gebruik van een pakketbeheerder om te installeren libmsquic:

  • GEPAST

    sudo apt-get install libmsquic 
    
  • APK

    sudo apk add libmsquic
    
  • DNF

    sudo dnf install libmsquic
    
  • zypper

    sudo zypper install libmsquic
    
  • YUM

    sudo yum install libmsquic
    

Installeren libmsquic vanuit de opslagplaats voor distributiepakketten

Installeren libmsquic vanuit de opslagplaats voor distributiepakketten is ook mogelijk, maar momenteel is dit alleen beschikbaar voor Alpine.

Voorbeelden

Hier volgen enkele voorbeelden van het gebruik van een pakketbeheerder om te installeren libmsquic:

  • Alpine 3.21 en hoger
apk add libmsquic
  • Alpine 3.20 en ouder
# Get libmsquic from community repository edge branch.
apk add --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community/ libmsquic
Afhankelijkheden van libmsquic

Alle volgende afhankelijkheden worden vermeld in het libmsquic pakketmanifest en worden automatisch geïnstalleerd door pakketbeheer:

  • OpenSSL 3+ of 1.1: is afhankelijk van de standaardVersie van OpenSSL voor de distributieversie, bijvoorbeeld OpenSSL 3 voor Ubuntu 22 en OpenSSL 1.1 voor Ubuntu 20.

  • libnuma1

macOS

QUIC wordt nu gedeeltelijk ondersteund in macOS via een niet-standaard Homebrew-pakketbeheerder met enkele beperkingen. U kunt libmsquic installeren op macOS met behulp van Homebrew met de volgende opdracht:

brew install libmsquic

Als u een .NET-toepassing wilt uitvoeren die gebruikmaakt libmsquic, moet u de omgevingsvariabele instellen voordat u deze uitvoert. Dit zorgt ervoor dat de toepassing de libmsquic bibliotheek kan vinden tijdens het dynamisch laden van runtime. U kunt dit doen door de volgende opdracht toe te voegen vóór de hoofdopdracht:

DYLD_FALLBACK_LIBRARY_PATH=$DYLD_FALLBACK_LIBRARY_PATH:$(brew --prefix)/lib dotnet run

or

DYLD_FALLBACK_LIBRARY_PATH=$DYLD_FALLBACK_LIBRARY_PATH:$(brew --prefix)/lib ./binaryname

U kunt ook de omgevingsvariabele instellen met:

export DYLD_FALLBACK_LIBRARY_PATH=$DYLD_FALLBACK_LIBRARY_PATH:$(brew --prefix)/lib

en voer vervolgens de hoofdopdracht uit:

./binaryname

API-overzicht

System.Net.Quic brengt drie hoofdklassen die het gebruik van het QUIC-protocol mogelijk maken:

Maar voordat u deze klassen gebruikt, moet uw code controleren of QUIC momenteel wordt ondersteund, zoals libmsquic mogelijk ontbreekt of TLS 1.3 mogelijk niet wordt ondersteund. Hiervoor worden zowel QuicListener QuicConnection een statische eigenschap als een statische eigenschap IsSupportedweergegeven:

if (QuicListener.IsSupported)
{
    // Use QuicListener
}
else
{
    // Fallback/Error
}

if (QuicConnection.IsSupported)
{
    // Use QuicConnection
}
else
{
    // Fallback/Error
}

Deze eigenschappen rapporteren dezelfde waarde, maar die kunnen in de toekomst veranderen. Het is raadzaam om te controleren op IsSupported serverscenario's en IsSupported voor de client.

QuicListener

QuicListener vertegenwoordigt een klasse aan de serverzijde die binnenkomende verbindingen van de clients accepteert. De listener is samengesteld en gestart met een statische methode ListenAsync(QuicListenerOptions, CancellationToken). De methode accepteert een exemplaar van QuicListenerOptions klasse met alle instellingen die nodig zijn om de listener te starten en binnenkomende verbindingen te accepteren. Daarna is de listener klaar om verbindingen via AcceptConnectionAsync(CancellationToken)te delen. Verbindingen die door deze methode worden geretourneerd, zijn altijd volledig verbonden, wat betekent dat de TLS-handshake is voltooid en de verbinding gereed is om te worden gebruikt. Ten slotte moet worden aangeroepen om te stoppen met luisteren en alle resources vrij DisposeAsync() te geven.

Bekijk de volgende QuicListener voorbeeldcode:

using System.Net.Quic;

// First, check if QUIC is supported.
if (!QuicListener.IsSupported)
{
    Console.WriteLine("QUIC is not supported, check for presence of libmsquic and support of TLS 1.3.");
    return;
}

// Share configuration for each incoming connection.
// This represents the minimal configuration necessary.
var serverConnectionOptions = new QuicServerConnectionOptions
{
    // Used to abort stream if it's not properly closed by the user.
    // See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
    DefaultStreamErrorCode = 0x0A, // Protocol-dependent error code.

    // Used to close the connection if it's not done by the user.
    // See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
    DefaultCloseErrorCode = 0x0B, // Protocol-dependent error code.

    // Same options as for server side SslStream.
    ServerAuthenticationOptions = new SslServerAuthenticationOptions
    {
        // Specify the application protocols that the server supports. This list must be a subset of the protocols specified in QuicListenerOptions.ApplicationProtocols.
        ApplicationProtocols = [new SslApplicationProtocol("protocol-name")],
        // Server certificate, it can also be provided via ServerCertificateContext or ServerCertificateSelectionCallback.
        ServerCertificate = serverCertificate
    }
};

// Initialize, configure the listener and start listening.
var listener = await QuicListener.ListenAsync(new QuicListenerOptions
{
    // Define the endpoint on which the server will listen for incoming connections. The port number 0 can be replaced with any valid port number as needed.
    ListenEndPoint = new IPEndPoint(IPAddress.Loopback, 0),
    // List of all supported application protocols by this listener.
    ApplicationProtocols = [new SslApplicationProtocol("protocol-name")],
    // Callback to provide options for the incoming connections, it gets called once per each connection.
    ConnectionOptionsCallback = (_, _, _) => ValueTask.FromResult(serverConnectionOptions)
});

// Accept and process the connections.
while (isRunning)
{
    // Accept will propagate any exceptions that occurred during the connection establishment,
    // including exceptions thrown from ConnectionOptionsCallback, caused by invalid QuicServerConnectionOptions or TLS handshake failures.
    var connection = await listener.AcceptConnectionAsync();

    // Process the connection...
}

// When finished, dispose the listener.
await listener.DisposeAsync();

Zie het API-voorstel voor meer informatie over hoe het QuicListener is ontworpen.

QuicConnection

QuicConnection is een klasse die wordt gebruikt voor QUIC-verbindingen aan de server- en clientzijde. Verbindingen aan de serverzijde worden intern gemaakt door de listener en doorgegeven via AcceptConnectionAsync(CancellationToken). Verbindingen aan de clientzijde moeten worden geopend en verbonden met de server. Net als bij de listener is er een statische methode ConnectAsync(QuicClientConnectionOptions, CancellationToken) waarmee de verbinding wordt geïnstitueert en verbonden. Het accepteert een instantie van QuicClientConnectionOptions, een analoge klasse voor QuicServerConnectionOptions. Daarna verschilt het werk met de verbinding niet tussen client en server. Hiermee kunnen uitgaande streams worden geopend en binnenkomende stromen worden geaccepteerd. Het biedt ook eigenschappen met informatie over de verbinding, zoals LocalEndPoint, RemoteEndPointof RemoteCertificate.

Wanneer het werk met de verbinding is voltooid, moet deze worden gesloten en verwijderd. QUIC-protocolmandaten met behulp van een toepassingslaagcode voor onmiddellijke sluiting, zie RFC 9000 Sectie 10.2. CloseAsync(Int64, CancellationToken) Hiervoor kan code van de toepassingslaag worden aangeroepen of zo niet, DisposeAsync() wordt de code gebruikt die is opgegeven in DefaultCloseErrorCode. In beide gevallen DisposeAsync() moet aan het einde van het werk worden aangeroepen met de verbinding om alle bijbehorende resources volledig vrij te geven.

Bekijk de volgende QuicConnection voorbeeldcode:

using System.Net.Quic;

// First, check if QUIC is supported.
if (!QuicConnection.IsSupported)
{
    Console.WriteLine("QUIC is not supported, check for presence of libmsquic and support of TLS 1.3.");
    return;
}

// This represents the minimal configuration necessary to open a connection.
var clientConnectionOptions = new QuicClientConnectionOptions
{
    // End point of the server to connect to.
    RemoteEndPoint = listener.LocalEndPoint,

    // Used to abort stream if it's not properly closed by the user.
    // See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
    DefaultStreamErrorCode = 0x0A, // Protocol-dependent error code.

    // Used to close the connection if it's not done by the user.
    // See https://www.rfc-editor.org/rfc/rfc9000#section-20.2
    DefaultCloseErrorCode = 0x0B, // Protocol-dependent error code.

    // Optionally set limits for inbound streams.
    MaxInboundUnidirectionalStreams = 10,
    MaxInboundBidirectionalStreams = 100,

    // Same options as for client side SslStream.
    ClientAuthenticationOptions = new SslClientAuthenticationOptions
    {
        // List of supported application protocols.
        ApplicationProtocols = [new SslApplicationProtocol("protocol-name")],
        // The name of the server the client is trying to connect to. Used for server certificate validation.
        TargetHost = ""
    }
};

// Initialize, configure and connect to the server.
var connection = await QuicConnection.ConnectAsync(clientConnectionOptions);

Console.WriteLine($"Connected {connection.LocalEndPoint} --> {connection.RemoteEndPoint}");

// Open a bidirectional (can both read and write) outbound stream.
// Opening a stream reserves it but does not notify the peer or send any data. If you don't send data, the peer
// won't be informed about the stream, which can cause AcceptInboundStreamAsync() to hang. To avoid this, ensure
// you send data on the stream to properly initiate communication.
var outgoingStream = await connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional);

// Work with the outgoing stream ...

// To accept any stream on a client connection, at least one of MaxInboundBidirectionalStreams or MaxInboundUnidirectionalStreams of QuicConnectionOptions must be set.
while (isRunning)
{
    // Accept an inbound stream.
    var incomingStream = await connection.AcceptInboundStreamAsync();

    // Work with the incoming stream ...
}

// Close the connection with the custom code.
await connection.CloseAsync(0x0C);

// Dispose the connection.
await connection.DisposeAsync();

Zie het API-voorstel voor meer informatie over hoe het QuicConnection is ontworpen.

QuicStream

QuicStream is het werkelijke type dat wordt gebruikt voor het verzenden en ontvangen van gegevens in het QUIC-protocol. Het is afgeleid van gewoon Stream en kan als zodanig worden gebruikt, maar biedt ook verschillende functies die specifiek zijn voor QUIC-protocol. Ten eerste kan een QUIC-stroom unidirectioneel of bidirectioneel zijn, zie RFC 9000 Section 2.1. Een bidirectionele stroom kan gegevens aan beide zijden verzenden en ontvangen, terwijl een unidirectionele stroom alleen kan schrijven vanaf de initiërende zijde en kan lezen over de accepterende stroom. Elke peer kan beperken hoeveel gelijktijdige stream van elk type bereid is te accepteren, te zien MaxInboundBidirectionalStreams en MaxInboundUnidirectionalStreams.

Een andere bijzonderheid van QUIC-stroom is de mogelijkheid om de schrijfzijde expliciet te sluiten in het midden van het werk met de stream, te zien CompleteWrites() of WriteAsync(ReadOnlyMemory<Byte>, Boolean, CancellationToken) te overbelasten met completeWrites een argument. Als de schrijfzijde wordt gesloten, weet de peer dat er geen gegevens meer binnenkomen, maar de peer kan nog steeds worden verzonden (in het geval van een bidirectionele stroom). Dit is handig in scenario's zoals HTTP-aanvraag-/antwoorduitwisseling wanneer de client de aanvraag verzendt en de schrijfzijde sluit om de server te laten weten dat dit het einde van de aanvraaginhoud is. De server kan het antwoord daarna nog steeds verzenden, maar weet dat er geen gegevens meer van de client worden ontvangen. En voor foutieve gevallen kan de schrijf- of leeszijde van de stream worden afgebroken.Abort(QuicAbortDirection, Int64)

Notitie

Het openen van een stream behoudt zich alleen voor zonder gegevens te verzenden. Deze benadering is ontworpen om het netwerkgebruik te optimaliseren door de overdracht van bijna lege frames te voorkomen. Omdat de peer pas op de hoogte wordt gesteld als de werkelijke gegevens worden verzonden, blijft de stream inactief vanuit het perspectief van de peer. Als u geen gegevens verzendt, herkent de peer de stroom niet, wat ertoe kan leiden AcceptInboundStreamAsync() dat deze vastloopt terwijl er wordt gewacht op een zinvolle stream. Om de juiste communicatie te garanderen, moet u gegevens verzenden nadat u de stream hebt geopend.

Het gedrag van de afzonderlijke methoden voor elk stroomtype wordt samengevat in de volgende tabel (houd er rekening mee dat zowel de client als de server streams kan openen en accepteren):

Wijze Peer-openingsstream Peer die stream accepteert
CanRead bidirectioneel: true
unidirectioneel: false
true
CanWrite true bidirectioneel: true
unidirectioneel: false
ReadAsync bidirectioneel: leest gegevens
unidirectioneel: InvalidOperationException
leest gegevens
WriteAsync verzendt gegevens => peer read retourneert de gegevens bidirectioneel: verzendt gegevens => peer read retourneert de gegevens
unidirectioneel: InvalidOperationException
CompleteWrites sluit de schrijfzijde => peer read retourneert 0 bidirectioneel: sluit de schrijfzijde => peer read retourneert 0
unidirectioneel: no-op
Abort(QuicAbortDirection.Read) bidirectioneel: STOP_SENDING => schrijfbewerkingen van peersQuicException(QuicError.OperationAborted)
unidirectioneel: no-op
STOP_SENDING => schrijfbewerkingen van peersQuicException(QuicError.OperationAborted)
Abort(QuicAbortDirection.Write) RESET_STREAM => peer read throwsQuicException(QuicError.OperationAborted) bidirectioneel: RESET_STREAM => peer read throws QuicException(QuicError.OperationAborted)
unidirectioneel: no-op

Boven op deze methoden QuicStream biedt u twee gespecialiseerde eigenschappen om op de hoogte te worden gebracht wanneer de lees- of schrijfzijde van de stream is gesloten: ReadsClosed en WritesClosed. Beide retourneren een Task die is voltooid met de bijbehorende kant wordt gesloten, of het nu gelukt of afgebroken is, in welk geval de Task juiste uitzondering bevat. Deze eigenschappen zijn handig wanneer de gebruikerscode moet weten dat de streamzijde wordt gesloten zonder aanroep naar ReadAsync of WriteAsync.

Ten slotte, wanneer het werk met de stream is voltooid, moet deze worden verwijderd met DisposeAsync(). De verwijdering zorgt ervoor dat zowel de lees- als/of schrijfzijde , afhankelijk van het stroomtype, is gesloten. Als de stream niet goed is gelezen tot het einde, geeft verwijdering een equivalent van Abort(QuicAbortDirection.Read). Als de schrijfzijde van de stroom echter niet is gesloten, wordt deze correct gesloten zoals bij CompleteWrites. De reden voor dit verschil is om ervoor te zorgen dat scenario's die werken met een gewoon Stream gedrag zoals verwacht werken, naar een succesvol pad leiden. Kijk een naar het volgende voorbeeld:

// Work done with all different types of streams.
async Task WorkWithStreamAsync(Stream stream)
{
    // This will dispose the stream at the end of the scope.
    await using (stream)
    {
        // Simple echo, read data and send them back.
        byte[] buffer = new byte[1024];
        int count = 0;
        // The loop stops when read returns 0 bytes as is common for all streams.
        while ((count = await stream.ReadAsync(buffer)) > 0)
        {
            await stream.WriteAsync(buffer.AsMemory(0, count));
        }
    }
}

// Open a QuicStream and pass to the common method.
var quicStream = await connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional);
await WorkWithStreamAsync(quicStream);

Het voorbeeldgebruik van QuicStream in het clientscenario:

// Consider connection from the connection example, open a bidirectional stream.
await using var stream = await connection.OpenOutboundStreamAsync(QuicStreamType.Bidirectional, cancellationToken);

// Send some data.
await stream.WriteAsync(data, cancellationToken);
await stream.WriteAsync(data, cancellationToken);

// End the writing-side together with the last data.
await stream.WriteAsync(data, completeWrites: true, cancellationToken);
// Or separately.
stream.CompleteWrites();

// Read data until the end of stream.
while (await stream.ReadAsync(buffer, cancellationToken) > 0)
{
    // Handle buffer data...
}

// DisposeAsync called by await using at the top.

En het voorbeeldgebruik van QuicStream in het serverscenario:

// Consider connection from the connection example, accept a stream.
await using var stream = await connection.AcceptInboundStreamAsync(cancellationToken);

if (stream.Type != QuicStreamType.Bidirectional)
{
    Console.WriteLine($"Expected bidirectional stream, got {stream.Type}");
    return;
}

// Read the data.
while (stream.ReadAsync(buffer, cancellationToken) > 0)
{
    // Handle buffer data...

    // Client completed the writes, the loop might be exited now without another ReadAsync.
    if (stream.ReadsCompleted.IsCompleted)
    {
        break;
    }
}

// Listen for Abort(QuicAbortDirection.Read) from the client.
var writesClosedTask = WritesClosedAsync(stream);
async ValueTask WritesClosedAsync(QuicStream stream)
{
    try
    {
        await stream.WritesClosed;
    }
    catch (Exception ex)
    {
        // Handle peer aborting our writing side ...
    }
}

// DisposeAsync called by await using at the top.

Zie het API-voorstel voor meer informatie over hoe het QuicStream is ontworpen.

Zie ook