用 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。