C# HttpListener 无 IIS 服务部署:拒绝访问与端口排查
简介:面向希望绕开 IIS、以轻量方式搭建自托管 HTTP 服务的 C# 开发者与教学学习者,这份源码示例围绕 HttpListener 监听、请求分发与响应处理展开,可用于本地接口调试、桌面程序内嵌 Web 入口或简单服务原型验证。压缩包仅 18KB,共 17 个文件,以 8 个 cs 源码为主体,辅以 resx 资源、xml 配置、htm 静态页面、csproj 项目文件及 settings、thmx 等辅助内容,便于直接打开阅读与二次修改。已有 2364 人学习,说明其在自托管 Web 服务入门场景中具有一定参考度。代码组织上包含服务启动入口、请求处理器、客户端请求模拟线程,以及可承载静态页面的 wwwroot 目录和窗体界面相关代码,读者可据此理解无 IIS 环境下从监听、接收上下文到返回响应的完整链路,并借鉴其项目结构与调试思路。
1. 从一条 netsh 命令说起:HttpListener 一跑就报拒绝访问
某开发者接了个内网小工具的活,需求一句话:C# Web服务 无需IIS,机器是干净的 Windows,装 IIS 嫌重,方案就定成控制台程序里挂一个HttpListener。代码三十行写完,开发机上按 F5 一切正常,打包拷到目标机双击,立刻弹HttpListenerException: 拒绝访问,可端口明明空着。这个坑跟代码一个字都没关系,是 Windows 的 URL 保留机制在门口拦人。类似的轻量场景其实特别常见:设备配套的配置页、某跨平台系统的回调接收端、图像处理 Demo 的预览服务,几十个请求的量,犯不上为它铺一套完整 Web 服务器。下面把 SDK 版本、项目骨架、权限配置、排错和验证拆开记一遍,照着走能复现。
2. 环境与版本选型:.NET 8 SDK 这套最省心
2.1 三个分支先分清:Framework 4.8、.NET 6、.NET 8
HttpListener这个类型在 .NET Framework 和 .NET(Core 及以后的统一分支)里都有,API 名字一模一样,行为却差着不少,所以第一件事不是装 SDK,而是先问清楚目标机的情况:能不能联网、有没有 IT 管控、是不是要交付单个 exe。
| 分支 | 目标机需要预装什么 | 交付形态 | 适合的场景 |
|---|---|---|---|
| .NET Framework 4.8 | Win10 1803 之后系统自带,基本零依赖 | 几十 KB 的 exe | 纯内网、老机器、不想装运行时 |
| .NET 6 | 需装运行时,或打成自包含 | 中等体积 | 已有工程在 6 上,迁移成本高 |
| .NET 8 | 需装运行时,或打成自包含 | 中等体积 | 新起项目,长期维护 |
判断顺序我一般是这样的:如果目标机是一批锁死了不允许装任何运行时的办公电脑,直接选 .NET Framework 4.8,HttpListener在那上面跑了十几年,稳得很,缺点是新语法用不上、跨平台别想。如果是自己可控的服务器或者要长期迭代的工具,选 .NET 8,它是长期支持版本,工具链、发布选项、单文件打包都比 6 那一代成熟。.NET 6 我基本不推荐新项目用了,它的支持周期已经走完,装上去等于开局就欠一笔技术债。
还有个容易忽略的点:.NET Framework 分支下HttpListener是直接压在 HTTP.sys 上的,而 .NET(Core)分支用的是托管实现,绝大多数场景没差别,但TimeoutManager这类属性在托管实现上支持不完整,别指望靠它兜底超时。这个差异在后面配超时的时候会咬人。
2.2 安装 SDK:装哪一条、装到哪、要不要重启
不用上 Visual Studio,独立 SDK 安装包就够,体积比整套 IDE 小一个量级。有 winget 的机器直接搜包名,选 SDK 8 那条装,比手动下安装包省事:
# 先搜出准确的包 ID,不同机器上包名可能略有差异 winget search "dotnet sdk" # 用上一步搜到的 ID 安装,装完不要急着开新窗口 winget install --id <上一步搜到的 SDK 8 包 ID> -ewinget search这一步别省。包 ID 里带版本号,写错一个字符就会装成运行时而不是 SDK,后面dotnet new直接报「找不到模板」。装完默认落在C:\Program Files\dotnet\,安装器会自己往系统 PATH 里塞dotnet的路径。
如果用离线安装包,注意两个勾选项:一个是「安装位置」,除非 C 盘真的紧张,否则保持默认,改路径之后某些工具链的探测会出玄学问题;另一个是遥测开关,内网环境关掉省得等超时。
装完必须重开一个终端。我见过不止一次「装完了但dotnet不是内部或外部命令」,纯粹是旧终端还揣着安装前的环境变量副本。这个血泪经验值得记一笔:装任何改 PATH 的东西,第一反应都应该是关掉所有命令行窗口。
2.3 装完先验环境:四条命令确认没装歪
SDK 装完别直接开写,先跑四条命令,把「SDK 有没有、版本对不对、运行时齐不齐、路径指向哪」一次看清:
dotnet --version # 当前目录生效的 SDK 版本,认的是 global.json dotnet --list-sdks # 机器上所有 SDK,确认 8.x 在里面 dotnet --list-runtimes # 运行时清单,自包含发布时用不到,框架依赖发布必须齐 where dotnet # Windows 上看实际调用的是哪个 dotnet.exedotnet --version和--list-sdks的结果可能对不上,这不是装坏了。前者受当前目录及各级父目录下global.json的影响,后者才是机器真实装了哪些。如果你在某个老项目目录里执行,--version很可能返回 6.x,这时候新建项目就会莫名其妙用了老 SDK。
再验一次模板能不能跑通,比看版本号更实在:
mkdir C:\temp\sdktest && cd C:\temp\sdktest dotnet new console -n HelloCheck cd HelloCheck dotnet run能看到Hello, World!就说明 SDK、模板、编译、运行这条链路全通了。这一步花两分钟,能省掉后面半小时「到底是代码问题还是环境问题」的扯皮。跑完把C:\temp\sdktest删掉,别留着污染以后的目录扫描。
3. 写出最小可跑的服务:一个 Program.cs 撑起全部
3.1 建项目与骨架:控制台模板就够了
不需要 Web 模板,不需要Microsoft.AspNetCore.*那一整套依赖,一个空控制台项目就能把这个服务架起来。ASP.NET Core 当然也能自宿主,但它带进来的中间件、依赖注入、配置系统,对一个几十行的小服务来说是纯粹的负担,发布体积也从几 MB 涨到几十 MB。
dotnet new console -n MiniWeb -o .\MiniWeb cd .\MiniWeb建完先看一眼 csproj,改成下面这样:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net8.0</TargetFramework> <Nullable>enable</Nullable> <ImplicitUsings>enable</ImplicitUsings> <!-- 服务要处理中文路径和中文响应,全球化不能裁掉 --> <InvariantGlobalization>false</InvariantGlobalization> <AssemblyName>MiniWeb</AssemblyName> <RootNamespace>MiniWeb</RootNamespace> </PropertyGroup> </Project>TargetFramework写net8.0就锁定在 8 这一代,避免机器上装了多套 SDK 时被global.json带到别的分支上。Nullable打开是为了让编译器帮你盯住Request.Url这种可能为 null 的地方——HttpListener的不少成员是可为空的,不开这个选项,运行时才炸NullReferenceException。InvariantGlobalization设成false是我踩过坑之后固定加上的,设成true能省一点体积,但中文排序、日期格式、编码探测全都会退化。
目录上就三样东西:Program.cs、MiniWeb.csproj,再加一个放静态资源的wwwroot文件夹。没有Startup.cs,没有appsettings.json,配置直接走命令行参数,小服务就该有小服务的样子。
3.2 监听循环:从 GetContext 到把响应关掉
整个服务的核心就是下面这四十来行,Program.cs全量替换成它:
using System.Net; using System.Text; // 控制台默认编码在部分中文系统上是 GBK,先掰成 UTF-8,否则日志里的中文全是问号 Console.OutputEncoding = Encoding.UTF8; // 前缀从命令行第一个参数取,不给就用默认值,换端口时不用重编译 var prefix = args.Length > 0 ? args[0] : "http://+:8080/"; var listener = new HttpListener(); listener.Prefixes.Add(prefix); // 客户端提前断开时不要抛异常,否则一个断连就能把主循环带崩 listener.IgnoreWriteExceptions = true; try { listener.Start(); // 拒绝访问的高发点,权限问题都在这一行暴露 } catch (HttpListenerException ex) { Console.WriteLine($"启动失败,错误码 {ex.ErrorCode}:{ex.Message}"); Console.WriteLine("检查 URL ACL 与端口占用,见第 4 章"); return; } Console.WriteLine($"MiniWeb 已启动,监听 {prefix}"); // 主循环只负责接收连接,处理丢给线程池,避免一个慢请求卡死后面所有人 while (listener.IsListening) { HttpListenerContext ctx; try { ctx = await listener.GetContextAsync(); } catch (HttpListenerException) { break; } // Stop() 时会走到这里,正常退出 catch (ObjectDisposedException) { break; } _ = HandleAsync(ctx); } listener.Close(); static async Task HandleAsync(HttpListenerContext ctx) { var payload = Encoding.UTF8.GetBytes("{\"ok\":true,\"ts\":" + DateTimeOffset.UtcNow.ToUnixTimeSeconds() + "}"); var res = ctx.Response; res.StatusCode = 200; res.ContentType = "application/json; charset=utf-8"; // 不设 ContentLength64,浏览器会一直转圈等结束标记 res.ContentLength64 = payload.Length; try { await res.OutputStream.WriteAsync(payload); } finally { res.Close(); // 这一句同时关流和释放上下文,漏了就是句柄泄漏 } }逐段说一下为什么这么写。listener.Start()外面包 try,是因为最典型的启动失败就两种:权限不足(错误码 5)和端口已被占(错误码 183),这两种情况打一行日志退出,比抛一堆堆栈好排查得多。
主循环里_ = HandleAsync(ctx)这个下划线不是装饰。GetContextAsync一次只交出一个连接,如果你在里面直接await完整个业务处理,那这个服务就成了严格串行——第一个请求只要卡住两秒,第二个请求就得等两秒才开始收。丢掉 Task 引用意味着不等待结果,请求之间互不影响,代价是异常不会冒泡到主线程,所以HandleAsync内部必须自己兜住所有异常。
res.Close()放在finally里,是为了防止写响应时抛异常导致流没关。没关的响应流在 HTTP.sys 侧会一直占着,表现出来的现象就是「第一次请求正常,第二次浏览器一直转圈」,这个问题在第 5 章还会细说。
3.3 参数配置:前缀格式、端口和并发的那几个数字
前缀(prefix)的写法是这个服务最容易配错的地方,它有三种形态,语义完全不同:
| 前缀写法 | 监听范围 | 需要 URL ACL | 典型用途 |
|---|---|---|---|
http://localhost:8080/ | 仅回环,本机 | 不需要 | 本机调试 |
http://+:8080/ | 所有网卡 | 需要(非管理员时) | 对内提供服务 |
http://192.168.1.20:8080/ | 指定网卡 IP | 需要 | 多网卡机器 |
+和*的区别要记牢:HttpListener只认+,写*在部分环境下会直接抛ArgumentException。另外前缀必须以/结尾,少写这个斜杠照样报错,报错信息还很不友好。
如果你就想本机跑跑,用localhost是最省事的方案,因为回环地址不需要任何权限配置,普通用户直接能起。但只要服务要给别人访问,就必须换成+,然后老老实实去做第 4 章的 URL ACL。
端口选择上有两条经验。一是避开 1024 以下的特权端口,那需要管理员权限,服务一旦做成开机自启会很别扭。二是避开系统可能动态保留的端口段,Hyper-V、容器宿主这些东西会抢一段端口,你选了个 5000 上下的端口,重启一次机器服务就起不来了,这个坑在第 4 章的端口排除范围里说。
并发这块,HttpListener本身没有「最大并发数」这个配置项,实际吞吐取决于线程池。默认线程池的起始线程数很小,短时间涌进几十个请求时会看到明显的排队。可以在启动时调一下:
// 最小工作线程数抬高,减少突发请求时的排队抖动;不要调太离谱,线程也是资源 ThreadPool.GetMinThreads(out var worker, out var io); ThreadPool.SetMinThreads(Math.Max(worker, 32), Math.Max(io, 32));放在listener.Start()之前执行,参数按实际并发量给,几十个请求的小工具给到 32 足够,写成 512 只会让 GC 更忙。这个数字不是越大越好,调大的收益是突发时首响应更快,代价是空闲时内存占用上升。
4. 权限与端口配置:让服务真正对外可用
4.1 URL ACL:netsh 那条命令的正确写法
回到第 1 章那个拒绝访问。原因在于 HTTP.sys 对 URL 命名空间做了保留(reservation),普通用户身份只能监听自己名下的保留项,http://+:8080/这种通配前缀默认属于管理员。开发机上之所以正常,多半是因为 IDE 是以管理员身份启动的——这就是那种「在我这儿好好的」的典型来源。
解决方式有两个:让程序以管理员运行,或者给普通用户授权。前者是个坏习惯,一个只处理几十个请求的服务不该常驻高权限,所以我一般走第二条路:
# 给当前登录用户授权监听 8080 的所有前缀 netsh http add urlacl url=http://+:8080/ user=%USERDOMAIN%\%USERNAME% # 查看已有保留项,确认加进去了 netsh http show urlacl url=http://+:8080/ # 授权写错了要撤掉,用 delete,别用 add 硬覆盖 netsh http delete urlacl url=http://+:8080/user=后面必须写完整的域\用户名形式,只写用户名在部分机器上识别不到。%USERDOMAIN%\%USERNAME%在命令行里会自动展开,比手动敲稳妥。如果这个服务是以某个服务账户身份跑的,就换成那个账户;内网受控机器图省事也可以授权给Everyone,但这等于放开了本机所有用户的监听权限,公网或多人共用的机器上不要这么干。
授权之后要重新登录或者重启相关进程才生效,改完立刻测试大概率还是老的权限状态。这个细节坑过不少人,改完就测、测完就怀疑命令写错了。
4.2 防火墙、端口占用与那段被保留的端口区间
权限过了,下一关是网络。本机 curl 通、同事访问不了,九成是防火墙:
# 只对专用网络放行 8080,别用 Public,公共网络配置下开放端口风险太大 New-NetFirewallRule -DisplayName "MiniWeb 8080" ` -Direction Inbound -Protocol TCP -LocalPort 8080 ` -Action Allow -Profile Private # 规则建错了先查再删,别直接重建,名称会冲突 Get-NetFirewallRule -DisplayName "MiniWeb 8080" | Format-List Name,Enabled,Profile Remove-NetFirewallRule -DisplayName "MiniWeb 8080"-Profile Private这个限定很关键。开发机上经常同时挂着公司网络和手机热点,如果规则放行到 Public,等于在咖啡厅的公共网络里也开了个口子。判断当前网络属于哪个配置文件用Get-NetConnectionProfile。
端口占用是另一类高频问题,而且有两种完全不同的成因。第一种是真的有进程在占:
# 找占用 8080 的 PID,最后一列就是 netstat -ano | findstr :8080 # 拿到 PID 之后确认是什么进程,别直接杀 tasklist /FI "PID eq <上面的PID>"第二种比较阴,netstat里看不到任何进程,但服务就是起不来。这是 Windows 的动态端口排除区间在作怪,Hyper-V、容器宿主会预留一大段端口,预留段里的端口任何应用都绑不上:
# 查看 IPv4 的 TCP 排除区间,输出里的 Start Port / End Port 就是被系统扣下的 netsh int ipv4 show excludedportrange protocol=tcp看到 8080 落在某个区间里,换个端口就行,或者用netsh int ipv4 add excludedportrange反向调整预留(这个要管理员,且会影响容器网络,非必要不动)。我的习惯是服务端口固定在一个高位段,比如 18080 以上,避开这些爱打架的区域。
4.3 静态文件托管与内容类型映射
光返回 JSON 的服务用处有限,配套的配置页、预览页总得给几个 HTML。这里不需要什么静态文件中间件,路径映射加一张 MIME 表就够:
// 物理根目录跟着 exe 走,单文件发布时也能定位到同级的 wwwroot var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "wwwroot")); static async Task ServeFileAsync(HttpListenerContext ctx, string root) { var rel = ctx.Request.Url!.AbsolutePath.TrimStart('/'); if (string.IsNullOrEmpty(rel)) rel = "index.html"; // 关键一步:规范化后必须校验仍在 root 之内,否则 ../ 就能读走整块磁盘 var full = Path.GetFullPath(Path.Combine(root, rel)); if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase)) { ctx.Response.StatusCode = 403; ctx.Response.Close(); return; } if (!File.Exists(full)) { ctx.Response.StatusCode = 404; ctx.Response.Close(); return; } var bytes = await File.ReadAllBytesAsync(full); ctx.Response.ContentType = MimeOf(Path.GetExtension(full)); ctx.Response.ContentLength64 = bytes.Length; await ctx.Response.OutputStream.WriteAsync(bytes); ctx.Response.Close(); } static string MimeOf(string ext) => ext.ToLowerInvariant() switch { ".html" or ".htm" => "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", ".svg" => "image/svg+xml", ".ico" => "image/x-icon", _ => "application/octet-stream" };Path.GetFullPath加StartsWith这两行是必须的,HttpListener不会帮你做任何路径安全检查,http://host:8080/../../windows/win.ini这种请求它会原样交到你手上。OrdinalIgnoreCase是因为 Windows 文件系统不区分大小写,用序数比较才和磁盘行为一致。
AppContext.BaseDirectory而不是Environment.CurrentDirectory,这是打包之后才暴露的差别:双击 exe 时当前目录是 exe 所在目录,但从计划任务或者服务方式启动时,当前目录可能是C:\Windows\System32,用后者会找不到 wwwroot。这个坑在第 5 章还会出现。
MIME 表里没列的类型一律回application/octet-stream,浏览器会当下载处理,不会白屏。别图省事全回text/html,那会让 CSS 和 JS 直接被当 HTML 解析,页面样式全丢。
5. 常见问题与排查:五次翻车现场
5.1 启动阶段:拒绝访问与访问不到
现象一:开发机 F5 正常,拷到目标机双击就报「拒绝访问」,错误码 5。原因:IDE 默认以管理员身份运行,而目标机上是普通用户双击,http://+:8080/这个通配前缀没有对应的 URL ACL,HTTP.sys 直接拒绝。解决:按 4.1 节执行netsh http add urlacl给实际运行账户授权,或者把前缀临时改成http://localhost:8080/先验证代码本身没问题。判断依据很简单,换 localhost 能起来就是权限问题,起不来就是别的问题。
现象二:本机 curl 通,同事的浏览器打不开。原因:前缀写成了localhost。这个前缀只绑定回环网卡,外部流量根本进不来,服务日志里也不会有任何记录,看起来就像服务没在跑。解决:改成http://+:8080/,重新做一次 URL ACL,再补一条-Profile Private的防火墙入站规则。改完先在本机curl http://本机内网IP:8080/试,这一步通了再去让别人试,能排除掉一半的沟通成本。
现象三:listener.Start()抛错误码 183(端口已存在)。原因:端口被占,或者落进了系统的动态排除区间。解决:先netstat -ano | findstr :8080找 PID,找不到再netsh int ipv4 show excludedportrange protocol=tcp看排除段。前者是杀进程或者换端口,后者只能换端口。我一般直接把服务端口定在 18080 往上,从源头绕开。
5.2 运行阶段:卡死、句柄泄漏与打包后找不到文件
现象四:第一次请求正常返回,刷新第二次浏览器一直转圈,最后超时。原因:响应流没关,或者ContentLength64没设。HTTP 响应靠长度或者连接关闭来界定边界,两个都没有,客户端就只能死等。解决:ContentLength64在写之前设好,res.Close()放在finally里保证一定执行。还有个变体是只在成功分支里Close,异常分支直接 return,表现一样但更难查——所以别在每个 return 前面手写 Close,用 finally 统一收口。
现象五:发布成单文件之后,本地跑一切正常,目标机上静态页面全 404。原因:wwwroot没跟着发布出去,或者代码用了Environment.CurrentDirectory拼路径。单文件发布默认只打包程序集,wwwroot这种内容文件需要在 csproj 里显式声明为Content并设置CopyToOutputDirectory。解决:把wwwroot复制到 exe 同级目录,代码里的根路径统一用AppContext.BaseDirectory。还有个容易混的:单文件模式下Assembly.Location返回空字符串,任何依赖它定位目录的代码都会失效。
现象六:服务跑了一夜,第二天重启报端口被占,但netstat里的 PID 是空的。原因:进程被强杀(任务管理器结束进程、机器断电)之后,HTTP.sys 侧的注册项没清理干净,短时间内端口处于残留状态。解决:优先用listener.Stop()加listener.Close()做优雅退出,配合Console.CancelKeyPress捕获 Ctrl+C;实在遇到残留了,等一两分钟或者换端口,不要用taskkill /F当日常操作。这一条属于那种「平时用不上,出事时能救命」的后悔药。
6. 装完怎么验证跑通:三条命令加一次并发回环
验证这件事,我不太信「打开浏览器看一眼」。手动点开的页面永远只覆盖最顺的那条路径,回环、跨机、并发这三条线才是真正暴露问题的。所以每次部署完,我都会固定跑一套三连。
第一层,本机回环,验的是程序本身活着:
# -w 只打状态码,-m 3 设三秒超时,卡住就不要傻等 curl -s -o NUL -w "%{http_code}\n" -m 3 http://127.0.0.1:18080/health第二层,用内网 IP 访问,验的是前缀和防火墙:
# 把 192.0.2.10 换成目标机实际的内网地址 curl -s -o NUL -w "%{http_code}\n" -m 3 http://192.0.2.10:18080/health第一条通、第二条不通,问题一定在绑定范围或防火墙,不用再翻代码。这两条命令的价值就在于把「网络问题」和「代码问题」一刀切开。
第三层,并发回环,验的是主循环有没有写成串行。用 PowerShell 7 的并行管道最省事:
$sw = [Diagnostics.Stopwatch]::StartNew() 1..500 | ForEach-Object -Parallel { (Invoke-WebRequest "http://127.0.0.1:18080/health" -UseBasicParsing -TimeoutSec 5).StatusCode } -ThrottleLimit 32 | Group-Object | Select-Object Name, Count $sw.Elapsed.TotalSeconds-ThrottleLimit 32同时压 32 个并发,跑 500 次。输出里应该只有一行200,Count 是 500。如果状态码里混进了 500,说明HandleAsync里的异常没兜住;如果耗时不正常地长(比如几十秒),说明主循环里那个_ = HandleAsync(ctx)被误写成了await HandleAsync(ctx),请求在排队。
对着结果查表会更省事:
| 观察到的现象 | 大概率原因 | 先看哪里 |
|---|---|---|
| 第一条就不通 | 进程没起来 / 端口错 | 控制台有无启动日志,netstat -ano |
| 第一条通,第二条不通 | 前缀是 localhost / 防火墙 | 前缀写法、入站规则 Profile |
| 并发测试混入 500 | 处理函数异常未捕获 | HandleAsync的 try 覆盖范围 |
| 并发耗时十几秒以上 | 主循环串行 await | 主循环有没有丢掉 Task |
| 结果全对但内存缓慢上涨 | 响应流未关,句柄泄漏 | 每条路径是否都走到Close() |
补一句/health端点本身。别图省事让它和业务走同一个处理分支,单独开一条最短路径,只返回固定字符串和当前时间戳,不碰文件、不查任何外部依赖。这样它才是真的探活,而不是一个会跟着业务一起挂掉的假探针。
从那以后我每次部署这套无 IIS 的小服务,都会强制把这三条命令跑一遍,哪怕只是在同事机器上改了个端口。因为出问题的地方从来不是那四十行代码,而是它和操作系统之间那层看不见的权限、端口和路径约定——这层约定一旦对了,服务能连跑几个月不出声;一旦错了,表现又千奇百怪,最难的那次我花了两个小时才想起来看端口排除区间。希望这套流程能帮到你,少走一遍我走过的弯路。
本文还有配套的精品资源,点击获取