ASP.NET Core的路由[5]:内联路由约束的检验
当某个请求能够被成功路由的前提是它满足某个Route对象设置的路由规则,具体来说,当前请求的URL不仅需要满足路由模板体现的路径模式,请求还需要满足Route对象的所有约束。路由系统采用IRouteConstraint接口来表示路由约束,所以我们在接下来的内容中将路由约束统称为RouteConstraint。 在大部分情况下,约束都是针对路由模板中定义的某个路由参数,其目的在于验证URL携带的某部分的内容是否有效。不过也有一些约束与路由参数无关,这些约束规范往往是除URL之前的其他请求元素,比如前面提到的HttpMethodRouteConstraint检验的就是请求采用的方法。 [本文已经同步到《ASP.NET Core框架揭秘》之中]
1: public interface IRouteConstraint
2: {
3: bool Match(HttpContext httpContext, IRouter route, string routeKey,
4: RouteValueDictionary values, RouteDirection routeDirection);
5: }
如上面的代码片段所示,IRouteConstraint接口仅仅定义了如下一个唯一的Match方法来定义约束规范。方法的参数分别是代表当前请求上下文的HttpContext、当前Router对象、约束在约束字典中的Key(对于针对路由参数的约束,这个Key就是路由参数的名称)、从请求URL解析出来的所有路由参数和路由方向(针对入栈请求进行的路由解析还是为了生成URL而进行的路由解析)。
一、预定义RouteConstraint
路由系统定义了一系列原生的RouteConstraint类型,我们可以使用它们解决很多常见的约束问题,即使现有的RouteConstraint类型无法满足某些特殊的约束需求,我们还可以自定义对应的RouteConstraint类型。对于路由约束的应用,除了直接创建对应的RouteConstraint对象之外,我们知道还可以采用内联的方式直接在路由模板中定义为某个路由参数定义相应的约束表达式。这些以表达式定义的约束类型其实对应着一种具体的RouteConstraint类型。下表列出了两者之间的匹配关系。
内联约束类型 |
RouteConstraint类型 |
说明 |
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} |
minlength |
MinLengthRouteConstraint |
要求参数值表示的字符串不于指定的长度{ variable:minlength(5)} |
maxlength |
MaxLengthRouteConstraint |
要求参数值表示的字符串不大于指定的长度,比如{ variable:maxlength(10)} |
length |
LengthRouteConstraint |
要求参数值表示的字符串长度限于指定的区间范围,比如{ variable:length(5,10)} |
min |
MinRouteConstraint |
要求参数值不于指定的值,比如{ variable:min(5)} |
max |
MaxRouteConstraint |
要求参数值大于指定的值,比如{ variable:max(10)} |
range |
RangeouteConstraint |
要求参数值介于指定的区间范围,比如{variable:range(5,10)} |
alpha |
AlphaRouteContraint |
要求参数值得所有字符都是字母,比如{variable:alpha} |
regex |
RegexInlineRouteConstraint |
要求参数值表示字符串与指定的正则表达式相匹配,比如{variable:regex(^d{0[0-9]{{2,3}-d{2}-d{4}$)}}}$)} |
required |
RequiredRouteConstraint |
要求参数值不应该是一个空字符串,比如{variable:required} |
RangeRouteConstraint
为了让读者朋友们对这些RouteConstraint具有更加深刻的理解,我们选择一个用于限制变量值范围的RangeRouteConstraint类进行单独介绍。如下面的代码片断所示,RangeRouteConstraint类型具有两个长整型的只读属性Max和Min,它们分别表示约束范围的上下限。
1: public class RangeRouteConstraint : IRouteConstraint
2: {
3: public long Max { get; }
4: public long Min { get; }
5: public RangeRouteConstraint(long min, long max)
6: {
7: this.Min = min;
8: this.Max = max;
9: }
10:
11: public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection)
12: {
13: object value;
14: if (values.TryGetValue(routeKey, out value) && value != null)
15: {
16: long longValue;
17: var valueString = Convert.ToString(value, CultureInfo.InvariantCulture);
18: if (long.TryParse(valueString, NumberStyles.Integer, CultureInfo.InvariantCulture, out longValue))
19: {
20: return longValue >= Min && longValue <= Max;
21: }
22: }
23: return false;
24: }
25: }
具体的约束检验实现在Match方法中。具体来说,RangeRouteConstraint根据被检验变量的名称(对应于routeKey参数)从参数values(表示路由检验生成的所有路由变量)中提取被验证的参数值,然后判断它是否在通过属性Max和Min表示的数值范围内。
HttpMethodRouteConstraint
上面介绍的这些预定义的RouteConstraint类型都是对某个路由参数的值加以约束,除此之外还具有一个特殊的名为HttpMethodRouteConstraint的约束。我们在上面已经提到过,这个约束并不是应用在具有某个路由参数上,而是应用到整个请求上,它要求匹配的请求必须具有指定的方法。当我们在使用这种约束的时候,一般将对应的Key设置为“httpMethod”。
1: public class HttpMethodRouteConstraint : IRouteConstraint
2: {
3: public IList<string> AllowedMethods { get; }
4:
5: public HttpMethodRouteConstraint(params string[] allowedMethods)
6: {
7: this.AllowedMethods = new List<string>(allowedMethods);
8: }
9:
10: public virtual bool Match(HttpContext httpContext, IRouter route, string routeKey,RouteValueDictionary values, RouteDirection routeDirection)
11: {
12: switch (routeDirection)
13: {
14: case RouteDirection.IncomingRequest:return AllowedMethods.Contains(httpContext.Request.Method, StringComparer.OrdinalIgnoreCase);
15:
16: case RouteDirection.UrlGeneration:
17: object obj;
18: if (!values.TryGetValue(routeKey, out obj))
19: {
20: return true;
21: }
22: return AllowedMethods.Contains(Convert.ToString(obj), StringComparer.OrdinalIgnoreCase);
23:
24: default:throw new ArgumentOutOfRangeException(nameof(routeDirection));
25: }
26: }
27: }
当我们在创建一个 HttpMethodRouteConstraint对象的时候,需要指定一个允许的HTTP方法列表。对于针对入栈请求的路由解析来说,HttpMethodRouteConstraint会检验当前请求采用的方法是否在这个列表之内。如果路由解析是为了生成URL,HttpMethodRouteConstraint会从指定的参数列表中提取指定的HTTP方法,如果这样的参数存在,则会检验这个HTTP方法是否在允许的列表之内,否则意味着不需要针对HTTP方法进行验证。
二、InlineConstraintResolver
如果在进行路由注册的时候针对路由变量的约束是直接以内联表达式的形式定义在路由模板中,所以路由系统需要解析约束表达式来创建对应类型的RouteConstraint对象,这项任务由一个叫做InlineConstraintResolver的对象来完成。所有的InlineConstraintResolver类型实现了具有如下定义的IInlineConstraintResolver接口,定义其中的唯一方法ResolveConstraint实现了约束从字符串表达式到RouteConstraint对象之间的转换。
1: public interface IInlineConstraintResolver
2: {
3: IRouteConstraint ResolveConstraint(string inlineConstraint);
4: }
路由系统只定义了一个唯一的InlineConstraintResolver类型实现了这个接口,它就是DefaultInlineConstraintResolver类型。如下面的代码片断所示,它具有一个字典类型的字段_inlineConstraintMap,如表1所示的内联约束类型与对应RouteConstraint类型之间的映射关系就保存在这个字典中。
1: public class DefaultInlineConstraintResolver : IInlineConstraintResolver
2: {
3: private readonly IDictionary<string, Type> _inlineConstraintMap;
4: public DefaultInlineConstraintResolver(IOptions<RouteOptions> routeOptions)
5: {
6: _inlineConstraintMap = routeOptions.Value.ConstraintMap;
7: }
8: public virtual IRouteConstraint ResolveConstraint(string inlineConstraint);
9: }
10:
11: public class RouteOptions
12: {
13: public IDictionary<string, Type> ConstraintMap { get; set; }
14: public bool LowercaseUrls { get; set; }
15: public bool AppendTrailingSlash { get; set; }
16: }
DefaultInlineConstraintResolver首先根据指定的约束表达式获得以字符串表示的约束类型和参数列表。通过约束类型,它可以从ConstraintMap属性表示的映射关系中得到对应的HttpRouteConstraint类型。接下来它根据参数个数得到匹配的构造函数,然后将字符串表示的参数转换成对应的参数类型并以反射的形式将它们传入构造函数创建相应的HttpRouteConstraint对象。
对于一个通过指定的路由模板创建的Route对象来说,当它在初始化的时候会利用ServiceProvider采用依赖注入的形式获取这个InlineConstraintResolver对象来解析定义在路由模板中的内联约束表达式,并将它们全部转换成具体的RouteConstraint对象。这意味着在这之前,针对InlineConstraintResolver的服务注册就以及存在,那么这个服务是在什么时候注册的呢?
当我们在一个ASP.NET Core应用中使用路由功能的时候,除了需要注册这个RouterMiddleware中间件之外,一般还需要调用ServiceCollection的扩展方法AddRouting注册一些与路由相关的服务,针对InlineConstraintResolver的服务注册就实现在这个方法之中。
三、自定义约束
我们可以使用上述这些预定义的RouteConstraint类们完成一些常用的约束检验,但是在一些对路由变量具有特殊的约束的应用场景中,我们不得不创建自定义的约束。举个简单的例子,如果我们需要对资源提供针对多语言的支持,最好的方式是在请求的URL中提供目标资源所针对的Culture。为了确保包含在URL中的是一个合法有效的Culture,我们最好为此定义相应的约束。
接下来,我们将通过一个简单的实例来演示如何创建这么一个用于验证Culture的自定义约束。不过在这之前我们不妨先来看看使用这个约束最终实现的效果。在本例中我们创建了一个提供基于不同语言资源的Web API,简单起见,我们仅仅提供针对相应Culture的文本数据。我们利用资源文件来作为文本资源的存储,如下图所示,我们在一个ASP.NET Core应用中创建了两个资源文件Resources.resx(语言文化中性)和Resources.zh.resx(中文),并定义了一个名为“hello”的文本资源条目。
如下所示的是整个应用程序的定义。这段程序非常简单,我们注册了一个模板为“resources/{lang:culture}/{resourcename:required}”的路由。路由参数{ resourcename }表示获取的资源条目的名称(比如“hello”),这是一个必需的路由参数(应用了RequiredRouteConstraint约束)。另一个路由参数{lang}表示指定的语言,约束表达式名称“culture”对应的就是我们自定义的针对语言文件的约束类型CultureConstraint。也正是因为是一个自定义的路由约束,我们必须将内联约束表达式名称和CultureConstraint类型之间的应用,我们在调用ConfigureServices方法中将这样的映射添加到注册的RouteOptions之中。
1: public class Program
2: {
3: public static void Main()
4: {
5: string template = "resources/{lang:culture}/{resourceName:required}";
6:
7: Action<IApplicationBuilder> action = app => app
8: .UseMiddleware<LocalizationMiddleware>("lang")
9: .Run(async context =>
10: {
11: var values = context.GetRouteData().Values;
12: string resourceName = values["resourceName"].ToString().ToLower();
13: await context.Response.WriteAsync(Resources.ResourceManager.GetString(resourceName));
14: });
15:
16: new WebHostBuilder()
17: .UseKestrel()
18: .ConfigureServices(svcs => svcs
19: .AddRouting()
20: .Configure<RouteOptions>(options=>options.ConstraintMap.Add("culture", typeof(CultureConstraint))))
21: .Configure(app =>app.UseRouter(builder=> builder.MapRoute(template, action)))
22: .Build()
23: .Run();
24: }
25: }
我们通过调用扩展方法MapRoute注册了这个路由。利用作为参数的Action<IApplicationBuilder>对象,我们注册了一个自定义的LocalizationMiddleware中间件,这个中间件实现针对多语言的本地化。至于资源内容的响应,我们将它实现在通过调用ApplicationBuilder的Run方法注册的中间件上。我们从解析出来的路由参数中获取目标资源条目的名称,然后利用资源文件自动生成的Resoruces类型获取对应的资源内容并响应给客户端。
在揭秘CultureConstraint这个自定义路由约束以及LocalizationMiddleware中间件的实现原理之前,我们先来看看客户端采用是采用怎样的形式获取某个资源条目针对某种语言的内容。如下图所示,我们直接利用浏览器采用与注册路由相匹配的URL(“/resources/en/hello”或者“/resources/zh/hello”)不仅可以获取目标资源的内容,显示的语言也与我们指定的语言文化一致。如果指定一个不合法的语言(比如“xx”),将会违反我们自定义的约束,此时就会得到一个状态码为“404 Not Found”的响应。
接下来我们来看看这个针对语言文化的路由约束CultureConstraint就是做了些什么。如下面的代码片段所示,我们在Match方法中会试图获取作为语言文化内容的路由参数值,如果这样的路由参数存在,我们会利用它创建一个CultureInfo对象。如果这个CultureInfo的EnglishName属性名不以“Unknown Language”字符作为前缀,我们就认为指定的是合法的语言文件。
1: public class CultureConstraint : IRouteConstraint
2: {
3: public bool Match(HttpContext httpContext, IRouter route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection)
4: {
5: try
6: {
7: object value;
8: if (values.TryGetValue(routeKey, out value))
9: {
10: return !new CultureInfo(value.ToString()).EnglishName.StartsWith("Unknown Language");
11: }
12: return false;
13: }
14: catch
15: {
16: return false;
17: }
18: }
19: }
我们.NET应用在运行的时候具有根据当前线程的语言文化选择资源文件的能力。就我们这实例提供的两个资源文件(Resources.resx和Resources.zh.resx)来说,如果当前线程的UICulture属性代表的是一个针对“zh”的语言文化,资源文件Resources.zh.resx会被选择。对于其他语言文件,则被选择的就是这个Resources.resx文件。换句话说,如果我们要让运行时选择某个我们希望的资源文件,我们可以为当前线程设置相应的语言文化,实际上LocalizationMiddleware这个中间件就是这么做的。
1: public class LocalizationMiddleware
2: {
3: private RequestDelegate _next;
4: private string _routeKey;
5:
6: public LocalizationMiddleware(RequestDelegate next, string routeKey)
7: {
8: _next = next;
9: _routeKey = routeKey;
10: }
11:
12: public async Task Invoke(HttpContext context)
13: {
14: object culture;
15: CultureInfo currentCulture = CultureInfo.CurrentCulture;
16: CultureInfo currentUICulture = CultureInfo.CurrentUICulture;
17: try
18: {
19: if (context.GetRouteData().Values.TryGetValue(_routeKey, out culture))
20: {
21: CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = new CultureInfo(culture.ToString());
22: }
23: await _next(context);
24: }
25: finally
26: {
27: CultureInfo.CurrentCulture = currentCulture;
28: CultureInfo.CurrentUICulture = currentUICulture;
29: }
30: }
31: }
如上面的代码片段所示,LocalizationMiddleware的Invoke方法被执行的时候,它会试图从路由参数中得到目标语言,代表路由参数名称的字段_routeKey是在构造函数中初始化的。如果这样的路由参数存在,它会据此创建一个CultureInfo对象并将其作为当前线程的Culture和CultureInfo属性。值得一提的是,在完成后续请求处理流程之后,我们需要将当前线程的语言文化恢复到之前的状态。
ASP.NET Core的路由[1]:注册URL模式与HttpHandler的映射关系
ASP.NET Core的路由[2]:路由系统的核心对象——Router
ASP.NET Core的路由[3]:Router的创建者——RouteBuilder
ASP.NET Core的路由[4]:来认识一下实现路由的RouterMiddleware中间件
ASP.NET Core的路由[5]:内联路由约束的检验