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]: 路由约束