简介:面向C#开发者的WinForm接口测试小工具,支持POST、GET、PUT、DELETE四种HTTP请求,可自由切换请求方式,填写URL、请求头及请求体数据,快速查看状态码和响应内容,适合日常接口联调与自测。压缩包共41个文件、约62KB,包含13个cs源码文件、多个config配置文件、生成后的exe和pdb调试文件等,既有完整工程,也可直接运行体验,已有1441人学习下载。工具代码注释清晰,从界面布局到请求发送逻辑均有说明,便于初学者理解HTTP协议和WinForm开发流程;针对POST、PUT请求,可输入JSON或表单格式的数据,并支持自定义Content-Type、Authorization等请求头,模拟真实调用场景。对需要快速验证API返回结果的开发者而言,这也是一个体积小巧、开箱即用的桌面实用程序。
1. 为什么放着现成的Postman不用,偏要自己写一个
先交代一下背景。我平时做C#上位机开发,经常要跟设备端的HTTP服务打交道,比如给工控设备下发配置、从视觉检测系统拉取结果、或者调一下内部平台的开放接口。最开始我也用Postman,后来在实际项目中越来越别扭,主要卡在这几个地方:
- 数据格式不通用:上位机这边走的大多是字节流或者经过加密签名的报文,Postman里拼起来很费劲,而且自带的脚本环境调试C#风格的签名算法(尤其是MD5加盐、AES加密这类)很不顺手。
- 内网环境受限:部分客户的工控网是物理隔离的,Postman装不了、也用不了,但测试联调又必须做。
- 某些平台需要长连接和自定义Header:比如Keep-Alive、Token动态刷新、客户端证书,Postman虽然能配,但每次换一台电脑、换一个项目,环境配置就得重新搞一遍。
- 和自己系统的对接问题:我要的是能在自己的工具框架里直接调用,把结果集成为断言、Excel用例管理的一部分,而Postman的数据导来导去太折腾。
所以我动了写一个C#版接口测试工具的念头。它不需要界面多花哨,核心就是干净利落地支持POST、GET、PUT、DELETE四种请求,能自由填Header、Body、参数,能看响应状态码、耗时、返回内容,最好还能把用例保存成文件,下次直接加载。这篇就把我的完整思路、关键代码、踩坑过程写出来,给同样有需求的兄弟一个参考。
2. 工具的基本架构设计:从需求到类划分
动手前我先把需求拆明白,不然写着写着容易失控。这个工具说白了就三层:请求配置层、请求执行层、结果展示层。其中请求配置层解决"我要发什么"的问题,请求执行层解决"怎么发出去"的问题,结果展示层解决"返回了什么、对不对"的问题。
2.1 请求配置模型:一个RequestModel搞定所有请求
为了同时支持四种请求方法,我定义了一个统一的请求模型,核心字段如下:
public class RequestModel { public string Method { get; set; } = "GET"; // GET/POST/PUT/DELETE public string Url { get; set; } public Dictionary<string, string> Headers { get; set; } = new(); public Dictionary<string, string> QueryParams { get; set; } = new(); public string Body { get; set; } public string ContentType { get; set; } = "application/json"; public int Timeout { get; set; } = 30; // 秒 }这里有个小细节容易忽略:QueryParams和Body是分开的。很多人刚接触接口测试,会把参数一股脑塞进URL里,在处理动态签名、动态Token时很痛苦。分开之后,我可以先拼Query,再做签名,最后决定Body的格式,每一步都清晰可控。
2.2 执行器的选择:为什么用HttpClient而不是WebClient或RestSharp
早期版本我试过WebClient,因为它写起来最简单,两三行就能发一个GET请求。但后来又发现两个硬伤:
- WebClient在.NET Core / .NET 5+里已经标记为过时(Obsolete),维护状态一般,新项目不推荐。
- WebClient对超时控制、取消Token、HTTP版本协商、连接复用的支持都比较弱,在做并发请求或者长耗时接口测试时非常难受。
RestSharp我也用过,封装确实舒服,但项目对第三方依赖有要求,而且我只需要最基础的四个方法,引入整个库略重。
最后选了原生HttpClient。它支持连接复用,可以设置超时、添加自定义Header、异步发送,还能配合HttpClientFactory做生命周期管理。虽然没有RestSharp那种一句client.GetAsync(url)的极简体验,但自己封装一层之后,灵活性完全不输。
2.3 同步还是异步:控制台工具里我推荐异步为主
我这个工具是控制台项目,运行在Windows上做联调验证。有些朋友图省事,全程用.Result或.Wait()把异步方法同步化,短时间跑没问题,但一旦遇到超时、网络抖动,或者以后要把工具界面化,会埋下死锁的隐患。我在设计时就统一用async Task,只在Main入口用GetAwaiter().GetResult()兜底。
当然,如果只是纯工具脚本,不涉及UI线程上下文,Wait()其实也能跑,但养成异步的习惯对后面扩展Windows窗体、WPF版本很重要。
3. 四大请求方法的实现与细节差异
这是整个工具的核心部分。每种请求方法的实现逻辑不完全一样,尤其是参数传递方式、Header默认值、Body格式,我会逐个说明。
3.1 GET:参数拼接与URL编码的坑
GET请求相对简单,参数都放在URL的Query String里。但这里有第一个坑:中文和特殊字符必须编码。我最初直接拼接字符串,遇到?name=张三这种请求,服务端经常解析乱码。后来统一用Uri.EscapeDataString做编码。
public static async Task<string> SendGetAsync(RequestModel model) { using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(model.Timeout) }; // 拼接Query参数 var url = model.Url; if (model.QueryParams.Count > 0) { var queryParts = model.QueryParams .Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}"); url += (url.Contains("?") ? "&" : "?") + string.Join("&", queryParts); } // 添加自定义Header foreach (var header in model.Headers) { client.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value); } var response = await client.GetAsync(url); var content = await response.Content.ReadAsStringAsync(); return $"Status: {(int)response.StatusCode} {response.ReasonPhrase}\n{content}"; }注意TryAddWithoutValidation这个API。有些Header(比如Content-Length、Host)是不允许通过DefaultRequestHeaders添加的,直接用Add会抛异常。TryAddWithoutValidation容忍常见的校验,遇到不允许的它会静默跳过,适合测试工具的灵活场景。
提示:GET请求虽然没有Body,但有些偏门服务端支持GET带Body(例如某些搜索接口)。真实项目里遇到这种情况,建议改用POST或PUT,不要硬刚,否则代理服务器和网关那一层很容易直接把Body丢掉。
3.2 POST:Content-Type决定服务端怎么解析
POST是接口测试里用得最多的方法,也是Config最多的一个。POST请求的Body格式五花八门,最典型的三类:
| Content-Type | Body格式 | 适用场景 |
|---|---|---|
| application/json | {"key":"value"} | RESTful API,现代Web服务最常用 |
| application/x-www-form-urlencoded | key1=value1&key2=value2 | 传统表单提交,登录接口常见 |
| multipart/form-data | 二进制分块 | 文件上传、混合字段 |
实现POST核心代码:
public static async Task<string> SendPostAsync(RequestModel model) { using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(model.Timeout) }; foreach (var header in model.Headers) { client.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value); } HttpContent content = model.ContentType switch { "application/json" => new StringContent(model.Body, Encoding.UTF8, "application/json"), "application/x-www-form-urlencoded" => new FormUrlEncodedContent( ParseFormBody(model.Body)), "multipart/form-data" => BuildMultipartContent(model.Body), _ => new StringContent(model.Body, Encoding.UTF8, model.ContentType) }; var response = await client.PostAsync(model.Url, content); var result = await response.Content.ReadAsStringAsync(); return $"Status: {(int)response.StatusCode} {response.ReasonPhrase}\n{result}"; }这里重点聊聊application/json和application/x-www-form-urlencoded的选择问题。很多刚开始做接口测试的朋友会把表单参数直接拼成JSON字符串发过去,结果服务端一直报参数缺失或无法绑定。原因很简单:服务端框架(包括ASP.NET Core、Spring MVC)根据Content-Type选择模型绑定器,你发JSON就要带[FromBody],发表单就要带[FromForm]。所以做工具的时候,把Content-Type做成可配置项,远比写死成一种要实用。
另外注意UTF-8编码。如果不显式指定Encoding.UTF8,某些服务端会按ISO-8859-1处理,中文Body直接乱码。
3.3 PUT:与POST的边界,以及部分更新怎么处理
PUT和POST在实现上基本一致,真正需要理解的是它们之间的语义差异,这在调用别人接口时特别关键。
简单区分:
- POST:创建资源。同一个请求发两次,服务端会创建两条记录。
- PUT:整体替换资源。同一个请求发两次,服务端最终状态一致(幂等)。
实际测试中,如果你拿POST的思维去调PUT接口,特别容易遇到“更新不生效”的困惑。比如一个更新用户信息的接口,POST方式服务端可能做了部分字段校验,而PUT要求把完整对象传过去,缺字段直接报错。我在工具实现里,PUT和POST共用一个方法体,只是更换动词:
public static async Task<string> SendPutAsync(RequestModel model) { // 逻辑同SendPostAsync,仅将PostAsync替换为PutAsync }不过要补充一个点:有些老旧服务端的PUT接口只接收application/x-www-form-urlencoded,不接收JSON,这与RESTful标准其实有出入,但现实里就是有。所以PUT的Content-Type配置必须保持和POST一样的灵活性,不要写死。
3.4 DELETE:容易被忽略的Body传参场景
DELETE通常被认为只有一个URL,但实际上很多内部系统(尤其是一些Java体系的管理后台)的DELETE接口也支持传Body,用来传递删除条件或批量删除的ID列表。所以在实现DELETE时,我依然保留Body配置:
public static async Task<string> SendDeleteAsync(RequestModel model) { using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(model.Timeout) }; foreach (var header in model.Headers) { client.DefaultRequestHeaders.TryAddWithoutValidation(header.Key, header.Value); } HttpRequestMessage request = new HttpRequestMessage { Method = HttpMethod.Delete, RequestUri = new Uri(model.Url) }; if (!string.IsNullOrWhiteSpace(model.Body)) { request.Content = new StringContent(model.Body, Encoding.UTF8, model.ContentType); } var response = await client.SendAsync(request); var result = await response.Content.ReadAsStringAsync(); return $"Status: {(int)response.StatusCode} {response.ReasonPhrase}\n{result}"; }这里用HttpRequestMessage而不是HttpClient.DeleteAsync,就是为了给DELETE请求附加Body。DeleteAsync重载不接受HttpContent参数,所以绕一下:先构造HttpRequestMessage,再调用SendAsync。
注意:某些Web服务器(包括Nginx、IIS的部分版本)可能会丢弃DELETE请求的Body,这是服务器行为,不是C#的问题。遇到这种情况,如果服务端也支持POST实现删除,建议协商改用POST,或者在URL里带删除参数。
4. 把工具做得"能用又顺手":序列化、超时、日志记录
写完发送逻辑只是第一步。一个能用的工具,必须把输入、输出、中间过程都处理好。这一章我讲三个在实操里对我帮助最大的功能:JSON格式化输出、统一的超时管理、可落地的日志策略。
4.1 响应JSON格式化:从"一串长字符串"到"一眼看明白"
接口返回的JSON如果直接打印,在控制台里就是一大串带转义符的文字,看着头大。所以我封装了一个格式化方法,把JSON字符串反序列化成对象,再重新序列化输出:
public static string FormatJson(string json) { try { using var doc = JsonDocument.Parse(json); var options = new JsonSerializerOptions { WriteIndented = true }; return JsonSerializer.Serialize(doc.RootElement, options); } catch { return json; // 不是JSON,原样返回 } }这个方法的巧妙之处在于JsonDocument.Parse不关心JSON的结构,只要能解析就重新排版,解析失败就说明返回的是普通文本(比如HTML错误页),那就原样显示。这样日志输出既照顾了可读性,又不会因为格式化失败导致崩溃。
实际使用中,这个格式化函数对排查问题帮助极大。有一次联调一个视觉检测系统的接口,返回的JSON嵌套了四层,没格式化之前根本看不清节点关系,格式化之后一眼就发现数据在data.items[0].result下,比自己瞎猜字段路径靠谱多了。
4.2 超时管理:为什么不要用默认的100秒
HttpClient的默认超时是100秒,这个值在日常联调中太长——如果一个接口15秒都没返回,大概率是卡在网络代理或死锁了,继续等是浪费时间。
但超时又不能设得太短,因为有些接口确实慢,尤其是涉及数据库查询或算法推理的接口,5秒可能不够。我的做法是把超时做成RequestModel里的一个配置项,默认30秒,用户可以在加载用例时单独修改。
这里还有一个隐藏细节:超时时间放在HttpClient上,而不是放在单个请求上。这意味着如果你复用一个HttpClient,它的超时对所有请求生效。想让不同请求有不同超时,要么每次new一个HttpClient,要么用CancellationTokenSource.CancelAfter配合SendAsync实现。
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(model.Timeout)); try { var response = await client.SendAsync(request, cts.Token); } catch (OperationCanceledException) { return "请求超时,已取消"; }这种方式更灵活,它对整个请求管线(包括DNS解析、连接建立、读取响应)都生效,而且不会因为超时抛出一个难看的TaskCanceledException堆栈,而是给出一个友好的超时提示。
4.3 用例保存与加载:用JSON文件当"简易版Postman Collection"
写工具的时候我就在想,光能发请求还不够,每次调试都要重新输入URL、Header、Body很烦。于是我把用例模型直接序列化成JSON文件,存成一个简单的集合:
public class TestCase { public string CaseName { get; set; } public RequestModel Request { get; set; } public string ExpectedStatus { get; set; } }加载时用File.ReadAllText+JsonSerializer.Deserialize<List<TestCase>>,一条Case对应一个文件,目录结构清爽:
Cases/ ├── 查询设备信息.json ├── 下发配置_POST.json └── 删除任务_DELETE.json每个文件就是一个完整的请求模板。测试时先加载、再改参数、最后发送。这种"文件即用例"的方式比数据库存储更轻量,适合工具类项目,也方便用Git做版本管理,跟同事共享用例时直接扔一个文件过去就行。
5. 避坑实录:我在这类工具上踩过的五个坑
整个工具从第一版能跑到今天稳定使用,中间踩了不少坑。我挑几个最有代表性的写出来,希望后来的人能少走弯路。
5.1 HttpClient的"陈旧DNS"问题
第一个坑非常隐蔽。我刚开始在工具里定义了一个静态HttpClient,结果用了几天后,发现同一个域名改了IP,工具始终请求旧地址。这是因为HttpClient默认会复用底层连接,而DNS解析结果在连接建立时就被缓存了。
解决方案是设置连接生命周期:
var handler = new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) }; using var client = new HttpClient(handler);这样每次连接使用超过5分钟就会被关闭重建,DNS解析会重新执行。对于设备IP可能变动的工控场景特别重要。
5.2 JSON中的时间格式
有一次调试一个跟ERP对接的接口,我传的参数里有时间字段,写的是2025-01-05 10:30:00,但服务端死活解析不了。后来发现服务端使用的是ASP.NET Core默认的JSON序列化,对DateTime的要求是ISO 8601格式:2025-01-05T10:30:00。
后来我在工具的Body编辑区加了一个提示,凡是时间字段统一改成yyyy-MM-ddTHH:mm:ss格式再发送。这个坑其实不怪工具,属于接口对接中对格式约定的理解问题,但工具里提前做个校验提示,能省去很多低级错误导致的联调时间浪费。
5.3 响应内容压缩导致乱码
有些服务端开启了Content-Encoding: gzip或br压缩,如果客户端不自动解压,读出来的内容就是乱码。HttpClient默认不会自动解压所有格式,需要在Handler里配置:
var handler = new HttpClientHandler { AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate };后来把Br也加上了(.NET 6+支持Brotli),这样绝大多数压缩响应都能直接读成正常文本。联调联通、移动IOT平台时特别有用,那些平台几乎都开了压缩。
5.4 HTTPS证书校验失败
自建服务和工控设备经常用自签名证书,调试时代码会抛AuthenticationException,提示证书链不受信任。写测试工具时,我加了一个"跳过SSL校验"的开关:
handler.ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => model.SkipSslVerify ? true : errors == SslPolicyErrors.None;注意这个开关只应该在上位机调试工具里默认开启,不能在生产业务的代码里这么写,否则就是给自己埋雷。
5.5 大响应体导致的OutOfMemoryException
工具曾有一次拉取一个报表接口,服务端一次性返回了接近200MB的JSON(虽然设计不合理,但真实存在)。直接用ReadAsStringAsync读,内存直接爆掉。
修复方案是流式读取,边读边处理:
using var stream = await response.Content.ReadAsStreamAsync(); using var reader = new StreamReader(stream); while (!reader.EndOfStream) { string? line = await reader.ReadLineAsync(); // 逐行处理或写文件 }或者干脆把响应保存到临时文件,再用小流读取展示。这个经验不太常用,但真遇到大响应的时候,能救你一把。
6. 运行效果与实际使用体验
工具完成后,我把它整合进了自己的工作目录,日常联调流程变成了这样:
- 根据接口文档编写Case文件,写好URL、Header、Body模板。
- 程序启动,加载Cases目录下所有用例,按顺序执行。
- 执行完打印每个用例的状态码、耗时、格式化后的响应体。
- 对照期望状态码,快速定位失败项。
实际跑一个查询设备信息的GET请求,输出长这样(简化示例):
========== 用例1:查询设备信息 ========== 请求URL : http://192.168.1.100:8080/api/device?sn=TEST001 状态码 : 200 OK 耗时 : 125ms 响应内容 : { "code": 0, "message": "success", "data": { "deviceName": "视觉检测单元", "status": "running", "temperature": 42.5 } } =========================================如果是POST下发配置:
========== 用例2:下发配置 ========== 请求URL : http://192.168.1.100:8080/api/config 方法 : POST Content-Type: application/json 状态码 : 200 OK 耗时 : 89ms 响应内容 : { "code": 0, "message": "配置下发成功" }整体体验是:比Postman轻、比curl直观、代码可复用、后续还能扩展成WinForm版本加一个输入框界面。工具虽然简单,但在没有外网环境、又要频繁联调的工业现场,它是我最顺手的调试伙伴。
7. 后续扩展方向与个人建议
目前这个工具已经能满足我80%的日常接口调试需求。剩下的20%,我计划从这几个方向继续扩展:
- 断言功能:每个用例加一个"期望结果"字段,自动比对状态码或正文关键字,输出绿色的PASS或红色的FAIL。
- 多用例顺序执行:批量跑完整套回归用例,统计通过率和平均耗时。
- 生成测试报告:执行完把结果导出为HTML或Markdown,方便跟项目组同步。
- 加一个简单的WinForm/WPF界面:把URL、Headers、Body都做成输入控件,对不熟悉命令行的同事更友好。
关于做这类小工具,我的个人看法是:不要一开始就追求大而全,先把"为自己解渴"做扎实——能发四种请求、能配置Header和Body、能看清响应结果,这就已经超过了大多数一次性调试脚本。等到真实项目里反复用、反复觉得差点意思的时候,再按需扩展,效率最高,也最不会写出用不上的功能。
如果你也在做上位机开发、接口联调,或者单纯想加深对HTTP协议的理解,强烈建议自己动手写一个类似的小工具。代码量不大,但对HttpClient、请求方法语义、Content-Type、序列化这些基础知识的理解,绝对比看十篇教程来得扎实。
本文还有配套的精品资源,点击获取