Giống như mỗi bản phát hành trước, chúng tôi lại có một bài blog về những thay đổi và bổ sung thú vị trong không gian mạng của .NET. Lần này, chúng tôi sẽ viết về các cải tiến HTTP, API WebSocket mới, thay đổi về bảo mật và nhiều bổ sung khác trong các nguyên thủy mạng.
Mục lục
Cải Tiến HTTP
Phần sau đây giới thiệu các cải tiến và bổ sung trong không gian HTTP. Trong số đó có tối ưu hiệu năng trong WinHttpHandler, một động từ HTTP mới và một bổ sung nhỏ về cookie.
WinHttpHandler
Một trong những cải tiến hiệu năng trong .NET 10 là tối ưu hóa việc xác thực chứng chỉ máy chủ trong WinHttpHandler. Thông thường, việc xác thực được để cho triển khai WinHTTP gốc xử lý. Nhưng đôi khi, mã người dùng cần kiểm soát nhiều hơn quy trình này và WinHttpHandler cung cấp ServerCertificateValidationCallback cho chính xác mục đích đó. Khi mã người dùng đăng ký callback, WinHttpHandler sẽ bỏ qua xác thực WinHTTP nội bộ. Thật không may, không có sự kiện chính xác nào từ WinHTTP gốc tương ứng với việc thiết lập kết nối và cũng cung cấp chứng chỉ máy chủ để xác thực. Kết quả là, lớp quản lý trong WinHttpHandler buộc phải gọi ServerCertificateValidationCallback tùy chỉnh trên mỗi yêu cầu. Để tránh điều này, một bộ nhớ đệm các chứng chỉ đã được xác thực theo địa chỉ IP máy chủ đã được đưa vào. Mỗi khi một yêu cầu mới được gửi, WinHttpHandler sẽ bỏ qua việc xây dựng toàn bộ chuỗi chứng chỉ và gọi callback tùy chỉnh nếu chứng chỉ đã được xác thực trước đó. Ngoài ra, mỗi kết nối mới sẽ xóa bộ nhớ đệm chứng chỉ cho IP máy chủ cụ thể đó để xác thực lại khi kết nối được tạo lại. Để đảm bảo an toàn từ góc độ bảo mật, tính năng này (dotnet/runtime#111791) là tùy chọn và được ẩn sau một công tắc AppContext:
AppContext.SetSwitch("System.Net.Http.UseWinHttpCertificateCaching", true);
Ví dụ sau minh họa tác động của công tắc này:
using System.Net.Security;
AppContext.SetSwitch("System.Net.Http.UseWinHttpCertificateCaching", true);
using var client = new HttpClient(new WinHttpHandler()
{
ServerCertificateValidationCallback = static (req, cert, chain, errors) =>
{
Console.WriteLine("Server certificate validation invoked");
return errors == SslPolicyErrors.None;
}
});
Console.WriteLine((await client.GetAsync("https://github.com")).StatusCode);
Console.WriteLine((await client.GetAsync("https://github.com")).StatusCode);
Console.WriteLine((await client.GetAsync("https://github.com")).StatusCode);
Callback chỉ được gọi một lần, với đầu ra như sau:
Server certificate validation invoked
OK
OK
OK
Trong khi đó nếu không có công tắc, callback được gọi với mỗi yêu cầu:
Server certificate validation invoked
OK
Server certificate validation invoked
OK
Server certificate validation invoked
OK
QUERY
Một bổ sung khác là động từ HTTP mới QUERY. Mục đích của động từ này là cho phép gửi chi tiết truy vấn trong phần thân yêu cầu trong khi vẫn sử dụng một yêu cầu an toàn, không gây ảnh hưởng (idempotent). Nói cách khác, có thể thực thi nhiều lần mà không ảnh hưởng đến dữ liệu máy chủ. Một trong những trường hợp sử dụng là khi chi tiết truy vấn có thể không vừa với giới hạn độ dài URI và việc sử dụng body trong yêu cầu GET không được máy chủ hỗ trợ. Động từ này vẫn đang trong quá trình chuẩn hóa trong đề xuất The HTTP QUERY Method. Vì lý do đó, chúng tôi đã quyết định trì hoãn việc triển khai đầy đủ các phương thức helper trong dotnet/runtime#113522 cho đến khi RFC được công bố. Và chúng tôi chỉ thêm hằng số chuỗi trong dotnet/runtime#114489, có thể sử dụng như sau:
using var client = new HttpClient();
var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Query, "https://api.example.com/resource"));
Cookie
Một thay đổi rất nhỏ đã được yêu cầu: làm cho constructor của CookieException trở thành public trong dotnet/runtime#95965. Thay đổi này được thực hiện bởi một cộng tác viên cộng đồng @deeprobin trong dotnet/runtime#109026. CookieException giờ đây có thể được ném thủ công để tránh những chuyến thăm bất ngờ từ Cookie Monster:
throw new CookieException("");
WebSockets
Làm việc với API WebSocket thô có nghĩa là phải xử lý các chi tiết cấp thấp — đệm, đóng khung, mã hóa và các wrapper tùy chỉnh để tích hợp với stream hoặc channel. Sự phức tạp này khiến việc xây dựng các tầng truyền tải và giao thức streaming trở nên khó khăn hơn.
.NET 10 giới thiệu WebSocketStream — một abstraction dạng stream trên WebSockets giúp việc đọc, ghi và phân tích dữ liệu cho các giao thức văn bản và nhị phân trở nên đơn giản hơn nhiều.
Lợi ích chính:
- Thiết kế stream-first: Hoạt động liền mạch với
StreamReader,JsonSerializer, bộ nén, bộ tuần tự hóa, v.v. - Không cần xử lý thủ công: Loại bỏ việc đóng khung tin nhắn và xử lý phần còn lại.
- Hỗ trợ nhiều kịch bản: Giao thức dựa trên JSON, giao thức dựa trên văn bản như STOMP, và giao thức nhị phân như AMQP.
Các mẫu sử dụng phổ biến
-
Đọc một tin nhắn JSON hoàn chỉnh (văn bản)
Sử dụngCreateReadableMessageStreamvớiJsonSerializer.DeserializeAsync<T>(). Bạn không cần vòng lặp nhận thủ công hay bộ đệmMemoryStream. Stream kết thúc chính xác tại ranh giới tin nhắn, vì vậyDeserializeAsynchoàn thành một cách tự nhiên khi tin nhắn được đọc hết. -
Stream một giao thức văn bản (ví dụ: STOMP, hướng dòng)
Sử dụngCreateđể lấy một transport stream, và xếp lớpStreamReaderlên trên. Bạn có thể phân tích từng dòng trong khi stream vẫn mở qua các frame. Mẫu này tận dụng xử lý UTF‑8 tự động và giữ cho các thao tác đọc có thể kết hợp với các trình phân tích văn bản tiêu chuẩn. -
Ghi một tin nhắn nhị phân đơn lẻ (ví dụ: tải trọng AMQP)
Sử dụngCreateWritableMessageStreamvà ghi từng đoạn;Disposesẽ gửi end-of-message (EOM) cho bạn. Ngữ nghĩa ghi một tin nhắn tránh việc xử lýEndOfMessagethủ công và làm cho đường gửi của bạn trông giống như bất kỳ stream ghi nào khác.
Nói cách khác:
- Ưu tiên
CreateReadableMessageStream/CreateWritableMessageStreamcho các luồng công việc hướng tin nhắn. - Đối với các giao thức liên tục, sử dụng
Createvới ngữ nghĩaleaveOpen/ownsWebSocketphù hợp trong các reader/writer phân lớp. - Dispose stream để hoàn thành EOM và đóng kết nối một cách nhẹ nhàng. Sử dụng
closeTimeoutđể giới hạn thời gian Dispose chờ đợi một bắt tay đóng kết nối.
Trước vs Sau (ví dụ JSON)
Xem đoạn mã dưới đây để so sánh giữa việc sử dụng vòng lặp WebSocket thô và sử dụng WebSocketStream để đọc một tin nhắn JSON hoàn chỉnh.
// TRƯỚC: đệm thủ công
static async Task<AppMessage?> ReceiveJsonManualAsync(WebSocket ws, CancellationToken ct)
{
var buffer = new byte[8192];
using var mem = new MemoryStream();
while (ws.State == WebSocketState.Open)
{
var result = await ws.ReceiveAsync(buffer, ct);
if (result.MessageType == WebSocketMessageType.Close)
{
return null;
}
await mem.WriteAsync(buffer.AsMemory(0, result.Count), ct);
if (result.EndOfMessage)
{
break;
}
}
mem.Position = 0;
return await JsonSerializer.DeserializeAsync<AppMessage>(mem, cancellationToken: ct);
}
// SAU: message stream
static async Task<AppMessage?> ReceiveJsonAsync(WebSocket ws, CancellationToken ct)
{
using Stream message = WebSocketStream.CreateReadableMessageStream(ws);
return await JsonSerializer.DeserializeAsync<AppMessage>(message, cancellationToken: ct);
}
Phiên bản “sau” loại bỏ việc đệm, sao chép và kiểm tra EndOfMessage thông qua abstraction Stream.
Bảo mật
Trong bản phát hành này, có hai thay đổi trong không gian bảo mật mạng đáng được đề cập. Một trong số đó là yêu cầu đã có từ lâu về hỗ trợ TLS 1.3 trên OSX. Và thứ hai là thống nhất cách chúng tôi hiển thị chi tiết bộ mã hóa TLS.
Hỗ trợ TLS 1.3 phía client trên OSX
Hỗ trợ TLS 1.3 trên OSX là một vấn đề được yêu cầu nhiều (dotnet/runtime#1979) qua nhiều bản phát hành. Đây là một nỗ lực không hề đơn giản vì nó yêu cầu chuyển sang một bộ API OSX gốc khác. Ngoài ra, các API mới kết hợp TLS với TCP trong khi .NET hiển thị hai lớp này độc lập với nhau dưới dạng SslStream và Socket. Hơn nữa, các API mới chỉ hỗ trợ TLS 1.2 và TLS 1.3 và, mặc dù các SslProtocols khác đã bị deprecated, .NET vẫn hỗ trợ chúng. Tất cả điều này dẫn đến quyết định chỉ hiển thị chức năng này như một tính năng tùy chọn đằng sau công tắc AppContext (dotnet/runtime#117428). Ứng dụng client có thể tận dụng hỗ trợ mới này bằng cách đặt công tắc trong mã của họ:
AppContext.SetSwitch("System.Net.Security.UseNetworkFramework", true);
hoặc với biến môi trường:
export DOTNET_SYSTEM_NET_SECURITY_USENETWORKFRAMEWORK=1
Lưu ý rằng điều này sẽ chuyển đổi backend mà SslStream sử dụng cho các thao tác client trên OSX, vì vậy chỉ TLS 1.3 và TLS 1.2 sẽ được hỗ trợ. Nhưng nó không ảnh hưởng đến việc sử dụng SslStream ở phía máy chủ.
Negotiated Cipher Suite
SslStream cung cấp một số thuộc tính với chi tiết về bộ mã hóa đã thương lượng. Trong số đó có KeyExchangeAlgorithm, HashAlgorithm và CipherAlgorithm. Tuy nhiên, các enum cơ bản cho các thuộc tính này đã không được cập nhật trong một thời gian và do đó không cung cấp thông tin chính xác. Ví dụ, ánh xạ nhiều thuật toán từ cùng một họ vào một giá trị enum duy nhất, làm mất thông tin quan trọng trong quá trình này. Thay vì mở rộng các enum, chúng tôi quyết định deprecated chúng và chỉ để lại NegotiatedCipherSuite như là nguồn thông tin duy nhất (dotnet/runtime#100361). Enum cơ bản TlsCipherSuite tuân theo đặc tả IANA cho TLS Cipher Suites và bao gồm tất cả thông tin cần thiết. Các thuộc tính bị deprecated chỉ được suy ra từ NegotiatedCipherSuite và không truyền tải thêm thông tin nào. Toàn bộ thay đổi có thể được xem xét trong dotnet/runtime#105875.
Ngoài ra, chúng tôi cũng đã giới thiệu thuộc tính NegotiatedCipherSuite tương tự cho QuicConnection trong dotnet/runtime#106391. Đây cũng là nguồn thông tin duy nhất cho các chi tiết TLS đã thương lượng cho kết nối đã được thiết lập. Thảo luận về việc bổ sung API có thể được tìm thấy trong dotnet/runtime#70184.
Các Nguyên Thủy Mạng
Phần này giới thiệu các thay đổi trong không gian tên System.Net bao gồm một số bổ sung cho các helper server-sent events, địa chỉ IP, URI và các loại tương tự.
Server-Sent Events Formatter
Trong bản phát hành trước của .NET, chúng tôi đã thêm hỗ trợ cho việc phân tích cú pháp server-sent events, đã được đề cập trong bài blog năm ngoái. Bản phát hành này giới thiệu mặt đối diện — formatter cho server-sent events (dotnet/runtime#109294). Trong kịch bản đơn giản nhất, với dữ liệu kiểu string, API mới có thể được sử dụng như trong ví dụ sau:
using var stream = new MemoryStream();
// Chỉ cần truyền vào nguồn các item và stream để tuần tự hóa.
await SseFormatter.WriteAsync(GetStringItems(), stream);
static async IAsyncEnumerable<SseItem<string>> GetStringItems()
{
yield return new SseItem<string>("data 1");
yield return new SseItem<string>("data 2");
yield return new SseItem<string>("data 3");
yield return new SseItem<string>("data 4");
}
Nội dung của stream cho mã này sẽ như sau:
data: data 1
data: data 2
data: data 3
data: data 4
Và ví dụ sau minh họa trường hợp khi tải trọng dữ liệu không phải là string đơn giản:
var stream = new MemoryStream();
// Đối số thứ ba là một delegate nhận SseItem và IBufferWriter để tuần tự hóa item.
await SseFormatter.WriteAsync<int>(GetItems(), stream, (item, writer) =>
{
// Dữ liệu của item nên được chuyển đổi thành chuỗi và chuỗi đó được chuyển đổi thành byte UTF-8 tương ứng.
writer.Write(Encoding.UTF8.GetBytes(item.Data.ToString()));
});
static async IAsyncEnumerable<SseItem<int>> GetItems()
{
yield return new SseItem<int>(1) { ReconnectionInterval = TimeSpan.FromSeconds(1) };
yield return new SseItem<int>(2);
yield return new SseItem<int>(3);
yield return new SseItem<int>(4);
}
Cho ví dụ này, nội dung của stream sẽ như sau:
data: 1
retry: 1000
data: 2
data: 3
data: 4
Như các ví dụ cho thấy, bổ sung này (dotnet/runtime#109832) giới thiệu hai overload để ghi các sự kiện:
- Overload đơn giản cho dữ liệu kiểu
string, không cần delegate định dạng - Overload tổng quát cho bất kỳ dữ liệu nào, với delegate định dạng
Ngoài ra, SseItem<T> đã được mở rộng với hai thuộc tính mới để ghi:
EventId: để gửi trườngidReconnectionInterval: để gửi trườngretry
Cả hai trường này kiểm soát hành vi của client khi kết nối cần được thiết lập lại. EventId được phân tích cuối cùng sẽ ánh xạ tới LastEventId ở phía parser và cuối cùng sẽ được sử dụng trong header Last-Event-ID nếu kết nối được tạo lại. Và ReconnectionInterval sẽ ánh xạ tới ReconnectionInterval và sẽ kiểm soát thời gian trước khi một kết nối mới được thử.
Với tất cả điều này, System.Net.ServerSentEvents cung cấp một bộ helper hoàn chỉnh cho cả hai phía giao tiếp và có thể được sử dụng để truyền dữ liệu qua lại:
var stream = new MemoryStream();
await SseFormatter.WriteAsync<int>(GetItems(), stream, (item, writer) =>
{
writer.Write(Encoding.UTF8.GetBytes(item.Data.ToString()));
});
stream.Seek(0, SeekOrigin.Begin);
var parser = SseParser.Create(stream, (type, data) =>
{
var str = Encoding.UTF8.GetString(data);
return Int32.Parse(str);
});
await foreach (var item in parser.EnumerateAsync())
{
Console.WriteLine($"{item.EventType}: {item.Data} {item.EventId} {item.ReconnectionInterval} [{parser.LastEventId};{parser.ReconnectionInterval}]");
}
static async IAsyncEnumerable<SseItem<int>> GetItems()
{
yield return new SseItem<int>(1) { ReconnectionInterval = TimeSpan.FromSeconds(1) };
yield return new SseItem<int>(2) { EventId = "2" };
yield return new SseItem<int>(3);
yield return new SseItem<int>(4);
}
Địa chỉ IP
Có hai bổ sung mới cho lớp IPAddress. Đầu tiên là một phương thức tĩnh để xác thực xem một chuỗi có phải là địa chỉ IP hợp lệ hay không (dotnet/runtime#111282). Nó có thể được sử dụng như:
if (IPAddress.IsValid("10.0.0.1"))
{ ... }
if (IPAddress.IsValid("::1"))
{ ... }
if (IPAddress.IsValid("10.0.1"))
{ ... }
if (IPAddress.IsValidUtf8("::192.168.0.1"u8))
{ ... }
if (IPAddress.IsValidUtf8("fe80::9656:d028:8652:66b6"u8))
{ ... }
Thay đổi khác nối tiếp một thay đổi đã thêm IUtf8SpanFormattable trong .NET 8. Cả IPAddress và IPNetwork hiện cũng triển khai IUtf8SpanParsable<T>. Đề xuất API (dotnet/runtime#103111) cũng như triển khai (dotnet/runtime#102144) được thực hiện bởi cộng tác viên cộng đồng @edwardneal.
Linh tinh
Những thay đổi cuối cùng trong không gian tên System.Net đáng đề cập là việc loại bỏ giới hạn độ dài trên Uri trong dotnet/runtime#117287. Lý do chính cho thay đổi này là để hỗ trợ lược đồ URI data (dotnet/runtime#96544) như được đặc tả trong RFC 2397. Thay vì liên kết đến một tài nguyên, URI data mang dữ liệu cho tài nguyên trong chính nó. Ví dụ như hình ảnh được mã hóa base64:
data:image/jpeg;base64,[dữ liệu hình ảnh được mã hóa base64]
Và với giới hạn ban đầu chỉ dưới 64 KB, điều này không đủ cho nhiều URI dữ liệu như vậy.
Thay đổi nhỏ cuối cùng là thêm một loại media mới cho các tệp yml (dotnet/runtime#105809). Thay đổi này thêm một hằng số mới MediaTypesName.Yaml trong dotnet/runtime#117211 và được thực hiện bởi cộng tác viên cộng đồng @martincostello.
Lời Kết
Đã trở thành truyền thống khi xuất bản bài viết này hàng năm và như những năm trước, bài viết không thể bao quát tất cả các thay đổi đã được thực hiện. Chúng tôi chọn những thứ có thể có tác động trực tiếp đến khách hàng của mình, dù đó là API mới, tính năng mới hay cải tiến hiệu năng. Và tất nhiên, nhiều cải tiến hiệu năng như vậy đã được đề cập trong bài viết tuyệt vời của Stephen về Cải Tiến Hiệu Năng Trong .NET 10. Chúng tôi cũng muốn khuyến khích bạn liên hệ với chúng tôi tại GitHub repo nếu bạn có bất kỳ câu hỏi nào, yêu cầu tính năng mới hoặc phát hiện ra bất kỳ lỗi nào. Và cuối cùng, tôi muốn cảm ơn đồng tác giả của tôi @CarnaViire đã viết phần WebSockets.



