用 C# 自己动手编写一个 Web 服务器,第三部分——路由

版权声明:所有博客文章除特殊声明外均为原创,允许转载,但要求注明出处。

路由(Routing)

上一篇文章 中,我们将 Web 服务器的功能拆分成一系列较小的中间件(Middleware),建立起一个灵活、可扩展的架构。但目前的中间件只提供了静态文件支持,还没有任何动态功能。

对于绝大多数现代 Web 服务器来说,路由(Routing)都是其中核心的部分。按照企业应用架构模式的分类,路由应该属于其中的“前端控制器”(Front controller),主要目的是将接收到的 HTTP 请求分发到相应的后端业务模块去处理。而分发规则主要是基于请求的信息(路径、HTTP方法、头部信息、Cookie等)。虽然总体思路是相似的,但各个语言或编程框架声明路由的方式还是相差很大。例如,Nodejs 框架 Express 要求你显式声明路由对应的方法:

app.get(url1, function(req, res) { ... });
app.post(url2, function(req, res) { ... });
app.all(url3, function(req, res) { ... });

Python 编程框架 Django 也有类似的要求,不过在代码结构上更加严格(所有路由必须在 urls.py 中声明)。另一方面,Django 倾向于使用路径来规划请求,因此 HTTP GET/POST 有时会用相同方法来处理(当然在处理时还是要区分的):

urlpatterns = [
    path(url1, views.view1, name='view1'),
    path(url2, views.view2, name='view2'),
    ...
]

显示声明的方式能够清晰的表达网站的设计意图,但每种地址都要声明,略显繁琐。另一方面,早期的 ASP.NET 和 PHP 直接将请求映射到服务器文件(.aspx/.php),这种方式无需路由,但现代 Web 设计通常认为这种 URL 不够友好。ASP.NET MVC 采取了另外一种思路,用一个默认的 url 格式(controller/action/{id})支持了大多数常规请求,当然也可以添加自定义的路由格式。我们今天就参考 ASP.NET MVC 的方式,为我们的 Web 服务器添加路由支持。当然了,我们并不打算完全实现 ASP.NET MVC 路由的所有功能,因此这里只实现 URL 匹配的部分。

代码

本文的示例代码已经全部放到 Github,每篇文章关联的代码放在独立的分支,方便读者参考。因此,要获取本文示例代码,请使用如下命令:

git clone https://github.com/shuhari/web-server-succinctly-example.git
git checkout -b  03-routing origin/03-routing 

实现

我们还是从接口开始。路由也是一个中间件(Middleware),所以我们首先修改程序入口点,把路由中间件添加到处理管线里面去:

        static void RegisterMiddlewares(IWebServerBuilder builder)
        {
            builder.Use(new HttpLog());
            // builder.Use(new BlockIp("::1", "127.0.0.1"));

            var routes = new Routing();
            RegisterRoutes(routes);
            builder.Use(routes);

            builder.Use(new StaticFile());
            ...
        }

        static void RegisterRoutes(Routing routes)
        {
            routes.MapRoute(name: "Default",
                url: "{controller}/{action}/{id}",
                defaults: new {controller = "Home", action = "Index", id = UrlParameter.Optional});
        }

你可以看到,我们声明路由格式的语法完全模仿了 ASP.NET MVC。当然,为了通过编译,我们还需要实现声明中用到的 UrlParameter:

public class UrlParameter
{
    public static readonly UrlParameter Optional = new UrlParameter();

    public static readonly UrlParameter Missing = new UrlParameter();
}

这里定义了两个特殊参数,Optional 表示路径中的可选参数(例如id);Missing 在后面解析路径的时候会用到,用来表示参数中缺失的部分。

为了保存路由中的参数变量,我们再声明一个辅助对象 RouteValueDictionary,这个接口同样是模拟 ASP.NET MVC 的。这里我偷一点小懒,因为参数基本上就是一个包含扩展功能的字典集合,所以直接从 BCL 继承可以省去一些力气:

public class RouteValueDictionary : Dictionary<string, object>
{
    public RouteValueDictionary Load(object values)
    {
        if (values != null)
        {
            foreach (var prop in values.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance))
            {
                this[prop.Name] = prop.GetValue(values);
            }
        }

        return this;
    }
}

RouteValueDictionary 需要从动态变量解析参数,这是因为默认的参数声明方式是基于 C# 动态对象语法的。完整的 RouteValueDictionary 应该还包含一些辅助方法用来在自身和 dynamic object 之间转换,不过我们暂时还用不到,这里就不写了。

接下来该实现 Routing 了。不过,Routing 需要包含多个用户定义的路由规则,我们首先还是把这些规则保存成独立的对象:

public class RouteEntry
{
    public RouteEntry(string name, string url, object defaults)
    {
        _name = name;
        _fragments = Parse(url, defaults);
    }

    private readonly string _name;

    private readonly RouteFragment[] _fragments;

    private RouteFragment[] Parse(string url, object defaults)
    {
        var defaultValues = new RouteValueDictionary().Load(defaults);
        return url.Split('/')
            .Select(x => new RouteFragment(x, defaultValues))
            .ToArray();
    }

    public RouteValueDictionary Match(HttpListenerRequest request)
    {
        // TODO: Extract url parameters from request
        ...
    }
}

RouteEntry——包含另一个辅助类 RouteFragment——实现略微有点长(其实也就是几十行左右)。这是因为 ASP.NET MVC 的路由规则并不能简单地用正则表达式来匹配。某些参数是可选的,这些可选参数会连带前面的路径分隔符(/)都是可选的。因此,除非我们把所有变量解析出来,否则简单的格式替换是行不通的。不过实现思路也并不复杂,大致如下:

  • 将路径分解为一系列片段(Fragment);
  • 从每个片段中解析出参数变量;
  • 将所有声明的变量,以及规则中定义的参数是否可选(Optional),以及对应的默认值记录下来。

匹配路由时的规则则大致为:

  • 将请求的路径同样分解为片段;
  • 由于我们已经知道每个参数是否可选,所以每个片段可以用正则表达式来匹配,只要在可选的部分加上?即可;
  • 对于所有定义的参数处理如下:
  • 如果请求路径中包含此变量,则使用请求路径中的值;
  • 如果请求路径中不包含此变量,但是有默认值,则使用默认值;
  • 如果请求路径中不包含此变量,但变量为可选(Optional),则跳过;
  • 如果请求路径中不包含此变量,且没有默认值、也没有声明为可选,则出错。

具体的处理代码基本上是单纯的字符串和正则表达式处理,为简洁起见这里就不列出了,需要的朋友请自行参考代码。

RouteEntry 已经到位,接下来 Routing 的实现就很简单了。Routing 需要将请求分派到对应的处理方法,还是按照 ASP.NET MVC 的处理方法,这些包含处理逻辑的类称为控制器(Controller):

    public abstract class Controller
    {
    }

然后我们就可以创建 Routing 的配置部分:

    public class Routing : IMiddleware
    {
        public Routing()
        {
            _entries = new List<RouteEntry>();
        }

        private List<RouteEntry> _entries;

        public Routing MapRoute(string name, string url, object defaults = null)
        {
            _entries.Add(new RouteEntry(name, url, defaults));
            return this;
        }

        ...
    }

当请求到来时,Routing 应该根据路由规则找到对应的控制器,并执行其中的方法:

public MiddlewareResult Execute(HttpListenerContext context)
{
    foreach (var entry in _entries)
    {
        var routeValues = entry.Match(context.Request);
        if (routeValues != null)
        {
            var controller = CreateController(routeValues);
            var actionMethod = GetActionMethod(controller, routeValues);
            var result = GetActionResult(controller, actionMethod, routeValues);
            context.Response.Status(200, result);

            return MiddlewareResult.Processed;
        }
    }

    return MiddlewareResult.Continue;
}

目前,我们只有一个程序集,框架(Web Framework)和应用(Web Application)并没有作明显的区分。因此,查找控制器的逻辑也简化为:查找程序集中所有 Controller 子类,按照控制器名称去匹配。我们知道,实际的框架会将此功能委托到 IControllerFactory 等其他接口,并允许依赖注入等机制,不过这里先使用最简单粗暴的方式来实现好了:

private IController CreateController(RouteValueDictionary routeValues)
{
    var controllerName = (string)routeValues["controller"];
    var className = char.ToUpper(controllerName[0]) + controllerName.Substring(1) + "Controller";
    foreach (var type in GetType().Assembly.GetExportedTypes())
    {
        if (type.Name == className && typeof(IController).IsAssignableFrom(type))
        {
            var instance = (IController) Activator.CreateInstance(type);
            return instance;
        }
    }
    throw new ArgumentException($"Controller {className} not found");
}

private MethodInfo GetActionMethod(IController controller, RouteValueDictionary routeValues)
{
    var controllerType = controller.GetType();
    string actionName = (string) routeValues["action"];
    actionName = char.ToUpper(actionName[0]) + actionName.Substring(1);
    var method = controller.GetType().GetMethod(actionName);
    if (method == null)
        throw new ArgumentException($"Controller {controllerType.Name} has no action method {actionName}");
    return method;
}

执行 Action Method 的处理同样做了一定的简化。我们知道控制器方法实际上应该返回 ActionResult,不过实现它意味着要引入一大堆子类,这个功能我们留给后面的部分好了。目前,我们直接让控制器方法返回 string,并将其作为返回的 HTML 内容。此外,这里还为方法参数提供了从路由引入的默认值:

private string GetActionResult(IController controller, MethodInfo method,
    RouteValueDictionary routeValues)
{
    var methodParams = method.GetParameters();
    var paramValues = new object[methodParams.Length];
    for (int i = 0; i < methodParams.Length; i++)
    {
        var routeValue = routeValues[methodParams[i].Name];
        var paramValue = Convert.ChangeType(routeValue, methodParams[i].ParameterType);
        paramValues[i] = paramValue;
    }

    var result = (string) method.Invoke(controller, paramValues);
    return result;
}

我们的 Routing 到此就实现好了。为了检查实际效果,我们再添加一个用于测试的控制器:

public class HomeController : Controller
{ 
    public string Index()
    {
        return "Index Page";
    }

    public string Details(int id)
    {
        return "Details of product " + id;
    }
}

运行程序,然后打开浏览器。你会发现请求下列地址均返回了预期的响应:

  • /
  • /home
  • /home/index
  • /details/1

这证明我们的路由规则被正确处理了。恭喜!现在你有了一个真正的动态服务器。

稍微变换一下参数,你会发现我们的实现还不是非常健壮——比如,请求 /details/xxx 就会显示一个错误。不过这也是预期中的,因为我们并没有添加很多代码去处理各种错误。另外,有全局的 Http500 兜底,某个请求错误并不会导致程序崩溃,所以也不是特别可怕。如果你愿意的话,可以扩展 Http500 以提供更完善的错误处理——比如显示一个自定义的错误页。

我们目前的实现也为控制器(Controller)提供了一个基本框架。你肯定想起了 MVC 架构,在 MVC 架构中,我们还缺失的一部分就是视图(View)了。(定义模型是应用程序的责任,我们这里不会讨论。)实现视图也并不困难,不过要证明视图是真正动态的、而不是写死的内容,我们还需要为 Web 服务器增加一些功能。所以在下一篇文章中,我们把视图放一放,先来实现对所有 Web 服务器来说都是非常重要的内容——Session。

系列文章