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方法的調用移除,對應參數的綁定自然會失敗,但是錯誤消息并不是我們希望看到的。
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方法。
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;}}
}