HttpClient 的 PostAsJsonAsync 擴充方法如何停用 chunked 分段資料傳輸 | The Will Will Web

The Will Will Web

記載著 Will 在網路世界的學習心得與技術分享

HttpClient 的 PostAsJsonAsync 擴充方法如何停用 chunked 分段資料傳輸

由於 Azure Logic App 的 HTTP Trigger 不支援 Transfer-Encoding: chunked 的 HTTP 要求,而 HttpClient 的 PostAsJsonAsync 擴充方法只支援透過 Transfer-Encoding: chunked 發出 HTTP 要求,這個問題是我嘗試了將近 30 分鐘才發現的魔鬼細節,真的是雷到一整個無以復加。今天這篇文章,我要來說明如何讓 HttpClient 的 PostAsJsonAsync 擴充方法支援 chunked 資料傳輸,以及 Transfer-Encoding: chunked 這個 HTTP 標頭到底是什麼。

以下文章會不斷提到 chunkingchunked transfer 等專有名詞,其代表的是 HTTP 封包內容以「分段傳輸」的方式傳送資料,通常用於大量資料傳輸時,可以將資料一段一段的傳到遠端 HTTP 伺服器。而只要 HTTP 用戶端要送出 chunked 的分段資料,就會在 HTTP Request Headers 中加入 Transfer-Encoding: chunked 標頭。

問題描述

我原本想從 Azure Logic App 取得用戶端 IP 地址,然後自動將取得的 IP 加入 Azure 的網路安全性群組(NSG),結果發現 Azure Logic App 目前完全無法取得用戶端 IP 地址,因此必須要透過 Azure Functions 先取得 IP 地址後,再將 IP 轉發到 Azure Logic App 的 HTTP Trigger 觸發動作。

在 Azure 的 UesrVoice 網站有一條 Add ability to see client IP Address (for HTTP triggers) in the job history and/or diagnostics logs 使用者建議,但看起來微軟還沒有關注到這個部分。

但是 Azure Logic App 的 HTTP Trigger (Request) 不支援收到 Transfer-Encoding: chunked 要求。我再 Handle large messages with chunking in Azure Logic Apps 這份文件有看到一句話:

Logic App triggers don't support chunking because of the increased overhead of exchanging multiple messages.

然而,我的 Azure Functions 應用程式是用 .NET Core 3.1 寫成的,很自然的我會用 HttpClient 來發出 HTTP 要求。而 .NET 5.0 內建的 PostAsJsonAsync 擴充方法,由於實在是太甜了,我幾乎所有需要 POST 發出 JSON 資料的 HTTP 要求,幾乎都會用這個 API 來呼叫。但是重點就是:「這個 PostAsJsonAsync 預設所有發出的 HTTP 要求,全部都會用 chunking 的方式發出,以致於 Azure Logic App 可以收到要求,但是完全收不到 Request Body 的狀況。

在 Azure 的 UesrVoice 網站有一條 Implement chunked transfer-encoding inside Logic Apps 使用者建議,但看起來微軟也還沒有關注到這個部分。

其實這個狀況真的很奇特,原因有幾點:

  1. 所有瀏覽器預設都不會以 chunking 的方式進行傳輸,除非資料量特別大的情況,才會將 HTTP POST 的資料進行分段傳送。
  2. 所有伺服器預設都可以支援 chunking 的方式接收要求,誰知道 Azure Logic App 不支援接收 chunking 要求。
  3. 事實上 Azure Logic App 可以接收到 HTTP POST 要求,沒有任何錯誤訊息,但是收到的 Content-Length 永遠為 0,而且完全沒有 Request Body 會傳入,但我明明有送資料過去啊,這真的會偵錯到懷疑人生!
  4. 然後我去 .NET Runtime(GitHub) 找到一條 Issue #49357 提到跟我一樣的困難,但是這條最後是以不處理結案,原因是微軟的人回應說「there is no detail about wide-spread server usage with chunked encoding disabled / not working」(沒有已知的伺服器不支援 chunked encoding 被停用的情況)。可是瑞凡,不支援 chunked encoding 的就是你們家 Azure 的產品啊!(怒)

證明 HttpClient 的 PostAsJsonAsync 實作方式

首先,最簡單可以得知真相的方法,就是從 HttpClientJsonExtensions.Post.cs 原始碼去看 PostAsJsonAsync 的實作細節。

其次,我們也可以寫點 Code 來驗證一下:

  1. 初始化專案

    mkdir TestPostAsJsonAsync
    cd TestPostAsJsonAsync
    dotnet new globaljson --sdk-version 3.1.100
    dotnet new console
    curl https://www.toptal.com/developers/gitignore/api/visualstudio > .gitignore
    git init
    git add .
    git commit -m "Initial commit"
    
  2. 加入 System.Net.Http.Json 套件

    由於 .NET Core 3.1 還沒有內建 PostAsJsonAsync 擴充方法,因此只要額外加裝 System.Net.Http.Json 套件就可以使用!

    dotnet add package System.Net.Http.Json
    
  3. 進入 https://webhook.site/ 網站取得 unique URL

    假設網址 https://webhook.site/265de048-0b91-4d6b-be7d-16cbf209ff05 是我們要測試發出要求的網址!

  4. 加入 HttpClientPostAsJsonAsync 的範例程式

    using System.Net.Http;
    using System.Net.Http.Json;
    using System.Threading.Tasks;
    
    namespace TestPostAsJsonAsync
    {
        class Program
        {
            static async Task Main(string[] args)
            {
                using var client = new HttpClient();
    
                await client.PostAsJsonAsync(
                    "https://webhook.site/265de048-0b91-4d6b-be7d-16cbf209ff05",
                    new { ClientIP = "127.0.0.1" });
            }
        }
    }
    
    dotnet run
    
  5. 回到 Webhook.site 網站查看剛剛發出的 HTTP 封包內容

    此時你會驚人的發現,明明發出的 HTTP 封包內容很小,但還是看到了 Transfer-Encoding: chunked 這個 HTTP 標頭,這也意味著由 PostAsJsonAsync 發出的 HTTP 封包,永遠都會以 chunking 的方式傳送資料。

    Webhook.site - Test, process and transform emails and HTTP requests

如何有效關閉 HttpClient 的 chunking 行為

我沒辦法左右 Azure Logic App 產品的設計,但我可以改變 .NET 用戶端的 HTTP 發送行為。

事實上,這個 HttpClient 並非只能發出 chunked 的 HTTP 封包,而是當發出去的 內容 (HttpContent) 當無法被事先判斷時,預設就會以 chunked 的方式進行傳輸,當達到預設的 Buffered Size 的時候,就會選擇先送出內容,然後在累積 JSON 序列化的內容,達到 Buffered Size 的時候,就再送出下一段內容。

我們可以從 HttpClientJsonExtensions.Post.cs 原始碼看到主要的程式碼是以下這段:

JsonContent content = JsonContent.Create(value, mediaType: null, options);
return client.PostAsync(requestUri, content, cancellationToken);

在使用 JsonContent.Create 建議 JsonContent 的時候,其實含沒有對 value 進行序列化,而執行 client.PostAsync 的時候,才會邊序列化、邊傳送資料,以增加程式的執行效率。基本上,在透過 HTTP POST 傳輸大量內容的時候,使用 chunked transfer 可以大幅提昇效率!

簡單來說,要關閉 chunked transfer 的話,只要將 JsonContent 全部都先讀入緩衝區(Buffer),之後傳給 HttpClient 的時候,就已經得知所有要傳送出去的 HTTP 封包內容,此時就會關閉 chunked transfer 行為。程式碼範例如下:

var content = JsonContent.Create<T>(value);
await content.LoadIntoBufferAsync();
var response = await httpClient.PostAsync(requestUri, content);

所以我們剛剛的範例程式,只要改成以下,就可以完美關閉 chunked transfer 行為:

using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
using System.Text.Json;

namespace TestPostAsJsonAsync
{
    class Program
    {
        static async Task Main(string[] args)
        {
            using var client = new HttpClient();

            var content = JsonContent.Create(new { ClientIP = "127.0.0.1" });
            await content.LoadIntoBufferAsync();
            var response = await client.PostAsync(
                "https://webhook.site/265de048-0b91-4d6b-be7d-16cbf209ff05",
                content);
        }
    }
}

2021-07-15_23-10-57

相關連結