翻譯至:http://www.codeproject.com/Articles/25050/Embedded-NET-HTTP-Server
介紹
現在HTTP已經無處不在。如果你想了解某些東西,沒準你會通過你的瀏覽器和HTTP在互聯網上尋找答案。如果一個無人值守的服務器應用程序可以監視,管理HTTP,那將成爲一個好主意。
.NET框架的System.Web程序集對HTTP的客戶端提供了很好的支持,並支持多種從一個地方轉移數據到另一個地方(遠程對象,web服務)的巧妙方式。其中大部分是通過HTTP在後臺進行的。但框架沒有提供一個簡單易用的HTTP服務器。微軟的IIS是爲企業提供的,而不是單個應用提供web服務。
爲了提供一個易於註冊,易於用戶管理,併爲我線上遊戲框架提供一個檢測系統,我在這邊的簡單的HTTP服務器,可以嵌入任何.NET程序,它可以用來查看應用程序的狀態或從瀏覽器內提交材料給他。
該服務器支持會話管理(通過cookie),基於主機請求開關文件夾(運行在多個域的同一個IP),維持連接,並通過它提供的請求處理程序,用磁盤上的僞標籤代替動態內容。
使用
要啓動HTTP服務器很簡單
HttpServer http = new HttpServer(new Server(80));
http.Handlers.Add(new SubstitutingFileReader(this));
但是挑戰是你將要執行鍼對某些URL的動態過程,支持回傳,或替換某些動態內容的特定標籤元素(例如,一個導航欄,或者顯示當前用戶私人信息的面板),在這種情況下,你需要繼承SubstitutingFileReader類來指定如何更換某些標籤:
public class MyHandler : SubstitutingFileReader {
public override string GetValue(HttpRequest req, string tag){
if(tag == "navbar") return "<!-- Navbar Begin -->" +
"<div class=navbar>" +
"<div class=logopanel>Test Web Application</div>" +
"<div class=navlinks>" +
"<a href=index.html>Home</a>" +
"<a href=register.html>Register</a>" +
"<a href=tos.html>Terms of Service</a>" +
"</div> </div>";
else return base.GetValue(req, tag);
}
}
會話
前面的例子引出了另一個問題:在大多數情況下,你都需要一個會話,因爲HTTP本身是無狀態的,你不能像典型的客戶-服務器系統一樣將信息保存在連接中。在動態服務器中通常的做法是使用會話對象,通過在瀏覽器和服務器傳遞這個對象來達到識別的作用。
這裏有三種常見的方法在多請求中保持會話存活狀態:
1、通過URL傳遞會話,如果你在瀏覽器地址欄中看見包含'?sessid=5b3426AF42' 或者其他類似,那就是每次移動到一個新頁面時被來回傳遞的會話標識符
2、通過設置一個cookie(一塊由瀏覽器存儲的數據),瀏覽器來發送未來的請求
3、在某些涉及表單提交的情況下,會話可以通過隱藏字段提交其他的字段中
由於是最爲常見的,我的服務器使用cookie來管理會話,在應用程序代碼方面,你需要在你的處理過程方法中請求會話:
public override bool Process(HttpServer server, HttpRequest request, HttpResponse response){
server.RequestSession(request);
request.Session["lastpage"] = request.Page;
return base.Process(server, request, response);
}
你可以把任何對象塞入會話對象中,這些對象在會在會話對象失效之前一直保持有效。一個典型的應用會話是管理認證和登錄,允許一些登錄頁面只能被登錄用戶看見,下一節將涉及。
回傳
一臺HTTP服務器通常接受信息和建立信息,可以使用POST請求,或者通過附加到URL的。查詢串。此信息可以通過HttpRequest的查詢字段進入你的應用程序,而你通常需要回發一定數量的URL。在會話管理,你需要重寫程序並插入代碼管理回傳。下面是一個處理一個用戶登錄的請求並生成一個HTML的例子:
public class PostbackHandler : SubstitutingFileHandler {
public override bool Process(HttpServer server,
HttpRequest request, HttpResponse response){
if((request.Page.Length > 8) &&
(request.Page.Substring(request.Page.Length - 8) == "postback")){
// Postback. Action depends on the page parameter
server.RequestSession(request);
string target = request.Page.Substring(0, request.Page.Length - 8) +
request.Query["page"] as string;
if(target == "/login") Login(request, response);
else {
response.Content = "Unknown postback target "+target;
response.ReturnCode = 404;
}
return true;
}
// Session management, special processing of GET requests etc
base.Process(server, request, response);
}
void Login(HttpRequest req, HttpResponse resp){
// Authenticate
if( (((string)req.Query["f1"]) != "test") ||
(((string)req.Query["f2"]) != "password") ){
resp.MakeRedirect("/login.html?error=1&redirect="+req.Query["redirect"]);
return;
}
// Add to session and redirect
req.Session["user"] = new string[]{"test",
"password", "A Test User"};
resp.MakeRedirect((string)req.Query["redirect"]);
}
public override string GetValue(HttpRequest req, string tag){
if(tag == "navbar") return "<i>insert navbar</i>";
else if(tag == "loginerror")
return ((string)req.Query["error"] == "1") ?
"<p class=error>The user name or password " +
"you provided was incorrect.</p>" : "";
else if(tag == "redirect") return "" + req.Query["redirect"];
else return base.GetValue(req, tag);
}
}
這是用來回發的HTML文件<!-- login.html -->
<html>
<head>
<title>Test App: Log In</title>
<link rel=stylesheet href=my.css>
</head>
<body>
<%navbar>
<!-- Navbar End -->
<div id=content>
<h1>Log In</h1>
<p>The page you were trying to view requires you to be logged in.
Please enter your details below to be redirected.</p>
<%loginerror>
<div class=loginpanel>
<form action="postback?page=login&redirect=<%redirect>" method=POST>
<table class=login>
<tr><td align=right>Username:</td><td><input name="f1" value=""></td></tr>
<tr><td align=right>Password:</td><td><input type="password" name="f2" value=""></td></tr>
<tr><td colspan=2 align=center><input type=submit value=" Log in! "></td></tr>
</table>
</form>
</div>
</div></body></html>
請注意,這個HTML文件包含三個僞標籤(navbar,loginerror,redirect),這是爲了處理GetValue方法而定義。
更多的關於認證和會員區
在上面回傳的例子中,登錄功能被放在一個數組中塞入會話中,包含當前登錄用戶的信息。我推薦使用這種技術,無論是放在數組,哈希表,或者會話自定義的UserInfo類中,會話中包含了你需要了解的當前登錄用戶一切。
if((request.Page.Length > 9) && (request.Page.Substring(0, 9) == "/members/")){
server.RequestSession(request);
if(request.Session["user"] == null){
response.MakeRedirect("/login.html?redirect="+request.Page);
return true;
}
}
現在,任何試圖訪問/menbers下URL的未登錄用戶都會被重定向到一個登錄頁面。當然如果在本文之前使用登錄代碼和HTML,那麼當你登錄成功後,你將被重定向回你最初嘗試訪問的頁面。
多個處理程序
在多數情況下,一個處理程序就夠了,但是如果你願意,也可以有多個處理程序(IHttpHandler接口的實例)添加到HttpServer的處理程序列表;例如,你可以有一個單獨的處理程序應對回傳和保護文件夾,而不是增加分支機構的流程方法。
他是如何工作的?
如果你有興趣在你的應用程序中使用HTTP服務器,你可以返回頂部,點擊鏈接下載源碼,但是大多數人會有興趣它的內部工作原理,這個實現使用了我自己的socket庫,但是與直接運行在.NET的socket上類似,或者其他語言的socket
HTTP頭
GET /path/page.html?query=value HTTP/1.1
Host: www.test.com
Header-Field: value
通過空白行"\r\n\r\n"終止,我的socket庫允許使用文本分隔符來終止消息,因此在處理連接程序的時候,可以設置一個事件處理程序,來解析頭
bool ClientConnect(Server s, ClientInfo ci){
ci.Delimiter = "\r\n\r\n";
ci.Data = new ClientData(ci);
ci.OnRead += new ConnectionRead(ClientRead);
ci.OnReadBytes += new ConnectionReadBytes(ClientReadBytes);
return true;
}
我們需要一個讀取表頭,讀取文本信息直到遇到定界符。但我們還需要一個ReadBytes處理程序以接收內容,其不被固定的分隔符終止,並且可以包含任何字符。
<span style="font-size:18px;"></span><pre name="code" class="csharp" style="font-size:18px;">ClientData data = (ClientData)ci.Data;
if(data.state != ClientState.Header) return;
// already done; must be some text in content, which will be handled elsewhere
因爲它能夠接受POST內容中的空行,取代了兩個字符的"\r\n"結束符。第一行包含了大量的重要信息,必須首先解析和驗證
// First line: METHOD /path/url HTTP/version
string[] firstline = lines[0].Split(' ');
if(firstline.Length != 3){
SendResponse(ci, data.req, new HttpResponse(400,
"Incorrect first header line "+lines[0]), true); return;
}
if(firstline[2].Substring(0, 4) != "HTTP"){
SendResponse(ci, data.req, new HttpResponse(400,
"Unknown protocol "+firstline[2]), true); return;
}
data.req.Method = firstline[0];
data.req.Url = firstline[1];
data.req.HttpVersion = firstline[2].Substring(5);
該URL掃描問號,如果找到一個,將它分爲頁和查詢字符串。如果第一行是有效的,其餘的行被假定爲頭字段,分割然後放置到頭哈希表中。有跡象表面,服務器着眼於三個特殊的頭字段,主機:被放置在request.Host中並且必須存在;Cookie:被放置在Cookie哈希表中;內容長度:指定後面有多少內容。
data.req.Host = (string)data.req.Header["Host"];
if(null == data.req.Host){
SendResponse(ci, data.req, new HttpResponse(400, "No Host specified"), true);
return;
}
if(null != data.req.Header["Cookie"]){
string[] cookies = ((string)data.req.Header["Cookie"]).Split(';');
foreach(string cookie in cookies){
p = cookie.IndexOf('=');
if(p > 0){
data.req.Cookies[cookie.Substring(0, p).Trim()] = cookie.Substring(p+1);
} else {
data.req.Cookies[cookie.Trim()] = "";
}
}
}
if(null == data.req.Header["Content-Length"]) data.req.ContentLength = 0;
else data.req.ContentLength = Int32.Parse((string)data.req.Header["Content-Length"]);
最後,連接的狀態被改變來指示它準備接收數據以及跳過報頭的多少字節。在準備讀取內容(如果有的話)。即時沒有,因爲我的socket庫該結構沒有內容,ClientReadBytes處理程序被調用來有效的處理零字節的消息。
內容
該消息沒有固定的界定符,所以我們必須連接socket的二進制流,通過ClientReadBytes事件處理,ClientReadBytes在數據被接收的時候調用。即使我們在前面處理過,這邊也包含頭的部分,所以我們必須跳過頭數據,在移除頭數據之後,我們就是簡單的將讀到的內容附近爲請求內容,如果該消息是完整的,那麼查詢字符串被解析並處理請求。
data.req.Content += Encoding.Default.GetString(bytes, ofs, len-ofs);
data.req.BytesRead += len - ofs;
data.headerskip += len - ofs;
if(data.req.BytesRead >= data.req.ContentLength){
if(data.req.Method == "POST"){
if(data.req.QueryString == "")data.req.QueryString = data.req.Content;
else data.req.QueryString += "&" + data.req.Content;
}
ParseQuery(data.req);
DoProcess(ci);
}
data.headerskip用於此連接的下一個請求,以確保該消息的內容沒有被誤解爲下一個報頭的一部分,作爲一個連接保存活動狀態
處理
一旦請求被解析,它被傳遞給一個響應處理程序併產生結果,除去無效的情況(頭部不能被正確解析)。處理有兩個步驟:
首先,查詢字符串被解析,如果存在的話
其次,請求被依次傳遞給每個處理程序,從最新添加的開始,逐一的處理它:
void DoProcess(ClientInfo ci){
ClientData data = (ClientData)ci.Data;
string sessid = (string)data.req.Cookies["_sessid"];
if(sessid != null) data.req.Session = (Session)sessions[sessid];
bool closed = Process(ci, data.req);
data.state = closed ? ClientState.Closed : ClientState.Header;
data.read = 0;
HttpRequest oldreq = data.req;
// Once processed, the connection will be used for a new request
data.req = new HttpRequest();
data.req.Session = oldreq.Session; // ... but session is persisted
data.req.From = ((IPEndPoint)ci.Socket.RemoteEndPoint).Address;
}
protected virtual bool Process(ClientInfo ci, HttpRequest req){
HttpResponse resp = new HttpResponse();
resp.Url = req.Url;
for(int i = handlers.Count - 1; i >= 0; i--){
IHttpHandler handler = (IHttpHandler)handlers[i];
if(handler.Process(this, req, resp)){
SendResponse(ci, req, resp, resp.ReturnCode != 200);
return resp.ReturnCode != 200;
}
}
return true;
}
在Process之前,DoProcess執行一定的管理工作:
首先,它加載會話請求;在請求處理之後,它創造出一個新的HttpRequest對象來連接下一次請求。
迴應
最後一個階段發送一個響應,一旦應用程序確定要發送什麼內容。包括添加一個有限的HTTP頭部信息,然後添加發送的內容信息,可以是二進制或者文本。
void SendResponse(ClientInfo ci, HttpRequest req, HttpResponse resp, bool close){
ci.Send("HTTP/1.1 " + resp.ReturnCode + Responses[resp.ReturnCode] +
"\r\nDate: "+DateTime.Now.ToString("R")+
"\r\nServer: RedCoronaEmbedded/1.0"+
"\r\nConnection: "+(close ? "close" : "Keep-Alive"));
if(resp.RawContent == null )
ci.Send("\r\nContent-Encoding: utf-8"+
"\r\nContent-Length: "+resp.Content.Length);
else
ci.Send("\r\nContent-Length: "+resp.RawContent.Length);
if(req.Session != null) ci.Send("\r\nSet-Cookie: _sessid="+req.Session.ID+"; path=/");
foreach(DictionaryEntry de in resp.Header) ci.Send("\r\n" + de.Key + ": " + de.Value);
ci.Send("\r\n\r\n"); // End of header
if(resp.RawContent != null) ci.Send(resp.RawContent);
else ci.Send(resp.Content);
//Console.WriteLine("** SENDING\n"+Encoding.Default.GetString(resp.Content));
if(close) ci.Close();
}
此服務器的一個有用的功能是會話管理。大多數會話管理的代碼在DoProcess中,cookie _sessid被檢查,如果它存在的話會話被加載,並在SendResponse中設置cookie。這裏有兩種關於session的方法:RequestSession獲取一個有效會話的方法;CleanUpSessions每當任何請求被處理時調用,刪除已經到期的會話
public Session RequestSession(HttpRequest req){
if(req.Session != null){
if(sessions[req.Session.ID] == req.Session) return req.Session;
}
req.Session = new Session(req.From);
sessions[req.Session.ID] = req.Session;
return req.Session;
}
void CleanUpSessions(){
ICollection keys = sessions.Keys;
ArrayList toRemove = new ArrayList();
foreach(string k in keys){
Session s = (Session)sessions[k];
int time = (int)((DateTime.Now - s.LastTouched).TotalSeconds);
if(time > sessionTimeout){
toRemove.Add(k);
Console.WriteLine("Removed session "+k);
}
}
foreach(object k in toRemove) sessions.Remove(k);
}