ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

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

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()。而本项目坚持用TcpListener+NetworkStream,强制你处理每一个环节:

  • 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\n<html>...</html>,少一个\r\n或多一个空格,浏览器就报 ERR_EMPTY_RESPONSE。
    这不是“复古”,而是教学必需——当你亲手把byte[] buffer = new byte[1024]读满、Encoding.UTF8.GetString(buffer, 0, bytesRead)出来一串GET /test?x=1 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()42
HTTP 解析层读取原始字节流、拆解请求行与 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 Body?

HTTP Body 的长度由Content-Lengthheader 或Transfer-Encoding: chunked决定,而初学者常犯的错误是直接stream.Read(buffer, 0, buffer.Length)试图一次读完——这必然失败,因为 TCP 是流式协议,Read()只保证返回 1~n 字节,绝非“整块”。本项目采用双阶段读取策略:

// HttpRequest.cs 中的 ReadBody 方法(简化版) public void ReadBody(NetworkStream stream, Dictionary<string, 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)) { // 场景2:chunked 编码,需解析 chunk size 行(如 "1a\r\n" → 26 字节) // 本项目暂未实现 chunked(因教学复杂度高),遇到时返回 411 Length Required throw new NotSupportedException("Chunked encoding not supported in this demo"); } else { // 场景3:无 body(GET/HEAD)或长度为 0,Body 保持 null Body = new byte[0]; } }

提示:stream.Read()的第三个参数count不是“期望读取数”,而是“最多读取数”。必须用while循环累加,否则contentLength=1024时,第一次Read()可能只返回 512 字节,第二次再读 512。漏掉循环,Body数组前半部分是垃圾数据,后半部分是 0,解析 JSON 必然失败。

3. 三步跑起来:编译、配置、验证,从零到浏览器显示 "Hello World"

3.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 Ctrl+C 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 长度;nc(netcat)命令则直接向 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 :8080(Windows)或lsof -i :8080(macOS/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.NewLine(Windows 是\r\n,Linux 是\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(),但如果处理过程中发生未捕获异常(如FileNotFoundException),client.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 Action<string> 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 Dictionary<string, string> MimeTypes = new() { { ".html", "text/html; charset=utf-8" }, { ".htm", "text/html; charset=utf-8" }, { ".css", "text/css; charset=utf-8" }, { ".js", "application/javascript; charset=utf-8" }, { ".png", "image/png" }, { ".jpg", "image/jpeg" }, { ".jpeg", "image/jpeg" }, { ".gif", "image/gif" }, { ".json", "application/json; charset=utf-8" }, { ".txt", "text/plain; charset=utf-8" } }; // 在 BuildResponse() 中 var ext = Path.GetExtension(filePath).ToLowerInvariant(); response.Headers["Content-Type"] = MimeTypes.GetValueOrDefault(ext, "application/octet-stream");

注意:charset=utf-8对文本类型至关重要。缺少它,浏览器可能用 ISO-8859-1 解析 UTF-8 中文,显示为方块。

5.3 最小化 HTTPS 支持:用 .NET 内置 X509Certificate2 加密传输

本项目不实现 TLS 握手,但可利用SslStream包装NetworkStream,让 HTTP 运行在加密通道上。你需要一个自签名证书(开发用):

# 生成证书(macOS/Linux,Windows 用 PowerShell New-SelfSignedCertificate) openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost"

然后在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, Func<HttpRequest, 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,然后用浏览器访问——当那个著名的 “I'm a teapot” 出现在屏幕上,协议就不再是纸上的文字,而是他亲手拧开的水龙头。希望帮到你。

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

返回列表