藉助路由系統提供的請求URL模式與對應終結點之間的對映關係,我們可以將具有相同URL模式的請求分發給與之匹配的終結點進行處理。ASP.NET的路由是通過EndpointRoutingMiddleware和EndpointMiddleware這兩個中介軟體共同作業完成的,它們在ASP.NET平臺上具有舉足輕重的地位,MVC和gRPC框架,Dapr的Actor和釋出訂閱程式設計模式都建立在路由系統之上。Minimal API更是將提升到了前所未有的高度,是我們直接在路由系統基礎上定義REST API。(本篇提供的範例已經彙總到《ASP.NET Core 6框架揭祕-範例演示版》)
[S2001]註冊路由終結點 (原始碼)
[S2002]以內聯方式設定路由引數的約束(原始碼)
[S2003]定義可預設的路由引數(原始碼)
[S2004]為路由引數指定預設值(原始碼)
[S2005]一個路徑分段定義多個路由引數(原始碼)
[S2006]一個路由引數跨越多個路徑分段(原始碼)
[S2007]主機名系結(原始碼)
[S2008]將終結點處理定義為任意型別的委託(原始碼)
[S2009]IResult 的應用(原始碼)
我們演示的這個ASP.NET應用是一個簡易版的天氣預報站點。伺服器端利用註冊的一個終結點來提供某個城市在未來N天之內的天氣資訊,對應城市(採用電話區號表示)和天數直接至於請求URL的路徑中。如圖1所示,為了得到成都未來兩天的天氣資訊,我們將傳送請求的路徑設定為「weather/028/2」。路徑為「weather/0512/4」的請求返回就是蘇州未來4天的天氣資訊。
圖1 獲取天氣預報資訊
演示程式定義瞭如下這個WeatherReport記錄型別來表示某個城市在某段時間範圍內的天氣報告。如程式碼片段所示,某一天的天氣體現為一個WeatherInfo記錄。簡單起見,我們讓WeatherInfo記錄只攜帶基本天氣狀況和氣溫區間的資訊。
public readonly record struct WeatherInfo(string Condition, double HighTemperature, double LowTemperature); public readonly record struct WeatherReport(string CityCode, string CityName,IDictionary<DateTime, WeatherInfo> WeatherInfos);
我們定義瞭如下這個工具型別WeatherReportUtility,兩個Generate方法會根據指定的城市程式碼和天數/日期生成一份由WeatherReport物件表示的天氣報告。為了將這份報告呈現在網頁上,我們定義了另一個RenderAsync方法將指定的WeatherReport轉換成HTML,並利用指定的HttpContext上下文將它作為響應內容,具體的HTML內容由AsHtml方法生成。
public static class WeatherReportUtility { private static readonly Random _random = new(); private static readonly Dictionary<string, string> _cities = new() { ["010"] = "北京", ["028"] = "成都", ["0512"] = "蘇州" }; private static readonly string[] _conditions = new string[] { "晴", "多雲", "小雨" }; public static WeatherReport Generate(string city, int days) { var report = new WeatherReport(city, _cities[city], new Dictionary<DateTime, WeatherInfo>()); for (int i = 0; i < days; i++) { report.WeatherInfos[DateTime.Today.AddDays(i + 1)] = new WeatherInfo(_conditions[_random.Next(0, 2)], _random.Next(20, 30), _random.Next(10, 20)); } return report; } public static WeatherReport Generate(string city, DateTime date) { var report = new WeatherReport(city, _cities[city], new Dictionary<DateTime, WeatherInfo>()); report.WeatherInfos[date] = new WeatherInfo(_conditions[_random.Next(0, 2)], _random.Next(20, 30), _random.Next(10, 20)); return report; } public static Task RenderAsync(HttpContext context, WeatherReport report) { context.Response.ContentType = "text/html;charset=utf-8"; return context.Response.WriteAsync(AsHtml(report)); } public static string AsHtml(WeatherReport report) { return @$" <html> <head><title>Weather</title></head> <body> <h3>{report.CityName}</h3> {AsHtml(report.WeatherInfos)} </body> </html> "; static string AsHtml(IDictionary<DateTime, WeatherInfo> dictionary) { var builder = new StringBuilder(); foreach (var kv in dictionary) { var date = kv.Key.ToString("yyyy-MM-dd"); var tempFrom = $"{kv.Value.LowTemperature}℃ "; var tempTo = $"{kv.Value.HighTemperature}℃ "; builder.Append( $"{date}: {kv.Value.Condition} ({tempFrom}~{tempTo})<br/></br>"); } return builder.ToString(); } } }
Minimal API會預設新增針對路由的服務註冊,完成路由的兩個中介軟體(RoutingMiddleware和EndpointRoutingMiddleware)也會在自動註冊到建立的WebApplication物件上。WebApplication型別同時實現了IEndpointRouteBuilder介面,我們只需要利用它註冊相應的終結點就可以了。如下的演示程式呼叫了WebApplication物件的MapGet方法註冊了一個僅針對GET請求的終結點,終結點採用的路徑模板為「weather/{city}/{days}」,攜帶的兩個路由引數({city}和{days})分別代表目標城市程式碼(區號)和天數。
using App; var app = WebApplication.Create(); app.MapGet("weather/{city}/{days}", ForecastAsync); app.Run(); static Task ForecastAsync(HttpContext context) { var routeValues = context.GetRouteData().Values; var city = routeValues["city"]!.ToString(); var days = int.Parse(routeValues["days"]!.ToString()!); var report = WeatherReportUtility.Generate(city!, days); return WeatherReportUtility.RenderAsync(context, report); }
註冊中介軟體採用的處理器是一個RequestDelegate委託,我們將它指向ForecastAsync方法。該方法呼叫HttpContext上下文的GetRouteData方法得到承載「路由資料」的RouteData物件,後者的Values屬性返回路由引數字典。我們從中提取出代表城市程式碼和天數的路由引數,並建立出對應的天氣報告,最後將其轉換成HTML作為響應內容。
上面的演示範例註冊的路由模板中定義了兩個引數({city}和{days}),分別表示獲取天氣預報的目標城市對應的區號和天數。區號應該具有一定的格式(以零開始的3~4位元數位),而天數除了必須是一個整數,還應該具有一定的範圍。由於沒有對這兩個路由引數坐任何約束,所以請求URL攜帶的任何字元都是有效的。ForecastAsync方法也並沒有對提取的路由引數做任何驗證,所以在執行過程中面對不合法的輸入會直接丟擲異常。
為了確保路由引數值的有效性,在進行中介軟體註冊時可以採用內聯(Inline)的方式直接將相應的約束規則定義在路由模板中。ASP.NET為常用的驗證規則定義了相應的約束表示式,我們可以根據需要為某個路由引數指定一個或者多個約束表示式。如下面的程式碼片段所示,我們為路由引數「{city}」指定了一個基於「區號」的正規表示式(「:regex(^0[1-9]{{2,3}}$)」)。另一個路由引數{days}則應用了兩個約束,一個是針對資料型別的約束(「:int」),另一個是針對區間的約束(「:range(1,4)」)。
using App; var template = @"weather/{city:regex(^0\d{{2,3}}$)}/{days:int:range(1,4)}"; var app = WebApplication.Create(); app.MapGet(template, ForecastAsync); app.Run();
如果在註冊路由時應用了約束,那麼RoutingMiddleware中介軟體在進行路由解析時除了要求請求路徑必須與路由模板具有相同的模式,還要求攜帶的資料滿足對應路由引數的約束條件。如果不能同時滿足這兩個條件,RoutingMiddleware中介軟體將無法選擇一個終結點來處理當前請求。對於我們演示的這個範例來說,如果提供的是一個不合法的區號(1014)和預報天數(5),那麼使用者端都將得到圖2所示的狀態碼為「404 Not Found」的響應。
圖2 不滿足路由約束而返回的「404 Not Found」響應
路由模板(如「weather/{city}/{days}」)可以包含靜態的字元(如「weather」),也可以包含動態的引數(如{city}和{days}),我們將後者稱為路由引數。並非每個路由引數都必須有請求URL對應的部分來指定,如果賦予路由引數一個預設值,那麼它在請求URL中就是可以預設的。對上面演示的範例來說,我們可以採用如下方式在路由引數名後面新增一個問號(「?」)將原本必需的路由引數變成可以預設的預設引數的。可以預設的路由引數與在方法中定義可預設的(Optional)params引數一樣,只能出現在路由模板尾部。
using App; var template = "weather/{city?}/{days?}"; var app = WebApplication.Create(); app.MapGet(template, ForecastAsync); app.Run(); static Task ForecastAsync(HttpContext context) { var routeValues = context.GetRouteData().Values; var city = routeValues.TryGetValue("city", out var v1) ? v1!.ToString() : "010"; var days = routeValues.TryGetValue("days", out var v2) ? v1!.ToString() : "4"; var report = WeatherReportUtility.Generate(city!, int.Parse(days!)); return WeatherReportUtility.RenderAsync(context, report); }
既然路由變數佔據的部分路徑是可以預設的,那麼即使請求的URL不具有對應的值(如「weather」和「weather/010」),它與路由規則也是匹配的,但此時在路由引數字典中是找不到它們的。此時我們不得不對處理請求的ForecastAsync方法進行相應的改動。針對上述改動,如果希望獲取北京未來4天的天氣狀況,我們可以採用圖3所示的三種URL(「weather」、「weather/010」和「weather/010/4」),這三個請求的URL本質上是完全等效的。
圖3 不同URL針對預設路由引數的等效性
實際上可預設路由引數預設值的設定還有一種更簡單的方式,那就是按照如下所示的方式直接將預設值定義在路由模板中。這樣針對ForecastAsync方法的改動就完全沒有必要。
using App; var template = @"weather/{city=010}/{days=4}"; var app = WebApplication.Create(); app.MapGet(template, ForecastAsync); app.Run(); static Task ForecastAsync(HttpContext context) { var routeValues = context.GetRouteData().Values; var city = routeValues["city"]!.ToString(); var days = int.Parse(routeValues["days"]!.ToString()!); var report = WeatherReportUtility.Generate(city!, days); return WeatherReportUtility.RenderAsync(context, report); }
一個URL可以通過分隔符「/」劃分為多個路徑分段(Segment),路由引數一般來說會佔據某個獨立的分段(如「weather/{city}/{days}」)。但也有例外情況,我們既可以在一個單獨的路徑分段中定義多個路由引數,也可以讓一個路由引數跨越多個連續的路徑分段。以我們的演示程式為例,我們需要設計一種路徑模式來獲取某個城市某一天的天氣資訊,如使用「/weather/010/2019.11.11」這樣URL獲取北京在2019年11月11日的天氣,對應模板為「/weather/{city}/{year}.{month}.{day}」。
using App; var template = "weather/{city}/{year}.{month}.{day}"; var app = WebApplication.Create(); app.MapGet(template, ForecastAsync); app.Run(); static Task ForecastAsync(HttpContext context) { var routeValues = context.GetRouteData().Values; var city = routeValues["city"]!.ToString(); var year = int.Parse(routeValues["year"]!.ToString()!); var month = int.Parse(routeValues["month"]!.ToString()!); var day = int.Parse(routeValues["day"]!.ToString()!); var report = WeatherReportUtility.Generate(city!, new DateTime(year,month,day)); return WeatherReportUtility.RenderAsync(context, report); }
對於修改後的程式,如果採用「/weather/{city}/{yyyy}.{mm}.{dd}」這樣的URL,我們就可以獲取某個城市指定日期的天氣。如圖4所示,我們採用請求路徑「/weather/010/2019.11.11」可以獲取北京在2019年11月11日的天氣。
圖4 一個路徑分段定義多個路由引數
上面設計的路由模板採用「.」作為日期分隔符,如果採用「/」作為日期分隔符(如2019/11/11),這個路由預設應該如何定義呢?由於「/」同時也是路徑分隔符,就意味著同一個路由引數跨越了多個路徑分段,這種情況只能採用「萬用字元」的形式才能達成我們的目標。萬用字元路由引數採用{*variable}或者{**variable}的形式,星號(*)表示路徑「餘下的部分」,所以這樣的路由引數也只能出現在模板的尾端。演示程式的路由模板可以定義成「/weather/{city}/{*date}」。
using App; using System.Globalization; var template = "weather/{city}/{*date}"; var app = WebApplication.Create(); app.MapGet(template, ForecastAsync); app.Run(); static Task ForecastAsync(HttpContext context) { var routeValues = context.GetRouteData().Values; var city = routeValues["city"]!.ToString(); var date = DateTime.ParseExact(routeValues["date"]?.ToString()!,"yyyy/MM/dd",CultureInfo.InvariantCulture); var report = WeatherReportUtility.Generate(city!, date); return WeatherReportUtility.RenderAsync(context, report); }
我們可以對程式做如上修改來使用新的URL模板(「/weather/{city}/{*date}」)。為了得到北京在2019年11月11日的天氣,請求的URL可以替換成「/weather/010/2019/11/11」,返回的天氣資訊如圖5所示。
圖5 一個路由引數跨越多個路徑分段
一般來說,在利用某路由終結點與待路由的請求進行匹配的時候只需要考慮請求地址的路徑部分,並忽略主機(Host)名稱和埠號,但是一定要加上針對主機名稱(含埠)的匹配策略也未嘗不可。在如下這個演示程式中,我們通過呼叫MapGet擴充套件方法為根路徑「/」新增了三個路由終結點,並呼叫該方法返回的IEndpointConventionBuilder物件的RequireHost擴充套件方法系結了對應的主機名(「*.artech.com」、「www.foo.artech.com」和「www.foo.artech.com:9999」)。指定的第一個主機名包含一個前置萬用字元「*」,最後一個則指定了埠號。註冊的這三個終結點會直接將指定的主機名作為響應內容。
var app = WebApplication.Create(); app.Urls.Add("http://0.0.0.0:6666"); app.Urls.Add("http://0.0.0.0:9999"); app .MapHost("*.artech.com") .MapHost("www.foo.artech.com") .MapHost("www.foo.artech.com:9999"); app.Run(); internal static class Extensions { public static IEndpointRouteBuilder MapHost(this IEndpointRouteBuilder endpoints,string host) { endpoints.MapGet("/", context => context.Response.WriteAsync(host)).RequireHost(host); return endpoints; } }
為了能夠在本機採用不同的域名對演示應用發起請求,我們通過修改Hosts檔案的方式將本地地址(「127.0.0.1」)對映為多個不同的域名。我們以管理員(Administrator)身份開啟檔案Hosts 「%windir%\System32\drivers\etc\hosts」,並以如下所示的方式新增了針對兩個域名的對映。
127.0.0.1 www.foo.artech.com 127.0.0.1 www.bar.artech.com
應用啟動之後,我們利用瀏覽器使用不同的域名和埠對其發起請求,並得到如圖6所示的輸出結果。輸出的內容不僅僅體現了終結點選擇過程中針對主機名的過濾,還體現了終結點選擇策略的一個重要的特性,那就是路由系統總是試圖選擇一個與當前請求匹配度最高的終結點,而不是選擇第一個匹配的終結點。
圖6 主機名系結
上面的例子都直接使用一個RequestDelegate委託作為終結點的處理器,實際上我們在註冊終結點時可以將處理器設定為任何型別的委託都可以。當路由請求分發給註冊的委託進行處理器時,會盡可能地從當前HttpContext上下文中提取相應的資料對委託的輸入引數進行繫結。對於委託的執行結果,路由系統也會按照預定義的規則「智慧」地將它應用到針對請求的響應中。按照這個規則,我們演示程式中用來處理請求的ForecastAsync方法可以簡寫成如下形式。第一個引數會自動繫結為當前HttpContext上下文,後面的兩個引數則自動與同名的路由引數進行繫結。
using App; var app = WebApplication.Create(); app.MapGet("weather/{city}/{days}", ForecastAsync); app.Run(); static Task ForecastAsync(HttpContext context, string city, int days) { var report = WeatherReportUtility.Generate(city,days); return WeatherReportUtility.RenderAsync(context, report); }
不論終結點處理器的委託返回何種型別的物件,路由系統總能做出對應的處理。比如對於返回的字串會直接作為響應的主體內容,並將Content-Type報頭設定為「text/plain」。如果希望對返回物件具有明確的控制,最好返回一個IResult物件(或者Task<IResult>和ValueTask<IResult>),IResult相當ASP.NET MVC中的IActionResult。我們演示程式中的ForecastAsync方法也可以改寫成如下這個返回型別為IResult的Forecast方法,該方法通過呼叫Results型別的靜態Content方法返回一個ContentResult物件,它將天氣報告轉換成的HTML作為響應型別,Content-Type報頭設定為 「text/html」 。
using App; var app = WebApplication.Create(); app.MapGet("weather/{city}/{days}", Forecast); app.Run(); static IResult Forecast(HttpContext context, string city, int days) { var report = WeatherReportUtility.Generate(city,days); return Results.Content(WeatherReportUtility.AsHtml(report), "text/html"); }