嵌入式 .NET HTTP 服务器
一个可嵌入到任何 .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 的查询字符串。此信息通过 HttpRequest
的 Query
字段提供给您的应用程序,您通常会希望启用对有限数量的 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 文件包含三个伪标签(navbar
、loginerror
和 redirect
),我们已在处理程序的 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
的实例)添加到 HttpServer
的 Handlers
列表中;例如,您可以有一个单独的处理程序来处理回发或受保护的文件夹,而不是在单个处理程序的 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 方法,该方法在查询内容上调用。