ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

简介:这是一份面向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。这个资源就是它的最小可运行源码包:用原生Socket+ThreadPool+ 手写 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; } } }

这里三个关键点必须改:

  • backlog=100:Windows 默认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/POST,HTTP/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]*\s+HTTP/\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 Dictionary<string, 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 Allowed;
  • lines[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; charset=utf-8"; string body = ""; int statusCode = 200; string statusText = "OK"; switch (request.Path.ToLowerInvariant()) { case "/": body = "<h1>Welcome to C# Mini Web Server</h1><p>Server time: " + DateTime.Now.ToString("o") + "</p>"; contentType = "text/html; charset=utf-8"; break; case "/health": body = "{\"status\":\"up\",\"timestamp\":\"" + DateTime.UtcNow.ToString("o") + "\"}"; contentType = "application/json; charset=utf-8"; break; case "/favicon.ico": // 返回 1x1 透明 ICO(base64 编码后 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 = "<h1>404 Not Found</h1><p>File '" + request.Path + "' does not exist.</p>"; contentType = "text/html; charset=utf-8"; } } else { statusCode = 404; statusText = "Not Found"; body = "<h1>404 Not Found</h1><p>Path '" + request.Path + "' is not handled.</p>"; contentType = "text/html; charset=utf-8"; } break; } // 动态计算 Content-Length(UTF8 编码后字节数) 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 字节数,不是字符串长度:"你好".Length==2,但Encoding.UTF8.GetBytes("你好").Length==6,错用会导致浏览器等待超时;
  • Date头必须用 UTC 时间(DateTime.UtcNow.ToString("r")),RFC 7231 明确要求,否则某些代理会拒绝缓存;
  • Cache-Control: no-cache是故意设的:避免开发时改了 HTML 却看到旧版本,上线前可改为public, max-age=3600;
  • Accept-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.config,Path.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 = "<h1>403 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; charset=utf-8", ".css" => "text/css; charset=utf-8", ".js" => "application/javascript; charset=utf-8", ".json" => "application/json; charset=utf-8", ".png" => "image/png", ".jpg" or ".jpeg" => "image/jpeg", ".gif" => "image/gif", ".ico" => "image/x-icon", ".svg" => "image/svg+xml", _ => "application/octet-stream" }; }

为什么.js用application/javascript而非text/javascript?
RFC 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不够,因为:

  • 客户端发完请求头后不发 Body(GET 请求正常,但恶意客户端可能停住);
  • 客户端收完响应后不关闭连接(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(而非 FIN),curl 认为连接异常终止。
解决:在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.Default(Windows 系统编码,通常是 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 分钟。前一次服务器异常退出(如 Ctrl+C 未触发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(它线程安全)。本项目采用lock:

private 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 响应体

这是本项目最不可替代的价值——你能亲眼看到协议栈如何工作。步骤如下:

  1. 启动 Wireshark,选择本机网卡(非Loopback,因localhost抓不到完整流程);
  2. 过滤器输入:tcp.port == 8080 and ip.addr == 127.0.0.1(只看本地通信);
  3. 启动服务器,再在浏览器打开http://localhost:8080;
  4. 观察数据包流:
    • No.1:SYN(客户端→服务器);
    • No.2:SYN-ACK(服务器→客户端);
    • No.3:ACK(客户端→服务器)→ TCP 连接建立;
    • No.4:PSH, ACK(客户端发GET / HTTP/1.1\r\nHost:...);
    • No.5:PSH, ACK(服务器发HTTP/1.1 200 OK\r\nDate:...\r\n\r\n<h1>...</h1>);
    • No.6:FIN, 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 List<RequestHandler> _handlers = new List<RequestHandler>(); 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.cs:

public 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; charset=utf-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.Json;
  • WriteIndented=true方便调试,上线前设为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
本项目(Socket)4,21023.47.286否(需自行集成 OpenSSL)
HttpListener3,89025.711.5142否(需httpsys配置)
Kestrel(.NET 6)18,6505.224.8310是(内置)

解读:

  • 本项目 QPS 比HttpListener高 8%,因无高层封装开销;
  • 但远低于 Kestrel,因 Kestrel 使用Span<T>和Memory<T>零拷贝,而本项目Encoding.UTF8.GetBytes()每次都分配新数组;
  • 内存优势明显:Kestrel 启动即加载 ASP.NET Core 全栈,本项目只引用System和System.Core;
  • 如果你的场景是“每秒几百请求、内存敏感、无需 HTTPS”,本项目是更优解。

希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进