借助路由系統提供的請求URL模式與對應終結點之間的映射關系,我們可以將具有相同URL模式的請求分發給與之匹配的終結點進行處理。ASP.NET的路由是通過EndpointRoutingMiddleware和EndpointMiddleware這兩個中間件協作完成的,它們在ASP.NET平臺上具有舉足輕重的地位,MVC和gRPC框架,Dapr的Actor和發布訂閱編程模式都建立在路由系統之上。Minimal API更是將提升到了前所未有的高度,是我們直接在路由系統基礎上定義REST API。[本文節選《ASP.NET Core 6框架揭秘》第20章]
[S2001]注冊路由終結點 (源代碼)
[S2002]以內聯方式設置路由參數的約束(源代碼)
[S2003]定義可缺省的路由參數(源代碼)
[S2004]為路由參數指定默認值(源代碼)
[S2005]一個路徑分段定義多個路由參數(源代碼)
[S2006]一個路由參數跨越多個路徑分段(源代碼)
[S2007]主機名綁定(源代碼)
[S2008]將終結點處理定義為任意類型的委托(源代碼)
[S2009]IResult 的應用(源代碼)
[S2001]注冊路由終結點
我們演示的這個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作為響應內容。
[S2002]以內聯方式設置路由參數的約束
上面的演示實例注冊的路由模板中定義了兩個參數({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”響應
[S2003]定義可缺省的路由參數
路由模板(如“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針對默認路由參數的等效性
[S2004]為路由參數指定默認值
實際上可缺省路由參數默認值的設置還有一種更簡單的方式,那就是按照如下所示的方式直接將默認值定義在路由模板中。這樣針對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);
}
[S2005]一個路徑分段定義多個路由參數
一個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 一個路徑分段定義多個路由參數
[S2006]一個路由參數跨越多個路徑分段
上面設計的路由模板采用“.”作為日期分隔符,如果采用“/”作為日期分隔符(如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 一個路由參數跨越多個路徑分段
[S2007]主機名綁定
一般來說,在利用某路由終結點與待路由的請求進行匹配的時候只需要考慮請求地址的路徑部分,并忽略主機(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 主機名綁定
[S2008]將終結點處理定義為任意類型的委托
上面的例子都直接使用一個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);
}
[S2009]IResult 的應用
不論終結點處理器的委托返回何種類型的對象,路由系統總能做出對應的處理。比如對于返回的字符串會直接作為響應的主體內容,并將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");
}