🌑

小羊儿的心情天空

ASP.NET Core 修改 EndpointRouting 的链接生成行为

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 是否继续复制隐式路由值。

    • 如果 ambientValues 为空引用,那么不复制。
    • 对于每一个在上一步中确定的显式值,检查和隐式路由值是否相同。检查过程会考虑值相等、any值匹配、显式null值。
  • 对于每一个路由参数,检查是否存在显式值、隐式值。

    • 如果还可以复制隐式值,那么检查此处的显式和隐式值是否匹配。如果不匹配,那么不再复制后面的隐式值。
    • 如果不能再复制隐式值,并且没有显式值,但它确实是路由需要的值,而且存在隐式值,并且和要求的值相等,那么使用它。
    • 如果在以上两种情况中匹配成功,那么将它加入 accepted 匹配成功的路由字典。
    • 如果是可选参数或者通配参数,那么将它从路由字典中忽略。
    • 如果没有匹配但是有默认值,那么使用默认值。
    • 以上过程均没有对应的话,认为此Endpoint匹配失败,不适用于此结果。
  • 对每个filter字段进行检查。

    • 如果存在显式值,检查两者是否相等,如果不相等那么就是匹配失败
    • 如果不存在显式值,那么不加入 accepted 匹配成功的路由字典。
  • 如果存在没有处理的显式值,加入 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;
}