.NET 7 Покращення мережевих можливостей
Оскільки нещодавно вийшов .NET 7, хотілося б познайомити вас з деякими цікавими змінами та доповненнями, зробленими в мережевому просторі. У цій статті йтиметься про зміни .NET 7 у просторі HTTP, нові API-інтерфейси QUIC, мережеву безпеку та WebSockets.
HTTP
Удосконалена обробка невдалих спроб з’єднання
У версіях до .NET 6, якщо в пулі з’єднань не було доступного з’єднання, новий HTTP-запит завжди створював нову спробу з’єднання і чекав на неї (якщо це дозволяли налаштування обробника, наприклад, MaxConnectionsPerServer для HTTP/1.1 або EnableMultipleHttp2Connections для HTTP/2). Недоліком цього сценарію є те, що якщо встановлення цього з’єднання займе деякий час, а тим часом стане доступним інше з’єднання, цей запит продовжить очікувати на з’єднання, яке він створив, що призведе до збільшення затримки. У .NET 6.0 було замінено це на обробку запитів на тому з’єднанні, яке стане доступним першим, незалежно від того, чи це щойно створене з’єднання, чи те, яке стало готовим до обробки запиту в цей час. Нове з’єднання все одно створюється (з урахуванням обмежень), і якщо воно не було використане ініціатором запиту, воно об’єднується в пул, щоб наступні запити могли його використати.
На жаль, реалізація .NET 6.0 виявилася проблематичною для деяких користувачів: невдала спроба з’єднання також призводить до невдачі запиту на початку черги запитів, що може призвести до несподіваних відмов запитів у певних сценаріях. Крім того, якщо в пулі є відкладене з’єднання, яке ніколи не стане доступним, наприклад, через несправність сервера або проблеми з мережею, нові вхідні запити, пов’язані з ним, також гальмуватимуться і можуть завершитися в тайм-ауті.
У .NET 7.0 було впроваджено наступні зміни для усунення цих проблем:
1. Невдала спроба з’єднання може призвести до невдачі лише того запиту, який її ініціював, але ніколи не може призвести до невдачі запиту, який не пов’язаний з нею. Якщо початковий запит було оброблено до того моменту, коли з’єднання було розірвано, то помилка з’єднання ігнорується (dotnet/runtime#62935).
2. Якщо запит ініціює нове з’єднання, але потім обробляється іншим з’єднанням з пулу, нова спроба з’єднання буде автоматично завершена через короткий проміжок часу, незалежно від ConnectTimeout. Завдяки цій зміні, зупинені з’єднання не будуть зупиняти непов’язані запити (dotnet/runtime#71785). Зауважте, що невдачі таких “відкинутих” спроб з’єднання відбуватимуться у фоновому режимі й ніколи не стануть відомі користувачеві, єдиний спосіб їх побачити – увімкнути телеметрію.
HttpHeaders зчитують безпеку потоку
Колекції HttpHeaders ніколи не були безпечними для потоків. Доступ до заголовка може призвести до лінивого розбору його значення, що може спричинити модифікацію базових структур даних.
До .NET 6 одночасне читання з колекції у більшості випадків було безпечним для потоків.
Починаючи з .NET 6, розбір заголовків став менше блокуватися, оскільки в ньому зникла внутрішня потреба. Через цю зміну з’явилося багато прикладів помилкового одночасного доступу користувачів до заголовків, наприклад, у gRPC (dotnet/runtime#55898),, NewRelic (newrelic/newrelic-dotnet-agent#803) або навіть у самому HttpClient (dotnet/runtime#65379). Порушення безпеки потоків у .NET 6 може призвести до дублювання/спотворення значень заголовків або генерування різних винятків під час зчитування/доступу до заголовків.
.NET 7 робить поведінку заголовків більш інтуїтивно зрозумілою. Колекція HttpHeaders тепер відповідає гарантіям потокової безпеки Словника:
Колекція може підтримувати декілька читачів одночасно, якщо її не модифіковано. У рідкісних випадках, коли перерахування конкурує з доступом на запис, колекція повинна бути заблокована протягом усього перерахування. Щоб дозволити доступ до колекції декільком потокам для читання і запису, потрібно реалізувати власну синхронізацію.
Цього було досягнуто наступними змінами:
“Читання з перевіркою” недійсного значення не призводить до видалення недійсного значення - dotnet/runtime#67833
Одночасне читання є безпечним для потоків – dotnet/runtime#68115.
Виявлення помилок протоколів HTTP/2 і HTTP/3
Протоколи HTTP/2 і HTTP/3 визначають коди помилок на рівні протоколу у RFC 7540, розділ 7 і RFC 9114, розділ 8.1, наприклад, REFUSED_STREAM (0×7) в HTTP/2 або H3_EXCESSIVE_LOAD (0×0107) в HTTP/3. На відміну від кодів HTTP-статусу, це низькорівнева інформація про помилки, яка не є важливою для більшості користувачів HttpClient, але вона допомагає в розширених сценаріях HTTP/2 або HTTP/3, зокрема grpc-dotnet, де розпізнавання помилок протоколу є життєво важливим для реалізації повторних спроб клієнта.
Було створено нове виключення HttpProtocolException для зберігання коду помилки на рівні протоколу у його властивості ErrorCode.
При безпосередньому виклику HttpClient, HttpProtocolException може бути внутрішнім виключенням HttpRequestException:
При роботі з відповіддю потоку HttpContent він передається безпосередньо:
HTTP/3
Підтримка HTTP/3 у HttpClient вже була реалізована у попередній версії .NET, тому основні зусилля у цій сфері було зосереджено на базовому System.Net.Quic. Попри це, було внесено декілька виправлень та змін у .NET 7.
Найважливішою зміною є те, що HTTP/3 тепер увімкнено за замовчуванням (dotnet/runtime#73153). Це не означає, що відтепер всі HTTP-запити будуть віддавати перевагу HTTP/3, але в певних випадках вони можуть оновитися до нього. Щоб це сталося, запит повинен вибрати оновлення версії за допомогою HttpRequestMessage.VersionPolicy, встановленого в RequestVersionOrHigher. Потім, якщо сервер оголосить HTTP/3 повноваження в заголовку Alt-Svc, HttpClient буде використовувати його для подальших запитів, див. RFC 9114, розділ 3.1.1.
Ось декілька інших цікавих змін:
1. Телеметрія HTTP була розширена на HTTP/3 – dotnet/runtime#40896.
2. Покращено деталі винятків у випадку неможливості встановлення з’єднання QUIC – dotnet/runtime#70949.
3. Виправлено правильне використання заголовка Host для ідентифікації імені сервера (SNI) – dotnet/runtime#57169.
QUIC
QUIC – це новий протокол транспортного рівня. Нещодавно його було стандартизовано у RFC 9000. Він використовує UDP як базовий протокол і є безпечним за своєю суттю, оскільки вимагає використання TLS 1.3, див. RFC 9001. Ще одна цікава відмінність від відомих транспортних протоколів, таких як TCP і UDP, полягає в тому, що він має вбудоване потокове мультиплексування на транспортному рівні. Це дозволяє мати декілька паралельних, незалежних потоків даних, які не впливають один на одного.
Сам по собі QUIC не визначає ніякої семантики для даних, що обмінюються, оскільки це транспортний протокол. Він скоріше використовується в протоколах прикладного рівня, наприклад, в HTTP/3 або в SMB over QUIC. Він також може бути використаний для будь-якого користувацького протоколу.
Протокол має багато переваг над TCP з TLS. Наприклад, швидше встановлення з’єднання, оскільки він не вимагає стільки обходів, як TCP з TLS. Або уникнення проблеми блокування головної лінії, коли один втрачений пакет не блокує дані всіх інших потоків. З іншого боку, використання QUIC має свої недоліки. Оскільки це новий протокол, його впровадження все ще триває і є обмеженим. Крім того, трафік QUIC може навіть блокуватися деякими мережевими компонентами.
QUIC в .NET
Реалізацію QUIC в .NET 5 представлено в бібліотеці System.Net.Quic. Однак до цього часу бібліотека була суто внутрішньою і слугувала лише для власної реалізації HTTP/3. З виходом .NET 7 бібліотека стає загальнодоступною і доступні її API. Оскільки в цій версії API використовувалися лише HttpClient та Kestrel, було вирішено залишити їх у вигляді функції попереднього перегляду.
Це дає можливість доопрацювати API в наступному релізі, перш ніж він набуде остаточного вигляду.
З точки зору реалізації, System.Net.Quic залежить від MsQuic, нативної реалізації протоколу QUIC. В результаті, підтримка платформи System.Net.Quic і залежності успадковані від MsQuic і задокументовані в залежності від платформи HTTP/3. Коротко кажучи, бібліотека MsQuic постачається як частина .NET для Windows. Для Linux libmsquic необхідно встановити вручну за допомогою відповідного менеджера пакунків. Для інших платформ, як і раніше, можна зібрати MsQuic вручну, чи то для SChannel, чи то для OpenSSL, і використовувати її з System.Net.Quic.
Огляд API
System.Net.Quic містить три основні класи, які дозволяють використовувати протокол:
QuicListener – клас на стороні сервера для приймання вхідних з’єднань.
QuicConnection – QUIC з’єднання, що відповідає RFC 9000 Section 5.
QuicStream – потік QUIC, що відповідає RFC 9000 Section 2.
Але перед будь-яким використанням цих класів користувацький код повинен перевірити, чи підтримується QUIC, оскільки libmsquic може бути відсутнім, або TLS 1.3 може не підтримуватися. Для цього і QuicListener, і QuicConnection мають статичну властивість IsSupported:
Зауважте, що наразі обидві ці властивості синхронізовані й показуватимуть однакове значення, але це може змінитися у майбутньому. Тому рекомендується перевірити QuicListener.IsSupported для серверних сценаріїв і QuicConnection.IsSupported для клієнтських.
QuicListener
QuicListener являє собою клас на стороні сервера, який приймає вхідні з’єднання від клієнтів. Слухач створюється і запускається за допомогою статичного методу QuicListener.ListenAsync. Метод приймає екземпляр класу QuicListenerOptions з усіма налаштуваннями, необхідними для запуску слухача і приймання вхідних з’єднань. Після цього слухач готовий роздавати з’єднання через AcceptConnectionAsync. З’єднання, що повертаються цим методом, завжди повністю з’єднані, що означає, що рукостискання TLS завершено і з’єднання готове до використання. Нарешті, щоб припинити прослуховування і звільнити всі ресурси, необхідно викликати DisposeAsync.
Приклад використання QuicListener:
Більш детальну інформацію про те, як було розроблено цей клас, можна знайти у QuicListener API Proposal (dotnet/runtime#67560).
QuicConnection
QuicConnection – це клас, який використовується як для серверних, так і для клієнтських QUIC-з’єднань. З’єднання на стороні сервера створюються внутрішньо слухачем і роздаються через QuicListener.AcceptConnectionAsync. Клієнтські з’єднання повинні бути відкриті й підключені до сервера. Як і у випадку зі слухачем, існує статичний метод QuicConnection.ConnectAsync, який створює та встановлює з’єднання. Він приймає екземпляр класу QuicClientConnectionOptions, аналогічного класу QuicServerConnectionOptions. Після цього робота зі з’єднанням не відрізняється між клієнтом і сервером. Він може відкривати вихідні потоки й приймати вхідні. Він також надає властивості з інформацією про з’єднання, такі як LocalEndPoint, RemoteEndPoint або RemoteCertificate.
Після завершення роботи зі з’єднанням його потрібно закрити та скинути. Протокол QUIC вимагає використання коду програмного рівня для негайного закриття, див. RFC 9000, розділ 10.2. Для цього можна викликати CloseAsync з кодом програмного рівня або, якщо ні, DisposeAsync використає код, наданий у QuicConnectionOptions.DefaultCloseErrorCode. У будь-якому випадку, DisposeAsync має бути викликано наприкінці роботи зі з’єднанням, щоб повністю звільнити всі пов’язані з ним ресурси.
Приклад використання QuicConnection:
Більш детально про те, як був розроблений цей клас, можна прочитати в QuicConnection API Proposal (dotnet/runtime#68902).
QuicStream
QuicStream – це тип, який використовується для надсилання та отримання даних у протоколі QUIC. Він походить від звичайного потоку і може використовуватися як такий, але він також пропонує кілька особливостей, які є специфічними для протоколу QUIC. По-перше, потік QUIC може бути однонапрямний або двонапрямний, див. RFC 9000, розділ 2.1. Двонапрямний потік може надсилати та отримувати дані з обох сторін, тоді як однонапрямний потік може тільки писати зі сторони, що ініціює та читати зі сторони, що приймає. Кожен одноранговий комп’ютер може обмежити кількість одночасних потоків кожного типу, див. QuicConnectionOptions.MaxInboundBidirectionalStreams та QuicConnectionOptions.MaxInboundUnidirectionalStreams.
Ще однією особливістю потоку QUIC є можливість явно закрити сторону запису посеред роботи з потоком, див. перевантаження CompleteWrites або WriteAsync-system-boolean-system-threading-cancellationtoken)) з аргументом completeWrites. Закриття сторони запису дає змогу одноранговій системі знати, що дані більше не надходитимуть, проте вона може продовжувати надсилати (у випадку двонапрямного потоку). Це корисно в таких сценаріях, як обмін HTTP-запитами/відповідями, коли клієнт надсилає запит і закриває сторону запису, щоб повідомити серверу, що на цьому вміст запиту закінчився. Сервер все ще може відправити відповідь після цього, але знає, що більше ніяких даних від клієнта не надійде. У випадку помилкових ситуацій можна перервати потік як на стороні запису, так і на стороні читання, див. розділ Переривання. Поведінка окремих методів для кожного типу потоку підсумована у наступній таблиці (зауважте, що і клієнт, і сервер можуть відкривати й приймати потоки):
На додаток до цих методів, QuicStream пропонує дві спеціалізовані властивості для отримання сповіщень про закриття потоку для читання або запису: ReadsClosed та WritesClosed. Обидві властивості повертають завдання, яке завершується закриттям відповідної сторони потоку, незалежно від того, чи було воно успішне, чи перериване, в останньому випадку завдання буде містити відповідний виняток. Ці властивості корисні, коли користувацькому коду потрібно знати про закриття сторони потоку без виклику ReadAsync або WriteAsync.
Нарешті, коли робота з потоком завершена, його потрібно вилучити за допомогою DisposeAsync. Програма переконається, що сторона читання та/або запису – залежно від типу потоку – закрита. Якщо потік не було прочитано належним чином до кінця, програма видасть еквівалент Abort(QuicAbortDirection.Read). Однак, якщо потік не було закрито на стороні запису, він буде поступово закритий, як це було б у випадку з CompleteWrites. Причина такої різниці полягає в тому, щоб переконатися, що сценарії, які працюють зі звичайним потоком, поводяться очікувано і ведуть до успішного завершення. Розглянемо наступний приклад:
Приклад використання QuicStream у клієнтському сценарії:
І приклад використання QuicStream у серверному сценарії:
Більш детально про те, як був розроблений цей клас, можна прочитати в QuicStream API Proposal (dotnet/runtime#69675).
Безпека
Узгодження API
Автентифікація Windows – це загальний термін для позначення різних технологій, що використовуються на підприємствах для автентифікації користувачів і програм за допомогою центрального органу, зазвичай контролера домену. Вона уможливлює такі сценарії, як єдиний вхід до служб електронної пошти або програм інтрамережі. Основними технологіями, що використовуються для автентифікації, є Kerberos, NTLM і протокол Negotiate, де для конкретного сценарію автентифікації вибирається найбільш відповідна технологія.
До .NET 7 автентифікація Windows була доступна у високорівневих API, таких як HttpClient (схеми автентифікації Negotiate і NTLM), SmtpClient (схеми автентифікації GSSAPI й NTLM), NegotiateStream, ASP.NET Core і у клієнтських бібліотеках SQL Server. Хоча вона охоплює більшість сценаріїв для кінцевих користувачів, проте обмежує можливості авторів бібліотек. Інші бібліотеки, такі як клієнт Npgsql PostgreSQL, MailKit, клієнт Apache Kudu та інші, повинні були вдаватися до різних хитрощів, щоб реалізувати ті ж схеми автентифікації для низькорівневих протоколів, які не були побудовані на HTTP або інших доступних високорівневих будівельних блоках.