资讯动态

C# HttpClient POST JSON 实战:序列化、超时重试与排错

发布时间:2026/10/24 6:59:23 来源:尧图企业网站定制
简介面向需要在 C# 项目中完成 HTTP POST 与 JSON 数据交互的开发者尤其适合为不同 .NET 目标框架挑选兼容 Newtonsoft.Json 组件的场景可解决请求体序列化、响应反序列化及多版本引用选择等问题。压缩包为 rar 格式共 27 个文件约 6.41MB内含 9 个 dll、9 个 xml 与 9 个 pdb分别对应可引用的程序集、API 文档和调试符号覆盖 net20 至 netstandard2.0 等多个目标版本便于按项目框架快速匹配。已有约 1400 人学习下载。对需要稳定处理 JSON 对象转换、排查序列化异常或补齐旧项目依赖的读者这份资源能减少逐个查找版本的时间也方便在调试时借助符号与文档定位调用细节。1. 一个 POST 请求为什么会耗掉半天对接某第三方接口时我遇到过最典型的场景本地 curl 一把过换到 C# 客户端返回 415把 Content-Type 补上变 400改完字段名又变超时。整个链路只有一句话——POST 一个 JSON 过去拿 JSON 回来可真正卡人的从来不是「怎么发」而是编码、头、序列化、连接生命周期这几处细节叠加。c# 网络协议交互里json 是最常见的载体也是最容易被当成「随便写写就行」的一层。这篇按我实际拆过的顺序走先把请求骨架立稳再谈序列化选型最后落到真实网络里的超时、重试和排查。适合正在写对接层、被 4xx 反复打回的人。2. HttpClient 的请求骨架从实例到报文2.1 为什么不能把 HttpClient 塞进 using新手最容易照抄的写法是using (var client new HttpClient())每次调用新建、用完释放。在控制台里跑一次没问题放进 Web 服务或者循环里就会出问题。HttpClient 内部持有HttpMessageHandler真正干活的是它维护的连接池Dispose掉 HttpClient 会把底层 socket 一起关掉于是短时间内大量连接进入 TIME_WAIT端口被占满表现为「跑一会儿就开始报连接失败」。反直觉的地方在于HttpClient 本身是线程安全的绝大多数公开方法都支持并发调用它被设计成长期存活。真正需要控制的是它的生命周期而不是「用完就扔」。我一般的做法分两种进程内只有一个远端地址就用静态只读实例要对接多个不同域名或不同配置用IHttpClientFactory按名字取。前者简单后者能顺带解决 DNS 刷新和统一配置的问题第 4 章会展开。提示静态实例不等于配置永远不变。BaseAddress和DefaultRequestHeaders是共享状态改了会影响所有并发请求别在业务代码里随手改。2.2 最小可跑的 POST JSON 骨架先把能跑通的版本写出来后面所有讨论都基于它改。这里用System.Text.Json.NET Core 3.0 之后内置不引第三方包。using System; using System.Net.Http; using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; public class OrderClient { // 静态只读实例整个进程复用同一份连接池 private static readonly HttpClient Http new HttpClient { BaseAddress new Uri(https://api.example.invalid/), Timeout TimeSpan.FromSeconds(15) // 整体超时含连接发送读取 }; public async TaskOrderResult CreateOrderAsync(OrderRequest req, CancellationToken ct) { // 1. 序列化显式指定命名策略避免 PascalCase 直接发出去 var json JsonSerializer.Serialize(req, new JsonSerializerOptions { PropertyNamingPolicy JsonNamingPolicy.CamelCase, DefaultIgnoreCondition System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull }); // 2. 包成 StringContent并在这里明确 mediaType 与编码 using var content new StringContent(json, Encoding.UTF8, application/json); // 3. 发请求ct 用于业务侧主动取消和 Timeout 是两套机制 using var resp await Http.PostAsync(v1/orders, content, ct).ConfigureAwait(false); // 4. 先读字符串再判断错误响应体往往带业务错误码不能直接丢 var body await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); if (!resp.IsSuccessStatusCode) throw new HttpRequestException($HTTP {(int)resp.StatusCode}: {body}); return JsonSerializer.DeserializeOrderResult(body, new JsonSerializerOptions { PropertyNameCaseInsensitive true // 服务端字段大小写不固定时兜底 }) ?? throw new InvalidOperationException(响应体为空); } } public record OrderRequest(string OrderNo, decimal Amount, string Currency); public record OrderResult(string TradeId, int Status);这段代码有四个地方值得盯。第一StringContent的三个参数是「内容、编码、媒体类型」第三个参数写application/json之后实际发出的头是Content-Type: application/json; charsetutf-8charset 由第二个参数决定。很多人只写了new StringContent(json)默认媒体类型是text/plain服务端严格校验就会回 415。第二PropertyNamingPolicy JsonNamingPolicy.CamelCase决定了 C# 的OrderNo发出去是orderNo。不加这一行发出去就是OrderNo对接方如果按 camelCase 解析字段全丢接口可能返回 200 但业务失败这种问题最难查因为状态码是对的。第三using var resp和using var content要保留。HttpResponseMessage和HttpContent都实现了IDisposable不释放会把连接占住。注意using var的作用域是整个方法体别在读完 body 之前就提前释放。第四ConfigureAwait(false)在类库代码里建议加避免同步上下文死锁在 ASP.NET Core 里影响不大但保持习惯没坏处。2.3 请求头到底该加在哪一层请求头有三个落点优先级从低到高HttpClient.DefaultRequestHeaders、HttpRequestMessage.Headers、HttpContent.Headers。内容相关的头Content-Type、Content-Length、Content-Encoding属于第三层只能挂在 content 上其余业务头Authorization、X-Request-Id、自定义签名挂在第二层最稳。如果你确实需要往DefaultRequestHeaders里加东西务必只在初始化时加一次。它的典型翻车是同一个静态 HttpClientA 业务加了Authorization: Bearer aB 业务加了Bearer b因为默认头是共享的最后谁后加谁生效表现为「偶发鉴权失败」。一旦发现这种「时好时坏」的玄学问题先去看有没有人往默认头里塞了动态值。// 正确做法请求级头挂在 message 上互不影响 using var msg new HttpRequestMessage(HttpMethod.Post, v1/orders) { Content content }; msg.Headers.Add(X-Request-Id, Guid.NewGuid().ToString(N)); msg.Headers.Authorization new System.Net.Http.Headers.AuthenticationHeaderValue(Bearer, token); using var resp await Http.SendAsync(msg, HttpCompletionOption.ResponseHeadersRead, ct);这里SendAsync的第二个参数是HttpCompletionOption。默认值ResponseContentRead表示「等响应体读完才返回」ResponseHeadersRead表示「收到响应头就返回」body 由你自己流式读。对返回体可能很大的接口后者能显著降低内存峰值也便于做流式解析对返回体只有几百字节的普通 JSON 接口两者差别可以忽略用默认值更省心。3. JSON 序列化选型两套库的边界在哪3.1 System.Text.Json 与 Newtonsoft.Json 的实际差异.NET 生态里同时存在两套序列化器选错不会立刻报错但会在字段名、枚举、日期、空值这些地方悄悄偏掉。我按实际踩过的点列一下这些差异比官方文档里那些性能对比更影响日常。维度System.Text.JsonNewtonsoft.Json字段名默认策略保持原样PascalCase保持原样但反序列化默认大小写不敏感反序列化大小写默认敏感需开PropertyNameCaseInsensitive默认不敏感枚举默认序列化为数字默认序列化为数字StringEnumConverter可转字符串null 处理DefaultIgnoreCondition控制默认输出 nullNullValueHandling控制默认输出 null非公有成员默认不参与可通过特性放开多态反序列化需要显式配置JsonDerivedTypeTypeNameHandling可做但有安全风险动态结构JsonElement/JsonNodeJObject/JToken循环引用直接抛异常可配置ReferenceLoopHandling最常见的错配是「服务端用 Newtonsoft 序列化客户端用 STJ 反序列化」。Newtonsoft 默认把枚举写成数字STJ 读数字没问题反过来 STJ 写数字、Newtonsoft 读数字也没问题。真正会炸的是日期Newtonsoft 对DateTime默认按 ISO 8601 带偏移量输出STJ 也是但两边对DateTimeKind的处理不完全一致跨时区服务上容易出现「时间差 8 小时」这类问题。我的习惯是 DTO 里统一用DateTimeOffset两边都不纠结。选型上没有绝对答案新项目、只做 HTTP 对接、对性能敏感用 STJ已有大量 Newtonsoft 特性标注、需要复杂多态或JObject动态解析继续用 Newtonsoft别为了统一而统一。混用也可以但要在边界处明确「这一段用的是哪套」不要在一个 DTO 上同时挂两套特性。3.2 用 DTO 固定请求体别拼字符串拼 JSON 字符串是我见过最容易出事的写法少个逗号、引号没转义、金额用了浮点、中文没编码全都要到联调时才发现。用 DTO 让编译器帮你挡一部分剩下的交给序列化器。using System.Text.Json.Serialization; public class PaymentRequest { [JsonPropertyName(merchant_id)] // 对接方要求下划线命名逐个指定 public string MerchantId { get; set; } ; [JsonPropertyName(amount)] public decimal Amount { get; set; } // 金额用 decimal不用 double [JsonPropertyName(currency)] public string Currency { get; set; } CNY; [JsonPropertyName(extra)] public Dictionarystring, string? Extra { get; set; } // 可选字段null 时不输出 [JsonPropertyName(created_at)] public DateTimeOffset CreatedAt { get; set; } DateTimeOffset.UtcNow; }[JsonPropertyName]的优先级高于全局命名策略字段命名不统一时逐个标最省事。金额那行是血泪经验double在序列化时可能出现0.1 0.2 0.30000000000000004这种值对账时会被财务打回来decimal才是金额的正确类型。DateTimeOffset带上时区偏移比裸DateTime少一层歧义。Extra字段用Dictionarystring, string配合第 2 章里的DefaultIgnoreCondition WhenWritingNull为 null 时整个字段不出现。有些对接方对多余字段敏感多一个 null 字段就报参数校验失败这个配置能省掉一轮沟通。3.3 反序列化的容错参数服务端返回的 JSON 未必永远规整有时多字段、有时少字段、有时数字是字符串。反序列化侧要提前想清楚「哪些可以忍、哪些必须炸」。private static readonly JsonSerializerOptions ReadOptions new() { PropertyNameCaseInsensitive true, // 字段大小写不固定 NumberHandling JsonNumberHandling.AllowReadingFromString, // 123 也能读成 int ReadCommentHandling JsonCommentHandling.Skip, // 容忍 // 注释 AllowTrailingCommas true // 容忍尾逗号 }; public static T? SafeDeserializeT(string body) { try { return JsonSerializer.DeserializeT(body, ReadOptions); } catch (JsonException ex) { // 把原始报文带进异常否则线上只能看到「解析失败」四个字 throw new InvalidOperationException($JSON 解析失败原始报文{body[..Math.Min(body.Length, 500)]}, ex); } }NumberHandling.AllowReadingFromString解决的是「服务端把金额写成100.00字符串」这类情况不加就会抛JsonException。AllowTrailingCommas和注释跳过是针对手工拼接的响应体做的兜底。异常里带原始报文这一点很关键——线上排查时没有原始报文的解析异常基本等于黑匣子只能靠猜。注意JsonSerializerOptions实例应当缓存复用。每次反序列化都 new 一个 optionsSTJ 无法复用内部元数据缓存高并发下性能会明显下降。写成静态只读字段是标准做法。4. 认证、超时与重试让请求活过真实网络4.1 超时是分层的别只设一个HttpClient.Timeout是整体超时从发出请求到读完响应体全都算在内。它管不到连接建立阶段的细节。真正分层的超时在SocketsHttpHandler上var handler new SocketsHttpHandler { ConnectTimeout TimeSpan.FromSeconds(5), // TCPTLS 建连上限 PooledConnectionLifetime TimeSpan.FromMinutes(5), // 连接存活上限解决 DNS 变更 PooledConnectionIdleTimeout TimeSpan.FromMinutes(2), // 空闲连接回收 MaxConnectionsPerServer 20 // 单服务端并发连接上限 }; var client new HttpClient(handler) { Timeout TimeSpan.FromSeconds(20) // 整体上限要大于 ConnectTimeout };ConnectTimeout和Timeout的关系要理顺整体超时必须大于建连超时否则建连还没结束整体超时先触发日志里只会看到一次笼统的取消分不清是网络不通还是服务端慢。我一般按「建连 5 秒、整体 15 到 30 秒」配具体看接口的 P99 耗时。PooledConnectionLifetime是很多人忽略的一项。HttpClient 长期存活时连接池里的连接可能一直指向旧 IP服务端做了 DNS 切换或扩容客户端还在往老地址发表现为「部分请求持续失败、重启进程就好」。设一个 5 分钟左右的存活上限能把这个窗口压到可接受范围。4.2 取消令牌和超时是两套机制CancellationToken用于业务侧主动取消用户关页面、上游请求已断开Timeout用于保护自己不被慢接口拖死。两者都会让SendAsync抛TaskCanceledException但含义完全不同。using var cts CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(TimeSpan.FromSeconds(10)); // 业务级超时比 HttpClient.Timeout 更细 try { using var resp await client.SendAsync(msg, cts.Token); // ... } catch (OperationCanceledException) when (!ct.IsCancellationRequested) { // 不是外部取消那就是超时 throw new TimeoutException(请求超时耗时超过 10 秒); }CreateLinkedTokenSource把外部取消和本地超时串起来when过滤条件用来区分「谁取消的」。.NET 5 之后HttpClient自身超时会在TaskCanceledException的InnerException里塞一个TimeoutException但依赖这个内部结构不如自己判ct.IsCancellationRequested稳。4.3 重试之前先问幂等POST 天然不幂等同一个请求发两次可能产生两笔订单。重试这件事必须先和对接方确认语义常见做法是带一个客户端生成的幂等键public async Taskstring PostWithRetryAsync(HttpRequestMessageFactory factory, CancellationToken ct) { var idempotencyKey Guid.NewGuid().ToString(N); // 同一次业务操作复用同一个 key var delays new[] { 200, 500, 1200 }; // 毫秒最多重试 3 次 for (var i 0; i delays.Length; i) { using var msg factory.Create(); // 每次重试都要新建 message不能复用 msg.Headers.Add(Idempotency-Key, idempotencyKey); try { using var resp await client.SendAsync(msg, ct); if ((int)resp.StatusCode is 500 or 429) // 只对可恢复状态码重试 { if (i delays.Length) return await resp.Content.ReadAsStringAsync(ct); await Task.Delay(delays[i], ct); continue; } return await resp.Content.ReadAsStringAsync(ct); // 4xx 直接返回重试没意义 } catch (HttpRequestException) when (i delays.Length) { await Task.Delay(delays[i], ct); // 网络层异常才重试 } } throw new InvalidOperationException(重试次数已用尽); }三个要点。其一HttpRequestMessage不能被重复发送第二次SendAsync会抛InvalidOperationException所以每次重试都要重新构造用工厂委托是最省事的写法。其二只对 5xx、429 和网络异常重试4xx 是参数或鉴权问题重试一百次也是同一个结果。其三退避时间要有间隔连续快速重试在服务端看来和攻击没区别反而会触发限流。注意幂等键要由业务侧生成并在一次业务操作内保持一致。每次重试都新生成一个 key等于告诉服务端「这是新请求」幂等保护直接失效。4.4 多客户端场景用工厂管一个进程里要对接多个服务、每个服务的 BaseAddress、超时、认证方式都不同时IHttpClientFactory是标准解法// Program.cs.NET 6 最小宿主写法 builder.Services.AddHttpClient(payment, c { c.BaseAddress new Uri(https://pay.example.invalid/); c.Timeout TimeSpan.FromSeconds(10); c.DefaultRequestHeaders.Add(Accept, application/json); }) .ConfigurePrimaryHttpMessageHandler(() new SocketsHttpHandler { PooledConnectionLifetime TimeSpan.FromMinutes(5) });工厂内部维护 handler 池按配置复用避免每次 new 造成的端口耗尽同时解决 DNS 刷新。注入到业务类里用IHttpClientFactory.CreateClient(payment)取取到的 HttpClient 可以短生命周期使用底层连接仍然复用。5. 避坑与排查五条从日志里翻出来的记录5.1 415 之后补了头还是 415现象返回415 Unsupported Media Type检查代码发现已经设了Content-Type: application/json。原因头加在了HttpRequestMessage.Headers上而不是 content 上。Content-Type属于内容头挂在 message 的Headers集合里会抛异常或者被忽略实际发出的请求仍然没有正确的内容类型。解决统一用new StringContent(json, Encoding.UTF8, application/json)或者content.Headers.ContentType new MediaTypeHeaderValue(application/json)。排查时打开HttpClient的DefaultRequestHeaders和实际 message 的头对照一下别只看代码里写了什么。5.2 状态码 200业务却报「参数缺失」现象接口返回 200响应体里是业务错误码和「缺少 orderNo 字段」。原因C# 属性是OrderNo序列化时没用 camelCase 策略发出去是OrderNo对接方按orderNo取值取不到。解决全局设PropertyNamingPolicy JsonNamingPolicy.CamelCase或者对每个字段加[JsonPropertyName]。验证方式很简单在发送前把序列化结果打出来看一眼比事后猜快得多。这类问题在联调阶段出现是好事上线后才发现就要走紧急发版。5.3 偶发超时重启就好现象服务跑几个小时之后开始零星超时重启进程立刻恢复。原因两种可能。一是 HttpClient 每次 new 且没释放连接和端口堆积二是长期存活的连接指向了旧的服务端 IPDNS 变更后没有刷新。解决静态复用 HttpClient 或走IHttpClientFactory并给 handler 设PooledConnectionLifetime。排查时看进程的 TCP 连接数netstat或ss都行连接数持续上涨基本就是前者。5.4 中文变成乱码或 \uXXXX现象请求体里的中文到了服务端变成问号或者日志里看到一堆\u4f60\u597d。原因前者是编码不一致StringContent的第二个参数如果传了Encoding.Default在某些环境是 GBK而服务端按 UTF-8 解就会乱码后者是 STJ 默认把非 ASCII 字符转义成\uXXXX功能上没问题但人看着难受某些老系统还会解析失败。解决编码固定Encoding.UTF8。要去掉转义配置Encoder JavaScriptEncoder.UnsafeRelaxedJsonEscapingvar options new JsonSerializerOptions { Encoder System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping };名字里带 Unsafe 是因为它放宽了 HTML 敏感字符的转义如果序列化结果直接拼进 HTML 页面会有 XSS 风险。只做 HTTP 传输、不落地到前端页面时用它是安全的。5.5 在同步方法里调 .Result 卡死现象控制台程序正常放到某些宿主环境里调用PostAsync(...).Result或.Wait()之后整个线程卡住不动。原因同步阻塞等待异步任务而异步方法内部捕捉了同步上下文回调需要回到原线程执行原线程却被.Result占着互相等形成死锁。解决一路 async 到底不要在中途.Result。类库代码里所有 await 加ConfigureAwait(false)。如果调用方确实是同步接口无法改用Task.Run(() xxxAsync()).GetAwaiter().GetResult()把异步挪到线程池但这只是绕开不是修复长期还是要把调用链改成异步。6. 进阶用 DelegatingHandler 统一日志与签名6.1 把横切逻辑从业务里抽出来每写一个接口就重复加签名头、打日志、算耗时代码很快就会变成一坨。DelegatingHandler是 HttpClient 管道里的拦截器请求发出前和响应回来后都能插一脚适合放这类横切逻辑。using System.Diagnostics; using System.Net.Http; using System.Threading; using System.Threading.Tasks; public class SigningLoggingHandler : DelegatingHandler { private readonly ILoggerSigningLoggingHandler _logger; public SigningLoggingHandler(ILoggerSigningLoggingHandler logger) _logger logger; protected override async TaskHttpResponseMessage SendAsync( HttpRequestMessage request, CancellationToken ct) { var sw Stopwatch.StartNew(); var traceId Guid.NewGuid().ToString(N); request.Headers.Add(X-Trace-Id, traceId); // 请求体只能读一次读之前先 buffer读完再重建 content string? reqBody null; if (request.Content is not null) { reqBody await request.Content.ReadAsStringAsync(ct); var bytes System.Text.Encoding.UTF8.GetBytes(reqBody); request.Content new ByteArrayContent(bytes); request.Content.Headers.ContentType new System.Net.Http.Headers.MediaTypeHeaderValue(application/json); } _logger.LogInformation([{TraceId}] {Method} {Url} body{Body}, traceId, request.Method, request.RequestUri, reqBody); try { var resp await base.SendAsync(request, ct); var respBody await resp.Content.ReadAsStringAsync(ct); // 响应读完要重建否则业务层拿到的 content 是空的 var buffer System.Text.Encoding.UTF8.GetBytes(respBody); resp.Content new ByteArrayContent(buffer); resp.Content.Headers.ContentType new System.Net.Http.Headers.MediaTypeHeaderValue(application/json); _logger.LogInformation([{TraceId}] {Status} {Elapsed}ms resp{Body}, traceId, (int)resp.StatusCode, sw.ElapsedMilliseconds, respBody); return resp; } catch (Exception ex) { _logger.LogError(ex, [{TraceId}] 请求异常耗时 {Elapsed}ms, traceId, sw.ElapsedMilliseconds); throw; } } }这段代码的核心是「读一次、重建一次」。HttpContent本质是流被读过一次之后再读就是空字符串如果 handler 里读了响应体但没重建业务代码拿到的resp.Content永远是空的这个 bug 极其隐蔽日志里一切正常只有业务层报「响应体为空」。重建时要连Content-Type一起补回去ByteArrayContent默认不带媒体类型。注册方式builder.Services.AddTransientSigningLoggingHandler(); builder.Services.AddHttpClient(payment, c { /* 配置 */ }) .AddHttpMessageHandlerSigningLoggingHandler();Handler 的注册生命周期用Transient不要用单例它有InnerHandler状态被多个 HttpClient 共享会出错。6.2 怎么验证请求真的发对了联调阶段最有效的手段不是看代码是看报文。我一般做两件事。第一件构造一个本地回显服务把收到的原始请求行、头、body 原样返回客户端把响应打出来对照是否和服务端约定一致。用HttpListener或者最小 API 起一个都行几十行代码。第二件抓包。开发机上起抓包工具过滤目标端口看实际发出的Content-Type、Content-Length、字段名。代码里写的是 A线上发的是 B这种事只有抓包能看见。最后一件事把这一层做成可开关的。日志 Handler 在 Debug 下全量打 body生产环境关掉或者只打摘要金额、手机号这类字段要做脱敏。我吃过亏为了排查临时打开全量日志忘了关敏感字段在日志系统里留了两个月。从那以后我每次加日志 Handler 都强制走一遍「默认关闭、按开关打开、字段白名单」这三步宁可排查时多花五分钟也不让原始报文裸奔。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑