用 C# 自己动手编写一个 Web 服务器,第二部分——中间件
在 上一篇文章 我们创建了一个具有基本静态文件服务功能的 Web 服务器,但是还没有动态功能的支持。我们希望在这个程序的基础上进一步扩充,成为具有完整功能的动态 Web 服务器。不过先别忙着写代码,让我们从架构的层次上考虑一下 Web 框架应该是什么样的。
Web 处理管线
虽然不同的编程语言实现 Web 服务器的方式各不相同,不过看上一圈下来,你会发现大多数实现方式只是具体细节的差异,它们的核心思想是差不多的。这个核心思想中最关键的一点是:
Web 框架是一个处理管线。
这又是什么意思呢?想一想 Web 服务器要完成哪些工作,你会发现,某些任务几乎对于任何类型的服务器都是必须的,并且处理方式几乎是一成不变的:
- 日志记录;
- IP过滤;
- 静态文件支持;
- 缓存;
- HTTP压缩;
- HTTP协议解析;
- ...
而其他一些功能,虽然也是服务器必须的,但具体处理细节则视框架的实现方式有很大差别:
- Session;
- 用户验证和授权;
- 视图引擎;
- 路由;
- ...
我们希望将 Web 服务器本身、以及 Web 服务器所要支持的功能这两者从设计上解耦,让它们允许各自独立变化。这样的设计带来了众多可能:
- 每一个功能可以分解为小的、独立的组件,允许单独部署、测试和重用;
- Web 服务器可以在核心稳定不变的前提下,以类似插件的机制,灵活地启用或禁用各项功能,从而在功能和性能之间保持很好的平衡;
- 允许在单一Web 服务中承载多种应用(绝大多数现代 Web 服务器都有支持多种语言的插件);
- 允许将多个 Web 服务器连接起来,每种浏览器承载各自最擅长的部分(Apache/Nginx 作前端反向代理,后端用动态服务器处理业务是最普遍的模式);
- 在应用层面,可以通过配置或代码动态添加、修改或删除处理步骤,实现对处理过程的深度定制。
这种设计思想非常优秀,以至于目前绝大多数流行的 Web 服务器都是按照此思路设计的。感兴趣的朋友可以阅读 Wikipedia 条目 HTTP Pipeline 提供的诸多资料和链接。当然,在具体实现方法上,各个语言和框架还是有很多差异的,比如 JavaEE 架构中一般使用 Filter 或者自定义 Servlet;ASP.NET 中分为 HttpFilter 和 HttpModule,后续的 ASP.NET MVC 则提供了更多扩展点;而 Nodejs 中使用比较广泛的 Connect/Express 架构则一概称为中间件(Middleware)。我们这里的示例也采用了中间件(Middleware)的叫法,因为这似乎是最近大多数框架不成文的约定了。
说明:原书 写于 2015 年,可能由于成书较早的原因(当然也有可能是作者个人的风格),书中将各个步骤命名为 WorkflowItem,而并未采纳流行的 Web 框架中比较流行的叫法。我自己则更加喜欢 Middleware 的叫法,并且写法和原书差别较大。我并不认为自己的代码一定优于原书;如果你更喜欢原作者的风格,请自行下载阅读。
代码
本文的示例代码已经全部放到 Github,每篇文章关联的代码放在独立的分支,方便读者参考。因此,要获取本文示例代码,请使用如下命令:
git clone https://github.com/shuhari/web-server-succinctly-example.git
git checkout -b 02-middlewares origin/02-middlewares
实现
要实现一个 Web 处理管线并不困难:我们要做的就是创建一个 Middleware 的队列,依次调用它们即可。当然,某些中间件的性质是传递性的(处理完毕之后由下一个继续接手);其他一些则是中止性的(它们已经把活干完了,后面的人就没必要再插手了),我们的代码需要妥善处理这些情况。另外,考虑到中间件执行可能抛出异常,为保证服务器不至于崩溃,我们最好是创建一个全局异常处理钩子——Nodejs 把它也视为中间件,但是需要多传递一个异常参数。考虑到 C# 语言的特点,我们还是用接口的方式来声明它。首先定义中间件可能返回的结果:
public enum MiddlewareResult
{
Processed = 1,
Continue = 2,
}
在 Github 上的代码是包含注释的,但在文章中已经有了文字解释,为简洁起见,注释从代码中拿掉了。
接下来,定义中间件(以及错误处理钩子)的接口:
public interface IMiddleware
{
MiddlewareResult Execute(HttpListenerContext context);
}
public interface IExceptionHandler
{
void HandleException(HttpListenerContext context, Exception exp);
}
再定义一个执行管线的辅助类(MiddlewarePipeline),它的作用主要就是注册并依次执行各个中间件:
class MiddlewarePipeline
{
public MiddlewarePipeline()
{
_middlewares = new List<IMiddleware>();
}
private readonly List<IMiddleware> _middlewares;
private IExceptionHandler _exeptionHandler;
internal void Add(IMiddleware middleware)
{
_middlewares.Add(middleware);
}
internal void UnhandledException(IExceptionHandler handler)
{
_exeptionHandler = handler;
}
internal void Execute(HttpListenerContext context)
{
try
{
foreach (var middleware in _middlewares)
{
var result = middleware.Execute(context);
if (result == MiddlewareResult.Processed)
{
break;
}
else if (result == MiddlewareResult.Continue)
{
continue;
}
}
}
catch (Exception ex)
{
if (_exeptionHandler != null)
_exeptionHandler.HandleException(context, ex);
else
throw;
}
}
}
为了让应用程序可以自定义中间件的执行步骤,我们再定义一个配置性质的接口,允许程序自己决定使用哪些中间件:
public interface IWebServerBuilder
{
IWebServerBuilder Use(IMiddleware middleware);
IWebServerBuilder UnhandledException(IExceptionHandler handler);
}
回想一下,目前的 WebServer 类是自行处理静态文件的,现在我们可以把工作委托给上面实现的 WorkflowPipeline,WebServer 自身的工作就变得简单了(这里只列出有变化的部分,以免看不清重点):
public class WebServer : IWebServerBuilder
{
...
private readonly MiddlewarePipeline _pipeline;
public WebServer(int concurrentCount)
{
...
_pipeline = new MiddlewarePipeline();
}
public void Start()
{
_listener.Start();
Task.Run(async () =>
{
while (true)
{
_sem.WaitOne();
var context = await _listener.GetContextAsync();
_sem.Release();
_pipeline.Execute(context);
}
});
}
public IWebServerBuilder Use(IMiddleware middleware)
{
_pipeline.Add(middleware);
return this;
}
public IWebServerBuilder UnhandledException(IExceptionHandler handler)
{
_pipeline.UnhandledException(handler);
return this;
}
}
现在一切就绪,我们可以写几个中间件来验证架构了。按照一般 Web 应用的通用结构,我们的示例程序添加下列这些功能中间件:
- 记录 HTTP 请求日志(用 Console 模拟);
- 允许按照 IP 地址屏蔽请求(黑名单);
- 提供静态文件;
- 如果上述中间件都没有响应,则返回 HTTP 404;
- 最后,如果有中间件执行错误,则返回 HTTP 500。
程序入口类改成如下所示:
internal class Program
{
...
public static void Main(string[] args)
{
var server = new WebServer(concurrentCount);
RegisterMiddlewares(server);
...
}
static void RegisterMiddlewares(IWebServerBuilder builder)
{
builder.Use(new HttpLog());
// builder.Use(new BlockIp("::1", "127.0.0.1"));
builder.Use(new StaticFile());
builder.Use(new Http404());
builder.UnhandledException(new Http500());
}
}
出于示例目的,我们的中间件都非常简单,只要实现 IMiddleware 接口的一个方法即可。不过我们发现,所有 HTTP 错误的处理方法都是类似的,因此先实现一个公共的辅助方法;
public static class HttpUtil
{
public static HttpListenerResponse Status(this HttpListenerResponse response,
int statusCode, string description)
{
var messageBytes = Encoding.UTF8.GetBytes(description);
response.StatusCode = statusCode;
response.StatusDescription = description;
response.ContentLength64 = messageBytes.Length;
response.OutputStream.Write(messageBytes, 0, messageBytes.Length);
response.OutputStream.Close();
return response;
}
}
最后我们来看各个中间件的实现。HttpLog 将输入信息输出到控制台。真正的应用程序需要按照配置输出到日志,我们这里只是为了说明原理。但你可以看到,这个类已经和程序的其他部分解耦,因此要扩展它以支持日志并不困难,也不用担心影响到其他功能——这正是 Middleware 架构的强大之处。
public class HttpLog : IMiddleware
{
public MiddlewareResult Execute(HttpListenerContext context)
{
var request = context.Request;
var path = request.Url.LocalPath;
var clientIp = request.RemoteEndPoint.Address;
var method = request.HttpMethod;
Console.WriteLine("[{0:yyyy-MM-dd HH:mm:ss}] {1} {2} {3}",
DateTime.Now, clientIp, method, path);
return MiddlewareResult.Continue;
}
}
BlockIp 实现了类似黑名单的功能。如果你发现某个 IP 地址是攻击者,或者你就是不想让他(她)看你的网站,那么这是个很有用的功能——当然,在生产环境中可能在反向代理层面或更靠前的位置实现这个功能,性能会更好。
public class BlockIp : IMiddleware
{
public BlockIp(params string[] forbiddens)
{
_forbiddens = forbiddens;
}
private string[] _forbiddens;
public MiddlewareResult Execute(HttpListenerContext context)
{
var clientIp = context.Request.RemoteEndPoint.Address;
if (_forbiddens.Contains(clientIp.ToString()))
{
context.Response.Status(403, "Forbidden");
return MiddlewareResult.Processed;
}
return MiddlewareResult.Continue;
}
}
StaticFile 基本上就是把原来 WebServer 的静态文件处理部分搬过来了。当然,这个实现对于.html之外的文件类型肯定是有问题的。不过文件类型的查找繁琐且没有多大技术含量,这里就不再展开了。该类同样很容易扩展以支持其他文件类型。
public class BlockIp : IMiddleware
{
public BlockIp(params string[] forbiddens)
{
_forbiddens = forbiddens;
}
private string[] _forbiddens;
public MiddlewareResult Execute(HttpListenerContext context)
{
var clientIp = context.Request.RemoteEndPoint.Address;
if (_forbiddens.Contains(clientIp.ToString()))
{
context.Response.Status(403, "Forbidden");
return MiddlewareResult.Processed;
}
return MiddlewareResult.Continue;
}
}
有了上面写好的辅助方法,错误处理也非常简单明了:
public class Http404 : IMiddleware
{
public MiddlewareResult Execute(HttpListenerContext context)
{
context.Response.Status(404, "File Not Found");
return MiddlewareResult.Processed;
}
}
public class Http500 : IExceptionHandler
{
public void HandleException(HttpListenerContext context, Exception exp)
{
Console.WriteLine(exp.Message);
Console.WriteLine(exp.StackTrace);
context.Response.Status(500, "Internal Server Error");
}
}
再次说明,上述代码目的是为了说明实现原理,在健壮性上肯定没有达到产品代码的级别——但我并不希望太多错误处理模糊了文章的焦点。如果你需要实现生产级别的服务器,那么上述处理代码都需要仔细设计,以支持各种可能的场景。不过到这里,我们已经设计出了一个灵活的系统架构,框架的使用者可以简单的添加各种自定义的功能。
当然,我们的代码到这里还是没有可以支持动态服务的功能。这也是我们下一篇文章的主题:路由。