← 全部文章 工业通讯

WinForm 里的简单 HTTP 服务器与客户端

2023 年 12 月 25 日 · 3 分钟读完

目录
  1. HTTP 是什么
  2. 服务器:HttpListener + 路由表
  3. 使用
  4. 客户端:HttpClient
  5. 和 WebApi 的区别
  6. 延伸

项目里和别的系统交换数据时,同事说用 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 的发布与调用。

# C# # HTTP # WinForm

原文首发于 blog.csdn.net