65.9K
CodeProject 正在变化。 阅读更多。
Home

嵌入式 .NET HTTP 服务器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.72/5 (31投票s)

2008 年 4 月 8 日

CPOL

10分钟阅读

viewsIcon

202928

downloadIcon

5311

一个可嵌入到任何 .NET 应用程序的简单 HTTP 服务器。

注意:此下载需要您拥有我的套接字库的源代码或二进制文件。

介绍 

如今,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 执行动态处理、支持回发或将特定的 <%pseudotags> 替换为动态内容元素(例如,导航栏、计数器或显示当前用户私人信息(如私人消息)的面板)。在这种情况下,您需要继承 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);
 }
}

这个简单的示例将用您的代码中指定的固定导航栏替换 <%navbar>

会话

上一个示例引出了另一点:为了执行有用的替换,在大多数情况下,您将需要一个会话。因为 HTTP 本质上是无状态的,所以您无法像典型的客户端-服务器系统那样将信息与连接一起保存,在典型的客户端-服务器系统中,连接会长时间保持活动状态(例如游戏服务器)。然而,在动态服务器中,通常的做法是使用会话对象来规避这个问题,会话对象由一个可以在浏览器和服务器之间来回传递的令牌标识。

有三种常见的方式可以在多个请求之间保持会话活动

  • 通过在URL中传递会话令牌。如果您见过地址栏中包含“?sessid=5b3426AF42”或类似内容的网站,那就是每次您跳转到新页面时来回传递的会话标识符。
  • 通过设置cookie(浏览器存储的一段数据),浏览器将在未来的请求中发送该 cookie。
  • 在某些涉及表单提交的情况下,会话 ID 可以通过隐藏字段发送,该字段将在其他字段提交时一起提交。

目前最常见的情况是,我的服务器使用 cookie 来管理会话。在应用程序代码方面,您需要在处理程序的 Process 方法中请求一个会话

public override bool Process(HttpServer server, HttpRequest request, HttpResponse response){
  server.RequestSession(request);
  request.Session["lastpage"] = request.Page;
  return base.Process(server, request, response);
}

您可以在 Session 对象中放置任何对象,并且它将一直可用,直到该会话不再有效。会话的一个典型用途是管理身份验证和登录,允许某些页面仅对已登录用户可见。我将在下一节中继续讨论这一点。

回发

除了提供信息外,HTTP 服务器通常还会接收信息,无论是作为 POST 请求(通常来自表单)还是通过附加到 URL 的查询字符串。此信息通过 HttpRequestQuery 字段提供给您的应用程序,您通常会希望启用对有限数量的 URL 的回发。与会话管理一样,您需要重写 Process 并在其中插入代码来管理回发。以下是通过回发处理用户登录的示例以及用于生成请求的 HTML 文件(login.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 文件包含三个伪标签(navbarloginerrorredirect),我们已在处理程序的 GetValue 方法中定义了它们。显然,您将包含某种形式的身份验证,例如来自文件加载的用户列表,或已加载到应用程序中的用户,但此示例显示了回发的基本机制。表单中的字段最终会存储在 request.Query 哈希表中。

另请注意,该示例使用 response.MakeRedirect() 在登录失败时将用户重定向回登录页面,并在登录成功时将用户重定向到目标页面。(您需要导航到 login.html?redirect=otherpage.html 才能使其正常工作。)MakeRedirect 发送 HTTP 303,这会导致浏览器请求您传递给 MakeRedirect 的页面。这允许您将回发处理与文件读取完全分离,这可能被认为是一件好事;但是,这纯粹是风格上的选择,如果您愿意,可以以普通方式响应 POST 设置内容。

更多关于身份验证和会员专区

在上面的回发示例中,登录函数将一个数组放入 Session 中,其中包含当前登录用户的信息。我推荐这种技术,将数组、Hashtable 或自定义 UserInfo 类放入会话中。然后 Session["user"] 包含您需要了解的有关当前登录用户的所有信息,无论您在何处需要它。例如,保护文件夹需要登录的简单技术(再次,将此代码放在您的 Process 方法中)

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;
    }
}

现在,任何尝试访问 /members 下的 URL 的用户如果尚未登录,都将被重定向到登录页面。(您必须在访问 request.Session 之前调用 RequestSession;对于许多应用程序,您会希望在 Process 的第一行调用 RequestSession,以便它始终可用。)当然,如果您使用本文前面部分的登录代码和 HTML 文件,当您成功登录时,您将被重定向回您最初尝试访问的页面。

多个处理程序

在大多数情况下,一个处理程序就足够了。但是,如果您愿意,可以将多个处理程序(IHttpHandler 的实例)添加到 HttpServerHandlers 列表中;例如,您可以有一个单独的处理程序来处理回发或受保护的文件夹,而不是在单个处理程序的 Process 方法流中添加分支。最后添加的处理程序将优先,并且对于每个从 Process 返回 false 的处理程序(倒序),将要求前一个处理程序处理它。

工作原理

如果您只对在应用程序中使用 HTTP 服务器感兴趣,您可以回到顶部并点击下载链接。但是,CodeProject 毕竟是 CodeProject,大多数人都会对一些内部实现感兴趣。此实现使用我自己的套接字库,但类似的代码将支持直接从 .NET 套接字或实际上是另一种语言的套接字运行的 HTTP 服务器。

HTTP 头

搜索互联网会很快找到 HTTP 标准,包括所有有效头字段的定义,以及您可能不想看到的更多细节。(这并不声称是一个完整的 HTTP 1.1 实现;只是足够工作。)然而,HTTP 头通常采用以下形式

GET /path/page.html?query=value HTTP/1.1
Host: www.test.com
Header-Field: value

...并以空行("\r\n\r\n")终止。我的套接字库允许以文本分隔符终止的消息,因此在连接处理程序中,我们可以设置一个事件处理程序来解析头

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;
}

我们需要一个 Read,它读取以分隔符终止的文本消息作为头部,但我们还需要一个 ReadBytes 处理程序来接收内容,该内容不受固定分隔符终止,并且可能包含任何字符。Read 处理程序执行解析和验证头部相对容易的任务。首先,它检查它是否应该处理当前消息

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 会被扫描以查找问号,如果找到,则将其拆分为页面和查询字符串。假设第一行有效,则其余行被假定为标头字段,并在冒号处拆分并放入 Header 哈希表中。服务器会查看三个特殊的标头字段:Host(放在 request.Host 中且必须存在)、Cookie(已解析并放在 Cookie 哈希表中)和 Content-Length(指定后面内容的长度)。

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"]);

最后,连接状态被更改,以指示它已准备好接收内容以及要跳过多少字节的头部,为读取内容(如果有)做准备。即使没有内容,由于我的套接字库的结构,ClientReadBytes 处理程序也将被调用,并将有效地处理零字节消息。

Content

消息的内容没有固定的结束分隔符,因此我们必须连接到套接字的二进制流,该流通过 ClientReadBytes 事件公开,该事件在收到数据时被调用。这包括头部,即使我们上面已经处理过它,所以我们必须跳过作为头部一部分的任何数据。移除头部后,我们读取的内容只是附加到请求的内容中,如果消息完成,则解析查询字符串(来自 URL 和任何 POST 内容)并处理请求。

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;
}

DoProcess 在调用 Process 之前和之后执行一定量的管理工作:首先,如果存在会话,它会为此请求加载会话;在处理请求之后,它会为在此连接上进行的下一个请求创建一个新的 HttpRequest 对象。Process 本身通过处理程序工作,当一个处理程序响应请求时,它会调用 SendResponse(见下文),如果没有任何错误,则保持连接活动。

响应

最后一步是,一旦应用程序确定了内容,就发送响应。这包括向消息添加有效的 HTTP 标头,然后发送内容,内容可以是二进制或文本(以 UTF-8 编码发送)。

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。还有另外两个与会话相关的方法: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);
}

历史

  • 2012 年 4 月:更新了源代码,在请求中使用泛型,并在服务器中添加了一个 URLDecode 方法,该方法在查询内容上调用。
© . All rights reserved.