Mar 2, 2020 由 小羊
微软在 ASP.NET Core 2.2 时期引入了 EndpointRouting,并且在之后的 3.0 / 3.1 进行了很多的升级改造。我前段时间刚刚把网站升级到了 3.1,之前一直在使用 2.1 的兼容模式,并且内部有很多的属性路由的配置,而没有使用 DefaultRoute 那条规则。按照官方的文档将 UseMvc 那套替换成了 EndpointRouting 那一套。然后发现,很多的链接生成出了问题。
由于之前略微读过 AnchorTagHelper 的源码,大约知道问题出在了 UrlHelper 身上,将。随后发现, 在使用终结点路由后,UrlHelper 的实现类型变成了 EndpointRoutingUrlHelper。而后者则在内部调用了 LinkGenerator,其默认实现为 DefaultLinkGenerator。
LinkGenerator 的实现使用到了几个比较重要的对象:EndpointDataSource 是所有终结点的集合,TemplateBinderFactory 是根据终结点的 RoutePattern 生成 TemplateBinder 的工厂,TemplateBinder 是一个保存了 RoutePattern 内部信息(例如默认RouteValues、链接模板)并提供实际链接生成的工具。由于微软在这方面的代码编写中全都 internal 了,且代码中注释较少,在接下来我将介绍他们的基本工作流程,并给出一个能够基本做到“兼容”、不修改太多代码的方法。此处的解释仅仅针对属性路由。
请关注 GetEndpoints<TAddress>(TAddress address)
函数。此处,TAddress 取值有两种:string、RouteValuesAddress。
当 TAddress 为 string 时,请回顾 RouteAttribute 中的 Name 属性。没错就是这个,在 anchor 链接使用 asp-route=“something” 的时候,路由的查找是根据此处进行的。在生成终结点时,程序已经确保了所有的 RouteName 都唯一。查找直接找字典就好了。
当 TAddress 为 RouteValuesAddress 时,是使用 asp-area、asp-controller、asp-page、asp-action、asp-route-xxx 时进行的寻址方式。此 Address 由三个部分组成,其中包括 RouteName、ExplicitValues、AmbientValues。根据 RoutePattern 中的 RequiredValues 来检查 ExplicitValues 和 AmbientValues 的值。
结束后返回一个终结点列表,其中包含可能匹配的结果。我们针对每个可能的终结点,尝试匹配路由模板。
生成一个 TemplateBinder,这个对象提供三个函数:GetValues、TryProcessConstraints、TryBindValues。
GetValues 会根据 RoutePattern 和两种 RouteValue 生成一个最终匹配列表,使用了 Ambient Values Invalidation Algorithm,在接下来介绍。
TryProcessConstraints 是根据上方匹配,检查是否满足路由值的限制。
TryBindValues 是将获取的 RouteValues 生成最后的终结点链接。
首先介绍几个字段,在接下来会被算法使用到。
_slots 是一个 KVP<string, object>,其 Key 为路由值的名称,Value 为 null。其中前几个字段依次是 pattern 的参数,后几个字段是filter默认值。
_requiredKeys 是一个 string 数组,是以 RoutePattern 的字段中提取的。
_defaults 是默认路由值的字典。
_pattern 是 RoutePattern。
ambientValues 是目前页面的路由值字典。
values 是即将导航的页面的路由值字典,一般由asp-action等等计算得到。
最开始会将“需要填的空格”复制一份,以供本次计算使用。
首先将所有需要填的空在 values 中寻找一遍,决定使用该值或者 explicit null 值。
考虑针对每个 requiredKeys 是否继续复制隐式路由值。
对于每一个路由参数,检查是否存在显式值、隐式值。
对每个filter字段进行检查。
如果存在没有处理的显式值,加入 combined 合并后的路由字典。
对于一些非参数的隐式值,需要加入到 combined 字典,这样可以对路由限制可见。
以上对于 /[area]/{area_id}/[controller]/[action]
非常的不友好。
可以通过将该 TemplateBinder 的 _requiredValues 清空来达到这一点操作。但是这是 readonly 的 private 字段,所以使用反射解决。
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing.Template;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
namespace Microsoft.AspNetCore.Routing
{
public sealed class OrderLinkGenerator : LinkGenerator, IDisposable
{
private readonly LinkGenerator inner;
private readonly TemplateBinderFactory _binderFactory;
private readonly Func<RouteEndpoint, TemplateBinder> _createTemplateBinder;
private readonly FieldInfo _requiredKeys;
const string typeName = "Microsoft.AspNetCore.Routing.DefaultLinkGenerator";
internal static Type typeInner;
public OrderLinkGenerator(
ParameterPolicyFactory parameterPolicyFactory,
TemplateBinderFactory binderFactory,
EndpointDataSource dataSource,
IOptions<RouteOptions> routeOptions,
IServiceProvider serviceProvider)
{
if (typeInner.FullName != typeName)
throw new NotImplementedException();
var logger = serviceProvider.GetService(typeof(ILogger<>).MakeGenericType(typeInner));
var autoFlag = BindingFlags.NonPublic | BindingFlags.Instance;
var args = new object[]
{
parameterPolicyFactory,
binderFactory,
dataSource,
routeOptions,
logger,
serviceProvider
};
var ctorInfo = typeInner.GetConstructors()[0];
var newExp = Expression.New(ctorInfo, args.Select(o => Expression.Constant(o)));
var ctor = Expression.Lambda<Func<LinkGenerator>>(newExp).Compile();
inner = ctor();
_binderFactory = binderFactory;
_createTemplateBinder = CreateTemplateBinder;
var fieldInfo = typeInner.GetField(nameof(_createTemplateBinder), autoFlag);
fieldInfo.SetValue(inner, _createTemplateBinder);
_requiredKeys = typeof(TemplateBinder).GetField(nameof(_requiredKeys), autoFlag);
}
private TemplateBinder CreateTemplateBinder(RouteEndpoint endpoint)
{
/*
* The following code section is disabled
* for its change to RoutePattern may cause
* errors.
*
* var rawText = endpoint.RoutePattern.RawText;
* var rv = endpoint.RoutePattern.RequiredValues as RouteValueDictionary;
*
* if (rawText != null)
* {
* var m = Regex.Matches(rawText, "\\{(\\w+)\\}");
* for (int i = 0; i < m.Count; i++)
* rv.Add(m[i].Value.TrimStart('{').TrimEnd('}'), RoutePattern.RequiredValueAny);
* }
*
* A better solution is to disable the _requiredKeys.
*/
var binder = _binderFactory.Create(endpoint.RoutePattern);
_requiredKeys.SetValue(binder, Array.Empty<string>());
return binder;
}
public override string GetPathByAddress<TAddress>(HttpContext httpContext, TAddress address, RouteValueDictionary values, RouteValueDictionary ambientValues = null, PathString? pathBase = null, FragmentString fragment = default, LinkOptions options = null) =>
inner.GetPathByAddress(httpContext, address, values, ambientValues, pathBase, fragment, options);
public override string GetPathByAddress<TAddress>(TAddress address, RouteValueDictionary values, PathString pathBase = default, FragmentString fragment = default, LinkOptions options = null) =>
inner.GetPathByAddress(address, values, pathBase, fragment, options);
public override string GetUriByAddress<TAddress>(HttpContext httpContext, TAddress address, RouteValueDictionary values, RouteValueDictionary ambientValues = null, string scheme = null, HostString? host = null, PathString? pathBase = null, FragmentString fragment = default, LinkOptions options = null) =>
inner.GetUriByAddress(httpContext, address, values, ambientValues, scheme, host, pathBase, fragment, options);
public override string GetUriByAddress<TAddress>(TAddress address, RouteValueDictionary values, string scheme, HostString host, PathString pathBase = default, FragmentString fragment = default, LinkOptions options = null) =>
inner.GetUriByAddress(address, values, scheme, host, pathBase, fragment, options);
public void Dispose() => ((IDisposable)inner).Dispose();
}
}
最后在依赖注入容器中替换即可。
public static IMvcBuilder ReplaceLinkGenerator(this IMvcBuilder mvc)
{
var old = mvc.Services.FirstOrDefault(s => s.ServiceType == typeof(LinkGenerator));
OrderLinkGenerator.typeInner = old.ImplementationType;
mvc.Services.Replace(ServiceDescriptor.Singleton<LinkGenerator, OrderLinkGenerator>());
return mvc;
}