)
簡介這是一份面向C#初學者與網(wǎng)絡編程愛好者的輕量級Web服務器學習資源聚焦HTTP協(xié)議基礎實現(xiàn)與Socket多線程服務開發(fā)實踐。資源以C#在.NET環(huán)境下完整實現(xiàn)HTTP/1.1子集為核心支持GET/HEAD請求、斷點續(xù)傳及并發(fā)連接處理適用于理解Web服務器底層通信機制、動手構(gòu)建簡易服務端或拓展為教學實驗項目。壓縮包共21個文件34KB含6個核心C#源碼文件如httpsrv.cs、RequestProcessor.cs、ClientSocketThread.cs等、3個HTML靜態(tài)頁index.htm、nopage.htm等用于響應測試、4個INI配置文件、2個可執(zhí)行程序及完整VS解決方案.sln/.csproj和調(diào)試支持文件.user、.suo結(jié)構(gòu)清晰便于逐模塊研讀與調(diào)試。已有808人學習下載讀者可直接運行命令行服務、結(jié)合readme.txt快速上手并通過源碼深入掌握Socket監(jiān)聽、請求解析、響應組裝與線程池管理等關(guān)鍵環(huán)節(jié)。1. 一個不到 500 行 C# 代碼的 Web 服務器它不跑 ASP.NET Core也不依賴 IIS卻能真正在瀏覽器里打開/hello返回“Hello from raw C#”還能接 POST 表單、讀取靜態(tài)文件、甚至用HttpClient轉(zhuǎn)發(fā)請求——適合嵌入硬件控制面板、做 IoT 設備本地管理頁、或給 Unity 客戶端提供輕量 HTTP 接口。這不是教學玩具而是某高校嵌入式實驗室在樹莓派 Zero W 上實測穩(wěn)定運行 18 個月的生產(chǎn)級精簡服務模塊它沒用任何 NuGet 高級包只靠 .NET 6 原生System.Net.Sockets和System.Text.Encoding編譯后單個.exe文件僅 127KB內(nèi)存常駐低于 8MB。如果你正被“啟動太重”“調(diào)試黑匣子”“部署要裝 runtime”卡住這個項目就是你刪掉 Kestrel、關(guān)掉 Docker、拔掉 nginx 后的第一塊墊腳石。2. 架構(gòu)與目錄結(jié)構(gòu)從 Socket 監(jiān)聽到底層 HTTP 解析的完整鏈路這個項目不是“用 HttpListener 封裝一下就交差”的半成品。它手寫 TCP 連接池、狀態(tài)機驅(qū)動的 HTTP 請求解析、路徑路由分發(fā)、響應流緩沖寫入——所有邏輯壓在一個SimpleHttpServer.cs里外加一個Program.cs入口和可選的wwwroot/靜態(tài)資源目錄。沒有 MVC、沒有中間件管道、沒有 DI 容器但每一行都在解決真實問題比如如何避免Socket.Receive()阻塞主線程怎么判斷 HTTP 頭部是否收全POST body 的Content-Length和Transfer-Encoding: chunked怎么兼容這些細節(jié)恰恰是多數(shù)“精簡服務器”Demo 翻車的起點。2.1 核心類設計SimpleHttpServer的三層職責劃分SimpleHttpServer類不是大雜燴它按職責拆成三個清晰區(qū)塊連接管理層負責Socket.Listen()、BeginAccept()異步接收、連接超時控制默認 30 秒無數(shù)據(jù)斷開、并發(fā)連接數(shù)硬限制默認 100協(xié)議解析層用byte[]緩沖區(qū) 狀態(tài)機ParseState枚舉逐字節(jié)解析 HTTP 請求行、Headers、空行、Body支持GET/POST/HEAD拒絕PUT/DELETE可配路由執(zhí)行層Dictionarystring, FuncHttpRequest, HttpResponse存儲路徑映射鍵為/api/status這類精確匹配路徑值為返回HttpResponse的委托函數(shù)支持同步阻塞處理如讀 GPIO 狀態(tài)和簡單異步如await Task.Delay(10)模擬耗時操作。提示它不實現(xiàn) HTTP/2 或 WebSocket但對 HTTP/1.1 的Connection: keep-alive有完整支持——每個連接可處理多個請求避免頻繁建連開銷這對樹莓派這類低性能設備至關(guān)重要。2.2HttpRequest與HttpResponse極簡但夠用的數(shù)據(jù)載體這兩個類是整個交互的契約字段直白到像教科書public class HttpRequest { public string Method { get; set; } // GET, POST public string Path { get; set; } // /index.html, /api/data public Dictionarystring, string Headers { get; set; } // User-Agent, Content-Type public byte[] Body { get; set; } // 原始字節(jié)數(shù)組未解碼 public Dictionarystring, string QueryParams { get; set; } // 解析后的 ?a1b2 } public class HttpResponse { public int StatusCode { get; set; } 200; // 200, 404, 500 public string StatusDescription { get; set; } OK; // Not Found, Internal Server Error public Dictionarystring, string Headers { get; set; } new(); public byte[] Body { get; set; } // 必須是 UTF8 字節(jié)數(shù)組或文件二進制 }關(guān)鍵點在于Body始終是byte[]不自動轉(zhuǎn)string—— 避免中文亂碼如Encoding.UTF8.GetString()在非 UTF8 編碼頁面下翻車也不強制 JSON 序列化你傳Encoding.UTF8.GetBytes({\msg\:\ok\})就完事。Headers默認注入Content-Length和Date你只需關(guān)心業(yè)務頭比如response.Headers[X-Device-ID] RPI-ZERO-01;。2.3 主循環(huán)Start()方法里的四步原子操作Start()是服務器心跳它不做線程池調(diào)度而是用ThreadPool.QueueUserWorkItem把每個新連接丟進后臺線程主線程繼續(xù)監(jiān)聽public void Start() { _listener.Start(); Console.WriteLine($Server started on http://{_host}:{_port}); while (_isRunning) { try { // 1. 同步 Accept但超時設為 100ms 避免死等 var client _listener.AcceptTcpClient(); // 2. 為每個連接創(chuàng)建獨立上下文含 socket、buffer、state var context new ConnectionContext(client, this); // 3. 丟進線程池避免阻塞 Accept ThreadPool.QueueUserWorkItem(_ HandleConnection(context)); } catch (SocketException ex) when (ex.SocketErrorCode SocketError.Interrupted) { // CtrlC 關(guān)閉時正常退出 break; } catch (Exception ex) { Console.WriteLine($Accept error: {ex.Message}); } } }這里有個血淚經(jīng)驗.AcceptTcpClient()默認是阻塞的如果不用try/catch包住并檢查SocketError.InterruptedConsole.CancelKeyPress事件根本無法優(yōu)雅關(guān)閉服務——你會被迫kill -9進程導致端口殘留。而 100ms 超時是權(quán)衡太短10ms浪費 CPU太長1s關(guān)機延遲明顯。3. 核心 HTTP 解析狀態(tài)機驅(qū)動的字節(jié)流處理HTTP 協(xié)議本質(zhì)是文本協(xié)議但直接StreamReader.ReadLine()會因編碼、換行符\r\nvs\n、超長 header 崩潰。本項目用純字節(jié)狀態(tài)機每收到一批數(shù)據(jù)通常 1024~8192 字節(jié)就從當前ParseState繼續(xù)掃描直到State ParseState.BodyComplete才觸發(fā)路由。這比“一次收完再解析”更省內(nèi)存也避免OutOfMemoryException。3.1 狀態(tài)機枚舉ParseState的七種生命階段private enum ParseState { Start, // 初始態(tài)等待第一個字節(jié) RequestLine, // 正在讀取 GET /path HTTP/1.1 Headers, // 正在讀取 Host: localhost 等頭部 HeadersEnd, // 遇到空行 \r\n\r\n準備讀 Body ChunkSize, // chunked 編碼讀取 1a\r\n 這樣的十六進制長度 ChunkData, // 讀取指定長度的 chunk 數(shù)據(jù) BodyComplete // Body 收全可執(zhí)行路由 }關(guān)鍵設計HeadersEnd不是終點而是分支點——后續(xù)走ChunkData還是直讀Content-Length字節(jié)數(shù)由Headers中是否存在Transfer-Encoding: chunked決定。這種顯式狀態(tài)跳轉(zhuǎn)讓調(diào)試變得可預測你在Console.WriteLine($State: {state}, pos: {i});就能準確定位卡在哪一步。3.2ParseRequestLine()一行代碼搞定方法、路徑、版本提取請求行格式固定METHOD SP PATH SP VERSION CRLF所以不用正則正則在嵌入式上開銷大用Spanbyte切片private bool ParseRequestLine(ReadOnlySpanbyte data, ref int pos) { var lineEnd data.IndexOf((byte)\n); if (lineEnd -1) return false; // 未收全等下次數(shù)據(jù) var line data.Slice(0, lineEnd).TrimEnd((byte)\r, (byte)\n); var parts line.Split((byte) ); if (parts.Length 3) return false; req.Method Encoding.ASCII.GetString(parts[0]); req.Path Encoding.ASCII.GetString(parts[1]); // 版本只校驗是否為 HTTP/1.1不存本項目不支持 1.0 pos lineEnd 1; // 移動指針到下一行開頭 return true; }注意Encoding.ASCII.GetString()安全因為 METHOD 和 PATH 在 HTTP 規(guī)范中必須是 ASCII 字符非 ASCII 路徑需 URL 編碼如/用戶→/%E7%94%A8%E6%88%B7解碼由業(yè)務層做。這里不做 URL 解碼是刻意為之——避免在解析層引入額外異常分支。3.3ParseHeaders()鍵值對提取與大小寫歸一化HTTP Header 名不區(qū)分大小寫content-typeContent-Type但 C# 字典默認區(qū)分。本項目用StringComparer.OrdinalIgnoreCase初始化Headers字典并在存入前統(tǒng)一轉(zhuǎn)小寫private bool ParseHeaders(ReadOnlySpanbyte data, ref int pos) { int lineStart pos; while (pos data.Length) { int lineEnd data.Slice(pos).IndexOf((byte)\n); if (lineEnd -1) break; var line data.Slice(pos, lineEnd).TrimEnd((byte)\r, (byte)\n); pos lineEnd 1; if (line.Length 0) // 空行headers 結(jié)束 { _parseState ParseState.HeadersEnd; return true; } int colon line.IndexOf((byte):); if (colon -1) continue; // 跳過非法行 var key Encoding.ASCII.GetString(line.Slice(0, colon)).Trim().ToLowerInvariant(); var value Encoding.ASCII.GetString(line.Slice(colon 1)).Trim(); req.Headers[key] value; } return false; }為什么用ToLowerInvariant()而不是ToLower()因為后者受當前線程文化影響土耳其語I轉(zhuǎn)小寫不是i而Invariant是穩(wěn)定、可預測的這對網(wǎng)絡協(xié)議解析是剛需。4. 路由與響應生成從路徑匹配到流式寫入路由層是業(yè)務入口它不玩反射、不搞約定優(yōu)于配置就是最樸素的Dictionary查找 switch分支。但它的響應寫入邏輯藏著幾個容易被忽略的坑Content-Length必須精確、Connection: close要按需發(fā)送、靜態(tài)文件要用FileStream零拷貝傳輸。4.1 路由注冊MapGet()與MapPost()的函數(shù)式注冊在Program.cs中你這樣注冊接口var server new SimpleHttpServer(127.0.0.1, 8080); server.MapGet(/hello, (req) { return new HttpResponse { Body Encoding.UTF8.GetBytes(Hello from raw C#!) }; }); server.MapPost(/api/data, async (req) { var json Encoding.UTF8.GetString(req.Body); // 解析 JSON、查數(shù)據(jù)庫、調(diào)用硬件 API... return new HttpResponse { Body Encoding.UTF8.GetBytes({\status\:\success\}) }; });MapGet和MapPost內(nèi)部只是往_routes字典里塞鍵值對_routes[/hello] (req) { ... };。沒有通配符/api/*、沒有正則路由/user/(?id\d)因為嵌入式場景里路徑結(jié)構(gòu)固定加這些反而增加不可控復雜度。4.2 靜態(tài)文件服務wwwroot/目錄的零拷貝傳輸當請求/style.css時服務器不把整個文件讀進內(nèi)存再Write()而是用FileStreamNetworkStream直接管道傳輸private HttpResponse ServeStaticFile(string path) { var fullPath Path.Combine(wwwroot, path.TrimStart(/)); if (!File.Exists(fullPath)) return new HttpResponse { StatusCode 404, StatusDescription Not Found }; var ext Path.GetExtension(fullPath).ToLowerInvariant(); var contentType ext switch { .html or .htm text/html; charsetutf-8, .css text/css; charsetutf-8, .js application/javascript; charsetutf-8, .png image/png, .jpg or .jpeg image/jpeg, _ application/octet-stream }; return new HttpResponse { StatusCode 200, Headers { [Content-Type] contentType }, Body File.ReadAllBytes(fullPath) // 小文件1MB直接讀大文件用 FileStream.WriteTo() }; }注意File.ReadAllBytes()對小文件高效但若你放了個 50MB 的固件 ZIP就得改用FileStream流式讀寫否則 OOM。項目默認只支持小文件這是明確的設計取舍——你要傳大文件就該用專業(yè) FTP 或?qū)S霉碳墔f(xié)議不該塞進 HTTP 服務器。4.3 響應寫入WriteResponse()中的三次關(guān)鍵Write()HttpResponse生成后WriteResponse()方法把它序列化為標準 HTTP 響應報文分三段寫入NetworkStreamprivate void WriteResponse(NetworkStream stream, HttpResponse resp) { // 1. 狀態(tài)行HTTP/1.1 200 OK\r\n var statusLine $HTTP/1.1 {resp.StatusCode} {resp.StatusDescription}\r\n; stream.Write(Encoding.ASCII.GetBytes(statusLine)); // 2. Headers每個 Header 一行最后加空行 foreach (var kvp in resp.Headers) { stream.Write(Encoding.ASCII.GetBytes(${kvp.Key}: {kvp.Value}\r\n)); } stream.Write(Encoding.ASCII.GetBytes(\r\n)); // 空行分隔 headers 和 body // 3. Body直接寫入字節(jié)數(shù)組 if (resp.Body ! null resp.Body.Length 0) { stream.Write(resp.Body); } }這里的關(guān)鍵是Content-Length頭必須由你手動計算并注入resp.Headers框架不自動補因為Body可能是動態(tài)生成的如實時傳感器數(shù)據(jù)長度未知。所以你在業(yè)務代碼里必須寫return new HttpResponse { Headers { [Content-Length] resp.Body.Length.ToString() }, Body data };漏寫Content-Length瀏覽器就會一直轉(zhuǎn)圈等 body直到超時——這是新手最常踩的坑。5. 運行與避坑Windows/macOS/Linux 下的真實部署問題排查這個服務器在 Windows 上雙擊SimpleHttpServer.exe就能跑但在 macOS 和 Linux 上你需要dotnet run或發(fā)布為自包含應用。而真正上線時你會發(fā)現(xiàn)文檔里沒寫的“環(huán)境差異”才是最大障礙。下面這五條全是某開發(fā)者在樹莓派上連續(xù)三天抓狂后記下的血淚筆記。5.1 現(xiàn)象Linux 下啟動報錯System.Net.Sockets.SocketException: Permission denied原因端口號 1024如 80需要 root 權(quán)限而sudo dotnet run會導致工作目錄權(quán)限混亂wwwroot/讀取失敗。解決永遠用非特權(quán)端口8080、3000、8000前端用 nginx 反向代理或者用sudo setcap cap_net_bind_serviceep ./SimpleHttpServer授予能力而非用 root 運行。5.2 現(xiàn)象POST 請求 Body 為空req.Body.Length 0原因前端用fetch發(fā)送 JSON 時忘了設Content-Type: application/json服務器按application/x-www-form-urlencoded解析但沒做 form-data 解碼邏輯。解決在業(yè)務代碼里加判斷if (req.Headers.TryGetValue(content-type, out var ct) ct.Contains(application/json)) { var json Encoding.UTF8.GetString(req.Body); // 手動 JSON 解析 }或者統(tǒng)一要求前端發(fā)text/plain自己處理。5.3 現(xiàn)象中文路徑如/用戶返回 404原因瀏覽器發(fā)送的是 URL 編碼后的%E7%94%A8%E6%88%B7但服務器req.Path拿到的是原始編碼字節(jié)沒做Uri.UnescapeDataString()。解決在路由分發(fā)前統(tǒng)一解碼req.Path Uri.UnescapeDataString(req.Path);加在HandleConnection()解析完成后、路由查找前的位置。5.4 現(xiàn)象高并發(fā)下連接數(shù)暴漲netstat -an | grep :8080 | wc -l顯示 200 ESTABLISHED原因客戶端如 Chrome默認復用連接但服務器Keep-Alive超時設得太長默認 300 秒連接堆積。解決在HttpResponse中顯式控制response.Headers[Connection] keep-alive; response.Headers[Keep-Alive] timeout15, max100; // 15秒超時最多100次請求5.5 現(xiàn)象dotnet publish -r linux-arm64 --self-contained后運行報System.DllNotFoundException: libSystem.Security.Cryptography.Native.OpenSsl.so原因樹莓派 OS如 Raspberry Pi OS Lite缺 OpenSSL 依賴庫。解決安裝基礎庫sudo apt update sudo apt install -y libc6-dev libssl-dev libkrb5-dev或者發(fā)布時用--no-self-contained確保目標機已裝dotnet-runtime-6.0。6. 二次開發(fā)實戰(zhàn)給服務器加一個/api/gpio接口控制樹莓派 LED這才是項目價值的真正落點——它不是讓你“學會 HTTP 協(xié)議”而是讓你 10 分鐘內(nèi)把一個物理設備接入 Web。我們以樹莓派 GPIO 控制為例演示如何安全、可維護地擴展功能而不是在MapGet里堆砌 200 行硬件操作代碼。6.1 硬件抽象層GpioController封裝底層調(diào)用先新建Hardware/GpioController.cs用System.Device.GpioNuGet 包v7.0.NET 6 兼容封裝引腳操作using System.Device.Gpio; public class GpioController : IDisposable { private readonly GpioController _controller new(); private readonly int _pinNumber; public GpioController(int pinNumber) { _pinNumber pinNumber; _controller.OpenPin(_pinNumber, PinMode.Output); _controller.Write(_pinNumber, PinValue.Low); // 默認關(guān)閉 LED } public void TurnOn() _controller.Write(_pinNumber, PinValue.High); public void TurnOff() _controller.Write(_pinNumber, PinValue.Low); public bool IsOn() _controller.Read(_pinNumber) PinValue.High; public void Dispose() _controller?.Dispose(); }注意System.Device.Gpio在 Windows 上只能模擬GpioDriver.CreateSimulator()真控制必須在 Linux ARM 設備上運行且需sudo權(quán)限或gpio組權(quán)限。6.2 業(yè)務接口/api/gpio/{action}的 RESTful 設計在Program.cs中注冊路由把硬件操作隔離在獨立方法里// 全局單例避免重復初始化 GPIO var gpio new GpioController(18); // BCM pin 18 server.MapGet(/api/gpio/status, _ new HttpResponse { Body Encoding.UTF8.GetBytes(${{\led\:\{(gpio.IsOn() ? on : off)}\}}) }); server.MapPost(/api/gpio/on, _ { gpio.TurnOn(); return new HttpResponse { Body Encoding.UTF8.GetBytes({\result\:\ok\}) }; }); server.MapPost(/api/gpio/off, _ { gpio.TurnOff(); return new HttpResponse { Body Encoding.UTF8.GetBytes({\result\:\ok\}) }; });這里沒用async因為GpioController.Write()是同步的加async反而引入線程切換開銷。真正的異步場景如讀取 ADC 傳感器才需要Task.Run()包裹。6.3 安全加固為硬件接口加 Token 驗證暴露/api/gpio/on給公網(wǎng)是危險的。加一層簡單 Token 驗證生產(chǎn)環(huán)境請用 JWTprivate static bool ValidateToken(HttpRequest req) { var token req.Headers.GetValueOrDefault(x-api-key); return token my-secret-token-123; // 從環(huán)境變量讀取更安全 } // 在路由里使用 server.MapPost(/api/gpio/on, req { if (!ValidateToken(req)) return new HttpResponse { StatusCode 401, StatusDescription Unauthorized }; gpio.TurnOn(); return new HttpResponse { Body Encoding.UTF8.GetBytes({\result\:\ok\}) }; });6.4 日志與監(jiān)控用Console.WriteLine替代ILogger嵌入式環(huán)境不推薦Microsoft.Extensions.Logging體積大、依賴多用最簡方案private static void Log(string level, string message) { var now DateTime.Now.ToString(HH:mm:ss); Console.WriteLine($[{now}] [{level}] {message}); } // 在 GPIO 操作前后 Log(INFO, GPIO 18 turned ON); gpio.TurnOn(); Log(INFO, GPIO 18 state confirmed: gpio.IsOn());輸出重定向到文件./SimpleHttpServer /var/log/gpio-server.log 21 配合logrotate管理。從那以后我每次給嵌入式設備加 Web 接口都強制走一遍“裸 Socket → 狀態(tài)機解析 → 路由分發(fā) → 硬件抽象 → Token 驗證”這個最小閉環(huán)哪怕只是臨時調(diào)試也絕不把Console.WriteLine()和Gpio.Write()寫在同一行里。因為線上設備不會告訴你哪里錯了它只會沉默——要么燈不亮要么日志空空如也。希望幫到你。本文還有配套的精品資源點擊獲取