☰
C# Web服务入门:不用IIS从HttpListener到Kestrel
2026/11/4 13:49:12 网站建设 项目流程

简介:这份资源面向具备一定 C# 基础、希望快速搭建轻量级 HTTP 服务的开发者,重点解决在开发、测试或简易部署场景中不想安装或依赖 IIS 的问题。核心思路是用 C# 内置的 HttpListener 实现自托管 Web 服务,示例中演示了初始化监听前缀、启动监听、异步获取请求上下文、编写回调处理以及停止关闭监听等完整流程;配套的 Form1 相关文件提供简易的 WinForms 交互界面,ClientSocketThread 用于模拟客户端发起 HTTP 请求,wwwroot 目录存放静态页面资源,便于直接验证服务响应效果。资源包为 rar 压缩格式,共 17 个文件,约 18KB,主要包含 8 个 cs 源码文件、2 个 resx 资源文件、2 个 xml 配置文件和 2 个 htm 页面,另有项目文件、设置文件与主题数据等,体量小巧但结构完整。目前已有 2364 人学习,适合想绕开 IIS 理解自托管服务原理、并动手跑通最小示例的读者参考。

1. 先弄明白一件事:C# 写 Web 服务,为什么可以没有 IIS

我见过不少人卡在同一个地方:C# 语法学完了,想用 C# 建一个 Web 服务给人访问,一搜教程,满屏都是装 IIS、建应用程序池、配网站绑定、改目录权限。折腾一下午,浏览器还是那个打不开的页面,人先泄气了。

其实建立最简单的 C# Web 服务,完全可以做到无需 IIS。你的程序自己就能当服务器,双击运行,浏览器输个地址就能看到你写的页面。这在业内叫「自宿主」:不需要一个额外的大家伙在前面挡着,你的控制台程序一启动,服务就已经在了,关掉窗口,服务就停了。

这份资源带你走的就是这条路。先用最少的代码把服务跑起来,看到浏览器里出现自己写的字,再一点点往里加东西。零基础也能上手,只要你会一点点 C# 语法——知道什么是变量、什么是方法,就够了,剩下的边做边补。

2. 拆开来看:一个 Web 服务到底由哪几块组成

2.1 把 Web 服务想成一家只做三件事的小店

你在浏览器里敲一个地址、按下回车,这个动作的本质是:你的电脑向另一台电脑(或者同一个电脑上的某个程序)发了一句话,然后等一句话回来。

所以一个 Web 服务,翻来覆去只做三件事。

第一件,站在门口等。程序要占住一个端口,一直守着,看有没有人来找。端口你可以理解成门牌号:一台电脑上可能同时跑着聊天软件、数据库、还有你的服务,光有 IP 地址不够,还得有门牌号才知道敲哪扇门。

第二件,听懂客人要什么。客人(浏览器)敲门的时候不是空手的,它会带一张单子,上面写着「我要用 GET 方法拿 /hello 这个路径」,可能还附带了几个参数。服务得把这张单子读明白。

第三件,把东西递出去。递出去的不只是内容,还有一句话说明「这是网页」「这是 JSON」「这个没找到」。这句话叫响应头,内容叫响应体,合起来叫响应。

整个 Web 开发,不管框架多花哨,剥到最里面就是这三件事的无限循环。你现在觉得 Web 服务神秘,多半是因为中间隔了太多层工具,把它包成了一个黑匣子。等你自己用二十行代码把这个循环写一遍,黑匣子就打开了。

2.2 IIS 是什么,为什么它「可以不在」

IIS 是 Windows 自带的一个 Web 服务器软件。它的工作是:常驻在机器上,占住 80 端口,收到请求之后,判断这个请求该交给哪个网站、哪个程序去处理,处理完了再把结果送回去。

它本质上也是一个程序,只不过这个程序专门干「接客」这件事,而且干了很多年,考虑得比较周全——多个网站共存、进程崩了自动重启、日志、权限隔离,都有。

那为什么可以不用它?因为「接客」这件事,你的 C# 程序自己也能干。你写一个控制台程序,让它占住 8080 端口,收到请求就回一句话,那它就是一个 Web 服务。没有 IIS,没有配置文件,没有图形界面,就一个 exe。

这里有个概念要分清:不用 IIS,不等于不用服务器。你的程序就是服务器,只是它同时也是业务代码。这种「自己既是服务器又是业务」的形态,就是自宿主。它特别适合新手练手、做内部小工具、写个接口给自己用。真要上线给成千上万人访问,才需要考虑更完整的东西。

2.3 两条路:HttpListener 和 Kestrel,新手该选哪个

C# 里做自宿主的 Web 服务,主流有两条路。

第一条是HttpListener。它是 .NET 基础类库里自带的,属于System.Net命名空间,不用装任何额外的东西。你手动创建它、手动加监听地址、手动循环接请求、手动拼响应。优点是零件全暴露在你面前,写一遍就懂了 Web 服务是怎么回事;缺点是所有东西都得自己来,路由要自己判断,并发要自己处理,稍微复杂一点就写得很难看。

第二条是 Kestrel。它是 ASP.NET Core 自带的服务器,你用dotnet new web建一个项目,模板里那个app.Run()启动的就是它。Kestrel 帮你把接客、解析、并发、路由全做完了,你只需要写「访问这个路径的时候返回什么」。优点是省事、跨平台、写出来的东西能直接长大成正经项目;缺点是它把细节藏起来了,你上手时可能有点懵——到底谁在监听端口?

我的建议是:两条都走一遍,但顺序别反。先用HttpListener写那个二十行的版本,把「占端口、收请求、发响应」这三步亲手摸一遍,你会踏实很多。然后再用 Kestrel 的模板跑一遍,体会一下「原来这些活都能被代劳」。先懂原理再上工具,比一上来就dotnet run复制粘贴强得多。

对比项HttpListenerKestrel(ASP.NET Core)
要不要额外安装不用,类库自带需要装 .NET SDK
代码量全部手写模板生成大半
路由自己写 if 判断MapGet一行一个
并发自己开线程天生异步
平台Windows 上支持最完整Windows / Linux / macOS 都行
适合场景理解原理、极简小工具真正要维护的项目

3. 第一个能跑的例子:二十行代码起一个本地 Web 服务

3.1 准备工作:装好 SDK,一条命令验证

先确认你机器上有没有 .NET SDK。打开命令行(Windows 上是 PowerShell 或 cmd,macOS 和 Linux 上是终端),敲这一行:

dotnet --version

如果它打印出一个数字,比如8.0.100这样的版本号,说明已经装好了,直接往下走。如果提示「命令未找到」或者「不是内部或外部命令」,那就去 .NET 官网下载 SDK 装一下,一路默认下一步就行。

注意这里装的是 SDK,不是运行时。运行时只能跑别人编译好的程序,SDK 才能编译、才能用dotnet new创建项目。新手最容易在这翻车:装了个 Runtime,然后发现dotnet new用不了。

装完之后再敲一次dotnet --version,能看到版本号再继续。这一步没过去,后面全是白费。

3.2 路线 A:用 HttpListener 手写,看清每个零件

新建一个控制台项目:

dotnet new console -n MiniWeb cd MiniWeb

然后把Program.cs里的内容整个替换成下面这段:

using System; using System.Net; using System.Text; // 创建一个监听器对象,此时它还没开始工作 HttpListener listener = new HttpListener(); // 加一个监听地址。注意结尾的斜杠不能少,否则启动时会报错 listener.Prefixes.Add("http://localhost:8080/"); // 真正开始监听,这一行之后端口就被占住了 listener.Start(); Console.WriteLine("服务已启动,浏览器打开 http://localhost:8080/"); // 死循环:一次接一个请求,接完再等下一个 while (true) { // 程序会卡在这一行,直到有浏览器来访问才往下走 HttpListenerContext ctx = listener.GetContext(); HttpListenerRequest req = ctx.Request; // 客人带来的单子 HttpListenerResponse res = ctx.Response; // 我们准备递回去的东西 // 要返回的内容,先转成字节数组,因为网络上传的都是字节 string body = "<h1>你好,这是我第一个 C# Web 服务</h1>"; byte[] buffer = Encoding.UTF8.GetBytes(body); // 告诉浏览器这是什么类型,charset 不写中文会变乱码 res.ContentType = "text/html; charset=utf-8"; // 告诉浏览器一共多少个字节,少了这行有些浏览器会一直转圈 res.ContentLength64 = buffer.Length; // 把字节写回去,然后关掉输出流表示「我说完了」 res.OutputStream.Write(buffer, 0, buffer.Length); res.OutputStream.Close(); // 在控制台打一行日志,方便我们知道请求真的进来了 Console.WriteLine($"收到请求:{req.HttpMethod} {req.Url}"); }

保存,回到命令行敲dotnet run。看到「服务已启动」这行字之后,别关窗口,打开浏览器输入http://localhost:8080/。页面上应该出现那句中文。同时命令行会多出一行「收到请求:GET http://localhost:8080/」。

每个零件的作用,对照代码看一遍就明白了。Prefixes.Add是告诉程序守哪扇门,localhost表示只接受本机访问,8080是门牌号。listener.Start()是真正把门打开。GetContext()是站在门口等,没有人的时候它就卡在那里不动,这是正常的,不是程序卡死。ContentType和ContentLength64是两张贴在包裹上的标签,告诉浏览器里面装的是什么、有多重。

改一下body里那句中文,重新dotnet run,页面就变了。想换端口,把8080改成9000,浏览器地址也跟着改。就这么简单。

3.3 路线 B:用自带 Kestrel 的模板,三行命令跑起来

刚才那段代码能跑,但你会发现一个问题:如果我想让/about返回一段话、/hello返回另一段话,就得在循环里写一串if (req.Url.AbsolutePath == "/about")。加十个页面就是十个 if,越写越难看,这就是手写的天花板。

现实中的项目用 Kestrel。建项目:

dotnet new web -n MyFirstWeb cd MyFirstWeb dotnet run

dotnet new web会生成一个空模板,Program.cs大概长这样:

var builder = WebApplication.CreateBuilder(args); // 建一个宿主,Kestrel 是它的默认服务器 var app = builder.Build(); // 把请求处理管道装配好 app.MapGet("/", () => "你好,这是我第一个 C# Web 服务"); // 访问根路径返回这句话 app.Run(); // 启动,控制台会打印出实际监听的地址

dotnet run之后,控制台会打印一行Now listening on: http://localhost:5xxx,把那个地址复制到浏览器里打开,就能看到返回的文字。

这段代码里,builder是「筹备处」,负责收集配置;app是「成品」,负责接请求;app.MapGet是「登记一条规则」,告诉它什么样的请求交给哪段代码处理;app.Run()就是前面那个listener.Start()加死循环的合体,只不过它内部是异步的,能同时接待很多人。

模板默认的端口每次可能不一样,这是好事,避免和你机器上别的东西撞车。想固定端口,在启动命令后面加参数:

dotnet run --urls "http://localhost:8080"

3.4 跑通之后,先做这三件事

第一件,把中文换成英文再换回来,看看有没有乱码。这一步是让你记住charset=utf-8不是可有可无的装饰。

第二件,故意把端口改成一个已经被占用的(比如再开一个窗口跑同一个程序),看报错长什么样。你会见到HttpListenerException或者「地址已在使用」。提前见过这个错,以后遇到就不会慌。

第三件,在MapGet里加第二个路径:

app.MapGet("/about", () => "这是一个练习用的小服务");

重启,访问http://localhost:端口/about。你会发现不用写 if,这就是路由的雏形,下一章就讲它。

4. 让服务像样一点:路由、参数和返回 JSON

4.1 路由是什么:把不同的网址分给不同的代码

路由这个词听着唬人,其实就是一张对照表:什么网址,交给哪段代码。

上一章你已经试过了,app.MapGet("/about", ...)就是往表里加了一行。访问/about就走这段,访问/就走那段。没匹配上的路径,框架会自动返回 404,你不用自己写。

路由的路径里还能挖坑,叫路径参数。比如:

// {name} 是一个占位符,访问 /hello/A同学 时,name 就是 "A同学" app.MapGet("/hello/{name}", (string name) => $"你好,{name},欢迎光临");

大括号里的名字要和后面方法参数的名字对得上,框架靠这个名字把网址里的值取出来塞进去。这是最小 API 里最省事的一点:你不需要手动解析字符串,声明一个参数就完事了。

路径后面还能加约束,比如{id:int}表示这一段必须是整数。如果访问者传了个字母进去,框架直接返回 404,不用你写校验代码。这个细节新手容易忽略,但它能挡掉一大类脏数据。

4.2 收参数:路径参数、查询参数、请求体

除了路径里的参数,还有两种常见的数据来源。

一种是查询参数,就是网址里问号后面那串,比如/search?keyword=C%23。写法是方法参数名和问号后面的名字一致:

// 问号后面的 keyword 会自动塞进同名参数里 // 加 ? 表示这个参数可以不给,不会因为缺参数报错 app.MapGet("/search", (string? keyword) => { if (string.IsNullOrWhiteSpace(keyword)) { return Results.BadRequest("keyword 不能为空"); } return Results.Ok(new { Keyword = keyword, Count = 3 }); });

另一种是请求体,用在 POST 请求里,通常是一段 JSON。最小 API 里只要方法参数是一个自定义类型,框架就会自动把请求体里的 JSON 反序列化成这个对象:

// 定义一个形状,用来接前端传来的数据 public record UserDto(string Name, int Age); // POST 过来的 JSON 会自动变成 UserDto 对象 app.MapPost("/api/users", (UserDto user) => { // 这里只是演示,真实项目里应该存起来 return Results.Ok(new { Message = $"收到用户 {user.Name},年龄 {user.Age}" }); });

record那一行要放在文件最下面,因为Program.cs顶部是顶级语句,类型声明得放在后面。这是新手很容易踩的一个语法坑,写了之后报「命名空间不能直接包含字段」之类的错,基本都是位置放错了。

4.3 返回 JSON,让前端能直接吃

你注意到上面用了Results.Ok(...),括号里扔的是一个new { ... }匿名对象。它会被自动序列化成 JSON,浏览器里看到的是{"keyword":"C#","Count":3}这样的字符串。

这里有个白话理解:Results.Ok是「200,一切正常,东西给你」,Results.BadRequest是「400,你传的东西有问题」,Results.NotFound是「404,没这东西」。前面的数字叫状态码,前端拿到之后能判断该走成功分支还是失败分支。你只要记住:正常就 Ok,参数不对就 BadRequest,找不到就 NotFound,大部分场景够用了。

还有一种你想自己控制返回内容类型的情况,用Results.Text("...", "text/plain")或者Results.Content。但九成情况下Results.Ok就够了。

把这一章的代码拼起来,大致是这样:

var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.MapGet("/", () => "首页"); app.MapGet("/hello/{name}", (string name) => $"你好,{name}"); app.MapGet("/api/users/{id:int}", (int id) => Results.Ok(new { Id = id, Name = "A同学" })); app.MapGet("/search", (string? keyword) => string.IsNullOrWhiteSpace(keyword) ? Results.BadRequest("keyword 不能为空") : Results.Ok(new { Keyword = keyword, Count = 3 })); app.MapPost("/api/users", (UserDto user) => Results.Ok(new { Message = $"收到用户 {user.Name}" })); app.Run(); public record UserDto(string Name, int Age);

跑起来之后,用浏览器访问几个 GET 路径试试。POST 那个用浏览器地址栏试不了,得用 curl:

curl -X POST http://localhost:8080/api/users \ -H "Content-Type: application/json" \ -d '{"name":"A同学","age":20}'

-X POST是指定方法,-H是加一个请求头告诉对方我发的是 JSON,-d是请求体。Content-Type这个头不写,服务端可能不认,会返回 415,这是新手用 curl 最常遇到的坑。

4.4 改了代码怎么让它生效

手写HttpListener的时候,每次改完都要按 Ctrl+C 停掉,再dotnet run,很烦。

Kestrel 项目可以用热重载:

dotnet watch run

这个命令会盯着你的文件,一保存就自动重新编译重启。控制台会打印「文件已更改,正在重新启动」。刚开始用会觉得神奇,用久了离不开。

但要注意,热重载不是万能的。改了方法签名、改了类的结构、加了新的 NuGet 包,它有时候会失败,提示你手动重启。看到「无法应用更改」这类提示,就老老实实 Ctrl+C 再来一次,别跟它较劲。

5. 新手避坑:这 5 个地方我第一次全踩了一遍

5.1 启动就报错,说访问被拒绝

现象:listener.Start()这一行直接抛异常,提示「拒绝访问」或者「访问被拒绝」,程序还没开始跑就挂了。

原因:监听地址的写法有讲究。http://localhost:8080/一般不需要管理员权限,但如果你写成了http://+:8080/或者http://*:8080/(这两个表示接受任意来源的访问),Windows 会要求管理员权限,或者要求你提前给这个地址授权。

解决:练手阶段就用localhost,别图省事写+。确实需要对外开放,两个办法,一是用管理员身份运行命令行,二是用netsh http add urlacl给这个地址加一条授权(这个命令需要在管理员窗口里执行)。Kestrel 那边没这个限制,因为它不走系统那套 HTTP 栈,这也是它更省心的一个原因。

5.2 服务启动了,浏览器却打不开

现象:控制台明明打印了「服务已启动」,浏览器却显示「无法访问此网站」,或者一直转圈。

原因:三个地方最可疑。一是端口对不上,控制台打印的端口和你地址栏里输的不是一个;二是程序监听的是localhost,你却在别的机器上访问——localhost只认本机;三是HttpListener那个死循环被上一个请求卡住了,看起来像没响应。

解决:先看控制台打印的地址,原样复制到地址栏,别自己拼。然后确认是不是在本机访问。最后看命令行有没有打出「收到请求」——没打出来说明请求根本没进来,是端口或者网络的问题;打出来了但页面不动,说明是你返回内容的那段代码有问题。

5.3 两个人同时访问,第二个要等半天

现象:自己测试一切正常,叫了个同学同时打开页面,他的浏览器一直转圈,等你的页面关掉了他那边才出来。

原因:手写HttpListener的那个while循环是单线程的。GetContext()拿到一个请求之后,程序就一直在这段代码里处理,处理完才回去接下一个。上一个没结束,下一个只能在门外排队。这就是为什么真实项目都用 Kestrel。

解决:如果只是想验证,最简单的改法是拿到请求之后丢给线程池:

while (true) { var ctx = listener.GetContext(); // 把处理逻辑丢到后台线程,主循环立刻回去接下一个 Task.Run(() => Handle(ctx)); }

Task.Run是「这件事交给别人做,我继续等下一个」。Handle是你自己写的一个方法,把原来循环体里的代码搬进去。但这么改会带来新的麻烦:多线程同时写同一个控制台、同时访问同一个变量,容易出乱七八糟的问题。所以我的血泪经验是:一旦你开始考虑并发,就别在HttpListener上耗了,直接换 Kestrel,它天生就是干这个的。

5.4 中文全变成了问号或者方块

现象:页面上本来该显示「你好」,结果是一串????或者「ä½ å¥½」这种看不懂的东西。

原因:中文在计算机里存的是字节,同一句话用不同编码存出来的字节不一样。你写Encoding.UTF8.GetBytes(body)是按 UTF-8 存的,但如果没在响应头里说明白「我这是 UTF-8」,浏览器可能会按别的编码去解,解出来就乱了。

解决:ContentType里必须带上charset=utf-8,写成"text/html; charset=utf-8"。如果是返回 JSON,Results.Ok会自动处理,不用操心。另外ContentLength64要填字节数组的Length,不要填字符串的Length——中文一个字占三个字节,填错了浏览器会截断或者一直等。

5.5 代码改了,刷新页面还是老的

现象:明明把返回的文字改了,保存了,浏览器刷新还是原来那句。

原因:八成是程序没重启。dotnet run启动的是一个编译好的程序,你改了源文件但没重新编译,跑的还是旧的。另一成是浏览器缓存,它觉得这个页面刚才拿过了,就不再去问服务端。

解决:先确认命令行是不是用dotnet watch run启动的,不是的话每次改完都要 Ctrl+C 再dotnet run。如果确认重启过了还没变,按 Ctrl+F5 强制刷新,绕过缓存。养成习惯:改完代码先看控制台有没有重新编译的输出,再看页面。

6. 下一步学什么:从「能跑」到「能用」的四步

跑通第一个例子之后,最容易犯的错是到处收藏教程,然后一个都没练完。我的建议是沿着一条线往下走,每一步都小到当天能做完。

第一步,把路由练熟。给你手上的小服务加五个路径:一个返回纯文本、一个带路径参数、一个带查询参数、一个返回 JSON 对象、一个接收 POST。每个都自己手敲一遍,别复制。这一步做完,你对「一个请求进来,从哪进、从哪出」就有肌肉记忆了。

第二步,学会看日志。在app.MapGet里打一行Console.WriteLine,把请求路径、方法、时间打出来。然后故意访问一个不存在的路径,看看框架返回什么。很多人卡住不是不会写代码,是出了问题不知道去哪看。控制台输出就是你最早、最好用的排查工具。

第三步,把数据存起来。现在你的数据都写在代码里,一重启就没了。找一个最简单的存储方式,把 POST 进来的用户存到一个列表里,再加一个 GET 接口把它读出来。这一步会让你第一次体会到「服务是有状态的」是什么意思,也会让你第一次遇到「多个人同时改同一个列表」的问题——这个坑早点踩比晚点踩好。

第四步,才是去了解部署。注意是了解,不是马上做。你要知道你的程序最终是要跑在某个地方、被别人访问的,那时候端口、防火墙、进程常驻这些词才会变得有意义。但在这之前,本地能跑通、能改、能调试,就已经超过大部分停在「收藏了但没打开」的人了。

阶段目标判断标准
第一周跑通并改造第一个例子能不看教程写出 3 个路径
第二周掌握参数与返回格式能用 curl 调通 GET 和 POST
第三周加上最简单的存储重启后数据还能读出来
之后了解部署与并发知道端口和进程的关系

有件事我特别想提醒:别在第一个项目上追求「架构」。我刚开始学的时候,看了太多讲分层、讲依赖注入的文章,结果一个能跑的接口都没写出来。后来我把那些全放下,就用一个Program.cs写了十几个接口,边写边加,写到写不下去了才去想怎么拆。那一次之后我才真正理解为什么要分文件——不是因为有规范,是因为一个文件真的改不动了。

所以从那以后我每次带新人,都强制让他们先在一个文件里写满,写到自己觉得难受了,再来问「怎么组织代码比较好」。带着痛感去学结构,比空着脑子看规范有效十倍。希望帮到你。

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

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

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

立即咨询