不装IIS也能跑C# Web服务:HttpListener轻量方案
简介针对无需IIS的轻量级部署需求这套C#自托管Web服务示例包适合.NET开发者、初学者或需要在本地快速搭建HTTP接口的调试场景。资源包共17个文件压缩后仅18KB包含后端服务处理、Windows窗体交互界面、客户端请求线程、静态HTML页面及项目配置等模块代码结构清晰可直接运行学习。该示例以HttpListener为核心完整覆盖URL前缀注册、异步请求循环、上下文处理与响应构造等关键环节能够帮助读者绕过传统IIS限制理解自托管Web服务的工作原理。配合窗体界面和客户端线程还能直观体验请求发送与回显过程适用于课程设计、内部工具或接口原型验证。截至目前已有2363人学习下载适合作为快速上手C#自托管服务的参考。1. 不装 IIS 也能跑 C# Web 服务一个轻量方案很多人一听到“C# Web 服务”脑子里立刻蹦出 IIS、ASP.NET、一大坨配置和权限问题。某天我在内网环境里排查一个问题目标机器是精简版系统IIS 装不上装上了也起不来 80 端口业务方只求一个能接收请求、返回状态码的最小接口。翻来找去最后用 C# 的 HttpListener 写了一个控制台程序整个服务不到 100 行代码编译出来的 exe 拷过去就能跑。这篇博文就把这个方案完整拆开HttpListener 的工作原理、控制台程序的完整写法、开机自启和进程守护的配置、以及我在调试过程中踩过的端口占用、编码乱码、路径路由这几个典型坑。适合不想碰 IIS 的 .NET 开发者、做本地工具集成的运维人员以及需要在受限环境里快速提供一个 HTTP 接口的场景。2. 为什么是 HttpListener先搞清底层原理再动手2.1 HttpListener 与 TcpListener 的本质区别有人会问Web 服务嘛用 TcpListener 监听端口自己解析 HTTP 报文不就行了能但那是把简单问题复杂化。TcpListener 拿到的是原始字节流你得自己处理请求行、Header、Body 的切分还得处理 Keep-Alive、Chunked 编码这些边缘情况。而 HttpListener 是 .NET Framework 内置的 HTTP 协议栈它帮你完成了 HTTP 报文的解析你拿到手的直接就是 HttpListenerRequest 对象有 Url、Headers、QueryString、InputStream 这些属性操作方式接近 ASP.NET 里的 Request 对象。我推荐 HttpListener 的核心理由有三个第一它是系统级组件不依赖 IIS 进程不依赖 ASP.NET 运行时一个 exe 就能跑第二它自带 HTTP/1.1 协议实现包括请求解析、响应组装、连接管理你不需要处理 TCP 粘包拆包这些底层细节第三它在 Windows 上默认使用 HTTP.sys 内核驱动性能对内部工具来说完全够用。选型之前别想复杂了先问自己一个问题你的接口是给机器调用的还是给浏览器用户交互用的前者选 HttpListener后者才考虑 ASP.NET。2.2 监听前缀Prefix的格式与权限规则用 HttpListener 第一关就是前缀格式。很多人第一次跑起来就报“拒绝访问”或“无法监听”其实不是代码问题是前缀写错了。正确的格式是http://*:8080/或http://:8080/注意协议、IP 或通配符、端口、路径四要素齐全。下表是前缀格式的常见用法前缀写法含义使用场景http://*:8080/监听本机所有 IP 的 8080 端口内网服务推荐不限访问网卡http://:8080/同上通配符语义更强和*效果基本一致http://localhost:8080/仅监听本机回环地址本地调试专用http://192.168.1.100:8080/绑定具体 IP多网卡机器需要固定出口时注意*和的区别其实不大但在某些 Windows 版本上需要显式 URL 保留项配置。如果监听失败可以在 cmd 里执行netsh http add urlacl urlhttp://:8080/ usereveryone添加 URL 保留项。这个命令在 Windows 7 到 Windows 11 上都适用权限不够时记得用管理员身份运行。2.3 非管理员端口绕过别死磕 80 端口还有一类坑是端口权限。在 Windows 上非管理员账户监听 80 端口通常会失败因为 HTTP.sys 对 80 端口有默认的保留策略。我在实际项目里从来不用 80统一用 8080 或 18080 这类高位端口原因有两点一是避开权限问题二是防止和本机已有的 Web 服务冲突。如果你确实需要 80 端口又不想给程序管理员权限可以执行下面的命令把端口保留给指定用户netsh http add urlacl urlhttp://:80/ userEveryone这段命令的实际效果是让 HTTP.sys 允许所有人绑定 80 端口。userEveryone可以换成具体用户名比如userDOMAIN\username。执行完后用netsh http show urlacl可以查看到当前的 URL 保留项列表。我是建议非必要不上 80因为后面排查端口冲突的成本远高于你换一个端口号的成本。3. 一个可用的最小 HttpListener 服务完整代码与逐段拆解3.1 控制台程序的骨架与上下文处理直接给一个我调试过能跑的完整版本。这个版本比网上很多 Demo 多做了一件事处理了跨域响应头这样浏览器里的页面也能直接调用它。先看主程序入口和请求分发逻辑using System; using System.Collections.Generic; using System.IO; using System.Net; using System.Text; using System.Threading.Tasks; class SimpleWebServer { private readonly HttpListener _listener new HttpListener(); private readonly Dictionarystring, FuncHttpListenerContext, Task _routes; public SimpleWebServer(string prefix, int port) { _listener.Prefixes.Add($http://{prefix}:{port}/); _routes new Dictionarystring, FuncHttpListenerContext, Task(); } public void RegisterRoute(string path, FuncHttpListenerContext, Task handler) { _routes[path] handler; } public async Task StartAsync() { _listener.Start(); Console.WriteLine($服务已启动监听地址: {string.Join(, , _listener.Prefixes)}); while (_listener.IsListening) { var context await _listener.GetContextAsync(); _ Task.Run(() ProcessRequest(context)); } } private async Task ProcessRequest(HttpListenerContext context) { var path context.Request.Url.AbsolutePath.TrimEnd(/); if (string.IsNullOrEmpty(path)) path /; Console.WriteLine($[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {context.Request.HttpMethod} {path}); if (_routes.TryGetValue(path, out var handler)) { await handler(context); } else { context.Response.StatusCode 404; await WriteResponseAsync(context, {\error\:\route not found\}, application/json); } } private static async Task WriteResponseAsync(HttpListenerContext context, string json, string contentType) { var bytes Encoding.UTF8.GetBytes(json); context.Response.ContentType contentType; context.Response.ContentLength64 bytes.Length; context.Response.AddHeader(Access-Control-Allow-Origin, *); context.Response.AddHeader(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS); context.Response.AddHeader(Access-Control-Allow-Headers, Content-Type); await context.Response.OutputStream.WriteAsync(bytes, 0, bytes.Length); context.Response.Close(); } }这个代码块的逻辑说明_listener.Prefixes.Add绑定监听地址和端口RegisterRoute方法做路由注册StartAsync里用了GetContextAsync()异步获取请求拿到请求后丢给Task.Run去异步处理避免阻塞主循环。这里有一个关键点GetContextAsync()在_listener.Stop()或Dispose()之后会抛异常所以 while 条件里的IsListening判断不是百分之百可靠实际停止时要用 try-catch 包住GetContextAsync()。参数说明HttpListenerContext是整个请求的上下文容器包含 Request、Response 两个子对象。Request.Url.AbsolutePath取的是 URL 的路径部分比如请求http://localhost:8080/api/health这里拿到的就是/api/healthTrimEnd(/)是为了把/归一化成空字符串再补成/方便做字典匹配。WriteResponseAsync接收三个参数请求上下文、返回的 JSON 字符串、Content-Type 类型逐层把字符串转成 UTF-8 字节流写入响应流。3.2 路由注册与 JSON读取 Body 的工具方法再补上路由注册的实际使用和读取请求 Body 的方法。很多网上 Demo 不读取 Body只处理 GET实际使用中 POST 请求才是常态private static string ReadRequestBody(HttpListenerRequest request) { if (!request.HasEntityBody) return string.Empty; using var reader new StreamReader(request.InputStream, request.ContentEncoding ?? Encoding.UTF8); return reader.ReadToEnd(); } private static string GetQueryParam(HttpListenerRequest request, string key) { return request.QueryString[key] ?? string.Empty; } class Program { static async Task Main(string[] args) { var server new SimpleWebServer(, 8080); server.RegisterRoute(/api/health, async context { await SimpleWebServer.WriteResponseAsync(context, {\status\:\ok\,\time\:\ DateTime.Now.ToString(yyyy-MM-dd HH:mm:ss) \}, application/json); }); server.RegisterRoute(/api/echo, async context { var body ReadRequestBody(context.Request); var query GetQueryParam(context.Request, name); var response new System.Text.StringBuilder(); response.Append({\received\:\ (body.Length 0 ? body : (no body)) \,); response.Append(\queryName\:\ query \}); await SimpleWebServer.WriteResponseAsync(context, response.ToString(), application/json); }); server.RegisterRoute(/api/upload, async context { var fileName GetQueryParam(context.Request, filename); var data ReadRequestBody(context.Request); var filePath Path.Combine(D:\\uploads, fileName); await File.WriteAllTextAsync(filePath, data); await SimpleWebServer.WriteResponseAsync(context, {\saved\:\ filePath \,\size\: data.Length }, application/json); }); await server.StartAsync(); } }逻辑说明ReadRequestBody方法先判断HasEntityBody防止没有 Body 的 GET 请求去读流导致异常ContentEncoding属性可能为 null所以用?? Encoding.UTF8做了兜底。/api/upload路径展示了如何接收前端 POST 上来的文本内容并落盘这在做内网小工具时非常实用。这段代码有几个参数需要特别说明request.QueryString[key]返回的是 string 类型如果 key 不存在返回 null写代码时不能假定它非空File.WriteAllTextAsync的路径目录必须已经存在否则会抛DirectoryNotFoundExceptionContentEncoding在 request 没有明确指定编码时经常是 null直接传 null 给StreamReader会导致问题加个空合并运算符是必须的。3.3 静态文件服务的补充图片和 HTML 怎么返回上面都是返回 JSON但实际场景里很可能需要返回图片或 HTML 页面。这里有一个大坑直接把文件字节流写进OutputStream时不设置 ContentType浏览器会把它当纯文本显示。补充一个能用的静态文件处理方法server.RegisterRoute(/static, async context { var filePath Path.Combine(D:\\webroot, Path.GetFileName(context.Request.Url.AbsolutePath)); if (!File.Exists(filePath)) { context.Response.StatusCode 404; await SimpleWebServer.WriteResponseAsync(context, {\error\:\file not found\}, application/json); return; } var extension Path.GetExtension(filePath).ToLower(); var contentType extension switch { .html text/html; charsetutf-8, .js application/javascript, .css text/css, .png image/png, .jpg or .jpeg image/jpeg, _ application/octet-stream }; var fileBytes await File.ReadAllBytesAsync(filePath); context.Response.ContentType contentType; context.Response.ContentLength64 fileBytes.Length; await context.Response.OutputStream.WriteAsync(fileBytes, 0, fileBytes.Length); context.Response.Close(); });参数说明Path.GetFileName()这里做了安全处理防止路径穿越攻击它只取 URL 中的文件名部分丢弃所有目录路径。extension switch是 C# 8.0 的写法如果你的编译环境是旧版 .NET Framework要改成传统的 if-else 判断。ContentLength64必须设置为文件字节数否则 HTTP.sys 可能用 chunked 编码导致某些客户端接收到的数据不完整。4. 把服务做成 Windows 服务开机自启与进程守护4.1 用 NSSM 替代 IIS 的托管模式控制台程序有个痛点用户一关命令行窗口进程就死了服务器重启后还得手动登录去启动。内网环境的服务器大多是无人值守的这问题必须解决。常见做法是用 NSSMNon-Sucking Service Manager把控制台程序注册成 Windows 服务。NSSM 是一个第三方小工具但它在运维圈用了十几年稳定性和口碑都够硬。下载 NSSM 后解压在管理员 cmd 里执行nssm install SimpleWebServer D:\tools\SimpleWebServer.exe nssm set SimpleWebServer AppDirectory D:\tools nssm set SimpleWebServer DisplayName 内网文件接收服务 nssm start SimpleWebServer这里的关键参数是AppDirectory很多服务起不来就是因为工作目录不对——程序里如果用相对路径读文件工作目录会默认指向C:\Windows\System32。DisplayName是服务在 Windows 服务管理器里显示的名字不影响内部名SimpleWebServer。start命令立即拉起服务。4.2 日志采集与崩溃自动重启NSSM 还兼职做了进程守护程序崩溃后它能自动重启。配置方式nssm set SimpleWebServer AppExitAction Restart nssm set SimpleWebServer AppRestartDelay 5000 nssm set SimpleWebServer AppStdout D:\logs\server_out.log nssm set SimpleWebServer AppStderr D:\logs\server_err.log nssm set SimpleWebServer AppRotateFiles 1 nssm set SimpleWebServer AppRotateOnline 1 nssm set SimpleWebServer AppRotateBytes 10485760参数解释AppExitAction Restart表示进程退出后自动重启AppRestartDelay 5000是重启延迟 5 秒避免程序崩了立刻又拉起来、连续崩溃循环。日志配置里AppStdout和AppStderr分别接管标准输出和错误输出AppRotateBytes设置为 1048576010MB表示日志超过 10MB 自动轮转。这些配置是血泪经验换来的——最开始我没配日志轮转一个月后日志文件膨胀到 3GB磁盘直接满了服务写日志异常然后挂掉。我一般还会把服务的启动方式设为自动命令是nssm set SimpleWebServer Start SERVICE_AUTO_START这样服务器重启后服务自动拉起省去人工干预。如果你希望服务器重启后服务自动启动但有延迟可以用SERVICE_DELAYED_AUTO_START适合需要等网络或依赖服务就绪的场景。4.3 防火墙放行与外部访问测试服务在本机能访问外部机器访问不到八成是防火墙挡了 8080 端口。在管理员 PowerShell 里执行New-NetFirewallRule -DisplayName SimpleWebServer 8080 -Direction Inbound -LocalPort 8080 -Protocol TCP -Action AllowNew-NetFirewallRule的参数含义DisplayName是规则显示名Direction Inbound表示入站规则LocalPort 8080是本地端口Protocol TCP指定 TCP 协议。执行完后可以用Get-NetFirewallRule -DisplayName SimpleWebServer 8080查看规则是否创建成功。验证外部访问的命令Invoke-WebRequest -Uri http://192.168.1.100:8080/api/health -UseBasicParsing-UseBasicParsing参数很关键没有它 PowerShell 在某些 Windows 版本上会用 IE 引擎解析响应反而容易出奇怪问题。正常响应StatusCode应该是 200。如果外部访问超时优先检查防火墙规则然后确认服务监听地址不是localhost最后用netstat -ano | findstr 8080确认进程实际在监听。5. 调试与验证的几个硬技巧附踩坑记录5.1 请求日志的玄学不同客户端的差异日志能解决一半的联网问题。我在这个服务里加了请求日志打印时间、方法、路径、来源 IP调试时帮了大忙。但有个细节让我反复翻车同一个 URL浏览器发出的请求会有Origin头而Invoke-WebRequest发出的请求没有浏览器请求带不带 Cookie 取决于站点上下文POST 请求的 JSON 可能带 BOM 头导致第一个字段解析失败。一个典型的日志记录代码Console.WriteLine($[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] $来源: {context.Request.RemoteEndPoint} | $方法: {context.Request.HttpMethod} | $路径: {path} | $UA: {context.Request.UserAgent});RemoteEndPoint返回的是IPEndPoint类型打印出来是IP:端口格式能直接看到请求来自哪台机器。UserAgent能区分客户端类型这在排查某个客户端发来的请求为什么和预期不一致的时候特别有用。注意context.Request.RemoteEndPoint可能为 null理论上是不会但我在某些代理环境下碰到过。5.2 避免常见的五个坑现象、原因、解决以下是这个项目里我实际踩过、也帮别人排查过的五个高频问题。坑一端口被占用现象启动服务时直接报HttpListenerException: 拒绝访问或者仅可使用一个使用该地址的监听程序。原因端口被另一个进程占用了或者前一个进程还没完全释放端口。排查netstat -ano | findstr 8080看 PID再tasklist /FI PID eq PID号看是什么进程。解决换端口或者结束占用进程。一般我直接换 8081省时间。坑二中文乱码现象服务端返回的中文 JSON 在浏览器里显示正常用HttpClient接收变乱码。原因响应头没有声明charsetutf-8HttpClient 默认按 ISO-8859-1 解码。解决WriteResponseAsync方法里 ContentType 用application/json; charsetutf-8而不是application/json。这是个小改动但影响面很大。坑三路由匹配不上现象请求/api/health/返回 404请求/api/health正常返回。原因末尾带斜杠的路径和注册的键不一致。解决ProcessRequest里对 path 做规范化处理把斜杠 trim 掉。但要注意如果希望区分/api/health和/api/health/是两个不同路由就不能这么做。坑四文件名中文无法读取现象POST 文件落盘时参数filename是中文文件名存成乱码。原因QueryString 解析默认用 UTF-8但是浏览器或客户端把中文 URL 编码成了别的格式。解决要么调整客户端发送用encodeURIComponent要么服务端手动解码一次。最稳妥的是服务端统一处理Uri.UnescapeDataString(context.Request.QueryString[filename])。坑五HTTP 请求体过大导致读取超时现象POST 大文件时前端显示成功服务端收到的是空 Body 或截断。原因HttpListenerRequest.InputStream读取时没有设置超时时间或者客户端请求头没有设置合适的Expect: 100-continue。解决ReadRequestBody里指定request.InputStream.ReadTimeout同时客户端发送大文件时用流式上传而不是一次性 StringContent。5.3 善用 curl 与格式化的调试请求调试 HttpListener 服务时我一直用 curl 做验证比浏览器干净太多。下面几条命令覆盖了 GET、POST、带 Header 和文件上传四种场景curl http://localhost:8080/api/health curl -X POST http://localhost:8080/api/echo -d hello world curl -X POST http://localhost:8080/api/echo -H Content-Type: application/json -d {\key\:\value\} curl -X POST http://localhost:8080/api/upload?filenametest.txt --data-binary test.txt第一、二条命令最常用。-d hello world会把内容作为 form 数据发上去Content-Type 自动变成application/x-www-form-urlencoded。第三条命令手动加了 JSON 头服务端ReadRequestBody才能正确解析。第四条命令用--data-binary test.txt读取本地文件作为请求体比-d更可控不会做换行符转换。调试时注意curl 命令默认不显示响应头需要用-i参数才能看到 Status Code 和 Header紧跟-v参数能看到完整的请求和响应报文定位问题最快。6. 进阶配置安全、并发与优雅停机的边界6.1 给服务加一个最简单的 Token 校验如果服务暴露在内网但你想限制只有少数调用方可以访问可以在请求入口加一个 Token 检查不做完整认证只做基础拦击。在ProcessRequest最前面加上private const string ApiToken your-secret-token; private static bool IsAuthorized(HttpListenerRequest request) { var authHeader request.Headers[Authorization]; return authHeader $Bearer {ApiToken}; }然后在ProcessRequest里对除/api/health之外的请求调用IsAuthorized(context.Request)不通过就直接返回 401。这个方案适合内网自用不要在公网这么干因为 Token 在 HTTP 明文传输里是能被抓到的。如果要更安全至少用 HTTPS 绑定证书netsh http add sslcert ipport0.0.0.0:8080 certhash证书哈希 appid{GUID}这一步比较麻烦内网自用通常用 Token 就够了。6.2 并发处理的上限与线程安全边界Task.Run(() ProcessRequest(context))这个写法会让每个请求跑在独立线程上理论并发能力受限于线程池大小。内部工具并发在 100 以下完全没问题再高就需要改用SemaphoreSlim控流或者引入消息队列。这里有三个线程安全边界要注意第一个是Console.WriteLine的线程安全问题。多请求并发打印日志时控制台输出可能交错但不会崩因为 Console 内部有锁不过建议打印内容拼接成一个字符串再输出减少跨线程写入的碎片。第二个是路由字典的TryGetValue读取并发。RegisterRoute只在启动时调用运行期只读所以不需要加锁。如果你要在运行期动态增加路由就要改成ConcurrentDictionary。第三个是文件写入的并发。/api/upload多个请求同时写同一个文件名时后写的会覆盖先写的。如果要做防重可以在文件名后面拼时间戳或 GUID比如${Path.GetFileNameWithoutExtension(fileName)}_{DateTime.Now:yyyyMMddHHmmss}{Path.GetExtension(fileName)}。这个做法在多人同时提交同一名称的文件时避免互相覆盖。6.3 停止服务的优雅处理控制台程序里按 CtrlC 直接退出会导致当前正在处理的请求被中断客户端那边会收到连接重置。加一个关闭处理逻辑Console.CancelKeyPress async (sender, e) { e.Cancel true; Console.WriteLine(正在停止服务...); _listener.Stop(); _listener.Close(); Console.WriteLine(服务已停止当前请求将完成处理。); Environment.Exit(0); };CancellKeyPress事件里设置e.Cancel true表示阻止默认的进程终止行为然后手动调用_listener.Stop()。这里要说明Stop()会立即停止接收新请求但不会中断已经在处理中的请求已经进入ProcessRequest的上下文还能继续写响应。这个机制比直接杀进程温和很多。调用Close()释放底层资源最后Environment.Exit(0)收尾。如果你用 NSSM 托管服务NSSM 停止服务时是发停止信号给进程不是直接 kill所以这个 CtrlC 处理器和 NSSM 的AppStopMethodConsole参数能协同建议配成nssm set SimpleWebServer AppStopMethodConsole 1让 NSSM 用发送 CtrlC 的方式优雅停止服务而不是AppStopMethodKill直接杀。从那以后我每次做这类工具的部署都是先看启动方式再看停止方式配置 NSSM 时强制把这三条命令过一遍安装服务、设置工作目录、配置优雅停止这套流程已经成了我的默认习惯希望帮到你。本文还有配套的精品资源点击获取