C# HttpClient 发送 JSON POST:序列化、超时与重试避坑
简介:面向 C# 网络通信与 JSON 数据交互场景整理的 Newtonsoft.Json 多版本程序集包,适合需要通过 HTTP POST 向服务端提交 JSON、并解析响应数据的开发者使用,也可为处理对象序列化、反序列化及不同 .NET 目标框架兼容问题提供参考。压缩包共 27 个文件,约 6.41MB,包含 9 个 dll、9 个 xml 和 9 个 pdb:dll 为可引用的 Json.NET 运行库,xml 提供 API 文档注释,pdb 保留调试符号,便于在项目中排查异常与调试。内容覆盖 net20、net35、net40、net45、netstandard1.0/1.3/2.0 及 portable 等版本,可匹配传统 .NET Framework、.NET Core 与跨平台项目的引用需求。已有 1400 人学习,适合希望降低版本适配与 JSON 处理试错成本的 C# 开发者。
1. 从一次 400 说起:C# 用 POST 传 JSON,真正要盯的只有四件事
对接外部接口时最常见的场景:Postman 里点一下秒回 200,把同样的 body 搬进 C# 代码就 400,把 json 字符串打印出来逐字符比对,看着一模一样。这类 C# 网络协议交互问题,八成出在序列化、编码、Content-Type 和超时这四处,跟 JSON 语法本身没关系。
围绕的正是这一环:用 HttpClient 发 HTTP POST,请求体和响应体都走 JSON,把 DTO 设计、序列化参数、Header 注入、状态码判断、超时与重试串成一条可复现的链路。适合刚接手接口联调的后端和上位机开发者,也适合手上堆着一批散装 HttpWebRequest 旧代码、想统一收口的人。这篇不聊协议史,只写我怎么搭、怎么排错、哪几行改一次就能少熬两小时。
2. 客户端与序列化选型:HttpClient 复用、两套序列化器的边界在哪
动手之前先把两件事定下来:用什么发请求,用什么做 JSON 序列化。这两项选错,后面所有调试都在替选型还债。很多人觉得发个 POST 而已,随便 new 一个 HttpClient 就完事,跑单机测试永远没问题,一上并发就开始偶发 SocketException,日志里全是"由于系统缓冲区空间不足"。
2.1 四种发请求的写法,什么场景用哪个
我把实际项目里出现过的写法列成一张表,选型时直接对号入座:
| 写法 | 适用运行时 | 实例复用 | 常见翻车点 |
|---|---|---|---|
| HttpWebRequest | .NET Framework 全系 | 每次新建 | 异步模型别扭,GetResponse 同步阻塞,连接数受 ServicePointManager 限制 |
| HttpClient | .NET Framework 4.5+ / .NET Core+ | 静态单例或 IHttpClientFactory | 频繁 new 导致 TIME_WAIT 堆积;长驻单例不刷新 DNS |
| RestSharp | 需要 multipart、表单、复杂反序列化 | 单例 | 107 版本前后 API 断层,老教程代码直接编译不过 |
| Flurl.Http | 链式写法,快速脚本 | 静态 | 依赖链偏长,团队规范不好统一 |
.NET Framework 上还有个容易忽略的点:ServicePointManager.DefaultConnectionLimit 默认是 2,也就是同一个域名最多两条并发连接。压测时发现 QPS 上不去、请求排队,十有八九是这里没改。.NET Core 之后默认值跟着 CPU 走,一般不用管,但单例 HttpClient 的 DNS 缓存问题还在,需要靠 SocketsHttpHandler 的 PooledConnectionLifetime 兜底。
我一般这么定:新项目在 .NET 6 以上,用 IHttpClientFactory 注册命名客户端,配 PooledConnectionLifetime 五分钟;老 Framework 项目统一一个静态 HttpClient,启动时顺手把 DefaultConnectionLimit 调到 32 到 64 之间。别在方法体里 new,这是血泪经验,不是风格洁癖。
2.2 一个能直接跑的 POST JSON 骨架
骨架不追求功能全,追求能一眼看清每一步在干什么:
using System; using System.Net.Http; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; public static class JsonPostDemo { // 进程内复用同一个实例,避免每次 new 造成端口耗尽 private static readonly HttpClient Client = new HttpClient { // 这里管的是整个请求到响应读完的总时长 Timeout = TimeSpan.FromSeconds(15) }; public static async Task<string> PostAsync(string url, object payload, CancellationToken ct = default) { var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, // 输出 userName 而不是 UserName DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }; string json = JsonSerializer.Serialize(payload, options); // StringContent 第二个参数是编码,第三个参数是 media type using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var resp = await Client.PostAsync(url, content, ct).ConfigureAwait(false); // 先拿字符串再判断,错误体往往不是标准 JSON string body = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); if (!resp.IsSuccessStatusCode) throw new HttpRequestException($"HTTP {(int)resp.StatusCode}: {body}"); return body; } }逻辑说明:序列化放在方法内而不是靠 HttpClient 的扩展方法,好处是序列化参数显式可见,出问题能立刻定位到是"字符串生成错了"还是"传输过程改了字节"。ConfigureAwait(false)在类库和控制台程序里都建议加,避免同步上下文回切造成的死锁,只有在 UI 事件处理里才需要恢复上下文。
参数说明:Timeout设为 15 秒是经验值,内部服务可以压到 5 秒,跨公网对接放到 30 秒;PropertyNamingPolicy.CamelCase要和服务端约定一致,对方要 PascalCase 就删掉这行;DefaultIgnoreCondition设为忽略 null,能避免一部分服务端把 null 当成类型错误的校验逻辑。ct一路透传,是为了在界面取消或进程退出时能真正中断在途请求,而不是干等超时。
2.3 序列化器定谁:System.Text.Json 与 Newtonsoft.Json 的边界
两套都能用,但行为差异不小,混用还会出现"同一个对象序列化两次结果不同"的玄学。
| 对比项 | System.Text.Json | Newtonsoft.Json |
|---|---|---|
| 默认命名 | 保持原样 | 保持原样 |
| 字段(field) | 默认不序列化,要设 IncludeFields | 默认序列化 public 字段 |
| null 值 | 默认输出 null | 默认输出 null |
| 日期时间 | ISO 8601,带 T 和偏移 | 默认 ISO 8601,但反序列化时会自动把字符串识别成日期 |
| 循环引用 | 直接抛异常 | 默认抛异常,可配 ReferenceLoopHandling |
| 大文档性能 | 更好,Span 友好 | 一般 |
我踩过最隐蔽的一个坑在 Newtonsoft 的反序列化侧:DateParseHandling默认会把长得像时间的字符串自动转成 DateTime,于是JObject里那个字段从 string 变成了 DateTime,再往下取字符串就炸了。对接那种 ID 恰好是"20240115"格式的字段时,这个坑几乎必现,加一行DateParseHandling = DateParseHandling.None才消停。
选择上我一般这样:新项目一律 System.Text.Json,少一个依赖,性能也够;需要和 Newtonsoft 特有的 JObject 动态操作、或者依赖大量特性(比如 JsonExtensionData 的高级用法)时,才引 Newtonsoft。两边都要注意的一点是,DTO 里出现循环引用时不要指望序列化器帮你处理,直接给反向导航属性打上忽略标记,比配参数可靠得多。
3. 请求体怎么拼:DTO、编码、Header 三处最容易走样的细节
骨架跑通只是第一步。真正常见的失败,是请求发出去了、服务端也收到了,但解析出来的字段是错的。这一章拆三处最容易走样的细节:DTO 与序列化参数、Content-Type 和字符集、公共 Header 与签名。
3.1 DTO 设计与序列化参数:命名、可空、日期
DTO 的第一原则是只声明你要发的字段,别直接把业务实体丢进去。业务实体往往带着一堆数据库字段和导航属性,序列化出来体积大,还容易把不该外发的内部 ID 带出去。
using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.Unicode; public class OrderRequest { [JsonPropertyName("order_no")] // 服务端要下划线风格时用特性兜底 public string? OrderNo { get; set; } public decimal Amount { get; set; } public DateTimeOffset CreatedAt { get; set; } } public static readonly JsonSerializerOptions JsonOpts = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, // 中文不转成 \u4e2d\u6587,抓包时肉眼能比对 Encoder = JavaScriptEncoder.Create(UnicodeRanges.All), // 允许服务端把 "123" 这种字符串数字读进 int/decimal NumberHandling = JsonNumberHandling.AllowReadingFromString };逻辑说明:JsonPropertyName优先级高于全局命名策略,适合那种大部分字段小驼峰、个别字段下划线风格的接口。Encoder是关键一行,默认编码器会把非 ASCII 字符转义成 \uXXXX,体积膨胀不说,抓包对比时基本没法看;JavaScriptEncoder.Create(UnicodeRanges.All)只放开非 ASCII,仍然会转义<、>、&这些有 XSS 风险的字符,比UnsafeRelaxedJsonEscaping更稳一点。
参数说明:NumberHandling.AllowReadingFromString只影响反序列化,不影响写出;如果服务端对数字精度敏感,decimal 比 double 靠谱,别图省事用 double 接金额。日期统一用DateTimeOffset,字段带时区偏移,能省掉一大类"差八小时"的扯皮。
3.2 Content-Type 与字符集:StringContent 的三个参数
很多服务端框架对 Content-Type 做严格匹配,application/json; charset=utf-8和application/json在它眼里是两种情况。
// 写法一:StringContent 会自动补 charset=utf-8 var c1 = new StringContent(json, Encoding.UTF8, "application/json"); // 实际发出:Content-Type: application/json; charset=utf-8 // 写法二:服务端严格校验 media type 时,手动构造,不带 charset var c2 = new ByteArrayContent(Encoding.UTF8.GetBytes(json)); c2.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");逻辑说明:StringContent的便捷之处在于它替你设了编码,代价是 Content-Type 被追加了 charset 参数。绝大多数服务端能接受,但确实有一部分老网关按字符串全等匹配,多一个分号就返回 415。这时用ByteArrayContent显式指定头部,字节还是 UTF-8 那一份,只是不再声明。
参数说明:编码一律Encoding.UTF8,绝对不要用Encoding.Default,那在中文 Windows 上是 GBK,服务端按 UTF-8 解出来就是一串问号,而且这种错误在日志里看起来"请求成功了",最难查。另外注意序列化后的字符串如果被某处Trim()或者拼接过,字节就变了,签名校验会直接失败。
3.3 公共 Header、Token 与签名
需要鉴权的接口,签名必须基于"实际发出去的那份字节"计算,而不是基于对象重新序列化一遍。
byte[] bytes = Encoding.UTF8.GetBytes(json); // 只序列化一次,后面都用它 string ts = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(); using var hmac = new System.Security.Cryptography.HMACSHA256(Encoding.UTF8.GetBytes(secret)); byte[] hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(ts + "\n" + Encoding.UTF8.GetString(bytes))); // .NET 5+ 用 ToHexString;Framework 用 BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant() string sign = Convert.ToHexString(hash).ToLowerInvariant(); var req = new HttpRequestMessage(HttpMethod.Post, url); req.Headers.Add("X-Timestamp", ts); req.Headers.Add("X-Sign", sign); req.Content = new ByteArrayContent(bytes); req.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");逻辑说明:先把 json 转成 byte 数组存下来,签名和请求体都从这同一份 bytes 出发。只要中间没有二次序列化、没有改编码、没有多一个空格,服务端算出来的签名就一致。
参数说明:时间戳建议用 Unix 秒并参与签名,服务端做五分钟窗口校验,能挡掉重放;密钥不要硬编码在源码里,从配置中心或环境变量取。请求级 Header 用HttpRequestMessage,不要往Client.DefaultRequestHeaders里塞,那个是全局共享的,多线程下会互相污染。
4. 响应与超时:状态码判断、CancellationToken 与重试的止损线
请求侧拼对了,问题往往转移到响应侧:状态码判断太粗、超时管不住、重试把一次下单变成三次。这一章按"读响应 → 控超时 → 定重试"的顺序走。
4.1 别用 EnsureSuccessStatusCode 一把梭
EnsureSuccessStatusCode()抛出的异常里只有状态码,没有响应体,而 4xx 的真实原因几乎全在 body 里。
using var req = new HttpRequestMessage(HttpMethod.Post, url) { Content = content }; // ResponseHeadersRead:响应头到手就返回,内容边读边处理,避免大响应全量缓冲 using var resp = await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct); string raw = await resp.Content.ReadAsStringAsync(ct); if (!resp.IsSuccessStatusCode) { string message = raw; try { // 错误体可能是 JSON,也可能是一段 HTML 网关页 using var doc = JsonDocument.Parse(raw); if (doc.RootElement.TryGetProperty("message", out var m)) message = m.GetString() ?? raw; } catch (JsonException) { /* 不是 JSON 就原样带回 */ } throw new HttpRequestException($"HTTP {(int)resp.StatusCode} {resp.StatusCode}: {message}"); }逻辑说明:先把响应读成字符串,再尝试解析成 JSON 错误结构,解析失败就把原文带进异常。这样日志里出现的是"参数 amount 精度超限"这类人话,而不是"Response status code does not indicate success: 400"。
参数说明:HttpCompletionOption.ResponseHeadersRead适合响应体可能较大的场景,配合流式读取能减少内存峰值;如果响应都很小,用默认的 ResponseContentRead 更省事。异常里带原始 body 时注意别把手机号、身份证这类字段原样落盘,日志脱敏要在这一层做。
4.2 超时、CancellationToken 与 HttpClient.Timeout 的关系
这三者的关系不搞清楚,排查"偶发卡死"会非常痛苦。.NET Core 上HttpClient.Timeout覆盖到响应内容读完为止;.NET Framework 上它只管到响应头,后面读 body 是可以无限期挂住的。所以生产代码里别只依赖Timeout。
using var cts = CancellationTokenSource.CreateLinkedTokenSource(outerToken); cts.CancelAfter(TimeSpan.FromSeconds(10)); // 单次调用的硬上限 try { return await PostInternalAsync(url, json, cts.Token); } catch (OperationCanceledException) when (!outerToken.IsCancellationRequested) { // 外层没取消,说明是我们自己超时掐断的 throw new TimeoutException($"调用 {url} 超过 10 秒未完成"); }逻辑说明:CreateLinkedTokenSource把外部取消和内部超时合成一个令牌,两者任一触发都会中断请求。catch 里的when过滤是关键,用来区分"用户主动取消"和"我们自己等不及了",前者不该报警,后者该进监控。
参数说明:CancelAfter的时间要大于服务端承诺的 P99,又要小于上游调用方的超时,形成递减的漏斗,否则超时会被层层传递放大。用OperationCanceledException而不是TaskCanceledException捕获,前者是后者的父类,覆盖更全。
4.3 哪些错误能重试,哪些重试就是二次下单
重试不是无条件循环。判断标准有两条:错误是不是瞬时的,请求是不是幂等的。
for (int attempt = 0; attempt < 3; attempt++) { try { var resp = await SendOnceAsync(req.Clone(), ct); int code = (int)resp.StatusCode; // 只对明确的瞬时错误重试;400/401/403/422 重试毫无意义 bool transient = code == 408 || code == 429 || code == 502 || code == 503 || code == 504; if (!transient) return resp; // 服务端给了 Retry-After 就听它的 var wait = resp.Headers.RetryAfter?.Delta ?? TimeSpan.FromMilliseconds(300 * Math.Pow(2, attempt)); await Task.Delay(wait, ct); } catch (HttpRequestException) when (attempt < 2) { await Task.Delay(TimeSpan.FromMilliseconds(300 * Math.Pow(2, attempt)), ct); } }逻辑说明:只对连接层失败和 5xx 里的少数几个码重试,指数退避加抖动。请求体带Idempotency-Key头时,服务端才能安全去重;没有这个约定的场景,创建类接口(下单、提交工单)一律不重试。
参数说明:重试次数 3 是上限,再多只会把下游打得更难受。注意HttpRequestMessage不能重复发送,重试前必须重建或用Clone(),否则会抛"请求消息已发送"的异常。
5. 避坑与排查:五条把联调拖到半夜的记录
下面五条都是我实际遇到过、并且事后复盘觉得"早知道能省三小时"的问题。每条按现象、原因、解决写。
5.1 现象:Postman 通、代码 400
现象:同一个接口,同一个账号,Postman 里点发送返回 200,C# 代码跑出来 400,把 json 打印出来贴进 Postman 又是 200。
原因:肉眼看到的字符串一致,不代表字节一致。常见差异有三种:编码不是 UTF-8、字符串被Trim()或拼接改过、序列化多输出了一层{"value":{...}}包装。还有一种隐蔽情况是 STJ 把中文转义成了 \uXXXX,服务端做了长度或内容校验。
解决:在发出前把字节打出来,用BitConverter.ToString(Encoding.UTF8.GetBytes(json))取前若干字节对比,或者直接在ByteArrayContent之前断点看bytes.Length。我一般会写一行日志POST {url} len={bytes.Length},长度对不上就一定有问题。
5.2 现象:中文变问号或 \uXXXX
现象:服务端收到的姓名字段是 "??" 或者一串 \u5f20\u4e09,英文和数字完全正常。
原因:用Encoding.Default取字节(中文系统是 GBK),却声明了 UTF-8;或者用了默认编码器把非 ASCII 全转义,对方系统按字面量解析。
解决:序列化和转字节统一走Encoding.UTF8,Content-Type 明确声明 utf-8;需要可读时加JavaScriptEncoder.Create(UnicodeRanges.All)。反方向也要注意,响应读取用ReadAsStringAsync时,如果服务端返回的 Content-Type 没写 charset,HttpClient 会按 UTF-8 解,遇到 GBK 服务端要在响应头里纠正。
5.3 现象:偶发 SocketException,端口耗尽
现象:单机压测跑几千次之后开始报 "Only one usage of each socket address is normally permitted",重启进程又好了。
原因:方法体里using var client = new HttpClient(),每次请求都新建实例、新建连接,短连接关闭后进入 TIME_WAIT,Windows 默认动态端口约 16000 个,几分钟就耗光。即便using释放了对象,底层连接也不会立刻归还。
解决:HttpClient 复用为静态单例,或走IHttpClientFactory;服务端支持的话开启长连接。这一条属于必须一次性解决的架构性问题,别用"重试一下就好了"掩盖。
5.4 现象:Content-Length 与实际字节不符
现象:服务端报 body 截断或解析到一半失败,网关日志里 Content-Length 比实际内容小。
原因:用流作为 Content 时长度不可知,走了 chunked 编码,某些老网关不支持;或者手工req.Content.Headers.ContentLength设了一个未按字节数计算的字符数(Ultra 中文一个字符三字节,长度按字符算必然错)。
解决:小请求体一律ByteArrayContent或StringContent,由 HttpClient 自己算长度;确需流式上传再显式开 chunked。手工设 ContentLength 时,长度用bytes.Length而不是json.Length,这是最容易写错的一行。
5.5 现象:读响应卡住,一直等到超时
现象:请求发出去了,服务端日志显示已返回 200,但客户端一直挂着,最后抛超时。
原因:HttpClient.Timeout在 .NET Framework 上不覆盖读取响应体阶段;或者SendAsync用了ResponseHeadersRead之后忘记读 Content,连接一直占用,连接池被占满后续请求全部排队。
解决:用CancellationTokenSource.CancelAfter做端到端硬超时,并且确保ResponseMessage和HttpContent都被using释放。用ResponseHeadersRead时,务必把流读完或显式Dispose,否则连接不会归还。这类问题在监控上表现为"QPS 掉到 0 但没有异常",靠日志挖不出来,得看连接池指标。
6. 收口:一个可复用的 JsonPostClient 与上线前的三步验证
散落在各处的 POST 代码最后一定会分叉:有人加了日志,有人忘了超时,有人自己拼 Header。我一般的做法是收成一个泛型入口,只暴露"地址、请求对象、追踪回调"三个参数。
public static async Task<TResp?> PostJsonAsync<TReq, TResp>( HttpClient client, string url, TReq body, Action<string>? trace = null, CancellationToken ct = default) { string json = JsonSerializer.Serialize(body, JsonOpts); trace?.Invoke($"POST {url} req={json}"); // 只在需要时打开,防止日志刷爆 using var content = new StringContent(json, Encoding.UTF8, "application/json"); using var resp = await client.PostAsync(url, content, ct).ConfigureAwait(false); string raw = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); trace?.Invoke($"POST {url} status={(int)resp.StatusCode} resp={raw}"); if (!resp.IsSuccessStatusCode) throw new HttpRequestException($"{(int)resp.StatusCode}: {raw}"); return string.IsNullOrWhiteSpace(raw) ? default : JsonSerializer.Deserialize<TResp>(raw, JsonOpts); }上线前我会跑三步验证。第一步,把trace打开跑一次真实请求,把req=后面那段字符串原封不动贴进 Postman,通与不通立刻分流:通说明是传输层差异,不通说明是序列化差异。第二步,拿一个必错的入参(比如把必填字段置空)跑一次,确认异常信息里能看到服务端的原始错误描述,而不是一个光秃秃的状态码。第三步,把超时临时调到 1 秒,确认超时路径抛的是TimeoutException而不是TaskCanceledException,避免监控规则对不上。
还有一个具体技巧:JsonOpts一定只保留一个静态实例,不要每次调用 new 一个JsonSerializerOptions。STJ 在第一次序列化时会缓存类型元数据,反复 new 会让缓存完全失效,大对象序列化耗时能差出好几倍,这个性能问题在压测里表现得非常明显。
从那以后我每次接新接口,都强制先跑一遍"字节比对 + 错误入参 + 缩短超时"这三步再写业务逻辑,宁可多花二十分钟,也不要在半夜对着两段看起来一模一样的 JSON 找差异。希望帮到你。
本文还有配套的精品资源,点击获取