从头开始编写 Web 服务器






4.99/5 (102投票s)
应大众要求,我将介绍如何用不到 650 行代码实现一个轻量级 Web 服务器。
引言
主要是为了好玩,我决定研究一下编写自己的 Web 服务器需要什么。我很快就被这个想法以及我正在发现的关于浏览器、响应中对浏览器期望的遵守以及编写一个精简高效的 Web 服务器的纯粹乐趣所吸引。因此,本文(应几周前一些人的要求)描述了这个过程。目前,该实现支持
- 路由
- 会话管理器
- Authorization
- 过期会话
因为 Web 服务器非常轻量,我发现完全没有必要做任何复杂的事情,例如为实际应用程序实现插件。有三个核心文件
- Listener.cs - 使用 HttpListener 侦听并响应连接
- Router.cs - 管理路由
- SessionManager.cs - 管理连接会话状态
一个发表看法的时刻(或几个)
我认为重要的是要说明这个 Web 服务器**不是**什么。你不会发现什么(你可能会不同意我的严厉批评,但这是我过去几年 Web 开发中学到的)
- 任何 ORM。ORM 绝不应该成为 Web 服务器的一部分
- 任何 MVC。对于 Web 应用程序来说,整个 MVC 概念通常是一种不必要的架构,旨在让 Ruby on Rails 开发人员适应 Microsoft 技术。
- 没有 Web 页面运行时编译或用于解析“增强”HTML 文件的自定义语法。鉴于现代基于 jQuery 的控件(如 jqwidgets)以及采用 JSON 和 AJAX,通过嵌入 C#(或其他语言)元素将 HTML 文件变成命令式东西的需求,嗯,那根本没有必要。它
- 减慢页面服务速度
- 将决定视图状态的逻辑涂抹到通常不应该存在的视图中
- 如果需要编写复杂的渲染,最好采用体面的流畅架构,而不是使用进一步混淆 HTML 和 CSS 已经晦涩难懂的语法的语法。
- 属性 - 从我开始使用 C# 起我就说过:属性对于向序列化器提供如何序列化字段或属性的提示非常有用,除此之外,它们大多会促进糟糕的设计——这种设计通过良好的面向对象设计会好得多。例如,我的路由是由 `Route` 基类和 `AuthenticatedRoute` 子类实现的。如果你想要基于角色的身份验证,它会成为一个派生类,而不是装饰通常为空的控制器函数并且需要通过重重障碍才能实现与框架设计者决定应该是什么的正确实现不同的属性。把所有这些都扔掉,因为它永远不会按你想要的方式工作,并且它会通过不断使用反射来检查“哦,我被授权了吗”,“哦,我有这个的正确角色吗?”而增加更多的性能膨胀。再说一次,这是糟糕设计的另一个例子。
- 没有 IIS。更多不必要的臃肿和不需要的配置复杂性。
讽刺的是(这是我站着发表看法时最后要说的话),在实现了自己的 Web 服务器之后,我意识到像 MVC Razor 和 Ruby on Rails(随便举两个例子)这样的技术在服务网页和让程序员控制如何渲染非静态内容方面**碍事**太多了。通过一个简单的 Web 服务器,我发现自己更多地专注于客户端 JavaScript、HTML 和组件,而我只需要关注服务器端进程中的 PUT 处理程序和偶尔的 AJAX 请求。MVC 造成的负担、晦涩的路由语法、装饰不必要的控制器函数的属性——在我看来,编写 Web 应用程序的整个现状相当令人沮丧。
关于源代码仓库
源代码托管在 GitHub 上
git clone https://github.com/cliftonm/BasicWebServer.git
关于本文的编写过程
我认为,与其直接展示最终的 Web 服务器,不如一步步记录 Web 服务器的构建过程,这样你就能了解它是如何从零开始构建的,以及设计和实现决策(无论好坏)和整个过程。我希望你,读者,喜欢这种方法。
步骤 1 - HttpListener
流程的第一步是让 `HttpListener` 类工作起来。我选择这条路线而不是更底层的套接字路线,因为 `HttpListener` 提供了许多有用的服务,例如解码 HTML 请求。我读到它的性能不如套接字路线,但我对一点性能下降并不是特别担心。
Web 服务器作为一个库实现。有一个用于特定 Web 应用程序可执行文件的控制台应用程序。对于第一步,Web 服务器需要
using System.Net; using System.Net.Sockets; using System.Threading;
因为 Web 服务器主要是无状态的(除了会话对象),所以大多数行为都可以作为静态单例实现。
namespace Clifton.WebServer { /// <summary> /// A lean and mean web server. /// </summary> public static class Server { private static HttpListener listener; ...
我们首先假设我们正在连接到内网上的服务器,因此我们获取本地主机的 IP
/// <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 前缀
private static HttpListener InitializeListener(List<IPAddress> localhostIPs) { HttpListener listener = new HttpListener(); listener.Prefixes.Add("https:///"); // 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。
借鉴 Sacha 的一个简单的 REST 框架中的概念,我们将设置一个信号量,它等待指定数量的并发连接
public static int maxSimultaneousConnections = 20; private static Semaphore sem = new Semaphore(maxSimultaneousConnections, maxSimultaneousConnections);
这在工作线程中实现,由 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); } }
最后,我们将连接侦听器实现为可等待的异步进程
/// <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... }
那么,让我们做点什么吧
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();
而且,我们需要一个公共的 `Start` 方法
/// <summary> /// Starts the web server. /// </summary> public static void Start() { List<IPAddress> localHostIPs = GetLocalHostIPs(); HttpListener listener = InitializeListener(localHostIPs); Start(listener); }
现在在我们的控制台应用程序中,我们可以启动服务器
using System; using Clifton.WebServer; namespace ConsoleWebServer { class Program { static void Main(string[] args) { Server.Start(); Console.ReadLine(); } } }
我们开始吧
始终检查浏览器的 Web 控制台窗口
浏览器的 Web 控制台窗口是你的朋友——它会告诉你所有你做错的事情!例如,在上面的测试用例中,我们发现
在这里我们了解到需要处理编码,这在 HTML 中完成
string response = "<html><head><meta http-equiv='content-type' content='text/html; charset=utf-8'/> </head>Hello Browser!</html>";
由于这只是一个例子,我们暂时就说到这里。稍后我们会发现更多我们做错的事情!
步骤 2 - 日志记录
首先,让我们添加一些日志记录,因为日志记录对于查看我们的 Web 服务器正在发出什么样的请求非常有用
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,我曾在这里写过相关内容。
在释放信号量后,我们立即添加 Log 调用
Log(context.Request);
添加日志记录器后我们注意到什么
一旦我们添加了日志记录,我们立即注意到浏览器不仅请求默认页面,它还在请求 favicon.ico!
嗯,我们需要对此做些什么!
步骤 3 - 服务内容:默认路由
显然,我们不想在 C# 中将网页编写为字符串。所以,让我们为我们的 Web 应用程序创建一个基本结构。这是完全任意的,但我选择的结构是所有内容都将派生自“Website”文件夹。在“Website”下,我们找到以下文件夹
- Pages:所有页面的根目录
- CSS:包含所有 .css 及相关文件
- Scripts:包含所有 .js 文件
- Images:包含所有图像文件
为了处理一些基本功能,我们需要路由器的雏形。我们的第一次尝试将只响应在“Website”文件夹和子文件夹中找到的文件,这些文件由 URL 路径和请求扩展名确定。我们首先从 URL 请求中提取一些信息
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.
我们现在可以将这些信息传递给路由器
router.Route(verb, path, kvParams);
尽管它可以是静态的,但将路由器作为实际实例可能会有一些潜在的好处,因此我们在 `Server` 类中对其进行初始化
private static Router router = new Router();
另一个吹毛求疵的细节是实际的网站路径。因为我正在从 bin\debug 文件夹运行控制台程序,所以网站路径实际上是“..\..\Website”。这是一个获取此路径的笨拙方法
public static string GetWebsitePath() { // Path of our exe. string websitePath = Assembly.GetExecutingAssembly().Location; websitePath = websitePath.LeftOfRightmostOf("\\").LeftOfRightmostOf("\\").LeftOfRightmostOf("\\") + "\\Website"; return websitePath; }
需要进行一些重构,我们将其传递给 Web 服务器,由 Web 服务器配置路由器
public static void Start(string websitePath) { router.WebsitePath = websitePath; ...
由于我特别不喜欢 switch 语句,我们将初始化一个已知扩展及其加载位置的映射。考虑如何使用不同的函数从例如数据库加载内容。
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 页面。
另外请注意,我们设置了内容类型。如果我们不这样做,我们会在 Web 控制台中收到警告,指出内容被假定为特定类型。
最后,请注意我们正在指示一个用于执行实际加载的函数。
图像加载器
/// <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; }
文件加载器
/// <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; }
页面加载器
页面加载器必须做一些巧妙的调整来处理诸如
- foo.com
- foo.com\index
- foo.com\index.html
所有这些组合最终都会加载 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; }
我们有几个辅助类
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; } }
将所有这些整合在一起,我们就有了路由器的雏形,它现在返回文件中找到的内容。
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; }
我们还需要最后一次重构——删除我们的测试响应并用路由器返回的内容替换它
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(); }
这目前是基础实现。
我们可以在实际操作中看到这一点。下面是一些 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>
你可以看到网站结构
我们的小演示页面正常工作!
你可以看到这里发生了一些事情
- 确实,favicon.ico 正在加载(如果你好奇的话,它是一棵棕榈树)
- 页面当然正在加载
- 样式正在生效
- JQuery 脚本正在工作
这都很好,但是
- 未处理未知扩展名
- 未处理丢失内容
- 未处理加载内容时发生的错误
- 动词总是假定为“get”
- 应用程序没有机会在内容(特别是 HTML)加载后对其进行操作
- 你不能覆盖路由
- 没有授权内容的概念
- 没有考虑会话持续时间
- 没有异常处理
- 未处理重定向
这些都是我们需要解决的问题,但是,我们现在可以创建带有 CSS 和 Javascript 的页面:所以,尽管还有很多事情要做,但我们现在已经有很多功能在运行了!
这里揭示的一件事是,内容的实际“文件”位置如何被服务器完全欺骗。在上面的代码中,我将所有 HTML 内容放在 Pages 文件夹下,从而欺骗了根位置。我们可以做其他事情——从数据库加载数据,与另一个服务器通信,根据数据动态生成页面……这些都是我们超越默认内容加载后将探索的功能。
步骤 4 - 魔鬼藏在细节中
让我们开始处理上面提到的问题。
错误页面
我们将添加几个错误页面,尽管我们目前并非全部使用它们
- 会话过期
- 未经授权
- 页面未找到
- 服务器错误
- 未知类型
现在,你可能想知道为什么服务器会知道会话过期和授权失败的事情。嗯,因为它是有道理的——这些错误是路由的组成部分,但错误状态由 Web 应用程序(而不是服务器)决定。服务器所做的只是查询 Web 应用程序的状态。稍后会详细介绍。
我们希望应用程序能够确定这些页面在给定错误情况下的位置,因此我们将在服务器中添加一个枚举
public enum ServerError { OK, ExpiredSession, NotAuthorized, FileNotFound, PageNotFound, ServerError, UnknownType, }
我们现在可以开始处理错误(不抛出异常)。首先是未知扩展名
if (extFolderMap.TryGetValue(ext, out extInfo)) { ... } else { ret = new ResponsePacket() { Error = Server.ServerError.UnknownType }; }
等等。我们将使用 Web 应用程序可以提供的回调来处理错误。这以用户应该重定向到的页面的形式呈现。
然后我们重构我们的代码,从应用程序获取要在错误时显示的页面
ResponsePacket resp = router.Route(verb, path, kvParams); if (resp.Error != ServerError.OK) { resp = router.Route("get", onError(resp.Error), null); } Respond(context.Response, resp);
并在应用程序中实现一个直接的错误处理程序
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; }
当然,我们必须初始化错误处理程序
Server.onError = ErrorHandler;
我们现在可以测试一些东西。当然,你的应用程序可能需要更复杂的消息!
未知类型错误
页面未找到
文件未找到
重定向
你会注意到上面错误消息中的 URL 没有改变以反映页面。这是因为我们没有响应重定向功能。是时候解决这个问题了
我们假设错误处理程序总是将我们重定向到不同的页面,所以我们改变了处理响应的方式。我们不再获取一个新的 `ResponsePacket` 并将该内容发送回浏览器,而是简单地将 `Redirect` 属性设置为 Web 应用程序希望我们去的页面。(顺便说一下,这成为一种通用的重定向机制。)
if (resp.Error != ServerError.OK) { resp.Redirect = onError(resp.Error); }
我们在 `Respond` 方法中做了一点重构
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(); }
顺便说一句,关闭输出流非常重要。如果你不这样做,浏览器可能会一直挂起,等待数据。
请注意,由于我们使用重定向处理错误,因此我们的 Web 服务器可以响应的唯一两种可能的状态码是 OK 和 Redirect。
现在我们的重定向功能正常工作了
异常处理
我们使用相同的重定向机制来捕获实际异常,方法是将 `GetContextAsync` 延续包装在一个 try-catch 块中
catch(Exception ex) { Console.WriteLine(ex.Message); Console.WriteLine(ex.StackTrace); resp = new ResponsePacket() { Redirect = onError(ServerError.ServerError) }; }
以下是模拟错误的样子
步骤 5 - 回顾并解决更多问题
我们进展到哪里了?
未处理未知扩展名未处理丢失内容未处理加载内容时发生的错误- 动词总是假定为“get”
- 应用程序没有机会在内容(特别是 HTML)加载后对其进行操作
- 你不能覆盖路由
- 没有授权内容的概念
- 没有考虑会话持续时间
没有异常处理未处理重定向
接下来我们来处理动词,特别是 POST 动词。这将使我们能够解决接下来的三个重点问题。
动词
HTTP 请求可以附带几个动词
- OPTIONS
- GET
- HEAD
- POST
- PUT
- 删除
- TRACE
- CONNECT
本质上,Web 服务器并不真正关心动词——动词所做的只是提供关于为响应调用哪个处理程序的附加信息。在这里,我们终于谈到了我迄今为止一直避免的话题——控制器。我实现的 Web 服务器不了解 Model-View-Controller 模式和/或强制 Web 应用程序开发人员使用这种模式,而是提供了一种简单的机制,用于将动词和路径路由到处理程序。它只需要做这些。处理程序反过来决定浏览器是应该重定向到不同的页面还是停留在当前页面。在幕后,处理程序可以做其他事情,但从 Web 服务器的角度来看,这就是 Web 服务器关心的所有事情。
路由
我们将从添加一个基本路由器开始。它由一个 `Route` 类组成
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。
我们添加一个简单的方法来将路由添加到路由表
public void AddRoute(Route route) { routes.Add(route); }
现在我们可以实现调用应用程序特定的处理程序,这是 Route 方法的重构
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 中处理,但我们在这里演示动词路径处理程序,所以我们将在服务器端实现此行为。
我们还要将请求的输入流处理成键值对,并添加请求中参数的日志记录(包括 URL 中的参数和输入流中的任何参数)
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 参数和回传参数组合成一个键值对集合可能不是一个好习惯,但我们暂时采用这种“更简单”的实现。
我们创建一个新的 HTML 页面 /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>
然后,不进行任何进一步的操作,让我们看看跟踪日志以及单击按钮时的行为
首先,我们看到页面加载时的 GET 动词,然后,点击按钮后,我们看到带有参数的 POST。编写自己的 Web 服务器的有趣之处在于,你确实对幕后发生的事情有了更深入的了解,这对于 Web 开发新手来说非常重要。请注意以下与 HTML 相关的内容
- 方法动词必须是小写。如果你使用“POST”,Visual Studio 的 IDE 会警告这是一个无法识别的 HTML5 动词。
- 讽刺的是,HttpListenerRequest.HttpMethod 属性中的动词是大写的!
- 注意操作路径如何是 HttpListenerRequest.Url.AbsoluteUri
- 请注意 POST 数据的打包方式。“键”是 HTML 元素的名称,“值”是 HTML 元素的值。观察值中的空格是如何被“+”替换的。
现在让我们为这个动词和路径注册一个处理程序
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"; }
现在,当我们点击按钮时,我们被重定向了
这很容易。
经过少量重构,我们已经解决了这三个问题
- 动词总是假定为“get”
- 应用程序没有机会在内容(特别是 HTML)加载后对其进行操作
- 你不能覆盖路由
步骤 6 - 身份验证和会话过期
在上面的步骤 5 中,我实现了一个非常基本的路由处理程序。我们想要的是一个稍微复杂一点的东西,可以处理非常常见的任务
- 确保用户有权查看页面
- 检查会话是否已过期
我们将重构上面的处理程序回调,以利用一个 Routing 类,我们可以从中提供一些内置行为,并允许 Web 应用程序开发人员替换和/或添加他们自己的其他行为,例如基于角色的身份验证。
会话管理
首先,让我们添加一个基本的 `Session` 和 `SessionManager` 类
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; } } }
`SessionManager` 管理与客户端端点 IP 关联的 `Session` 实例。请注意 todo——我们需要某种方式在某个时候删除会话,否则这个列表只会不断增长!`Session` 类包含几个有用的属性,用于管理上次连接日期/时间以及用户是否已被授权(登录,随便)查看“授权”页面。我们还提供了一个键值对字典,供 Web 应用程序持久化与键关联的“对象”。基本,但功能齐全。
现在,在我们的侦听器延续中,我们可以获取与端点 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();
这很容易!请注意,在给路由器(和我们的处理程序)选择首先检查上次会话状态后,我们是如何更新上次连接时间的。
由于会话过期与授权密切相关,我们期望当会话过期时,`Authorized` 标志将被清除。
匿名路由与已认证路由
现在让我们添加一些内置功能来检查授权和会话过期。我们将在服务器中添加三个类,应用程序可以使用它们
- 匿名路由处理程序
- 已认证路由处理程序
- 已认证可过期路由处理程序
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; } } }
请注意,我们现在也将会话实例传递给处理程序。方便!
接下来,我们将 Web 应用程序路由表重构为使用 `RouteHandler` 派生类。我们的 Route 类被重构
public class Route { public string Verb { get; set; } public string Path { get; set; } public RouteHandler Handler { get; set; } }
会话现在传递给路由器并移交给路由处理程序
public ResponsePacket Route(Session session, string verb, string path, Dictionary<string, string> kvParams) { ... string redirect = route.Handler.Handle(session, kvParams); ...
现在我们只需要通过指定处理程序的类型来更新我们的 Web 应用程序,例如
Server.AddRoute(new Route() { Verb = Router.POST, Path = "/demo/redirect", Handler=new AnonymousRouteHandler(RedirectMe) });
当然,我们的处理程序现在接收到会话实例
public static string RedirectMe(Session session, Dictionary<string, string> parms) { return "/demo/clicked"; }
让我们创建一个需要授权但会话中未设置授权标志的路由
Server.AddRoute(new Route() { Verb = Router.POST, Path = "/demo/redirect", Handler=new AuthenticatedRouteHandler(RedirectMe) });
我们将点击“重定向我”按钮,并注意我们得到了“未授权”页面
我们将做同样的事情来测试过期逻辑
Server.AddRoute(new Route() { Verb = Router.POST, Path = "/demo/redirect", Handler=new AuthenticatedExpirableRouteHandler(RedirectMe) });
在“重定向我”页面上等待 60 秒(可在服务器中配置)后
在构建网站时,我发现身份验证/过期常常会成为障碍,所以我喜欢伪造身份验证。我们可以通过实现 onRequest 来做到这一点,如果存在,服务器会调用它
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));
我们可以通过这种方式实现“始终授权且永不过期”的会话
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 查询
让我们看看 AJAX 回调,看看是否有需要处理的地方。我们将用一个简单的 AJAX jQuery 脚本搭建一个 HTML 页面
<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>
我们可以看到请求正在发出,但由于我们没有为此请求指定处理程序,我们看到服务器响应了页面内容,这是预期的结果。
所以我们来注册一个路由处理程序
Server.AddRoute(new Route() { Verb = Router.PUT, Path = "/demo/ajax", Handler = new AnonymousRouteHandler(AjaxResponder) });
但现在我们遇到了一个问题。我们的标准处理程序期望重定向,而不是数据响应
public static string AjaxResponder(Session session, Dictionary<string, string> parms) { return "what???"; }
是的,又到了重构的时候了。处理程序需要对响应有更精细的控制,因此应该返回一个 `ResponsePacket`,例如
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; }
此更改需要在处理程序响应曾是字符串的几个地方进行修改。最相关的代码更改是在路由器本身中
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 分钟,结果如下
你当然可以以 JSON 或 XML 格式返回数据——这完全独立于 Web 服务器,但建议你正确设置内容类型
- ContentType = "application/json"
- ContentType = "application/xml"
AJAX GET 动词
另外请注意,我使用了“PUT”动词,这不一定合适,但我想用它作为一个例子。看看如果改用 GET 动词会发生什么
使用 GET 动词时,参数作为 URL 的一部分传递!让我们为这条路由编写一个处理程序
Server.AddRoute(new Route() { Verb = Router.GET, Path = "/demo/ajax", Handler = new AnonymousRouteHandler(AjaxGetResponder) });
请注意,我们必须处理不带参数(浏览器请求)和带参数的 GET 动词
有趣的是,我们现在可以使用浏览器来测试 GET 响应——注意 URL
那个下划线是什么?
下划线参数是由 jQuery 添加的,用于绕过 Internet Explorer 的缓存,并且仅在 `cache` 设置为 `false` 且你使用 `GET` 动词时才存在。忽略它。
步骤 8 - 互联网与内网
本地 IP 地址与公共 IP 地址
在 192.168... IP 地址上本地测试 Web 服务器很好,但是当你部署站点时会发生什么?我使用 Amazon EC2 服务器进行了此操作,并发现(显然)防火墙后面有一个本地 IP,而不是公共 IP。你可以在路由器上看到同样的情况。我们可以使用我在 Stack Overflow 上找到的这段代码获取公共 IP(抱歉,我没有链接来提供正确的出处)
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; }
可能不是最好的方法,但它确实有效。
这里的突出之处在于,当响应重定向时,必须使用公共 IP,而不是 `UserHostAddress`
if (String.IsNullOrEmpty(publicIP)) { response.Redirect("http://" + request.UserHostAddress + resp.Redirect); } else { response.Redirect("http://" + publicIP + resp.Redirect); }
请注意,上述获取外部 IP 的代码可能会有点慢,并且显然应该只在服务器启动时执行。此外,如果你有实际的域名,当然就没有必要。然而,对于使用主机提供商测试你的 Web 应用程序而没有注册域名并将其指向主机提供商,上述步骤是绝对必要的。
域名
上面的代码在没有注册域名的情况下进行测试是可行的,但显然我们不希望用户在每次重定向时都看到 IP 地址。我没有用实际域名测试过这个,但这里的指导是,简单地将 `publicIP` 设置为实际域名,例如
Server.publicIP="www.yourdomain.com";
步骤 9 - 你想动态修改 HTML(以及为什么要这样做)
正如我在介绍中提到的,凭借 jQuery、AJAX、Javascript 和专业的第三方组件的功能,我很少能想象到需要使用嵌入式 Ruby 或 C# 以及标记本身来生成任何复杂的服务器端 HTML。话虽如此,你可能希望服务器修改 HTML 的一个原因是为了处理 CSRF 攻击。
跨站请求伪造 (CSRF)
这里有关于 CSRF 以及为什么你应该关心它的很好解释。然而,我们需要运行时动态代码编译来输出必要的 HTML 吗?不,当然不需要。因此,为了处理 CSRF,更普遍地说,服务器端 HTML 操作,我们将添加 Web 应用程序在 HTML 返回给浏览器之前对其进行后处理的功能。我们可以在路由器中在 HTML 被编码成字节数组之前完成此操作
string text = File.ReadAllText(fullPath); text = Server.postProcess(session, text); // post processing option, such as adding a validation token.
服务器提供的默认实现是
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; }
重构时间到了!当遇到新会话时会创建一个令牌
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 检查(尽管我们可能应该更具选择性,目前我只是将其保留在那里)
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
<!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>
我们可以检查源代码并看到我们的令牌,例如
<form name="myform" action="/demo/redirect" method="post"> <input name='__CSRFToken__' type='hidden' value='a9161119-de6f-4bb2-8e21-8d089d556c37'/>
在控制台窗口中,在 POST 请求上,我们看到
如果我们省略验证令牌,控制台窗口中会出现警告
其他 HTML 替换
当您对服务器有更精细的控制时,您几乎可以随心所欲地发明自己的令牌替换集。您甚至可以将 HTML 传递给不同的解析器。例如,我非常喜欢 Ruby on Rails 中支持的 Slim 语言模板。例如,在 Slim 语法中,登录 HTML 看起来像这样
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 解析器引擎并非易事。但是,我们可以轻松地在**我们的**Web 服务器中添加 Slim 到 HTML 的后处理解析器。
留待日后解决的问题
CSRF 和 AJAX
由于我们放置这个检查的位置,我们也会在 AJAX post/put/delete 请求上收到此警告,这可能是一个好主意。以下是我们的 AJAX 演示页面传入 CSRF 令牌的样子
<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 请求是否应该有一个验证令牌。如果你省略它,控制台将只发出警告。
HTTPS
现在的网站确实应该使用 HTTPS,但我将把这个问题留待日后解决,可能会另写一篇文章或作为本文的某个附录。
解码参数值
解码参数值可能会很好,例如,将“+”替换为空格,将“%xx”替换为相应的实际字符。
链接后处理
对 HTML 进行后处理是那些成熟的链式操作之一,并且已列入待办事项。
还有什么?
我确定还有其他可以做的事情!
结论
还有哪些主要问题需要解决?我犯了哪些可怕的错误?
这个想法是让 Web 服务器非常小。我总共有四个类(不包括扩展方法),整个代码略少于 650 行,包括注释。