ASP.NET的路由是通過EndpointRoutingMiddleware和EndpointMiddleware這兩個中間件協作完成的,它們在ASP.NET平臺上具有舉足輕重的地位,MVC和gRPC框架,Dapr的Actor和發佈訂閱編程模式都建立在路由系統之上。Minimal API更是將提升到了前所未有的高度,上一篇通過9個實例演示了基於路由的REST API開發,本篇演示一些“高階”的用法。
[S2010]解析路由模式 (源代碼)
[S2011]利用多箇中間件來構建終結點處理器(源代碼)
[S2012]在參數上標註特性來決定綁定的數據源(源代碼)
[S2013]默認的參數綁定規則(源代碼)
[S2014]針對TryPar[Se方法的參數綁定(源代碼)
[S2015]針對BindA[Sync方法的參數綁定(源代碼)
[S2016]自定義路由約束(源代碼)
[S2010]解析路由模式
下面我們通過一個簡單的實例演示如何利用RoutePatternFactory對象解析指定的路由模板,並生成對應的RoutePattern對象。我們定義瞭如下所示的Format方法將指定的RoutePattern對象格式化成一個字符串。
static string Format(RoutePattern pattern) { var builder = new StringBuilder(); builder.AppendLine($"RawText:{pattern.RawText}"); builder.AppendLine($"InboundPrecedence:{pattern.InboundPrecedence}"); builder.AppendLine($"OutboundPrecedence:{pattern.OutboundPrecedence}"); var segments = pattern.PathSegments; builder.AppendLine("Segments"); foreach (var segment in segments) { foreach (var part in segment.Parts) { builder.AppendLine($"\t{ToString(part)}"); } } builder.AppendLine("Defaults"); foreach (var @default in pattern.Defaults) { builder.AppendLine($"\t{@default.Key} = {@default.Value}"); } builder.AppendLine("ParameterPolicies "); foreach (var policy in pattern.ParameterPolicies) { builder.AppendLine( $"\t{policy.Key} = {string.Join(',',policy.Value.Select(it => it.Content))}"); } builder.AppendLine("RequiredValues"); foreach (var required in pattern.RequiredValues) { builder.AppendLine($"\t{required.Key} = {required.Value}"); } return builder.ToString(); static string ToString(RoutePatternPart part) => part switch { RoutePatternLiteralPart literal => $"Literal: {literal.Content}", RoutePatternSeparatorPart separator => $"Separator: {separator.Content}", RoutePatternParameterPart parameter => @$"Parameter: Name = {parameter.Name}; Default = {parameter.Default}; IsOptional = { parameter.IsOptional}; IsCatchAll = { parameter.IsCatchAll};ParameterKind = { parameter.ParameterKind}", _ => throw new ArgumentException("Invalid RoutePatternPart.") }; }
如下的演示程序調用了RoutePatternFactory 類型的靜態方法Parse解析指定的路由模板“weather/{city:regex(^0\d{{2,3}}$)=010}/{days:int:range(1,4)=4}/{detailed?}”生成一個RoutePattern對象,我們在調用該方法時還指定了requiredValues參數。我們調用創建的WebApplication對象的MapGet方法註冊了針對根路徑“/”的終結點,對應的處理器直接返回RoutePattern對象格式化生成的字符串。
using Microsoft.AspNetCore.Routing.Patterns; using System.Text; var template =@"weather/{city:regex(^0\d{{2,3}}$)=010}/{days:int:range(1,4)=4}/{detailed?}"; var pattern = RoutePatternFactory.Parse( pattern: template, defaults: null, parameterPolicies: null, requiredValues: new { city = "010", days = 4 }); var app = WebApplication.Create(); app.MapGet("/", ()=> Format(pattern)); app.Run();
如果利用瀏覽器訪問啓動後的應用程序,回到得到如圖1所示結果,它結構化地展示了路由模式的原始文本、出入棧路由匹配權重、每個段的組成、路由參數的默認值和參數策略,以及生成URL必須提供的默認參數值。
圖1 針對路由模式的解析
[S2011]利用多箇中間件來構建終結點處理器
如果某個終結點針對請求處理的邏輯相對複雜,需要多箇中間件協同完成,我們可以調用IEndpointRouteBuilder 對象的CreateApplicationBuilder方法創建一個新的IApplicationBuilder對象,並將這些中間件註冊到這個該對象上,最後利用它這些中間件轉換成RequestDelegate委託。
var app = WebApplication.Create(); IEndpointRouteBuilder routeBuilder = app; app.MapGet("/foobar", routeBuilder.CreateApplicationBuilder() .Use(FooMiddleware) .Use(BarMiddleware) .Use(BazMiddleware) .Build()); app.Run(); static async Task FooMiddleware(HttpContext context,RequestDelegate next) { await context.Response.WriteAsync("Foo=>"); await next(context); }; static async Task BarMiddleware(HttpContext context, RequestDelegate next) { await context.Response.WriteAsync("Bar=>"); await next(context); }; static Task BazMiddleware(HttpContext context, RequestDelegate next) => context.Response.WriteAsync("Baz");
上面的演示程序註冊了一個路徑模板爲“foobar”的路由,並註冊了三個中間件來處理路由的請求。該演示程序啓動之後,如果我們利用瀏覽器對路由地址“/foobar”發起請求,將會得到如圖2所示的輸出結果。呈現出來的字符串是通過註冊的三個中間件(FooMiddleware、BarMiddleware和BazMiddleware)輸出內容組合而成。
圖2 輸出結果
[S2012]在參數上標註特性來決定綁定的數據源
如下這個演示程序調用WebApplication對象的MapPost方法註冊了一個採用“/{foo}”作爲模板的終結點。作爲終結點處理器的委託指向靜態方法Handle,我們爲這個方法定義了五個參數,分別標註了上述五個特性。我們將五個參數組合成一個匿名對象作爲返回值。
using Microsoft.AspNetCore.Mvc; var app = WebApplication.Create(); app.MapPost("/{foo}", Handle); app.Run(); static object Handle( [FromRoute] string foo, [FromQuery] int bar, [FromHeader] string host, [FromBody] Point point, [FromServices] IHostEnvironment environment) => new { Foo = foo, Bar = bar, Host = host, Point = point, Environment = environment.EnvironmentName }; public class Point { public int X { get; set; } public int Y { get; set; } }
程序啓動之後,我們針對“http://localhost:5000/abc?bar=123”這個URL發送了一個POST請求,請求的主體內容爲一個Point對象序列化成生成的JSON。如下所示的是請求報文和響應報文的內容,可以看出Handle方法的foo和bar參數分別綁定的是路由參數“foo”和查詢字符串“bar”的值,參數host綁定的是請求的Host報頭,參數point是請求主體內容反序列化的結果,參數environment則是由針對當前請求的IServiceProvider對象提供的服務(S2012)。
POST http://localhost:5000/abc?bar=123 HTTP/1.1 Content-Type: application/json Host: localhost:5000 Content-Length: 18 {"x":123, "y":456}
HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 Date: Sat, 06 Nov 2021 11:55:54 GMT Server: Kestrel Content-Length: 100 {"foo":"abc","bar":123,"host":"localhost:5000","point":{"x":123,"y":456},"environment":"Production"}
[S2013]默認的參數綁定規則
如果請求處理器方法的參數沒有顯式指定綁定數據的來源,路由系統也能根據參數的類型儘可能地從當前HttpContext上下文中提取相應的內容予以綁定。針對如下這幾個類型,對應參數的綁定源是明確的。
- HttpContext:綁定爲當前HttpContext上下文。
- HttpRequest:綁定爲當前HttpContext上下文的Request屬性。
- HttpResponse: 綁定爲當前HttpContext上下文的Response屬性。
- ClaimsPrincipal: 綁定爲當前HttpContext上下文的User屬性。
- CancellationToken: 綁定爲當前HttpContext上下文的RequestAborted屬性。
上述的綁定規則體現在如下演示程序的調試斷言中。這個演示實例還體現了另一個綁定規則,那就是隻要當前請求的IServiceProvider能夠提供對應的服務,對應參數(“httpContextAccessor”)上標註的FromSerrvicesAttribute特性不是必要的。但是倘若缺少對應的服務註冊,請求的主體內容會一般會作爲默認的數據來源,所以FromSerrvicesAttribute特性最好還是顯式指定爲好。對於我們演示的這個例子,如果我們將前面針對AddHttpContextAccessor方法的調用移除,對應參數的綁定自然會失敗,但是錯誤消息並不是我們希望看到的(S2013)。
using System.Diagnostics; using System.Security.Claims; var builder = WebApplication.CreateBuilder(); builder.Services.AddHttpContextAccessor(); var app = builder.Build(); app.MapGet("/", Handle); app.Run(); static void Handle(HttpContext httpContext, HttpRequest request, HttpResponse response,ClaimsPrincipal user, CancellationToken cancellationToken, IHttpContextAccessor httpContextAccessor) { var currentContext = httpContextAccessor.HttpContext; Debug.Assert(ReferenceEquals(httpContext, currentContext)); Debug.Assert(ReferenceEquals(request, currentContext.Request)); Debug.Assert(ReferenceEquals(response, currentContext.Response)); Debug.Assert(ReferenceEquals(user, currentContext.User)); Debug.Assert(cancellationToken == currentContext.RequestAborted); }
[S2014]針對TryParse方法的參數綁定
如果我們在某個類型中定義了一個名爲TryParse的靜態方法將指定的字符串表達式轉換成當前類型的實例,路由系統在對該類型的參數進行綁定的時候會優先從路由參數和查詢字符串中提取相應的內容,並通過調用這個方法生成綁定的參數。
var app = WebApplication.Create(); app.MapGet("/", (Point foobar) => foobar); app.Run(); public class Point { public int X { get; set; } public int Y { get; set; } public Point(int x, int y) { X = x; Y = y; } public static bool TryParse(string expression, out Point? point) { var split = expression.Trim('(', ')').Split(','); if (split.Length == 2 && int.TryParse(split[0], out var x) && int.TryParse(split[1], out var y)) { point = new Point(x, y); return true; } point = null; return false; } }
上面的演示程序爲自定義的Point類型定義了一個靜態的TryParse方法使我們可以將一個以“(x,y)”形式定義的表達式轉換成Point對象。註冊的終結點處理器委託以該類型爲參數,指定的參數名稱爲“foobar”。我們在發送的請求中以查詢字符串的形式提供對應的表達式“(123,456)”,從返回的內容可以看出參數得到了成功綁定。
圖3 TryParse方法針對參數綁定的影響
[S2015]針對BindAsync方法的參數綁定
如果某種類型的參數具有特殊的綁定方式,我們還可以將具體的綁定實現在一個按照約定定義的BindAsync方法中。按照約定,這個BindAsync應該定義成返回類型爲ValueTask<T>的靜態方法,它可以擁有一個類型爲HttpContext的參數,也可以額外提供一個ParameterInfo類型的參數,這兩個參數分別與當前HttpContext上下文和描述參數的ParameterInfo對象綁定。前面演示實例中爲Point類型定義了一個TryParse方法可以替換成如下這個 BingAsync方法(S2015)。
public class Point { public int X { get; set; } public int Y { get; set; } public Point(int x, int y) { X = x; Y = y; } public static ValueTask<Point?> BindAsync(HttpContext httpContext, ParameterInfo parameter) { Point? point = null; var name = parameter.Name; var value = httpContext.GetRouteData().Values.TryGetValue(name!, out var v) ? v : httpContext.Request.Query[name!].SingleOrDefault(); if (value is string expression) { var split = expression.Trim('(', ')')?.Split(','); if (split?.Length == 2 && int.TryParse(split[0], out var x) && int.TryParse(split[1], out var y)) { point = new Point(x, y); } } return new ValueTask<Point?>(point); } }
[S2016]自定義路由約束
我們可以使用預定義的IRouteConstraint實現類型完成一些常用的約束,但是在一些對路由參數具有特定約束的應用場景中,我們不得不創建自定義的約束類型。舉個例子,如果需要對資源提供針對多語言的支持,最好的方式是在請求的URL中提供對應的Culture。爲了確保包含在URL中的是一個合法有效的Culture,最好爲此定義相應的約束。下面將通過一個簡單的實例來演示如何創建這樣一個用於驗證Culture的自定義路由約束。我們創建了一個提供基於不同語言資源的API。我們將資源文件作爲文本資源進行存儲,如圖4所示,我們創建了兩個資源文件 (Resources.resx和Resources.zh.resx),並定義了一個名爲hello的文本資源條目。
圖4 存儲文本資源的兩個資源文件
如下演示程序中註冊了一個模板爲“resources/{lang:culture}/{resourceName:required}”的終結點。路由參數“{resourceName}”表示資源條目的名稱(比如“hello”),另一個路由參數“{lang}”表示指定的語言,約束表達式名稱culture對應的就是我們自定義的針對語言文化的約束類型CultureConstraint。因爲這是一個自定義的路由約束,我們通過調用IServiceCollection接口的Configure<TOptions>方法將此約束採用的表達式名稱(“culture”)和CultureConstraint類型之間的映射關係添加到RouteOptions配置選項中。
using App; using App.Properties; using System.Globalization; var builder = WebApplication.CreateBuilder(); var template = "resources/{lang:culture}/{resourceName:required}"; builder.Services.Configure<RouteOptions>(options => options.ConstraintMap.Add("culture", typeof(CultureConstraint))); var app = builder.Build(); app.MapGet(template, GetResource); app.Run(); static IResult GetResource(string lang, string resourceName) { CultureInfo.CurrentUICulture = new CultureInfo(lang); var text = Resources.ResourceManager.GetString(resourceName); return string.IsNullOrEmpty(text)? Results.NotFound(): Results.Content(text); }
該終結點的處理方法GetResource定義了兩個參數,我們知道它們會自動綁定爲同名的路由參數。由於系統自動根據當前線程的UICulture來選擇對應的資源文件,我們對CultureInfo類型的CurrentUICulture靜態屬性進行了設置。如果從資源文件將對應的文本提取出來,我們將創建一個ContentResult對象並返回。應用啓動之後,我們可以利用瀏覽器指定匹配的URL獲取對應語言的文本。如圖5所示,如果指定一個不合法的語言(如“xx”),將會違反我們自定義的約束,此時就會得到一個狀態碼爲“404 Not Found”的響應。
圖5 採用相應的URL得到某個資源針對某種語言的內容
我們來看看針對語言文化的路由約束CultureConstraint究竟做了什麼。如下面的代碼片段所示,我們在Match方法中會試圖獲取作爲語言文化內容的路由參數值,如果存在這樣的路由參數,就可以利用它創建一個CultureInfo對象。如果這個CultureInfo對象的EnglishName屬性名不以“Unknown Language”字符串作爲前綴,我們就認爲指定的是合法的語言文件。
public class CultureConstraint : IRouteConstraint { public bool Match(HttpContext? httpContext, IRouter? route, string routeKey,RouteValueDictionary values, RouteDirection routeDirection) { try { if (values.TryGetValue(routeKey, out var value) && value is not null) { return !new CultureInfo((string)value) .EnglishName.StartsWith("Unknown Language"); } return false; } catch { return false; } } }