简介:这是一个基于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 Core
2.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 Dictionary<string, 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 Dictionary<string, 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?name=world时路由会找不到,改造点五我会讲怎么处理。
注意Content-Length的读取方式,这里用的是reader.Read(buffer, 0, contentLength),它返回的其实是读取的字符数,理论上可能小于contentLength,需要循环读取。对一个教学项目来说,这样写问题不大,但你做二次开发上传大文件时,要改成ReadAsync配合MemoryStream的循环读,否则大 Body 会读不完整。
3.3 路由与静态文件处理:字典匹配到文件映射
路由模块没有用反射、没有特性标注,就是一个Dictionary<string, 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; charset=utf-8", ".css" => "text/css; charset=utf-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 时忘了charset=utf-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; charset=utf-8") { return new Response(200, "OK", body, contentType); } public static Response NotFound() { return new Response(404, "Not Found", Encoding.UTF8.GetBytes("<html><body><h1>404 Not Found</h1></body></html>"), "text/html; charset=utf-8"); } public static Response Forbidden() { return new Response(403, "Forbidden", Encoding.UTF8.GetBytes("<html><body><h1>403 Forbidden</h1></body></html>"), "text/html; charset=utf-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.cs | HTTP 请求解析器 |
src/Response.cs | HTTP 响应拼装器 |
src/Router.cs | 路由注册与分发 |
src/IHandler.cs | 处理器接口定义 |
src/Handlers/ | 内置处理器:IndexHandler、HelloHandler、StaticFileHandler |
wwwroot/ | 默认静态文件根目录,里面是测试页面 |
SimpleCSharpServer.csproj | 项目文件,TargetFramework 为 net8.0 |
4.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; charset=utf-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 :8080(macOS/Linux)或者netstat -ano | findstr :8080(Windows)看谁在被占用。查出来如果是你自己上次跑的程序没退干净,找到 PID 用kill -9 PID干掉。如果是权限问题,换个 8080 以上的端口,比如 5000、8088。我一般直接在启动参数里改端口,比改代码快,因为代码里读取的是args解析出来的值。
5.2 中文乱码:Content-Type 缺少 charset=utf-8
现象:浏览器打开 HTML,所有中文汉字变成乱码,英文显示正常。
原因:响应头里Content-Type是text/html但没有charset=utf-8。浏览器按默认编码解析,通常是 ISO-8859-1,遇到 UTF-8 字节流就会乱掉。很多初学者改来改去,问题根本不在 C# 文件本身,而在响应头的字符串里。
解决:打开Response.cs,确认Ok方法的默认contentType参数是text/html; charset=utf-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?name=world,返回 404,但访问/hello正常。
原因:HttpRequest.ReadFromStream里Path字段直接是请求行里空格切割后的原始值,/hello?name=world作为整体赋给了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就能实现?name=xxx的动态欢迎语。
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.GetCustomAttributes<RouteAttribute>(); 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,拿到任何机器上跑起来就能共享文件。那时你会发现,当初这个「简单服务器」已经变成了一个趁手的内网工具。我从那以后每次做内部工具,都会把「静态文件服务 + 自定义路由」这个组合作为第一版原型,跑通了再加功能。希望这份源码阅读指南能帮到你。
本文还有配套的精品资源,点击获取