Протокол QUIC
QUIC — это протокол сетевого транспортного уровня, стандартизированный в RFC 9000. Он использует UDP в качестве базового протокола, и он по сути безопасно, так как он требует использования TLS 1.3. Дополнительные сведения см. в статье RFC 9001. Еще одно интересное отличие от известных транспортных протоколов, таких как TCP и UDP, заключается в том, что он имеет мультиплексирование потоков, встроенных на транспортном уровне. Это позволяет иметь несколько параллельных, независимых потоков данных, которые не влияют друг на друга.
Сам QUIC не определяет семантику для обменных данных, так как это транспортный протокол. Он довольно используется в протоколах уровня приложений, например в ПРОТОКОЛе HTTP/3 или В SMB через QUIC. Его также можно использовать для любого пользовательского протокола.
Протокол предлагает множество преимуществ по протоколу TCP с протоколом TLS. Ниже приведены некоторые из следующих вариантов:
- Быстрое создание подключения, так как для него не требуется столько круговой передачи, сколько TCP с TLS сверху.
- Избегание проблемы блокировки головной строки, когда один потерянный пакет не блокирует данные всех остальных потоков.
С другой стороны, существуют потенциальные недостатки, которые следует учитывать при использовании QUIC. Как более новый протокол, его внедрение по-прежнему растет и ограничено. Помимо этого, трафик QUIC может быть даже заблокирован некоторыми сетевыми компонентами.
QUIC в .NET
Реализация QUIC появилась в .NET 5 в качестве библиотеки System.Net.Quic
. Однако до .NET 7 библиотека была строго внутренней и служила только реализацией HTTP/3. С помощью .NET 7 библиотека была открыта таким образом, предоставляя свои API.
Примечание.
В .NET 7.0 и 8.0 API были опубликованы в качестве предварительных версий функций. Начиная с .NET 9 эти API больше не считаются предварительными версиями функций и теперь считаются стабильными.
С точки зрения реализации, System.Net.Quic
зависит от MsQuic, собственной реализации протокола QUIC. В результате поддержка System.Net.Quic
платформы и зависимости наследуются от MsQuic и описаны в разделе зависимостей платформы. Короче говоря, библиотека MsQuic поставляется в составе .NET для Windows. Но для Linux необходимо вручную установить libmsquic
с помощью соответствующего диспетчера пакетов. Для других платформ по-прежнему можно создать MsQuic вручную, независимо от SChannel или OpenSSL, и использовать его с System.Net.Quic
. Однако эти сценарии не являются частью матрицы тестирования и непредвиденные проблемы могут возникнуть.
Зависимости платформы
В следующих разделах описаны зависимости платформы для QUIC в .NET.
Windows
- Windows 11, Windows Server 2022 или более поздней версии. (Более ранние версии Windows отсутствуют криптографические API, необходимые для поддержки QUIC.)
В Windows msquic.dll распространяется в рамках среды выполнения .NET, а для установки не требуется никаких других действий.
Linux
Примечание.
.NET 7+ совместим только с версиями libmsquic версии 2.2+ .
Пакет libmsquic
требуется в Linux. Этот пакет опубликован в официальном репозитории пакетов Linux корпорации Майкрософт, https://packages.microsoft.com а также доступен в некоторых официальных репозиториях, таких как Alpine Packages — libmsquic.
Установка libmsquic
из официального репозитория пакетов Linux корпорации Майкрософт
Перед установкой пакета необходимо добавить этот репозиторий в диспетчер пакетов. Дополнительные сведения см. в репозитории программного обеспечения Linux для продуктов Майкрософт.
Внимание
Добавление репозитория пакетов Майкрософт может конфликтуть с репозиторием дистрибутива, когда репозиторий дистрибутива предоставляет .NET и другие пакеты Майкрософт. Чтобы избежать или устранить неполадки с пакетами, ознакомьтесь с ошибками .NET, связанными с отсутствующими файлами в Linux.
Примеры
Ниже приведены некоторые примеры использования диспетчера пакетов для установки libmsquic
:
СПОСОБНЫЙ
sudo apt-get install libmsquic
APK
sudo apk add libmsquic
DNF
sudo dnf install libmsquic
zypper
sudo zypper install libmsquic
НЯМ
sudo yum install libmsquic
Установка libmsquic
из репозитория пакетов распространения
Установка libmsquic
из репозитория пакетов распространителя также возможна, но в настоящее время она доступна только для Alpine
.
Примеры
Ниже приведены некоторые примеры использования диспетчера пакетов для установки libmsquic
:
- Alpine 3.21 и более поздних версий
apk add libmsquic
- Alpine 3.20 и старше
# Get libmsquic from community repository edge branch.
apk add --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community/ libmsquic
Зависимости libmsquic
Все следующие зависимости указаны в манифесте libmsquic
пакета и автоматически устанавливаются диспетчером пакетов:
OpenSSL 3+ или 1.1 — зависит от версии OpenSSL по умолчанию для версии дистрибутива, например OpenSSL 3 для Ubuntu 22 и OpenSSL 1.1 для Ubuntu 20.
libnuma1
macOS
QuIC теперь частично поддерживается в macOS с помощью диспетчера пакетов Homebrew нестандартного уровня с некоторыми ограничениями. Вы можете установить libmsquic
в macOS с помощью Homebrew с помощью следующей команды:
brew install libmsquic
Чтобы запустить приложение .NET, которое используется libmsquic
, необходимо задать переменную среды перед его запуском. Это гарантирует, что приложение может найти библиотеку во время динамической libmsquic
загрузки во время выполнения. Это можно сделать, добавив следующую команду перед основной командой:
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
Кроме того, можно задать переменную среды следующим образом:
export DYLD_FALLBACK_LIBRARY_PATH=$DYLD_FALLBACK_LIBRARY_PATH:$(brew --prefix)/lib
а затем выполните главную команду:
./binaryname
Обзор API
System.Net.Quic включает три основных класса, которые позволяют использовать протокол QUIC:
- QuicListener — серверный класс для приема входящих подключений.
- QuicConnection — QUIC-подключение, соответствующее RFC 9000 раздела 5.
- QuicStream — ПОТОК QUIC, соответствующий RFC 9000 раздел 2.
Но прежде чем использовать эти классы, код должен проверить, поддерживается ли QUIC в настоящее время, так как libmsquic
может быть отсутствует, или TLS 1.3 может не поддерживаться. Для этого и QuicListener
QuicConnection
предоставление статического свойства IsSupported
:
if (QuicListener.IsSupported)
{
// Use QuicListener
}
else
{
// Fallback/Error
}
if (QuicConnection.IsSupported)
{
// Use QuicConnection
}
else
{
// Fallback/Error
}
Эти свойства будут сообщать о том же значении, но это может измениться в будущем. Рекомендуется проверить IsSupported сценарии сервера и IsSupported клиентские сценарии.
QuicListener
QuicListener представляет серверный класс, который принимает входящие подключения от клиентов. Прослушиватель создается и запускается со статическим методом ListenAsync(QuicListenerOptions, CancellationToken). Метод принимает экземпляр QuicListenerOptions класса со всеми параметрами, необходимыми для запуска прослушивателя и приема входящих подключений. После этого прослушиватель готов раздавать подключения через AcceptConnectionAsync(CancellationToken). Подключения, возвращаемые этим методом, всегда полностью подключены, что означает, что подтверждение TLS завершено и подключение готово к использованию. Наконец, чтобы прекратить прослушивание и освободить все ресурсы, DisposeAsync() необходимо вызвать.
Рассмотрим следующий QuicListener
пример кода:
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();
Дополнительные сведения о том, как QuicListener
было разработано, см. в предложении API.
QuicConnection
QuicConnection — это класс, используемый как для серверных, так и клиентских подключений QUIC. Серверные подключения создаются внутренне прослушивателем и передаются через AcceptConnectionAsync(CancellationToken)него. Клиентские подключения должны быть открыты и подключены к серверу. Как и в случае с прослушивателем, существует статический метод ConnectAsync(QuicClientConnectionOptions, CancellationToken) , который создает экземпляры и подключает подключение. Он принимает экземпляр аналогичного QuicClientConnectionOptionsкласса QuicServerConnectionOptions. После этого работа с подключением не отличается от клиента и сервера. Он может открывать исходящие потоки и принимать входящие. Он также предоставляет свойства со сведениями о подключении, например LocalEndPoint, RemoteEndPointили RemoteCertificate.
После завершения работы с подключением его необходимо закрыть и удалить. Протокол QUIC требует использования кода уровня приложения для немедленного закрытия, см . раздел RFC 9000 10.2. Для этого CloseAsync(Int64, CancellationToken) с кодом уровня приложений можно вызвать или, если нет, DisposeAsync() используйте указанный в DefaultCloseErrorCodeней код. В любом случае DisposeAsync() необходимо вызвать в конце работы с подключением, чтобы полностью освободить все связанные ресурсы.
Рассмотрим следующий QuicConnection
пример кода:
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();
Дополнительные сведения о том, как QuicConnection
был разработан, см. в предложении API.
QuicStream
QuicStream — фактический тип, используемый для отправки и получения данных в протоколе QUIC. Он является производным от обычных Stream и может использоваться как таковой, но также предлагает несколько функций, относящихся к протоколу QUIC. Во-первых, поток QUIC может быть однонаправленным или двунаправленным, см . раздел RFC 9000 2.1. Двунаправленный поток может отправлять и получать данные на обеих сторонах, в то время как однонаправленный поток может записывать только из инициирующей стороны и читать их на приеме. Каждый одноранговый узел может ограничить количество одновременных потоков каждого типа, которые готовы принять, просмотреть MaxInboundBidirectionalStreams и MaxInboundUnidirectionalStreams.
Другая специфика потока QUIC — возможность явно закрыть сторону записи в середине работы с потоком, просмотреть CompleteWrites() или WriteAsync(ReadOnlyMemory<Byte>, Boolean, CancellationToken) перегрузить аргументом completeWrites
. Закрытие стороны записи позволяет однорангового узла знать, что больше данных не будет поступать, но одноранговый узел по-прежнему может продолжать отправлять (в случае двунаправленного потока). Это полезно в таких сценариях, как обмен HTTP-запросом и ответом, когда клиент отправляет запрос и закрывает запись стороны, чтобы сообщить серверу, что это конец содержимого запроса. Сервер по-прежнему может отправить ответ после этого, но знает, что больше данных не будет поступать от клиента. И для ошибочных случаев можно прервать запись или чтение стороны потока.Abort(QuicAbortDirection, Int64)
Примечание.
Открытие потока резервирует его только без отправки данных. Этот подход предназначен для оптимизации использования сети, избегая передачи почти пустых кадров. Так как одноранговый узел не уведомляется до отправки фактических данных, поток остается неактивным с точки зрения однорангового узла. Если вы не отправляете данные, одноранговый узел не распознает поток, что может привести AcceptInboundStreamAsync()
к зависаю по мере ожидания значимого потока. Чтобы обеспечить надлежащее взаимодействие, необходимо отправить данные после открытия потока.
Поведение отдельных методов для каждого типа потока приведено в следующей таблице (обратите внимание, что клиент и сервер могут открывать и принимать потоки):
Способ | Поток открытия однорангового узла | Одноранговый прием потока |
---|---|---|
CanRead |
двунаправленный: true unidirectional: false |
true |
CanWrite |
true |
двунаправленный: true unidirectional: false |
ReadAsync |
двунаправленный: считывает данные unidirectional: InvalidOperationException |
считывает данные |
WriteAsync |
отправляет данные => одноранговая чтение возвращает данные | двунаправленное: отправляет данные => одноранговое чтение возвращает данные unidirectional: InvalidOperationException |
CompleteWrites |
закрывает запись стороны => одноранговая чтение возвращает 0 | двунаправленное: закрывает сторону записи => одноранговое чтение возвращает 0 unidirectional: no-op |
Abort(QuicAbortDirection.Read) |
двунаправленный: STOP_SENDING => вызовы одноранговой записи QuicException(QuicError.OperationAborted) unidirectional: no-op |
STOP_SENDING => вызовы одноранговой записиQuicException(QuicError.OperationAborted) |
Abort(QuicAbortDirection.Write) |
RESET_STREAM => вызовы однорангового чтенияQuicException(QuicError.OperationAborted) |
двунаправленный: RESET_STREAM => вызовы однорангового чтения QuicException(QuicError.OperationAborted) unidirectional: no-op |
В верхней части этих методов QuicStream
предлагает два специализированных свойства для получения уведомлений при каждом чтении или записи стороны потока: ReadsClosed и WritesClosed. Оба возвращают значение Task
, которое завершается с соответствующей стороной, закрываемой стороной, будь то успешное или прерывание, в этом случае Task
будет содержать соответствующее исключение. Эти свойства полезны, если пользовательский код должен знать о закрытии стороны потока, не выдавая вызов или ReadAsync
WriteAsync
.
Наконец, когда выполняется работа с потоком, его необходимо удалить.DisposeAsync() Удаление гарантирует, что как чтение, так и запись стороны ( в зависимости от типа потока ) закрывается. Если поток не был правильно прочитан до конца, удаление выдает эквивалент Abort(QuicAbortDirection.Read)
. Тем не менее, если запись потока не была закрыта, она будет корректно закрыта, как это будет с CompleteWrites
. Причина этой разницы заключается в том, чтобы убедиться, что сценарии, работающие с обычным Stream
поведением, как ожидалось, и привести к успешному пути. Рассмотрим следующий пример:
// 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);
Пример использования в клиентском QuicStream
сценарии:
// 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.
И пример использования QuicStream
в сценарии сервера:
// 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.
Дополнительные сведения о том, как QuicStream
было разработано, см. в предложении API.