简介:这是一份基于C#与WinForm开发的HTTP接口测试工具配套源码包,面向需要在Windows桌面端调试API的开发者、测试人员及C#学习者,可用于快速发起POST、GET、PUT、DELETE请求并查看接口返回结果。压缩包共41个文件,包含13个cs源码、6个config配置、3个exe可执行文件,以及csproj、sln、resx等工程文件与界面资源,整体约62KB,体积轻便,便于直接运行或导入解决方案研究。已有1450人学习下载,说明其在接口调试与WinForm实践场景中具有一定参考价值。源码注释较为清晰,读者可从中了解HTTP请求方法封装、请求头与请求体设置、响应状态码及内容展示等实现思路,也可借鉴窗体布局、配置读写和工程组织方式,以及请求参数录入与结果核对。对希望理解桌面端HTTP调试流程、进行接口联调辅助、排查常见请求问题或二次开发小型测试工具的读者较为友好,并积累可复用的实现经验。
1. 一个双击就能跑的 exe:C# WinForm 接口请求工具解决的是什么场面
内网机房里只有一台不许装外部软件的业务服务器,接口联调还得继续;或者对面坐着一位不熟命令行的同事,需要他每天点几下就把四个核心接口冒烟一遍。这类场景下,一套用 C# WinForm 写的接口请求工具比任何在线调试客户端都好使——它是单个 exe,拷过去双击就能开,输入 URL、选 GET/POST/PUT/DELETE、填 Header 和 Body,点发送就能看状态码、耗时和格式化后的响应。这份资源覆盖的就是这样一套桌面端接口调用工具,适合现场联调、冒烟回归、给非开发同事做自测入口。
下面按「界面怎么分层、请求怎么发、响应怎么读、坑在哪」四步拆开。
2. 界面与请求引擎分层:WinForm 里别让 HttpClient 跟着窗体一起生灭
WinForm 写接口工具,最省事的写法是在按钮事件里new HttpClient()然后SendAsync,三十行就能跑通,很多人到这里就收工了。但工具一旦被用来连续调十几个接口,问题就开始露头:调用几十次之后突然连不上,重启程序又好了;点发送之后窗体白屏两三秒;超时设了不生效。这些都不是 C# 的脾气问题,是分层没做对。我的血泪经验是,动手写第一行代码之前先把三件事定下来:谁负责收集界面输入、谁负责发请求、异常在哪里兜底。
2.1 三层拆分:窗体只管收集,服务只管发
我一般把这类工具切成三块,代码量都不大,但边界必须清楚。
RequestModel:一次请求的全部要素。方法、URL、Header 字典、Body 类型、超时,全部收在一个对象里。HttpService:全项目唯一发请求的地方,静态类,只有一个SendAsync。MainForm:只做两件事——把控件值读成RequestModel,把ResponseModel写回控件。
这么拆的直接好处是:以后要加批量执行或者命令行模式,HttpService原封不动就能复用,不用跟窗体控件纠缠。
public enum BodyKind { None, FormUrlEncoded, Json, Multipart } public class RequestModel { public string Method { get; set; } = "GET"; public string Url { get; set; } = ""; // 自定义头,键值都是原始字符串,不做任何规范化 public Dictionary<string, string> Headers { get; } = new Dictionary<string, string>(); public BodyKind BodyKind { get; set; } = BodyKind.None; public string BodyText { get; set; } = ""; public Dictionary<string, string> FormFields { get; } = new Dictionary<string, string>(); public List<string> UploadFiles { get; } = new List<string>(); // 超时放在模型上而不是全局常量,导出类接口经常要跑几十秒 public int TimeoutSeconds { get; set; } = 15; }几个参数的选择理由值得说一下。Method用字符串而不是枚举,是因为界面上就是一个 ComboBox,直接取Text最省事,做枚举映射反而多一层转换。BodyText存原始文本而不是反序列化后的对象,因为 JSON 合法性校验应该放在发送前一步单独做,反序列化失败要给用户看具体哪一行错。TimeoutSeconds挂在模型上,是为了让「这个接口要跑一分钟」这种例外能单独设,而不是把所有接口一起放宽。
2.2 HttpClient 的生命周期与 async/await 的正确接法
HttpClient是全进程一个实例,用static readonly字段持有。这一点如果搞反,现象特别有迷惑性:前二十次调用完全正常,之后开始SocketException或者连接超时,关掉程序重开又好了——因为每次new出来的实例在 GC 之前一直占着本地端口,端口耗尽就发不出去了。
public static class HttpService { // 全进程一个实例,不要每次请求 new,也不要 using 包起来 private static readonly HttpClient Client = CreateClient(); private static HttpClient CreateClient() { var handler = new HttpClientHandler { // 服务端开了压缩时不加这句,拿到的可能是乱码二进制 AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate }; var client = new HttpClient(handler) { // 全局超时关掉,改由每次请求自己的 CancellationToken 控制 Timeout = Timeout.InfiniteTimeSpan }; client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "SelfTest/1.0"); return client; } }把HttpClient.Timeout设成无限、改用CancellationTokenSource做单次超时,是有原因的:HttpClient.Timeout一旦设了就是全局的,而工具里不同接口的超时需求差得很远;更要紧的是它抛出来的是TaskCanceledException,跟用户手动取消长得一模一样,排错时分不清到底是超时还是点了取消。
按钮事件这边,规范只有一条:全链路 async,绝不出现.Result或.Wait()。
private async void btnSend_Click(object sender, EventArgs e) { btnSend.Enabled = false; txtResponse.Text = "请求中…"; try { var model = CollectRequest(); // await 之后的代码会自动回到 UI 线程,不需要 Invoke var result = await HttpService.SendAsync(model); RenderResult(result); } catch (Exception ex) { txtResponse.Text = "请求异常:" + ex.Message; } finally { btnSend.Enabled = true; } }async void只在事件处理器里用,这是它唯一被允许出现的位置;方法内部必须用 try/catch 全包住,因为async void里漏出去的异常没有调用方接得住,会直接把进程打挂,用户看到的是程序闪退,连个提示都没有。
2.3 把界面输入收敛成一个模型
Header 输入我用一个多行文本框,约定Key: Value一行一个。解析的时候有个细节:用IndexOf(':')找第一个冒号,不能用Split(':')拿第二段——Header 值里本身可能带冒号,时间戳、完整的回调 URL 都带,Split 之后值会被截断,接口报签名错误你查半天查不出来。
private RequestModel CollectRequest() { var m = new RequestModel { Method = cboMethod.Text.Trim().ToUpperInvariant(), Url = txtUrl.Text.Trim(), BodyKind = (BodyKind)cboBodyKind.SelectedIndex, BodyText = txtBody.Text, TimeoutSeconds = (int)numTimeout.Value }; foreach (var line in txtHeaders.Lines) { if (string.IsNullOrWhiteSpace(line)) continue; int idx = line.IndexOf(':'); if (idx <= 0) continue; // 没有冒号的行跳过,不要让一行错格式毁掉整次发送 m.Headers[line.Substring(0, idx).Trim()] = line.Substring(idx + 1).Trim(); } return m; }这里的取舍是「宽松解析」:宁可静默跳过一行格式不对的 Header,也不要弹一个 MessageBox 把整次发送打断。调试工具的使用节奏是快速试错,被自己的工具拦住会很烦,Header 对不对最终由服务端反馈决定。真要严格,可以在发送前把解析出的 Header 回显到日志区让用户自己核对。
3. 四种请求方法的落地:GET 拼串、POST 分体、PUT 与 DELETE 的边界
四种方法在HttpClient眼里其实是同一个 API,区别全在 URL 和 Content 的组装方式上。很多自己写的工具最后只敢用 POST,就是因为 GET 的中文参数和 PUT 的 body 处理没搞明白,一遇到 400 就退回去改 POST。这一章把四条路的组装规则和各自的边界讲清楚。
3.1 GET:QueryString 拼接与 URL 编码
GET 的参数必须编码。不编码的典型症状是:URL 里带中文,服务端返回 400 或者收到的参数只剩下第一个字;URL 里带&的值,直接被截成两段。
public static string BuildUrl(string baseUrl, IDictionary<string, string> query) { if (query == null || query.Count == 0) return baseUrl; var sb = new StringBuilder(baseUrl); sb.Append(baseUrl.Contains("?") ? "&" : "?"); bool first = true; foreach (var kv in query) { if (!first) sb.Append('&'); first = false; sb.Append(Uri.EscapeDataString(kv.Key)) .Append('=') .Append(Uri.EscapeDataString(kv.Value ?? string.Empty)); } return sb.ToString(); }编码函数选Uri.EscapeDataString,不用老项目里常见的HttpUtility.UrlEncode。区别在空格:前者编成%20,后者编成+。+在 query string 里被解码成空格是合法的,但如果这段值被拼进 path 段,+就是字面的加号,服务端拿到的参数会莫名多一个加号。用%20两边都不会错。
判断分隔符用baseUrl.Contains("?")而不是无脑拼?,是因为要调到手的 URL 经常已经带了?a=1这种固定参数,再拼一个?就变成两个问号,后面的参数全是路径的一部分。
GET 还有一个边界:不要往 GET 上挂 body。HttpRequestMessage允许你这么干,但一部分服务端框架和网关会直接丢弃,另一部分会返回 400,而且这种行为在不同环境下还不一致,非常玄学。要在 GET 上传复杂结构,正确做法是压缩成 JSON 再 URL 编码塞进一个 query 参数。
3.2 POST:三种 Content 的选择与构造
POST 的复杂度全在 Content 上。工具里我给三种选择:表单编码、JSON、multipart 文件上传。它们对应三种完全不同的构造方式,混用就会翻车。
private static HttpContent BuildBody(RequestModel m) { switch (m.BodyKind) { case BodyKind.FormUrlEncoded: // 内部自动做 URL 编码,中文不用手动处理 return new FormUrlEncodedContent(m.FormFields); case BodyKind.Json: // new UTF8Encoding(false) 不带 BOM return new StringContent(m.BodyText, new UTF8Encoding(false), "application/json"); case BodyKind.Multipart: var mp = new MultipartFormDataContent(); foreach (var kv in m.FormFields) mp.Add(new StringContent(kv.Value, Encoding.UTF8), kv.Key); foreach (var path in m.UploadFiles) { var file = new ByteArrayContent(File.ReadAllBytes(path)); file.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); // 第三个参数是文件名,第二个是服务端约定的字段名,两者别搞反 mp.Add(file, "file", Path.GetFileName(path)); } return mp; default: return null; } }三个参数值得单独点出来。new UTF8Encoding(false)里的false表示不写 BOM,这个坑极其隐蔽:带 BOM 的 JSON 在很多服务端上解析直接失败,但你在工具里看请求体,肉眼看过去完全正常,多出来的EF BB BF三个字节是看不见的。FormUrlEncodedContent的构造参数是Dictionary,中文和特殊字符它自己会编码,手动再编一次反而会导致服务端拿到双重编码的值。multipart 的Add(content, name, fileName)里,name必须和服务端约定的字段名一致,写错的表现是「文件收到了但业务字段全是空」。
3.3 PUT / DELETE:不是「换个动词」那么简单
HttpMethod可以直接从字符串构造,所以四种方法能共用一个发送入口,不用写四份 if。
public static async Task<ResponseModel> SendAsync(RequestModel m) { var method = new HttpMethod(m.Method); // GET/POST/PUT/DELETE 统一走这里 using (var req = new HttpRequestMessage(method, m.Url)) { if (method != HttpMethod.Get && m.BodyKind != BodyKind.None) req.Content = BuildBody(m); foreach (var h in m.Headers) { // 内容头(Content-Type、Content-Length 等)挂到 request 上会抛异常 if (!req.Headers.TryAddWithoutValidation(h.Key, h.Value)) req.Content?.Headers.TryAddWithoutValidation(h.Key, h.Value); } using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(m.TimeoutSeconds))) { var sw = Stopwatch.StartNew(); using (var resp = await Client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, cts.Token)) { var body = await ReadBodyAsync(resp); // 见第 4 章 sw.Stop(); return new ResponseModel { StatusCode = (int)resp.StatusCode, ReasonPhrase = resp.ReasonPhrase, ElapsedMs = sw.ElapsedMilliseconds, Body = body, SizeBytes = resp.Content.Headers.ContentLength ?? 0 }; } } } }PUT 和 DELETE 的两个真实边界,这里必须写清楚。
第一,DELETE 带 body 在规范上不禁止,但一部分服务端框架默认不解析 DELETE 的请求体,表现是「参数永远是 null,日志里也看不到报错」。遇上这种,改成 query 传参就能通,或者让服务端改成从 body 读。不要花时间去怀疑自己的序列化代码。
第二,PUT 不带 Content 时,一部分网关直接回 411。所以代码里那个m.BodyKind != BodyKind.None的判断很有必要:界面上把 Body 类型选成 None 又选了 PUT,很可能是用户忘了切,这时候让它报一个清晰的错,比让网关回 411 更容易定位。
四种方法和 Body 的对应关系,我一般在工具里做成一张提示表挂在 Body 区旁边:
| 方法 | 是否允许 Content | 推荐 Body 类型 | 常见误用 |
|---|---|---|---|
| GET | 不建议 | 无 | 把查询条件塞进 body,服务端收不到 |
| POST | 允许 | JSON / 表单 / multipart | 中文没编码,或 JSON 带 BOM |
| PUT | 通常需要 | JSON | 忘选 Body,网关回 411 |
| DELETE | 允许但常被忽略 | 无,改走 query | body 传参永远是 null |
4. 响应侧:状态码、耗时、编码三件事决定这工具好不好用
请求发出去只是前半程,工具好不好用,一半看响应区。做过现场联调的人都知道,最耗时间的不是写请求,是拿到一坨没有格式化的返回以后猜服务端到底想说什么。响应区要解决三件事:这次到底成功了没有、慢在哪、中文为什么是乱码。
4.1 统一响应模型与 Stopwatch 计时
ResponseModel里最关键的设计是把「网络层失败」和「HTTP 层失败」分成两个字段。
public class ResponseModel { public int StatusCode { get; set; } // HTTP 层结果,200 / 404 / 500 public string ReasonPhrase { get; set; } public long ElapsedMs { get; set; } // 从发出到读完 body 的总耗时 public long SizeBytes { get; set; } public string Body { get; set; } public string Error { get; set; } // 网络层失败,DNS / 连不上 / 超时 }分成两个字段的理由很实际:排错时最怕看到一屏红字不知道从哪下手。Error有值说明请求根本没到服务端,去查地址、端口、网络;StatusCode是 4xx/5xx 说明服务端收到了但拒绝了,去看响应体和后
本文还有配套的精品资源,点击获取