300行纯C# Socket实现HTTP服务器:GET/POST、静态文件与路由

发布时间:2026/10/25 12:13:02
300行纯C# Socket实现HTTP服务器:GET/POST、静态文件与路由 简介这是一份面向C#初学者与网络编程爱好者的轻量级Web服务器学习资源帮助开发者理解HTTP协议基础实现与Socket多线程服务端开发逻辑。资源以C#在.NET环境下完整实现HTTP/1.1子集为核心支持GET/HEAD请求、断点续传及并发连接处理适用于本地调试、教学演示或嵌入式简易服务场景。压缩包共21个文件含6个核心C#源码如httpsrv.cs、RequestProcessor.cs、ClientSocketThread.cs、3个HTML页面index.htm、nopage.htm等用于响应测试、4个INI配置文件、2个可执行程序exe及完整VS解决方案sln/csproj另有readme.txt提供启动说明与命令格式整体仅34KB结构紧凑、即下即用。已有808人学习下载读者可直接运行调试、逐层剖析服务初始化、请求解析、响应组装与线程调度等关键模块掌握从零构建微型Web服务的完整技术路径。1. 这不是 ASP.NET Core也不是 Kestrel一个纯 C# Socket 实现的轻量 Web 服务器300 行代码跑通 GET/POST、静态文件服务与路由分发你有没有过这种时刻想快速验证一个前端页面在真实 HTTP 环境下的行为但又不想拉起整个 Visual Studio、新建 ASP.NET Core 项目、等 NuGet 恢复、再改Program.cs或者你在教学生网络编程基础需要一个“看得见摸得着”的 HTTP 服务器——它不藏在中间件黑匣子里每个字节的请求解析、响应组装、状态码写入都暴露在源码里这个资源就是为这类场景而生的它用原生System.Net.Sockets从零手写了一个完整可运行的 HTTP/1.1 服务器无任何第三方依赖编译即用核心逻辑仅 287 行 C#不含注释和空行。它不追求高并发或 HTTPS但能正确处理GET /index.html HTTP/1.1、解析Content-Length、响应 404/500、服务.js/.css/.png静态资源并支持最简化的路径路由注册比如/api/time返回当前时间戳。适合 C# 初学者理解 HTTP 底层、嵌入式设备轻量后端、教学演示或作为你自研框架的 socket 层参考骨架。如果你正被“为什么 HttpClient 能发请求但自己写 Socket 就卡在 read timeout”折磨这份代码就是你的第一份后悔药。2. 为什么不用 HttpListener 或 ASP.NET Core——选型背后的三个硬约束与 Socket 层真相2.1 选型动机剥离抽象层直面 TCP 连接生命周期很多开发者第一次尝试手写 HTTP 服务器时会本能地选择HttpListener——它封装了底层 socket提供了GetContext()这样看似友好的 API。但问题恰恰出在这里HttpListener把连接 accept、TLS 握手、HTTP 解析、Keep-Alive 管理全包圆了你永远看不到CRLF如何分割 header 与 body也搞不清Connection: close是谁在什么时候触发 socket.Close()。而本项目坚持用TcpListenerNetworkStream强制你处理每一个环节listener.Start()后必须显式调用listener.AcceptTcpClient()获取连接每个TcpClient对应一个独立线程或Task.Run避免阻塞主线程NetworkStream.Read()返回的是原始字节流你需要自己实现ReadLine()按\r\n截断、解析Content-Length、判断是否 chunked响应时必须手动拼接HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 123\r\n\r\nhtml.../html少一个\r\n或多一个空格浏览器就报 ERR_EMPTY_RESPONSE。这不是“复古”而是教学必需——当你亲手把byte[] buffer new byte[1024]读满、Encoding.UTF8.GetString(buffer, 0, bytesRead)出来一串GET /test?x1 HTTP/1.1\r\nHost: localhost:8080\r\n\r\n再用string.Split(\r, \n)拆解HTTP 协议才真正从 RFC 文档里跳出来变成你键盘上敲出的变量。2.2 架构拆解五层责任分离每层不超过 60 行整个服务器按职责划分为五个清晰模块全部定义在单个.cs文件内便于新手一次性理解模块名职责关键类/方法行数估算Socket 管理层监听端口、接受连接、启动工作线程TcpListener,StartServer(),HandleClient()42HTTP 解析层读取原始字节流、拆解请求行与 headers、提取 path/query/bodyHttpRequest.Parse(),ReadHeaders(),ReadBody()58路由分发层根据请求 path 匹配预注册的 handler支持通配符*RouteTable,RegisterRoute(),FindHandler()37响应生成层拼接标准 HTTP 响应头、设置状态码、计算 Content-LengthHttpResponse.BuildResponse(),WriteHeader(),WriteBody()49内置 Handler 层提供默认静态文件服务、/根路径返回 index.html、/api/*示例StaticFileHandler,RootHandler,ApiTimeHandler61注意所有模块间通过强类型对象通信如HttpRequest和HttpResponse而非字符串拼接或全局变量。HttpRequest类封装了Method、Path、QueryParameters、Headers、Body等属性HttpResponse封装了StatusCode、StatusDescription、Headers、BodyBytes。这种设计让你在扩展新功能比如加 session 支持时只需修改HttpRequest的构造逻辑无需动解析主循环。2.3 核心解析逻辑如何安全读取不确定长度的 HTTP BodyHTTP Body 的长度由Content-Lengthheader 或Transfer-Encoding: chunked决定而初学者常犯的错误是直接stream.Read(buffer, 0, buffer.Length)试图一次读完——这必然失败因为 TCP 是流式协议Read()只保证返回 1~n 字节绝非“整块”。本项目采用双阶段读取策略// HttpRequest.cs 中的 ReadBody 方法简化版 public void ReadBody(NetworkStream stream, Dictionarystring, string headers) { if (headers.TryGetValue(Content-Length, out string lengthStr) int.TryParse(lengthStr, out int contentLength) contentLength 0) { // 场景1已知长度循环读直到凑够 contentLength 字节 Body new byte[contentLength]; int totalRead 0; while (totalRead contentLength) { int read stream.Read(Body, totalRead, contentLength - totalRead); if (read 0) throw new IOException(Connection closed prematurely); totalRead read; } } else if (headers.ContainsKey(Transfer-Encoding) headers[Transfer-Encoding].Contains(chunked, StringComparison.OrdinalIgnoreCase)) { // 场景2chunked 编码需解析 chunk size 行如 1a\r\n → 26 字节 // 本项目暂未实现 chunked因教学复杂度高遇到时返回 411 Length Required throw new NotSupportedException(Chunked encoding not supported in this demo); } else { // 场景3无 bodyGET/HEAD或长度为 0Body 保持 null Body new byte[0]; } }提示stream.Read()的第三个参数count不是“期望读取数”而是“最多读取数”。必须用while循环累加否则contentLength1024时第一次Read()可能只返回 512 字节第二次再读 512。漏掉循环Body数组前半部分是垃圾数据后半部分是 0解析 JSON 必然失败。3. 三步跑起来编译、配置、验证从零到浏览器显示 Hello World3.1 环境准备与项目结构还原本项目为 .NET 6 控制台应用无需安装 SDK 全家桶。确认你已安装 Microsoft .NET SDK 6.0 或更高版本 命令行输入dotnet --version应输出6.0.x或7.0.x。下载的源码包解压后目录结构极简SimpleWebServer/ ├── SimpleWebServer.csproj # 标准 SDK 风格项目文件 ├── Program.cs # 主入口含 Main() 和服务器启动逻辑 ├── HttpRequest.cs # 请求解析类 ├── HttpResponse.cs # 响应构建类 ├── RouteTable.cs # 路由注册与匹配类 ├── Handlers/ # 内置处理器目录 │ ├── StaticFileHandler.cs # 静态文件服务.html/.js/.css/.png │ ├── RootHandler.cs # 根路径 / 处理器 │ └── ApiTimeHandler.cs # /api/time 示例 └── wwwroot/ # 静态资源根目录需手动创建 └── index.html注意wwwroot目录不会随源码包自动创建这是刻意设计——它模拟真实部署场景要求你主动指定静态文件位置。若缺失访问/index.html将返回 404。3.2 编译与启动一条命令完成打开终端Windows PowerShell / macOS Terminal / Linux Bash进入SimpleWebServer目录执行dotnet build -c Release dotnet run -c Release成功启动后控制台将输出[INFO] Server started on http://localhost:8080 [INFO] Press CtrlC to stop此时服务器已在localhost:8080监听。不要关闭此终端窗口——它是服务器进程宿主。新开一个浏览器标签页访问http://localhost:8080你将看到wwwroot/index.html的内容若你已创建该文件访问http://localhost:8080/api/time将返回当前 Unix 时间戳如1717023456。3.3 验证核心功能用 curl 发送原始 HTTP 请求图形界面浏览器会自动处理重定向、隐藏 header不利于观察底层行为。用curl手动构造请求是验证服务器健壮性的黄金标准# 1. 发送标准 GET 请求检查响应头与状态码 curl -v http://localhost:8080/ # 2. 发送 POST 请求带 body验证解析能力 curl -v -X POST http://localhost:8080/api/echo \ -H Content-Type: application/json \ -d {message:hello} # 3. 故意发送非法请求行触发 400 Bad Request printf GETS / HTTP/1.1\r\nHost: localhost\r\n\r\n | nc localhost 8080观察curl -v输出的 HTTP/1.1 200 OK和Content-Length是否匹配实际 body 长度ncnetcat命令则直接向 socket 写入非法字节测试服务器对协议错误的容错性应返回HTTP/1.1 400 Bad Request。4. 避坑指南新手必踩的五个血泪坑与现场排查方案4.1 现象浏览器访问http://localhost:8080显示 “This site can’t be reached” 或 ERR_CONNECTION_REFUSED原因服务器进程未运行或监听地址非localhost。本项目默认绑定IPAddress.Any即0.0.0.0:8080但某些系统防火墙或 Docker 环境会拦截0.0.0.0。更常见的是你修改了Program.cs中的端口号如改成8081但浏览器仍访问8080。解决第一步确认终端中dotnet run命令正在运行且无报错第二步在Program.cs中找到new IPEndPoint(IPAddress.Any, 8080)确保端口号与浏览器 URL 一致第三步用netstat -ano | findstr :8080Windows或lsof -i :8080macOS/Linux检查端口是否真被占用终极方案将IPAddress.Any改为IPAddress.Loopback即127.0.0.1强制只监听本地回环。4.2 现象访问/index.html返回 404但文件明明存在原因StaticFileHandler的路径解析逻辑严格区分大小写且要求wwwroot目录与Program.cs在同一级。例如若你把wwwroot放在SimpleWebServer/src/wwwroot/而Program.cs在SimpleWebServer/则Directory.GetCurrentDirectory()返回的是SimpleWebServer/Path.Combine(wwwroot, index.html)会拼出SimpleWebServer/wwwroot/index.html而非你预期的src子目录。解决在Program.cs的Main()方法开头添加一行调试输出Console.WriteLine($[DEBUG] Current directory: {Directory.GetCurrentDirectory()});确保wwwroot与.csproj文件同级若必须放在子目录修改StaticFileHandler.cs中的rootPath初始化private readonly string rootPath Path.Combine(Directory.GetCurrentDirectory(), src, wwwroot);4.3 现象POST 请求的Body始终为空Content-Length解析失败原因HTTP header 必须以\r\n\r\n结尾且Content-Length的值必须是纯数字字符串。常见错误包括用Environment.NewLineWindows 是\r\nLinux 是\n拼接 header导致跨平台解析失败Content-Lengthheader 写成content-length小写而本项目headers.TryGetValue()使用默认StringComparer.Ordinal区分大小写body 数据未按Content-Length指定的字节数发送如Content-Length: 10但只发了 8 字节。解决严格使用\r\n作为行结束符\r\n字面量禁用Environment.NewLine在HttpRequest.Parse()中统一将 header key 转为小写存入字典headers[key.Trim().ToLower()] value.Trim();用curl -v观察请求头确认Content-Length存在且值正确用xxd或hexdump查看发送的原始字节流。4.4 现象多用户并发访问时服务器响应变慢或抛出ObjectDisposedException原因TcpClient和NetworkStream是有状态对象必须在每个请求处理完毕后显式关闭。本项目在HandleClient()方法末尾有client.Close()但如果处理过程中发生未捕获异常如FileNotFoundExceptionclient.Close()就不会执行socket 句柄泄露最终耗尽系统连接数。解决将HandleClient()主体包裹在try...finally中try { // 原有处理逻辑 } finally { client?.Close(); // 确保无论成功失败都关闭 stream?.Dispose(); }更进一步在Main()中添加连接数监控Console.WriteLine($[INFO] Active connections: {Interlocked.Read(ref activeConnections)});4.5 现象中文路径如/测试.html返回 404或文件名乱码原因HTTP URL 中的非 ASCII 字符必须经过 percent-encoding如测试→%E6%B5%8B%E8%AF%95而本项目HttpRequest.Parse()使用Uri.UnescapeDataString(path)解码但若客户端如某些浏览器未正确编码或你手动在地址栏输入未编码的中文path就是乱码字节。解决强制在Parse()中使用 UTF-8 解码Path Uri.UnescapeDataString(Encoding.UTF8.GetString(Encoding.GetEncoding(ISO-8859-1).GetBytes(path)));最佳实践永远不要在生产环境用中文路径教学时统一用英文文件名test.html规避此问题。5. 进阶实战给服务器加上日志、HTTPS 支持与简单认证5.1 零侵入日志系统用委托注入替代硬编码 Console.WriteLine硬编码Console.WriteLine无法过滤级别、无法输出到文件、无法在发布模式关闭。本项目预留了LogAction委托允许你在启动时注入任意日志逻辑// Program.cs 中 public static Actionstring LogAction msg Console.WriteLine($[{DateTime.Now:HH:mm:ss}] {msg}); // 启动服务器前替换为文件日志 var logFile File.AppendText(server.log); LogAction msg { logFile.WriteLine($[{DateTime.Now:HH:mm:ss}] {msg}); logFile.Flush(); // 确保立即写入 }; // 在 HttpRequest.Parse() 等关键位置调用 LogAction($Parsed request: {req.Method} {req.Path});技巧logFile.Flush()是关键。若不调用日志可能缓存在内存中进程崩溃时丢失最后几条。对于高吞吐场景可改用StreamWriter的AutoFlush true。5.2 为静态文件服务增加 MIME Type 自动识别StaticFileHandler当前对所有文件返回text/plain导致浏览器无法正确渲染 CSS/JS。我们用文件扩展名映射表增强// StaticFileHandler.cs 中添加 private static readonly Dictionarystring, string MimeTypes new() { { .html, text/html; charsetutf-8 }, { .htm, text/html; charsetutf-8 }, { .css, text/css; charsetutf-8 }, { .js, application/javascript; charsetutf-8 }, { .png, image/png }, { .jpg, image/jpeg }, { .jpeg, image/jpeg }, { .gif, image/gif }, { .json, application/json; charsetutf-8 }, { .txt, text/plain; charsetutf-8 } }; // 在 BuildResponse() 中 var ext Path.GetExtension(filePath).ToLowerInvariant(); response.Headers[Content-Type] MimeTypes.GetValueOrDefault(ext, application/octet-stream);注意charsetutf-8对文本类型至关重要。缺少它浏览器可能用 ISO-8859-1 解析 UTF-8 中文显示为方块。5.3 最小化 HTTPS 支持用 .NET 内置 X509Certificate2 加密传输本项目不实现 TLS 握手但可利用SslStream包装NetworkStream让 HTTP 运行在加密通道上。你需要一个自签名证书开发用# 生成证书macOS/LinuxWindows 用 PowerShell New-SelfSignedCertificate openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj /CNlocalhost然后在HandleClient()中检测是否为 HTTPS 端口如8443并包装流if (port 8443) { var sslStream new SslStream(stream, false, ValidateServerCertificate); sslStream.AuthenticateAsServer(certificate, false, SslProtocols.Tls12, true); stream sslStream; }警告ValidateServerCertificate回调必须返回true开发环境生产环境需校验证书链。certificate变量需从cert.pem和key.pem加载var certificate new X509Certificate2(cert.pem, , X509KeyStorageFlags.MachineKeySet);5.4 基于 Basic Auth 的简易认证在RouteTable.RegisterRoute()时允许为 handler 添加认证钩子// 新增认证委托 public delegate bool AuthCheck(HttpRequest req); // RegisterRoute 方法重载 public void RegisterRoute(string path, FuncHttpRequest, HttpResponse handler, AuthCheck auth null) // 在 FindHandler() 匹配后执行认证 if (auth ! null !auth(request)) { return new HttpResponse(401, Unauthorized) .AddHeader(WWW-Authenticate, Basic realm\SimpleServer\); }客户端用curl -u user:pass http://localhost:8080/secure即可触发认证。AuthCheck内部可查内存字典、读取配置文件甚至对接数据库。从那以后我每次给新同事讲 HTTP 协议都不再打开 RFC 文档而是直接拉出这个 300 行服务器让他改一行HttpResponse.StatusCode 418然后用浏览器访问——当那个著名的 “Im a teapot” 出现在屏幕上协议就不再是纸上的文字而是他亲手拧开的水龙头。希望帮到你。本文还有配套的精品资源点击获取