简介:这是一份面向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()卡住。
本项目设三重超时:
| 超时类型 | 触发位置 | 默认值 | 作用 |
|---|---|---|---|
ReceiveTimeout | client.Receive()前 | 5000ms | 防止请求头读不完 |
SendTimeout | client.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 响应体
这是本项目最不可替代的价值——你能亲眼看到协议栈如何工作。步骤如下:
- 启动 Wireshark,选择本机网卡(非
Loopback,因localhost抓不到完整流程); - 过滤器输入:
tcp.port == 8080 and ip.addr == 127.0.0.1(只看本地通信); - 启动服务器,再在浏览器打开
http://localhost:8080; - 观察数据包流:
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,210 | 23.4 | 7.2 | 86 | 否(需自行集成 OpenSSL) |
HttpListener | 3,890 | 25.7 | 11.5 | 142 | 否(需httpsys配置) |
| Kestrel(.NET 6) | 18,650 | 5.2 | 24.8 | 310 | 是(内置) |
解读:
- 本项目 QPS 比
HttpListener高 8%,因无高层封装开销; - 但远低于 Kestrel,因 Kestrel 使用
Span<T>和Memory<T>零拷贝,而本项目Encoding.UTF8.GetBytes()每次都分配新数组; - 内存优势明显:Kestrel 启动即加载 ASP.NET Core 全栈,本项目只引用
System和System.Core; - 如果你的场景是“每秒几百请求、内存敏感、无需 HTTPS”,本项目是更优解。
希望帮到你。
本文还有配套的精品资源,点击获取