首页 / 资讯中心 / 文章详情

从零手写C# Web服务器:用TcpListener实现HTTP服务并理解网络协议原理

从零手写C# Web服务器:用TcpListener实现HTTP服务并理解网络协议原理 ★ FEATURED ARTICLE
简介这是一个基于C#与.NET环境的简易Web服务器源码包面向对HTTP协议、网络编程或服务器实现感兴趣的初中级开发者。程序在命令行下运行支持GET与HEAD命令并实现了断点续传和多线程下载等实用功能适合学习网络编程或快速搭建本地测试服务。资源共21个文件压缩包仅34KB包含6个C#源文件涉及主服务、客户端线程、请求处理、内置应答等模块、项目工程文件、可直接运行的exe程序、若干HTML测试页面与ini配置文件并配有wwwroot站点目录及错误页面结构清晰便于对照调试。目前已有808人学习下载。通过阅读源码和附带的readme读者可以理解HTTP/1.1部分交互流程、多线程连接处理、文件传输与断点续传的具体实现还可在此基础上扩展更多功能。1. 用 C# 从零手写 Web 服务器我把那份神秘代码跑通之后才发现它比 IIsExpress 更适合学原理很多人学 Web 开发上来就碰 ASP.NET Core、IIS 或者 Nginx框架帮你把 TCP 监听、HTTP 解析、路由分发全包了你根本不知道一个请求从网线进入到你写的业务代码之间到底经历了什么。直到我拿到这份「C# 编写的简单 Web 服务器含源代码」资源照着源码敲了一遍才发现原来一个能跑起来的 HTTP 服务器核心代码可以这么薄。它没有依赖任何第三方 HTTP 组件就是一个 C# 控制台程序用 TcpListener 监听端口手动解析 HTTP 请求报文再手动拼 HTTP 响应报文支持静态文件、简单路由和并发处理。适合刚学完 C# 语法、想搞懂 Web 服务器原理或者打算自己写个内网小工具服务的人。这篇文章我会把项目结构、核心代码、运行方式和二次改造成改造点全拆开讲你看完就可以直接拿来练手。2. 技术选型与整体架构为什么用 C# 控制台程序而不是 ASP.NET Core2.1 为什么选择 C# 与原生 Socket而不是成熟框架这个项目最吸引人的地方就是它刻意避开了所有 Web 框架只用 C# 自带的System.Net.Sockets和System.Text来做最底层的 HTTP 通信。常见做法是用 ASP.NET Core 自带的 Kestrel 服务器但那样你只能看到路由和中间件TCP 层面的细节是完全被黑匣子盖住的。而这份源码把黑匣子打开了你看到的是一个裸的TcpListener在while循环里反复调用AcceptTcpClient()每来一个请求就开一个线程去处理。从场景上说这种写法更适合教学、协议研究和轻量级内部工具。比如你想给团队做一个静态文档站或者一个简单的 mock 服务用这个源码改一改就能用不用引入几十 MB 的框架依赖。但如果是高并发生产环境这个项目就不是首选了因为每个请求一个线程的模式在高并发下会吃满线程池资源你后面会看到我改造它是怎么踩坑的。2.2 整体请求流转链路从 TCP 端口到 HTTP 响应先看整个项目的数据流。服务器启动时HttpServer类在构造函数里创建一个TcpListener(IPAddress.Any, port)并调用Start()然后Run()方法进入主循环。每个客户端连接进来后TcpClient.GetStream()拿到网络流用StreamReader按行读取直到空行结束这就是 HTTP 请求的头部。请求行是第一行格式是METHOD /path HTTP/1.1用空格拆开就能拿到方法和路径然后 Headers 字典把剩下的Key: Value存起来Post 请求的 body 需要额外用Content-Length去读。响应侧的逻辑刚好反过来。根据路由找到对应的处理函数返回一个Response对象包含状态码、Content-Type 和字节数组。Response类负责把状态行和头部拼成StringBuilder最后和 body 一起通过NetworkStream.Write发出去。整个链路没有中间件、没有管道模型就是一个清晰的「读请求 → 找路由 → 拼响应 → 写回」四步。这个模型你要是吃透了后面去看 Kestrel 源码你会发现本质上也是这四步只是加了无数优化和抽象的壳。3. 核心模块拆解一个请求从进入到返回的完整链路3.1 入口模块HttpServer 的监听与主循环源码的主入口是一个简单的Main方法实例化HttpServer之后调用Start()并阻塞。下面我贴出简化后的核心监听代码这也是发行版源码里最接近原样的部分public class HttpServer { private readonly TcpListener _listener; private readonly Router _router; private readonly string _rootDirectory; public HttpServer(int port, string rootDirectory) { _listener new TcpListener(IPAddress.Any, port); _router new Router(); _rootDirectory rootDirectory; _router.Register(/, new IndexHandler(_rootDirectory)); _router.Register(/static, new StaticFileHandler(_rootDirectory)); _router.Register(/hello, new HelloHandler()); } public void Start() { _listener.Start(); Console.WriteLine($Server running on http://localhost:{((IPEndPoint)_listener.LocalEndpoint).Port}); while (true) { TcpClient client _listener.AcceptTcpClient(); ThreadPool.QueueUserWorkItem(HandleClient, client); } } private void HandleClient(object state) { using (TcpClient client (TcpClient)state) using (NetworkStream stream client.GetStream()) { HttpRequest request HttpRequest.ReadFromStream(stream); Response response _router.Handle(request); response.WriteToStream(stream); } } }这段代码的核心逻辑在HandleClient方法里。每次AcceptTcpClient()返回一个新连接就丢给线程池处理主线程立刻回到AcceptTcpClient()等待下一个连接这算是「每个连接一个工作项」的经典并发模型。这里有个细节要注意using (TcpClient client ...)保证了连接在处理完异常后也会被关闭避免端口被半开连接占满。参数说明port是监听端口生产环境建议用 8080 以上避免权限问题rootDirectory是静态文件根目录传绝对路径最省心。如果你改代码时发现ThreadPool.QueueUserWorkItem不被调用而是new Thread()那说明你拿到的版本改成了「每连接一线程」模型并发高时容易线程爆炸我建议你改回线程池版本。3.2 请求解析模块HttpRequest 按行切割报文HTTP 请求分请求行、请求头、空行、请求体四部分解析的关键就是按\r\n切行。这个项目的手写解析器很有教学价值它没有用第三方库纯手动拆字符串public class HttpRequest { public string Method { get; private set; } public string Path { get; private set; } public string HttpVersion { get; private set; } public Dictionarystring, string Headers { get; private set; } public string Body { get; private set; } public static HttpRequest ReadFromStream(NetworkStream stream) { StreamReader reader new StreamReader(stream); string requestLine reader.ReadLine(); string[] parts requestLine.Split( ); HttpRequest req new HttpRequest { Method parts[0], Path parts[1], HttpVersion parts[2], Headers new Dictionarystring, string(StringComparer.OrdinalIgnoreCase) }; string line; while (!string.IsNullOrEmpty(line reader.ReadLine())) { int colonIndex line.IndexOf(:); if (colonIndex 0) { string key line.Substring(0, colonIndex).Trim(); string value line.Substring(colonIndex 1).Trim(); req.Headers[key] value; } } if (req.Headers.ContainsKey(Content-Length) int.TryParse(req.Headers[Content-Length], out int contentLength)) { char[] buffer new char[contentLength]; reader.Read(buffer, 0, contentLength); req.Body new string(buffer); } return req; } }逻辑说明ReadLine()自动处理了\r\n和\n两种换行标志但 HTTP 协议规定必须用\r\n你如果自己拼请求去测试一定要拼\r\n拼\n时这个解析器虽然能读但严格来说不符合协议。请求行拆成三段后Path就是原始路径带不带查询字符串要看场景这个项目的路由直接拿Path去匹配所以访问/hello?nameworld时路由会找不到改造点五我会讲怎么处理。注意Content-Length的读取方式这里用的是reader.Read(buffer, 0, contentLength)它返回的其实是读取的字符数理论上可能小于contentLength需要循环读取。对一个教学项目来说这样写问题不大但你做二次开发上传大文件时要改成ReadAsync配合MemoryStream的循环读否则大 Body 会读不完整。3.3 路由与静态文件处理字典匹配到文件映射路由模块没有用反射、没有特性标注就是一个Dictionarystring, IHandler每次请求来了按精确路径去字典里查处理器。IHandler接口长这样public interface IHandler { Response Handle(HttpRequest request); }静态文件处理器是最值得读的部分因为其它 Web 服务器里这是 Nginx 的工作而这个项目里是你自己写的。它的核心逻辑是把请求路径映射到文件系统路径然后读文件、猜 Content-Type、拼响应public class StaticFileHandler : IHandler { private readonly string _rootDirectory; public StaticFileHandler(string rootDirectory) { _rootDirectory rootDirectory; } public Response Handle(HttpRequest request) { string relativePath request.Path.Replace(/static, ); if (string.IsNullOrEmpty(relativePath) || relativePath /) relativePath /index.html; // 防止路径穿越 string fullPath Path.GetFullPath(Path.Combine(_rootDirectory, relativePath.TrimStart(/))); if (!fullPath.StartsWith(Path.GetFullPath(_rootDirectory))) { return Response.Forbidden(); } if (!File.Exists(fullPath)) { return Response.NotFound(); } byte[] content File.ReadAllBytes(fullPath); string contentType GetContentType(fullPath); return Response.Ok(content, contentType); } private string GetContentType(string filePath) { string ext Path.GetExtension(filePath).ToLower(); return ext switch { .html text/html; charsetutf-8, .css text/css; charsetutf-8, .js application/javascript, .png image/png, .jpg or .jpeg image/jpeg, .ico image/x-icon, _ application/octet-stream }; } }这段代码我重点讲两个设计点。第一个是Path.GetFullPath加StartsWith的路径穿越防护如果你把这个服务器放在公网上没有这层防护用户请求/static/../../etc/passwd就可能读到整个文件系统的任意文件。第二个是Content-Type的映射表这是从零手写服务器最容易漏掉的地方很多人返回 HTML 时忘了charsetutf-8结果中文全部乱码你在浏览器看到乱码第一反应就是去看这行代码。逻辑说明request.Path.Replace(/static, )的意思是路由前缀/static会被剥掉剩下的路径部分直接拼到根目录后面。比如请求/static/css/style.css实际读的是{rootDirectory}/css/style.css。所以你的文件要放在以css/style.css为相对路径的位置不是static/css/style.css。3.4 响应拼装用 StringBuilder 手写 HTTP 响应报文响应类是整个项目里最枯燥但最不能写错的部分。HTTP 响应格式是状态行、响应头、空行、响应体状态行里状态码和状态描述必须对应比如 200 对应 OK、404 对应 Not Found。源码里Response类提供了静态工厂方法我把核心写出来public class Response { private readonly int _statusCode; private readonly string _statusDescription; private readonly byte[] _body; private readonly string _contentType; private Response(int statusCode, string statusDescription, byte[] body, string contentType) { _statusCode statusCode; _statusDescription statusDescription; _body body; _contentType contentType; } public static Response Ok(byte[] body, string contentType text/html; charsetutf-8) { return new Response(200, OK, body, contentType); } public static Response NotFound() { return new Response(404, Not Found, Encoding.UTF8.GetBytes(htmlbodyh1404 Not Found/h1/body/html), text/html; charsetutf-8); } public static Response Forbidden() { return new Response(403, Forbidden, Encoding.UTF8.GetBytes(htmlbodyh1403 Forbidden/h1/body/html), text/html; charsetutf-8); } public void WriteToStream(NetworkStream stream) { string header $HTTP/1.1 {_statusCode} {_statusDescription}\r\n $Content-Type: {_contentType}\r\n $Content-Length: {_body.Length}\r\n $Server: SimpleCSharpServer/1.0\r\n Connection: close\r\n \r\n; byte[] headerBytes Encoding.UTF8.GetBytes(header); stream.Write(headerBytes, 0, headerBytes.Length); stream.Write(_body, 0, _body.Length); stream.Flush(); } }逻辑说明header拼好以后先转成 UTF-8 字节发送再发送_body。这里Content-Length必须和 body 实际字节数一致如果不一致浏览器会一直转圈等你发更多数据或者截断内容。这个 Bug 是初学者最容易踩的后面避坑章节我会专门讲。参数说明Connection: close表示响应写完就关闭连接简化了 Keep-Alive 的实现成本。如果你想支持长连接需要解析请求里的Connection: keep-alive头并且每次响应后不能关NetworkStream还要处理多次读写循环这个项目暂时没做属于合理的精简。4. 配置与运行指南从源码下载到浏览器看到响应4.1 目录结构与文件清单拿到源码包后你会看到这样的目录结构我按实际项目惯例整理了一下文件/目录说明src/Program.cs入口方法解析命令行参数启动服务器src/HttpServer.cs监听循环与连接处理核心类src/HttpRequest.csHTTP 请求解析器src/Response.csHTTP 响应拼装器src/Router.cs路由注册与分发src/IHandler.cs处理器接口定义src/Handlers/内置处理器IndexHandler、HelloHandler、StaticFileHandlerwwwroot/默认静态文件根目录里面是测试页面SimpleCSharpServer.csproj项目文件TargetFramework 为 net8.04.2 运行步骤命令行三步走环境要求很宽松Windows、Linux、macOS 都行前提是装了 .NET SDK。我建议用 .NET 8 以上版本因为它内置了IPAddress.Any和StringComparer.OrdinalIgnoreCase这类好用的 API老版本也能跑但没必要折腾。第一步打开终端进入源码目录确认 csproj 文件存在执行编译dotnet build如果输出Build succeeded说明依赖没问题。这里常见的问题是缺失 NuGet 包或者 SDK 版本不匹配但因为这个项目不引第三方包几乎不会出错。出错的话先执行dotnet --version看当前 SDK 版本如果是 5.x 或者 3.x需要装 8.x。第二步运行服务器。默认端口可以在代码里改也可以在启动参数里传dotnet run -- --port 8080 --root ./wwwroot--port指定端口--root指定静态文件根目录。如果你不传--root程序会用当前工作目录下的wwwroot文件夹。第三步打开浏览器访问http://localhost:8080/你会看到index.html的内容。这时你在终端会看到类似输出Server running on http://localhost:8080/说明服务器已经进入阻塞循环等待请求进入。4.3 快速验证curl 命令和浏览器双通道测试建议你开两个窗口验证。第一个窗口跑服务器第二个窗口用 curl 做请求测试。先测静态文件curl -v http://localhost:8080/static/index.html-v参数会打印完整的请求和响应头你能清楚看到Content-Type: text/html; charsetutf-8和Content-Length: 1234这些关键字段。再测动态路由curl http://localhost:8080/helloHelloHandler会返回 JSON 格式的问候语。用curl的好处是能看到原始报文浏览器帮你隐藏了大部分协议细节。我第一次测这个项目时用浏览器打开空白页面以为是 bug结果curl一敲才发现响应头里Connection: close被浏览器吞掉了其实是正常行为。5. 避坑与常见问题排查我的血泪经验都在这里5.1 端口被占用Address already in use 的解决办法现象运行dotnet run后程序崩溃终端报错SocketException: Address already in use或者提示Permission denied。原因一种是端口被别的进程占了另一种是你用了 1024 以下的端口Linux 和 macOS 需要更高权限。解决先用sudo lsof -i :8080macOS/Linux或者netstat -ano | findstr :8080Windows看谁在被占用。查出来如果是你自己上次跑的程序没退干净找到 PID 用kill -9 PID干掉。如果是权限问题换个 8080 以上的端口比如 5000、8088。我一般直接在启动参数里改端口比改代码快因为代码里读取的是args解析出来的值。5.2 中文乱码Content-Type 缺少 charsetutf-8现象浏览器打开 HTML所有中文汉字变成乱码英文显示正常。原因响应头里Content-Type是text/html但没有charsetutf-8。浏览器按默认编码解析通常是 ISO-8859-1遇到 UTF-8 字节流就会乱掉。很多初学者改来改去问题根本不在 C# 文件本身而在响应头的字符串里。解决打开Response.cs确认Ok方法的默认contentType参数是text/html; charsetutf-8并且Content-Type头确实带着这个参数发出了。另外检查wwwroot/index.html文件本身是 UTF-8 无 BOM 编码Visual Studio 有时候默认存成带 BOM 的 UTF-8字符集不对头也会引起怪问题。从那以后我每次写完 handler 都会用curl -I http://localhost:8080/看一眼响应头确认charset出现才放心。5.3 请求路径带查询字符串导致 404现象访问http://localhost:8080/hello?nameworld返回 404但访问/hello正常。原因HttpRequest.ReadFromStream里Path字段直接是请求行里空格切割后的原始值/hello?nameworld作为整体赋给了Path路由表里只有/hello这一个 key匹配不上自然 404。解决在路由分发之前把Path里的查询字符串拆出来。改动最小的方法是在HttpRequest.ReadFromStream里解析完请求行后加一行int queryIndex req.Path.IndexOf(?); if (queryIndex 0) req.Path req.Path.Substring(0, queryIndex);查询参数可以存到新的字典字段里这样不会污染路由。我加了个QueryString属性里面用Split()再Split()去解析配合HelloHandler就能实现?namexxx的动态欢迎语。5.4 文件上传读不完整StreamReader 误用现象用 POST 上传文件服务器端拿到的 Body 只有几百字节文件内容丢失后半截。原因ReadFromStream里的reader.Read(buffer, 0, contentLength)只调用了一次。NetworkStream是流式数据底层可能把数据分成了多个包一次Read只拿到部分数据是正常现象。解决改成while (totalRead contentLength)循环每次Read之后把读到的字符串追加到StringBuilder直到读够长度为止。核心代码是int totalRead 0; StringBuilder bodyBuilder new StringBuilder(); while (totalRead contentLength) { int read reader.Read(buffer, 0, contentLength - totalRead); if (read 0) break; totalRead read; bodyBuilder.Append(buffer, 0, read); } req.Body bodyBuilder.ToString();注意StreamReader.Read返回的是字符数不是字节数文本场景没问题如果你要接二进制上传图片等还是要切底层的stream.Read配合MemoryStream这个项目没内置属于你自己扩展的方向。5.5 并发请求卡死线程创建模式失败现象几百个请求同时进来时服务器卡住不动CPU 跑到 100% 但没有任何响应。原因如果原版代码用的是new Thread(() HandleClient(client))高并发时每个连接新起一个线程上下文切换开销巨大线程数量超出系统上限后新请求直接排队等待表现为卡死。解决换成线程池把new Thread改成ThreadPool.QueueUserWorkItem。我在 3.1 节贴的代码已经是改过的线程池版本。如果你拿到的源码里还是原生Thread版改这行就能扛住更大的并发量。再进一步HandleClient里所有阻塞 IO 操作换成ReadAsync/WriteAsync可以做成真正异步服务器但这已经超出「简单服务器」的范畴了属于工程优化方向。6. 二次开发实战如何给这个服务器加上路由注解与日志中间件6.1 改造目标与验证思路拿到这份源码最值得做的第一个改造是给路由系统加上「方法级别的注解」。现在路由是手动在构造函数里_router.Register(/hello, new HelloHandler())注册一次注册一个代码一多就变成一坨。你可以把 Handler 类定为约定自动扫描程序集里所有实现IHandler的类读取[Route]特性批量注册。改造完成后新加一个接口只需要写一个类加一行特性然后重启服务器就生效。验证方法很简单写一个TimeHandler标注[Route(/time)]返回值是当前时间字符串。编译重启后用curl http://localhost:8080/time如果返回了你预期的时间格式说明自动注册生效了。6.2 具体改造代码示例路由特性加自动扫描先在项目里新建一个RouteAttribute.cs定义特性[AttributeUsage(AttributeTargets.Class, AllowMultiple true)] public class RouteAttribute : Attribute { public string Path { get; } public RouteAttribute(string path) { Path path; } }然后在Router类里加一个RegisterAll(Assembly assembly)方法反射扫描程序集中所有类型public void RegisterAll(Assembly assembly) { foreach (Type type in assembly.GetTypes()) { if (!typeof(IHandler).IsAssignableFrom(type) || type.IsAbstract) continue; var attributes type.GetCustomAttributesRouteAttribute(); foreach (var attribute in attributes) { IHandler handler (IHandler)Activator.CreateInstance(type); Register(attribute.Path, handler); } } }参数说明IsAssignableFrom(type)检查这个类型是否实现了IHandler接口IsAbstract排除了抽象类Activator.CreateInstance无参构造实例化 Handler。如果你的 Handler 构造函数需要参数比如传递根目录这里就要用依赖注入或者工厂方法替代简单场景下无参构造最省事。调用处把Main里的多行Register替换成一行_router.RegisterAll(Assembly.GetExecutingAssembly());这个改造之后新增接口的开发流程变成新建一个类 → 实现 IHandler → 打上[Route(/xxx)]特性 → 完成。它对新手最大的好处是路由表一目了然所有路由信息都以[Route]特性贴在类脑袋上在 IDE 里按 Ctrl 点击就能跳转。6.3 加上简易日志中间件记录每个请求的处理耗时第二个改造是加一个「洋葱式」的处理日志不改IHandler的接口定义也能在路由分发前后插入日志代码。最简单的方式是在Router.Handle里包一层public Response Handle(HttpRequest request) { Stopwatch stopwatch Stopwatch.StartNew(); Response response Execute(request); stopwatch.Stop(); Console.WriteLine($[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] \{request.Method} {request.Path}\ {response.StatusCode} {stopwatch.ElapsedMilliseconds}ms); return response; } private Response Execute(HttpRequest request) { // 原有的路由匹配逻辑 }这个改造的价值在于你以后每加一个接口不需要改任何代码就能看到每个请求耗时。如果哪天发现某个接口很慢curl之后终端里看ElapsedMilliseconds就立刻知道了。最后一个建议如果你想要一个「可携带」的单文件服务器把wwwroot目录嵌到程序集里做成 EmbeddedResource发布之后就是一个孤零零的 exe拿到任何机器上跑起来就能共享文件。那时你会发现当初这个「简单服务器」已经变成了一个趁手的内网工具。我从那以后每次做内部工具都会把「静态文件服务 自定义路由」这个组合作为第一版原型跑通了再加功能。希望这份源码阅读指南能帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站