[译]从头开始编写Web服务器
By robot-v1.0
本文链接 https://www.kyfws.com/applications/writing-a-web-server-from-scratch-zh/
版权声明 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
- 36 分钟阅读 - 17873 个词 阅读量 0[译]从头开始编写Web服务器
原文地址:https://www.codeproject.com/Articles/859108/Writing-a-Web-Server-from-Scratch
原文作者:Marc Clifton
译文由本站 robot-v1.0 翻译
前言
By popular request, here is how I implemented a lightweight web server in <650 lines of code.
根据普遍的要求,这是我用不到650行代码实现轻量级Web服务器的方法.
In this article I document, step by step, the construction of the web server, so you can get a feel for how it is built from a blank slate, the design and implementation decisions (good or bad) and the overall process. The first step of process is to get an HttpListener class working. Then we add some logging. Then create a basic structure for our web application. Next I implement a simple mechanism for routing verbs and paths to handlers, and deal with authenticaion and session expiration, and do some AJAX Queries.
在本文中,我将逐步介绍Web服务器的构建,以便您了解如何从空白状态,设计和实现决策(好的或坏的)以及整个过程中构建Web服务器.该过程的第一步是使HttpListener类正常工作.然后,我们添加一些日志记录.然后为我们的Web应用程序创建一个基本结构.接下来,我实现一种简单的机制,将动词和路径路由到处理程序,处理认证和会话期满,并执行一些AJAX查询.
介绍(Introduction)
主要是出于乐趣,我决定研究编写自己的Web服务器所涉及的内容.我很快就被关于浏览器的想法和令人着迷的事物所吸引,对响应中的浏览器期望的遵守以及编写精简而卑鄙的Web服务器的乐趣使我着迷.因此,本文(几个人在几周前的要求下)描述了该过程.至此,实现支持:(Mainly for the fun of it, I decided to look into what’s involved in writing my own web server. I quickly became enthralled with the idea and the fascinating things I was discovering about browsers, compliance with browser expectations in the responses, and the sheer pleasure of writing a lean and mean web server. So, this article (as requested a few weeks ago by several people) describes the process. At this point, the implementation supports:)
-
路由(Routing)
-
会议经理(Session Manager)
-
授权书(Authorization)
-
过期的会话(Expired Sessions) 因为Web服务器非常轻巧,所以我发现完全没有必要做任何复杂的事情,例如为实际应用程序实现插件.有三个核心文件:(Because the web server is so lightweight, I found it completely unnecessary to do anything complicated like implementing plug-ins for the actual application. There’s three core files:)
-
Listener.cs-使用HttpListener侦听并响应连接(Listener.cs - Listens and responds to connections using HttpListener)
-
Router.cs-管理路由(Router.cs - Manages routing)
-
SessionManager.cs-管理连接会话状态(SessionManager.cs - Manages connection sessions state)
肥皂盒片刻(或多个)(A Soapbox Moment (or several))
我认为声明此Web服务器的内容很重要(I think it’s important to state what this web server)*不是.(is not.)*您找不到的内容(您可能不同意我的严厉批评,但这是我在网络开发的最近几年中学到的):(What you won’t find (and you may disagree with my severe criticisms, but this what I’ve learned over the last few years of web development):)
-
任何ORM. ORM绝对不应成为Web服务器的一部分(Anything ORM. ORM should absolutely never be part of a web server)
-
任何MVC. Web应用程序的整个MVC概念通常是不必要的体系结构,旨在使Ruby on Rails开发人员熟悉Microsoft技术.(Anything MVC. The whole MVC concept for web apps is an often unnecessary architecture designed to make Ruby on Rails developers comfortable with Microsoft technologies.)
-
没有网页的运行时编译或用于解析"增强型" HTML文件的自定义语法.给定基于jQuery的现代控件(如jqwidgets)并使用JSON和AJAX,那么通过嵌入C#(或其他语言)元素将HTML文件转换为必不可少的需求是完全必要的.它:(No runtime compilation of web pages or custom syntaxes for parsing “enhanced” HTML files. Given modern jQuery-based controls like jqwidgets and employing JSON and AJAX, the need for turning an HTML file into something imperative by embedding C# (or other language) elements, well, that just isn’t necessary. It:)
- 放慢页面的投放速度(slows down serving the page)
- 将确定视图状态的逻辑涂抹到通常不应该属于的视图中(smears the logic that determines view state into the view where usually shouldn’t belong)
- 并且,如果有必要编写一个复杂的渲染,最好在一个像样的流畅的体系结构中完成,而不是进一步混淆了HTML和CSS已经神秘的语法.(and if it’s necessary to write a complex rendering, this is better done in a decent fluid-like architecture rather than syntaxes that further obfuscate the already arcane syntax of HTML and CSS.)
-
属性-自从我开始使用C#以来,我就这么说过:属性非常适合为序列化程序提供有关如何序列化字段或属性的提示,除此之外,它们大多会促进不良的设计-设计效果更好时,效果会更好面向对象的设计.例如,我的路线由(Attributes - I’ve said it since I started working with C#: attributes are great for giving a serializer hints for how to serialize fields or properties, other than that, they mostly promote bad design – design which would be much better served with good object oriented design. For example, my routes are implemented by a)
Route
基类和(base class and)AuthenticatedRoute
儿童班.如果您想要基于角色的身份验证,它将成为派生类,而不是通常装饰空控制器功能的属性,并且需要箍以实现与框架设计者认为正确的实现不同的东西.扔掉所有这些,因为它永远无法按照您想要的方式工作,并且不断使用反射来检查"哦,我是否被授权",“哦,我是否对此具有正确的作用?",从而增加了更多的性能膨胀.再次,可怕的设计的另一个例子.(child class. If you want role-based authentication, it becomes a derived class rather than an attribute that decorates often empty controller functions and that requires hoops to go through to implement something different than what the framework designers decided would be the right implementation. Throw that all away, because it never works the way you want it, and it adds more performance bloat constantly using reflection to check “oh, am I authorized”, “oh, do I have the right role for this?” Again, another example of horrid design.) -
没有IIS.不需要的其他不必要的膨胀和配置复杂性.(No IIS. More unnecessary bloat and configuration complexity that isn’t needed.) 具有讽刺意味的是(这是我站在肥皂盒上的最后一句话)是,实现了自己的Web服务器之后,我开始意识到有多少技术可以像MVC Razor和Ruby on Rails(挑二)(The ironic thing (and this is the last I’ll say of it while standing on my soapbox) is that, having implemented my own web server, I’ve come to realize how much technologies like MVC Razor and Ruby on Rails (to pick two))*妨碍(get in the way)*服务网页和让程序员控制如何呈现非静态内容的业务.使用简单的Web服务器,我发现自己将更多的精力集中在客户端JavaScript,HTML和组件上,在这些方面,我唯一需要注意的服务器端过程是PUT处理程序和偶尔的AJAX请求.由MVC创造的包arc,不可思议的路由语法,装饰不必要的控制器功能的属性-嗯,在我看来,编写Web应用程序的整个过程相当令人沮丧.(of the business of serving web pages and giving the programmer control over how to render non-static content. With a simple web server, I find myself focusing much more on the client-side JavaScript, HTML, and components, where the only attention I have to pay to the server-side process is the PUT handlers and the occasional AJAX request. The baggage created by MVC, arcane routing syntaxes, attributes decorating unnecessary controller functions–well, the whole state of affairs of writing a web application is rather dismal, in my opinion.)
关于源代码存储库(About the Source Code Repository)
源代码托管在GitHub上:(The source code is hosted on GitHub:)
git clone https://github.com/cliftonm/BasicWebServer.git
关于本文的过程(About the Process of this Article)
我认为,不仅仅向您展示最终的Web服务器,而是逐步介绍Web服务器的构造会更加有趣,这样您就可以了解如何从空白,构建和实现的角度来构建它.决定(好坏)和整个过程.希望您(读者)喜欢这种方法.(Rather than just showing you the final web server, I think it’s much more interesting to document, step by step, the construction of the web server, so you can get a feel for how it is built from a blank slate, the design and implementation decisions (good or bad) and the overall process. I hope you, the reader, enjoy this approach.)
第1步-HttpListener(Step 1 - HttpListener)
该过程的第一步是获取(The first step of process is to get an) HttpListener
上班.我选择走这条路线而不是较低级别的套接字路线,因为(class working. I opted to go this route rather than the lower level socket route because) HttpListener
提供了很多有用的服务,例如解码HTML请求.我已经读过它的性能不如使用套接字路由,但是我并不太担心性能会有所降低.(provides a lot of useful services, such as decoding the HTML request. I’ve read that it’s not as performant as going the socket route, but I’m not overly concerned with a little performance reduction.)
Web服务器被实现为库.有一个用于特定Web应用程序可执行文件的控制台应用程序.对于第一步,Web服务器需要:(The web server is implemented as a library. There is a console application for the specific web application executable. For this first step, the web server needs:)
using System.Net;
using System.Net.Sockets;
using System.Threading;
因为Web服务器主要是无状态的(会话对象除外),所以大多数行为都可以实现为静态单例.(Because a web server is primarily stateless (except for session objects) most of behaviors can be implemented as static singletons.)
namespace Clifton.WebServer
{
/// <summary>
/// A lean and mean web server.
/// </summary>
public static class Server
{
private static HttpListener listener;
...
我们将最初假设我们要连接到Intranet上的服务器,因此我们获得了本地主机的IP:(We’re going to make the initial assumption that we’re connecting to the server on an intranet, so we obtain the IP’s of our local host:)
/// <summary>
/// Returns list of IP addresses assigned to localhost network devices, such as hardwired ethernet, wireless, etc.
/// </summary>
private static List<IPAddress> GetLocalHostIPs()
{
IPHostEntry host;
host = Dns.GetHostEntry(Dns.GetHostName());
List<IPAddress> ret = host.AddressList.Where(ip => ip.AddressFamily == AddressFamily.InterNetwork).ToList();
return ret;
}
然后,我们实例化HttpListener并添加localhost前缀:(We then instantiate the HttpListener and add the localhost prefixes:)
private static HttpListener InitializeListener(List<IPAddress> localhostIPs)
{
HttpListener listener = new HttpListener();
listener.Prefixes.Add("http://localhost/");
// Listen to IP address as well.
localhostIPs.ForEach(ip =>
{
Console.WriteLine("Listening on IP " + "http://" + ip.ToString() + "/");
listener.Prefixes.Add("http://" + ip.ToString() + "/");
});
return listener;
}
您可能拥有多个本地IP.例如,我的笔记本电脑同时具有用于以太网和无线"端口"的IP.(You will probably have more than one localhost IP. For example, my laptop has an IP for both the ethernet and wireless “ports.")
借用Sacha的概念(Borrowing a concept from Sacha’s) 一个简单的REST框架(A Simple REST Framework) ,我们将设置一个信号灯,等待指定数量的同时允许的连接:(, we’ll set up a semaphore that waits for a specified number of simultaneously allowed connections:)
public static int maxSimultaneousConnections = 20;
private static Semaphore sem = new Semaphore(maxSimultaneousConnections, maxSimultaneousConnections);
这是在工作线程中实现的,该工作线程可通过Task.Run调用:(This is implemented in a worker thread, which is invoked with Task.Run:)
/// <summary>
/// Begin listening to connections on a separate worker thread.
/// </summary>
private static void Start(HttpListener listener)
{
listener.Start();
Task.Run(() => RunServer(listener));
}
/// <summary>
/// Start awaiting for connections, up to the "maxSimultaneousConnections" value.
/// This code runs in a separate thread.
/// </summary>
private static void RunServer(HttpListener listener)
{
while (true)
{
sem.WaitOne();
StartConnectionListener(listener);
}
}
最后,我们将连接侦听器实现为一个等待的异步过程:(Lastly, we implement the connection listener as an awaitable asynchronous process:)
/// <summary>
/// Await connections.
/// </summary>
private static async void StartConnectionListener(HttpListener listener)
{
// Wait for a connection. Return to caller while we wait.
HttpListenerContext context = await listener.GetContextAsync();
// Release the semaphore so that another listener can be immediately started up.
sem.Release();
// We have a connection, do something...
}
因此,让我们做点事情:(So, let’s do something:)
string response = "Hello Browser!";
byte[] encoded = Encoding.UTF8.GetBytes(response);
context.Response.ContentLength64 = encoded.Length;
context.Response.OutputStream.Write(encoded, 0, encoded.Length);
context.Response.OutputStream.Close();
而且,我们需要一个公众(And, we need a public) Start
方法:(method:)
/// <summary>
/// Starts the web server.
/// </summary>
public static void Start()
{
List<IPAddress> localHostIPs = GetLocalHostIPs();
HttpListener listener = InitializeListener(localHostIPs);
Start(listener);
}
现在,在控制台应用程序中,我们可以启动服务器:(Now in our console app, we can start up the server:)
using System;
using Clifton.WebServer;
namespace ConsoleWebServer
{
class Program
{
static void Main(string[] args)
{
Server.Start();
Console.ReadLine();
}
}
}
走开:(and away we go:)
始终检查浏览器的Web控制台窗口(Always Inspect the Browser’s Web Console Window)
浏览器的Web控制台窗口是您的朋友-它会告诉您所有您做错的事情!例如,在上面的测试案例中,我们发现:(The browser’s web console window is your friend - it will tell you all the things you are doing wrong! For example, in our test case above, we discover:)
在这里我们了解到我们需要注意编码,这是在HTML中完成的:(Here we learn that we need to take care of the encoding, which is done in the HTML:)
string response = "<html><head><meta http-equiv='content-type' content='text/html; charset=utf-8'/>
</head>Hello Browser!</html>";
由于这只是一个示例,因此我们暂时将其保留.稍后,我们将了解更多我们做错的事情!(As this is just an example, we’ll leave it at that for now. Later we’ll learn more things we’re doing wrong!)
第2步-记录(Step 2 - Logging)
首先,让我们添加一些日志记录,因为日志记录对于查看我们的Web服务器正在发出什么样的请求非常有用:(First, let’s add some logging, as logging is really useful to see what kind of requests are being made of our web server:)
Log(context.Request);
/// <summary>
/// Log requests.
/// </summary>
public static void Log(HttpListenerRequest request)
{
Console.WriteLine(request.RemoteEndPoint + " " + request.HttpMethod + " /" + request.Url.AbsoluteUri.RightOf('/', 3));
}
您还可以使用我写过的远程记录器,例如PaperTrailApp(You can also use remote loggers, like PaperTrailApp, which I’ve written about) 这里(here) .(.)
我们在释放信号量后立即添加Log调用:(We add the Log call right after releasing the semaphore:)
Log(context.Request);
添加记录器后的注意事项(What we Notice After Adding the Logger)
添加日志后,我们立即注意到浏览器不仅在默认页面上请求该页面,而且还要求提供favicon.ico!(Once we add the logging, we notice immediately that the browser not only requests the page at the default page, but it’s also asking for favicon.ico!)
好吧,我们需要一些有关此的东西!(Well, we need to something about that!)
第3步-提供内容:默认路由(Step 3 - Serving Content: Default Routing)
显然,我们不想在C#中将网页编码为字符串.因此,让我们为我们的Web应用程序创建一个基本结构.这完全是任意的,但是我选择的结构是所有内容都将从文件夹” Website"派生.在"网站"下,我们找到以下文件夹:(Obviously, we don’t want to code our web pages as strings in C#. So, let’s create a basic structure for our web application. This is completely arbitrary, but what I’ve chosen as a structure is that everything will derive from the folder “Website”. Under “Website”, we find the following folders:)
- 页面:所有页面的根(Pages: root of all pages)
- CSS:包含所有.css和相关文件(CSS: contains all .css and related files)
- 脚本:包含所有.js文件(Scripts: contains all .js files)
- 图片:包含所有图片文件(Images: contains all image files) 要处理一些基本功能,我们需要路由器的开始.我们的第一个剪切操作除了响应URL路径和请求扩展名所确定的” Webiste"文件夹中的文件以及子文件夹外,什么也不会做.我们首先从URL请求中提取一些信息:(To handle some basic functionality, we need the beginnings of a router. Our first cut will do nothing else than respond with files found in the “Webiste” folder and sub-folders as determined by the URL path and the request extension. We first extract some information from the URL request:)
HttpListenerRequest request = context.Request;
string path = request.RawUrl.LeftOf("?"); // Only the path, not any of the parameters
string verb = request.HttpMethod; // get, post, delete, etc.
string parms = request.RawUrl.RightOf("?"); // Params on the URL itself follow the URL and are separated by a ?
Dictionary<string, string> kvParams = GetKeyValues(parms); // Extract into key-value entries.
现在,我们可以将此信息传递给路由器:(We can now pass this information to the router:)
router.Route(verb, path, kvParams);
尽管它可能是静态的,但使路由器成为实际实例仍有一些潜在的好处,因此我们在(Even though it could be static, there are some potential benefits to making the router an actual instance, so we initialize it in the) Server
类:(class:)
private static Router router = new Router();
另一个不明确的细节是实际的网站路径.因为我正在bin \ debug文件夹中运行控制台程序,所以网站路径实际上是" .. \ .. \ Website".这是获取此路径的笨拙方法:(Another nitpicky detail is the actual website path. Because I’m running the console program out of a bin\debug folder, the website path is actually “....\Website”. Here’s a clumsy way of getting this path:)
public static string GetWebsitePath()
{
// Path of our exe.
string websitePath = Assembly.GetExecutingAssembly().Location;
websitePath = websitePath.LeftOfRightmostOf("\\").LeftOfRightmostOf("\\").LeftOfRightmostOf("\\") + "\\Website";
return websitePath;
}
需要进行一些重构.我们将其传递给配置路由器的Web服务器:(A little refactoring is need to We pass this in to the web server, which configures the router:)
public static void Start(string websitePath)
{
router.WebsitePath = websitePath;
...
由于我不特别喜欢switch语句,因此我们将初始化一个已知扩展及其加载器位置的映射.考虑如何使用不同的功能从例如数据库加载内容.(Since I don’t particularly like switch statements, we’ll initialize a map of known extensions and their loader locations. Consider how different functions could be used to load the content from, say, a database.)
public class Router
{
public string WebsitePath { get; set; }
private Dictionary<string, ExtensionInfo> extFolderMap;
public Router()
{
extFolderMap = new Dictionary<string, ExtensionInfo>()
{
{"ico", new ExtensionInfo() {Loader=ImageLoader, ContentType="image/ico"}},
{"png", new ExtensionInfo() {Loader=ImageLoader, ContentType="image/png"}},
{"jpg", new ExtensionInfo() {Loader=ImageLoader, ContentType="image/jpg"}},
{"gif", new ExtensionInfo() {Loader=ImageLoader, ContentType="image/gif"}},
{"bmp", new ExtensionInfo() {Loader=ImageLoader, ContentType="image/bmp"}},
{"html", new ExtensionInfo() {Loader=PageLoader, ContentType="text/html"}},
{"css", new ExtensionInfo() {Loader=FileLoader, ContentType="text/css"}},
{"js", new ExtensionInfo() {Loader=FileLoader, ContentType="text/javascript"}},
{"", new ExtensionInfo() {Loader=PageLoader, ContentType="text/html"}},
};
}
....
请注意,我们还如何处理"无扩展名"情况,我们假设内容将是HTML页面而实现.(Notice how we also handle the “no extension” case, which we implement as assuming that the content will be an HTML page.)
还要注意,我们设置了内容类型.如果不这样做,则会在Web控制台中收到一条警告,提示内容被认为是特定类型.(Also notice that we set the content type. If we don’t do this, we get a warning in the web console that content is assumed to be of a particular type.)
最后,请注意,我们指出了执行实际加载的功能.(Finally, notice that we’re indicating a function for performing the actual loading.)
图像加载器(Image Loader)
/// <summary>
/// Read in an image file and returns a ResponsePacket with the raw data.
/// </summary>
private ResponsePacket ImageLoader(string fullPath, string ext, ExtensionInfo extInfo)
{
FileStream fStream = new FileStream(fullPath, FileMode.Open, FileAccess.Read);
BinaryReader br = new BinaryReader(fStream);
ResponsePacket ret = new ResponsePacket() { Data = br.ReadBytes((int)fStream.Length), ContentType = extInfo.ContentType };
br.Close();
fStream.Close();
return ret;
}
文件加载器(File Loader)
/// <summary>
/// Read in what is basically a text file and return a ResponsePacket with the text UTF8 encoded.
/// </summary>
private ResponsePacket FileLoader(string fullPath, string ext, ExtensionInfo extInfo)
{
string text = File.ReadAllText(fullPath);
ResponsePacket ret = new ResponsePacket() { Data = Encoding.UTF8.GetBytes(text), ContentType = extInfo.ContentType, Encoding = Encoding.UTF8 };
return ret;
}
页面加载器(Page Loader)
页面加载器必须做一些花哨的工作来处理诸如以下的选项:(The page loader has to do some fancy footwork to handle options like:)
- foo.com(foo.com)
- foo.com \ index(foo.com\index)
- foo.com \ index.html(foo.com\index.html) 所有这些组合最终将加载Pages \ index.html.(All of these combinations end up load Pages\index.html.)
/// <summary>
/// Load an HTML file, taking into account missing extensions and a file-less IP/domain,
/// which should default to index.html.
/// </summary>
private ResponsePacket PageLoader(string fullPath, string ext, ExtensionInfo extInfo)
{
ResponsePacket ret = new ResponsePacket();
if (fullPath == WebsitePath) // If nothing follows the domain name or IP, then default to loading index.html.
{
ret = Route(GET, "/index.html", null);
}
else
{
if (String.IsNullOrEmpty(ext))
{
// No extension, so we make it ".html"
fullPath = fullPath + ".html";
}
// Inject the "Pages" folder into the path
fullPath = WebsitePath + "\\Pages" + fullPath.RightOf(WebsitePath);
ret = FileLoader(fullPath, ext, extInfo);
}
return ret;
}
我们有几个辅助课程:(We have a couple helper classes:)
public class ResponsePacket
{
public string Redirect { get; set; }
public byte[] Data { get; set; }
public string ContentType { get; set; }
public Encoding Encoding { get; set; }
}
internal class ExtensionInfo
{
public string ContentType { get; set; }
public Func<string, string, string, ExtensionInfo, ResponsePacket> Loader { get; set; }
}
在将所有内容放在一起之后,我们有了路由器的开头,该路由器现在返回位于文件中的内容.(And after putting it all together, we have the beginnings of a router, which now returns content located in files.)
public ResponsePacket Route(string verb, string path, Dictionary<string, string> kvParams)
{
string ext = path.RightOf('.');
ExtensionInfo extInfo;
ResponsePacket ret = null;
if (extFolderMap.TryGetValue(ext, out extInfo))
{
// Strip off leading '/' and reformat as with windows path separator.
string fullPath = Path.Combine(WebsitePath, path);
ret = extInfo.Loader(fullPath, ext, extInfo);
}
return ret;
}
我们需要进行最后的重构-删除测试响应并将其替换为路由器返回的内容:(We need one final refactoring – removing our test response and replacing it with the content returned by the router:)
private static void Respond(HttpListenerResponse response, ResponsePacket resp)
{
response.ContentType = resp.ContentType;
response.ContentLength64 = resp.Data.Length;
response.OutputStream.Write(resp.Data, 0, resp.Data.Length);
response.ContentEncoding = resp.Encoding;
response.StatusCode = (int)HttpStatusCode.OK;
response.OutputStream.Close();
}
目前这只是系统实现.(This is bare-bones implementation for now.)
我们可以看到这一点.这是一些HTML:(We can see this in action. Here’s some HTML:)
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<script type="text/javascript" src="/Scripts/jquery-1.11.2.min.js"></script>
<link type="text/css" rel="Stylesheet" href="/CSS/demo.css"/>
<title>Button Click Demo</title>
<script type="text/javascript">
$(document).ready(function () {
$("#me").click(function () {
alert("I've been clicked!");
});
});
</script>
</head>
<body>
<div class="center-inner top-margin-50">
<input class="button" type="button" value="Click Me" id="me"/>
</div>
</body>
</html>
您可以看到网站结构:(You can see the website structure:)
而且我们的演示页面也很有效!(And our little demo page works!)
您可以在这里看到几件事:(You can see several things going on here:)
-
是的,确实,favicon.ico正在加载(如果您想知道,那是一棵棕榈树)(Yes indeed, favicon.ico is being loaded (it’s a palm tree, if you were wondering))
-
该页面当然正在加载(The page is of course being loaded)
-
样式正在工作(The styling is working)
-
JQuery脚本正在工作(The JQuery script is working) 一切都很好,但是:(This is all fine and dandy, but:)
-
无法处理未知的扩展名(Unknown extensions are not handled)
-
缺少的内容无法处理(Missing content is not handled)
-
无法处理加载内容的错误(Errors loading content are not handled)
-
动词始终被假定为" get"(The verb is always assumed to be “get”)
-
加载内容后,应用程序没有任何操作内容的选项(尤其是HTML)(The application isn’t given any option to manipulate the content (particularly the HTML) after it’s been loaded)
-
您不能覆盖路由(You can’t override the routing)
-
没有授权内容的概念(There’s not concept of authorized content)
-
没有考虑会话时间(There’s no session duration considered)
-
没有异常处理(There’s no exception handling)
-
重定向不处理(Redirects are not handled) 这些都是我们需要解决的问题,但是,我们现在可以使用CSS和Javascript创建一些页面:因此,即使还有很多事情要做,但到目前为止,我们的工作还是很多!(These are all issues that we need to address, however, we can at this point create some pages with CSS and Javascript:, so, even though there’s a lot of things to still work on, we do at this point have a lot working!)
这里揭示的一件事是服务器如何完全欺骗内容"文件"的实际位置.在上面的代码中,我将所有HTML内容都放在了Pages文件夹下,从而欺骗了根位置.我们可以做其他事情-从数据库加载数据,与另一台服务器通信,从数据动态生成页面…随着我们超越默认内容加载,所有这些功能都将得到探索.(One of the things revealed here is how the actual location of the content “file” can be completely spoofed by the server. In the above code, I put all the HTML content under the folder Pages, thus spoofing the root location. We could do other things – load data from a database, communicate with another server, generate the page dynamically from data…these are all features will explore as we move beyond default content loading.)
第4步-细节中有魔鬼(Step 4 - The Devil is in the Details)
让我们开始处理上述问题.(Let’s start dealing with the issues mentioned above.)
错误页面(Error Pages)
我们将添加几个错误页面,即使我们暂时不使用它们也是如此:(We’ll add several error pages, even though we’re not using them all at the moment:)
- 会话期满(Expired session)
- 未经授权(Not authorized)
- 找不到网页(Page not found)
- 服务器错误(Server error)
- 未知类型(Unknown type) 现在,您可能想知道为什么服务器知道有关过期会话和授权失败的信息.好吧,因为这是有道理的-这些错误是路由所不可或缺的,但是错误状态是由Web应用程序(而不是服务器)确定的.服务器所做的只是向Web应用程序查询状态.稍后再详细介绍.(Now, you may wonder why the server knows things about expired sessions and authorization failures. Well, because it makes sense – these errors are integral to the routing, but the error state is determined by the web application (not the server.) All the server does is query the web application for the state. More on this later.)
我们希望应用程序确定给定错误的这些页面的位置,因此我们将向服务器添加一个枚举:(We’d like the application to be determine where these pages are for the given error, so we’ll add an enum to the server:)
public enum ServerError
{
OK,
ExpiredSession,
NotAuthorized,
FileNotFound,
PageNotFound,
ServerError,
UnknownType,
}
现在,我们可以开始处理错误(没有引发异常).首先是一个未知的扩展名:(We can now begin to handle errors (without throwing exceptions). First off is an unknown extension:)
if (extFolderMap.TryGetValue(ext, out extInfo))
{
...
}
else
{
ret = new ResponsePacket() { Error = Server.ServerError.UnknownType };
}
等等.我们将使用Web应用程序可以提供的回调来处理错误.这是用户应重定向到的页面的形式.(and so forth. We’ll use a callback that the web application can provide for handling errors. This is in the form of the page to which the user should be redirected.)
然后,我们重构代码以从应用程序获取要显示错误的页面:(We then refactor our code to get, from the application, the page to display on error:)
ResponsePacket resp = router.Route(verb, path, kvParams);
if (resp.Error != ServerError.OK)
{
resp = router.Route("get", onError(resp.Error), null);
}
Respond(context.Response, resp);
并在应用程序中实现直接错误处理程序:(and implement a straight forward error handler in the application:)
public static string ErrorHandler(Server.ServerError error)
{
string ret = null;
switch (error)
{
case Server.ServerError.ExpiredSession:
ret= "/ErrorPages/expiredSession.html";
break;
case Server.ServerError.FileNotFound:
ret = "/ErrorPages/fileNotFound.html";
break;
case Server.ServerError.NotAuthorized:
ret = "/ErrorPages/notAuthorized.html";
break;
case Server.ServerError.PageNotFound:
ret = "/ErrorPages/pageNotFound.html";
break;
case Server.ServerError.ServerError:
ret = "/ErrorPages/serverError.html";
break;
case Server.ServerError.UnknownType:
ret = "/ErrorPages/unknownType.html";
break;
}
return ret;
}
当然,我们必须初始化错误处理程序:(Of course, we have to initialize the error handler:)
Server.onError = ErrorHandler;
现在我们可以测试一些事情.当然,您的应用程序可能需要一些更复杂的消息!(We can now test a few things out. Of course, your application may want some more sophisticated messages!)
未知类型错误(Unknown Type Error)
找不到网页(Page Not Found)
文件未找到(File Not Found)
重新导向(Redirects)
您会注意到,以上错误消息中的URL并未更改以反映该页面.这是因为我们没有响应重定向工作.是时候解决这个问题了:(You’ll note that the URL in the above error messages hasn’t changed to reflect the page. This is because we don’t have response redirect working. Time to fix that:)
我们假设错误处理程序将始终将我们重定向到其他页面,因此我们更改了处理响应的方式.而不是得到一个新的(We assume that the error handler will always redirect us to a different page, so we change how we handle the response. Rather than getting a new) ResponsePacket
并将该内容发送回浏览器,我们只需设置(and sending that content back to the browser, we simply set the) Redirect
Web应用程序希望我们转到的页面的属性.顺便说一下,这成为一种通用重定向机制.)(property to the page the web application wants us to go to. This becomes, by the way, a universal redirect mechanism.))
if (resp.Error != ServerError.OK)
{
resp.Redirect = onError(resp.Error);
}
我们在其中做了一些重构(and we do a little refactoring in the) Resond
方法:(method:)
private static void Respond(HttpListenerRequest request, HttpListenerResponse response, ResponsePacket resp)
{
if (String.IsNullOrEmpty(resp.Redirect))
{
response.ContentType = resp.ContentType;
response.ContentLength64 = resp.Data.Length;
response.OutputStream.Write(resp.Data, 0, resp.Data.Length);
response.ContentEncoding = resp.Encoding;
response.StatusCode = (int)HttpStatusCode.OK;
}
else
{
response.StatusCode = (int)HttpStatusCode.Redirect;
response.Redirect("http://" + request.UserHostAddress + resp.Redirect);
}
response.OutputStream.Close();
}
顺便说一句,关闭输出流非常重要.如果不这样做,浏览器可能会挂起,等待数据.(By the way, it’s very important to close the output stream. If you don’t, the browser can be left hanging, waiting for data.)
请注意,由于我们正在处理重定向错误,因此Web服务器可以响应的仅有两个可能的状态代码是"确定"和"重定向".(Notice that since we’re handling errors with redirects, the only two possible status codes our web server can respond with is OK and Redirect.)
现在我们的重定向工作了:(Now our redirecting is working:)
异常处理(Exception Handling)
我们使用相同的重定向机制,通过包装(We use the same redirect mechanism to catch actual exceptions by wrapping the) GetContextAsync
在try-catch块中继续:(continuation in a try-catch block:)
catch(Exception ex)
{
Console.WriteLine(ex.Message);
Console.WriteLine(ex.StackTrace);
resp = new ResponsePacket() { Redirect = onError(ServerError.ServerError) };
}
这是模拟错误的样子:(Here’s what a simulated error looks like:)
步骤5-审查并处理更多问题(Step 5 - Review and Tackle More Issues)
我们在哪?(Where are we?)
- 无法处理未知的扩展名(Unknown extensions are not handled)
- 缺少的内容无法处理(Missing content is not handled)
- 无法处理加载内容的错误(Errors loading content are not handled)
- 动词始终被假定为" get"(The verb is always assumed to be “get”)
- 加载内容后,应用程序没有任何操作内容的选项(尤其是HTML)(The application isn’t given any option to manipulate the content (particularly the HTML) after it’s been loaded)
- 您不能覆盖路由(You can’t override the routing)
- 没有授权内容的概念(There’s not concept of authorized content)
- 没有考虑会话时间(There’s no session duration considered)
- 没有异常处理(There’s no exception handling)
- 重定向不处理(Redirects are not handled) 接下来处理动词,尤其是POST动词.这将使我们能够解决接下来的三个实时项目符号问题.(Let’s deal with verbs next, particularly POST verbs. This will allow us to tackle the next three live bullet items.)
动词(Verbs)
有(There are) 几个动词(several verbs) 可以伴随HTTP请求:(that can accompany an HTTP request:)
- 选项(OPTIONS)
- 得到(GET)
- 头(HEAD)
- 开机自检(POST)
- 放(PUT)
- 删除(DELETE)
- 跟踪(TRACE)
- 连接(CONNECT) 本质上,Web服务器并不真正在乎动词-动词所做的只是提供有关为响应调用哪个处理程序的附加信息.在这里,我们终于找到了到目前为止我一直避免的主题-控制器.我实现的Web服务器没有提供对Model-View-Controller模式的了解和/或在Web应用程序开发人员上强制执行这种模式,而是提供了一种简单的机制来将动词和路径路由到处理程序.这就是它要做的全部.反过来,处理程序确定是将浏览器重定向到其他页面还是停留在当前页面上.在后台,处理程序可以执行其他操作,但是从Web服务器的角度来看,这就是Web服务器所关心的全部.(Essentially, the web server doesn’t really care about the verb – all the verb does is provide additional information as to what handler to invoke for the response. Here we finally get to a topic I’ve avoided so far – controllers. Rather than the web server having any cognizance of a Model-View-Controller pattern and/or enforcing such pattern on the web application developer, the web server I’ve implemented provides a simple mechanism for routing verbs and paths to handlers. That’s all it needs to do. The handler, in turn, determines whether the browser should be redirected to a different page or stay on the current page. Behind the scenes, the handler can do other things, but from the perspective of the web server, that’s all that the web server cares about.)
路线(Routes)
我们将从添加一个基本路由器开始.这包括一个(We’ll begin by adding a basic router. This consists of a) Route
类:(class:)
public class Route
{
public string Verb { get; set; }
public string Path { get; set; }
public Func<Dictionary<string,string>, string> Action { get; set; }
}
请注意Action属性,它是一个回调函数,它传递URL参数(稍后将处理post参数)并期望使用"可选"重定向URL.(Notice the Action property, which is a callback function that passes in the URL parameters (we’ll deal with post parameters in a bit) and expects an “optional” redirect URL.)
我们添加了一个简单的方法来将路由添加到路由表中:(We add a simple method to add routes to a route table:)
public void AddRoute(Route route)
{
routes.Add(route);
}
现在,我们可以实现调用应用程序特定的处理程序,这是Route方法的重构:(Now we can implement calling application specific handlers, which is a refactor of the Route method:)
public ResponsePacket Route(string verb, string path, Dictionary<string, string> kvParams)
{
string ext = path.RightOfRightmostOf('.');
ExtensionInfo extInfo;
ResponsePacket ret = null;
verb = verb.ToLower();
if (extFolderMap.TryGetValue(ext, out extInfo))
{
string wpath = path.Substring(1).Replace('/', '\\'); // Strip off leading '/' and reformat as with windows path separator.
string fullPath = Path.Combine(WebsitePath, wpath);
Route route = routes.SingleOrDefault(r => verb == r.Verb.ToLower() && path == r.Path);
if (route != null)
{
// Application has a handler for this route.
string redirect = route.Action(kvParams);
if (String.IsNullOrEmpty(redirect))
{
// Respond with default content loader.
ret = extInfo.Loader(fullPath, ext, extInfo);
}
else
{
// Respond with redirect.
ret = new ResponsePacket() { Redirect = redirect };
}
}
else
{
// Attempt default behavior
ret = extInfo.Loader(fullPath, ext, extInfo);
}
}
else
{
ret = new ResponsePacket() { Error = Server.ServerError.UnknownType };
}
return ret;
}
现在,让我们修改演示页面,以在单击按钮时对服务器进行POST调用,然后将重定向到处理程序中的其他页面.是的,我知道这可以完全用Javascript处理,但是我们在这里演示了动词路径处理程序,因此我们将在服务器端实现此行为.(Now let’s modify our demo page to make a POST call to the server when we click the button, and we’ll redirect to a different page in our handler. Yes, I know this could be handled entirely in the Javascript, but we’re demonstrating verb-path handlers here, so we’ll implement this behavior on the server-side.)
我们还要将请求的输入流也处理成键-值对,并添加作为请求一部分的参数的记录(URL和输入流中的任何参数):(Let’s also add processing the input stream of the request into key-value pairs as well, and add logging of the parameters (both in the URL and any parameters in the input stream) that is part of the request:)
private static async void StartConnectionListener(HttpListener listener)
{
...
Dictionary<string, string> kvParams = GetKeyValues(parms); // Extract into key-value entries.
string data = new StreamReader(context.Request.InputStream, context.Request.ContentEncoding).ReadToEnd();
GetKeyValues(data, kvParams);
Log(kvParams);
...
}
private static Dictionary<string, string> GetKeyValues(string data, Dictionary<string, string> kv = null)
{
kv.IfNull(() => kv = new Dictionary<string, string>());
data.If(d => d.Length > 0, (d) => d.Split('&').ForEach(keyValue => kv[keyValue.LeftOf('=')] = keyValue.RightOf('=')));
return kv;
}
private static void Log(Dictionary<string, string> kv)
{
kv.ForEach(kvp=>Console.WriteLine(kvp.Key+" : "+kvp.Value));
}
将URL参数和回发参数组合到单个键值对集合中可能不是一个好习惯,但是现在我们将使用这种"更简单"的实现.(It may be a bad practice to combine URL parameters and postback parameters into a single key-value pair collection, but we’ll go with this “simpler” implementation for now.)
我们创建一个新的HTML页面/demo/redirect:(We create a new HTML page /demo/redirect:)
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<title>Redirect Demo</title>
<link type="text/css" rel="Stylesheet" href="/CSS/demo.css"/>
</head>
<body>
<form name="myform" action="/demo/redirect" method="post">
<div class="center-inner top-margin-50">
<input type="submit" class="button" value="Redirect Me" id='redirect' name="redirectButton" />
</div>
</form>
</body>
</html>
而且,在不做进一步操作的情况下,让我们看一下单击按钮时的跟踪日志和行为:(And, without doing anything further, let’s look at the trace log and the behavior when we click on the button:)
首先,我们在页面加载时看到GET动词,然后单击按钮,我们看到带有参数的POST.编写自己的Web服务器的有趣之处在于,您确实可以更深入地了解幕后发生的事情,这对于Web开发的新手来说非常重要.请注意以下有关HTML的内容:(First we see the GET verb as the page loads, then, clicking on the button, we see the POST with the parameters. The fun thing about writing your own web server is you really get a deeper sense of what is happening behind the scenes, something that is important for people who are new to web development. Note the following in relation to the HTML:)
- 方法动词必须小写.如果您使用" POST",Visual Studio的IDE会警告您这是无法识别的HTML5动词.(The method verb must be in lowercase. If you use “POST”, Visual Studio’s IDE warns that this is an unrecognized HTML5 verb.)
- 具有讽刺意味的是,HttpListenerRequest.HttpMethod属性中的动词是大写的!(Ironically, the verb in the HttpListenerRequest.HttpMethod property is in uppercase!)
- 请注意操作路径是HttpListenerRequest.Url.AbsoluteUri(Note how the action path is the HttpListenerRequest.Url.AbsoluteUri)
- 注意发布数据的打包方式. “键"是HTML元素的名称,“值"是HTML元素的值.观察值中的空格如何用” +“替换.(Note the way the post data is packaged. The “key” is the HTML element’s name and the “value” is the HTML element’s value. Observe how whitespaces in the value have been replaced with ‘+’.) 现在让我们为该动词和路径注册一个处理程序:(Now let’s register a handler for this verb and path:)
static void Main(string[] args)
{
string websitePath = GetWebsitePath();
Server.onError = ErrorHandler;
// register a route handler:
Server.AddRoute(new Route() { Verb = Router.POST, Path = "/demo/redirect", Action = RedirectMe });
Server.Start(websitePath);
Console.ReadLine();
}
public static string RedirectMe(Dictionary<string, string> parms)
{
return "/demo/clicked";
}
现在,当我们单击按钮时,我们将被重定向:(And now, when we click the button, we’re redirected:)
那很简单.(That was easy.)
只需进行最少的重构,我们就可以解决以下三个问题:(With a minimal amount of refactoring, we’ve take care of these three issues:)
- 动词始终被假定为” get”(The verb is always assumed to be “get”)
- 加载内容后,应用程序没有任何操作内容的选项(尤其是HTML)(The application isn’t given any option to manipulate the content (particularly the HTML) after it’s been loaded)
- 您不能覆盖路由(You can’t override the routing)
第6步-身份验证和会话过期(Step 6 - Authentication and Session Expiration)
在上面的第5步中,我实现了一个非常基本的路由处理程序.我们想要的是稍微复杂一些的东西,可以处理非常常见的任务:(In step 5 above, I implemented a very basic route handler. What we’d like is something a little more sophisticated that can handle very common tasks:)
- 确保用户有权查看页面(making sure the user is authorized to view the page)
- 检查会话是否已过期(checking if the session has expired) 我们将重构上面的处理程序回调,以利用Routing类,从中我们可以提供一些内置行为,并允许Web应用程序开发人员替换和/或添加他们自己的其他行为,例如基于角色的身份验证.(We’ll refactor the handler callbacks above to utilize a Routing class from which we can provide some built-in behaviors as well as allowing the web application developer to replace and/or add their own additional behaviors, such as role-based authentication.)
会话管理(Session Management)
首先,让我们添加一个基本(First, let’s add a basic) Session
和(and) SessionManager
类:(class:)
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading.Tasks;
using Clifton.ExtensionMethods;
namespace Clifton.WebServer
{
/// <summary>
/// Sessions are associated with the client IP.
/// </summary>
public class Session
{
public DateTime LastConnection { get; set; }
public bool Authorized { get; set; }
/// <summary>
/// Can be used by controllers to add additional information that needs to persist in the session.
/// </summary>
public Dictionary<string, string> Objects { get; set; }
public Session()
{
Objects = new Dictionary<string, string>();
UpdateLastConnectionTime();
}
public void UpdateLastConnectionTime()
{
LastConnection = DateTime.Now;
}
/// <summary>
/// Returns true if the last request exceeds the specified expiration time in seconds.
/// </summary>
public bool IsExpired(int expirationInSeconds)
{
return (DateTime.Now - LastConnection).TotalSeconds > expirationInSeconds;
}
}
public class SessionManager
{
/// <summary>
/// Track all sessions.
/// </summary>
protected Dictionary<IPAddress, Session> sessionMap = new Dictionary<IPAddress, Session>();
// TODO: We need a way to remove very old sessions so that the server doesn't accumulate thousands of stale endpoints.
public SessionManager()
{
sessionMap = new Dictionary<IPAddress, Session>();
}
/// <summary>
/// Creates or returns the existing session for this remote endpoint.
/// </summary>
public Session GetSession(IPEndPoint remoteEndPoint)
{
// The port is always changing on the remote endpoint, so we can only use IP portion.
Session session = sessionMap.CreateOrGet(remoteEndPoint.Address);
return session;
}
}
}
的(The) SessionManager
管理(manages) Session
与客户端的端点IP关联的实例.请注意待办事项-我们需要在某种程度上删除会话,否则此列表将不断增长! Session类包含几个有用的属性,用于管理上次连接的日期/时间以及用户是否已被授权(登录等)来查看"授权"页面.我们还为Web应用程序提供了一个键值对字典,以保留与键关联的"对象".基本但实用.(instances associated with the client’s endpoint IP. Note the todo–that we need some way of removing sessions at some point, otherwise this list will just keep growing! The Session class contains a couple useful properties for managing the last connection date/time as well as whether the user has been authorized (logged in, whatever) to view “authorized” pages. We also provide a key-value pair dictionary for the web application to persist “objects” associated with keys. Basic, but functional.)
现在,在侦听器继续中,我们可以获取与端点IP关联的会话:(Now, in our listener continuation, we can get the session associated with the endpoint IP:)
private static async void StartConnectionListener(HttpListener listener)
{
ResponsePacket resp = null;
// Wait for a connection. Return to caller while we wait.
HttpListenerContext context = await listener.GetContextAsync();
Session session = sessionManager.GetSession(context.Request.RemoteEndPoint);
...
resp = router.Route(verb, path, kvParams);
// Update session last connection after getting the response,
// as the router itself validates session expiration only on pages requiring authentication.
session.UpdateLastConnectionTime();
那很简单!请注意,在给路由器(和我们的处理程序)选择首先检查上次会话状态的选项之后,我们如何更新上次连接时间.(That was quite easy! Note how we’re updating the last connection time after giving the router (and our handlers) the option to first inspect the last session state.)
由于会话期满与授权密切相关,因此我们希望当会话期满时,(Because session expiration is intimately associated with authorization, we expect that when a session expires, the) Authorized
标志将被清除.(flag will be cleared.)
匿名与经过验证的路由(Anonymous vs. Authenticated Routes)
现在,让我们添加一些内置功能来检查授权和会话到期.我们将在服务器中添加三个可以供应用程序使用的类:(Now let’s add some built-in functionality for checking authorization and session expiration. We’ll add three classes to our server that the application can use:)
- 匿名路由处理程序(AnonymousRouteHandler)
- AuthenticatedRouteHandler(AuthenticatedRouteHandler)
- AuthenticatedExpirableRouteHandler(AuthenticatedExpirableRouteHandler)
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Clifton.WebServer
{
/// <summary>
/// The base class for route handlers.
/// </summary>
public abstract class RouteHandler
{
protected Func<Session, Dictionary<string, string>, string> handler;
public RouteHandler(Func<Session, Dictionary<string, string>, string> handler)
{
this.handler = handler;
}
public abstract string Handle(Session session, Dictionary<string, string> parms);
}
/// <summary>
/// Page is always visible.
/// </summary>
public class AnonymousRouteHandler : RouteHandler
{
public AnonymousRouteHandler(Func<Session, Dictionary<string, string>, string> handler)
: base(handler)
{
}
public override string Handle(Session session, Dictionary<string, string> parms)
{
return handler(session, parms);
}
}
/// <summary>
/// Page is visible only to authorized users.
/// </summary>
public class AuthenticatedRouteHandler : RouteHandler
{
public AuthenticatedRouteHandler(Func<Session, Dictionary<string, string>, string> handler)
: base(handler)
{
}
public override string Handle(Session session, Dictionary<string, string> parms)
{
string ret;
if (session.Authorized)
{
ret = handler(session, parms);
}
else
{
ret = Server.onError(Server.ServerError.NotAuthorized);
}
return ret;
}
}
/// <summary>
/// Page is visible only to authorized users whose session has not expired.
/// </summary>
public class AuthenticatedExpirableRouteHandler : AuthenticatedRouteHandler
{
public AuthenticatedExpirableRouteHandler(Func<Session, Dictionary<string, string>, string> handler)
: base(handler)
{
}
public override string Handle(Session session, Dictionary<string, string> parms)
{
string ret;
if (session.IsExpired(Server.expirationTimeSeconds))
{
session.Authorized = false;
ret = Server.onError(Server.ServerError.ExpiredSession);
}
else
{
ret = base.Handle(session, parms);
}
return ret;
}
}
}
注意,我们现在还将会话实例传递给处理程序.方便!(Notice that we also now pass the session instance to the handler. Convenient!)
接下来,我们重构Web应用程序路由表以使用(Next, we refactor the web application routing table to use the) RouteHandler
派生类.我们的Route类被重构:(derived classes. Our Route class is refactored:)
public class Route
{
public string Verb { get; set; }
public string Path { get; set; }
public RouteHandler Handler { get; set; }
}
现在,该会话将传递到路由器并移交给路由处理程序:(The session is now passed in to the router and handed over to the route handler:)
public ResponsePacket Route(Session session, string verb, string path, Dictionary<string, string> kvParams)
{
...
string redirect = route.Handler.Handle(session, kvParams);
...
现在,我们只需要通过指定处理程序的类型来更新Web应用程序,例如:(Now we just need to update our web application by specifying the type of handler, for example:)
Server.AddRoute(new Route(){动词=Router.POST,路径="/demo/redirect",Handler =new AnonymousRouteHandler(RedirectMe)});(Server.AddRoute(new Route() { Verb = Router.POST, Path = “/demo/redirect”, Handler=new AnonymousRouteHandler(RedirectMe) });)
当然,我们的处理程序现在会收到会话实例:(and of course, our handler now receives the session instance:)
public static string RedirectMe(Session session, Dictionary<string, string> parms)
{
return "/demo/clicked";
}
让我们创建一个需要授权的路由,但是未在会话中设置授权标志:(Let’s create a route that requires authorization but the authorization flag is not set in the session:)
Server.AddRoute(new Route()
{
Verb = Router.POST,
Path = "/demo/redirect",
Handler=new AuthenticatedRouteHandler(RedirectMe)
});
我们将单击"重定向我"按钮,并注意,我们获得了"未授权"页面:(We’ll click on the “Redirect Me” button, and note that we get the “not authorized” page:)
我们将做同样的事情来测试到期逻辑:(We’ll do the same thing to test the expiration logic:)
Server.AddRoute(new Route()
{
Verb = Router.POST,
Path = "/demo/redirect",
Handler=new AuthenticatedExpirableRouteHandler(RedirectMe)
});
并且在"重定向我"页面上等待60秒(可在服务器中配置)后:(and after waiting 60 seconds (configurable in the Server) on the “Redirect Me” page:)
建立网站时,我发现身份验证/有效期通常会受到阻碍,因此我喜欢欺骗身份验证.我们可以通过实现onRequest来实现,服务器会调用onRequest:(While building a website, I find that authentication/expiration often gets in the way, so I like to spoof the authentication. We can do that by implementing onRequest, which the server calls if it exists:)
public static Action<Session, HttpListenerContext> onRequest;
...
// Wait for a connection. Return to caller while we wait.
HttpListenerContext context = await listener.GetContextAsync();
Session session = sessionManager.GetSession(context.Request.RemoteEndPoint);
onRequest.IfNotNull(r => r(session, context));
这样我们就可以实现"始终授权且永不过期"的会话:(and we can implement our “always authorized and never expiring” session this way:)
static void Main(string[] args)
{
string websitePath = GetWebsitePath();
Server.onError = ErrorHandler;
// Never expire, always authorize
Server.onRequest = (session, context) =>
{
session.Authorized = true;
session.UpdateLastConnectionTime();
};
第7步-AJAX查询(Step 7 - AJAX Queries)
让我们看一下AJAX回调,看看是否需要做任何处理.我们将用一个简单的AJAX jQuery脚本整理一个HTML页面:(Let’s look at an AJAX callback to see if there’s anything we need to do to handle that. We’ll put together an HTML page with a simple AJAX jQuery script:)
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<title>AJAX Demo</title>
<script type="text/javascript" src="/Scripts/jquery-1.11.2.min.js"></script>
<link type="text/css" rel="Stylesheet" href="/CSS/demo.css"/>
<script type="text/javascript">
$(document).ready(function () {
$("#me").click(function () {
$.ajax({
url: this.href,
datatype: "json",
async: true,
cache: false,
type: "put",
data: {
number: 5
},
success: function(data, status)
{
alert(data);
}
});
});
});
</script>
</head>
<body>
<div class="center-inner top-margin-50">
<input class="button" type="button" value="AJAX!" id="me"/>
</div>
</body>
</html>
我们可以看到正在发出请求,但是由于我们没有针对该请求的特定处理程序,因此我们看到服务器响应页面的内容,这是预期的.(We can see the request being made, but since we don’t have a specific handler for this request, we see the server responding with the contents of the page, which is expected.)
因此,让我们注册一个路由处理程序:(So let’s register a route handler:)
Server.AddRoute(new Route()
{
Verb = Router.PUT,
Path = "/demo/ajax",
Handler = new AnonymousRouteHandler(AjaxResponder)
});
但是现在我们有一个问题.我们的标准处理程序期望重定向,而不是数据响应:(But now we have a problem. Our standard handler expects a redirect, not a data response:)
public static string AjaxResponder(Session session, Dictionary<string, string> parms)
{
return "what???";
}
是的,是时候进行另一次重构了.处理程序需要更好地控制响应,因此应返回一个(Yes, it’s time for another refactoring. The handler needs finer control over the response, and thus should return a) ResponsePacket
, 例如:(, for example:)
public static ResponsePacket RedirectMe(Session session, Dictionary<string, string> parms)
{
return Server.Redirect("/demo/clicked");
}
public static ResponsePacket AjaxResponder(Session session, Dictionary<string, string> parms)
{
string data = "You said " + parms["number"];
ResponsePacket ret = new ResponsePacket() { Data = Encoding.UTF8.GetBytes(data), ContentType = "text" };
return ret;
}
此更改需要触摸处理程序响应以前是字符串的几个位置.最相关的代码段是在路由器本身中:(This change required touching a few places where the handler response used to be a string. The most relevant piece of code changed was in the router itself:)
Route handler = routes.SingleOrDefault(r => verb == r.Verb.ToLower() && path == r.Path);
if (handler != null)
{
// Application has a handler for this route.
ResponsePacket handlerResponse = handler.Handler.Handle(session, kvParams);
if (handlerResponse == null)
{
// Respond with default content loader.
ret = extInfo.Loader(session, fullPath, ext, extInfo);
}
else
{
// Respond with redirect.
ret = handlerResponse;
}
}
但是更改大约花了5分钟,结果如下:(but the change took all of about 5 minutes, and here’s the result:)
您当然可以返回JSON或XML数据-完全独立于Web服务器,但是建议您正确设置内容类型:(You can of course return the data in JSON or XML – that is completely independent of the web server, but you are advised to set the content type correctly:)
- ContentType =" application/json"(ContentType = “application/json”)
- ContentType =“应用程序/xml”(ContentType = “application/xml”)
AJAX GET动词(AJAX GET Verb)
还要注意,我使用了" PUT"动词,它不一定合适,但我想以它为例.看看如果使用GET动词,会发生什么情况:(Also note that I used the “PUT” verb, which isn’t necessarily appropriate, but I wanted to use it as an example. Look what happens if instead, we use the GET verb:)
使用GET动词,参数将作为URL的一部分传递!让我们为该路由编写一个处理程序:(With the GET verb, the parameters are passed as part of the URL! Let’s write a handler for this route:)
Server.AddRoute(new Route()
{
Verb = Router.GET,
Path = "/demo/ajax",
Handler = new AnonymousRouteHandler(AjaxGetResponder)
});
请注意,我们必须处理不带参数(浏览器的请求)和带参数的GET动词:(Note that we have to handle the GET verb with both no parameters (the browser’s request) and with parameters:)
有趣的是,我们现在可以使用浏览器来测试GET响应-注意URL:(Interestingly, we can use the browser now to test the GET response – note the URL:)
下划线是什么?(What’s that Underscore?)
jQuery添加了underscore参数来绕过Internet Explorer的缓存,并且仅在将cache设置为false且您使用的是GET动词时才出现.忽略它.(The underscore parameter is added by jQuery to get around Internet Explorer’s caching, and is present only when cache is set to false and you’re using the GET verb. Ignore it.)
第8步-Internet与Intranet(Step 8 - Internet vs. Intranet)
本地与公共IP地址(Local vs. Public IP Addresses)
在192.168 …本地测试Web服务器.IP地址很好,但是部署站点时会发生什么?我使用Amazon EC2服务器进行了此操作,(显然)发现防火墙后面有一个本地IP,而不是公共IP.您可以在路由器上看到相同的内容.我们可以使用我在Stack Overflow上找到的以下代码来获得公共IP(对不起,我提供了适当的信誉的链接):(Testing a web server locally on a 192.168… IP address is fine, but what happens when you deploy the site? I did this using an Amazon EC2 server and discovered (obivously) that there is a local IP behind the firewall, vs. the public IP. You can see the same thing with your router. We can get the public IP with this code which I found on Stack Overflow (sorry, I the link to give proper credit):)
public static string GetExternalIP()
{
string externalIP;
externalIP = (new WebClient()).DownloadString("http://checkip.dyndns.org/");
externalIP = (new Regex(@"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}")).Matches(externalIP)[0].ToString();
return externalIP;
}
可能不是最好的方法,但是它确实有效.(Possibly not the best approach, but it does work.)
这里的重点是,在通过重定向进行响应时,必须使用公共IP,而不是公共IP.(The salient thing here is that, when responding with a redirect, the public IP must be used, not the) UserHostAddress
:(:)
if (String.IsNullOrEmpty(publicIP))
{
response.Redirect("http://" + request.UserHostAddress + resp.Redirect);
}
else
{
response.Redirect("http://" + publicIP + resp.Redirect);
}
请注意,上面获取外部IP的代码可能会有点慢,并且显然仅应在服务器启动时执行.此外,如果您有一个实际的域名,那当然不是必需的.但是,要在没有注册域的情况下用主机提供商测试您的Web应用程序并将其指向主机提供商,上述步骤是绝对必要的.(Note that the above code for obtaining the external IP can be a bit slow, and should obviously only be done at server startup. Furthermore, it of course isn’t necessary if you have an actual domain name. However, for testing your web application with a host provider without having registered a domain and pointing it to the host provider, the above step is absolutely necessary.)
域名(Domain Names)
当您没有注册的域名时,上面的代码可以很好地进行测试,但是显然我们不希望用户在进行重定向时看到IP地址.我没有用实际的域名对此进行测试,但是这里的指导只是设置(The above code is fine for testing when you don’t have a registered domain name, but obviously we don’t want the user to see the IP address whenever we do a redirect. I haven’t testing this with an actual domain name, but the guidance here is, simply set) publicIP
使用实际域名,例如:(with the actual domain name, for example:)
Server.publicIP="www.yourdomain.com";
第9步-因此,您想动态修改HTML(以及为什么要这么做)(Step 9 - So You Want to Modify the HTML Dynamically (and why you ought to))
正如我在简介中所述,借助jQuery,AJAX,Javascript和专业的第三方组件的功能,我几乎无法想象需要使用嵌入式Ruby或C#以及标记本身来生成任何复杂的服务器端HTML.就是说,您可能要服务器修改HTML的一个原因就是应对CSRF攻击.(As I said in the introduction, with the capabilities of jQuery, AJAX, Javascript and professional third party components, I can only rarely imagine the need for any complex server-side HTML generation using embedded Ruby or C# along with the markup itself. That said, there is one reason you probably want the server to modify the HTML, and that is to deal with CSRF attacks.)
跨站请求伪造(CSRF)(Cross-Site Request Forgery (CSRF))
这是(Here’s) 对CSRF的很好解释,以及为什么您应该关心它.但是,我们是否需要运行时动态代码编译来吐出必要的HTML?不,当然不.因此,为了处理CSRF以及更一般的服务器端HTML操作,我们将为Web应用程序添加在HTML返回浏览器之前对HTML进行后处理的功能.我们可以在将HTML编码为字节数组之前在路由器中执行此操作:(a good explanation of CSRF and why you should care about it. However, do we need a runtime dynamic code compilation to spit out the necessary HTML? No, of course not. So, to deal with CSRF and more generally, server-side HTML manipulation, we’ll add the ability for the web application to post-process the HTML before it is returned to the browser. We can do this in the router just before the HTML is encoded into a byte array:)
string text = File.ReadAllText(fullPath);
text = Server.postProcess(session, text); // post processing option, such as adding a validation token.
服务器提供的默认实现是:(The default implementation provided by the server is:)
public static string validationTokenScript = "<%AntiForgeryToken%>";
public static string validationTokenName = "__CSRFToken__";
private static string DefaultPostProcess(Session session, string html)
{
string ret = html.Replace(validationTokenScript,
"<input name='" +
validationTokenName +
"' type='hidden' value='" +
session.Objects[validationTokenName].ToString() +
" id='#__csrf__'" +
"/>");
return ret;
}
重构时间!遇到新会话时会创建一个令牌:(Refactoring time! A token is created when a new session is encountered:)
public Session GetSession(IPEndPoint remoteEndPoint)
{
Session session;
if (!sessionMap.TryGetValue(remoteEndPoint.Address, out session))
{
session=new Session();
session.Objects[Server.validationTokenName] = Guid.NewGuid().ToString();
sessionMap[remoteEndPoint.Address] = session;
}
return session;
}
然后,默认情况下,我们可以对非GET动词实施CSRF检查(尽管我们应该比这更具有选择性,目前我只保留它):(We can then, by default, implement a CSRF check on non-GET verbs (though we should probably be more selective than that, for the moment I’ll just leave it at that):)
public ResponsePacket Route(Session session, string verb, string path, Dictionary<string, string> kvParams)
{
string ext = path.RightOfRightmostOf('.');
ExtensionInfo extInfo;
ResponsePacket ret = null;
verb = verb.ToLower();
if (verb != GET)
{
if (!VerifyCSRF(session, kvParams))
{
// Don't like multiple return points, but it's so convenient here!
return Server.Redirect(Server.onError(Server.ServerError.ValidationError));
}
}
...
}
/// <summary>
/// If a CSRF validation token exists, verify it matches our session value.
/// If one doesn't exist, issue a warning to the console.
/// </summary>
private bool VerifyCSRF(Session session, Dictionary<string,string> kvParams)
{
bool ret = true;
string token;
if (kvParams.TryGetValue(Server.validationTokenName, out token))
{
ret = session.Objects[Server.validationTokenName].ToString() == token;
}
else
{
Console.WriteLine("Warning - CSRF token is missing. Consider adding it to the request.");
}
return ret;
}
因此,鉴于此HTML:(So, given this HTML:)
<!DOCTYPE html>
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8" />
<title>Login</title>
<link type="text/css" rel="Stylesheet" href="/CSS/demo.css"/>
</head>
<body>
<form name="myform" action="/demo/redirect" method="post">
<%AntiForgeryToken%>
<div class="center-inner top-margin-50">
Username:
<input name="username"/>
</div>
<div class="center-inner top-margin-10">
Password:
<input type="password" name="password"/>
</div>
<div class="center-inner top-margin-10">
<input type="submit" value="Login"/>
</div>
</form>
</body>
</html>
我们可以检查源并查看我们的令牌,例如:(We can inspect the source and see our token, for example:)
<form name="myform" action="/demo/redirect" method="post">
<input name='__CSRFToken__' type='hidden' value='a9161119-de6f-4bb2-8e21-8d089d556c37'/>
在控制台窗口中的帖子中,我们看到:(And in the console window, on the post, we see:)
如果省略验证令牌,则会在控制台窗口中收到警告:(If we omit the validation token, we get a warning in the console window:)
其他HTML替换(Other HTML Replacement)
对服务器进行更精细的控制后,就可以发明自己的令牌替换集,几乎可以做任何您想做的事情.您甚至可以将HTML馈送到不同的解析器.例如,我真的很喜欢(When you have finer grained control over the server, you can pretty much do anything you want in terms of inventing your own set of token replacements. You could even feed the HTML to different parsers. For example, I really like the) 苗条的语言模板(Slim language template) 在Ruby on Rails中受支持.例如,在Slim语法中,登录HTML如下所示:(supported in Ruby on Rails. For example, in the Slim syntax, the login HTML looks like this:)
doctype html
html lang="en" xmlns="<a href="http://www.w3.org/1999/xhtml">http://www.w3.org/1999/xhtml</a>"
head
meta charset="utf-8" /
title Login
link href="/CSS/demo.css" rel="Stylesheet" type="text/css" /
body
form action="/demo/redirect" method="post" name="myform"
| <%AntiForgeryToken%
.center-inner.top-margin-50
| Username:
input name="username" /
.center-inner.top-margin-10
| Password:
input name="password" type="password" /
.center-inner.top-margin-10
input type="submit" value="Login" /
这在ASP.NET,Razor等中不可用,并且替换Razor解析器引擎并非易事.但是,我们可以轻松地将Slim to HTML后处理解析器添加到(This is not available with ASP.NET, Razor, etc., and replacing the Razor parser engine is not trivial. However, we can easily add a Slim to HTML post-process parser to)*我们的(our)*网络服务器.(web server.)
另一天的问题(Issues for Another Day)
CSRF和AJAX(CSRF and AJAX)
由于我们将检查放置在什么地方,我们也会在AJAX发布/删除/删除操作上收到此警告,这可能是个好主意.这是我们的AJAX演示页面看起来像传递CSRF令牌的样子:(Because of where we put this check in, we will get this warning on AJAX post/put/deletes as well, which is probably a good idea. Here’s what our AJAX demo page looks like passing in the CSRF token:)
<script type="text/javascript">
$(document).ready(function () {
$("#me").click(function () {
$.ajax({
url: this.href,
async: true,
cache: false,
type: "put",
data: {
number: 5,
__CSRFToken__: $("#__csrf__").val()
},
success: function(data, status)
{
alert(data);
}
});
});
});
</script>
这可能不是您的典型实现方式,并且如果验证失败(由于AJAX响应发送重定向有点怪异,则发送重定向)也会导致一些有趣的浏览器行为.无论如何,这都是一个我不想做的兔子漏洞进一步研究,并将其留给读者来决定AJAX请求是否应具有验证令牌.如果不选择它,则控制台将仅发出警告.(This is probably not your typical implementation and it also results in some interesting browser behavior if the validation fails (sending a redirect as an AJAX response is a bit weird.) In any case, this becomes a rabbit hole that I don’t want to pursue further and will leave it to the reader to decide whether AJAX requests should have a validation token. If you leave it off, then the console will simply issue a warning.)
HTTPS(HTTPS)
如今,网站确实应该使用HTTPS,但是我将把这一天留待另一天,可能会在本文的某个地方发表单独的文章或附录.(Websites should really use HTTPS nowadays, however I’m am going to leave this for another day, possibly a separate article or an addendum at some point to this article.)
解码参数值(Decoding Parameter Values)
解码参数值可能会很好,例如,将" +“替换为空格,将”%xx"替换为适当的实际字符.(It would probably be nice to decode parameter values, for example, replacing “+” with whitespace and “%xx” with the appropriate actual character.)
链接后处理(Chaining Post Processing)
对HTML进行后处理是链接的成熟条件之一,并且在待办事项列表上.(Post processing the HTML is one of those things ripe for chaining, and is on the todo list.)
还有什么?(What Else?)
我确定还有其他事情可以做!(I’m sure there’s other things that could be done!)
结论(Conclusion)
还有哪些其他主要问题需要注意?我犯了什么可怕的错误?(What other major issues need to be taken care of? What horrendous mistakes did I make?)
这个想法是使Web服务器保持很小.我总共有四个类(不包括扩展方法),整个过程略少于650行代码,注释和所有内容.(The idea is to keep the web server very small. I have a total of four classes (not including extension methods) and the whole thing is slightly less than 650 lines of code, comments and all.)
许可
本文以及所有相关的源代码和文件均已获得The Code Project Open License (CPOL)的许可。
C# .NET Windows MVC jQuery Ajax Dev Architect 新闻 翻译