表示路由終結點的RouteEndpoint對象包含以RoutePattern對象表示的路由模式,某個請求能夠被成功路由的前提是它滿足某個候選終結點的路由模式所體現的路由規則。具體來說,這不僅要求當前請求的URL路徑必須滿足路由模板指定的路徑模式,還需要具體的字符內容滿足對應路由參數上定義的約束。
目錄
一、IRouteConstraint
二、預定義約束
三、InlineConstraintResolver
四、自定義約束
一、IRouteConstraint
路由系統采用IRouteConstraint接口來表示路由約束,該接口具有唯一的Match方法,該方法用來驗證URL攜帶的參數值是否有效。路由約束在表示路由模式的RoutePattern對象中是以路由參數策略的形式存儲在ParameterPolicies屬性中的,所以IRouteConstraint接口派生於IParameterPolicy接口。通過IRouteConstraint接口表示的路由約束同時兼容傳統IRouter路由系統和最新的終結點路由系統,所以Match方法具有一個表示IRouter對象的route參數。
public interface IRouteConstraint : IParameterPolicy { bool Match(HttpContext httpContext, IRouter route, string routeKey,RouteValueDictionary values, RouteDirection routeDirection); } public enum RouteDirection { IncomingRequest, UrlGeneration }
針對路由參數約束的檢驗同時應用在兩個路由方向上,即針對入棧請求的路由解析和針對URL的生成,當前應用的路由方向通過Match方法的routeDirection參數表示。Match方法的第一個參數httpContext表示當前HttpContext上下文,routeKey參數表示的其實是路由參數名稱。如果當前的路由方向為IncomingRequest,那么Match方法的values參數就代表解析出來的所有路由參數值;否則,該參數代表為生成URL提供的路由參數值。一般來說,我們只需要利用routeKey參數提供的參數名從values參數表示的字典中提取出當前參數值,並根據對應的規則加以驗證即可。
二、預定義約束
路由系統定義了一系列原生的IRouteConstraint實現類型,我們可以使用它們解決很多常見的約束問題。即使現有的IRouteConstraint實現類型無法滿足某些特殊的約束需求,我們也可以通過實現IRouteConstraint接口創建自定義的約束類型。對於路由約束的應用,除了直接創建對應的IRouteConstraint對象,還可以采用內聯的方式直接在路由模板中為某個路由參數定義相應的約束表達式。這些以表達式定義的約束類型其實對應着一種具體的IRouteConstraint類型。下表列舉了內聯約束類型與IRouteConstraint類型。
內聯約束類型 |
IRouteConstraint類型 |
說明 |
int |
IntRouteConstraint |
要求路由參數值能夠解析為一個int整數,如{variable:int} |
bool |
BoolRouteConstraint |
要求參數值可以解析為一個bool值,如{ variable:bool} |
datetime |
DateTimeRouteConstraint |
要求參數值可以解析為一個DateTime對象(采用CultureInfo. InvariantCulture進行解析),如{ variable:datetime} |
decimal |
DecimalRouteConstraint |
要求參數值可以解析為一個decimal數字,如{ variable:decimal} |
double |
DoubleRouteConstraint |
要求參數值可以解析為一個double數字,如{ variable:double} |
float |
FloatRouteConstraint |
要求參數值可以解析為一個float數字,如{ variable:float} |
guid |
GuidRouteConstraint |
要求參數值可以解析為一個Guid,如{ variable:guid} |
long |
LongRouteConstraint |
要求參數值可以解析為一個long整數,如{ variable:long} |
內聯約束類型 |
IRouteConstraint類型 |
說 明 |
minlength |
MinLengthRouteConstraint |
要求參數值表示的字符串不小於指定的長度,如{ variable: |
maxlength |
MaxLengthRouteConstraint |
要求參數值表示的字符串不大於指定的長度,如{ variable: |
length |
LengthRouteConstraint |
要求參數值表示的字符串長度限於指定的區間范圍,如{ variable: |
min |
MinRouteConstraint |
最小值,如{ variable:min(5)} |
max |
MaxRouteConstraint |
最大值,如{ variable:max(10)} |
range |
RangeRouteConstraint |
要求參數值介於指定的區間范圍,如{variable:range(5,10)} |
alpha |
AlphaRouteConstraint |
要求參數的所有字符都是字母,如{variable:alpha} |
regex |
RegexInlineRouteConstraint |
要求參數值表示的字符串與指定的正則表達式相匹配,如{variable: |
required |
RequiredRouteConstraint |
要求參數值不應該是一個空字符串,如{variable:required} |
file |
FileNameRouteConstraint |
要求參數值可以作為一個包含擴展名的文件名,如{variable:file} |
nonfile |
NonFileNameRouteConstraint |
與FileNameRouteConstraint剛好相反,這兩個約束類型旨在區分針對靜態文件的請求 |
為了使讀者對這些IRouteConstraint實現類型有更加深刻的理解,我們選擇一個用於限制變量值范圍的RangeRouteConstraint類進行單獨介紹。如下面的代碼片段所示,RangeRouteConstraint類型具有兩個長整型的只讀屬性Max和Min,它們分別表示約束范圍的上限和下限。
public class RangeRouteConstraint : IRouteConstraint { public long Max { get; } public long Min { get; } public RangeRouteConstraint(long min, long max) { Min = min; Max = max; } public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection) { if (values.TryGetValue(routeKey, out var value) && value != null) { var valueString = Convert.ToString(value, CultureInfo.InvariantCulture); if (long.TryParse(valueString, NumberStyles.Integer, CultureInfo.InvariantCulture, out var longValue)) { return longValue >= Min && longValue <= Max; } } return false; } }
具體的約束檢驗實現在Match方法中。RangeRouteConstraint在該方法中會根據被檢驗變量的名稱(對應routeKey參數)從參數values(表示路由檢驗生成的所有路由參數)中提取被驗證的參數值,然后判斷它是否在Max屬性和Min屬性表示的數值范圍內。
三、InlineConstraintResolver
由於在進行路由注冊時針對路由變量的約束直接以內聯表達式的形式定義在路由模板中,所以路由系統需要解析約束表達式來創建對應類型的IRouteConstraint對象,這項任務由IInlineConstraintResolver對象來完成。如下面的代碼片段所示,IInlineConstraintResolver接口定義了唯一的ResolveConstraint方法,實現了路由約束從字符串表達式到IRouteConstraint對象之間的轉換。
public interface IInlineConstraintResolver { IRouteConstraint ResolveConstraint(string inlineConstraint); }
DefaultInlineConstraintResolver類型是對IInlineConstraintResolver接口的默認實現,如下面的代碼片段所示,DefaultInlineConstraintResolver具有一個字典類型的字段_inlineConstraintMap,上表列舉的內聯約束類型與IRouteConstraint類型之間的映射關系就保存在這個字典中。
public class DefaultInlineConstraintResolver : IInlineConstraintResolver { private readonly IDictionary<string, Type> _inlineConstraintMap; public DefaultInlineConstraintResolver(IOptions<RouteOptions> routeOptions) =>_inlineConstraintMap = routeOptions.Value.ConstraintMap; public virtual IRouteConstraint ResolveConstraint(string inlineConstraint); } public class RouteOptions { public IDictionary<string, Type> ConstraintMap { get; set; } ... }
在根據提供的內聯約束表達式創建對應的IInlineConstraintResolver對象時,DefaultInlineConstraintResolver會根據指定表達式獲得以字符串表示的約束類型和參數列表。通過解析出來的約束類型名稱,它可以從ConstraintMap屬性表示的映射關系中得到對應的IRouteConstraint類型。接下來它根據參數個數得到匹配的構造函數,然后將字符串表示的參數轉換成對應的參數類型,並以反射的形式將它們傳入構造函數,進而創建出相應的IHttpRouteConstraint對象。
對於一個通過指定的路由模板創建的Route對象來說,它在初始化時會利用IServiceProvider獲取這個IInlineConstraintResolver對象,並用它來解析定義在路由模板中的所有內聯約束表達式,最后將它們全部轉換成具體的IRouteConstraint對象。針對IInlineConstraintResolver的服務注冊就實現在IServiceCollection接口的AddRouting擴展方法中。
四、自定義約束
我們可以使用上述這些預定義的IRouteConstraint實現類型完成一些常用的約束,但是在一些對路由參數具有特定約束的應用場景中,我們不得不創建自定義的約束類型。例如,如果需要對資源提供針對多語言的支持,最好的方式是在請求的URL中提供目標資源所針對的Culture。為了確保包含在URL中的是一個合法有效的Culture,最好為此定義相應的約束。
下面將通過一個簡單的實例來演示如何創建這樣一個用於驗證Culture的自定義路由約束。但在此之前需要先介紹使用這個約束最終實現的效果。在本例中我們創建了一個提供基於不同語言資源的Web API,簡單起見,我們僅僅提供針對相應Culture的文本數據。可以將資源文件作為文本資源進行存儲,如下圖所示,我們在一個ASP.NET Core應用中創建了兩個資源文件,即Resources.resx(語言文化中性)和Resources.zh.resx(中文),並定義了一個名為hello的文本資源條目。(S1508)
我們在演示程序中注冊了一個模板為“resources/{lang:culture}/{resourceName:required}”的路由。路由參數{resourceName}表示獲取的資源條目的名稱(如hello),這是一個必需的路由參數(路由參數應用了RequiredRouteConstraint約束)。另一個路由參數{lang}表示指定的語言,約束表達式名稱culture對應的就是我們自定義的針對語言文化的約束類型CultureConstraint。也正是因為這是一個自定義的路由約束,所以必須預先注冊內聯約束表達式名稱culture和CultureConstraint類型之間的映射關系,在調用AddRouting方法時應將這樣的映射添加到注冊的RouteOptions之中。
public class Program { public static void Main() { var template = "resources/{lang:culture}/{resourceName:required}"; Host.CreateDefaultBuilder() .ConfigureWebHostDefaults(builder => builder .ConfigureServices(svcs => svcs .AddRouting(options => options.ConstraintMap.Add("culture",typeof(CultureConstraint)))) .Configure(app => app .UseRouting() .UseEndpoints(routes => routes.MapGet( template, BuildHandler(routes.CreateApplicationBuilder()))))) .Build() .Run(); static RequestDelegate BuildHandler(IApplicationBuilder app) { app.UseMiddleware<LocalizationMiddleware>("lang") .Run(async context => { var values = context.GetRouteData().Values; var resourceName = values["resourceName"].ToString().ToLower(); await context.Response.WriteAsync( Resources.ResourceManager.GetString(resourceName)); }); return app.Build(); } } }
我們通過調用UseEndpoints擴展方法注冊了路由終結點。該終結點的RequestDelegate對象是利用IEndpointRouteBuilder對象的CreateApplicationBuilder方法返回的IApplicationBuilder對象構建的。我們在這個IApplicationBuilder對象上注冊了一個自定義的LocalizationMiddleware中間件,這個中間件可以實現針對多語言的本地化。至於資源內容的響應,我們將它實現在通過調用IApplicationBuilder對象的Run方法注冊的中間件上。先從解析出來的路由參數中獲取目標資源條目的名稱,然后利用資源文件自動生成的Resources類型獲取對應的資源內容並響應給客戶端。
在揭秘自定義路由約束CultureConstraint及LocalizationMiddleware中間件的實現原理之前,需要先了解客戶端采用什么樣的形式獲取某個資源條目針對某種語言的內容。如下圖所示,直接利用瀏覽器采用與注冊路由相匹配的URL(“/resources/en/hello”或者“/resources/zh/hello”)不但可以獲取目標資源的內容,而且顯示的語言與我們指定的語言文化是一致的。如果指定一個不合法的語言(如“xx”),將會違反我們自定義的約束,此時就會得到一個狀態碼為“404 Not Found”的響應。
下面介紹針對語言文化的路由約束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 object value)) { return !new CultureInfo(value.ToString()).EnglishName.StartsWith("Unknown Language"); } return false; } catch { return false; } } }
應用在運行的時候具有根據當前線程的語言文化屬性選擇對應匹配資源的能力。就我們演示實例提供的兩個資源文件(Resources.resx和Resources.zh.resx)來說,如果當前線程的UICulture屬性代表的是一個針對“zh”的語言文化,資源文件Resources.zh.resx就會被選擇。對於其他語言文化,被選擇的就是這個中性的Resources.resx文件。換句話說,如果要讓應用程序選擇某個我們希望的資源文件,就需要為當前線程設置相應的語言文化,實際上,LocalizationMiddleware中間件就是這樣做的。
public class LocalizationMiddleware { private readonly RequestDelegate _next; private readonly string _routeKey; public LocalizationMiddleware(RequestDelegate next, string routeKey) { _next = next; _routeKey = routeKey; } public async Task InvokeAsync(HttpContext context) { var currentCulture = CultureInfo.CurrentCulture; var currentUICulture = CultureInfo.CurrentUICulture; try { if (context.GetRouteData().Values.TryGetValue(_routeKey, out var culture)) { CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = new CultureInfo(culture.ToString()); } await _next(context); } finally { CultureInfo.CurrentCulture = currentCulture; CultureInfo.CurrentUICulture = currentUICulture; } } }
如上面的代碼片段所示,LocalizationMiddleware中間件的InvokeAsync方法被執行時,它會試圖從路由參數中得到目標語言,代表路由參數名稱的字段_routeKey是在構造函數中初始化的。如果存在這樣的路由參數,它會據此創建一個CultureInfo對象並將其作為當前線程的Culture屬性和CultureInfo屬性。值得注意的是,在完成后續請求處理流程之后,我們需要將當前線程的語言文化恢復到之前的狀態。
ASP.NET Core路由中間件[1]: 終結點與URL的映射
ASP.NET Core路由中間件[2]: 路由模式
ASP.NET Core路由中間件[3]: 終結點
ASP.NET Core路由中間件[4]: EndpointRoutingMiddleware和EndpointMiddleware
ASP.NET Core路由中間件[5]: 路由約束