
简介这是一份面向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.serverNode几十行代码就能起服务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 CtrlC 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.csHTTP请求的本质是文本协议。请求行是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 Dictionarystring, string Headers { get; } new(StringComparer.OrdinalIgnoreCase); public string? Body { get; private set; } public static async TaskHttpRequest? 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.csHTTP响应的结构是状态行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; charsetutf-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(); } }这里必须强调charsetutf-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; charsetutf-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; charsetutf-8, .css text/css; charsetutf-8, .js application/javascript; charsetutf-8, .png image/png, .jpg or .jpeg image/jpeg, .gif image/gif, .json application/json; charsetutf-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 htmlbodyh1404 - Page Not Found/h1pThe 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; charsetutf-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.csnamespace 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:PublishSingleFiletrue如果目标机器没有装.NET运行时可以改--self-contained true这样会把整个运行时打进去生成一个大约70MB的exe文件双击就能跑。运行后日志输出如下[Server] Listening on http://0.0.0.0:8080/ [Server] Root path: D:\workspace\SimpleWebServer [Server] Press CtrlC 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文件刷新浏览器却发现样式没变多半是缓存。除了在浏览器按CtrlF5强刷还可以在响应头中加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卡住了结合性能计数器很快就能定位问题。这个思路我后来一直沿用不管用什么框架写服务都会先搭好请求耗时日志这个基础能力。本文还有配套的精品资源点击获取