资讯动态

C# HttpListener手写轻量级HTTP服务器,Unity客户端通信实战

发布时间:2026/10/20 20:49:15 来源:尧图企业网站定制
真正动手写这篇笔记的起因是某次项目里Unity客户端需要从服务器拉取配置。原本想直接上一个现成的Web框架后来仔细一想一个轻量级配置下发功能上框架确实有点杀鸡用牛刀的意味。用C#自带的HttpListener手写一个小型HTTP服务器反而更可控、更好维护。加上客户端用UnityWebRequest发请求整个通信链路不到十个核心文件就打通了。这类“小工具型服务器”在实际开发里的应用场景非常广本地联调、配置热更新、排行榜上报、远程日志、甚至给美术或策划做一个预览接口。这篇文章就围绕“一个简单的C#HTTP服务器含Unity通信调用”展开把服务端实现、客户端调用、常见坑位一次讲透希望能给你一条可以直接落地的思路。1. 项目拆分一个HTTP服务器加一个Unity客户端1.1 为什么需要自己写HTTP服务器很多人的第一反应是C#不是已经有很完整的HTTP服务器框架了吗Windows上还能直接启用IIS为什么还要自己写我最早也这么想。当时项目里要做配置下发Unity客户端启动时要从服务器拿一包参数。如果用完整Web框架要么引入一堆NuGet包要么在部署环境里配IIS。对一个内部调试工具来说这两种方案都太重了。我们的实际部署目标是一台平时没人管的测试机装IIS、配防火墙规则、部署应用池光是环境准备就能消耗半天。后来我干脆用C#自带的HttpListener类手写了一个不到200行的服务器问题直接解决。自己动手写HTTP服务器最大的优势就是可控。你想要什么行为就直接在代码里写什么行为。比如返回固定JSON、读取本地文件、按请求路径分发不同响应整个过程一目了然。另一个优势是便于分发部署一个exe带着几个配置文件拷到哪都能跑不需要额外安装任何组件。对于一个小团队、一条开发流水线来说这种轻量级方案比任何一种重量级框架都来得顺手。1.2 适合的应用场景这种轻量级服务器适合什么场景我根据自己的项目经验总结了几个典型方向配置下发客户端启动时请求服务器获取配置文件或参数表服务器根据版本号返回最新内容客户端做增量或全量更新。数据上报客户端把操作日志、崩溃信息、统计埋点发到本地HTTP服务器服务器落盘后由后续工具统一处理。联调利器Unity开发时需要一个模拟API本地服务器可以快速返回假数据不用干等后端接口进度。远程控制局域网内调试设备时通过HTTP接口触发命令比如重启模拟器、切换关卡、采集日志。不建议的场景同样要说明白如果要做高并发每秒几百上千请求、复杂权限认证、跨域域名绑定、大规模断点续传那还是要用专业框架比如Kestrel或完整的云服务。自写HTTP服务器的定位是“够用、可控、方便调试”不是冲着生产级高并发去的。1.3 技术选型对比用C#实现HTTP服务器我实际对比过三条路方案依赖代码量适用场景HttpListener无需额外包最少轻量内部服务、局域网通信TcpListener Socket无需额外包中等需要精细控制协议、处理大文件流Kestrel / ASP.NET CoreNuGet包较多较大正式对外服务、高并发这次的主角是HttpListener。既然标题叫“简单”主力方案就该是HttpListener。它把HTTP协议解析、请求上下文、响应流管理这些底层逻辑都封装好了我们只需要写业务逻辑。至于Unity客户端的通信封装用UnityWebRequest即可这是跨平台方案里最稳的选择。需要特别指出的是HttpListener的请求上下文对象极其好用。它包含Request和Response两个对象前者负责读取请求数据后者负责写响应。拿到一个请求后你能直接从URL上解析路径、从QueryString里取参数、从InputStream里读POST体整个接口设计对小型服务器来说非常直观。2. 服务端实现让C#跑起来一个HTTP服务2.1 核心骨架与启动逻辑先看一个最小可运行的服务器骨架。我会用端口8080作为示例实际项目中你可以换成任意没有冲突的端口。using System; using System.IO; using System.Net; using System.Text; using System.Threading.Tasks; public class SimpleHttpServer { private HttpListener _listener; private int _port; public SimpleHttpServer(int port) { _port port; _listener new HttpListener(); _listener.Prefixes.Add($http://*:{port}/); } public void Start() { _listener.Start(); Console.WriteLine($HTTP服务器已启动监听端口{_port}); // 每收到一个请求就开一个异步任务处理 while (true) { HttpListenerContext context _listener.GetContext(); Task.Run(() ProcessRequest(context)); } } private void ProcessRequest(HttpListenerContext context) { HttpListenerRequest request context.Request; HttpListenerResponse response context.Response; string path request.Url.AbsolutePath; string method request.HttpMethod; string responseJson {}; if (path /get_config method GET) { responseJson HandleGetConfig(request); } else if (path /report method POST) { responseJson HandleReport(request); } else { response.StatusCode 404; responseJson {\code\:404,\msg\:\not found\}; } byte[] buffer Encoding.UTF8.GetBytes(responseJson); response.ContentType application/json; charsetutf-8; response.ContentLength64 buffer.Length; response.OutputStream.Write(buffer, 0, buffer.Length); response.OutputStream.Close(); } public void Stop() { _listener.Stop(); } }这段代码里有一个容易踩坑的细节HttpListener的Prefix要写http://*:8080/。如果你写成http://localhost:8080/那么只有本机可以访问局域网里的Unity真机设备就完全连不上。到底选择什么前缀取决于场景如果只是本机联调localhost没问题如果要真机测试就选*或者具体网卡的IP地址。我通常会先写*避免后续转移到真机调试时又发现访问不通。另一个注意点是Task.Run。HttpListener的GetContext本身是同步阻塞方法如果只在一个线程里循环一旦请求处理耗时比如从磁盘读取大文件下一个请求就会排队等待。用Task.Run把每个请求丢到线程池去执行并发能力会立刻上一个台阶。当然这个处理方式只适合轻量场景真要搞高并发服务器还是得用异步版本配合await _listener.GetContextAsync()那种写法更规范但复杂度也会增加不少。2.2 请求路由与响应格式设计服务器核心逻辑在ProcessRequest方法里。我把路由分发写成简单分支结构路径加方法组成一个唯一键命中后执行对应业务方法。这种写法虽然朴素但对几十个接口以内的内部服务器来说完全够用而且不用引入反射和特性标签出问题时非常容易定位。我来补充一个完整的GET配置接口和POST上报接口private string HandleGetConfig(HttpListenerRequest request) { // 读取客户端传来的版本号 string version request.QueryString[version]; if (string.IsNullOrEmpty(version)) { version 0; } var config new { code 0, msg ok, data new { version 1.2.0, debugEnabled true, logLevel 3, maxRetryCount 5 } }; return Newtonsoft.Json.JsonConvert.SerializeObject(config); } private string HandleReport(HttpListenerRequest request) { string body ReadRequestBody(request); Console.WriteLine(收到上报数据 body); return {\code\:0,\msg\:\report received\}; } private string ReadRequestBody(HttpListenerRequest request) { using (StreamReader reader new StreamReader(request.InputStream, request.ContentEncoding)) { return reader.ReadToEnd(); } }响应格式我用统一JSON结构code msg data。这个结构在客户端解析时非常省事code等于0表示成功非0表示失败。相比裸返回数据这种封装让客户端业务逻辑更清晰。很多内部接口一开始不重视统一返回结构后面客户端到处是坑这一点提前做好很划算。如果你不想引入Newtonsoft也可以用System.Text.Json。Unity 2021之后对System.Text.Json的兼容性明显改善了但要注意AOT裁剪带来的风险。如果你的数据规模不大、结构简单直接用字符串拼接JSON也未尝不可关键是保证格式正确。2.3 把业务方法和路由入口分离如果所有业务逻辑都堆在ProcessRequest里代码很快会变成一坨。我的习惯是每个接口定义一个独立方法路由入口只做分发。这样新增接口时只需要加一个分支调用对应方法即可不会动到公共逻辑。抽象一下处理流程private void ProcessRequest(HttpListenerContext context) { try { RouteRequest(context); } catch (Exception ex) { Console.WriteLine($请求处理异常{ex}); WriteResponse(context.Response, 500, {\code\:500,\msg\:\server error\}); } }把异常处理包在最外层非常重要。服务器跑在无人值守的测试机上一旦某个请求触发了未捕获异常整个进程崩溃客户端就再也连不上了。有了这层try-catch即使某个接口内部报错最多返回500服务器仍然存活这是我在实战中得到的教训。3. Unity端通信调用客户端的完整请求姿势3.1 用UnityWebRequest封装GET请求Unity客户端与服务器通信时最常遇到的问题不是代码写错而是对UnityWebRequest生命周期的理解不到位。很多新人直接在主线程里用request.SendWebRequest()然后立刻去读downloadHandler.text结果发现数据是空的。原因是UnityWebRequest是异步执行的请求还没完成响应数据自然没准备好。正确的做法是配合协程使用using System; using System.Collections; using UnityEngine; using UnityEngine.Networking; public class HttpClientHelper : MonoBehaviour { public IEnumerator GetCoroutine(string url, Actionstring onSuccess, Actionstring onError, float timeout 5f) { using (UnityWebRequest request UnityWebRequest.Get(url)) { request.timeout (int)timeout; yield return request.SendWebRequest(); if (request.result UnityWebRequest.Result.Success) { onSuccess?.Invoke(request.downloadHandler.text); } else { onError?.Invoke(request.error); } } } }这段代码有几个关键点。第一using声明确保请求对象在结束时被释放防止内存泄漏。第二yield return挂起协程直到请求完成回调自动回到Unity主线程这样拿到结果后你可以直接操作场景中的对象。第三timeout参数控制超时我的经验是默认5秒在局域网内足够了但如果是公网请求根据网络状况可能要放宽到10秒以上。我见过同事直接在Update里轮询请求状态那是一种更底层但繁琐的做法完全没有必要。协程方式最符合Unity的开发习惯也最不容易出错。3.2 POST JSON数据与消息格式约定POST请求的封装比GET稍微复杂一点主要在于请求体和Content-Type的处理。我直接给出自己常用的最终版public IEnumerator PostJsonCoroutine(string url, string json, Actionstring onSuccess, Actionstring onError, float timeout 5f) { using (UnityWebRequest request new UnityWebRequest(url, POST)) { byte[] bodyRaw Encoding.UTF8.GetBytes(json); request.uploadHandler new UploadHandlerRaw(bodyRaw); request.downloadHandler new DownloadHandlerBuffer(); request.SetRequestHeader(Content-Type, application/json); request.timeout (int)timeout; yield return request.SendWebRequest(); if (request.result UnityWebRequest.Result.Success) { onSuccess?.Invoke(request.downloadHandler.text); } else { onError?.Invoke(request.error); } } }有个细节必须提醒如果直接用UnityWebRequest.Post(url, json)Unity默认会设置Content-Type为application/x-www-form-urlencoded。服务器端按表单方式解析你的JSON结构就需要额外转换非常别扭。用UploadHandlerRaw加SetRequestHeader组合Content-Type变成application/json服务端直接按JSON解析即可全链路语义统一。还有一个小习惯我发送前会主动打印一次请求体接收响应时也会打印响应体。这种日志在联调阶段极其有用很多问题光看错误码根本定位不出来但看到请求报文内容问题直接就暴露了。3.3 Unity侧JSON解析拿到响应字符串后就是解析环节。我推荐两种方案。第一种结构简单的数据用UnityEngine.JsonUtility[System.Serializable] public class ConfigData { public int code; public string msg; public ConfigPayload data; } [System.Serializable] public class ConfigPayload { public string version; public bool debugEnabled; public int logLevel; public int maxRetryCount; } public void ParseConfig(string json) { ConfigData data JsonUtility.FromJsonConfigData(json); Debug.Log($版本{data.data.version}调试模式{data.data.debugEnabled}); }第二种结构复杂、需要支持Dictionary和数组顶层的数据用Newtonsoft。两者混用也没问题只要一个项目内规范统一即可。需要牢记的是JsonUtility的字段匹配是大小写敏感的服务器返回的字段名必须和C#字段名完全一致。如果服务器是别的团队维护、字段命名习惯不同要么让服务器统一成小写驼峰要么在解析前做一次字段名转换。我经历过几次对接混乱之后就强调前后端通信协议文档必须有一份里面明确列出每个字段的名字、类型、含义这一步省不掉。3.4 配置下发场景完整串联我把前面所有代码串起来展示一个完整的配置下发流程。服务器地址用内网IP加端口例如http://192.168.1.100:8080。public IEnumerator FetchConfigCoroutine(string serverUrl, MonoBehaviour caller) { yield return caller.StartCoroutine(GetCoroutine( serverUrl /get_config?version1.0.0, json { Debug.Log(拿到配置 json); ParseConfig(json); }, error { Debug.LogWarning(请求失败 error); }, 10f )); }调用方式StartCoroutine(FetchConfigCoroutine(http://192.168.1.100:8080, this));在这个流程里Unity启动时请求一次配置服务器根据version参数判断是否需要返回最新内容。如果客户端版本落后就返回新配置如果版本一致则返回一个“无需更新”标记。这样一个简单的版本比对逻辑就为后续的热更新和远程调试提供了基础。4. 部署、联调验证与踩坑记录4.1 先在本机验证服务器服务器写好后如何确认它真的能用我习惯按下面顺序验证把服务器跑起来确认控制台输出“HTTP服务器已启动监听端口”。浏览器访问http://localhost:8080/get_config?version1看能否返回JSON。如果没有返回打开任务管理器确认端口有没有被其他进程占用。再借助curl确认接口语义curl http://localhost:8080/get_config?version1最后才把Unity客户端拉进来做联调。先本机验证这个步骤看似基础却能在第一时间排除“服务器本身有问题”和“网络环境有问题”两类干扰后面排查真机连不上时会节省大量时间。4.2 局域网连不上的排查思路最常见的问题是Unity在真机上请求服务器一直超时。这时候按顺序排查确认服务器监听地址是http://*:8080/而不是localhost。确认Android或iOS设备与服务器在同一个网段可以先ping一下服务器IP看通不通。确认操作系统防火墙放行了8080端口。Windows下可以在命令行执行netsh advfirewall firewall add rule nameOpen 8080 dirin actionallow protocolTCP localport8080如果服务器部署在路由器后面检查端口映射是否配置。这里面最容易忽略的是防火墙。很多开发机自己的防火墙策略是默认关闭的但测试机可能开了导致完全无法从局域网访问。遇到超时问题时第一反应不应该是改代码而是先做网络连通性测试。我吃过亏排查了半天代码才发现是防火墙放行规则忘了加。4.3 响应慢、超时与线程问题实际运行期间我遇到过几类典型的运行时问题。第一类是响应慢导致客户端超时。最常见元凶是服务器端读取请求体时没有设置读取超时或者ProcessRequest方法里不小心做了阻塞操作比如Thread.Sleep。因为HttpListener默认没有全局超时策略解决办法是给每个请求处理加上合理的超时机制或者把耗时离线队列化处理绝不阻塞请求线程。第二类是Unity主线程假死。在一些旧代码里有人图省事在Start方法里写while (!operation.isDone) { }死等一旦服务器网络异常整个Unity主线程会直接冻结。协程yield方式则天然避免了这个问题。这是我在项目评审时反复强调的一个规范不要在Unity主线程里用同步方式请求除非你明确知道自己在做什么。第三类是线程安全问题。HttpListener的处理逻辑运行在线程池线程上如果直接在ProcessRequest里访问Unity静态变量或调用UnityEngine API在某些情况下会触发异常或产生未定义行为。我们的封装中回调由UnityWebRequest驱动到主线程执行服务器端只负责读写数据不直接操作Unity对象两条线程的边界清晰从源头上规避了这类问题。4.4 中文乱码、端口占用与权限问题中文乱码问题绝大多数是因为编码不一致。解决思路是全链路统一UTF-8服务器端ContentType设置成application/json; charsetutf-8Unity端直接使用downloadHandler.text它内部按UTF-8解码。只要两端都遵守UTF-8中文基本不会乱码。端口占用问题一启动就报错或直接被操作系统拒绝先查占用进程netstat -ano | findstr 8080如果看到非本进程占用了端口要么杀掉占用进程要么换一个端口。有个容易被忽视的点是HttpListener在Windows上可能因为URL ACL权限不足而报错解决办法是在管理员控制台执行netsh http add urlacl urlhttp://*:8080/ userEveryone这个坑在Windows Server上尤其常见开发机反而很少遇到。4.5 常见问题速查表现象可能原因解决建议浏览器打不开端口监听失败、Prefix写错管理员权限启动检查Prefix通配符真机连不上防火墙拦截、IP不在同一网段ping验证连通性放行端口响应中文乱码两端编码不一致全链路统一UTF-8请求超时服务器阻塞、客户端timeout过短服务端异步化客户端协程重试JSON解析失败字段名不一致或大小写不匹配对照通信协议文档严格校验端口被占用其他进程占用换端口或用netstat定位占用进程Windows报权限错误URL ACL未配置netsh http add urlacl5. 进阶扩展如果还想多做一点到这里“简单够用”的HTTP服务器已经能覆盖很多内部需求。如果后续想继续完善有几个方向可以按需扩展。5.1 静态文件服务如果某个请求路径指向磁盘上的文件服务器可以直接返回文件内容。那就可以托管打包清单、更新包、配置文件等资源Unity客户端通过HTTP下载。实现时只需要在路由入口加一层判断路径映射到本地文件存在就返回文件流不存在就返回404。文件下载有一个细节要正确设置Content-Disposition和Content-Type否则客户端可能无法识别文件类型。对于小文件可以直接读进内存再写响应对于大文件建议用流式复制避免一次性加载占用过多服务器内存。5.2 更精细的协议控制如果不想用HttpListener而希望完全控制网络层可以选择基于TcpListener手工实现一个简易HTTP解析器。需要自己处理请求行的三段结构、请求头键值对、空行与请求体的边界代码量会明显增加。这种方式适合对协议细节有强定制需求或者想学习HTTP协议的同学但对绝大多数业务场景来说是过度设计了。我个人的建议是先用HttpListener把业务跑起来如果性能或灵活性成为瓶颈再针对瓶颈局部替换不要一开始就奔着底层去。5.3 HTTPS支持给HttpListener开启HTTPS支持本质上是为服务器绑定证书。测试环境下可以生成自签名证书Unity客户端请求地址从http改成https即可。需要注意自签名证书在移动端真机上经常会报证书校验失败Android上尤其明显。解决思路有两个要么把证书安装到设备信任列表要么在客户端实现证书校验逻辑对特定测试环境放行。这个坑我踩过一次。当时测试环境开启了HTTPSAndroid设备请求一直失败一开始还以为是代码问题查了半天才发现是证书不被系统信任。后来统一在客户端做了一层证书校验对开发环境的放行问题才彻底解决。5.4 向正式框架迁移如果项目从内部调试阶段进入正式生产需要应对更大的访问量、更完善的路由与认证那么迁移到ASP.NET Core Mini API或Kestrel是正确的方向。届时现在封装的Unity网络层不需要改动只需要替换服务器地址即可。这正是把项目拆成“客户端通信协议层”与“服务器实现层”带来的最大收益上层接口稳定底层实现可以按需替换。迁移时服务器的接口路径、请求参数、返回JSON结构保持兼容客户端代码几乎零改动。这种“接口优先”的设计习惯比单纯写好一个服务器更有长远价值。5.5 日志与监控增强内部服务器在开发期通常不需要监控但跑到后期你会发现一个简单日志系统非常有用。你可以在服务器端把每个请求的路径、方法、耗时、状态码输出到控制台随后写入文件。这样即使没人盯着服务器也能在问题发生后回溯现场。更进一步的增强是提供一个/health接口返回服务器当前状态比如进程存活性、内存占用、最近一次请求时间。Unity客户端启动时先请求健康检查接口如果连不上则使用本地缓存配置这样即使服务器宕机客户端也能降级运行。写在最后的一点经验我在实际项目中反复体会到一个道理工具的价值不取决于它有多先进而取决于它是否贴合团队当前的真实需求。一个用HttpListener手写的简单服务器听起来不如Kestrel高端但它确实帮我解决了不少实际的配置下发与联调需求部署简单、排错直接、不依赖外部环境。Unity端用UnityWebRequest加协程封装稳定且跨平台后续迁移到正式后端也只是改一个地址的事。如果你正在为Unity项目寻找一个轻量级的本地通信方案或者被各种框架的繁杂配置折磨到崩溃不妨也试试这个思路。先用HttpListener把服务跑起来再把UnityWebRequest的封装写好一套够用的C#HTTP通信链路就完成了。等确认业务确实需要更专业的能力时再考虑升级也不迟。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑