真正动手写这篇笔记的起因,是某次项目里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 Core | NuGet包较多 | 较大 | 正式对外服务、高并发 |
这次的主角是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; charset=utf-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, Action<string> onSuccess, Action<string> 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, Action<string> onSuccess, Action<string> 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.FromJson<ConfigData>(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?version=1.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?version=1,看能否返回JSON。 - 如果没有返回,打开任务管理器确认端口有没有被其他进程占用。
- 再借助curl确认接口语义:
curl http://localhost:8080/get_config?version=1- 最后才把Unity客户端拉进来做联调。
先本机验证这个步骤看似基础,却能在第一时间排除“服务器本身有问题”和“网络环境有问题”两类干扰,后面排查真机连不上时会节省大量时间。
4.2 局域网连不上的排查思路
最常见的问题是:Unity在真机上请求服务器一直超时。这时候按顺序排查:
- 确认服务器监听地址是
http://*:8080/,而不是localhost。 - 确认Android或iOS设备与服务器在同一个网段,可以先ping一下服务器IP看通不通。
- 确认操作系统防火墙放行了8080端口。Windows下可以在命令行执行:
netsh advfirewall firewall add rule name="Open 8080" dir=in action=allow protocol=TCP localport=8080 - 如果服务器部署在路由器后面,检查端口映射是否配置。
这里面最容易忽略的是防火墙。很多开发机自己的防火墙策略是默认关闭的,但测试机可能开了,导致完全无法从局域网访问。遇到超时问题时,第一反应不应该是改代码,而是先做网络连通性测试。我吃过亏,排查了半天代码才发现是防火墙放行规则忘了加。
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; charset=utf-8,Unity端直接使用downloadHandler.text,它内部按UTF-8解码。只要两端都遵守UTF-8,中文基本不会乱码。
端口占用问题,一启动就报错或直接被操作系统拒绝,先查占用进程:
netstat -ano | findstr 8080如果看到非本进程占用了端口,要么杀掉占用进程,要么换一个端口。有个容易被忽视的点是HttpListener在Windows上可能因为URL ACL权限不足而报错,解决办法是在管理员控制台执行:
netsh http add urlacl url=http://*:8080/ user=Everyone这个坑在Windows Server上尤其常见,开发机反而很少遇到。
4.5 常见问题速查表
| 现象 | 可能原因 | 解决建议 |
|---|---|---|
| 浏览器打不开 | 端口监听失败、Prefix写错 | 管理员权限启动,检查Prefix通配符 |
| 真机连不上 | 防火墙拦截、IP不在同一网段 | ping验证连通性,放行端口 |
| 响应中文乱码 | 两端编码不一致 | 全链路统一UTF-8 |
| 请求超时 | 服务器阻塞、客户端timeout过短 | 服务端异步化,客户端协程重试 |
| JSON解析失败 | 字段名不一致或大小写不匹配 | 对照通信协议文档严格校验 |
| 端口被占用 | 其他进程占用 | 换端口或用netstat定位占用进程 |
| Windows报权限错误 | URL ACL未配置 | netsh http add urlacl |
5. 进阶扩展:如果还想多做一点
到这里,“简单够用”的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上尤其明显。解决思路有两个:要么把证书安装到设备信任列表,要么在客户端实现证书校验逻辑,对特定测试环境放行。
这个坑我踩过一次。当时测试环境开启了HTTPS,Android设备请求一直失败,一开始还以为是代码问题,查了半天才发现是证书不被系统信任。后来统一在客户端做了一层证书校验对开发环境的放行,问题才彻底解决。
5.4 向正式框架迁移
如果项目从内部调试阶段进入正式生产,需要应对更大的访问量、更完善的路由与认证,那么迁移到ASP.NET Core Mini API或Kestrel是正确的方向。届时,现在封装的Unity网络层不需要改动,只需要替换服务器地址即可。这正是把项目拆成“客户端通信协议层”与“服务器实现层”带来的最大收益:上层接口稳定,底层实现可以按需替换。
迁移时服务器的接口路径、请求参数、返回JSON结构保持兼容,客户端代码几乎零改动。这种“接口优先”的设计习惯,比单纯写好一个服务器更有长远价值。
5.5 日志与监控增强
内部服务器在开发期通常不需要监控,但跑到后期,你会发现一个简单日志系统非常有用。你可以在服务器端把每个请求的路径、方法、耗时、状态码输出到控制台,随后写入文件。这样即使没人盯着服务器,也能在问题发生后回溯现场。
更进一步的增强是提供一个/health接口,返回服务器当前状态,比如进程存活性、内存占用、最近一次请求时间。Unity客户端启动时先请求健康检查接口,如果连不上则使用本地缓存配置,这样即使服务器宕机,客户端也能降级运行。
写在最后的一点经验
我在实际项目中反复体会到一个道理:工具的价值不取决于它有多先进,而取决于它是否贴合团队当前的真实需求。一个用HttpListener手写的简单服务器,听起来不如Kestrel高端,但它确实帮我解决了不少实际的配置下发与联调需求,部署简单、排错直接、不依赖外部环境。Unity端用UnityWebRequest加协程封装,稳定且跨平台,后续迁移到正式后端也只是改一个地址的事。
如果你正在为Unity项目寻找一个轻量级的本地通信方案,或者被各种框架的繁杂配置折磨到崩溃,不妨也试试这个思路。先用HttpListener把服务跑起来,再把UnityWebRequest的封装写好,一套够用的C#HTTP通信链路就完成了。等确认业务确实需要更专业的能力时,再考虑升级也不迟。