资讯详情

C# HttpListener轻量服务器搭建与Unity通信实战:从零到稳定联调

📅 2026/10/20 20:49:41 | 华诺云谱 👁 阅读
C# HttpListener轻量服务器搭建与Unity通信实战:从零到稳定联调
1. 为什么我用原生HttpListener做了个轻量服务器先交代一下背景。前阵子接了个模拟项目X要求在一个类似展厅演示的场景里让Unity画面实时响应外部数据外部数据由一个独立程序产生。最直接的方案当然是挂一套完整框架比如把Node服务或者Python的FastAPI跑起来再让Unity去拉。但需求其实特别轻只有几个接口数据量不大部署环境是内网Windows机器还得装一堆运行时环境。权衡之后我决定用C#自带的HttpListener手写一个HTTP服务器理由很实际项目本身就是C#生态一台机器上其他人写的工具也是.NET不用额外装Python或Node。HttpListener是微软系统库不引第三方包目标机器不需要额外配置。可以编译成单文件exe直接丢过去跑非常省事。Unity端本来就在C#环境下用UnityWebRequest做客户端通信协议两边都是同一套思维出问题排查起来快。这不是说完整框架不好。如果接口有二十个、要连数据库、要鉴权那我肯定老老实实上框架。但这个场景下接口数量用一只手数得过来数据量是每次几百字节用HttpListener完全够运行稳定还没什么坑。所以这篇不是教你怎么写一个生产级Web服务器而是分享一套“够用且跑得稳”的最小方案从服务器骨架、路由设计、Unity客户端封装、到联调时容易踩的问题。整个思路适合做Unity工具链、本地数据桥接、原型Demo这类场景核心代码你直接可以改一改拿过去用。2. 服务器端骨架从监听端口到请求路由2.1 HttpListener的最小启动流程先看最基础的部分。HttpListener用起来其实门槛很低核心就三步初始化监听前缀、启动、循环处理请求。using System; using System.Net; using System.Text; using System.Threading.Tasks; public class SimpleHttpServer { private readonly HttpListener listener new HttpListener(); private bool isRunning false; public SimpleHttpServer(string prefix) { // 例如 http://localhost:8080/ listener.Prefixes.Add(prefix); } public void Start() { listener.Start(); isRunning true; Console.WriteLine($Server started at {string.Join(, , listener.Prefixes)}); } public void Stop() { isRunning false; listener.Stop(); Console.WriteLine(Server stopped); } }这段代码看起来简单但这里有个前提条件特别容易在正式环境漏掉用户要给自己机器上的监听端口配上URL保留条目否则启动会报Access Denied。我一开始在本机跑没问题部署到一台服务器上就被卡住了报错信息指向权限问题。解决办法有两个用管理员身份运行一次命令行执行netsh命令为指定端口添加URL保留。直接把exe放到一个目录然后用管理员权限启动exe。命令行方式是这样的netsh http add urlacl urlhttp://:8080/ userEveryone注意这里的user参数如果是部署在服务器上可能需要指定具体账户而不是Everyone。这个影响很小但如果你是第一次部署会卡在这里怀疑自己代码写错了。2.2 路由设计手动映射比框架更直观既然不用框架就必须自己实现一个极简路由表。需求很直接客户端请求不同路径比如/api/pose、/api/health分别返回不同数据。我采用的方案是字典映射方法。public class ApiRouter { private readonly Dictionarystring, FuncHttpListenerRequest, HttpResponse routes new Dictionarystring, FuncHttpListenerRequest, HttpResponse(StringComparer.OrdinalIgnoreCase); public void Register(string path, FuncHttpListenerRequest, HttpResponse handler) { routes[path] handler; } public bool TryHandle(HttpListenerContext context) { var path context.Request.Url.AbsolutePath; if (!routes.TryGetValue(path, out var handler)) { return false; } var response handler(context.Request); WriteResponse(context.Response, response); return true; } }这里有一个设计上的取舍直接按AbsolutePath匹配不考虑QueryString。因为在数据桥接场景里参数通常放在POST的body里GET请求一般就用来做健康检查简单路径匹配完全够用。如果后续接口变复杂在字典基础上加一层参数提取也不难。2.3 Response对象的封装与序列化为了让路由写法更简洁我定义了一个简单的响应类统一处理JSON序列化和错误码。public class HttpResponse { public int StatusCode { get; set; } 200; public string ContentType { get; set; } application/json; public string Body { get; set; } public static HttpResponse Json(object data) { return new HttpResponse { Body JsonConvert.SerializeObject(data), ContentType application/json }; } public static HttpResponse Error(int statusCode, string message) { return new HttpResponse { StatusCode statusCode, Body JsonConvert.SerializeObject(new { error message }), ContentType application/json }; } }序列化我用了Newtonsoft.Json这是唯一引入的第三方库。如果是极简部署也可以直接用Unity的JsonUtility但JsonUtility不支持字典和某些类型序列化所以我更喜欢Newtonsoft。2.4 异步循环与线程安全服务器必须处理并发请求所以监听循环不能写死成单线程。我采用的是每个请求进线程池的方式。public async Task StartAsync() { Start(); while (isRunning) { try { var context await listener.GetContextAsync(); Task.Run(() ProcessRequest(context)); } catch (HttpListenerException ex) { if (!isRunning) { break; } Console.WriteLine($Listening error: {ex.Message}); } } }这里需要留意线程安全问题。因为数据桥接场景中Unity端拿到的数据会最终写入某个共享状态类服务器端的处理函数是并行执行的如果多个请求同时写同一个字段必须加锁或使用原子操作。我的惯例是让请求处理程序只读数据或者把写入操作统一丢到一个队列里由主线程消费这样能避免很多难排查的间歇性问题。具体到当前场景数据源是一个外部程序定期推送进来服务器只管读取。所以我用了一个线程安全的队列缓存最新数据每个客户端请求直接从队列里拿最新值返回不存在写入冲突。3. Unity端通信封装WebRequest与协程的取舍3.1 用UnityWebRequest还是HttpWebRequestUnity里发起HTTP请求有两个常见选择。早期文档里常看到HttpWebRequest它确实能用但在Unity主线程上配合使用会有一个典型坑在非主线程调用会有依赖主线程同步的问题在某些平台还会被WebException卡住。UnityWebRequest是官方推荐方式兼容所有Unity平台底层是C实现对Unity的生命周期管理更好。所以客户端通信我全部用UnityWebRequest。基础GET请求using System.Collections; using UnityEngine; using UnityEngine.Networking; public class HttpApiClient : MonoBehaviour { public string baseUrl http://127.0.0.1:8080; public IEnumerator GetHealth() { string url baseUrl /api/health; using (UnityWebRequest request UnityWebRequest.Get(url)) { request.timeout 5; request.SetRequestHeader(Content-Type, application/json); yield return request.SendWebRequest(); if (request.result UnityWebRequest.Result.Success) { Debug.Log(Server response: request.downloadHandler.text); } else { Debug.LogError($Health check failed: {request.error}); } } } }POST则要自己构造body数据public IEnumerator PostData(string jsonData) { string url baseUrl /api/telemetry; using (UnityWebRequest request new UnityWebRequest(url, POST)) { byte[] bodyRaw Encoding.UTF8.GetBytes(jsonData); request.uploadHandler new UploadHandlerRaw(bodyRaw); request.downloadHandler new DownloadHandlerBuffer(); request.SetRequestHeader(Content-Type, application/json); yield return request.SendWebRequest(); if (request.result UnityWebRequest.Result.Success) { Debug.Log(Post response: request.downloadHandler.text); } else { Debug.LogError($Post failed: {request.error}); } } }3.2 协程与回调的封装如果每次请求都直接在业务类里写协程代码会乱成一团。我建议做一层封装把请求、超时、回调都统一收口业务代码只关心“发请求然后拿到结果”。我封装了一个基于Action回调的接口public class UnityHttpClient { public void Get(string path, Actionstring onSuccess, Actionstring onError) { UnityMainThreadDispatcher.Instance.StartCoroutine(SendRequest(path, GET, null, onSuccess, onError)); } public void Post(string path, string jsonData, Actionstring onSuccess, Actionstring onError) { UnityMainThreadDispatcher.Instance.StartCoroutine(SendRequest(path, POST, jsonData, onSuccess, onError)); } private IEnumerator SendRequest(string path, string method, string jsonData, Actionstring onSuccess, Actionstring onError) { string url baseUrl path; using (UnityWebRequest request new UnityWebRequest(url, method)) { if (!string.IsNullOrEmpty(jsonData)) { byte[] bodyRaw Encoding.UTF8.GetBytes(jsonData); request.uploadHandler new UploadHandlerRaw(bodyRaw); request.downloadHandler new DownloadHandlerBuffer(); request.SetRequestHeader(Content-Type, application/json); } else { request.downloadHandler new DownloadHandlerBuffer(); } yield return request.SendWebRequest(); if (request.result UnityWebRequest.Result.Success) { onSuccess?.Invoke(request.downloadHandler.text); } else { onError?.Invoke(request.error \n request.downloadHandler.text); } } } }这个封装的关键点在于UnityMainThreadDispatcher。因为协程依赖MonoBehaviour生命周期如果这个请求是从非主线程的某个系统回调里触发的直接StartCoroutine会失败。所以需要一个全局的单例MonoBehaviour来承载协程。3.3 轮询与主动推送的选择Unity和服务器通信有一种常见模式是轮询。每隔一小段时间发一次GET请求拿最新数据。简单但在高频下对服务器压力大而且响应延迟取决于轮询间隔。还有一种模式是服务器长连接推送这在HTTP协议里可以用SSE或者WebSocket实现。但当前场景数据量不大延迟容忍度在500毫秒以内轮询完全够用。我在客户端用了自适应轮询频率的策略数据不变化时轮询频率降到2秒一次收到一次有效变更后立即再请求一次然后回到正常节奏这样既保证了数据实时性又不会产生无谓的请求压力。这个策略的收益在联调时体验非常明显。场景静止时服务器几乎零负载一旦外部数据开始变更Unity端会在几百毫秒内做出响应。4. 通信数据协议让两边少踩坑的关键设计4.1 一个约定的数据格式HTTP通了但如果数据格式两边没约定好后面联调就是灾难。我的建项目时第一件事就是定协议而不是先写代码。这条规则放在任何前后端协作场景都适用。当前项目定义了一个简单协议{ id: 12345, timestamp: 2025-01-15 14:30:22, data: { position: { x: 0.5, y: 1.2, z: -3.4 }, rotation: { x: 0.0, y: 90.0, z: 0.0 } } }关键字段说明id唯一递增序列号用来丢包检测timestamp数据产生时间data实际载荷。Unity端拿到这个JSON后旋转数据是欧拉角而我们内部用的是四元数。所以解析时要手动做一次转换。这个看起来很细节的问题如果两边不统一会直接导致画面旋转错乱。4.2 C#模型序列化与反序列化方案在服务器端我定义了和协议对应的C#模型类再用Newtonsoft反序列化public class DataPacket { public int id { get; set; } public string timestamp { get; set; } public PacketData data { get; set; } } public class PacketData { public PositionData position { get; set; } public PositionData rotation { get; set; } } public class PositionData { public float x { get; set; } public float y { get; set; } public float z { get; set; } }在服务器里收到外部数据时直接var packet JsonConvert.DeserializeObjectDataPacket(bodyString);Unity端也一样。但Unity的JsonUtility不支持这种嵌套带构造的类直接反序列化所以我统一用了Newtonsoft.Json。项目里Unity的package包管理器直接搜Newtonsoft Json就能装就不需要额外说明怎么导入了。4.3 编码问题一个容易在Windows上踩的暗坑HTTP协议里的编码问题比想象中常见。默认情况下如果不指定请求和响应的Content-Type charset很多库会用ISO-8859-1而不是UTF-8。如果数据里面包含中文就可能出现乱码。服务器端写响应时要显式指定编码private void WriteResponse(HttpListenerResponse response, HttpResponse data) { response.StatusCode data.StatusCode; response.ContentType data.ContentType ; charsetutf-8; byte[] buffer Encoding.UTF8.GetBytes(data.Body); response.ContentLength64 buffer.Length; response.OutputStream.Write(buffer, 0, buffer.Length); response.OutputStream.Close(); }Unity端请求时则要确保UploadHandlerRaw的字节编码是UTF-8。我已经用Encoding.UTF8.GetBytes(jsonData)处理过了。这两个地方一不注意在纯英文数据时毫无感知一旦数据包含中文特殊字符或Unicode符号就出现一眼看不出原因的乱码。联调时排查半天最后才发现是编码不一致非常浪费时间。5. 联调阶段踩过的坑和排查链路5.1 第一个坑端口被占用与URL保留权限部署到目标机器后双击运行exe控制台直接抛HttpListenerException: Access Denied。这个报错不是端口被占用而是当前用户没有权限监听该URL。排查过程先用netstat -ano | findstr 8080看端口是否被占用发现8080端口是空闲的再尝试改用管理员权限启动exe启动成功通过netsh配置URL保留项后普通权限也能正常启动。这里有个值得注意的点如果以后会频繁更换部署机器最好在代码里写一个启动前的提示或者提供一个脚本来执行netsh命令避免每次部署都被这个权限问题卡住。5.2 第二个坑Unity端请求返回CORS相关错误第一次联调时Unity Editor运行客户端请求服务器端收到了请求并正常返回但Unity控制台报了一个类似跨域的错误。这里要区分场景。如果是一个纯Unity客户端不涉及浏览器那CORS根本不会影响。但当时我为了快速验证先在一个WebGL预览模式里跑了一下WebGL基于浏览器规范才会检查CORS。解决办法很简单服务器端给所有响应统一加上跨域头response.Headers.Add(Access-Control-Allow-Origin, *); response.Headers.Add(Access-Control-Allow-Methods, GET, POST, OPTIONS); response.Headers.Add(Access-Control-Allow-Headers, Content-Type);这个headers加上之后无论是Unity Editor、WebGL还是外部调试工具都不受跨域限制。这对本地工具型服务器非常合适但如果服务器后续要接公网跨域头还是要按需设置不能无脑*。5.3 第三个坑请求处理异常导致服务器直接退出这个坑很隐蔽。我一开始的请求处理循环里每个请求都调Task.Run但内部没有包一层try-catch。后来某个客户端发了一个不合法JSON的POST请求反序列化抛了异常异常直接在Task里冒出来服务器进程就崩了。排查过程比较漫长因为不是一次稳定的复现属于偶发。直到我把服务器端日志加上看到一条未捕获异常堆栈才定位到是反序列化崩溃。修复办法是在请求处理入口加全局异常捕获private void ProcessRequest(HttpListenerContext context) { try { HandleRequest(context); } catch (Exception ex) { Console.WriteLine($Unhandled error: {ex}); TryWriteError(context.Response, 500, Internal Server Error); } finally { context.Response.Close(); } }这条规则对所有服务器程序都适用任何请求处理都必须在最外层兜住异常不能让它直接冒泡导致进程退出。特别轻量级服务器没有进程守护一旦崩掉整个联调就得手动重启。5.4 第四个坑Unity端频繁请求时的内存压力Unity端用UnityWebRequest时下载内容如果不及时处理会造成一小点内存累积。因为UnityWebRequest继承自IDisposable我代码里统一用了using理论上不会有泄漏。但一个容易忽略的点是DownloadHandlerBuffer的重复分配。每个请求都创建一个新的DownloadHandler如果数据量不大没有太大影响。但在高频轮询下每秒5次以上建议用同一个DownloadHandler复用或者直接降低请求频率。我最终把轮询频率从1秒降到了2秒配合自适应变化内存和CPU占用都明显下降而数据实时性几乎没有损失。这个优化思路其实很简单就是在“获取最新状态”和“避免无谓请求”之间找平衡。6. 后续扩展从Demo走向长期稳定运行这个服务器虽然做成了轻量方案但后续碰到更复杂的需求扩展路径也很清晰。我整理了一些可以按需接入的增强点。6.1 增加请求日志与监控当前代码里只用了Console.WriteLine输出关键信息在长时间运行时日志会淹没在控制台里。可以增加一个简单的文件日志private void Log(string message) { string line $[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}; Console.WriteLine(line); System.IO.File.AppendAllText(server.log, line Environment.NewLine); }这个文件日志在排查问题时极其有用尤其是部署到无人工值守的机器上时。6.2 增加体量较大的数据缓存如果外部数据频率很高比如每秒几十次推送而Unity端只是每2秒拉一次那就需要一个版本号或增量序号机制。客户端请求时带上自己已拿到的lastId服务器只返回比lastId新的数据。这样既减少流量又保证不丢数据。实现也不复杂服务器维护一个递增的nextId每次推送分配一个客户端请求带上lastId,服务器从内存队列里筛选大于lastId的数据返回。如果字段太多可以考虑用数据库但在这个轻量场景下一个内存队列加一个id比较就足够稳定。6.3 API自动文档如果后续有其他同事要接入建议提供一个/api/docs接口返回一个纯HTML的接口说明页面。这样客户端开发者不用翻文档直接在浏览器打开服务器地址就能看到所有可用的路由和参数示例。这个成本很低但对团队协作效率的提升非常明显。6.4 单元测试的关键逻辑轻量服务器虽然没有庞大的业务逻辑但路由是否正确、JSON协议是否能互通值得写几个单元测试特别是反序列化兼容性测试。我实际写测试时发现一个很有意思的问题服务器端和Unity端虽然都是C#但两边的Newtonsoft版本可能不同导致反序列化时某些类型默认值处理不一致。这种问题如果靠人工联调会浪费很长时间用一个固定JSON样例跑两边测试能瞬间暴露差异。7. 一点个人经验总结这个项目前前后后从写第一行代码到联调通过只花了大概一天半的时间。其中服务器骨架花了半天Unity端封装花了大半天剩下的时间全在踩上面提到的那些坑URL权限、CORS、异常崩溃、编码。真正业务逻辑的代码量其实很少精力大头往往在边界情况和环境兼容性上。如果你也要做类似的事情我建议不管多小的工具型服务器都按“能长期运行”的标准来写入口处兜住所有异常响应头统一加齐日志打全路由表独立出来别写成一坨。这样即使需求后面加码你也不会被自己当初的临时代码坑到。最后分享一个小技巧联调时不要把服务器和Unity客户端放在同一台机器上测完就结束最好在另一台真实部署机上跑一遍因为本机环境和部署环境在权限、防火墙、网络配置上往往有差异这些差异才是真正消耗时间的地方。提前把部署机的权限配置和防火墙放行规则准备好整个项目会顺利非常多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑