【譯】.NET 6 網路改進

MingsonZheng發表於2022-03-16

原文 | Máňa Píchová

翻譯 | 鄭子銘

對於 .NET 的每個新版本,我們都希望釋出一篇部落格文章,重點介紹網路的一些變化和改進。在這篇文章中,我很高興談論 .NET 6 中的變化。

這篇文章的上一個版本是 .NET 5 網路改進

HTTP

HTTP/2 視窗縮放

隨著 HTTP/2 和 gRPC 的興起,我們的客戶發現 SocketsHttpHandler 的 HTTP/2 下載速度在連線到具有顯著網路延遲的地理位置較遠的伺服器時無法與其他實現相提並論。在具有高頻寬延遲產品的鏈路上,與其他能夠利用鏈路物理頻寬的實現相比,一些使用者報告了 5 到 10 倍的差異。舉個例子:在我們的一個基準測試中,curl 能夠達到特定跨大西洋鏈路的最大 10 Mbit/s 速率,而 SocketsHttpHandler 的速度最高為 2.5 Mbit/s。除其他外,這嚴重影響了 gRPC 流式處理方案。

問題的根本原因是固定大小的 HTTP/2 接收視窗,當以高延遲接收 WINDOW_UPDATE 幀時,它的 64KB 大小太小而無法保持網路繁忙,這意味著 HTTP/2 自己的流量控制機制正在停止網路連結。

我們考慮了“廉價”選項來解決這個問題,例如定義一個固定大小的大視窗——這可能會導致不必要的高記憶體佔用——或者要求使用者根據經驗觀察手動配置接收視窗。這些似乎都不令人滿意,因此我們決定實現一種類似於 TCP 或 QUIC 中的自動視窗大小調整演算法 (dotnet/runtime#54755)。

結果證明效果很好,將下載速度提升到接近其理論最大值。但是,由於 HTTP/2 PING 幀用於確定 HTTP/2 連線的往返時間,因此我們必須非常小心,以免觸發伺服器的 PING 泛洪保護機制。我們實現了一個演算法,該演算法應該可以很好地與 gRPC 和現有的 HTTP 伺服器一起工作,但我們想確保我們有一個逃生路徑,以防出現問題。可以通過將 System.Net.SocketsHttpHandler.Http2FlowControl.DisableDynamicWindowSizing AppContext 開關設定為 true 來關閉動態視窗大小以及相應的 PING 幀。如果這變得有必要,仍然可以通過為 SocketsHttpHandler.InitialHttp2StreamWindowSize 分配更高的值來解決吞吐量問題。

HTTP/3 和 QUIC

在 .NET 5 中,我們釋出了 QUIC 和 HTTP/3 的實驗性實現。它僅限於 Windows 的 Insider 版本,並且有相當多的儀式讓它工作。

在 .NET 6 中,我們大大簡化了設定。

  • 在 Windows 上,我們將 MsQuic 庫作為執行時的一部分提供,因此無需下載或引用任何外部內容。唯一的限制是需要 Windows 11 或 Windows Server 2022。這是因為 TLS 1.3 對 SChannel 中的 QUIC 的支援在早期的 Windows 版本中不可用。
  • 在 Linux 上,我們將 MsQuic 作為標準 Linux 包 libmsquic(deb 和 rpm)釋出在 Microsoft Package Repository 中。在 Linux 上不將 MsQuic 與 runtime 捆綁在一起的原因是,我們將 libmsquic 與 QuicTLS 一起釋出,QuicTLS 是 OpenSSL 的一個分支,提供了必要的 TLS API。由於我們將 QuicTLS 與 MsQuic 捆綁在一起,我們需要能夠在正常的 .NET 釋出計劃之外進行安全補丁。

我們還大大提高了穩定性並實現了許多缺失的功能,在 .NET 6 里程碑中解決了大約 90 個問題

HTTP/3 使用 QUIC 而不是 TCP 作為其傳輸層。我們的 QUIC 協議的 .NET 實現是在 System.Net.Quic 庫中的 MsQuic 之上構建的託管層。 QUIC 是一種通用協議,可用於多種場景,不僅僅是 HTTP/3,而且是新的,最近才在 RFC 9000 中獲得批准。我們沒有足夠的信心認為當前的 API 形式能夠經受住時間,並且適合其他協議使用,因此我們決定在此版本中將其保密。因此,.NET 6 包含 QUIC 協議實現,但沒有公開它。它僅在內部用於 HttpClient 和 Kestrel 伺服器中的 HTTP/3。

儘管在此版本中為消除錯誤付出了很多努力,但我們仍然認為 HTTP/3 的質量還沒有完全為生產做好準備。由於任何 HTTP 請求都可能通過 Alt-Svc 標頭無意中升級到 HTTP/3 並開始失敗,因此我們選擇在此版本中預設禁用 HTTP/3 功能。在 HttpClient 中,它隱藏在 System.Net.SocketsHttpHandler.Http3Support AppContext 開關後面。

我們之前的文章中已經描述瞭如何設定所有內容的所有細節:HttpClientKestrel。在 Linux 上,獲取 libmsquic 包,在 Windows 上,確保作業系統版本至少為 10.0.20145.1000。然後,您只需要啟用 HTTP/3 支援並將 HttpClient 設定為使用 HTTP/3:

using System.Net;

// Set this switch programmatically or in csproj:
// <RuntimeHostConfigurationOption Include="System.Net.SocketsHttpHandler.Http3Support" Value="true" />
AppContext.SetSwitch("System.Net.SocketsHttpHandler.Http3Support", true);

// Set up the client to request HTTP/3.
var client = new HttpClient()
{
    DefaultRequestVersion = HttpVersion.Version30,
    DefaultVersionPolicy = HttpVersionPolicy.RequestVersionOrHigher,
};
var resp = await client.GetAsync("https://<http3 endpoint>");

// Print the response version.
Console.WriteLine($"status: {resp.StatusCode}, version: {resp.Version}");

我們鼓勵您嘗試 HTTP/3!如果您遇到任何問題,請在 dotnet/runtime 中提出問題。

HTTP 重試邏輯

.NET 6 將 HTTP 請求重試邏輯更改為基於固定重試計數限制(請參閱 dotnet/runtime#48758)。

以前,.NET 5 不允許在“新”連線(未用於先前請求的連線)上發生連線失敗時請求重試。我們這樣做主要是為了確保重試邏輯不會陷入無限迴圈。這對於 HTTP/2 連線來說不是最理想的並且特別有問題(請參閱 dotnet/runtime#44669)。另一方面,.NET 5 對在許多情況下允許重試過於寬鬆,這並不完全符合 RFC 2616。例如,我們正在重試任意異常,例如在 IO 超時時,即使使用者明確設定了此超時,並且可能希望在超過超時時使請求失敗(而不是重試)。

無論請求是否是連線上的第一個請求,.NET 6 重試邏輯都將起作用。它引入了當前設定為 5 的重試限制。將來,如果需要,我們可能會考慮對其進行調整或使其可配置。

為了更好地遵守 RFC,請求現在只有在我們認為伺服器正試圖優雅地斷開連線時才可重試——也就是說,當我們在 HTTP/1.1 的任何其他響應資料之前收到 EOF 或收到 HTTP/2 的 GOAWAY。

.NET 6 更保守的重試行為的缺點是,以前被寬鬆重試策略掩蓋的失敗將開始對使用者可見。例如,如果伺服器以非優雅的方式(通過傳送 TCP RST 資料包)斷開空閒連線,則由於 RST 失敗的請求將不會自動重試。這在關於遷移到 .NET 6 的 AAD 文章中簡要提及。解決方法是將客戶端的空閒超時 (SocketsHttpHandler.PooledConnectionIdleTimeout) 設定為伺服器空閒超時的 50-75%(如果已知)。這樣一來,請求永遠不會在伺服器以空閒狀態關閉連線的競爭中被捕獲——HttpClient 會更快地清除它。另一種方法是在 HttpClient 之外實現自定義重試策略。這也將允許調整重試策略和啟發式方法,例如,如果可以根據特定伺服器的邏輯和實現重試一些通常非冪等的請求。

SOCKS 代理支援

SOCKS 代理支援是一個長期存在的問題 (dotnet/runtime#17740),最終由社群貢獻者 @huoyaoyuan 實現。我們已經在 .NET 6 Preview 5 部落格文章中介紹了這一新增功能。該更改增加了對 SOCKS4、SOCKS4a 和 SOCKS5 代理的支援。

SOCKS 代理是一個非常通用的工具。例如,它可以提供與 VPN 類似的功能。最值得注意的是 SOCKS 代理用於訪問 Tor 網路。

配置HttpClient使用SOCKS代理,只需要在定義proxy1時使用socks方案即可:

var client = new HttpClient(new SocketsHttpHandler()
{
    // Specify the whole Uri (schema, host and port) as one string or use Uri directly.
    Proxy = new WebProxy("socks5://127.0.0.1:9050")
});

var content = await client.GetStringAsync("https://check.torproject.org/");
Console.WriteLine(content);

此示例假設您正在計算機上執行 tor 例項。如果請求成功,您應該能夠找到“恭喜。此瀏覽器配置為使用 Tor。”在響應內容中。

1.在原博文中,我們犯了一個錯誤,使用了錯誤的WebProxy 建構函式過載。它只需要第一個引數中的主機名,並且不能與 HTTP 以外的任何其他代理型別一起使用。我們還為 .NET 7 (dotnet/runtime#62338) 修復了這種特殊的建構函式行為不一致問題。

WinHTTP

WinHttpHandler 是 WinHTTP 的包裝器,因此功能集取決於 WinHTTP 中的功能。在此版本中,有一些新增功能可以公開或啟用 HTTP/2 的 WinHttp 功能。它們是使使用者能夠在 .NET Framework 上使用 gRPC .NET 的更大努力 (dotnet/core#5713) 的一部分。目標是實現從 WCF 到 .NET Framework 上的 gRPC 以及再到 .NET Core / .NET 5+ 上的 gRPC 的更平滑過渡。

  • 尾隨標頭 (dotnet/runtime#44778)。
    • 對於 .NET Core 3.1 / .NET 5 及更高版本,尾隨標頭在 HttpResponseMessage.TrailingHeaders 中公開。
    • 對於 .NET Framework,它們在 HttpRequestMessage.Properties["__ResponseTrailers"] 中公開,因為 .NET Framework 上沒有 TrailingHeaders 這樣的屬性。
  • 雙向流 (dotnet/runtime#44784)。此更改是完全無縫的,WinHttpHandler 將在適當時自動允許雙向流式傳輸,即當請求內容沒有已知長度並且底層 WinHTTP 支援它時。
  • TCP 保持活動配置。 TCP keep-alive 用於保持空閒連線開啟,並防止中間節點(如代理和防火牆)比客戶端預期的更快斷開連線。在 .NET 6 中,我們為 WinHttpHandler 新增了 3 個新屬性來配置它:
public class WinHttpHandler
{
// Controls whether TCP keep-alive is getting send or not.
public bool TcpKeepAliveEnabled { get; set; }
// Delay to the first keep-alive packet during inactivity.
public TimeSpan TcpKeepAliveTime { get; set; }
// Interval for subsequent keep-alive packets during inactivity.
public TimeSpan TcpKeepAliveInterval { get; set; }
}

這些屬性對應於 WinHTTP tcp_keepalive 結構。

將 TLS 1.3 與 WinHttpHandler 一起使用 (dotnet/runtime#58590)。此功能對使用者是透明的,唯一需要的是 Windows 支援。

其他 HTTP 更改

.NET 6 中的許多 HTTP 更改已經在 Stephen Toub 關於效能的大量文章中進行了討論,但其中很少有值得重複的。

  • 在 SocketsHttpHandler (runtime/dotnet#44818) 中重構了連線池。新方法允許我們始終處理首先可用的連線上的請求,無論是新建立的連線還是同時準備好處理請求的連線。之前,在請求到來時所有連線都忙的情況下,我們將開始開啟一個新連線並讓請求等待它。此更改適用於 HTTP/1.1 以及啟用了 EnableMultipleHttp2Connections 的 HTTP/2。
  • 新增了未經驗證的 HTTP 標頭列舉 (runtime/dotnet#35126)。更改將新的 API HttpHeaders.NonValidated
  • 新增到標頭集合中。它允許在收到標頭時檢查標頭(無需進行清理),它還跳過所有解析和驗證邏輯,不僅節省了 CPU 週期,還節省了分配。
  • 優化 HPack Huffman 解碼 (dotnet/runtime#43603)。 HPack 是 HTTP/2 RFC 7541 的標頭(解)壓縮格式。從我們的微基準測試來看,這種優化將解碼所需的時間減少到原始解碼時間的 0.35 左右(dotnet/runtime#1506)。
  • 引入 ZLibStream。最初,我們沒想到 zlib 信封在 deflate 壓縮內容資料 (dotnet/runtime#38022) 中,RFC 2616 將其定義為帶 deflate 壓縮的 zlib 格式。一旦我們解決了這個問題,就會出現另一個問題,因為並非所有伺服器都將 zlib 信封放置到位。所以我們引入了一種機制來檢測格式並使用適當型別的流(dotnet/runtime#57862)。
  • 新增了 cookie 列舉。在 .NET 6 之前,無法列舉 CookieContainer 中的所有 cookie。您需要知道他們的域名才能獲得它們。此外,沒有辦法獲取有任何 cookie 的域列表。人們使用醜陋的伎倆來訪問 cookie (dotnet/runtime#44094)。因此我們引入了一個新的 API CookieContainer.GetAllCookies 來列出容器中的所有 cookie (dotnet/runtime#44094)。

Sockets

通過在 Windows 上使用自動重用埠範圍來處理埠耗盡

在大規模開啟併發 HTTP/1.1 連線時,您可能會注意到新連線嘗試在一段時間後開始失敗。在 Windows 上,這通常發生在大約 16K 併發連線左右,其中套接字錯誤 10055 (WSAENOBUFS) 作為內部 SocketException 訊息。通常,網路堆疊會選擇一個尚未繫結到另一個套接字的埠,這意味著同時開啟的最大連線數受動態埠範圍的限制。這是一個可配置的範圍,通常預設為 49152-65535,理論上限制為 216=65536 個埠,因為埠是 16 位數字。

為了解決遠端端點 IP 地址和/或埠不同的情況下的這個問題,Windows 早在 Windows 8.1 時代就引入了一種稱為自動重用埠範圍的功能。 .NET 框架通過可選屬性 ServicePointManager.ReusePort 公開了相關的套接字選項 SO_REUSE_UNICASTPORT,但此屬性在 .NET Core / .NET 5+ 上成為無操作 API。相反,在 dotnet/runtime#48219 中,我們為 .NET 6+ 上的所有傳出非同步 Socket 連線啟用了 SO_REUSE_UNICASTPORT,允許在連線之間重用埠,只要:

  • 連線的完整 4 元組(本地埠、本地地址、遠端埠、遠端地址)是唯一的。
  • 自動重用埠範圍在機器上配置。

您可以使用以下 PowerShell cmdlet 設定自動重用埠範圍:

Set-NetTCPSetting -SettingName InternetCustom `
                  -AutoReusePortRangeStartPort <start-port> `
                  -AutoReusePortRangeNumberOfPorts <number-of-ports>

設定需要重啟才能生效。

來自 Windows 功能的作者:

由於粘性向後相容性問題,自動重用埠範圍必須專門用於使用此特殊邏輯的出站連線。這意味著如果自動重用埠範圍配置為與眾所周知的偵聽埠(例如埠 80)重疊,則嘗試將偵聽套接字繫結到該埠將失敗。此外,如果自動重用埠範圍完全覆蓋常規臨時埠範圍,則正常的萬用字元繫結將失敗。通常,選擇作為預設臨時埠範圍的嚴格子集的自動重用範圍將避免問題。但是管理員仍然必須小心,因為一些應用程式使用臨時埠範圍內的大埠號作為“知名”埠號。

全域性禁用 IPv6 的選項

從 .NET 5 開始,我們在 SocketsHttpHandler 中使用 DualMode 套接字。這使我們能夠處理來自 IPv6 套接字的 IPv4 流量,並且被 RFC 1933 認為是一種有利的做法。另一方面,我們收到了一些使用者在通過不支援 IPv6 和/或雙通道的 VPN 隧道連線時遇到問題的報告- 正確堆疊套接字。為了緩解 IPv6 的這些問題和其他潛在問題,dotnet/runtime#55012 實施了一個開關,以在整個 .NET 6 程式中全域性禁用 IPv6。

如果您遇到類似問題並決定通過禁用 IPv6 來解決這些問題,您現在可以將環境變數 DOTNET_SYSTEM_NET_DISABLEIPV6 設定為 1 或 System.Net.DisableIPv6 執行時配置設定為 true。

System.Net.Sockets 中新的基於跨度和任務的過載

在社群的幫助下,我們設法使 Socket 和相關型別在 Span、Task 和取消支援方面接近 API-complete。完整的 API-diff 太長了,無法包含在這篇博文中,你可以在這個 dotnet/core 文件中找到它。我們要感謝@gfoidl@ovebastiansen@PJB3005 的貢獻!

安全

在 .NET 6 中,我們在網路安全領域做了兩個值得一提的小改動。

延遲的客戶端協商

這是一個伺服器端的 SslStream 函式。當伺服器決定需要為已建立的連線重新協商加密時使用它。例如,當客戶端訪問需要初始未提供的客戶端證書的資源時。

新的 SslStream 方法如下所示:

public virtual Task NegotiateClientCertificateAsync(CancellationToken cancellationToken = default);

該實現使用兩種不同的 TLS 功能,具體取決於 TLS 版本。對於最高 1.2 的 TLS,使用 TLS 重新協商 (RFC 5746)。對於 TLS 1.3,使用握手後身份驗證擴充套件 (RFC 8446)。這兩個特性在 SChannel AcceptSecurityContext 函式中被抽象出來。因此,Windows 完全支援延遲客戶端協商。不幸的是,OpenSSL 的情況有所不同,因此支援僅限於 TLS 重新協商,即 Linux 上的 TLS 最高 1.2。此外,MacOS 根本不受支援,因為它的安全層不提供其中任何一個。我們全力以赴縮小 .NET 7 中的這一平臺差距。

請注意,HTTP/2 (RFC 8740) 不允許 TLS 重新協商和握手後身份驗證擴充套件,因為它通過一個連線多路複用多個請求。

模仿改進

這是 Windows 獨有的功能,其中單個程式可以通過 WindowsIdentity.RunImpersonatedAsync 在不同使用者下執行執行緒。我們在 .NET 6 中修復的兩種情況下表現不佳。第一種情況是在進行非同步名稱解析時 (dotnet/runtime#47435)。另一個是在傳送 HTTP 請求時,我們不會尊重模擬使用者 (dotnet/runtime#58033)。

診斷

我們收到了很多關於 HttpClientActivity 建立 (dotnet/runtime#41072) 和自動跟蹤標頭注入 (dotnet/runtime#35337) 方面的預設行為的問題、投訴和錯誤報告。這些問題在自動建立 Activity 的 ASP.NET Core 專案中更加明顯,無意中開啟了作為 HttpClient 處理程式鏈的一部分的 DiagnosticsHandler。此外,DiagnosticsHandler 是一個內部類,沒有通過 HttpClient 公開的任何配置,因此迫使使用者想出一些變通辦法來控制行為(dotnet/runtime#31862)或只是將其完全關閉(dotnet/runtime#35337-comment)。

所有這些問題都在 .NET 6 (dotnet/runtime#55392) 中得到解決。現在可以使用 DistributedContextPropagator 控制標頭注入。它可以通過 DistributedContextPropagator.Current 在全域性範圍內完成,也可以通過 HttpClient/SocketsHttpHandler 和 SocketsHttpHandler.ActivityHeadersPropagator 來完成。我們還準備了一些最需要的實現:

  • NoOutputPropagator 抑制跟蹤標頭注入。
  • PassThroughPropagator 使用來自根 Activity 的值注入跟蹤標頭,即透明地執行併傳送與應用程式接收到的相同標頭值。

為了更精細地控制標頭注入,可以提供自定義 DistributedContextPropagator。例如,一個用於完全跳過 DiagnosticsHandler 發出的一層(歸功於@MihaZupan):

public sealed class SkipHttpClientActivityPropagator : DistributedContextPropagator
{
    private readonly DistributedContextPropagator _originalPropagator = Current;

    public override IReadOnlyCollection<string> Fields => _originalPropagator.Fields;

    public override void Inject(Activity? activity, object? carrier, PropagatorSetterCallback? setter)
    {
        if (activity?.OperationName == "System.Net.Http.HttpRequestOut")
        {
            activity = activity.Parent;
        }

        _originalPropagator.Inject(activity, carrier, setter);
    }

    public override void ExtractTraceIdAndState(object? carrier, PropagatorGetterCallback? getter, out string? traceId, out string? traceState) =>
        _originalPropagator.ExtractTraceIdAndState(carrier, getter, out traceId, out traceState);

    public override IEnumerable<KeyValuePair<string, string?>>? ExtractBaggage(object? carrier, PropagatorGetterCallback? getter) =>
        _originalPropagator.ExtractBaggage(carrier, getter);
}

最後,為了將這一切整合在一起,設定 ActivityHeadersPropagator:

// Set up headers propagator for this client.
var client = new HttpClient(new SocketsHttpHandler() {
    // -> Turns off activity creation as well as header injection
    // ActivityHeadersPropagator = null

    // -> Activity gets created but no trace header is injected
    // ActivityHeadersPropagator = DistributedContextPropagator.CreateNoOutputPropagator()

    // -> Activity gets created, trace header gets injected and contains "root" activity id
    // ActivityHeadersPropagator = DistributedContextPropagator.CreatePassThroughPropagator()

    // -> Activity gets created, trace header gets injected and contains "parent" activity id
    // ActivityHeadersPropagator = new SkipHttpClientActivityPropagator()

    // -> Activity gets created, trace header gets injected and contains "System.Net.Http.HttpRequestOut" activity id
    // Same as not setting ActivityHeadersPropagator at all.
    // ActivityHeadersPropagator = DistributedContextPropagator.CreateDefaultPropagator()
});

// If you want the see the order of activities created, add ActivityListener.
ActivitySource.AddActivityListener(new ActivityListener()
{
    ShouldListenTo = (activitySource) => true,
    ActivityStarted = activity => Console.WriteLine($"Start {activity.DisplayName}{activity.Id}"),
    ActivityStopped = activity => Console.WriteLine($"Stop {activity.DisplayName}{activity.Id}")
});

// Set up activities, at least two layers to show all the differences.
using Activity root = new Activity("root");
// Header format can be overridden, default is W3C, see https://www.w3.org/TR/trace-context/).
// root.SetIdFormat(ActivityIdFormat.Hierarchical);
root.Start();
using Activity parent = new Activity("parent");
// parent.SetIdFormat(ActivityIdFormat.Hierarchical);
parent.Start();

var request = new HttpRequestMessage(HttpMethod.Get, "https://www.microsoft.com");

using var response = await client.SendAsync(request);
Console.WriteLine($"Request: {request}"); // Print the request to see the injected header.

URI

HttpClient 使用 System.Uri,它根據 RFC 3986 進行驗證和規範化,並以可能破壞其最終客戶的方式修改一些 URI。例如,較大的服務或 SDK 可能需要將 URI 從其源(例如 Kestrel)透明地傳遞給 HttpClient,這在 .NET 5 中是不可能的(請參閱 dotnet/runtime#52628dotnet/runtime#58057)。

.NET 6 引入了一個新的 API 標誌 UriCreationOptions.DangerousDisablePathAndQueryCanonicalization(請參閱 dotnet/runtime#59274),這將允許使用者禁用 URI 上的任何規範化並“按原樣”使用它。

設定 DangerousDisablePathAndQueryCanonicalization 意味著沒有驗證和輸入的轉換不會超過許可權。作為副作用,使用此選項建立的 Uri 例項不支援 Uri.Fragments - 它始終為空。此外,Uri.GetComponents(UriComponents, UriFormat) 不能用於 UriComponents.Path 或 UriComponents.Query,並且會丟擲 InvalidOperationException。

請注意,禁用規範化還意味著保留字元不會被轉義(例如,空格字元不會更改為 %20),這可能會破壞 HTTP 請求並使應用程式受到請求偷渡的影響。僅當您確保 URI 字串已被清理時才設定此選項。

var uriString = "http://localhost/path%4A?query%4A#/foo";

var options = new UriCreationOptions { DangerousDisablePathAndQueryCanonicalization = true };
var uri = new Uri(uriString, options);
Console.WriteLine(uri); // outputs "http://localhost/path%4A?query%4A#/foo"
Console.WriteLine(uri.AbsolutePath); // outputs "/path%4A"
Console.WriteLine(uri.Query); // outputs "?query%4A#/foo"
Console.WriteLine(uri.PathAndQuery); // outputs "/path%4A?query%4A#/foo"
Console.WriteLine(uri.Fragment); // outputs an empty string

var canonicalUri = new Uri(uriString);
Console.WriteLine(canonicalUri.PathAndQuery); // outputs "/pathJ?queryJ"
Console.WriteLine(canonicalUri.Fragment); // outputs "#/foo"

請注意,該 API 是我們為 .NET 7 設計的更大 API 表面的一部分(請參閱 dotnet/runtime#59099)。

最後說明

這並不是 .NET 6 中發生的所有網路更改的詳盡列表。我們嘗試選擇最有趣或影響最大的更改。如果您在網路堆疊中發現任何錯誤,請隨時與我們聯絡。你可以在 GitHub 上找到我們。

另外,我要感謝我的合著者:

原文連結

.NET 6 Networking Improvements

知識共享許可協議

本作品採用知識共享署名-非商業性使用-相同方式共享 4.0 國際許可協議進行許可。

歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。

如有任何疑問,請與我聯絡 (MingsonZheng@outlook.com) 。

相關文章