首页 > 代码库 > 注册URL模式与HttpHandler的映射关系

注册URL模式与HttpHandler的映射关系

注册URL模式与HttpHandler的映射关系

ASP.NET Core的路由是通过一个类型为RouterMiddleware的中间件来实现的。如果我们将最终处理HTTP请求的组件称为HttpHandler,那么RouterMiddleware中间件的意义在于实现请求路径与对应HttpHandler之间的映射关系。对于传递给RouterMiddleware中间件的每一个请求,它会通过分析请求URL的模式并选择并提取对应的HttpHandler来处理该请求。除此之外,请求的URL还会携带相应参数,该中间件在进行路由解析过程中还会根据生成相应的路由参数提供给处理该请求的Handler。为了让读者朋友们对实现在RouterMiddleware的路由功能具有一个大体的认识,我们照例先来演示几个简单的实例。[本文已经同步到《ASP.NET Core框架揭秘》之中]

目录
一、注册请求路径与HttpHandler之间的映射
二、设置内联约束
三、为路由参数设置默认值
四、特殊的路由参数

一、注册请求路径与HttpHandler之间的映射

ASP.NET Core针对请求的处理总是在一个通过HttpContext对象表示的上下文中进行,所以上面我们所说的HttpHandler从编程的角度来讲体现为一个RequestDelegate的委托对象,因此所谓的“路由注册”就是注册一组具有相同默认的请求路径与对应RequestDelegate之间的映射关系。接下来我们就同一个简单的实例来演示这样的映射关系是如何通过注册RouterMiddleware中间件的方式来完成的。

我们演示的这个ASP.NET Core应用是一个简易版的天气预报站点。如果用户希望获取某个城市在未来N天之内的天气信息,他可以直接利用浏览器发送一个GET请求并将对应城市(采用电话区号表示)和天数设置在URL中。如下图所示,为了得到成都未来两天的天气信息,我们发送请求采用的路径为“weather/028/2”。对于路径“weather/0512/4”的请求,返回的自然就是苏州未来4天的添加信息。

技术分享

为了实现这个简单的应用,我们定义如下一个名为WeatherReport的类型表示某个城市在某段时间范围类的天气。如下面的代码片段所示,我们定义了另一个名为WeatherInfo的类型来表示具体某一天的天气。简单起见,我们让这个WeatherInfo对象只携带基本添加状况和气温区间的信息。当我们创建一个WeatherReport对象的时候,我们会随机生成这些天气信息。

   1: public class WeatherReport
   2: {
   3:     private static string[]     _conditions = new string[] { "晴", "多云", "小雨" };
   4:     private static Random       _random = new Random();
   5:  
   6:     public string                                 City { get; }
   7:     public IDictionary<DateTime, WeatherInfo>     WeatherInfos { get; }
   8:  
   9:     public WeatherReport(string city, int days)
  10:     {
  11:         this.City = city;
  12:         this.WeatherInfos = new Dictionary<DateTime, WeatherInfo>();
  13:         for (int i = 0; i < days; i++)
  14:         {
  15:             this.WeatherInfos[DateTime.Today.AddDays(i + 1)] = new WeatherInfo
  16:             {
  17:                 Condition         = _conditions[_random.Next(0, 2)],
  18:                 HighTemperature   = _random.Next(20, 30),
  19:                 LowTemperature    = _random.Next(10, 20)
  20:             };
  21:         }
  22:     }
  23:  
  24:     public WeatherReport(string city, DateTime date)
  25:     {
  26:         this.City = city;
  27:         this.WeatherInfos = new Dictionary<DateTime, WeatherInfo>
  28:         {
  29:             [date] = new WeatherInfo
  30:             {
  31:                 Condition          = _conditions[_random.Next(0, 2)],
  32:                 HighTemperature    = _random.Next(20, 30),
  33:                 LowTemperature     = _random.Next(10, 20)
  34:             }
  35:         };
  36:     }
  37:  
  38:     public class WeatherInfo
  39:     {
  40:         public string Condition { get; set; }
  41:         public double HighTemperature { get; set; }
  42:         public double LowTemperature { get; set; }
  43:     }
  44: }

我们说最终用于处理请求的HttpHandler最终体现为一个类型为RequestDelegate的委托对象,为此我们定义了如下一个与这个委托类型具有一致声明的方法WeatherForecast来处理针对天气的请求。如下面的代码片段所示,我们在这个方法中直接调用HttpContext的扩展方法GetRouteData得到RouterMiddleware中间件在路由解析过程中得到的路由参数。这个GetRouteData方法返回的是一个具有字典结构的对象,它的Key和Value分别代表路由参数的名称和值,我们通过预先定义的参数名(“city”和“days”)得到目标城市和预报天数。

   1: public class Program
   2: {
   3:     private static Dictionary<string, string> _cities = new Dictionary<string, string>
   4:     {
   5:         ["010"]  = "北京",
   6:         ["028"]  = "成都",
   7:         ["0512"] = "苏州"
   8:     };
   9:  
  10:     public static async Task WeatherForecast(HttpContext context)
  11:     {
  12:         string city = (string)context.GetRouteData().Values["city"]; 
  13:         city = _cities[city];
  14:         int days = int.Parse(context.GetRouteData().Values["days"].ToString());
  15:         WeatherReport report = new WeatherReport(city, days);
  16:  
  17:         context.Response.ContentType = "text/html";
  18:         await context.Response.WriteAsync("<html><head><title>Weather</title></head><body>");
  19:         await context.Response.WriteAsync($"<h3>{city}</h3>");
  20:         foreach (var it in report.WeatherInfos)
  21:         {
  22:             await context.Response.WriteAsync($"{it.Key.ToString("yyyy-MM-dd")}:");
  23:             await context.Response.WriteAsync($"{it.Value.Condition}({it.Value.LowTemperature}℃ ~ {it.Value.HighTemperature}℃)<br/><br/>");
  24:         }
  25:         await context.Response.WriteAsync("</body></html>");
  26:     }
  27:     …
  28: }

有了这两个核心参数之后,我们据此生成一个WeatherReport对象,并将它携带的天气信息以一个HTML文档的形式响应给客户端,图1所示就是这个HTML文档在浏览器上的呈现效果。由于目标城市最初以电话区号的形式体现,在呈现天气信息的过程中我们还会根据区号获取具体城市名称,简单起见,我们利用一个简单的字典来保存区号和城市之间的关系,并且只存储了三个城市而已。

接下来我们来完成所需的路由注册工作,实际上就是注册RouterMiddleware中间件。由于这各中间件定义在“Microsoft.AspNetCore.Routing”这个NuGet包中,所以我们需要添加对应的依赖。如下面的代码片段所示,针对RouterMiddleware中间件的注册实现在ApplicationBuilder的扩展方法UseRouter中。由于RouterMiddleware中间件在进行路由解析的过程中需要使用到一些服务,我们调用WebHostBuilder的ConfigureServices方法注册的就是这些服务。具体来说,这些与路由相关的服务是通过调用ServiceCollection的扩展方法AddRouting实现的。

   1: public class Program
   2: {    
   3:     public static void Main()
   4:     {
   5:         new WebHostBuilder()
   6:             .UseKestrel()
   7:             .ConfigureServices(svcs => svcs.AddRouting())
   8:             .Configure(app => app.UseRouter(builder => builder.MapGet("weather/{city}/{days}", WeatherForecast)))
   9:             .Build()
  10:             .Run();
  11:     }
  12:     …
  13: }

RouterMiddleware中间件针对路由的解析依赖于一个名为Router的对象,对应的接口为IRouter。我们在程序中会先根据ApplicationBuilder对象创建一个RouteBuilder对象,并利用后者来创建这个Router。我们说路由注册从本质上体现为注册某种URL模式与一个RequestDelegate对象之间的映射,这个映射关系的建立是通过调用RouteBuilder的MapGet方法的调用。MapGet方法具有两个参数,第一个参数代表映射的URL模板,后者是处理请求的RequestDelegate对象。我们指定的URL模板为“weather/{city}/{days}”,其中携带两个路由参数({city}和{days}),我们知道它代表获取天气预报的目标城市和天数。由于针对天气请求的处理实现在我们定义的WeatherReport方法中,我们将指向这个方法的RequestDelegate对象作为第二个参数。

二、设置内联约束

在上面进行路由注册的实例中,我们在注册的URL模板中定义了两个参数({city}和{days})来分别代表获取天气预报的目标城市对应的区号和天数。区号应该具有一定的格式(以零开始的3-4位数字),而天数除了必须是一个整数之外,还应该具有一定的范围。由于我们在注册的时候并没有为这个两个路由参数的取值做任何的约束,所以请求URL携带的任何字符都是有效的。而处理请求的WeatherForecast方法也并没有对提取的数据做任何的验证,所以在执行过程中会直接抛出异常。如下图所示,由于请求URL(“/weather/0512/iv”)指定了天数不合法,所有客户端接收到一个状态为“500 Internal Server Error”的响应。

技术分享

为了确保路由参数数值的有效性,我们在进行路由注册的时候可以采用内联(Inline)的方式直接将相应的约束规则定义在路由模板中。ASP.NET Core针对我们常用的验证规则定义了相应的约束表达式,我们可以根据需要为某个路由参数指定一个或者多个约束表达式。

如下面的代码片段所示,为了确保URL携带的是合法的区号,我们为路由参数{city}应用了一个针对正则表达式的约束(:regex(^0[1-9]{{2,3}}$))。由于路由模板在被解析的时候会将“{…}”这样的字符理解为路由参数,如果约束表达式需要使用“{}”字符(比如正则表达式“^0[1-9]{2,3}$)”),需要采用“{{}}”进行转义。至于另一个路由参数{days}则应用了两个约束,第一个是针对数据类型的约束(:int),它要求参数值必须是一个整数。另一个是针对区间的约束(:range(1,4)),意味着我们的应用最多只提供未来4天的天气。

   1: string template = @"weather/{city:regex(^0\d{{2,3}}$)}/{days:int:range(1,4)}";
   2: new WebHostBuilder()
   3:     .UseKestrel()
   4:     .ConfigureServices(svcs => svcs.AddRouting())
   5:     .Configure(app => app.UseRouter(builder=> builder.MapGet(template, WeatherForecast)))
   6:     .Build()
   7:     .Run();

如果我们在注册路由的时候应用了约束,那么当RouterMiddleware中间件在进行路由解析的时候除了要求请求路径必须与路由模板具有相同的模式,同时还要求携带的数据满足对应路由参数的约束条件。如果不能同时满足这两个条件,RouterMiddleware中间件将无法选择一个RequestDelegate对象来处理当前请求,在此情况下它将直接将请求递交给后续的中间件进行处理。对于我们演示的这个实例来说,如果我们提供一个不合法的区号(1014)和预报天数(5),客户端都将得到一个状态码为“404 Not Found”的响应。

技术分享

三、为路由参数设置默认值

路由注册时提供的路由模板(比如“Weather/{city}/{days}”)可以包含静态的字符(比如“weather”),也可以包括动态的参数(比如{city}和{days}),我们将它们成为路由参数。并非每个路由参数都是必需的(要求路由参数的值必需存在请求路径中),有的路由参数是可以缺省的。还是以上面演示的实例来说,我们可以采用如下的方式在路由参数名后面添加一个问号(“?”),原本必需的路由参数变成了可以缺省的。可缺省的路由参数只能出现在路由模板尾部,这个应该不难理解。

   1: string template = "weather/{city?}/{days?}";
   2: new WebHostBuilder()
   3:     .UseKestrel()
   4:     .ConfigureServices(svcs => svcs.AddRouting())
   5:     .Configure(app => app.UseRouter(builder=> builder.MapGet(template, WeatherForecast)))
   6:     .Build()
   7:     .Run();

既然可以路由变量占据的部分路径是可以缺省的,那么意味即使请求的URL不具有对应的内容(比如“weather”和“weather/010”),在进行路由解析的时候同样该请求与路由规则相匹配,但是在最终的路由参数字典中将找不到它们。由于表示目标城市和预测天数的两个路由参数都是可缺省的,我们需要对处理请求的WeatherForecast方法做作相应的改动。下面的代码片段表明如果请求URL为显式提供对应参数的数据,它们的默认值分别为“010”(北京)和4(天),也就是说应用默认提供北京地区未来四天的天气。

   1: public static async Task WeatherForecast(HttpContext context)
   2: {
   3:     object rawCity;
   4:     object rawDays;
   5:     var values = context.GetRouteData().Values;
   6:     string city = values.TryGetValue("city", out rawCity) ? rawCity.ToString() : "010";
   7:     int days = values.TryGetValue("days", out rawDays) ? int.Parse(rawDays.ToString()) : 4;     
   8:                    
   9:     city = _cities[city];
  10:     WeatherReport report = new WeatherReport(city, days);
  11:     …
  12: }

针对上述的改动,如果希望获取北京未来四天的天气状况,我们可以采用如下图所示的三种URL(“weather”和“weather/010”和“weather/010/4”),它们都是完全等效的。

技术分享

上面我们的程序相当于是在进行请求处理的时候给予了可缺省路由参数一个默认值,实际上路由参数默认值得设置还具有一种更简单的方式,那就是按照如下所示的方式直接将默认值定义在路由模板中。如果采用这样的路由注册方式,我们针对WeatherForecast方法的改动就完全没有必要了。

   1: string template = "weather/{city=010}/{days=4}";
   2: new WebHostBuilder()
   3:     .UseKestrel()
   4:     .ConfigureServices(svcs => svcs.AddRouting())
   5:     .Configure(app =>app.UseRouter(builder=>builder.MapGet(template, WeatherForecast)))
   6:     .Build()
   7:     .Run();

四、特殊的路由参数

一个URL可以通过分隔符“/”划分为多个路径分段(Segment),路由模板中定义的路由参数一般来说会占据某个独立的分段(比如“weather/{city}/{days}”)。不过也有特例,我们即可以在一个单独的路径分段中定义多个路由参数,同样也可以让一个路由参数跨越对个连续的路径分段。

我们先来介绍在一个独立的路径分段中定义多个路由参数的情况。同样以我们演示的获取天气预报的URL为例,假设我们设计一种URL来获取某个城市某一天的天气信息,比如“/weather/010/2016.11.11”这样一个URL可以获取北京地区在2016年双11那天的天气,那么路由模板为“/weather/{city}/{year}.{month}.{day}”。

   1: string tempalte = "weather/{city}/{year}.{month}.{day}";
   2: new WebHostBuilder()
   3:     .UseKestrel()
   4:     .ConfigureServices(svcs => svcs.AddRouting())
   5:     .Configure(app => app.UseRouter(builder=>builder.MapGet(tempalte, WeatherForecast)))
   6:     .Build()
   7:     .Run();
   8:  
   9: public static async Task WeatherForecast(HttpContext context)
  10: {
  11:     var values     = context.GetRouteData().Values;
  12:     string city    = values["city"].ToString();
  13:     city           = _cities[city];
  14:     int year       = int.Parse(values["year"].ToString());
  15:     int month      = int.Parse(values["month"].ToString());
  16:     int day        = int.Parse(values["day"].ToString());
  17:  
  18:     WeatherReport report = new WeatherReport(city, new DateTime(year,month,day));
  19:     …
  20: }

由于URL采用了新的设计,所以我们按照如上的形式对相关的程序进行了相应的修改。现在我们采用匹配的URL(比如“/weather/010/2016.11.11”)就可以获取到某个城市指定日期的天气。

技术分享

对于上面设计的这个URL来说,我们采用“.”作为日期分隔符,如果我们采用“/”作为日期分隔符(比如“2016/11/11”),这个路由默认应该如何定义呢?由于“/”同时也是URL得路径分隔符,如果表示日期的路由变量也采用相同的分隔符,意味着同一个路由参数跨越了多个路径分段,我们只能定义“通配符”路由参数的形式来达到这个目的。通配符路由参数采用“{*variable}”这样的形式,星号(“*”)表示路径“余下的部分”,所以这样的路由参数只能出现在模板的尾端。对我们的实例来说,路由模板可以定义成“/weather/{city}/{*date}”。

   1: new WebHostBuilder()
   2:     .UseKestrel()
   3:     .ConfigureServices(svcs => svcs.AddRouting())
   4:     .Configure(app => {
   5:         string tempalte = "weather/{city}/{*date}";
   6:         IRouter router  = new RouteBuilder(app).MapGet(tempalte, WeatherForecast).Build();
   7:         app.UseRouter(router);
   8:     })
   9:     .Build()
  10:     .Run();
  11:  
  12: public static async Task WeatherForecast(HttpContext context)
  13: {
  14:     var values      = context.GetRouteData().Values;
  15:     string city     = values["city"].ToString();
  16:     city            = _cities[city];
  17:     DateTime date   = DateTime.ParseExact(values["date"].ToString(), "yyyy/MM/dd", 
  18:     CultureInfo.InvariantCulture);
  19:     WeatherReport report = new WeatherReport(city, date);
  20:     …
  21: }

我们可以对程序做如上的修改来使用新的URL模板(“/weather/{city}/{*date}”)。这样为了得到如上图所示的北京在2016年11月11日的天气,请求的URL可以替换成“/weather/010/2016/11/11”。

注册URL模式与HttpHandler的映射关系