☰
C# Web服务无需IIS:HttpListener轻量HTTP服务闭环
2026/11/4 12:18:52 网站建设 项目流程

简介:这份 C# 示例工程面向需要在 Windows 上快速搭建轻量 HTTP 服务的开发者,适用于不安装、不依赖 IIS 的开发调试与小型部署场景。核心做法是借助 .NET 内置的 HttpListener 实现自托管:设置监听前缀、启动监听、循环获取请求上下文并交给回调处理,同时提供 Windows Forms 界面与客户端线程模块,可自行模拟发送 HTTP 请求做联调验证,静态页面也能由服务直接对外提供。压缩包共 17 个文件、约 18KB,以 8 个 cs 源码为主体,辅以 htm 静态页面、xml 配置、resx 资源以及 csproj 工程与 settings 配置,体量小、依赖少,解压后用 Visual Studio 打开即可编译运行。目前已有 2364 人学习下载。读者能拿到一套可直接运行的最小自托管服务骨架,理清请求监听、响应构造与启停控制的完整链路,并借鉴其客户端测试与静态资源目录的组织方式。

1. 从一台不装 IIS 的机器说起:C# Web服务 无需IIS 的最小闭环

某公司内网的一台采集机要临时开个 HTTP 接口,运维不想为它装 IIS,开发也不想背站点、应用池、绑定配置这一套包袱,最后落到 C# Web服务 无需IIS 这条路上:一个控制台程序,里面起HttpListener,双击就能回 JSON,关掉窗口服务就停。它解决的是“只想在进程内挂一个轻量 HTTP 入口”的问题,不是替代完整 Web 框架。适合内网小工具、采集器回调、设备侧配置页、临时联调接口;不适合直接暴露公网、需要复杂路由和静态资源管线的长期业务。下面按“能跑起来、能接路由、能挂住、少翻车”的顺序拆。

2. HttpListener 起步:从前缀绑定到第一个可 curl 的响应

2.1 选 HttpListener 还是自托管 Kestrel

HttpListener是 .NET 基础类库里的 HTTP 服务器 API,不需要额外 NuGet 包,也不需要 IIS。它的工作方式很直接:先注册一个 URL 前缀,调用Start()开始监听,然后循环拿HttpListenerContext,从Request读方法、路径、请求体,往Response写状态码、响应头和响应体。Windows 上它底层走 HTTP.sys,所以绑定http://+:8080/这类通配前缀时会碰到 URL ACL 权限问题;绑定http://localhost:8080/则只监听回环地址,适合本机联调。

选它的理由不是“性能最强”,而是“依赖最少”。一个内网采集程序要接收几个回调,或者一个桌面工具要开个本地配置页,用HttpListener几十行就能闭环,发布出来就是一个 exe 加一个配置文件,不需要装运行时之外的 Web 组件。代价是路由、静态文件、MIME、并发限流、日志、优雅停止都得自己补。常见做法是先用它把接口跑通,等接口数量上来、需要中间件和跨平台时,再迁到 ASP.NET Core 的 Kestrel 最小 API。

Kestrel 是自托管服务器,跨平台,路由、静态文件、模型绑定、日志都有现成积木。若目标机器是 Linux 或 macOS,或者团队后续要接 Swagger、鉴权、健康检查,直接上 Kestrel 更省事。HttpListener在非 Windows 平台上的支持情况要先在目标框架上验证,不要默认“C# 写的就能到处跑”。我的取舍标准是:只回几个内网接口、机器固定 Windows、不想引 Web 框架,选HttpListener;要长期维护、要跨平台、要静态资源管线,选 Kestrel。

2.2 一个控制台程序的最小请求闭环

先建一个控制台项目,把下面这段放进入口文件。它做了四件事:注册前缀、启动监听、循环取上下文、把请求交给线程池处理。代码不长,但每一行都对应一个容易翻车的参数。

using System; using System.Net; using System.Text; using System.Threading.Tasks; class Program { static async Task Main(string[] args) { // 末尾斜杠不能省,否则前缀匹配会出问题 var prefix = "http://localhost:8080/"; using var listener = new HttpListener(); listener.Prefixes.Add(prefix); listener.Start(); Console.WriteLine($"Listening on {prefix}"); while (true) { // GetContextAsync 只负责拿到请求,不负责处理业务 var ctx = await listener.GetContextAsync(); _ = Task.Run(() => HandleAsync(ctx)); } } static async Task HandleAsync(HttpListenerContext ctx) { try { var req = ctx.Request; var res = ctx.Response; var text = $"path={req.Url?.AbsolutePath} method={req.HttpMethod}"; var bytes = Encoding.UTF8.GetBytes(text); res.StatusCode = 200; res.ContentType = "text/plain; charset=utf-8"; res.ContentLength64 = bytes.Length; // 必须是字节数,不是字符数 await res.OutputStream.WriteAsync(bytes, 0, bytes.Length); } catch (Exception ex) { Console.WriteLine($"handle error: {ex.Message}"); } finally { ctx.Response.Close(); // 不关闭,客户端会一直等 } } }

prefix写成http://localhost:8080/时,只有本机通过localhost或127.0.0.1能访问,局域网其他机器访问不到。想让内网访问,通常改成http://+:8080/或http://*:8080/,但这两类通配前缀在 Windows 上需要 URL ACL 授权,否则启动时报“拒绝访问”。GetContextAsync()是异步等待下一个请求,放在while里形成 accept 循环;它本身不处理业务,所以后面用Task.Run把HandleAsync丢到线程池,避免一个慢请求卡住后续连接。ContentLength64必须等于响应体的字节长度,Encoding.UTF8.GetBytes之后取Length,不要拿字符串Length直接赋值,中文会短。Response.Close()放在finally里,保证异常时也能把连接收掉。

跑起来后先用 curl 看状态行和响应头:

curl -i http://localhost:8080/hello

返回里应看到HTTP/1.1 200 OK、Content-Type: text/plain; charset=utf-8和正确的Content-Length。如果 curl 一直转圈,优先检查ContentLength64是否设置、OutputStream是否关闭;如果浏览器报连接被拒绝,先确认监听前缀和端口,再看防火墙。

2.3 响应头、长度和关闭顺序

HTTP 响应由状态行、响应头、空行、响应体组成,HttpListener不会替你猜长度。写文本时先编码成字节数组,设置ContentLength64,再写OutputStream,最后关闭响应。顺序反了,比如先写流再改状态码,状态码可能已经随头部发出,改了也不生效。响应头里ContentType要带charset=utf-8,不然后端返回中文,浏览器可能按 Latin-1 猜,页面上就是乱码。

一个可复用的小工具方法长这样:

static async Task WriteTextAsync(HttpListenerContext ctx, int status, string contentType, string text) { var bytes = Encoding.UTF8.GetBytes(text); ctx.Response.StatusCode = status; ctx.Response.ContentType = contentType; ctx.Response.ContentLength64 = bytes.Length; await ctx.Response.OutputStream.WriteAsync(bytes, 0, bytes.Length); ctx.Response.Close(); }

参数里status用 200、400、404、413、500 这些标准码;contentType按返回内容选,纯文本用text/plain; charset=utf-8,JSON 用application/json; charset=utf-8,HTML 用text/html; charset=utf-8。text传字符串,方法内部统一转字节。这个方法把“设头、写体、关闭”收在一起,调用方不用重复记顺序。注意它内部已经Close(),外层不要再关一次;如果外层还有finally关闭,重复关闭通常不报错,但逻辑上容易混乱,建议统一出口。

3. 路由与静态文件:把 /api 和 /index.html 分开处理

3.1 路径分发:switch、URL 解码与查询参数

HttpListener不提供路由表,路径就是一个字符串。最简单的做法是拿req.Url.AbsolutePath做switch,把/api/time、/api/echo、/分到不同处理方法。路径比较要忽略大小写,末尾斜杠要统一,否则/api/time和/api/time/会进两个分支。查询参数用req.QueryString取,它返回的是已解码的键值集合,但键不存在时是null,取值前要判空。

static async Task RouteAsync(HttpListenerContext ctx) { var req = ctx.Request; var path = (req.Url?.AbsolutePath ?? "/").TrimEnd('/'); if (path.Length == 0) path = "/"; switch (path.ToLowerInvariant()) { case "/": case "/index.html": await WriteTextAsync(ctx, 200, "text/html; charset=utf-8", "<h1>ok</h1>"); break; case "/api/time": var name = req.QueryString["name"] ?? "guest"; var json = $"{{\"name\":\"{name}\",\"now\":\"{DateTimeOffset.Now:O}\"}}"; await WriteTextAsync(ctx, 200, "application/json; charset=utf-8", json); break; default: await WriteTextAsync(ctx, 404, "text/plain; charset=utf-8", "not found"); break; } }

TrimEnd('/')把末尾斜杠统一掉,根路径单独补回/,这样/api/time/和/api/time走同一分支。ToLowerInvariant()做大小写归一,避免客户端大小写不一致导致 404。req.QueryString["name"]取不到时给默认值,不要直接拼进 JSON 而不转义;上面示例只做演示,真实场景里名字里可能有引号,应该用System.Text.Json.JsonSerializer.Serialize构造对象,而不是手拼字符串。路径里如果带%2F、%2e%2e这类编码,AbsolutePath的行为要实际测,静态文件场景必须再做一次规范化校验。

3.2 静态文件的 MIME、缓存与目录穿越校验

静态文件是HttpListener最容易写出漏洞的地方。请求路径直接拼到根目录,攻击者用../或%2e%2e%2f就能读到根目录之外的文件。正确做法是先把 URL 路径解码,再和根目录组合,用Path.GetFullPath规范化,最后检查结果是否仍在根目录之下。MIME 类型用扩展名字典映射,未知类型回退到application/octet-stream,不要一律text/plain。

static readonly string Root = Path.GetFullPath( Path.Combine(AppContext.BaseDirectory, "wwwroot")); static readonly Dictionary<string, string> Mime = new(StringComparer.OrdinalIgnoreCase) { [".html"] = "text/html; charset=utf-8", [".css"] = "text/css; charset=utf-8", [".js"] = "application/javascript; charset=utf-8", [".json"] = "application/json; charset=utf-8", [".png"] = "image/png", [".jpg"] = "image/jpeg", [".svg"] = "image/svg+xml" }; static async Task ServeStaticAsync(HttpListenerContext ctx, string urlPath) { var rel = Uri.UnescapeDataString(urlPath.TrimStart('/')); if (string.IsNullOrWhiteSpace(rel)) rel = "index.html"; var full = Path.GetFullPath(Path.Combine(Root, rel)); var rootWithSep = Root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; // 必须用“根目录 + 分隔符”比较,防止 wwwroot2 这类前缀绕过 if (!full.StartsWith(rootWithSep, StringComparison.OrdinalIgnoreCase)) { await WriteTextAsync(ctx, 403, "text/plain; charset=utf-8", "forbidden"); return; } if (!File.Exists(full)) { await WriteTextAsync(ctx, 404, "text/plain; charset=utf-8", "not found"); return; } var ext = Path.GetExtension(full); var type = Mime.TryGetValue(ext, out var t) ? t : "application/octet-stream"; var bytes = await File.ReadAllBytesAsync(full); ctx.Response.StatusCode = 200; ctx.Response.ContentType = type; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.Headers["Cache-Control"] = "no-cache"; await ctx.Response.OutputStream.WriteAsync(bytes, 0, bytes.Length); ctx.Response.Close(); }

Root用AppContext.BaseDirectory拼wwwroot,保证发布后路径跟着 exe 走,而不是跟着当前工作目录走。Uri.UnescapeDataString处理%20这类编码,但解码后仍要GetFullPath做规范化。StartsWith(rootWithSep)是关键,不能只写StartsWith(Root),否则wwwroot2会被误判为合法。File.ReadAllBytesAsync适合小文件,大文件应该用FileStream复制到OutputStream,同时把ContentLength64设成文件长度,避免一次性吃内存。Cache-Control先设no-cache,调试期省得浏览器缓存旧页面;上线静态资源带哈希文件名时再改成长缓存。

3.3 表单与 JSON 请求体的读取边界

读请求体之前先看ContentLength64。它表示声明长度,可能很大,也可能为 -1 表示未知。直接ReadToEnd会让一个超大请求把内存打满。常见做法是设一个上限,比如 64 KB,超过就返回 413;读取时用req.ContentEncoding,为空则回退 UTF-8。

if (req.HttpMethod == "POST" && path.Equals("/api/echo", StringComparison.OrdinalIgnoreCase)) { if (req.ContentLength64 > 64 * 1024) { await WriteTextAsync(ctx, 413, "text/plain; charset=utf-8", "too large"); return; } var encoding = req.ContentEncoding ?? Encoding.UTF8; using var reader = new StreamReader(req.InputStream, encoding); var body = await reader.ReadToEndAsync(); // 这里只做透传示例,真实业务建议用 JsonSerializer 反序列化并校验字段 await WriteTextAsync(ctx, 200, "application/json; charset=utf-8", body); return; }

ContentLength64 > 64 * 1024只是第一道闸,分块传输时长度可能未知,所以还要在读取层做限制。StreamReader用完即释放,req.InputStream不要手动提前关。JSON 解析用System.Text.Json,不要用字符串拼接;表单application/x-www-form-urlencoded可以读成字符串后用HttpUtility.ParseQueryString,但multipart/form-data文件上传不要手写解析器,直接换 Kestrel 或 ASP.NET Core 的模型绑定,省下来的时间比调试边界多。

4. 并发、生命周期与部署:让服务从“能跑”到“敢挂着”

4.1 GetContextAsync 循环与背压限流

while(true) { var ctx = await listener.GetContextAsync(); _ = Task.Run(...); }在请求少时没问题,压测一上来就会暴露两个问题:线程池被慢请求占满,Task.Run没有上限,内存和句柄一起涨。GetContextAsync只是接客,业务处理必须异步,数据库、文件、HTTP 调用都不能同步阻塞。更稳的做法是加一个SemaphoreSlim控制同时处理的请求数,超过就排队或直接返回 503。

using var gate = new SemaphoreSlim(32, 32); while (true) { var ctx = await listener.GetContextAsync(); // 尝试拿槽位,拿不到立即拒绝,避免无限排队 if (!await gate.WaitAsync(0)) { _ = Task.Run(() => WriteTextAsync(ctx, 503, "text/plain; charset=utf-8", "busy")); continue; } _ = Task.Run(async () => { try { await RouteAsync(ctx); } catch (Exception ex) { Console.WriteLine($"route error: {ex.Message}"); } finally { gate.Release(); } }); }

SemaphoreSlim(32, 32)的第一个参数是初始许可数,第二个是最大许可数,这里表示最多 32 个请求同时处理。WaitAsync(0)不等待,拿不到就立刻走 503 分支,适合“宁可拒绝也不拖垮”的内网小服务;如果希望排队,改成await gate.WaitAsync(),但要接受客户端等待时间变长。gate.Release()必须放在finally,否则一次异常就会永久少一个槽位。32 不是拍脑袋的固定值,压测时看 CPU、内存和下游依赖的承受能力再调;这个数字控制的是并发处理数,不是 TCP 连接数。

4.2 优雅停止、异常兜底和日志落点

控制台程序被 Ctrl+C 或关窗口时,监听线程会直接断,正在写的响应可能半截。加一个取消信号,收到 Ctrl+C 后停止监听,让循环退出,再给在途请求一点收尾时间。HttpListener.Stop()会让挂起的GetContextAsync抛异常,所以循环里要捕获HttpListenerException和ObjectDisposedException,不要当成业务错误。

using var cts = new CancellationTokenSource(); Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); listener.Stop(); }; try { while (!cts.IsCancellationRequested) { var ctx = await listener.GetContextAsync(); _ = Task.Run(() => HandleAsync(ctx)); } } catch (HttpListenerException) when (cts.IsCancellationRequested) { // 停止监听时的正常异常 } catch (ObjectDisposedException) when (cts.IsCancellationRequested) { // listener 已释放 }

异常兜底要分两层:每个请求内部try/catch/finally,保证Response.Close()一定执行;accept 循环外层捕获停止信号,避免 Ctrl+C 打出红色堆栈。日志不要每个请求都File.AppendAllText,多线程下会互相锁文件,量大时磁盘直接成瓶颈。简单做法是记到ConcurrentQueue<string>,后台任务每秒批量落盘;或者接ILogger,把请求方法、路径、状态码、耗时记成一行。至少要有“启动监听地址、每个请求一行、异常堆栈”这三类信息,不然出问题只能靠猜。

4.3 控制台、计划任务与 Windows 服务的取舍

开发期直接dotnet run,发布用dotnet publish -c Release,把输出目录拷到目标机器。想开机自启,先用计划任务最省事:

schtasks /create /tn "CSharpWebDemo" /tr "C:\apps\CSharpWebDemo\CSharpWebDemo.exe" /sc onstart /ru SYSTEM

/tn是任务名,/tr是 exe 路径,/sc onstart表示开机启动,/ru SYSTEM指定运行账号。用SYSTEM跑要注意它看到的路径和网络权限与当前用户不同;更稳的是用专用低权限账号。控制台 exe 不能直接sc create成 Windows 服务,sc只对实现了服务控制协议的 exe 有效;真要做服务,得用ServiceBase包一层,或者继续用计划任务。绑定http://+:8080/前先加 URL ACL:

netsh http add urlacl url=http://+:8080/ user=Everyone netsh advfirewall firewall add rule name="CSharpWebDemo-8080" dir=in action=allow protocol=TCP localport=8080

user=Everyone只适合内网临时验证,生产环境换成运行服务的具体账号,遵循最小权限。防火墙规则只放行需要的端口,不要整段关闭防火墙。端口被占用时用netstat -ano | findstr :8080找 PID,再决定换端口还是停进程;不要看到“拒绝访问”就盲目加管理员权限,先确认是 URL ACL 还是端口冲突。

5. 避坑与排查:五个把“简单 Web 服务”拖进深夜的问题

5.1 绑定地址与 URL ACL 的权限坑

现象:本机curl http://localhost:8080/正常,换成局域网 IP 访问就连接失败;或者程序一启动就抛HttpListenerException: Access is denied。原因通常有两个:前缀写的是localhost,它只绑回环地址,局域网网卡上根本没有监听;或者前缀写成http://+:8080/,Windows 的 HTTP.sys 要求先做 URL ACL 授权,普通用户没有注册通配前缀的权限。解决:本机联调继续用localhost;要内网访问,改成http://+:8080/,用管理员执行netsh http add urlacl url=http://+:8080/ user=运行账号,再把防火墙 8080 端口放行。注意+和*的语义有差别,+绑定所有网卡,*在某些场景下行为不同,内网小工具优先用+并配 ACL。还有一点,URL ACL 的 URL 末尾斜杠要和代码里的前缀一致,少一个斜杠可能匹配不上。

5.2 响应挂起与 Content-Length 的坑

现象:浏览器一直转圈,curl 也拿不到完整响应;或者响应内容被截断,中文变成乱码。原因:没有设置ContentLength64,或者设置成了字符串长度而不是 UTF-8 字节长度;再或者写完OutputStream没有Close(),客户端认为响应还没结束。HttpListener不会自动帮你算长度,尤其是返回中文、JSON、HTML 时,text.Length和bytes.Length经常不相等。解决:统一走WriteTextAsync这类 helper,先Encoding.UTF8.GetBytes,用bytes.Length赋给ContentLength64,写完立即Close()。如果返回的是流式内容,长度未知,要用分块传输或明确关闭连接,不要随便省略长度。排查时用curl -i看响应头里有没有Content-Length,再看实际字节数和声明是否一致。

5.3 静态文件路径穿越与 MIME 的坑

现象:静态页面能打开,但某些文件名带空格或中文的请求 404;安全扫描报告“目录穿越”;.js文件被浏览器当成纯文本不执行。原因:URL 路径没有解码,%20和中文编码直接拼进文件路径;或者只做了字符串替换,没有用GetFullPath规范化,../绕过根目录;MIME 字典漏了扩展名,回退成application/octet-stream或text/plain。解决:先Uri.UnescapeDataString解码,再Path.GetFullPath规范化,最后用“根目录 + 分隔符”做前缀比较。MIME 至少覆盖 html、css、js、json、png、jpg、svg,未知类型回退二进制。不要用Path.Combine(Root, urlPath)直接读文件,也不要把异常堆栈返回给客户端。静态文件根目录最好放在 exe 同级的wwwroot,发布时一起拷走,避免开发机路径和服务器路径不一致。

5.4 端口占用、防火墙与 IPv6 的坑

现象:程序启动报“地址已在使用”,或者本机localhost能访问、另一台机器访问不了;有时127.0.0.1正常,localhost却解析到::1导致连不上。原因:8080 被其他进程占用;防火墙没放行;localhost在现代系统上可能优先解析到 IPv6 回环地址,而监听前缀只绑了 IPv4。解决:用netstat -ano | findstr :8080找占用 PID,换端口或停进程;防火墙只放行目标 TCP 端口;需要同时照顾 IPv4 和 IPv6 时,用http://+:8080/并确认 URL ACL,或者分别测试127.0.0.1和[::1]。不要为了省事直接关防火墙,也不要给所有端口开绿灯。上线前在目标机器上用另一台内网机器curl -i http://目标IP:8080/做一次真实链路验证。

5.5 压测下的线程与连接耗尽

现象:低并发正常,压到几十个并发后响应变慢、超时,甚至进程内存持续上涨。原因:每个请求Task.Run没有上限,慢请求把线程池占满;同步读文件、同步查数据库阻塞线程;HttpListener的连接没有及时关闭,句柄堆积。解决:用SemaphoreSlim限制并发处理数,超过就快速拒绝或排队;所有 I/O 改成异步;每个响应在finally里Close();压测时观察线程池线程数、GC 和句柄数。还要注意Task.Run不是“免费并发”,它只是把工作丢到线程池,线程池的线程是有限的。内网小服务给 16 到 64 个并发槽位通常够用,具体看下游依赖;如果下游是单线程设备接口,槽位开太大只会把设备打挂。

6. 进阶验证:用 Kestrel 最小 API 做对照和回归

6.1 同一组接口的最小 API 写法

HttpListener版本适合把逻辑跑通,但接口一多,路由和静态文件的手写代码会迅速膨胀。用 Kestrel 最小 API 做一份对照实现,能快速验证协议行为是否一致,也方便后续迁移。下面只保留两个接口,地址同样不经过 IIS:

var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.MapGet("/", () => Results.Text("ok", "text/plain; charset=utf-8")); app.MapGet("/api/time", (string? name) => Results.Json(new { name = name ?? "guest", now = DateTimeOffset.Now })); app.Run("http://localhost:8080");

MapGet的第一个参数是路由模板,第二个参数是处理委托;Results.Json负责序列化和application/json响应头,不用手拼字符串。app.Run("http://localhost:8080")指定监听地址,换成http://+:8080时同样要考虑 URL ACL 或反向代理。这个对照实现的意义不是“多写一套”,而是用同一组 curl 命令打两个服务,看状态码、响应头、JSON 字段是否一致。迁移时先把路由和返回结构对齐,再逐步把静态文件、日志、限流搬过去。

6.2 用 curl 和并发脚本验收

验收不要只看浏览器页面能打开。先看状态行和响应头,再看 POST 请求体,最后看并发下的错误率。三条命令足够覆盖大部分低级问题:

curl -i http://localhost:8080/ curl -i http://localhost:8080/api/time?name=test curl -i -X POST http://localhost:8080/api/echo \ -H "Content-Type: application/json" \ -d '{"a":1}'

第一条看首页的Content-Type和Content-Length;第二条看查询参数有没有正确解码、JSON 有没有转义;第三条看 POST 请求体是否被完整读取、超限时是否返回 413。并发验证可以用wrk或ab,没有这些工具就写个简单的循环脚本,观察 503 比例和平均耗时。对照表如下:

维度HttpListener 版本Kestrel 最小 API
依赖基础类库自带ASP.NET Core 项目
路由手写 switchMapGet/MapPost
静态文件自己处理 MIME 和路径中间件接管
跨平台先在目标框架验证默认跨平台
适合场景内网小工具、临时接口长期维护、接口增多

从那以后我每次把这类服务交给别人前,都先用curl -i看状态行和Content-Length,再用并发脚本压 30 秒,确认 503 是主动限流而不是线程池被打穿。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询