简介:一套用 C# WinForm 编写的 HTTP 接口测试桌面工具,面向需要调试 Web API 的后端与客户端开发者,可在 Windows 环境下快速发起 GET、POST、PUT、DELETE 四类请求,验证接口返回的状态码、响应头与响应体。压缩包共 41 个文件、约 62KB,以 13 个 .cs 源文件为核心,并配有 6 个 config 配置、2 个 csproj 项目文件与 sln 解决方案、3 个 exe 可执行程序,以及 resx、resources 等界面资源;源码和编译产物齐全,用 Visual Studio 打开即可阅读或二次修改。目前已有约 1450 人学习下载。代码注释较为清晰,主窗体与程序入口等模块划分直观;适合对照理解 WinForm 界面与 HTTP 通信的结合方式,也可用于日常接口联调,兼顾实用性与学习参考价值。
1. 一个 C# WinForms 接口测试工具:为什么它比网页版更值得留在桌面
接口调试这件事,很多人的第一反应是打开在线工具或者浏览器插件,粘 URL、点发送、看返回。临时验证一个 GET 接口确实够快,但只要遇到自定义 Header、多环境切换、一组参数反复复用的场景,网页工具的割裂感就冒出来了:数据在浏览器里,代码在编辑器里,两边来回贴,还经常忘了刚才那组 token 从哪来。手头这份 C# WinForms 写的接口测试工具,做的事情很直接——把 GET、POST、PUT、DELETE 四类请求的构造、发送、响应解析收进一个本地窗体程序,URL、请求头、请求体、状态码、返回正文在同一个界面里完成查看。它适合正在做后端联调的人,也适合刚接触 C# 网络编程、想找一份能真正跑起来的 HttpClient 样例源码来拆的读者。
2. 用 HttpClient 打通 GET/POST/PUT/DELETE:四类请求的构造与响应解析
2.1 选 HttpClient 而不是 HttpWebRequest 的理由
老一点的 C# 网络代码常用 HttpWebRequest,这个类功能全,但写法啰嗦:每个请求都要 new 一个实例,请求流、响应流要自己开自己关,异常路径上漏关流就是连接泄漏。HttpClient 从 .NET 4.5 起就是官方推荐的 HTTP 客户端,它把请求头组织、请求体写入、响应读取收敛到一套异步 API 上,同样的功能代码量能少一半。这份工具的思路也是这样:窗体负责收集用户输入,一个 ApiClient 类负责把输入翻译成 HttpRequestMessage,交给 HttpClient 发送。
有一个细节必须提前说清楚:HttpClient 实例要复用,不要每次点发送都 new 一个。每 new 一个就开一套底层连接池,短时间高频点击会把本地端口耗尽,典型报错是 "Only one usage of each socket address is normally permitted"。常见做法是工具启动时创建一个静态 HttpClient,整个进程生命周期共用,超时、默认请求头这些配置一次性设好。
// 工具启动时初始化,整个进程只创建一次 private static readonly HttpClient _client = new HttpClient(new HttpClientHandler { // 允许自动维护 Cookie,联调带会话的接口时用得上 UseCookies = true, // 重定向关掉,让原始响应暴露出来,方便排查 3xx AllowAutoRedirect = false }) { // 兜底超时给 30 秒,单次请求还可以用 CancellationToken 再收紧 Timeout = TimeSpan.FromSeconds(30) }; // 默认请求头:UA 和 Accept,避免某些网关对空 UA 直接回 403 _client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "ApiTester/1.0"); _client.DefaultRequestHeaders.TryAddWithoutValidation("Accept", "application/json, text/plain, */*");逻辑说明:HttpClientHandler 里 UseCookies 打开后,同一个实例会自动维护 Cookie 容器,登录接口返回的 Set-Cookie 会被后续请求带上;AllowAutoRedirect 关掉,是因为联调时 302 跳转后的最终地址往往不是你要看的那个目标,把 302 的 Location 原样展示出来更利于判断。Timeout 是进程级兜底,请求级取消靠 CancellationToken,两者不冲突,谁先触发算谁。
参数说明:Timeout 别设得太短,联调环境的接口冷启动拖到十几秒并不少见,设 3 秒会把正常请求误判成失败;UA 一定要写,有些网关对空 UA 直接拒绝,这个坑后文还会提。Accept 加上后服务端做内容协商会更配合,返回 JSON 的概率明显高于返回一段 HTML 错误页。
2.2 四类请求方法收敛到一个发送入口
窗体的方法下拉框里只有四个选项,但四个方法在 HTTP 语义上的差别要落到代码里:GET 取资源、POST 提交、PUT 全量替换、DELETE 删除。工具层面最省事的做法是让 HttpMethod 作为一个参数传进去,而不是写四个几乎一样的方法。这样请求头处理、超时处理、错误处理只写一遍,维护成本降下来。
public async Task<ApiResult> SendAsync( string url, string method, // "GET" / "POST" / "PUT" / "DELETE" Dictionary<string, string> headers, string body, string contentType, CancellationToken token) { using var request = new HttpRequestMessage(new HttpMethod(method.ToUpperInvariant()), url); foreach (var kv in headers) { // TryAddWithoutValidation 能写入非标准头,联调时经常要加自定义鉴权头 request.Headers.TryAddWithoutValidation(kv.Key, kv.Value); } // GET 与 DELETE 按语义不带请求体,带了部分服务端直接回 400 if (method.Equals("POST", StringComparison.OrdinalIgnoreCase) || method.Equals("PUT", StringComparison.OrdinalIgnoreCase)) { request.Content = new StringContent(body ?? string.Empty, Encoding.UTF8); request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(string.IsNullOrWhiteSpace(contentType) ? "application/json" : contentType); } using var response = await _client.SendAsync(request, HttpCompletionOption.ResponseContentRead, token); var text = await response.Content.ReadAsStringAsync(); return new ApiResult { StatusCode = (int)response.StatusCode, ReasonPhrase = response.ReasonPhrase, ContentType = response.Content.Headers.ContentType?.ToString(), Headers = response.Headers .Concat(response.Content.Headers) .ToDictionary(h => h.Key, h => string.Join("; ", h.Value)), Body = text }; }逻辑说明:一个入口吃四种方法,窗体的下拉框只负责把字符串传进来,接口语义由 HttpMethod 承担。请求头分两段写——自定义头挂在 request.Headers 上,内容相关的头(Content-Type、Content-Length)必须挂在 request.Content.Headers 上,写混了会抛 InvalidOperationException,这是新手最容易翻车的一个点。
参数说明:contentType 允许用户在下拉框里改成 application/x-www-form-urlencoded 或 text/xml,默认给 application/json;token 从窗体层传进来,让取消按钮真的能中断请求,而不只是把按钮文字改掉。headers 用字典传,重复键在窗体层就要先拦掉,否则后写的静默覆盖先写的,排查起来很费劲。
提示:PUT 与 POST 的差别在服务端实现上,不在客户端。客户端写法可以完全一致,但联调 PUT 时务必确认服务端是全量替换语义,否则漏传字段会被整段清空。
2.3 请求体的三种形态:JSON、表单、原始文本
联调时请求体不是只有 JSON 一种。后台管理系统里表单提交还大量存在,对接老系统时 XML 也躲不开,所以工具通常会在请求体输入框旁边放一个 Content-Type 下拉框,让用户自己决定这段文本该怎么解释。
JSON 是最常见的,也是最容易出问题的:用户手敲的 JSON 少个引号、多个逗号,客户端走 StringContent 直接透传时不报错,错误要等服务端返回 400 才暴露。合格的做法是在发送前做一次语法校验,用 JsonDocument.Parse 试解析一遍,失败就在界面上标红,省得用户拿着 400 去猜。
表单形态是要拼 key=value 再用 & 连接,而且值必须做 URL 编码。如果用户把整段a=1&b=2直接粘进请求体,又选了 form-urlencoded,比较稳妥的做法是用 FormUrlEncodedContent 让库帮忙编码,或者自己调 Uri.EscapeDataString 逐段处理。中文和特殊字符在表单里不编码,服务端解析出来就是乱码。
// 发送前按 Content-Type 分流处理请求体 private HttpContent BuildContent(string body, string contentType) { if (string.Equals(contentType, "application/x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)) { // 把 "a=1&b=2" 解析成键值对,交给库做百分号编码 var pairs = body.Split('&') .Select(p => p.Split(new[] { '=' }, 2)) .Where(p => p.Length == 2) .Select(p => new KeyValuePair<string, string>( Uri.UnescapeDataString(p[0]), Uri.UnescapeDataString(p[1]))); return new FormUrlEncodedContent(pairs); } if (string.Equals(contentType, "application/json", StringComparison.OrdinalIgnoreCase)) { // JSON 先校验语法再发,避免把语法错误推给服务端 using var _ = JsonDocument.Parse(body); // 解析失败抛 JsonException,由上层捕获提示 } // 其它类型一律透传,xml、text、二进制的文本形式都走这里 var content = new StringContent(body ?? string.Empty, Encoding.UTF8); content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType); return content; }逻辑说明:三种形态对应三条路径,表单走 FormUrlEncodedContent 自动编码,JSON 做一次性语法校验后透传,其它类型按用户声明的 Content-Type 直接发。校验 JSON 用 JsonDocument.Parse 而不是反序列化成具体对象,是因为调试工具不该假设响应结构,只校验语法就够了。
参数说明:Content-Type 最好带上 charset=utf-8,尤其是 text/plain 和 application/xml 这两个类型,不带 charset 时不少 Java 后端会按 ISO-8859-1 解码,中文直接变问号。JSON 按标准默认就是 UTF-8,可以不带,但带上也无害。表单类型不要自作主张加 charset,有些框架对这个参数的处理不一致。
2.4 响应拆解:状态码、响应头和正文分开呈现
响应回来后,界面上至少要把三块内容拆开:状态码加原因短语、响应头、响应正文。全塞进一个文本框是省事,但排查问题时很痛苦——想看一眼 Set-Cookie 有没有下发,得在几百行正文里翻。
状态码判断按区段来:2xx 成功,3xx 需要看 Location,4xx 是客户端问题(参数、鉴权),5xx 是服务端问题。工具不需要替用户下结论,但可以把状态码按区间上色,200 段绿色、400 段黄色、500 段红色。连续点十几个接口时,这个视觉提示能省掉不少盯数字的功夫。
响应头里几个高频关注点:Content-Type 决定正文怎么解析,Content-Length 决定是不是被截断,Set-Cookie 决定会话有没有建立,Location 决定重定向去哪,X-Request-Id 之类的链路头在排查线上问题时是唯一的串联线索。正文展示则要看 Content-Type:application/json 就缩进格式化,text/html 就原样输出,二进制就别硬转字符串。
这里有个容易忽略的点:响应流读取要用 ReadAsStringAsync,不要自己开流再读字节。前者内部处理了编码声明,后者要自己判断 charset,判断错了就是乱码。如果响应体很大(比如导出接口返回几 MB 的 CSV),还要注意别把整段内容直接塞给 RichTextBox,第 4 章会专门说这个问题。
3. WinForms 与网络线程的协作:async/await 加 Invoke 的三段式写法
3.1 同步阻塞为什么会把窗体的消息循环卡住
WinForms 是单线程 UI 模型,所有控件操作都在 UI 线程上跑,UI 线程同时还负责消息循环——鼠标点击、重绘、键盘输入都靠它。如果点发送按钮时用的是client.SendAsync(...).Result或者.Wait(),UI 线程就被阻塞在等待结果上,消息循环停摆,表现就是窗体变白、点不动、拖不动,任务管理器里显示"未响应"。请求本身可能只花 200 毫秒,但用户感知是程序死了。
更隐蔽的是死锁:在 UI 线程上同步等待一个内部用 await 且捕获了 SynchronizationContext 的任务,等的一方在 UI 线程,续体又要回 UI 线程执行,两边互等,直接卡死。这个坑在 WinForms 里踩一次就够记很久。正确的做法是把发送方法写成 async,按钮事件处理用 async void(这是事件处理器唯一可以接受 async void 的地方),await 之后 WinForms 的 SynchronizationContext 会自动把续体调度回 UI 线程,紧接着更新控件是安全的。
注意:async void 只用于事件处理器。自己封装的业务方法一律用 async Task,否则调用方无法 await、无法捕获异常,异常会直接抛到未处理异常处理器上,程序当场退出。
3.2 从点击到渲染:一个能跑的事件处理模板
把"点发送 → 禁用按钮 → 发请求 → 渲染结果 → 恢复按钮"这条链路写成模板,比每次凭感觉写代码可靠得多。关键在于按钮状态要和请求生命周期绑定,用户连点两次的时候,第二次点击要么被忽略,要么取消前一次,不能两个请求同时往里写。
private CancellationTokenSource _cts; private async void btnSend_Click(object sender, EventArgs e) { // 上一次请求还没结束时,先取消,避免两次结果互相覆盖 _cts?.Cancel(); _cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); btnSend.Enabled = false; txtResponse.Text = "请求发送中..."; try { var result = await _api.SendAsync( txtUrl.Text.Trim(), cmbMethod.Text, CollectHeaders(), txtBody.Text, cmbContentType.Text, _cts.Token); // await 之后已经回到 UI 线程,可以直接操作控件 RenderResponse(result); } catch (OperationCanceledException) { txtResponse.Text = "请求超时或被取消"; } catch (HttpRequestException ex) { txtResponse.Text = "网络异常:" + ex.Message; } finally { btnSend.Enabled = true; // 无论成功失败,按钮都要恢复 } }逻辑说明:CancellationTokenSource 在每次点击时重建,保证超时从本次点击开始算;await 之后的续体会被 WinForms 的 SynchronizationContext 调度回 UI 线程,所以 RenderResponse 里操作控件不会抛跨线程异常,这一点比 WPF 的 Dispatcher 模型要省心。finally 块里恢复按钮状态,是保证异常路径下界面不会永久卡在禁用态。
参数说明:CancellationTokenSource 的构造参数接受 TimeSpan,也可以无参构造后调 CancelAfter;HttpRequestException 捕捉的是网络层异常,DNS 解析失败、连接被拒、TLS 握手失败都会落在这里;如果服务端接口慢但没超时,用户点了取消,走的是 OperationCanceledException 分支。
3.3 超时与取消:CancellationTokenSource 的正确用法
超时和取消在实现上是同一套机制,CancellationTokenSource 触发后,正在进行的 SendAsync 会抛 OperationCanceledException,底层的 TCP 连接会被释放。这一点很关键:很多人以为取消只是把界面上的"发送中"改掉,其实请求还在后台跑,结果回来时还会写进已经换了状态的界面,出现串数据的现象。
常见做法是给一次请求配两个取消源:一个进程级兜底超时(HttpClient 的 Timeout),一个用户可主动触发的取消(取消按钮绑定 _cts.Cancel)。两者可以用 CreateLinkedTokenSource 合并,合并后任意一个触发都会中断请求。
// 把"用户点取消"和"30 秒兜底超时"合并成一个 token private CancellationTokenSource BuildLinkedSource() { var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); var manual = new CancellationTokenSource(); // 取消按钮点击时触发 manual btnCancel.Click += (s, e) => manual.Cancel(); // 任一源触发,合并源都取消 return CancellationTokenSource.CreateLinkedTokenSource(timeout.Token, manual.Token); }逻辑说明:CreateLinkedTokenSource 把多个 token 合成一个,任一方触发都生效。这样用户点取消能立刻中断,用户不点的时候 30 秒到点自动中断,两条路径都收敛到同一个 OperationCanceledException,上层只处理一种异常即可。
参数说明:linked source 本身也要 Dispose,否则内部注册的回调会一直挂着,长时间运行的工具里会累积。取消按钮建议做成"第一次点取消,第二次点恢复"这样的状态切换,避免用户误点后再也没法发请求。
3.4 响应面板的呈现与富文本处理
响应正文的展示方式直接影响排查效率。JSON 响应建议做缩进格式化再显示,用户一眼能看到层级;如果响应体小于一定阈值(比如 512 KB)就直接格式化,超过阈值先问用户"要不要格式化",因为格式化大 JSON 本身也要几百毫秒,界面会顿一下。
响应头用两列表格展示比用文本框拼字符串更好:左边键、右边值,值太长可以换行或省略。如果用户在响应头里点某个键,工具可以顺手把这个键值复制到剪贴板,这是个小功能,但连续调试同一批接口时非常好用。
还有一个细节是响应的颜色标记。状态码 2xx 用绿色前景,4xx 用深黄,5xx 用红色,用户眼睛扫过去就知道这次请求是不是出了问题,不用再去找三位数。正文里的 JSON 如果解析成功,键和值可以分色显示,这种富文本呈现其实只是给 RichTextBox 设 SelectionColor,实现成本很低。
4. 接口调试工具的避坑清单:五类翻车场景的现象、原因与解法
4.1 中文乱码:编码声明与实际字节对不上
现象:请求体里明明写的是中文,服务端收到的却是问号或乱码;或者响应里中文正常,但复制到 Excel 后变成乱码。
原因:这类问题九成出在 charset 上。请求侧如果 Content-Type 是 text/plain 或 application/xml 但不带 charset,很多服务端按 ISO-8859-1 解码;响应侧如果服务端只回了 Content-Type 没回 charset,而客户端自己按 UTF-8 转字符串,也会错。编码声明和实际字节对不上,乱码就出现了。
解决:请求侧固定用 StringContent(body, Encoding.UTF8) 并在 Content-Type 里显式带 charset=utf-8;响应侧不要自己读字节再猜编码,用 ReadAsStringAsync 让库按响应头里的 charset 处理。如果响应确实不带 charset 且内容是中文,给用户一个手动指定编码的下拉框,默认 UTF-8,可切 GB2312,这是最实用的兜底。
4.2 HTTPS 握手失败:自签名证书怎么处理
现象:请求内网测试环境的 https 地址时抛出 HttpRequestException,内部信息是 "The SSL connection could not be established",浏览器里打开同一个地址会提示证书不受信任。
原因:HttpClient 默认做完整的证书链校验,自签名证书、过期证书、域名不匹配都会让握手失败。内网测试环境用自签证书是常态,和线上环境不是一回事。
解决:调试工具可以提供一个"忽略证书错误"开关,打开时在 HttpClientHandler 上挂 ServerCertificateCustomValidationCallback 直接返回 true。这个开关默认关闭,并且界面上要有明显标识,避免有人把它带到生产联调里。更稳妥的做法是只放行指定的指纹,而不是全部放行,后者在多人共用的工具里风险太大。
4.3 Content-Type 缺省或拼错,服务端不认
现象:POST 请求发出去了,服务端返回 415 Unsupported Media Type,或者干脆回 400 并提示"请求体格式不正确"。
原因:StringContent 不显式设置 Content-Type 时,默认给的是 text/plain; charset=utf-8,很多后端只接受 application/json,直接拒绝。另一种情况是用户手敲了 "Application/JSON" 这种大小写混拼,个别严格的框架不认。
解决:窗体上的 Content-Type 下拉框要给标准值,不要用纯文本框让用户自由输入;如果保留自由输入,发送前做一次规范化(ToLowerInvariant 后对照白名单),不匹配就提示。默认值给 application/json 最合适,国内大部分新接口都是 JSON。
4.4 大响应把界面拖死
现象:调一个导出接口,点发送后界面卡住十几秒,最后弹出来一大段文本,滚动条拖一下都卡。
原因:一次性把几 MB 的字符串塞进 RichTextBox,控件要重新排版、重新计算滚动区域,性能极差;如果还在 UI 线程上做 JSON 格式化,卡顿会更明显。
解决:超过 1 MB 的响应只显示前 200 KB,配一个"另存为文件"按钮让用户存下来慢慢看;格式化操作放到 Task.Run 里跑,格式化完再回 UI 线程显示。这个问题在调试导出类接口时几乎必现,提前处理比事后返工划算。
4.5 重定向和 Cookie 的隐性行为
现象:登录接口返回 200,但紧接着调业务接口仍然提示未登录;或者一个接口在工具里返回 302,把 302 的响应体当成业务结果去解析,得到一堆 HTML。
原因:前者多半是 Cookie 没被带上——如果工具每次请求都 new 一个 HttpClient,Cookie 容器每次都是空的,登录拿到的会话在下一次请求就丢了。后者是自动重定向没关,客户端把 302 跟到了登录页,最终返回的是登录页 HTML,状态码是 200,但内容完全不是用户想要的。
解决:HttpClient 单例复用解决 Cookie 问题;AllowAutoRedirect 设为 false,让 3xx 的 Location 原样暴露,用户自己决定要不要跟。工具界面上可以显示一个 Cookie 面板,把当前容器里的 Cookie 列出来,调试会话类接口时非常直观。
5. 从单次调试到用例保存:把请求配置沉淀成 Json 文件
单次调通不算什么,真正省时间的是把常用的请求参数存下来,下次打开工具直接点。这份工具如果只做到"能发请求",它和一个简化版在线工具差别不大;把请求配置持久化成本地 Json 文件,才算把日常联调里那些重复劳动干掉。
我一般会在工具里加一个"保存当前请求"的按钮,把 URL、方法、请求头、请求体、Content-Type 这几个字段序列化成一个对象列表,落到程序目录下的 requests.json。字段设计不用复杂,够用就行:
public class SavedRequest { public string Name { get; set; } // 用例名,用户自己填 public string Url { get; set; } public string Method { get; set; } public Dictionary<string, string> Headers { get; set; } public string Body { get; set; } public string ContentType { get; set; } } // 保存:WriteIndented 便于人工排查和版本对比 var options = new JsonSerializerOptions { WriteIndented = true }; var json = JsonSerializer.Serialize(_savedList, options); File.WriteAllText(Path.Combine(AppContext.BaseDirectory, "requests.json"), json, Encoding.UTF8);字段含义看一眼就明白,关键是几个工程细节:用 AppContext.BaseDirectory 而不是当前工作目录,避免从快捷方式启动时文件跑到别的地方;序列化时开 WriteIndented,方便用户自己打开文件对比两次请求的差异,也方便用版本管理工具跟踪;反序列化时如果文件损坏就回退成空列表并提示,不要让工具直接崩在启动阶段。
读取逻辑比保存多一道校验:文件不存在就当空列表,文件存在但解析失败就备份成 requests.json.bak 再重建,保证用户之前存的用例不会因为一次序列化异常全部丢掉。这个习惯是被坑出来的——早期版本直接在读取时抛异常,用户一打开工具就闪退,而那个文件里存着他攒了两周的接口用例。从那以后我每次做配置持久化,都强制走一遍"读失败降级 + 备份"的流程,宁可丢一次改动,也不能丢历史数据。
最后补一句验证方法:保存一个 POST JSON 请求,把工具关掉重开,用例列表里能点出来、请求头没丢、body 里中文没变乱码,算是这份持久化逻辑过关了。希望帮到你。
本文还有配套的精品资源,点击获取