如果你想獲得更好的閱讀體驗,可以前往我在 github 上的博客進行閱讀,http://lcomplete.github.io/blog/2013/07/16/use-csharp-write-aspnet-web-server/。
你是否有過這樣的需求——想運行 ASP.NET 程序,又不想安裝 IIS 或者 Visual Studio?我想如果你經常編寫 ASP.NET 程序的話,應該或多或少都會碰到這種情況。除了使用 IIS 和 VS,我們還有哪些方式可以運行 ASP.NET 程序呢,自己寫一個支持 ASP.NET 的 Web 服務器怎么樣?NO NO NO,如果你只是想找個這樣的工具的話,那完全沒必要,我們知道使用 VS 可以運行 ASP.NET 程序,那么我們就可以找出 VS 所調用的程序,將其拷貝到沒有 VS 和 IIS 的環境中運行,就能運行 ASP.NET 程序了,安裝了 VS 的朋友可以到 C:\Program Files\Common Files\Microsoft Shared\DevServer\ 這個目錄里面找找看,這個程序的使用方式如下。
WebDev.WebServer.EXE /port:80 /path:"c:\mysite" /vpath:"/"
怎么樣?不錯吧,輕而易舉地就解決了文章開頭所說的問題了。當然這並不是本篇文章的重點,如果你不滿足於只知道這個用法,那可以繼續往下閱讀,接下來,我們將使用 C# 編寫一個支持 ASP.NET 的 Web 服務器,看看這一切究竟是如何運作的。
C# 中有着許多豐富的類庫,使用不同的類庫,我們可以站在不同的抽象層級去編寫一個 Web 服務器,比如在 System.Net 命名空間下提供了一個 HttpListener 類,使用這個類,我們可以很容易地創建一個簡單的 Web 服務器,但是這個類隱藏了很多實現的細節,為了避免知其然不知其所以然,我們將使用網絡框架最底層的 Socket 類來編寫這個程序。
預備知識
正式編寫這個程序之前,讓我們先來了解一些基礎知識。編寫一個 Web Server,必需要了解 HTTP 協議,它是萬維網的基礎,位於 TCP/IP 協議棧的應用層。
-
HTTP 協議
HTTP 協議是一個基於請求與響應模式、無狀態的應用層協議,HTTP 請求主要包括三部分:請求行、請求報頭、請求正文,下面是一個請求示例。
GET /lcomplete/AspNetServer HTTP/1.1 Host: github.com Connection: keep-alive Cache-Control: max-age=0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 User-Agent: Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1500.72 Safari/537.36 postdata #可選的消息體
第一行是請求行,該行又分為3個部分,分別是動作、URI 和 HTTP 協議版本,后面的 {key}: {value} 格式的行為報頭,如果請求為 post 動作的話,則報頭后面的post數據為請求正文,需要注意報頭和請求正文之間必需以(回車+換行)分割。
Web 服務器接收到一個請求后,就會將請求解析成上面3個部分,並開始處理應答,響應也由3個部分組成:狀態行、響應報頭、響應正文,響應報頭和正文同樣使用進行分割,狀態行為HTTP協議版本、狀態碼、狀態描述組成,響應報頭與請求報頭格式相同,只不過請求報頭由服務器解釋並處理,響應報頭由瀏覽器解釋並處理,最后的響應正文便是我們所熟悉的 HTML。
了解了 HTTP 協議的基礎知識后,我們可以很容易地構建出一個支持靜態文件的 HTTP 服務器,但是如何處理 ASP.NET 動態內容呢,這就要求我們熟悉 ASP.NET 的 HTTP 架構、管道機制、應用程序生命周期和宿主環境。
-
ASP.NET 運行時機制
ASP.NET 被特意設計成避免依賴 IIS,它的底層架構采用了管道機制,管道由一系列處理 HTTP 消息的對象組成,每個 HTTP 請求都要經過這些對象,每個對象都執行一些自己職責之內的任務。
HttpRuntime 類是管道的入口,它負責開始處理請求,管理首先執行 HttpRuntime 類上的靜態方法 ProcessRequest ,這個方法接收一個 HttpWorkerRequest 對象參數,該對象包含了當前請求的相關信息,HttpRuntime 類使用這個請求信息構建 HttpContext 對象,其中包含了 HttpRequest 和 HttpResponse 屬性,然后根據上下文獲取 HttpApplication 對象,之后請求交給 HttpApplication 對象進行處理。
處理請求時,HttpApplication 會執行一系列任務,其中包括為請求調用合適的 IHttpHandler 類的 ProcessRequest 方法,例如,如果請求針對某頁,則使用該頁的實例處理該請求,另外 HttpApplication 中還維護了 IHttpModule 對象列表,它可以在頁面實例處理請求前后進行一些額外的工作。
管道機制是完全自主的,不需要依附於 IIS 上,不過管道並沒有接收 HTTP 請求的能力,我們需要自己編寫這部分代碼,當收到請求時,創建 HttpWorkerRequest 對象並提供給 HttpRuntime.ProcessRequest 方法調用以啟動管道。
要處理 ASP.NET 請求,還需要創建一個應用程序域以托管 HTTP 管道,我們可以使用 ApplicationHost.CreateApplicationHost 方法創建應用程序域,該方法接收3個參數:宿主類型、虛擬路徑和物理路徑,宿主類型需要跨域應用程序邊界,所以需要繼承自 MarshalByRefObject 類,並提供與其交互的方法,例如至少要提供一個方法使得可以提交 ASP.NET 請求以進行處理。
了解了 ASP.NET 的運行機制后,再來看看編寫 ASP.NET 服務器需要使用到哪些類,首先我們需要使用 ApplicationHost 創建應用程序域以獲得處理 ASP.NET 請求的能力,接收到請求后構造HttpWorkerRequest (該類是抽象類,需要定義它的子類)對象,交由 HttpRuntime 類進行處理,接下來的事情就由 HTTP 管道處理了。
好了,預備知識已經講解完畢,下面讓我們進入編碼實戰。
編碼實戰
還記得文章開頭的命令嗎?運行一個網站需要提供3個必要的東西,端口、網站物理路徑、網站虛擬路徑,在程序開始運行時需要得到這3個參數。
static void Main(string[] args) { int port; string dir = Directory.GetCurrentDirectory(); if(args.Length==0 || !int.TryParse(args[0],out port)) { port = 45758; //端口 } InitHostFile(dir); SimpleHost host= (SimpleHost) ApplicationHost.CreateApplicationHost(typeof (SimpleHost), "/", dir); host.Config("/", dir); //配置虛擬路徑和物理路徑 WebServer server = new WebServer(host, port); server.Start(); } //需要拷貝執行文件 才能創建ASP.NET應用程序域 private static void InitHostFile(string dir) { string path = Path.Combine(dir, "bin"); if (!Directory.Exists(path)) Directory.CreateDirectory(path); string source = Assembly.GetExecutingAssembly().Location; string target = path + "/" + Assembly.GetExecutingAssembly().GetName().Name + ".exe"; if(File.Exists(target)) File.Delete(target); File.Copy(source, target); }
為了便於測試,我將這3個參數都寫死了,端口默認使用45758,物理路徑使用當前程序所在目錄,虛擬路徑使用根目錄,這兩個路徑信息保存在 host 對象中。由於 Application.CreateApplicationHost 方法期望在 GAC 或指定的物理路徑中的 bin 目錄中找到宿主類型所在的程序集,所以在創建應用程序域之前先將當前程序拷貝到了物理路徑的 bin 目錄中,創建完應用程序域后初始化 WebServer 對象,調用該對象的 Start 方法以啟動服務器。在 WebServer 中保留了 host 的引用,當處理 ASP.NET 請求時會使用到,我們先看一下啟動服務器的方法。
public void Start() { _serverSocket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); _serverSocket.ExclusiveAddressUse = true; _serverSocket.Bind(new IPEndPoint(IPAddress.Any, Port)); _serverSocket.Listen(1000); IsRuning = true; Console.WriteLine("Serving HTTP on 0.0.0.0 port " + Port + " ..."); new Thread(OnStart).Start(); } private void OnStart(object state) { while (IsRuning) { try { Socket socket = _serverSocket.Accept(); ThreadPool.QueueUserWorkItem(AcceptSocket, socket); } catch (Exception ex) { Console.WriteLine(ex); Thread.Sleep(100); } } } private void AcceptSocket(object state) { if (IsRuning) { Socket socket = state as Socket; HttpProcessor processor = new HttpProcessor(_host, socket); processor.ProcessRequest(); } }
在 Start 方法中,創建了一個全局的 socket 對象,使其監聽指定端口,並新開了一個線程用於處理客戶端請求,當接收到客戶端請求后,將其交給 HttpProcessor 對象處理。
public void ProcessRequest() { try { RequestInfo requestInfo = ParseRequest(); if (requestInfo != null) { string staticContentType = GetStaticContentType(requestInfo); if (!string.IsNullOrEmpty(staticContentType)) { WriteFileResponse(requestInfo.FilePath, staticContentType); } else if (requestInfo.FilePath.EndsWith("/")) { WriteDirResponse(requestInfo.FilePath); } else { _host.ProcessRequest(this, requestInfo); } } else { SendErrorResponse(400); } } finally { Close();//確保連接關閉 } }
處理的步驟如下:
- 解析請求數據,從建立的 socket 連接處獲取請求數據,將其解析為RequestInfo對象。
- 判斷請求是否有效,無效則響應 400 錯誤,有效則進行下一步處理。
- 判斷請求的是否為靜態內容,是則輸出文件響應。
- 判斷請求是否為目錄,是則輸出目錄下的子文件夾和文件的鏈接,與 IIS 目錄服務類似。
- 不為靜態內容和目錄時,則交給 host 對象處理(使用ASP.NET HTTP 運行時進行處理)。
- 處理完后確保連接關閉。
其中輸出響應是構造狀態行、響應報頭和響應正文,接着通過 socket 發送給客戶端的過程。相信看到這里,大家已經對整個交互過程有了一個了解,剩下的最后一個問題就是如何處理動態內容。
為了與 ASP.NET 的應用程序域交互,我們需要將請求信息提交給宿主對象 host 進行處理,下面是我們實現的宿主類。
public class SimpleHost : MarshalByRefObject { public string PhysicalDir { get; private set; } public string VituralDir { get; private set; } public void Config(string vitrualDir, string physicalDir) { VituralDir = vitrualDir; PhysicalDir = physicalDir; } public void ProcessRequest(HttpProcessor processor, RequestInfo requestInfo) { WorkerRequest workerRequest = new WorkerRequest(this, processor, requestInfo); HttpRuntime.ProcessRequest(workerRequest); } }
在 ProcessRequest 方法中,創建了 HttpWorkerRequest 的子類 WorkerRequest 對象,並提交給 HttpRuntime 進行處理。WorkerRequest 類中實現了 HttpWorkerRequest 中的抽象方法,其中包括 GetRawUrl 、GetHttpVerbName 等等這一類獲取請求相關信息的方法,HTTP 管道調用這些方法以獲取請求數據,同時它還包含類似 FlushResponse 這類輸出響應的方法,HTTP 管道最終會調用這類方法向客戶端發送數據,下面是 FlushResponse 方法的實現,在該方法中我們使用 HttpProcessor 對象向 socket 客戶端發送響應數據。
public override void FlushResponse(bool finalFlush) { if (!_isHeaderSent) { _processor.SendHeaders(_statusCode, _responseHeaders, -1, finalFlush); _isHeaderSent = true; } for (int i = 0; i < _responseBodyBytes.Count; i++) { byte[] data = _responseBodyBytes[i]; _processor.SendResponse(data); } _responseBodyBytes = new List<byte[]>(); if (finalFlush) _processor.Close(); }
到這一步,我們已經可以運行 ASP.NET 程序了,但是只實現抽象方法還不能提供足夠的信息給 HTTP 管道,例如 HTTP 管道無法得知 POST 數據和 Cookie 數據,要提供這些信息我們還需要重寫一些虛擬方法,如 GetKnownRequestHeader 、GetPreloadedEntityBody 等等,實現一些必要的方法之后,ASP.NET 程序就能夠良好地運行了。
總結
編寫支持 ASP.NET 的 Web 服務器,並不是一件難事,這得益於 ASP.NET 優雅的設計,只要向運行時提供必要的信息,HTTP 管道就能夠正確地進行處理。
文中只貼了一小部分代碼,你可以通過 https://github.com/lcomplete/AspNetServer 該地址查看所有代碼。