ASP.NET Core路由中介軟體[5]: 路由約束
表示路由終結點的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]: 路