用 C# 自己动手编写一个 Web 服务器,第二部分——中间件

1,637 阅读9分钟

上一篇文章 我们创建了一个具有基本静态文件服务功能的 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");
    }
}

再次说明,上述代码目的是为了说明实现原理,在健壮性上肯定没有达到产品代码的级别——但我并不希望太多错误处理模糊了文章的焦点。如果你需要实现生产级别的服务器,那么上述处理代码都需要仔细设计,以支持各种可能的场景。不过到这里,我们已经设计出了一个灵活的系统架构,框架的使用者可以简单的添加各种自定义的功能。

当然,我们的代码到这里还是没有可以支持动态服务的功能。这也是我们下一篇文章的主题:路由。

系列文章