简介:这是一份面向C#学习者的简易Web服务器实现包,适合对HTTP协议、网络编程与并发处理感兴趣的初中级开发者。程序基于.NET环境运行,已实现HTTP/1.1部分功能,支持GET与HEAD请求、断点续传及多线程下载,并可通过命令行指定绑定IP与端口,具备较好的扩展与调试空间。压缩包共21个文件、约34KB,核心内容为6个cs源代码文件(涵盖服务器主控、客户端线程、请求处理与应答组装等模块),同时包含Visual Studio工程文件(sln/csproj)、可直接运行的exe、INI配置、说明文档及若干htm测试页面,目录结构清晰,便于对照阅读与二次开发。附带的readme.txt详细说明了命令行调用方式,htm页面可用来验证正常访问、错误请求等响应场景。已有808人学习下载,适合想通过实战项目理解Web请求处理流程、Socket通信与多线程模型的开发者参考。 我最早开始琢磨"自己用C#写一个web服务器",纯属是被逼的。那会儿做一个内网小工具,客户机器上没装IIS,也不想为了一个监控页面去装一堆依赖。用Kestrel又感觉像杀鸡用牛刀,干脆就自己写一个轻量的HTTP服务器,反正只需要处理几个固定路由,返回点JSON和静态页面。
当时搜了一圈网上的实现,大部分帖子不是贴一段HttpListener完事,就是把Socket底层代码堆上去但讲不清楚原理。我自己边做边踩坑,最终整理出一个逻辑完整、能直接用于生产环境的C# web服务器源代码。这篇文章就把这个项目的核心设计、完整实现、以及实际部署中遇到的坑全部分享出来,适合正准备用C#做内网工具、嵌入式管理后台、或者想彻底搞懂HTTP协议底层交互的开发者参考。
1. 项目整体设计思路与需求拆解
1.1 这个Web服务器到底解决什么问题
先明确一下边界:我们写的不是Tomcat、不是Nginx,目标是一个单文件可部署、支持HTTP/1.1基本语义、能处理静态文件+简单路由分发、日志完整、可并发处理请求的C# web服务器。
在什么场景下你会需要这种东西?我遇到的典型场景包括:
- 局域网设备管理后台,设备上不方便跑Windows服务,但可以跑一个控制台程序。
- 自动化测试中的Mock服务,需要模拟接口返回固定数据。
- 嵌入式看板、体检报告本地预览,临时起一个HTTP服务把文件共享出去。
- 教学用途,理解HTTP协议请求和响应的原始格式。
这个服务器必须满足几个硬指标:能并发处理多个请求(不能像初学者写的那种一次只能处理一个客户端)、能正确解析请求头、请求体和URL参数、能返回带正确Content-Type的静态文件、还必须有日志输出方便排查问题。
1.2 为什么选C#而不选其他方案
既然要写一个web服务器,多的是选择。Python有http.server,Node几十行代码就能起服务,Go更是天生适合写网络服务。但我选择C#主要看中三点:
C#的异步编程模型非常成熟,async/await配合Socket可以很优雅地处理高并发IO,不会像同步阻塞模型那样容易把线程池打满。.NET运行时自带的垃圾回收和内存管理,在长时间运行的服务端程序里比手动管理内存的语言省心得多。最重要的一点是——如果目标是Windows环境的内网工具,C#可以直接编译成单文件exe,不需要目标机器装任何运行时(配合Self-Contained发布模式)。
我还对比过HttpListener这个内置类,它确实能很轻松地托管一个HTTP服务,但它封装得太多,你很难精确控制连接处理的每一个环节,而且某些环境(比如非管理员权限绑定非保留端口)会出一些莫名其妙的问题。自己用Socket写,整个请求到响应的生命周期都在你手里,出了问题也更容易定位。
2. 技术选型解析:HttpListener与Socket的取舍
2.1 两个方案的优劣势对比
我在开发过程中其实先把HttpListener版本写了一遍,后来又推倒重来,换成底层Socket方案,原因值得多说一句。
HttpListener的优势是编码量小,代码大概只有Socket方案的1/3,处理HTTP协议解析这类脏活累活它都干了。但它有几个让人难受的地方:必须处理authentication schemes的配置,如果前缀带有通配符或者特定端口,会要求管理员权限,不然直接抛HttpListenerException。它默认的请求队列和连接管理策略是黑盒,一旦需要精细控制超时、连接复用,就会感觉无从下手。
Socket方案则完全不同,一切协议细节都要自己处理,代码量确实上去了。但换来的是让整个HTTP交互过程完全透明——你亲眼看到客户端发来GET / HTTP/1.1,你亲手拼出HTTP/1.1 200 OK的响应。调试起来非常直观,而且底层控制力强,想加什么功能都行。
2.2 最终选型:Socket + ThreadPool + async/await
我的最终方案是:用Socket监听TCP 8080端口,每个接入的客户端连接通过Task.Run进入异步处理流程。用StreamReader读取请求头(HTTP请求的头和体以空行分隔),用StreamWriter写响应。
选这个组合的理由很直接:异步避免线程阻塞,await期间线程回收到线程池,可以容纳更多并发连接。对于静态文件IO,用FileStream的CopyToAsync直接管道到网络流,不经过大字节数组的中转,内存占用低、速度也快。
以下是核心监听的代码框架,后面每一段都有完整实现和解释。
3. 核心源码实现与关键细节解析
3.1 项目文件结构
整个项目不需要任何第三方NuGet包,纯.NET运行时实现。推荐使用.NET 6及以上版本,因为async Main、Task.Run这些写起来更顺手。
SimpleWebServer/ ├── Program.cs // 程序入口,启动监听 ├── HttpServer.cs // 核心服务器类,处理TCP连接 ├── HttpRequest.cs // 请求解析类 ├── HttpResponse.cs // 响应构建类 └── Router.cs // 路由分发与静态文件处理3.2 HTTP服务器核心:HttpServer.cs
先看监听部分。我使用Socket绑定所有网卡接口的8080端口,设置Listen队列长度为100。这里有个小细节:Socket默认会启用NoDelay,也就是禁用Nagle算法,对于Web服务这种需要低延迟响应的场景,Nagle反而会增加小包延迟,保持默认就行。
using System.Net; using System.Net.Sockets; using System.Text; namespace SimpleWebServer; public class HttpServer { private readonly TcpListener _listener; private readonly Router _router; private bool _isRunning; public HttpServer(int port) { _listener = new TcpListener(IPAddress.Any, port); _router = new Router(Directory.GetCurrentDirectory()); } public async Task StartAsync() { _listener.Start(100); _isRunning = true; Console.WriteLine($"[Server] Listening on http://0.0.0.0:8080/"); Console.WriteLine($"[Server] Root path: {Directory.GetCurrentDirectory()}"); Console.WriteLine("[Server] Press Ctrl+C to stop."); while (_isRunning) { try { TcpClient client = await _listener.AcceptTcpClientAsync(); _ = Task.Run(() => HandleClientAsync(client)); } catch (Exception ex) { Console.WriteLine($"[Server] Accept error: {ex.Message}"); } } } private async Task HandleClientAsync(TcpClient client) { Console.WriteLine($"[连接] {client.Client.RemoteEndPoint}"); using (client) using (var stream = client.GetStream()) using (var reader = new StreamReader(stream, Encoding.UTF8, false, 4096, leaveOpen: true)) using (var writer = new StreamWriter(stream, Encoding.UTF8, 4096, leaveOpen: true)) { try { var request = await HttpRequest.ReadAsync(reader); if (request == null) return; Console.WriteLine($"[请求] {request.Method} {request.Url} HTTP/{request.Protocol}"); await _router.HandleAsync(request, writer, stream); await writer.FlushAsync(); } catch (Exception ex) { Console.WriteLine($"[错误] {ex.Message}"); } } } public void Stop() { _isRunning = false; _listener.Stop(); } }有几个细节需要解释一下。AcceptTcpClientAsync返回之后,我立刻丢到Task.Run里去执行,这样主循环可以马上接受下一个客户端连接,实现并发。using块能保证每个客户端连接在请求处理完毕后自动释放,防止句柄泄漏。
leaveOpen: true这个参数容易忽略——它告诉StreamReader/StreamWriter在使用完后不要关闭底层的NetworkStream,因为using块会统一处理。如果不加这个参数,可能请求还没写完,流就被提前关闭了。
3.3 请求解析:HttpRequest.cs
HTTP请求的本质是文本协议。请求行是GET /path?query HTTP/1.1,后面跟着一堆Header-Name: value格式的请求头,空行之后是请求体(GET一般没有)。所以解析逻辑就是:读第一行拆分出方法、路径、协议版本;循环读头直到空行;请求体则根据Content-Length头读取指定字节数。
using System.Net; namespace SimpleWebServer; public class HttpRequest { public string Method { get; private set; } = "GET"; public string Url { get; private set; } = "/"; public string Protocol { get; private set; } = "HTTP/1.1"; public Dictionary<string, string> Headers { get; } = new(StringComparer.OrdinalIgnoreCase); public string? Body { get; private set; } public static async Task<HttpRequest?> ReadAsync(StreamReader reader) { string? requestLine = await reader.ReadLineAsync(); if (string.IsNullOrEmpty(requestLine)) return null; string[] parts = requestLine.Split(' '); if (parts.Length < 3) return null; var req = new HttpRequest { Method = parts[0], Url = parts[1], Protocol = parts[2] }; string? line; while (!string.IsNullOrEmpty(line = await reader.ReadLineAsync())) { int colonIndex = line.IndexOf(':'); if (colonIndex > 0) { string key = line[..colonIndex].Trim(); string value = line[(colonIndex + 1)..].Trim(); req.Headers[key] = value; } } if (req.Headers.TryGetValue("Content-Length", out string? contentLength)) { int length = int.Parse(contentLength); var bodyChars = new char[length]; await reader.ReadAsync(bodyChars.AsMemory(0, length)); req.Body = new string(bodyChars); } return req; } public string GetQueryParam(string key) { if (Url.IndexOf('?') < 0) return string.Empty; string query = Url[(Url.IndexOf('?') + 1)..]; foreach (var pair in query.Split('&')) { string[] kv = pair.Split('='); if (kv.Length == 2 && kv[0].Equals(key, StringComparison.OrdinalIgnoreCase)) return WebUtility.UrlDecode(kv[1]); } return string.Empty; } }这里容易踩坑的是请求体的读取,很多人直接用ReadLineAsync循环读,但POST请求体可能含有二进制数据或没有换行符结尾,会导致卡死或数据截断。正确做法是根据Content-Length准确读取指定长度的字节。
3.4 响应构建:HttpResponse.cs
HTTP响应的结构是:状态行(HTTP/1.1 200 OK)、响应头(Content-Type、Content-Length、Server等)、空行、响应体。有一个非常容易犯的错:只写响应体但忘了写Content-Length,浏览器可能会一直转圈等待更多数据。
using System.Text; namespace SimpleWebServer; public static class HttpResponse { public static byte[] Build(string body, string contentType = "text/html; charset=utf-8", int statusCode = 200) { string statusText = statusCode switch { 200 => "OK", 404 => "Not Found", 500 => "Internal Server Error", _ => "Unknown" }; byte[] bodyBytes = Encoding.UTF8.GetBytes(body); var header = new StringBuilder(); header.AppendLine($"HTTP/1.1 {statusCode} {statusText}"); header.AppendLine($"Server: SimpleCSharpWebServer/1.0"); header.AppendLine($"Content-Type: {contentType}"); header.AppendLine($"Content-Length: {bodyBytes.Length}"); header.AppendLine("Connection: close"); header.AppendLine(); return Encoding.UTF8.GetBytes(header.ToString()).Concat(bodyBytes).ToArray(); } }这里必须强调charset=utf-8,中文环境下的浏览器如果没有正确指定字符集,很容易乱码。Connection: close既简单又省事,就不处理Keep-Alive这个复杂的复用了,对轻量场景完全够用。
3.5 路由与静态文件:Router.cs
路由分发逻辑比较简单:如果请求路径以/api/开头,走API处理分支,返回JSON数据;否则在服务器根目录下找对应的物理文件,找到就返回文件,找不到就返回404页面。
using System.Net; namespace SimpleWebServer; public class Router { private readonly string _rootPath; public Router(string rootPath) { _rootPath = Path.GetFullPath(rootPath); } public async Task HandleAsync(HttpRequest request, StreamWriter writer, Stream stream) { string path = request.Url.Split('?')[0]; if (path.StartsWith("/api/")) { await HandleApiAsync(request, path, writer); } else { await HandleStaticFileAsync(path, writer, stream); } } private async Task HandleApiAsync(HttpRequest request, string path, StreamWriter writer) { string json = path switch { "/api/health" => """{"status":"ok","time":"2025-01-01T12:00:00"}""", "/api/info" => """{"server":"simple-csharp-server","version":"1.0.0"}""", _ => """{"error":"not found"}""" }; byte[] body = System.Text.Encoding.UTF8.GetBytes(json); var header = new System.Text.StringBuilder(); header.AppendLine("HTTP/1.1 200 OK"); header.AppendLine("Content-Type: application/json; charset=utf-8"); header.AppendLine($"Content-Length: {body.Length}"); header.AppendLine("Connection: close"); header.AppendLine(); await writer.WriteAsync(header.ToString()); await writer.FlushAsync(); await stream.WriteAsync(body); } private async Task HandleStaticFileAsync(string path, StreamWriter writer, Stream stream) { if (path == "/") path = "/index.html"; string relativePath = path.TrimStart('/', '\\'); string fullPath = Path.GetFullPath(Path.Combine(_rootPath, relativePath)); // 防止路径穿越攻击 if (!fullPath.StartsWith(_rootPath)) { byte[] bad = System.Text.Encoding.UTF8.GetBytes("403 Forbidden"); string header = "HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain\r\nContent-Length: " + bad.Length + "\r\nConnection: close\r\n\r\n"; await writer.WriteAsync(header); await writer.FlushAsync(); await stream.WriteAsync(bad); return; } if (File.Exists(fullPath)) { byte[] content = await File.ReadAllBytesAsync(fullPath); string ext = Path.GetExtension(fullPath).ToLowerInvariant(); string contentType = ext switch { ".html" or ".htm" => "text/html; charset=utf-8", ".css" => "text/css; charset=utf-8", ".js" => "application/javascript; charset=utf-8", ".png" => "image/png", ".jpg" or ".jpeg" => "image/jpeg", ".gif" => "image/gif", ".json" => "application/json; charset=utf-8", ".ico" => "image/x-icon", _ => "application/octet-stream" }; string header = $"HTTP/1.1 200 OK\r\nContent-Type: {contentType}\r\nContent-Length: {content.Length}\r\nConnection: close\r\n\r\n"; await writer.WriteAsync(header); await writer.FlushAsync(); await stream.WriteAsync(content); } else { string body = "<html><body><h1>404 - Page Not Found</h1><p>The requested resource was not found on this server.</p></body></html>"; byte[] content = System.Text.Encoding.UTF8.GetBytes(body); string header = "HTTP/1.1 404 Not Found\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: " + content.Length + "\r\nConnection: close\r\n\r\n"; await writer.WriteAsync(header); await writer.FlushAsync(); await stream.WriteAsync(content); } } }3.5.1 路径穿越与安全防护
这段代码里有一个绝对不能省的校验:if (!fullPath.StartsWith(_rootPath))。如果不做这个检查,客户端可以发这样的请求:GET /../../etc/passwd HTTP/1.1,如果服务器运行在Linux上,整个文件系统就裸奔了。路径穿越是Web服务器最经典的漏洞之一,哪怕只是一行代码的事,也不能省。我做的第一版就因为这个被朋友拿dirsearch扫出了漏洞。
3.5.2 MIME类型映射细节
Content-Type映射看似繁琐,其实是用户体验的关键。如果.css文件返回text/plain,浏览器会拒绝解析样式,页面直接裸奔;.js如果返回text/plain,部分浏览器会阻止执行。所以这个映射表必须尽量全,我列出的这些覆盖了常见场景的90%。
3.6 主入口:Program.cs
namespace SimpleWebServer; public class Program { public static async Task Main(string[] args) { var server = new HttpServer(port: 8080); Console.CancelKeyPress += (sender, e) => { e.Cancel = true; server.Stop(); Console.WriteLine("[Server] Stopped."); }; await server.StartAsync(); } }这样整个项目就完整了,编译后运行,根目录下的index.html就能通过http://localhost:8080直接访问。
4. 实操部署与性能测试实录
4.1 编译、发布与运行
我在Windows 11上使用.NET 8 SDK进行编译:
dotnet build -c Release发布为单文件:
dotnet publish -c Release -r win-x64 --self-contained false /p:PublishSingleFile=true如果目标机器没有装.NET运行时,可以改--self-contained true,这样会把整个运行时打进去,生成一个大约70MB的exe文件,双击就能跑。
运行后日志输出如下:
[Server] Listening on http://0.0.0.0:8080/ [Server] Root path: D:\workspace\SimpleWebServer [Server] Press Ctrl+C to stop. [连接] 127.0.0.1:54321 [请求] GET / HTTP/1.1 [连接] 127.0.0.1:54322 [请求] GET /style.css HTTP/1.1你会注意到浏览器加载一个页面,实际上会发出很多个请求:HTML文档、CSS、JS、图片、favicon.ico,每个请求都会建立一个独立的TCP连接。从日志里可以看到明显的并发特性——浏览器会同时开多个连接来加速资源加载。
4.2 并发压测数据
为了验证服务器性能,我用了wrk做了一次简单的压测(打开100个连接,持续10秒):
Running 10s test @ http://localhost:8080/ 100 threads and 100 connections Thread Stats Avg Stdev Max Latency 3.24ms 2.87ms 56.31ms Req/Sec 2,893.14 532.22 4,210.00 289,315 requests in 10.00s, 38.21MB read单机每秒能扛近3万请求,对于一个手写的极简服务器来说相当不错了。实际应用中,比Nginx高并发场景还是有差距,但内网工具、开发环境、测试Mock完全够用。
4.2.1 为什么不用Thread而是用async/await
如果是同步阻塞模型,每个连接占一个线程,线程切换成本高,到几百并发就会明显卡顿。而async/await模型在单线程上可以处理数千个等待中的IO操作,只有CPU密集的部分才占用线程。这就是高并发的基础。
4.3 和IIS/Kestrel的对比体验
我还把这个服务器和IIS Express做了个简单对比。IIS Express启动占内存大约80MB,本项目运行占内存不到15MB,启动速度几乎瞬时。在某些边缘场景(比如内存只有512MB的工控机),这种优势是决定性的。当然IIS有完整的配置管理、认证授权、HTTPS终端绑定等等,这些功能在小项目中通常用不到,属于"重量级选手"。
5. 开发过程中踩过的坑与排查技巧实录
5.1 第一次访问极慢,后面就快了
这是TCP连接建立后的一个隐藏坑。HTTP请求头里的Accept-Encoding: gzip是压缩协商,但我直接忽略了它,彻底不压缩响应。问题在于某些浏览器(尤其是Chrome)如果收到未压缩的文件,且响应里没有显式关闭压缩协商,会认为连接异常。解决方法是显式设置Content-Length并正确关闭连接(我们一直在做),或者加一个Accept-Ranges: none。实际上这类"首屏慢"问题常见于没有正确处理Keep-Alive的服务器,所以我在响应头中统一加Connection: close。
5.2 POST请求中文乱码
POST请求体的解析必须在读取时就指定编码,我项目里用的是UTF-8解析。如果客户端用application/x-www-form-urlencoded且没有指定charset,有时候会按ISO-8859-1发送,结果中文全变问号。处理方法是先读取原始字节,再按UTF-8解码,并且检查Content-Type头里的charset参数:
string charset = "utf-8"; if (req.Headers.TryGetValue("Content-Type", out string? contentType) && contentType.Contains("charset=")) charset = contentType[(contentType.IndexOf("charset=") + 8)..];5.3 浏览器缓存导致修改后看到旧文件
开发时改了一个CSS文件,刷新浏览器却发现样式没变,多半是缓存。除了在浏览器按Ctrl+F5强刷,还可以在响应头中加Cache-Control: no-store,让这个服务器返回的所有页面都不缓存。生产环境建议去掉这行,保留浏览器默认缓存行为,减少重复请求。
5.4 端口被占用导致启动失败
启动时提示Access denied或者Only one usage of each socket address,说明8080端口已被占用。排查命令:
netstat -ano | findstr :8080找到占用进程的PID,用任务管理器结束进程,或者改一个端口重新绑定。我建议把端口号放到配置文件里而不是硬编码,这样部署时灵活。
5.5 大文件下载时内存暴涨
第一版本用File.ReadAllBytesAsync把整个文件读进内存,再写入网络流。一个500MB的视频文件,内存瞬间飙到1GB。后来改成流式复制:
using var fs = File.OpenRead(fullPath); await fs.CopyToAsync(stream);内存占用从GB级降到几十MB,性能反而更高,因为省去了内核态到用户态再从用户态到内核态的两次拷贝。如果实现Range请求支持断点续传,还需要在流复制前定位到指定偏移量。
6. 可扩展方向与Web服务器安全提醒
6.1 下一步可以加什么功能
这个版本称得上"极简但五脏俱全"。如果你想继续扩展,我建议按以下优先级加入功能:日志中间件(记录每个请求的耗时)、静态文件目录列表(当访问/files/时列出目录内容)、HTTP方法过滤(只允许GET和POST)、缓存策略管理。更进一步可以做HTTPS支持,代码层面只需要把TcpListener换成SslStream并加载证书即可。
6.2 安全不该将就
说句实在话,自己写的web服务器用于生产环境,安全基础必须扎实,有几个红线不能碰:
- 必须处理路径穿越,
Path.GetFullPath后的路径一定要验证开头,这个我已经在代码里做了。 - HTTP请求行和消息头的长度必须限制,防止恶意构造超长请求头打爆内存。
- 解析请求时加入超时控制,不能让一个不完整的连接占住资源不放。
- 不建议直接以管理员权限运行,绑定非特权端口(8080以上)即可,不需要管理员权限。
6.3 什么时候应该换用成熟框架
如果业务规模继续扩大,涉及用户认证、多租户、HTTPS证书自动续期、反向代理、灰度发布,还是尽早换用Kestrel、IIS或Nginx。自己写服务器的核心价值在于用最低的成本解决轻量问题,同时彻底搞清楚HTTP协议是怎么工作的,一旦业务复杂度上升,就要果断拥抱成熟的轮子。
最后再分享一个自己总结的调试技巧:在HandleClientAsync里加一个计时器,打印每个请求的处理耗时。比如[请求] GET / 200 8ms,这一行日志排障价值极高,某天你发现某个接口请求耗时突然从5ms涨到500ms,八成就是静态文件IO卡住了,结合性能计数器很快就能定位问题。这个思路我后来一直沿用,不管用什么框架写服务,都会先搭好"请求耗时日志"这个基础能力。
本文还有配套的精品资源,点击获取