翻译至: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);
}