WinForm 里的简单 HTTP 服务器与客户端
2023 年 12 月 25 日 · 3 分钟读完
项目里和别的系统交换数据时,同事说用 WebApi、Get、Post,以前没接触过的我傻傻分不清。查了一圈发现就是普通的 HTTP 通讯。记录 WinForm 里一个简单的服务器和客户端,以及它和 WebApi 的区别。
HTTP 是什么
HTTP 是浏览器和网站之间用的应用层协议,也是现在系统之间交换数据最常用的方式。几个特点:
- 请求-响应:客户端发一个请求,服务器回一个响应,一问一答。
- 请求方法:GET(获取)、POST(提交)、PUT(更新)、DELETE(删除)等,「Get、Post」说的就是这个。
- URL 定位资源:
http://192.168.1.10:8080/PostAGV_Data/里,路径部分告诉服务器要找哪个接口。 - 无状态:服务器不记得上一个请求是谁发的,需要的话每个请求自己带上身份信息(Token、Cookie)。
原文说 HTTP「每次请求完连接立即关闭」,这是 HTTP/1.0 的行为。HTTP/1.1 起默认是长连接(Keep-Alive),同一个
HttpClient连续请求会复用 TCP 连接。
服务器:HttpListener + 路由表
HttpListener 是 .NET 自带的轻量 HTTP 服务器。用一个字典把 URL 路径映射到处理函数,就有了最简单的「路由」:
class HttpServer_DP
{
private readonly HttpListener listener = new();
private readonly Dictionary<string, Func<HttpListenerContext, Task<string>>> routes = new();
public HttpServer_DP(params string[] prefixes)
{
foreach (var prefix in prefixes) listener.Prefixes.Add(prefix); // 必须以 / 结尾
}
public bool IsOpen => listener.IsListening;
public void AddRoute(string path, Func<HttpListenerContext, Task<string>> handler)
=> routes.Add(path, handler);
public void Start()
{
listener.Start();
_ = Task.Run(AcceptLoop);
}
public void Stop() => listener.Close();
private async Task AcceptLoop()
{
while (listener.IsListening)
{
HttpListenerContext context;
try { context = await listener.GetContextAsync(); }
catch (Exception) { break; } // Stop() 之后会抛异常,正常退出
_ = Handle(context); // 不等它处理完,马上接下一个请求
}
}
private async Task Handle(HttpListenerContext context)
{
var response = context.Response;
try
{
if (routes.TryGetValue(context.Request.Url.AbsolutePath, out var handler))
{
byte[] buffer = Encoding.UTF8.GetBytes(await handler(context));
response.ContentType = "application/json; charset=utf-8";
response.ContentLength64 = buffer.Length;
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
}
else
{
response.StatusCode = (int)HttpStatusCode.NotFound;
}
}
catch (Exception ex)
{
response.StatusCode = (int)HttpStatusCode.InternalServerError;
Log($"处理请求出错:{ex.Message}");
}
finally
{
response.Close(); // 无论成功失败都要关,否则客户端一直等
}
}
}
和原文相比改了两处:原文用 BeginGetContext 回调,Stop() 时回调里会抛 ObjectDisposedException;处理函数出异常时响应没关闭,客户端会一直等到超时。这里统一放进 finally。
使用
HttpServer = new HttpServer_DP("http://+:8080/");
// 收到 /PostAGVC_Data/ 时调用 PostAGVC_Data 解析
HttpServer.AddRoute("/PostAGVC_Data/", PostAGVC_Data);
HttpServer.AddRoute("/PostAGV_Data/", PostAGV_Data);
HttpServer.Start();
static async Task<string> PostAGVC_Data(HttpListenerContext context)
{
if (context.Request.HttpMethod != "POST" || !context.Request.HasEntityBody)
return "{\"ok\":false}";
using var reader = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding);
string json = await reader.ReadToEndAsync();
var data = JsonConvert.DeserializeObject<AgvcData>(json);
// 处理数据……
return "{\"ok\":true}";
}
权限问题:监听 localhost / 127.0.0.1 不需要特殊权限;监听 +、* 或本机 IP(让别的电脑能访问)时,普通用户启动会抛 HttpListenerException: 拒绝访问。原文的办法是用管理员身份运行;更好的办法是用管理员执行一次 URL 授权,之后普通权限就能监听:
netsh http add urlacl url=http://+:8080/ user=Everyone
别忘了在 Windows 防火墙里放行 8080 端口。
客户端:HttpClient
class HttpClient_DP
{
private readonly HttpClient client = new() { Timeout = TimeSpan.FromSeconds(10) };
public async Task<string> Get(string url)
{
using var response = await client.GetAsync(url);
response.EnsureSuccessStatusCode(); // 非 2xx 抛 HttpRequestException
return await response.Content.ReadAsStringAsync();
}
public async Task<string> PostJson(string url, string json)
{
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var response = await client.PostAsync(url, content);
response.EnsureSuccessStatusCode();
return await response.Content.ReadAsStringAsync();
}
}
调用:
string ret = await HttpClient.PostJson(url, JsonConvert.SerializeObject(new PLC_WorkState
{
name = agvName,
workState = workState
}));
几个细节:
HttpClient整个程序共用一个,不要每次请求都new再Dispose。频繁创建会耗尽端口(大量 TIME_WAIT)。- 超时:原文说「不设置时永久等待」,其实默认是 100 秒。超时后抛
TaskCanceledException,要try-catch。工控现场一般设成几秒。 - Content-Type:
new StringContent(data)默认是text/plain,对方如果是 ASP.NET WebApi,会因为不是application/json而拿不到数据(415 或参数为 null)。发 JSON 时要像上面一样指定。 - 原文的错误处理:原文在失败时
return $"Error: {code}",调用方很难区分这是错误还是正常数据。用异常或返回(bool ok, string body)更清楚。
和 WebApi 的区别
| HttpListener 自己写 | ASP.NET Core Web API | |
|---|---|---|
| 本质 | 都是 HTTP 服务器,客户端看不出区别 | 同左 |
| 路由 | 自己用字典匹配路径 | 按 [Route]、[HttpPost] 自动映射到方法 |
| 参数 | 自己读 Body、自己反序列化 | 自动把 JSON 绑定到参数对象,自动校验 |
| 其他 | 无 | 中间件、认证、日志、Swagger 文档、依赖注入 |
| 适合 | 程序里加两三个简单接口 | 接口多、要长期维护的服务 |
对调用方来说,「调 WebApi」和「发 HTTP 请求」是一回事,上面的 HttpClient 照样能用。
延伸
- WinForm 里也能跑 ASP.NET Core:.NET 6 以后,WinForm 程序里可以用几行 Minimal API 起一个 Kestrel 服务器(
WebApplication.CreateBuilder()+app.MapPost("/PostAGV_Data", ...)),比 HttpListener 功能全,也不需要 urlacl 授权。 - 回调到界面线程:服务器的处理函数在线程池线程上执行,要更新界面得
Invoke,原因见 WinForm 加载界面卡死。 - JSON 序列化:
Newtonsoft.Json和 .NET 自带的System.Text.Json都可以,见 C# 里的 JSON 与 XML。 - 测试工具:写服务器时先用 Postman、Apifox 或者
curl -X POST -H "Content-Type: application/json" -d "{...}" http://...测,确认服务器没问题再写客户端。 - 老项目里的 WebService:基于 SOAP 和 XML,是更早的方案,见 VS2019 中 WebService 的发布与调用。
原文首发于 blog.csdn.net