ASP.NET Core路由中间件[1]: 终结点与URL的映射
借助路由系统提供的请求URL模式与对应终结点(Endpoint)之间的映射关系,我们可以将具有相同URL模式的请求分发给应用的终结点进行处理。ASP.NET Core的路由是通过EndpointRoutingMiddleware和EndpointMiddleware这两个中间件协作完成的,它们在ASP.NET Core平台上具有举足轻重的地位,因为ASP.NET Core MVC框架就建立在这个中间件之上。可以将一个ASP.NET Core应用视为一组终结点的组合,所谓的终结点可以理解为能够通过HTTP请求的形式访问的远程服务。每个终结点通过RequestDelegate对象来处理路由过来的请求。ASP.NET Core的路由是通过EndpointRoutingMiddleware和EndpointMiddleware这两个中间件来实现的,这两个中间件类型都定义在NuGet包“Microsoft.AspNetCore.Routing”中。为了使读者对实现在RouterMiddleware的路由功能有一个大体的认识,下面先演示几个简单的实例。[更多关于ASP.NET Core的文章请点这里]
目录
一、路由注册
二、设置内联约束
三、默认路由参数
四、特殊的路由参数
一、路由注册
我们演示的这个ASP.NET Core应用是一个简易版的天气预报站点。如果用户希望获取某个城市在未来N天之内的天气信息,他可以直接利用浏览器发送一个GET请求并将对应城市(采用电话区号表示)和天数设置在URL中。如下图所示,为了得到成都未来两天的天气信息,我们将发送请求的路径设置为“weather/028/2”。对于采用路径“weather/0512/4”的请求,返回的自然就是苏州未来4天的天气信息。
为了开发这个简单的应用,我们定义了如下所示的WeatherReport类型,表示某个城市在某段时间范围内的天气。如下面的代码片段所示,我们还定义了另一个WeatherInfo类型,表示具体某一天的天气。简单起见,我们让WeatherInfo对象只携带基本天气状况和气温区间的信息。创建一个WeatherReport对象时,我们会随机生成这些天气信息。
public class WeatherReport { private static string[] _conditions = new string[] { "晴", "多云", "小雨" }; private static Random _random = new Random(); public string City { get; } public IDictionary<DateTime, WeatherInfo> WeatherInfos { get; } public WeatherReport(string city, int days) { City = city; WeatherInfos = new Dictionary<DateTime, WeatherInfo>(); for (int i = 0; i < days; i++) { WeatherInfos[DateTime.Today.AddDays(i + 1)] = new WeatherInfo { Condition = _conditions[_random.Next(0, 2)], HighTemperature = _random.Next(20, 30), LowTemperature = _random.Next(10, 20) }; } } public WeatherReport(string city, DateTime date) { City = city; WeatherInfos = new Dictionary<DateTime, WeatherInfo> { [date] = new WeatherInfo { Condition = _conditions[_random.Next(0, 2)], HighTemperature = _random.Next(20, 30), LowTemperature = _random.Next(10, 20) } }; } public class WeatherInfo { public string Condition { get; set; } public double HighTemperature { get; set; } public double LowTemperature { get; set; } } }
由于用于处理请求的处理器最终体现为一个RequestDelegate对象,所以我们定义了如下一个与这个委托类型具有一致声明的WeatherForecast方法来处理对应的请求。如下面的代码片段所示,我们在这个方法中直接调用HttpContext的GetRouteData扩展方法提取RoutingMiddleware中间件在路由解析过程中设置的路由参数。GetRouteData扩展方法返回的是一个具有字典结构的对象,它的Key和Value分别代表路由参数的名称与值,通过预先定义的参数名(city和days)可以得到目标城市和预报天数。
public class Program { private static Dictionary<string, string> _cities = new Dictionary<string, string> { ["010"] = "北京", ["028"] = "成都", ["0512"] = "苏州" }; public static async Task WeatherForecast(HttpContext context) { var city = (string)context.GetRouteData().Values["city"]; city = _cities[city]; int days = int.Parse(context.GetRouteData().Values["days"].ToString()); var report = new WeatherReport(city, days); await RendWeatherAsync(context, report); } private static async Task RendWeatherAsync(HttpContext context, WeatherReport report) { context.Response.ContentType = "text/html;charset=utf-8"; await context.Response.WriteAsync("<html><head><title>Weather</title></head><body>"); await context.Response.WriteAsync($"<h3>{report.city}</h3>"); foreach (var it in report.WeatherInfos) { await context.Response.WriteAsync($"{it.Key.ToString("yyyy-MM-dd")}:"); await context.Response.WriteAsync($"{it.Value.Condition}({ it.Value.LowTemperature}℃ ~ { it.Value.HighTemperature}℃)< br />< br /> "); } await context.Response.WriteAsync("</body></html>"); } ... }
有了这两个核心参数之后,我们可以据此生成一个WeatherReport对象,并将它携带的天气信息以一个HTML文档的形式响应给客户端,图15-1就是这个HTML文档在浏览器上的呈现效果。由于目标城市最初以电话区号的形式体现,所以在呈现天气信息的过程中我们还会根据区号获取具体城市的名称。简单起见,我们利用一个简单的字典来维护区号和城市之间的关系,并且只存储了3个城市而已。
下面完成所需的路由注册工作。如下面的代码片段所示,我们调用IApplicationBuilder的UseRouting方法和UseEndpoints方法分别完成针对EndpointRoutingMiddleware与EndpointMiddleware这两个终结点的注册。由于它们在进行路由解析过程中需要使用一些服务,所以可以调用IServiceCollection的AddRouting扩展方法来对它们进行注册。
public class Program { public static void Main() { Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(builder => builder .ConfigureServices(svcs => svcs.AddRouting()) .Configure(app => app .UseRouting() .UseEndpoints(endpoints=> endpoints.MapGet("weather/{city}/{days}", WeatherForecast)))) .Build() .Run(); } }
UseEndpoints方法提供了一个Action<IEndpointRouteBuilder>类型的参数,我们利用这个参数调用IEndpointRouteBuilder的MapGet方法提供了一个路由模板与对应处理器之间的映射。我们指定的路径模板为“weather/{city}/{days}”,其中携带两个路由参数({city}和{days}),分别代表获取天气预报的目标城市和天数。由于针对天气请求的处理实现在WeatherForecast方法中,所以将指向这个方法的RequestDelegate对象作为第二个参数。MapGet的后缀“Get”表示HTTP方法,这意味着与指定路由模板的模式相匹配的GET请求才会被路由到WeatherForecast方法对应的终结点。
二、设置内联约束
上面的演示实例注册的路由模板中定义了两个参数({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}}$))。由于路由模板在被解析时会将{value}这样的字符理解为路由参数,如果约束表达式需要使用字符“{}”(如正则表达式^0[1-9]{2,3}$)),就需要采用“{{}}”进行转义。而路由参数{days}则应用了两个约束:第一个是针对数据类型的约束(:int),它要求参数值必须是一个整数;第二个是针对区间的约束(:range(1,4)),意味着我们的应用最多只提供未来4天的天气。
public class Program { public static void Main() { var template = @"weather/{city:regex(^0\d{{2,3}}$)}/{days:int:range(1,4)}"; Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(builder => builder .ConfigureServices(svcs => svcs.AddRouting()) .Configure(app => app .UseRouting() .UseEndpoints(routes => routes.MapGet(template, WeatherForecast)))) .Build() .Run(); } ... }
如果在注册路由时应用了约束,那么RoutingMiddleware中间件在进行路由解析时除了要求请求路径必须与路由模板具有相同的模式,还要求携带的数据满足对应路由参数的约束条件。如果不能同时满足这两个条件,RoutingMiddleware中间件将无法选择一个终结点来处理当前请求,在此情况下它会将请求直接递交给后续中间件进行处理。对于我们演示的这个实例来说,如果提供的是一个不合法的区号(1024)和预报天数(5),那么客户端都将得到下图所示的状态码为“404 Not Found”的响应。
三、默认路由参数
路由注册时提供的路由模板(如“weather/{city}/{days}”)可以包含静态的字符(如weather),也可以包含动态的参数(如{city}和{days}),我们将后者称为路由参数。并非每个路由参数都是必需的,有的路由参数是默认的。还是以上面演示的实例来说,我们可以采用如下方式在路由参数名后面添加一个问号(?)将原本必需的路由参数变成可以默认的。默认的路由参数只能出现在路由模板尾部,这个应该不难理解。
public class Program { public static void Main() { var template = "weather/{city?}/{days?}"; Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(builder => builder .ConfigureServices(svcs => svcs.AddRouting()) .Configure(app => app .UseRouting() .UseEndpoints(routes => routes.MapGet(template, WeatherForecast)))) .Build() .Run(); } ... }
既然路由变量占据的部分路径是可以默认的,那么即使请求的URL不具有对应的内容(如“weather”和“weather/010”),它与路由规则也是匹配的,但此时在路由参数字典中是找不到它们的。由于表示目标城市和预测天数的两个路由参数都是默认的,所以需要对处理请求的WeatherForecast方法做相应的改动。下面的代码片段表明:如果请求URL为了显式提供对应参数的数据,那么它们的默认值分别为010(北京)和4(天),也就是说,应用默认提供北京未来4天的天气。
public class Program { public static async Task WeatherForecast(HttpContext context) { var routeValues = context.GetRouteData().Values; var city = routeValues.TryGetValue("city", out var v1) ? (string)v1 : "010"; city = _cities[city]; var days = routeValues.TryGetValue("days", out var v2) ? int.Parse(v2.ToString()) : 4; var report = new WeatherReport(city, days); await RendWeatherAsync(context, report); } ... }
针对上述改动,如果希望获取北京未来4天的天气状况,我们可以采用下图所示的3种URL(“weather”、“weather/010”和“weather/010/4”),它们是完全等效的。
上面的程序相当于在进行请求处理时给予了默认路由参数一个默认值,实际上,路由参数默认值的设置还有一种更简单的方式,那就是按照如下所示的方式直接将默认值定义在路由模板中。如果采用这样的路由注册方式,针对WeatherForecast方法的改动就完全没有必要。
public class Program { public static void Main() { var template = "weather/{city=010}/{days=4}"; Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(builder => builder .ConfigureServices(svcs => svcs.AddRouting()) .Configure(app => app .UseRouting() .UseEndpoints(routes => routes.MapGet(template, WeatherForecast)))) .Build() .Run(); } ... }
四、特殊的路由参数
一个URL可以通过分隔符“/”划分为多个路径分段(Segment),路由模板中定义的路由参数一般来说会占据某个独立的分段(如“weather/{city}/{days}”)。但也有例外情况,我们既可以在一个单独的路径分段中定义多个路由参数,也可以让一个路由参数跨越多个连续的路径分段。
下面先介绍在一个独立的路径分段中定义多个路由参数的情况。同样以前面演示的获取天气预报的路径为例,假设设计一种路径模式来获取某个城市某一天的天气信息,如“/weather/010/2019.11.11”这样一个URL可以获取北京在2019年11月11日的天气,那么路由模板为“/weather/{city}/{year}.{month}.{day}”。
public class Program { public static void Main() { var template = "weather/{city}/{year}.{month}.{day}"; Host.CreateDefaultBuilder().ConfigureWebHostDefaults(builder => builder .ConfigureServices(svcs => svcs.AddRouting()) .Configure(app => app.UseRouter(builder => builder.MapGet(template, WeatherForecast)))) .Build() .Run(); } public static async Task WeatherForecast(HttpContext context) { var values = context.GetRouteData().Values; var city = values["city"].ToString(); city = _cities[city]; int year = int.Parse(values["year"].ToString()); int month = int.Parse(values["month"].ToString()); int day = int.Parse(values["day"].ToString()); var report = new WeatherReport(city, new DateTime(year, month, day)); await RendWeatherAsync(context, report); } ... }
由于URL采用了新的设计,所以我们按照如上形式对相关程序进行了相应的修改。现在我们采用“/weather/{city}/{yyyy}.{mm}.{dd}”这样的URL,就可以获取某个城市指定日期的天气。如下图所示,我们采用请求路径“/weather/010/2019.11.11”可以获取北京在2019年11月11日的天气。
对于上面设计的这个URL来说,我们采用“.”作为日期分隔符,如果采用“/”作为日期分隔符(如2019/11/11),这个路由默认应该如何定义?由于“/”也是路径分隔符,如果表示日期的路由变量也采用相同的分隔符,就意味着同一个路由参数跨越了多个路径分段,我们只能采用定义“通配符”的形式来达到这个目的。通配符路由参数采用{*variable}或者{**variable}的形式,星号(*)表示路径“余下的部分”,所以这样的路由参数只能出现在模板的尾端。对我们的实例来说,路由模板可以定义成“/weather/{city}/{*date}”。
public class Program { public static void Main() { var template = "weather/{city}/{*date}"; Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(builder => builder .ConfigureServices(svcs => svcs.AddRouting()) .Configure(app => app .UseRouting() .UseEndpoints(routes => routes.MapGet(template, WeatherForecast)))) .Build() .Run(); } public static async Task WeatherForecast(HttpContext context) { var values = context.GetRouteData().Values; var city = values["city"].ToString(); city = _cities[city]; var date = DateTime.ParseExact(values["date"].ToString(), "yyyy/MM/dd", CultureInfo.InvariantCulture); var report = new WeatherReport(city, date); await RendWeatherAsync(context, report); } ... }
我们可以对程序做如上修改来使用新的URL模板(“/weather/{city}/{*date}”)。为了得到北京在2019年11月11日的天气,请求的URL可以替换成“/weather/010/2019/11/11”,返回的天气信息如下图所示。
ASP.NET Core路由中间件[1]: 终结点与URL的映射
ASP.NET Core路由中间件[2]: 路由模式
ASP.NET Core路由中间件[3]: 终结点
ASP.NET Core路由中间件[4]: EndpointRoutingMiddleware和EndpointMiddleware
ASP.NET Core路由中间件[5]: 路由约束