Udostępnij za pośrednictwem


Protokół QUIC

QUIC to protokół warstwy transportu sieciowego ustandaryzowany w standardzie RFC 9000. Używa protokołu UDP jako podstawowego protokołu i jest z natury bezpieczny, ponieważ nakazuje użycie protokołu TLS 1.3. Aby uzyskać więcej informacji, zobacz RFC 9001. Kolejną interesującą różnicą między dobrze znanymi protokołami transportowym, takimi jak TCP i UDP, jest to, że ma wbudowane multipleksowanie w warstwie transportowej. Umożliwia to posiadanie wielu, współbieżnych, niezależnych strumieni danych, które nie wpływają na siebie nawzajem.

Sam QUIC nie definiuje żadnych semantyki dla wymienianych danych, ponieważ jest to protokół transportowy. Jest on raczej używany w protokołach warstwy aplikacji, na przykład w protokole HTTP/3 lub W protokole SMB za pośrednictwem quiC. Można go również używać dla dowolnego protokołu zdefiniowanego przez użytkownika.

Protokół oferuje wiele zalet protokołu TCP z protokołem TLS. Oto kilka:

  • Szybsze nawiązywanie połączenia, ponieważ nie wymaga tak wielu rund, jak tcp z protokołem TLS na górze.
  • Unikanie problemu z blokowaniem nagłówka linii, w którym jeden utracony pakiet nie blokuje danych wszystkich pozostałych strumieni.

Z drugiej strony istnieją potencjalne wady, które należy wziąć pod uwagę podczas korzystania z QUIC. Jako nowszy protokół jego wdrażanie wciąż rośnie i ogranicza. Oprócz tego ruch QUIC może być nawet blokowany przez niektóre składniki sieciowe.

QUIC na platformie .NET

Implementacja QUIC została wprowadzona na platformie .NET 5 jako System.Net.Quic biblioteka. Jednak aż do platformy .NET 7 biblioteka była ściśle wewnętrzna i służyła tylko jako implementacja protokołu HTTP/3. W przypadku platformy .NET 7 biblioteka została upubliczniona, co powoduje uwidocznianie jej interfejsów API.

Uwaga

W programach .NET 7.0 i 8.0 interfejsy API zostały opublikowane jako funkcje w wersji zapoznawczej. Począwszy od platformy .NET 9, te interfejsy API nie są już uznawane za funkcje w wersji zapoznawczej i są teraz uważane za stabilne.

Z perspektywy System.Net.Quic implementacji zależy od msQuic, natywnej implementacji protokołu QUIC. W związku z tym System.Net.Quic obsługa platformy i zależności są dziedziczone z biblioteki MsQuic i udokumentowane w sekcji Zależności platformy. Krótko mówiąc, biblioteka MsQuic jest dostarczana w ramach platformy .NET dla systemu Windows. Jednak w przypadku systemu Linux należy ręcznie zainstalować libmsquic za pośrednictwem odpowiedniego menedżera pakietów. W przypadku innych platform nadal można ręcznie skompilować bibliotekę MsQuic, zarówno w przypadku protokołu SChannel, jak i biblioteki OpenSSL i używać jej z System.Net.Quicprogramem . Jednak te scenariusze nie są częścią naszej macierzy testowania i mogą wystąpić nieprzewidziane problemy.

Zależności platformy

W poniższych sekcjach opisano zależności platformy dla platformy QUIC na platformie .NET.

Windows

  • Windows 11, Windows Server 2022 lub nowszy. (Starsze wersje systemu Windows nie mają interfejsów API kryptograficznych wymaganych do obsługi quiC).

W systemie Windows msquic.dll jest dystrybuowana w ramach środowiska uruchomieniowego platformy .NET i nie są wymagane żadne inne kroki, aby je zainstalować.

Linux

Uwaga

Program .NET 7+ jest zgodny tylko z wersjami biblioteki libmsquic w wersji 2.2 lub nowszej.

Pakiet libmsquic jest wymagany w systemie Linux. Ten pakiet jest publikowany w oficjalnym repozytorium pakietów systemu Linux firmy Microsoft i https://packages.microsoft.com jest również dostępny w niektórych oficjalnych repozytoriach, takich jak Alpine Packages - libmsquic.

libmsquic Instalowanie z oficjalnego repozytorium pakietów systemu Linux firmy Microsoft

Przed zainstalowaniem pakietu należy dodać to repozytorium do menedżera pakietów. Aby uzyskać więcej informacji, zobacz Linux Software Repository for Microsoft Products (Repozytorium oprogramowania systemu Linux dla produktów firmy Microsoft).

Uwaga

Dodanie repozytorium pakietów firmy Microsoft może powodować konflikt z repozytorium dystrybucji, gdy repozytorium dystrybucji udostępnia platformę .NET i inne pakiety firmy Microsoft. Aby uniknąć kombinacji pakietów lub rozwiązać problemy, zapoznaj się z artykułem Rozwiązywanie problemów z błędami platformy .NET związanymi z brakującymi plikami w systemie Linux.

Przykłady

Oto kilka przykładów użycia menedżera pakietów do zainstalowania programu libmsquic:

  • TRAFNY

    sudo apt-get install libmsquic 
    
  • APK

    sudo apk add libmsquic
    
  • DNF

    sudo dnf install libmsquic
    
  • zypper

    sudo zypper install libmsquic
    
  • MNIAM

    sudo yum install libmsquic
    

Instalowanie libmsquic z repozytorium pakietów dystrybucyjnych

Instalowanie libmsquic z repozytorium pakietów dystrybucyjnych jest również możliwe, ale obecnie jest to dostępne tylko dla programu Alpine.

Przykłady

Oto kilka przykładów użycia menedżera pakietów do zainstalowania programu libmsquic:

  • Alpine 3.21 i nowsze
apk add libmsquic
  • Alpine 3.20 i starsze
# Get libmsquic from community repository edge branch.
apk add --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community/ libmsquic
Zależności biblioteki libmsquic

Wszystkie następujące zależności są określone w libmsquic manifeście pakietu i są automatycznie instalowane przez menedżera pakietów:

  • OpenSSL 3+ lub 1.1 — zależy od domyślnej wersji biblioteki OpenSSL dla wersji dystrybucji, na przykład OpenSSL 3 dla ubuntu 22 i OpenSSL 1.1 dla systemu Ubuntu 20.

  • libnuma1

macOS

Funkcja QUIC jest teraz częściowo obsługiwana w systemie macOS za pośrednictwem niestandardowego menedżera pakietów Oprogramowania Homebrew z pewnymi ograniczeniami. Instalację w systemie macOS można zainstalować libmsquic przy użyciu oprogramowania Homebrew za pomocą następującego polecenia:

brew install libmsquic

Aby uruchomić aplikację .NET korzystającą z libmsquicprogramu , należy ustawić zmienną środowiskową przed uruchomieniem. Dzięki temu aplikacja będzie mogła znaleźć bibliotekę libmsquic podczas dynamicznego ładowania środowiska uruchomieniowego. Możesz to zrobić, dodając następujące polecenie przed głównym poleceniem:

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

lub

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

Alternatywnie można ustawić zmienną środowiskową za pomocą polecenia:

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

a następnie uruchom główne polecenie:

./binaryname

Przegląd interfejsu API

System.Net.Quic oferuje trzy główne klasy, które umożliwiają użycie protokołu QUIC:

Jednak przed użyciem tych klas kod powinien sprawdzić, czy quiC jest obecnie obsługiwany, ponieważ libmsquic może brakować, czy protokół TLS 1.3 może nie być obsługiwany. W tym przypadku zarówno właściwość statyczna, jak QuicListener i QuicConnection uwidaczniającą właściwość IsSupportedstatyczną:

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

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

Te właściwości będą zgłaszać tę samą wartość, ale może to ulec zmianie w przyszłości. Zaleca się sprawdzenie IsSupported scenariuszy serwerowych i IsSupported klientów.

QuicListener

QuicListener reprezentuje klasę po stronie serwera, która akceptuje połączenia przychodzące od klientów. Odbiornik jest konstruowany i uruchamiany przy użyciu metody ListenAsync(QuicListenerOptions, CancellationToken)statycznej . Metoda akceptuje wystąpienie klasy ze wszystkimi QuicListenerOptions ustawieniami niezbędnymi do uruchomienia odbiornika i akceptowania połączeń przychodzących. Następnie odbiornik jest gotowy do przekazywania połączeń za pośrednictwem .AcceptConnectionAsync(CancellationToken) Połączenia zwracane przez tę metodę są zawsze w pełni połączone, co oznacza, że uzgadnianie protokołu TLS zostało zakończone, a połączenie jest gotowe do użycia. Na koniec, aby zatrzymać nasłuchiwanie i zwalnianie wszystkich zasobów, DisposeAsync() należy wywołać metodę .

Rozważmy następujący QuicListener przykładowy kod:

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();

Aby uzyskać więcej informacji na temat QuicListener sposobu projektowania, zobacz propozycję interfejsu API.

QuicConnection

QuicConnection jest klasą używaną zarówno dla połączeń QUIC po stronie serwera, jak i klienta. Połączenia po stronie serwera są tworzone wewnętrznie przez odbiornik i przekazywane za pośrednictwem polecenia AcceptConnectionAsync(CancellationToken). Połączenia po stronie klienta muszą być otwarte i połączone z serwerem. Podobnie jak w przypadku odbiornika, istnieje metoda ConnectAsync(QuicClientConnectionOptions, CancellationToken) statyczna, która tworzy wystąpienie i łączy połączenie. Akceptuje wystąpienie QuicClientConnectionOptionsklasy , analogiczną klasę do QuicServerConnectionOptionsklasy . Następnie praca z połączeniem nie różni się między klientem a serwerem. Może otwierać strumienie wychodzące i akceptować przychodzące. Udostępnia również właściwości z informacjami o połączeniu, takimi jak LocalEndPoint, RemoteEndPointlub RemoteCertificate.

Po zakończeniu pracy z połączeniem należy go zamknąć i usunąć. Protokół QUIC nakazuje używanie kodu warstwy aplikacji do natychmiastowego zamknięcia, zobacz RFC 9000 Sekcja 10.2. W tym CloseAsync(Int64, CancellationToken) celu można wywołać kod warstwy aplikacji lub jeśli nie, DisposeAsync() użyje kodu podanego w pliku DefaultCloseErrorCode. Tak czy inaczej, należy wywołać na końcu pracy z połączeniem, DisposeAsync() aby w pełni zwolnić wszystkie skojarzone zasoby.

Rozważmy następujący QuicConnection przykładowy kod:

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();

Aby uzyskać więcej informacji na temat QuicConnection sposobu projektowania, zobacz propozycję interfejsu API.

QuicStream

QuicStream to rzeczywisty typ używany do wysyłania i odbierania danych w protokole QUIC. Pochodzi on ze zwykłych Stream i może być używany jako taki, ale oferuje również kilka funkcji specyficznych dla protokołu QUIC. Po pierwsze strumień QUIC może być jednokierunkowy lub dwukierunkowy, zobacz RFC 9000 Sekcja 2.1. Strumień dwukierunkowy może wysyłać i odbierać dane po obu stronach, natomiast jednokierunkowy strumień może zapisywać tylko ze strony inicjowania i odczytywać je. Każdy element równorzędny może ograniczyć liczbę współbieżnych strumieni każdego typu, które są skłonne zaakceptować, zobacz MaxInboundBidirectionalStreams i MaxInboundUnidirectionalStreams.

Inną specyfiką strumienia QUIC jest możliwość jawnego zamknięcia strony zapisu w środku pracy ze strumieniem, zobacz CompleteWrites() lub WriteAsync(ReadOnlyMemory<Byte>, Boolean, CancellationToken) przeciążyć argumentem completeWrites . Zamknięcie strony zapisu informuje element równorzędny, że żadne dane nie zostaną dostarczone, ale element równorzędny nadal może nadal wysyłać (w przypadku strumienia dwukierunkowego). Jest to przydatne w scenariuszach, takich jak wymiana żądań HTTP/odpowiedzi, gdy klient wysyła żądanie i zamyka stronę zapisu, aby poinformować serwer, że jest to koniec zawartości żądania. Serwer jest nadal w stanie wysłać odpowiedź po tym, ale wie, że żadne dane nie zostaną dostarczone z klienta. W przypadku błędnych przypadków można przerwać zapisywanie lub odczytywanie strony strumienia, zobacz Abort(QuicAbortDirection, Int64).

Uwaga

Otwarcie strumienia powoduje rezerwę tylko bez wysyłania żadnych danych. Takie podejście zostało zaprojektowane w celu zoptymalizowania użycia sieci przez uniknięcie transmisji prawie pustych ramek. Ponieważ element równorzędny nie jest powiadamiany do momentu wysłania rzeczywistych danych, strumień pozostaje nieaktywny z perspektywy elementu równorzędnego. Jeśli nie wyślesz danych, element równorzędny nie rozpozna strumienia, co może spowodować AcceptInboundStreamAsync() zawieszenie się podczas oczekiwania na znaczący strumień. Aby zapewnić właściwą komunikację, należy wysłać dane po otwarciu strumienia.

Zachowanie poszczególnych metod dla każdego typu strumienia jest podsumowane w poniższej tabeli (należy pamiętać, że zarówno klient, jak i serwer mogą otwierać i akceptować strumienie):

Method Strumień otwierania elementu równorzędnego Komunikacja równorzędna akceptująca strumień
CanRead dwukierunkowe: true
jednokierunkowe: false
true
CanWrite true dwukierunkowe: true
jednokierunkowe: false
ReadAsync dwukierunkowe: odczytuje dane
jednokierunkowe: InvalidOperationException
odczytuje dane
WriteAsync wysyła dane => odczyt elementu równorzędnego zwraca dane dwukierunkowe: wysyła dane => odczyt elementu równorzędnego zwraca dane
jednokierunkowe: InvalidOperationException
CompleteWrites zamyka stronę zapisu => odczyt elementu równorzędnego zwraca wartość 0 dwukierunkowe: zamyka stronę zapisu => odczyt równorzędny zwraca wartość 0
jednokierunkowe: brak operacji
Abort(QuicAbortDirection.Read) dwukierunkowe: STOP_SENDING => rzuty zapisu równorzędnego QuicException(QuicError.OperationAborted)
jednokierunkowe: brak operacji
STOP_SENDING => rzuty zapisu równorzędnegoQuicException(QuicError.OperationAborted)
Abort(QuicAbortDirection.Write) RESET_STREAM => rzuty odczytu elementu równorzędnegoQuicException(QuicError.OperationAborted) dwukierunkowe: RESET_STREAM => rzuty odczytu elementu równorzędnego QuicException(QuicError.OperationAborted)
jednokierunkowe: brak operacji

Oprócz tych metod oferuje dwie wyspecjalizowane właściwości, aby otrzymywać powiadomienia za każdym razem, QuicStream gdy strona odczytu lub zapisu strumienia została zamknięta: ReadsClosed i WritesClosed. Obie wartości zwracają element Task , który kończy się wraz z zamknięciem odpowiedniej strony, niezależnie od tego, czy jest to sukces, czy przerwanie, w takim przypadku Task będzie zawierać odpowiedni wyjątek. Te właściwości są przydatne, gdy kod użytkownika musi wiedzieć o zamknięciu po stronie strumienia bez wywoływania ReadAsync metody lub WriteAsync.

Na koniec po zakończeniu pracy ze strumieniem należy go usunąć za pomocą polecenia DisposeAsync(). Funkcja usuwania upewni się, że zarówno strona odczytu, jak i/lub zapisu — w zależności od typu strumienia — jest zamknięta. Jeśli strumień nie został prawidłowo odczytany do końca, usunięcie spowoduje wystawienie odpowiednika Abort(QuicAbortDirection.Read). Jeśli jednak strona zapisywania strumienia nie została zamknięta, zostanie bezpiecznie zamknięta, tak jak w przypadku CompleteWriteselementu . Przyczyną tej różnicy jest upewnienie się, że scenariusze współpracujące ze zwykłym Stream zachowaniem zachowują się zgodnie z oczekiwaniami i prowadzą do pomyślnej ścieżki. Rozważmy następujący przykład:

// 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);

Przykładowe użycie elementu w scenariuszu QuicStream klienta:

// 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.

Przykładowe użycie w scenariuszu QuicStream serwera:

// 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.

Aby uzyskać więcej informacji na temat QuicStream sposobu projektowania, zobacz propozycję interfejsu API.

Zobacz też