简介:面向Winform开发者的HTTP POST提交JSON与接收返回结果示例程序,聚焦桌面端与Web接口之间的数据交互场景,适合具备C#基础的初中级开发者学习。内容围绕HttpClient发起异步POST请求、使用Json.NET完成对象序列化与响应解析,并处理成功状态与错误分支展开,覆盖网络通信中的关键环节。压缩包共34个文件,约59KB,以C#源码为核心,配合resx窗体资源、config配置、sln解决方案与csproj项目文件,以及编译后的exe和pdb等,构成可直接查看与运行的完整工程。已有3385人学习,说明该主题在桌面应用网络通信学习中具有一定参考需求。读者可从中获得可复用的请求封装思路、窗体调用示例与JSON收发处理范式,并据此对照自身项目排查MIME类型设置、异步调用和返回结果解析等常见问题。
1. 一个 Winform 窗体、一个按钮,问题却比想象中多
一个 Winform 窗体,几个输入框,一个按钮,点下去把值拼成 JSON 用 HTTP POST 提交给后端,再把返回的 JSON 显示出来——听起来像是半小时就能写完的活。真动手的人基本都会遇到同一批问题:点完按钮窗体白屏几秒、中文发过去变成问号、接口明明返回了 400 却只看到一句「请求失败」、换台机器就报「基础连接已关闭」。
这份 Winform 程序要解决的就是这条简单链路:稳定地 POST JSON、完整拿到返回结果、并且等待期间界面还能拖动。它面向的是做内部工具、上位机配置端、桌面管理端,需要和 HTTP 接口对接的开发者;如果项目已经上了 gRPC 或者成熟的网关 SDK,这套东西带来的收益就没那么大了。下面按「骨架 → 组包拆包 → 不卡界面 → 踩坑 → 收口」的顺序拆一遍。
2. 先搭好基础框架:HttpClient 选型与第一次 POST 请求
2.1 为什么是 HttpClient,为什么不能每次 new
在 .NET Framework 时代,很多人习惯用HttpWebRequest拼请求:设置Method、ContentType、写GetRequestStream()、再读GetResponse()。这套 API 能用,但有两个老问题。第一是ServicePointManager.DefaultConnectionLimit默认值很小,桌面端并发几个请求就开始排队,现象是「明明网络没问题,第二个请求就是慢」。第二是流和响应对象必须手工Close(),少关一个就泄露一个连接,跑一上午之后开始报「操作超时」。
WebClient更简单一点,但它在 .NET 6 之后已经被标记为不推荐使用,而且取消请求、设置超时都很别扭。HttpClient是现在的主流选择,它把连接池、Keep-Alive、压缩、取消令牌都封在里面了。
但HttpClient有一个被说烂了、仍然天天有人踩的点:它是设计成复用的。每次new HttpClient()都会创建新的连接池,旧连接在 TCP 四次挥手完成之前还占着端口,请求频率一高就进入TIME_WAIT堆积,最后报SocketException: 由于系统缓冲区空间不足或队列已满。正确做法是把它做成static readonly字段,或者交给IHttpClientFactory管理。Winform 单窗体工具用前者就够了。
序列化库也有个选型问题。.NET Framework 4.x项目里我一般直接用Newtonsoft.Json:宽容度高、日期格式可控、JObject处理动态字段方便。.NET 6+的新项目可以用内置的System.Text.Json,性能更好,但默认行为更严格——属性名大小写敏感、遇到多余字段的处理策略不同,跟老接口对接时反而要多写几行JsonSerializerOptions。这份程序按兼容性优先,走Newtonsoft.Json。
2.2 建立工程、引入依赖、让第一次请求能够跑通
新建一个 Windows Forms 应用,目标框架选.NET Framework 4.7.2或.NET 6-windows都行。窗体上放四个控件:txtUrl(接口地址)、txtUser(示例参数)、btnSend(发送按钮)、txtLog(多行只读文本框,用来显示返回原文)。
装依赖用 NuGet,包管理器控制台或者命令行都可以:
# 包管理器控制台 Install-Package Newtonsoft.Json # 或者 .NET CLI(SDK 风格项目) dotnet add package Newtonsoft.Json然后在Form1.cs里写第一个能跑通的版本:
using System; using System.Net.Http; using System.Text; using System.Windows.Forms; using Newtonsoft.Json; public partial class Form1 : Form { // 静态只读:全进程共用一个连接池,避免端口耗尽 private static readonly HttpClient Http = new HttpClient { Timeout = TimeSpan.FromSeconds(15) // 整体超时:连接 + 发送 + 读完响应 }; public Form1() { InitializeComponent(); // 请求头只设一次,别在每次请求里重复 Add(会累加出重复项) Http.DefaultRequestHeaders.Accept.ParseAdd("application/json"); } private async void btnSend_Click(object sender, EventArgs e) { btnSend.Enabled = false; // 防连点重复提交 try { var payload = new { userId = txtUser.Text.Trim(), action = "query", ts = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") }; string json = JsonConvert.SerializeObject(payload); // StringContent 的第二个参数是编码,第三个是 Content-Type using (var content = new StringContent(json, Encoding.UTF8, "application/json")) using (HttpResponseMessage resp = await Http.PostAsync(txtUrl.Text.Trim(), content)) { // 先拿原文,再决定怎么解析:4xx/5xx 的响应体往往才是真正的错误信息 string body = await resp.Content.ReadAsStringAsync(); txtLog.AppendText($"[{(int)resp.StatusCode}] {resp.StatusCode}{Environment.NewLine}"); txtLog.AppendText(body + Environment.NewLine + Environment.NewLine); } } catch (TaskCanceledException) { txtLog.AppendText("[超时] 请求超过 15 秒未返回" + Environment.NewLine); } catch (HttpRequestException ex) { txtLog.AppendText("[网络异常] " + ex.Message + Environment.NewLine); } finally { btnSend.Enabled = true; // 无论成功失败都要恢复按钮 } } }这段代码有几个地方值得说明。payload用匿名对象就够了,字段少的时候不必专门建实体类。StringContent的第二个参数Encoding.UTF8决定了请求体的字节编码,第三个参数application/json决定了Content-Type头——注意它不会自动带上charset=utf-8,有些服务端框架(尤其老版本的 Java 侧)会因此按 ISO-8859-1 解,中文就变成问号了,稳妥写法是直接写"application/json; charset=utf-8"。
HttpResponseMessage用using包住,是因为响应流不释放会一直占着连接。await resp.Content.ReadAsStringAsync()把响应体整个读成字符串——很多人图省事直接resp.Content.ReadAsAsync<T>(),一旦服务端返回的是 HTML 错误页,抛出的异常里只有一句「反序列化失败」,原始内容全丢了,排查时等于断了一条线索。
提示:如果项目是
.NET Framework 4.6.2以下,或者LangVersion没开到 7.0 以上,using var这种写法编译不过,改成using (...) { }的块式写法即可。
2.3 把地址、超时、请求头提取到配置里
第一次跑通之后,紧接着要做的就是把硬编码的值拿出去。接口地址在测试环境和现场环境肯定不一样,超时时间也要根据接口实际耗时调。用App.config的appSettings是最省事的做法,现场改配置不用重新编译。
<!-- App.config --> <configuration> <appSettings> <add key="ApiBaseUrl" value="http://127.0.0.1:8080/api/" /> <add key="ApiTimeoutSeconds" value="20" /> <add key="ApiToken" value="" /> </appSettings> </configuration>读取的时候用ConfigurationManager.AppSettings["ApiBaseUrl"],超时值记得做一次int.TryParse兜底,别让一个手抖写错的配置把程序搞成启动即崩。HttpClient.Timeout一旦设了就改不了(它是只读属性),所以要么在创建时读配置,要么把它固定成一个上限,具体的单次超时交给CancellationTokenSource控制——这个在第四章展开。
请求头里的鉴权 token 是个容易出问题的地方。DefaultRequestHeaders是全局的,如果你在多处Add("Authorization", ...),会累加出多个同名头,服务端直接拒绝。正确做法是每次请求构造独立的HttpRequestMessage,把 token 放在它的Headers上,这属于「用一次就丢」的对象,不存在污染问题。
3. 组包与拆包:请求体怎么拼,返回体怎么读才不丢信息
3.1 用实体类映射 JSON,不做字符串拼接
手拼 JSON 是这条链路上最典型的低级错误来源:字段值里带个引号、带个换行、带个反斜杠,拼出来的字符串就是非法 JSON,服务端返回 400,你还得肉眼找哪个字符没转义。改成实体类 + 序列化,转义交给库处理。
using Newtonsoft.Json; public class OrderQueryRequest { [JsonProperty("order_no")] public string OrderNo { get; set; } [JsonProperty("start_time")] public DateTime StartTime { get; set; } [JsonProperty("page_size")] public int PageSize { get; set; } = 20; } public class ApiResponse<T> { [JsonProperty("code")] public int Code { get; set; } [JsonProperty("message")] public string Message { get; set; } [JsonProperty("data")] public T Data { get; set; } } public class OrderDto { [JsonProperty("order_no")] public string OrderNo { get; set; } [JsonProperty("amount")] public decimal Amount { get; set; } [JsonProperty("created_at")] public DateTime CreatedAt { get; set; } }[JsonProperty]的作用是把 C# 的PascalCase属性名映射到接口的snake_case字段名,这样客户端代码保持 .NET 风格,序列化出来的 JSON 又符合服务端约定。如果接口字段和属性名完全一致,这些特性可以省略,但一旦接口改过字段名,有显式映射的一方改起来只是改一行特性,没有的一方就得全局搜索替换。
PageSize给了默认值 20,这是个小设计:序列化时如果这个属性没被赋值,默认会输出"page_size": 20;如果你希望不传时干脆不出现在 JSON 里,就配合NullValueHandling.Ignore用可空类型。
3.2 序列化参数:日期、空值、大小写三处最容易出偏差
默认的JsonConvert.SerializeObject(obj)有很多隐式行为,跟接口对不上就会出问题。我的习惯是显式声明一份JsonSerializerSettings,全项目共用:
private static readonly JsonSerializerSettings JsonOpts = new JsonSerializerSettings { // 值为 null 的字段不参与序列化:某些服务端会把显式 null 当成"要清空该字段" NullValueHandling = NullValueHandling.Ignore, // 日期统一成本地格式字符串;不指定的话默认是 ISO8601 且带时区,老接口常解析失败 DateFormatString = "yyyy-MM-dd HH:mm:ss", // 服务端新增字段时,客户端不因为找不到对应属性而抛异常 MissingMemberHandling = MissingMemberHandling.Ignore, // 反序列化时允许把字符串 "123" 转成 int 123,容错接口字段类型漂移 // 只在对接不受控的老接口时开,内部接口不建议 // TypeNameHandling 保持默认 None,开了会有反序列化安全风险 };这三项里,最容易被忽略的是NullValueHandling。曾经有个更新接口,传{"remark": null}表示「把备注清空」,而客户端用Ignore之后这个字段根本不出现在报文里,服务端理解为「不修改备注」,导致界面上明明清空了、查回来还是旧值。所以这个参数不是无脑开的,要看接口语义:增量更新用Ignore更安全,全量覆盖则必须让 null 也发出去。
日期同理。默认序列化DateTime会输出2024-05-01T10:00:00+08:00这种带时区的 ISO 格式,很多老接口的正则只认yyyy-MM-dd HH:mm:ss,直接 400。反过来,反序列化时如果服务端返回的是带时区的字符串,而客户端按本地格式解析,跨时区部署就会差 8 小时——这类问题在现场环境下极难复现,属于典型的「开发机全对、客户机全错」。
3.3 读响应:先拿字符串,再决定怎么解析
响应处理的标准流程是「拿原文 → 判断状态码 → 尝试解析 → 校验业务码」。四步里少一步,出错时就会丢信息。下面是一个完整的处理骨架:
public async Task<T> PostAsync<T>(string url, object payload, CancellationToken ct) { string json = JsonConvert.SerializeObject(payload, JsonOpts); using (var content = new StringContent(json, Encoding.UTF8, "application/json; charset=utf-8")) using (HttpResponseMessage resp = await Http.PostAsync(url, content, ct).ConfigureAwait(false)) { // 第一步:无论状态码是什么,先把响应体原文读出来 string raw = await resp.Content.ReadAsStringAsync().ConfigureAwait(false); // 第二步:非 2xx 一律当失败,但要把状态码和原文一起抛,别只抛状态码 if (!resp.IsSuccessStatusCode) throw new ApiException((int)resp.StatusCode, raw); // 第三步:原文可能不是 JSON(网关错误页、HTML、空串),要单独兜住 if (string.IsNullOrWhiteSpace(raw)) throw new ApiException(-1, "响应体为空"); ApiResponse<T> result; try { result = JsonConvert.DeserializeObject<ApiResponse<T>>(raw, JsonOpts); } catch (JsonException ex) { throw new ApiException(-1, "响应不是合法 JSON:" + raw, ex); } // 第四步:HTTP 200 不代表业务成功,业务码要单独判 if (result == null || result.Code != 0) throw new ApiException(result?.Code ?? -1, result?.Message ?? "未知业务错误"); return result.Data; } }ConfigureAwait(false)在这里是可选的,因为这段代码放在服务层,不接触 UI 控件,加了好处是不强制回到 UI 线程上下文,减少一次调度;但如果你在调用方await之后直接改控件,调用方的上下文仍然会把它拉回 UI 线程,不受影响。
ApiException自定义异常要带Code和RawBody两个字段。RawBody是关键——用户看到的提示可以是「订单不存在」,但日志里必须留下完整的原始报文,否则线上排查只能靠猜。我在实际项目里会把它同时写进一个按天滚动的日志文件,一次故障回看半小时就能定位。
还有一个细节:ApiResponse<T>里Data是泛型。当接口返回的是数组时,T就是List<OrderDto>;返回空对象时Data会是null,调用方必须判空,不能直接.Count。这一条在代码评审里经常被漏掉。
4. 界面不卡死:async/await、取消与跨线程回填
4.1 async void 只留给事件处理器
Winform 的事件处理器签名是void,没法改成async Task,所以async void是唯一选择。但async void有个致命特性:里面抛出的异常不会被调用方捕获,会直接冒到Application.ThreadException,表现就是「程序突然弹一个未处理异常框,栈里全是框架代码」。所以每个async void方法体都必须自带完整的try/catch/finally。
private async void btnQuery_Click(object sender, EventArgs e) { SetBusy(true); try { var req = new OrderQueryRequest { OrderNo = txtOrderNo.Text.Trim(), StartTime = dtpStart.Value, PageSize = 50 }; var list = await _api.PostAsync<List<OrderDto>>("order/query", req, _cts.Token); BindGrid(list); } catch (OperationCanceledException) { AppendLog("请求已取消"); } catch (ApiException ex) { // 业务错误走这里:Message 给用户看,RawBody 进日志 MessageBox.Show(this, ex.Message, "接口返回错误", MessageBoxButtons.OK, MessageBoxIcon.Warning); AppendLog("业务错误 code=" + ex.Code + " body=" + ex.RawBody); } catch (Exception ex) { AppendLog("网络异常:" + ex.GetType().Name + " " + ex.Message); } finally { SetBusy(false); } } private void SetBusy(bool busy) { btnQuery.Enabled = !busy; btnCancel.Enabled = busy; Cursor = busy ? Cursors.WaitCursor : Cursors.Default; lblStatus.Text = busy ? "请求中…" : "就绪"; }SetBusy把按钮、光标、状态栏三处状态收在一个方法里,是为了避免「请求成功了但按钮忘了恢复」这类问题。放在finally里,是保证即使抛异常也会恢复。这几行看着啰嗦,但它消掉的是用户那边「程序卡住了、点不动了」的投诉。
4.2 按钮状态、超时与 CancellationToken
HttpClient.Timeout是「整体超时」,设成 20 秒意味着连接、发送、等响应、读完响应加在一起不能超过 20 秒,超了抛TaskCanceledException。这在多数场景够用,但有两个短板:一是它不能中途取消(用户点了取消按钮也得等满 20 秒),二是它无法针对单次请求调整。
所以更细的做法是:HttpClient.Timeout设一个宽松上限(比如Timeout.InfiniteTimeSpan),每次请求用CancellationTokenSource控制实际时长。
private CancellationTokenSource _cts; private CancellationTokenSource NewRequestCts(int timeoutSeconds) { _cts?.Cancel(); // 取消上一次还在飞的请求 _cts?.Dispose(); _cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds)); return _cts; } // 取消按钮:立刻中断当前请求 private void btnCancel_Click(object sender, EventArgs e) => _cts?.Cancel();new CancellationTokenSource(TimeSpan)这个构造函数会在指定时间后自动触发取消,等价于「定时取消 + 手动取消」二合一。_cts?.Cancel()放在新建之前,是为了处理「用户连点两次查询」的情况——不取消旧请求,两个响应先后回来,界面可能被先发后到的旧数据覆盖,这种问题在慢接口上特别容易出现,属于典型的玄学 Bug。
注意:
CancellationTokenSource用完要Dispose,否则内部那个定时器会一直挂着。虽然单个窗体工具影响不大,但这是习惯问题,写顺手了就不会在长期运行的服务里出问题。
4.3 回到 UI 线程:InvokeRequired 与 BeginInvoke
await之后能直接改控件,是因为 Winform 的SynchronizationContext会把续体调回 UI 线程。但下面两种场景不受这个保护:一是你用了Task.Run包住同步代码,二是回调来自HttpClient内部线程或者定时器。此时直接txtLog.AppendText(...)会抛InvalidOperationException: 线程间操作无效。
标准写法是这个模式:
private void AppendLog(string text) { if (txtLog.InvokeRequired) { // BeginInvoke 是异步投递,不阻塞调用线程;Invoke 是同步等待,容易死锁 txtLog.BeginInvoke(new Action(() => AppendLog(text))); return; } txtLog.AppendText($"[{DateTime.Now:HH:mm:ss}] {text}{Environment.NewLine}"); txtLog.SelectionStart = txtLog.TextLength; txtLog.ScrollToCaret(); // 日志长了自动滚到底部 }用BeginInvoke而不是Invoke是有原因的:Invoke会阻塞调用线程直到 UI 线程处理完,如果此时 UI 线程正好在等这个调用线程(比如在等一个Task.Result),两边互等,直接死锁,界面上表现为整个窗体永久白屏。这个坑在第五章还会再提一次。
ScrollToCaret在日志刷得很快时会有轻微闪烁,如果日志量特别大(每秒几十条),建议改成批量追加:用一个ConcurrentQueue<string>缓冲,配一个 200ms 的Timer统一刷进文本框,性能差别很明显。
5. 避坑排查:这条链路上最容易出错的五处
5.1 症状:点完按钮,窗体白屏十几秒完全点不动
现象:点击查询后整个窗体失去响应,标题栏显示「未响应」,几秒到几十秒后恢复,或者干脆弹异常。
原因:在 UI 线程上同步等待异步任务,典型写法是var s = Http.PostAsync(...).Result;或者.Wait(),或者调用了某个内部用.Result封装的同步方法。await的续体需要回到 UI 线程才能继续,而 UI 线程正卡在.Result上等它,两边互等,死锁。即使侥幸没死锁(比如库内部用了ConfigureAwait(false)),UI 线程也会被阻塞到请求结束,白屏是必然的。
解决:从事件处理器到最底层的方法全部一路async到底,不出现.Result、.Wait()、.GetAwaiter().GetResult()。如果某个第三方库只提供同步 API,用await Task.Run(() => SyncCall())包一层,让它跑在线程池上,别阻塞 UI 线程。
5.2 症状:请求里的中文到服务端变成问号或乱码
现象:本地日志看 JSON 是对的,服务端收到的字段值全是?或者䏿–‡这种。
原因:三个位置都可能出问题。第一,StringContent(json, Encoding.Default, "application/json")用了系统默认编码,中文系统是 GBK,跨平台就对不上。第二,Content-Type里没有charset=utf-8,服务端按自己的默认编码解。第三,读取响应时用了ReadAsStringAsync(),但如果响应头里声明的是charset=gb2312而实际内容是 UTF-8,也会乱。
解决:请求固定用Encoding.UTF8,Content-Type显式写全"application/json; charset=utf-8";响应侧如果服务端不规范,可以读成字节流自己按 UTF-8 解:var bytes = await resp.Content.ReadAsByteArrayAsync(); var text = Encoding.UTF8.GetString(bytes);。另外注意.NET Framework项目里.cs文件本身要存成 UTF-8 with BOM,不然源码里的中文字面量在编译期就已经坏了。
5.3 症状:接口报错,但界面上只有一句「请求失败」
现象:服务端日志明确记了 400 和具体原因,客户端只显示「发生错误」,拿不到任何细节。
原因:调用了resp.EnsureSuccessStatusCode(),它在非 2xx 时抛HttpRequestException,而异常对象的Message只有一句「Response status code does not indicate success: 400」,响应体根本没被读取,服务端返回的错误 JSON 被丢弃了。
解决:不用EnsureSuccessStatusCode,改成手动判断IsSuccessStatusCode,然后把响应体原文读出来一起抛给上层。日志里至少留三样东西:请求 URL、请求体原文、响应状态码 + 响应体原文。这三样凑齐,90% 的对接问题看一眼就能定位,不用再让对面查日志。
5.4 症状:开发机全对,换到现场机器就报「基础连接已关闭」
现象:同一份程序、同一个接口,本机一直成功,装到另一台机器上稳定失败,异常信息是The underlying connection was closed或无法建立 SSL/TLS 的安全通道。
原因:.NET Framework 4.5及更早版本默认只启用 TLS 1.0,而现在绝大多数服务端已经禁用 TLS 1.0,只接受 1.2 及以上。开发机可能因为系统补丁或注册表设置默认开了 1.2,现场机器没有。
解决:在程序启动时(Main方法或窗体构造函数最前面)显式声明协议版本:
// 仅 .NET Framework 4.5~4.7 需要;.NET Core / .NET 5+ 由系统自动协商,不用写 ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11;另外,如果现场是自签名证书,还需要挂一个ServerCertificateValidationCallback,但千万别写成无条件return true,那是一刀切关掉校验,等于把中间人攻击的门开着。正确做法是校验证书指纹是否在白名单里,不在就拒绝。这条在对外交付的工具里必须守住。
5.5 症状:请求发多之后偶发超时、连接被重置
现象:单次测试永远成功,连续点几十次或者批量跑的时候开始零星失败,报SocketException或者TaskCanceledException,重启程序又好了。
原因:主要是两个。一是每次请求都new HttpClient(),连接池不断新建,端口耗尽(TIME_WAIT堆积),系统分配不出新端口。二是DefaultRequestHeaders被反复Add,同名头累加,某些服务端或中间网关会直接拒绝超长或重复的头。
解决:HttpClient做成静态单例,或者至少一个窗体实例对应一个;请求级的信息(token、traceId)放到HttpRequestMessage.Headers上而不是DefaultRequestHeaders;如果确实需要中途换BaseAddress,不要改单例的属性,而是拼接完整 URL 传进PostAsync。另外把HttpClientHandler.MaxConnectionsPerServer适当调大(比如 10),可以缓解批量并发时的排队。
6. 收束成可复用的一层:把请求、校验、日志统一封装进 ApiClient
零散写在窗体事件里的请求代码,第一次能跑,改到第三版就一定开始出现重复和遗漏。我一般会把它们收进一个ApiClient类:静态HttpClient管连接,一个泛型PostAsync<T>管全流程,日志和异常在同一个地方处理。
public sealed class ApiClient { private static readonly HttpClient Http = CreateHttp(); private readonly string _baseUrl; public ApiClient(string baseUrl) => _baseUrl = baseUrl.TrimEnd('/') + "/"; private static HttpClient CreateHttp() { var handler = new HttpClientHandler { AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate, MaxConnectionsPerServer = 10 }; var c = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan }; // 超时交给 CTS c.DefaultRequestHeaders.Accept.ParseAdd("application/json"); return c; } public async Task<T> PostAsync<T>(string path, object payload, CancellationToken ct) { string url = _baseUrl + path.TrimStart('/'); string json = JsonConvert.SerializeObject(payload, JsonOpts); var sw = Stopwatch.StartNew(); try { using (var content = new StringContent(json, Encoding.UTF8, "application/json; charset=utf-8")) using (var resp = await Http.PostAsync(url, content, ct).ConfigureAwait(false)) { string raw = await resp.Content.ReadAsStringAsync().ConfigureAwait(false); var result = Parse<T>(raw, (int)resp.StatusCode); ApiLog.Info($"POST {url} {sw.ElapsedMilliseconds}ms 请求={json} 响应={raw}"); return result; } } catch (Exception ex) { ApiLog.Error($"POST {url} {sw.ElapsedMilliseconds}ms 请求={json} 异常={ex.Message}"); throw; } } // Parse<T> 即 3.3 节那段「状态码 → JSON → 业务码」的四步校验 }参数上我固定了几条规矩,写进团队约定里比写在文档里管用:
| 配置项 | 建议值 | 理由 |
|---|---|---|
| HttpClient.Timeout | InfiniteTimeSpan | 超时由每次请求的 CTS 控制,可单独调整、可中途取消 |
| 单次请求 CTS 超时 | 15~30 秒 | 视接口实际耗时定,超过这个值的接口应该改成异步任务 + 轮询 |
| MaxConnectionsPerServer | 10 | 批量导入场景够用,不至于把服务端打满 |
| AutomaticDecompression | GZip + Deflate | 响应体大时省带宽,几十 KB 的列表接口差别很明显 |
| 日志落盘 | 按天滚动,保留 7 天 | 现场排查主要靠它,超过一周的日志基本没人看 |
| 敏感字段 | 日志里脱敏 | token、手机号、身份证在写日志前先替换掉 |
还有一个验证习惯值得养成:写完之后别只点界面测试。我会在项目里留一个Debug配置下可见的「压测」小按钮,连续发 50 次同一个请求,看成功率、看平均耗时、看内存有没有持续上涨。这个按钮帮我提前发现过两次连接未释放的问题——单次点击永远不会暴露,只有连续请求才看得见。
从那以后,我每次对接新接口,都强制先走一遍这个流程:用 Postman 或 curl 确认报文格式,再在ApiClient里跑通一次,然后压测 50 次,最后才去接 UI。跳过其中任何一步,后面都得花更多时间补回来。希望帮到你。
本文还有配套的精品资源,点击获取