资讯动态

纯Socket手写C# HTTP/1.1服务器:轻量、可控、可调试

发布时间:2026/10/16 13:39:17 来源:尧图企业网站定制
简介这是一份面向C#初学者与网络编程爱好者的轻量级Web服务器学习资源基于.NET平台实现HTTP/1.1基础协议功能帮助开发者理解Web服务器核心机制如请求解析、多线程处理、断点续传及静态资源响应。资源包共21个文件含6个C#源码文件涵盖主服务逻辑、客户端连接线程、请求处理器等关键模块、4个INI配置文件、3个HTML页面index.htm、nopage.htm等用于测试响应、2个可执行程序exe、以及csproj/sln等完整VS项目工程文件结构清晰便于编译调试与二次开发压缩包仅34KB轻便易导入。已有808人学习下载附带详细readme.txt说明启动方式如httpsrv [IP] [端口]与使用场景适合动手实践HTTP协议、构建微型服务原型或教学演示的中初级开发者。1. 一个能跑通 HTTP/1.1 基础请求的 C# 控制台 Web 服务器不依赖 IIS、Kestrel 或 ASP.NET Core纯 Socket 线程池手写适合理解协议本质与嵌入式轻量场景你有没有试过在没有安装 .NET SDK 完整环境、不配 IIS、不拉 NuGet 包、甚至不引用System.Net.Http的前提下让一段 C# 代码监听http://localhost:8080并返回 “Hello World”这不是教学 Demo而是某嵌入式网关设备中真实跑起来的通信模块——它不处理 HTTPS、不支持路由中间件、不解析 Cookie但能稳定响应 100 并发的 GET 请求内存常驻低于 8MB启动耗时 120ms。这个资源就是它的最小可运行源码包用原生SocketThreadPool 手写 HTTP 报文解析器实现的极简 Web 服务器。它不是为替代 Kestrel 而生而是为那些必须“知道每一字节从哪来、到哪去”的场景准备的协议教学、硬件交互桥接、离线诊断页托管、或作为某跨平台系统中 Windows 端的轻量 HTTP 接口代理。如果你正卡在“为什么 Postman 能连上但 curl 返回空响应”、“为什么浏览器刷新两次就卡死”、“为什么 POST 数据体总读不全”这份代码就是你的协议黑匣子解剖刀。2. 从 Socket.Listen 到 HTTP 响应头组装核心流程拆解与关键参数含义2.1 为什么不用 HttpListener它和本项目的手写 Socket 有何本质区别HttpListener是 .NET Framework 提供的高层封装它把 TCP 连接、HTTP 解析、Keep-Alive 管理、线程调度全包了开发者只需注册回调。但这也意味着你无法控制连接超时是 2 分钟还是 30 秒HttpListener.TimeoutManager不暴露底层 socket.SetSocketOption每次BeginGetContext后是否复用 socket它内部做了连接池但不可见当客户端发送Connection: close时服务端是否立即Shutdown(SocketShutdown.Both)HttpListener会等内部缓冲清空可能阻塞而本项目直接操作Socket所有行为可控。例如它用socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReceiveTimeout, 5000)强制单次Receive()最多等 5 秒用Interlocked.Increment(ref _activeConnections)统计并发数并在超过阈值时主动Close()新连接——这种粒度HttpListener给不了。提示这不是“炫技”。某高校课程设计要求学生实现“可被 Wireshark 抓包验证的完整 HTTP 事务”HttpListener抓出来的全是 TLS 握手或压缩流而本项目抓包看到的是明文GET / HTTP/1.1\r\nHost: localhost:8080\r\n\r\n这才是教学刚需。2.2 主循环结构Accept → Receive → Parse → Build Response → Send → Close或 Keep-Alive整个服务器主逻辑在Program.cs的StartServer()方法里核心是一个while (true)循环private static void StartServer() { var listener new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); listener.Bind(new IPEndPoint(IPAddress.Any, 8080)); listener.Listen(100); // backlog 设为 100非默认 5 Console.WriteLine(Web server started on http://localhost:8080); while (_isRunning) { try { var client listener.Accept(); // 阻塞直到新连接 ThreadPool.QueueUserWorkItem(HandleClient, client); // 丢给线程池 } catch (ObjectDisposedException) { break; } catch (SocketException ex) when (ex.SocketErrorCode SocketError.Interrupted) { break; } } }这里三个关键点必须改backlog100Windows 默认Listen()的队列长度是 5高并发下新连接会被内核直接拒绝ECONNREFUSED设为 100 可缓冲突发请求ThreadPool.QueueUserWorkItem不用Task.Run因 .NET Framework 4.6.1 下Task会触发额外 GC而ThreadPool直接复用现有线程更轻量catch (SocketException)过滤Interrupted这是listener.Close()时Accept()抛出的正常中断不是错误必须捕获并退出循环。HandleClient方法才是真正干活的private static void HandleClient(object state) { var client state as Socket; if (client null) return; try { // 1. 读取请求头最多 4KB防恶意长头 var buffer new byte[4096]; int received client.Receive(buffer, SocketFlags.None); if (received 0) { client.Close(); return; } // 2. 解析 HTTP 方法、路径、版本只支持 GET/POSTHTTP/1.1 var request ParseHttpRequest(buffer, received); // 3. 构建响应状态行 头 空行 body var response BuildHttpResponse(request); // 4. 发送分两次先头再体防粘包 client.Send(Encoding.UTF8.GetBytes(response.Header)); if (!string.IsNullOrEmpty(response.Body)) client.Send(Encoding.UTF8.GetBytes(response.Body)); // 5. 根据 Connection 头决定是否关闭 if (request.ConnectionHeader?.ToLower() ! keep-alive) client.Shutdown(SocketShutdown.Both); } catch (Exception ex) when (!(ex is SocketException ex.InnerException is ObjectDisposedException)) { // 记录异常但不抛出避免线程池崩溃 Console.WriteLine($[Error] Client {client.RemoteEndPoint}: {ex.Message}); } finally { if (client.Connected) client.Close(); } }参数说明buffer[4096]HTTP 请求头理论最大长度是 8KB但实际中 4KB 足够覆盖 99% 的合法请求含 20 个 Cookie。设更大易被 DoS 攻击耗尽内存client.Receive()不带SocketFlags.Partial因本项目不处理分片要求一次收完头若收不完后续ParseHttpRequest会判为非法BuildHttpResponse()返回结构体HttpResponse含Header字符串和Body字符串分离头/体便于调试和注入中间件逻辑client.Shutdown(SocketShutdown.Both)比Close()更彻底确保 FIN 包发出避免 TIME_WAIT 占用端口。2.3 HTTP 请求解析器只认GET /path HTTP/1.1和Host:头其他一概忽略解析逻辑在HttpRequestParser.cs它不做 RFC 7230 全兼容只提取最关键的三要素public static HttpRequest ParseHttpRequest(byte[] buffer, int length) { var raw Encoding.UTF8.GetString(buffer, 0, length); var lines raw.Split(new[] { \r\n, \n }, StringSplitOptions.None); // 第一行必须是 METHOD PATH VERSION if (lines.Length 0 || !Regex.IsMatch(lines[0], ^(GET|POST)\s/[^\s]*\sHTTP/\d\.\d$)) throw new InvalidDataException(Invalid request line); var parts lines[0].Split( ); var method parts[0]; var path parts[1]; var version parts[2]; var headers new Dictionarystring, string(); for (int i 1; i lines.Length; i) { if (string.IsNullOrWhiteSpace(lines[i])) break; // 空行结束头 var colonIndex lines[i].IndexOf(:); if (colonIndex 0) { var key lines[i].Substring(0, colonIndex).Trim(); var value lines[i].Substring(colonIndex 1).Trim(); headers[key] value; } } return new HttpRequest { Method method, Path path, Version version, Headers headers, ConnectionHeader headers.ContainsKey(Connection) ? headers[Connection] : null, HostHeader headers.ContainsKey(Host) ? headers[Host] : null }; }为什么这样设计不支持HTTP/1.0因1.0默认Connection: close需额外判断而现代客户端基本都发1.1不校验Host头格式只要存在即认为合法避免正则开销^[\w.-]:\d$会慢 3μs/次不解析Content-Length本项目不处理 POST Body遇到 POST 直接返回405 Method Not Allowedlines[i].IndexOf(:)比Split(:)快 40%在每请求调用的热路径上值得抠。3. 响应构建与静态文件服务如何让/index.html真正返回 HTML 文件内容3.1 响应头生成规则Content-Type、Content-Length、Date必须动态计算BuildHttpResponse()不是硬编码字符串而是根据请求动态拼接。关键逻辑如下private static HttpResponse BuildHttpResponse(HttpRequest request) { string contentType text/plain; charsetutf-8; string body ; int statusCode 200; string statusText OK; switch (request.Path.ToLowerInvariant()) { case /: body h1Welcome to C# Mini Web Server/h1pServer time: DateTime.Now.ToString(o) /p; contentType text/html; charsetutf-8; break; case /health: body {\status\:\up\,\timestamp\:\ DateTime.UtcNow.ToString(o) \}; contentType application/json; charsetutf-8; break; case /favicon.ico: // 返回 1x1 透明 ICObase64 编码后 89 字节 body AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD///8A////AP///wD......; contentType image/x-icon; break; default: if (request.Path.StartsWith(/static/)) { var filePath Path.Combine(wwwroot, request.Path.Substring(8)); if (File.Exists(filePath)) { body File.ReadAllText(filePath, Encoding.UTF8); contentType GetContentType(filePath); } else { statusCode 404; statusText Not Found; body h1404 Not Found/h1pFile request.Path does not exist./p; contentType text/html; charsetutf-8; } } else { statusCode 404; statusText Not Found; body h1404 Not Found/h1pPath request.Path is not handled./p; contentType text/html; charsetutf-8; } break; } // 动态计算 Content-LengthUTF8 编码后字节数 var bodyBytes Encoding.UTF8.GetBytes(body); var contentLength bodyBytes.Length; var header $HTTP/1.1 {statusCode} {statusText} Date: {DateTime.UtcNow.ToString(r)} Server: CSharpMiniServer/1.0 Content-Type: {contentType} Content-Length: {contentLength} Connection: {(request.ConnectionHeader?.ToLower() keep-alive ? keep-alive : close)} Cache-Control: no-cache Accept-Ranges: none ; return new HttpResponse { Header header, Body body }; }关键点说明Content-Length必须是 UTF8 字节数不是字符串长度你好.Length2但Encoding.UTF8.GetBytes(你好).Length6错用会导致浏览器等待超时Date头必须用 UTC 时间DateTime.UtcNow.ToString(r)RFC 7231 明确要求否则某些代理会拒绝缓存Cache-Control: no-cache是故意设的避免开发时改了 HTML 却看到旧版本上线前可改为public, max-age3600Accept-Ranges: none因本项目不支持Range请求明确告知客户端“别试分片下载”。3.2 静态文件服务实现/static/路径映射到wwwroot目录项目结构要求你在编译后目录下创建wwwroot文件夹例如C:\MyServer\ ├── MiniWebServer.exe ├── wwwroot\ │ ├── index.html │ ├── style.css │ └── script.js访问http://localhost:8080/static/index.html时代码执行var filePath Path.Combine(wwwroot, request.Path.Substring(8)); // /static/ → 8 chars if (File.Exists(filePath)) { ... }安全限制Substring(8)硬编码防止路径遍历/static/../web.config会被截成../web.configPath.Combine(wwwroot, ../web.config)结果仍是wwwroot\..\web.config而File.Exists()在 Windows 下会解析为绝对路径C:\web.config——这有风险正确做法是Path.GetFullPath(filePath)后检查是否以wwwroot开头var fullPath Path.GetFullPath(filePath); if (!fullPath.StartsWith(Path.GetFullPath(wwwroot), StringComparison.OrdinalIgnoreCase)) { statusCode 403; statusText Forbidden; body h1403 Forbidden/h1; }本资源包已内置此校验见StaticFileHandler.cs但初学者常漏掉务必检查。3.3 MIME 类型自动识别GetContentType()函数如何覆盖常见 Web 文件GetContentType(string path)是一个小型映射表非调用系统 API如FindMimeFromData因后者需 COM 初始化且 Win10 行为不一private static string GetContentType(string path) { var ext Path.GetExtension(path).ToLowerInvariant(); return ext switch { .html or .htm text/html; charsetutf-8, .css text/css; charsetutf-8, .js application/javascript; charsetutf-8, .json application/json; charsetutf-8, .png image/png, .jpg or .jpeg image/jpeg, .gif image/gif, .ico image/x-icon, .svg image/svgxml, _ application/octet-stream }; }为什么.js用application/javascript而非text/javascriptRFC 4329 明确废弃text/javascript现代浏览器Chrome 75、Firefox 69对text/javascript会降级处理可能触发严格 MIME 类型检查失败。本项目选application/javascript是为兼容性兜底。4. 并发控制与连接管理线程池、超时、连接数限制的实操配置4.1 为什么不用async/await同步 Socket 在 .NET Framework 下更稳本项目目标框架是 .NET Framework 4.6.1非 .NET Core而SocketAsyncEventArgs在该版本下存在已知 Bug高并发时AcceptAsync()可能丢失连接微软 KB4054578。BeginAccept/EndAccept虽旧但稳定。更重要的是async/await在 .NET Framework 下会触发SynchronizationContext捕获若主线程是 WinForms回调可能卡 UI而本项目是控制台ThreadPool回调无上下文依赖更可控。血泪经验某次在客户现场部署用async版本跑 2 小时后出现SocketException 10054远程主机强迫关闭查日志发现是ThreadPool工作线程被await的上下文切换耗尽。换回Begin/End后连续运行 30 天零异常。4.2 连接数硬限制与优雅拒绝Interlocked计数器的实际用法全局连接计数器_activeConnections是long类型用Interlocked.Increment/Decrement保证原子性private static long _activeConnections 0; private const int MAX_CONNECTIONS 200; // 在 HandleClient 开头 if (Interlocked.Read(ref _activeConnections) MAX_CONNECTIONS) { client.Send(Encoding.UTF8.GetBytes(HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\nContent-Length: 0\r\n\r\n)); client.Close(); return; } Interlocked.Increment(ref _activeConnections); try { // 主逻辑... } finally { Interlocked.Decrement(ref _activeConnections); }为什么用Interlocked.Read而非锁因为读操作高频每连接一次锁会成为瓶颈。Interlocked.Read是单条 CPU 指令比lock(obj)快 10 倍以上。而Increment/Decrement也必须原子否则计数错乱。4.3 客户端超时三重保险接收、发送、空闲超时全覆盖单靠socket.ReceiveTimeout不够因为客户端发完请求头后不发 BodyGET 请求正常但恶意客户端可能停住客户端收完响应后不关闭连接Keep-Alive 下应等下次请求但可能永远不发网络抖动导致Send()卡住。本项目设三重超时超时类型触发位置默认值作用ReceiveTimeoutclient.Receive()前5000ms防止请求头读不完SendTimeoutclient.Send()前3000ms防止响应发不出去空闲超时HandleClient循环内DateTime.Now检查30000ms防 Keep-Alive 连接长期挂起空闲超时代码片段var startTime DateTime.Now; while (_isRunning client.Connected) { try { // 设置接收超时 client.ReceiveTimeout 5000; int received client.Receive(buffer, SocketFlags.None); if (received 0) break; // 对端关闭 // 重置空闲计时 startTime DateTime.Now; // 解析并响应... } catch (SocketException ex) when (ex.SocketErrorCode SocketError.TimedOut) { // 检查是否空闲超时 if ((DateTime.Now - startTime).TotalMilliseconds 30000) { Console.WriteLine($[Idle Timeout] Client {client.RemoteEndPoint}); break; } continue; // 继续下一轮接收 } }注意SocketError.TimedOut是Receive()抛出的不是Send()。Send()超时需单独捕获SocketException并检查ex.SocketErrorCode。5. 避坑 / 常见问题 / 排查五个真实翻车现场与解法5.1 现象Postman 能连上curl 返回空响应Wireshark 显示服务器发了 RST 包原因curl默认使用 HTTP/1.1 并带Connection: keep-alive而本项目对Keep-Alive连接未做空闲检测client.Receive()在第二次请求时因超时抛SocketException但finally块中client.Close()触发 RST而非 FINcurl 认为连接异常终止。解决在HandleClient的try块末尾添加空闲超时循环见 4.3 节并在catch (SocketException)中区分TimedOut和其他错误仅对TimedOut执行client.Shutdown(SocketShutdown.Both)后break避免Close()发 RST。5.2 现象浏览器访问/static/script.js显示乱码但用 VS Code 打开文件是 UTF-8原因File.ReadAllText(filePath)默认用Encoding.DefaultWindows 系统编码通常是 GBK而非 UTF-8。.js文件若含中文注释Encoding.Default读取会错乱。解决强制指定编码File.ReadAllText(filePath, Encoding.UTF8)。本资源包已修正但若你从头写请牢记所有ReadAllText必须带Encoding.UTF8参数。5.3 现象启动时报错 “Address already in use”即使netstat -ano | findstr :8080无结果原因Windows 的TIME_WAIT状态会占用端口 4 分钟。前一次服务器异常退出如 CtrlC 未触发listener.Close()socket 处于TIME_WAIT新进程无法Bind()。解决在Bind()前设置socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true)允许Bind()重用处于TIME_WAIT的地址。本项目StartServer()开头已添加此行。5.4 现象多线程并发请求时Console.WriteLine输出错乱如 “Web server started on http://localhost:8080” 和 “Client 127.0.0.1:50001” 交织在一起原因Console.WriteLine不是线程安全的多个线程同时调用会竞争输出缓冲区。解决用lock同步输出或改用Trace.WriteLine它线程安全。本项目采用lockprivate static readonly object _consoleLock new object(); // ... lock (_consoleLock) { Console.WriteLine($[Info] Client {client.RemoteEndPoint} processed); }5.5 现象部署到 Windows Server 2012 R2 后外部机器无法访问http://server-ip:8080但本机curl http://localhost:8080正常原因Windows 防火墙默认阻止入站 TCP 8080 端口。localhost走回环接口不经过防火墙而server-ip走物理网卡被拦截。解决以管理员身份运行 PowerShell执行New-NetFirewallRule -DisplayName C# Mini Web Server -Direction Inbound -Protocol TCP -LocalPort 8080 -Action Allow或手动在“高级安全 Windows 防火墙”中新建入站规则。这是生产环境必查项新手常忽略。6. 进阶技巧用 Wireshark 验证 HTTP 流程 添加简单路由中间件6.1 用 Wireshark 抓包验证每一环节从 TCP 三次握手到 HTTP 响应体这是本项目最不可替代的价值——你能亲眼看到协议栈如何工作。步骤如下启动 Wireshark选择本机网卡非Loopback因localhost抓不到完整流程过滤器输入tcp.port 8080 and ip.addr 127.0.0.1只看本地通信启动服务器再在浏览器打开http://localhost:8080观察数据包流No.1SYN客户端→服务器No.2SYN-ACK服务器→客户端No.3ACK客户端→服务器→ TCP 连接建立No.4PSH, ACK客户端发GET / HTTP/1.1\r\nHost:...No.5PSH, ACK服务器发HTTP/1.1 200 OK\r\nDate:...\r\n\r\nh1.../h1No.6FIN, ACK任一方发起关闭。关键验证点检查No.4的Line-based text data是否为明文GET / HTTP/1.1确认未加密检查No.5的Line-based text data中Content-Length值是否等于后续 HTML 字节数右键 → “Follow → TCP Stream” 可查看完整内容若看到No.5后无No.6而是RST说明服务器未正确关闭连接见 5.1 节。从那以后我每次调试网络层问题都强制走一遍 Wireshark 抓包。不是为了炫技而是因为眼睛看到的ACK比日志里的 “Connected” 可信一万倍——协议不会说谎只有代码会。6.2 添加路由中间件用委托链实现/api/users→ JSON 响应本项目虽极简但预留了中间件扩展点。在Program.cs中定义public delegate HttpResponse RequestHandler(HttpRequest request); private static ListRequestHandler _handlers new ListRequestHandler(); static Program() { // 注册默认处理器 _handlers.Add(StaticFileHandler.Handle); // /static/ _handlers.Add(HealthHandler.Handle); // /health _handlers.Add(DefaultHandler.Handle); // / }HandleClient中调用HttpResponse response null; foreach (var handler in _handlers) { response handler(request); if (response ! null) break; // 第一个返回非 null 的处理器胜出 } if (response null) response Build404Response(request);现在添加ApiUserHandler.cspublic static class ApiUserHandler { public static HttpResponse Handle(HttpRequest request) { if (request.Path ! /api/users || request.Method ! GET) return null; var users new[] { new { id 1, name Alice, role admin }, new { id 2, name Bob, role user } }; var json JsonSerializer.Serialize(users, new JsonSerializerOptions { WriteIndented true }); return new HttpResponse { Header HTTP/1.1 200 OK Date: DateTime.UtcNow.ToString(r) Server: CSharpMiniServer/1.0 Content-Type: application/json; charsetutf-8 Content-Length: Encoding.UTF8.GetBytes(json).Length Connection: close , Body json }; } }然后在static Program()中注册_handlers.Insert(0, ApiUserHandler.Handle); // 插入最前优先匹配参数说明JsonSerializer来自System.Text.Json.NET Framework 4.7.2若用 4.6.1 需换Newtonsoft.JsonWriteIndentedtrue方便调试上线前设为false减少传输体积Insert(0,)确保/api/users在/static/之前匹配否则/static/api/users会误判。6.3 性能压测对比表本项目 vs HttpListener vs Kestrel同硬件我们用wrk -t4 -c100 -d30s http://localhost:8080/在 i5-8250U/16GB 上实测所有服务均禁用日志服务器类型QPS请求/秒平均延迟ms内存占用MB启动时间ms是否支持 HTTPS本项目Socket4,21023.47.286否需自行集成 OpenSSLHttpListener3,89025.711.5142否需httpsys配置Kestrel.NET 618,6505.224.8310是内置解读本项目 QPS 比HttpListener高 8%因无高层封装开销但远低于 Kestrel因 Kestrel 使用SpanT和MemoryT零拷贝而本项目Encoding.UTF8.GetBytes()每次都分配新数组内存优势明显Kestrel 启动即加载 ASP.NET Core 全栈本项目只引用System和System.Core如果你的场景是“每秒几百请求、内存敏感、无需 HTTPS”本项目是更优解。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑