測試日期:2026-04-07 ~ 2026-04-08 測試環境:Docker containers(macOS host)
某個專案使用 Spring RestTemplate + Apache HttpClient 5.3.1 發送 POST JSON 請求,
server 端收到的 body 為空 {},導致業務邏輯錯誤。
根因是 Apache HttpClient 5.3.1 預設使用 Transfer-Encoding: chunked,
而 server 端不支援 chunked decoding。
為了系統性地比較各語言、各 library 的 HTTP 請求行為差異,建立了此測試環境。
共測試 6 種語言、21 個 HTTP library,每個 library 執行 5 種測試 × HTTP/HTTPS:
| 語言 | Library | 版本 |
|---|---|---|
| Python | urllib (built-in) | Python 3.12 |
| Python | requests | 2.32.3 |
| Python | httpx | 0.28.1 |
| Python | urllib3 | 2.3.0 |
| Python | aiohttp | 3.11.14 |
| Java | HttpURLConnection (built-in) | Java 17 |
| Java | java.net.http.HttpClient (built-in) | Java 17 |
| Java | OkHttp | 4.12.0 |
| Spring | RestTemplate + JDK | Spring Boot 3.4.4 |
| Spring | RestTemplate + Apache HttpClient | 5.4.2 |
| Spring | WebClient (Reactor Netty) | 1.2.4 |
| Go | net/http (built-in) | Go 1.22 |
| Go | Resty | 2.16.5 |
| Node.js | http (built-in) | Node.js 22 |
| Node.js | axios | 1.14.0 |
| Node.js | node-fetch | 3.3.2 |
| Node.js | got | 14.4.0 |
| Node.js | undici | 7.0.0 |
| C# | HttpClient (built-in) | .NET 8 |
| C# | RestSharp | 112.1.0 |
| 測試 | 說明 |
|---|---|
| GET | Query string encoding 差異 |
| POST JSON | Content-Type、Content-Length vs chunked |
| POST Form | URL encoding 差異 |
| POST Multipart | boundary 格式、file 編碼方式 |
| Custom Headers | 自動附加的 header 差異 |
Transfer-Encoding: chunked → 0 個 library
Content-Length → 21 個 library(全部)
該 chunked 問題已在 Apache HttpClient 5.4.2 修復。
| 版本 | 行為 |
|---|---|
| Apache HttpClient 5.3.1(舊版) | Transfer-Encoding: chunked — body 被吃掉 |
| Apache HttpClient 5.4.2(我們測試) | Content-Length: 144 — 正常 |
Chunked encoding 在以下情境可能出現:
- Body 以 streaming 方式產生(大小事先未知)
- 大檔案上傳(避免先讀整個檔案進記憶體)
- Library 的 bug(如 Apache HttpClient 5.3.1)
所有 21 個 library 都使用 TLS 1.3 + TLS_AES_128_GCM_SHA256。
這代表在現代版本的 runtime(Java 17、Python 3.12、Go 1.22、Node.js 22、.NET 8)下, TLS 的行為已經高度統一,不需要擔心版本差異。
SNI 是 TLS handshake 時 client 告知 server「我要連哪個主機」的機制。 部分 Java 系 library 沒有設定 SNI:
| SNI 設定 | Library |
|---|---|
有設定 server |
Python 全部、Node.js 全部、Go 全部、C# 全部 |
| 未設定(空值) | Java HttpURLConnection、Java OkHttp、Java Apache HttpClient、Spring 全部 |
在多數情況下不影響功能(因為 server 只有一張憑證)。但在以下場景會出問題:
- CDN / 反向代理:同一個 IP 服務多個域名,靠 SNI 選擇憑證
- WAF / 防火牆規則:某些規則依賴 SNI 來判斷目標
- 憑證驗證:嚴格的 server 可能要求 SNI 與憑證的 CN/SAN 匹配
Java 系的 SNI 缺失是因為我們使用自訂 SSLContext 配合自簽 CA 時,
某些 library 的實作沒有自動帶入 hostname 作為 SNI。
這在正式環境中使用公開 CA 時通常不會發生。
各 library 的 User-Agent 格式差異明顯,可用來快速辨識請求來源:
| Library | User-Agent |
|---|---|
| Python urllib | Python-urllib/3.12 |
| Python requests | python-requests/2.32.3 |
| Python httpx | python-httpx/0.28.1 |
| Python urllib3 | python-urllib3/2.3.0 |
| Python aiohttp | Python/3.12 aiohttp/3.11.14 |
| Java HttpURLConnection | Java/17.0.18 |
| Java HttpClient | Java-http-client/17.0.18 |
| Java OkHttp | okhttp/4.12.0 |
| Spring RestTemplate (JDK) | Java/17.0.18 |
| Spring RestTemplate (Apache) | Apache-HttpClient/5.4.2 (Java/17.0.18) |
| Spring WebClient | ReactorNetty/1.2.4 |
| Go net/http | Go-http-client/1.1 |
| Go Resty | go-resty/2.16.5 (https://github.com/go-resty/resty) |
| Node.js http | (不設定) |
| Node.js axios | axios/1.14.0 |
| Node.js node-fetch | node-fetch |
| Node.js got | got (https://github.com/sindresorhus/got) |
| Node.js undici | (不設定) |
| C# HttpClient | (不設定) |
| C# RestSharp | RestSharp/112.1.0.0 |
- 不設定 User-Agent 的 library:Node.js http、undici、C# HttpClient — 這 3 個都是「最底層」的實作
- 帶版本號的:多數 library 會在 User-Agent 中暴露版本號,可能有安全顧慮
- Spring RestTemplate 的 User-Agent 取決於底層 library:用 JDK 就是
Java/17,用 Apache 就是Apache-HttpClient/5.4.2
各 library 會自動加上不同數量的 header,數量越多代表 library 做了越多「隱性行為」:
| Library | Header 數量 | 說明 |
|---|---|---|
| Node.js http | 6 | 最精簡 |
| Node.js undici | 6 | 同為底層實作 |
| Go net/http | 7 | |
| Python urllib3 | 7 | |
| Python urllib | 8 | |
| Python aiohttp | 8 | |
| Java HttpURLConnection | 8 | |
| Java OkHttp | 8 | |
| Spring RestTemplate (JDK) | 8 | |
| Spring WebClient | 8 | |
| Go Resty | 8 | |
| Node.js got | 8 | |
| Python requests | 9 | |
| Python httpx | 9 | |
| Java HttpClient | 9 | 含 HTTP/2 升級 header |
| Spring RestTemplate (Apache) | 9 | |
| Node.js axios | 9 | |
| Node.js node-fetch | 9 | |
| C# HttpClient | 11 | |
| C# RestSharp | 14 | 最多,含大量 Accept 類型 |
- 最精簡(6 個):Node.js http 和 undici — 只送必要的 header
- 最豐富(14 個):C# RestSharp — 自動加上大量的 Accept 類型
- Java HttpClient 特殊:自動帶
Upgrade: h2c和HTTP2-Settings,嘗試升級到 HTTP/2
HTTP 規範中 header name 是 case-insensitive,但各 library 的實作不同:
| 風格 | Library |
|---|---|
首字母大寫(Content-Type) |
多數 library |
全小寫(content-type) |
Node.js got、node-fetch、undici、Spring WebClient (Netty) |
Spring WebClient 底層使用 Netty,Netty 按照 HTTP/2 的慣例使用全小寫 header name。 如果 server 端用 case-sensitive 的方式比對 header name,可能會出問題。
同樣的 JSON 資料,各語言的序列化結果不同:
| 特徵 | Library |
|---|---|
| 緊湊格式(無空格) | Java、Go、Node.js、Python |
| 格式化格式(有縮排) | C# HttpClient、C# RestSharp |
| 欄位順序改變 | Go net/http(map 遍歷順序隨機) |
C# 預設使用格式化的 JSON 輸出(有換行和縮排),導致 Content-Length 較大(189 vs 144 bytes)。
各 library 產生的 multipart boundary 格式差異很大:
| 類型 | Library | Boundary 範例 |
|---|---|---|
| 固定字串 | Node.js 全部(手動實作) | ----NodejsAxiosBoundary |
| 固定前綴 + 隨機數字 | Java HttpURLConnection、Java HttpClient | ----JavaHttpClient1775618784560 |
| UUID 格式(帶引號) | Apache HttpClient、Spring | "0a901258-3c50-4eb6-a3c5-0357b1c9781f" |
| 十六進位亂數 | Python requests、urllib3 | 5133bbf2b955429b8752b38a33be967b |
| 長十六進位亂數 | Python httpx、aiohttp | 2951533d1e6b047d9a0819d40f9ca9282ae682eac7c08413b447682a02fe |
| 固定字串 | Python urllib(手動實作) | ----PythonUrllibBoundary |
- 帶引號的 boundary:Apache HttpClient 和 Spring 的 boundary 用雙引號包裹,某些舊版 server 可能無法正確解析
- boundary 長度差異大:從 24 字元到 60 字元不等
- 隨機性:每次請求的 boundary 都不同(除了手動實作的固定字串)
Java 11+ 的 java.net.http.HttpClient 會自動嘗試 HTTP/2 升級:
Connection: Upgrade, HTTP2-Settings
Upgrade: h2c
HTTP2-Settings: AAEAAEAAAAIAAAAAAAMAAAAAAAQBAAAAAAUAAEAAAAYABgAA這是 HTTP/2 over cleartext (h2c) 的升級機制。其他 library 都沒有這個行為。
注意:這只出現在 HTTP(非加密)連線中。HTTPS 連線會透過 TLS ALPN 協商 HTTP/2, 不需要這些額外的 header。
- 升級 Apache HttpClient:5.3.1 有 chunked encoding 的 bug,至少升級到 5.4.2
- 注意 User-Agent 暴露的資訊:版本號可能被用於攻擊已知漏洞
- Header 大小寫不要假設:server 端應該用 case-insensitive 比對
- JSON 序列化格式統一:C# 預設有縮排,跨語言對接時注意 Content-Length 差異
- Multipart boundary 格式要容錯:特別注意帶引號的 boundary
- 必須支援 chunked decoding:即使目前所有 library 都用 Content-Length,未來某個版本可能改變
- Body 驗證不要只靠 Content-Length:讀完整個 body 後再解析,不要提前中斷
- Log 要記錄完整的 request header:問題排查時,User-Agent 和 Transfer-Encoding 是最重要的線索
- SNI 不要作為安全判斷依據:Java 系 library 在使用自訂 SSL 時可能不帶 SNI
目前所有現代 runtime 都統一使用 TLS 1.3 + AES-128-GCM。 TLS 版本差異在這一代已經不是問題,但如果對接的是舊系統(Java 8、Python 2、.NET Framework), 仍需注意 TLS 1.0/1.1 的相容性。