Embedded .NET HTTP Server

翻譯至: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頭

搜索互聯網將迅速轉向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);
}


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章