翻译 - ASP.NET Core 基本知识 - 路由

翻译自:https://docs.microsoft.com/en-us/aspnet/core/fundamentals/routing?view=aspnetcore-5.0

路由负责匹配 Http 请求,然后分发这些请求到应用程序的最终执行点。Endpoints 是应用程序可执行请求处理的代码单元。Endpoints 在应用程序中定义并在应用程序启动的时候配置。

Endpoint 匹配处理可以从请求的 URL 中提出值和为请求处理提供值。使用从应用程序获取的 Endpoint 信息,路由也可以生成匹配 Endpoint 的 URLS。

应用可以通过以下方式配置路由:

  • 控制器
  • Razor Pages
  • SignalR
  • gPRC Services
  • Endpoint - enable 中间件,例如: Health Checks.
  • 使用路由注册的代理和 lambdas.

这篇文档涵盖了ASP.NET Core 路由的底层详情。

这篇文档中描述的 Endpoint 路由系统适用于 ASP.NET Core 3.0 或者更新的版本。

路由基础

所有的 ASP.NET Core 模板代码中都包含路由。路由在 Startup.Configure 中注册在中间件管道中。

下面代码展示了一个路由的示例:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseRouting();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapGet("/", async context =>
        {
            await context.Response.WriteAsync("Hello World!");
        });
    });
}

路由使用了一对中间件,通过 UseRouting 和 UseEndPoints 注册:

  • UseRouting 添加路由匹配到中间件管道中。这个路由匹配中间件查询在应用程序中定义的 Endpoints 的集合,选择最佳匹配请求的 Endpoint。
  • UseEndpoints 添加 Endpoint 的执行体到中间件管道。它通过关联到选择的 Endpoint 代理执行。

前面这个示例包含了一个单独的路由到代码的 Endpoint 使用 MapGet 方法:

  • 当一个 HTTP GET 请求发送到根 URL /:
    - 请求代理展示执行
    - Hello World! 被写入 HTTP 请求回应中。默认的,根 URL / 是 https://localhost:5001/。
  • 如果说请求方法不是 GET 或者 根 URL 不是 /,没有路由匹配的情况下会返回 HTTP 404。

 Endpoint

MapGet 方法用来定义一个 Endpoint。一个 endpoint 可以是以下情况:

  • 选择:通过匹配 URL 和 HTTP 方法
  • 执行:通过执行代理

在 UseEndpoints 中配置的 Endpoints 可以被 APP 匹配和执行。例如,MapGet, MapPost, 和一些类似于连接请求代理到路由系统的方法。更多的方法可以被用于连接 ASP.NET Core 框架的特性到路由系统中:

  • MapRazorPages 用于 Razor Pages
  • MapController 用于 控制器
  • MapHub 用户 SignalR
  • MapGrpcService 用于 gPRC

下面这个例子展示了一个路由一个比较复杂的路由模板:

app.UseEndpoints(endpoints =>
{
    endpoints.MapGet("/hello/{name:alpha}", async context =>
    {
        var name = context.Request.RouteValues["name"];
        await context.Response.WriteAsync($"Hello {name}!");
    });
});

字符串 /hello/{name:alpha} 是一个路有模板。被用来配置 endpoint 如何被匹配到。在这个例子中,模板匹配以下情况:

  • 像 /hello/Ryan 这样一个 URL
  • 任何的以 /hello/ 开头的,紧跟一串字母的 URL。:alpha 应用了一个路由约束,它仅仅匹配字母。路由约束会在下面介绍到。

{name:alpha}: 上面 URL 路径中的第二段:

  • 被绑定到 name 参数上
  • 被捕获并存储到 HttpRequest.RouteValues 中

 当前文档中描述的 endpoint 路由系统是在 ASP.NET Core 3.0 中新添加的。然而,所有版本的 ASP.NET Core 都支持同样的路由模板特性和路由约束的集合。

下面的示例展示了带有 health checks 和 授权的路由:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    // Matches request to an endpoint.
    app.UseRouting();

    // Endpoint aware middleware. 
    // Middleware can use metadata from the matched endpoint.
    app.UseAuthentication();
    app.UseAuthorization();

    // Execute the matched endpoint.
    app.UseEndpoints(endpoints =>
    {
        // Configure the Health Check endpoint and require an authorized user.
        endpoints.MapHealthChecks("/healthz").RequireAuthorization();

        // Configure another endpoint, no authorization requirements.
        endpoints.MapGet("/", async context =>
        {
            await context.Response.WriteAsync("Hello World!");
        });
    });
}

上面这个示例展示了如何:

  • 授权中间件可以被路由使用
  • Endpoints 可以被用于配置授权行为

MapHealthChecks 添加了一个 health check endpoint。接着又调用了 RequireAuthorization 附加了一个授权策略到这个 endpoint 上。

UseAuthentication 和 UseAuthorization 添加认证和授权中间件。这些中间件在 UseRouting 和 UseEndpoints 中间调用,因此可以:

  • 查看哪个 endpoint 被选中通过 UseRouting
  • 在 UseEndpoints 分发请求到 endpoint 之前应用授权策略

Endpoint 元信息

在前面这个例子中,有两个 endpoints,但是只有 health check 附加了一个授权策略。如果请求匹配了 health check, /healthz,授权检查就会被执行。这说明 endpoints 可以有额外的数据附加到他们上面。这写额外的数据叫做 endpoint metadata:

  • metadata 可以被路由中间件处理
  • metadata 可以是任何的 .NET 类型

路由的概念

路由系统通过添加强大的 endpoint 概念建立在中间件管道之上。Endpoints 代表了一组应用程序的功能,这些功能和路由,授权和 ASP.NET Core 核心系统功能是不同的。

ASP.ENT Core 中 endpoint 的定义

ASP.NET Core endpoint:

  • 可执行的:包含一个请求代理
  • 可扩展的:包含一个 Meatadata 集合
  • 可选择的:可选的,包含路由信息
  • 可枚举的:enpoint 的集合可以通过 EndpointDataSource 获取被列出来

下面的代码展示了如何获取和检查匹配当前请求的 endpoint:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseRouting();

    app.Use(next => context =>
    {
        var endpoint = context.GetEndpoint();
        if (endpoint is null)
        {
            return Task.CompletedTask;
        }
        
        Console.WriteLine($"Endpoint: {endpoint.DisplayName}");

        if (endpoint is RouteEndpoint routeEndpoint)
        {
            Console.WriteLine("Endpoint has route pattern: " +
                routeEndpoint.RoutePattern.RawText);
        }

        foreach (var metadata in endpoint.Metadata)
        {
            Console.WriteLine($"Endpoint has metadata: {metadata}");
        }

        return Task.CompletedTask;
    });

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapGet("/", async context =>
        {
            await context.Response.WriteAsync("Hello World!");
        });
    });
}

如果 endpoint 被选中了,那么可以从 HttpContext 中获取到。它的属性可以被检测到。Endpoint 对象是不可变的,创建之后就不可以修改了。最常见的 endpoint 类型是 RouteEnpoint。RouteEndpoint 包含了它可以被路由系统选择的信息。

在前面的代码中,app.Use 配置了一个行内的 middleware。

下面的代码展示了由于 app.Use 调用位置不同,可能就没有一个 enpoint。

// Location 1: before routing runs, endpoint is always null here
app.Use(next => context =>
{
    Console.WriteLine($"1. Endpoint: {context.GetEndpoint()?.DisplayName ?? "(null)"}");
    return next(context);
});

app.UseRouting();

// Location 2: after routing runs, endpoint will be non-null if routing found a match
app.Use(next => context =>
{
    Console.WriteLine($"2. Endpoint: {context.GetEndpoint()?.DisplayName ?? "(null)"}");
    return next(context);
});

app.UseEndpoints(endpoints =>
{
    // Location 3: runs when this endpoint matches
    endpoints.MapGet("/", context =>
    {
        Console.WriteLine(
            $"3. Endpoint: {context.GetEndpoint()?.DisplayName ?? "(null)"}");
        return Task.CompletedTask;
    }).WithDisplayName("Hello");
});

// Location 4: runs after UseEndpoints - will only run if there was no match
app.Use(next => context =>
{
    Console.WriteLine($"4. Endpoint: {context.GetEndpoint()?.DisplayName ?? "(null)"}");
    return next(context);
});

上面示例添加了 Console.WriteLine 语句,显示了是否一个 endpoint 被选中。为了清晰,示例中为 / endpoint 增加了名称显示。

运行这段代码,访问 /,将会显示:

1. Endpoint: (null)
2. Endpoint: Hello
3. Endpoint: Hello

如果访问其他 URL,则会显示:

1. Endpoint: (null)
2. Endpoint: (null)
4. Endpoint: (null)

输出结果说明了:

  • 在 UseRouting 调用之前,enpoint 总是 null
  • 在 UseRouting 和 UseEndpoints 之间,如果一个匹配被发现,endpoin 就不是 null
  • 当一个匹配被发现时,UseEndpoints 中间件会是一个终结。终结中间件在文档后面会讲到
  • 在 UseEndpoints 之后的中间件只有在没有匹配被发现的时候才会执行

UseRouting 中间件使用 SetEndpoint 方法把 endpoint 附加到当前请求上下文。可以使用自定义的逻辑替换掉 UseRouting 并且使用 endpoint 好处。Endpoints 是和中间件类似的低级别的原语,不和路由的实现耦合在一起。大多数的应用程序不需要自定义逻辑替换 UseRouting。

UseEndpoints 中间件被设计用来和 UseRouting 中间件配合使用。执行一个 endpoint 的核心逻辑并不复杂。 使用 GetEndpoint 获取 endpoint,然后调用它的 RequestDelegate 属性。

下面的代码展示了中间件如何对路由产生影响或者做出反应:

public class IntegratedMiddlewareStartup
{ 
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        if (env.IsDevelopment())
        {
            app.UseDeveloperExceptionPage();
        }

        // Location 1: Before routing runs. Can influence request before routing runs.
        app.UseHttpMethodOverride();

        app.UseRouting();

        // Location 2: After routing runs. Middleware can match based on metadata.
        app.Use(next => context =>
        {
            var endpoint = context.GetEndpoint();
            if (endpoint?.Metadata.GetMetadata()?.NeedsAudit
                                                                            == true)
            {
                Console.WriteLine($"ACCESS TO SENSITIVE DATA AT: {DateTime.UtcNow}");
            }

            return next(context);
        });

        app.UseEndpoints(endpoints =>
        {         
            endpoints.MapGet("/", async context =>
            {
                await context.Response.WriteAsync("Hello world!");
            });

            // Using metadata to configure the audit policy.
            endpoints.MapGet("/sensitive", async context =>
            {
                await context.Response.WriteAsync("sensitive data");
            })
            .WithMetadata(new AuditPolicyAttribute(needsAudit: true));
        });

    } 
}

public class AuditPolicyAttribute : Attribute
{
    public AuditPolicyAttribute(bool needsAudit)
    {
        NeedsAudit = needsAudit;
    }

    public bool NeedsAudit { get; }
}

上面的示例展示了两个重要的概念;

  • 中间件可以在 UseRouting 之前运行,然后修改路由的运行行为。通常的,这些出现在路由之前的中间件,会修改一些请求的属性,比如 UseRewriter, UseHttpMethodOverride, 或者 UsePathBase。
  • 在中间件被执行之前,中间件可以运行在 UseRouting 和 UseEndpoints 之间处理一些路由的结果。运行在 UseRouting 和 UseEndpoints 之间的中间件:通常会检查 metadata 去理解 endpoints;通常会做出安全方面的决定,比如使用 UseAuthorization 和 UseCors。
  • 中间件和 metadata 的结合允许为每一个 endpoint 配置策略。

上面的代码展示了一个自定义的支持为每一个 endpoint 添加策略的 endpoint。这个中间件输出访问敏感数据的 audit log 到控制台。这个中间件可以使用 AuditPolicyAttribute metadata 配置为一个 audit enpoint。这个示例展示了一个选择模式,只有 enpoints 被标记为敏感的才会被验证。也可以反向定义逻辑,例如验证没有被标记为安全的一切。endpoint metadata 系统是灵活的。逻辑可以被设计为任何符合使用情况的方式。

上面的示例代码是为了展示 endpoints 的基本概念。示例不是为了用于生产环境。一个更完整的 audit log 中间件应该是这样的:

  • 日志保存到一个文件或者数据库中
  • 包含详细信息,例如用户,IP地址,敏感 endpoint 的名称以及更多的信息

audit 策略 metadata AuditPolicyAttribute 被定义为一个 Attribute 是为了在一个 class-based 的 framework 中更加容易使用,例如 controllers 和 SignalR。当使用路由编码时:

  • Metadata 是附加到一个 builder API 上的
  • Class-based frameworks 包含创建 endpoints 时关于对应的方法和类的所有的属性

对于 metadata 类型的最佳实践是把它们定义为接口或者属性。接口和属性允许代码复用。metadata 系统是灵活的并且不强加任何限制。

比较终端中间件和路由

下面的代码展示了使用中间件和使用路由的差别:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    // Approach 1: Writing a terminal middleware.
    app.Use(next => async context =>
    {
        if (context.Request.Path == "/")
        {
            await context.Response.WriteAsync("Hello terminal middleware!");
            return;
        }

        await next(context);
    });

    app.UseRouting();

    app.UseEndpoints(endpoints =>
    {
        // Approach 2: Using routing.
        endpoints.MapGet("/Movie", async context =>
        {
            await context.Response.WriteAsync("Hello routing!");
        });
    });
}

中间件的方式展示的方法1:一个终端中间件。被叫做终端中间件是因为它匹配了以下操作:

  • 前面例子中中间件匹配的操作是 Path = "/" ,路由匹配的操作是 Path = "/Movie"
  • 当一个匹配成功的时候,执行了一些功能然后返回了,而不是调用 next 执行下一个中间件

被叫做终端中间件是因为它终结了搜索,执行了一些操作,然后就返回了

比较一个终端中间件和路由:

  • 两种途径都允许终结处理管道:中间件通过返回语句而不是调用 next 方法终结管道;路由 Endpoints 总是结束运行
  • 终结中间件允许在管道的任意地方调用;Endpoints 执行的位置在 UseEnpoints 中
  • 终结中间件允许任意的的代码去决定什么时候去中间件匹配:自定义的路由匹配代码可能是很难写正确的;路由为典型的应用程序提供了一个直接的解决方案。大部分的应用程序不需要自定义路由匹配的逻辑
  • Enpoints 接口带有中间件,例如 UseAuthorizaton 和 UseCors: 通过调用 UseAuthorization 和 UseCors 使用一个终端中间件要求手动的实现授权系统

一个 endpoint 定义了:

  • 一个处理请求的代理
  • 一个任意 metadata 的集合。metadata 用来实现横向的基于策略的考虑和配置到每一个 endpoint

终结中间件可以是一个有效的工具,但是要求:

  • 大量的代码和测试
  • 手动的集成集成其它系统以获得更好的灵活性

在编写一个终结中间件之前,应该优先考虑使用集成到路由

现有的集成了 Map 或者 MapWhen 的终结中间件通常可以在一个路由中实现 endpoint。MapHealthChecks 展示了 router-ware 的模型:

  • 在 IEndpointRouteBuilder 接口上写了一个扩展方法
  • 使用 CreateApplicationBuilder 创建一个嵌套的中间件管道
  • 把中间件附加到一个新的管道,在这个例子中使用了 UseHealthChecks
  • 编译中间件管道到一个 RequestDelegate
  • 调用 Map 然后提供一个新的中间件管道
  • 从扩展方法中返回 Map 提供的编译后的对象

下面的代码展示了 MapHealthChecks 的使用:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    // Matches request to an endpoint.
    app.UseRouting();

    // Endpoint aware middleware. 
    // Middleware can use metadata from the matched endpoint.
    app.UseAuthentication();
    app.UseAuthorization();

    // Execute the matched endpoint.
    app.UseEndpoints(endpoints =>
    {
        // Configure the Health Check endpoint and require an authorized user.
        endpoints.MapHealthChecks("/healthz").RequireAuthorization();

        // Configure another endpoint, no authorization requirements.
        endpoints.MapGet("/", async context =>
        {
            await context.Response.WriteAsync("Hello World!");
        });
    });
}

上面的代码展示了为什么返回一个创建后的对象是重要的。返回一个创建的对象运行应用程序开发者去配置策略,例如 endpoint 授权策略。在这个例子中,health check 中间件没有直接集成授权系统。

metadata 系统的创建是为了响应扩展性作者在使用终结中间件时所遇到的问题。对于每一个中间件,它是不确定的,是为了实现中间件自己带有授权系统的集成。

URL matching

  • 是路由匹配进站请求到一个 endpoint 的过程
  • 基于 URL path 和 headers 中的数据
  • 可以被扩展为考虑请求中的任意数据

当一个路由中间件执行的时候,它设置一个 Endpoint 并且设置路由的值到一个从当前请求中获取的一个 HttpContext 的请求特性中:

  • 调用 HttpContext.GetEndpoint 获取一个 endpoint
  • HttpRequest.RouteValues 获取到路由值得集合

运行在路由中间件之后的中间件可以检测 endpoint 后决定执行的操作。举个例子,一个授权中间件可以查询 endpoint 的 metadata 集合实现授权策略。在请求处理管道中的所有中间件执行完毕后,被选择的 endpoint 的代理被调用。

基于 enpoint 路由的路由系统负责所有的请求分发的决定。因为中间件应用策略是基于被选择的 endpoint,这是很重要的:

  • 任何可能影响分发或者应用程序安全策略的决定都可以在路由系统中确定

 警告:为了向后兼容,当一个控制器或者 Razor 页面 endpoint 代理被执行的时候,目前基于执行的请求处理 RouteContext.RouteData 被设置为合适的值。

RouteContext 类型在以后的版本中将被标记为废弃的在:

  • 迁移 RouteData.Value 到 HttpRequest.RouteValues 中
  • 迁移 RaouteData.DataTokens 从 endpoint metadata 获取 IDataTokensMetadata

URL 匹配操作在一个可配置的分段集合中。在每一个分段中,输出是匹配的集合。通过下一个分段,匹配的集合会逐步缩小。路由的实现不保证匹配 endpoints 的处理顺序。所有可能的匹配一次就会处理。URL的匹配遵循以下顺序。

ASP.NET Core:

  1. 处理 URL path 和 endpoints 的集合比较和它们的路由模板,搜集所有匹配的
  2. 处理列表,移除不满足路由约束的匹配
  3. 处理列表,移除不满足 MatcherPolicy 示例集合的匹配
  4. 使用 EndpointSelector 从处理列表中做出最终决定

  enpoints 列表的优先级遵循以下原则:

  • RouteEndpoint.Order
  • 路由模板优先

所有匹配的 endpoints 在每个阶段直到 EndpointSelector 执行。 EnpointSelector 是最后一个阶段。它从所有匹配的 endpoints 中选择优先级最高的 endpoint 作为最佳匹配。如果有同样优先级的匹配,一个模糊匹配的异常将会抛出。

路由的优先级是基于一个更加具体的路由模板被计算出来被赋予一个更高的优先级。例如,比较一个两个路由模板  /hello 和 /{message}

  • 两个模板都匹配 URL path /hello
  • /hello 更加具体,因此有更高优先级

通常的,在实际中,路由优先级已经做了一个从各种各样的 URL schemes 中选择最佳匹配的很好的工作。仅仅在为了避免歧义的时候使用 Order。

由于路由提供了各种各样类型的扩展性,路由系统不太可能花费大量的时间去计算有歧义的路由。考虑一下像 /{message:alpha} 和 /{message:int} 这两个路由模板:

  • alpha 约束只匹配字母字符
  • int 约束只匹配数字
  • 这两个模板拥有相同的路由优先级,但是没有一个单独的 URL 是它们都匹配的
  • 如果路由系统在 startup 中报告了一个歧义错误,这将会阻止这种情况的使用

警告:

UseEnpoints 中的操作的顺序不会影响路由的行为,但有一个例外。 MapControllerRoute 和 MapAreaRoute 自动的会基于它们被调用的顺序赋值一个排序的值给它们的 enpoints。这模拟了控制器的长期行为,而这些控制器没有路由器提供和旧的路由实现相同的保证。

在旧的路由实现中,是可以实现依赖路由处理顺序的扩展。ASP.NET Core 以及更新的版本中的 endpoint 路由:

  • 没有路由的概念
  • 不提供顺序保证。所有的 endpoints 都一次处理。

路由模板的优先级和 enpoint 选择顺序

路由模板优先是基于如何具体化一个路由模板,并给它赋予一个值得系统。路由模板优先级:

  • 避免在大多数情况下调整 enpoints 顺序的必要
  • 尝试匹配路由行为的常识性期望

例如,模板 /Products/List 和 /Products/{id}。对于 URL path,系统将会认为 /Products/List 比/Produts/{id} 更加匹配。这是因为字面值段 /List 被认为比参数 /{id} 有更高的优先级。

优先级工作原理和路由模板如何定义相结合的详情如下:

  • 拥有更多段的模板被认为更加具体
  • 字面文字的段被认为比一个参数的段更加具体
  • 带有约束的参数的段被认为比没有约束的参数的段更加具体
  • 一个复杂的段被认为和一个带约束参数的段一样具体
  • Catch-all 参数最不具体。查看路由模板引用中的 catch-all 更多的关于 catch-all 路由的重要信息

URL 生成概念 

URL 生成:

  • 基于一组路由值创建一个 URL path 的过程
  • 允许在 enpoints 和 URLs 之间进行逻辑分离

Endpoint 裸游包含 LinkGenerator API。LInkGenerator 作为一个单利服务从依赖注入中获取。LinkGenerator API 可以在正在执行的请求的上下文之外执行。Mvc.IUrlHelper 和 scenarios 依赖于 IUrlHelper,例如 Tag Helpers,HTML  Helpers,以及 Action Results,在内部使用 LinkGenerator API 提供生成链接的功能。

路由生成器由地址和地址架构的概念的支持。一个地址架构是一种决定哪些 endpoints 应该被用来生成链接的方式。例如,许多用户熟悉的从控制器获取路由名称和路由值以及 Razor Pages 被用来实现作为一种地址架构。

链接生成器可以链接到控制器和 Razor Pages 通过以下扩展方法:

  • GetPathByAction
  • GetUriByAction
  • GetPathByPage
  • GetUriByPage

这些方法的重载的参数包含 HttpContext。这些方法在功能上等同于 Url.Action 和 Url.Page,但是提供更多的灵活性和选择。

GetPath* 之类的方法和 Url.Action 以及 Url.Page 很相似,它们生成的 URI 包含一个绝对路径。GetUrl* 方法总是生成一个包含一个架构和主机的绝对 URI。接受参数 HttpContext 参数的方法在正在执行的请求的 Context 中生成一个 URI。除非重写,否则路由值将使用当前正在执行的请求中的 URI base path,架构以及主机。

LinkGenerator 被地址调用。生成一个 URI 在以下两个步骤中出现:

  1. 地址被绑定到一组匹配当前地址的 enpoints
  2. 直到一个路由模型匹配了提供的值被发现了,endpoint 的路由模型才会被评估。输出的结果组合了其它 URI 的部分并提供给链接生成器返回。

LinkGenerator 提供的方法支持生成任何类型的标准的链接的能力。使用 link generator 最方便的方式是通过那些为特定地址类型操作的扩展方法:

GetPathByAddress 基于提供的值生成一个绝对路径的 URI

GetUriByAdderss    基于提供的值生成一个绝对的 URI

⚠️ 警告:

注意调用 LinkGenerator 会有以下影响:

  • 在配置不验证 Host headers 的应用程序中,请谨慎使用 GetUri* 扩展方法。如果请求的 Host header 不验证,不被信任的请求输入就会在视图或者page中的URIs被发送到客户端。我们建议素有的生产用的应用程序都配置服务器验证已知的值作为 Host header 有效的值。
  • 在结合了 Map 或者 MapWhen 的中间件中谨慎使用 LinkGenerator。Map* 改变了执行请求的基本路径,这会影响 link generator 的输出。所有的 LinkGenerator APIs 允许指定一个基本的路径。指定一个空的基本路径可以避免 Map* 对 link generation 产生的影响。

 中间件示例

在下面的例子中,一个中间件使用了 LinkGenerator API 为一个列出存储产品的方法创建一个链接。通过注入 link generator 到一个类中,然后在任何一个应用程序中的类都可以调用 GenerateLink:

public class ProductsLinkMiddleware
{
    private readonly LinkGenerator _linkGenerator;

    public ProductsLinkMiddleware(RequestDelegate next, LinkGenerator linkGenerator)
    {
        _linkGenerator = linkGenerator;
    }

    public async Task InvokeAsync(HttpContext httpContext)
    {
        var url = _linkGenerator.GetPathByAction("ListProducts", "Store");

        httpContext.Response.ContentType = "text/plain";

        await httpContext.Response.WriteAsync($"Go to {url} to see our products.");
    }
}

路由模板参考

{} 中的符号定义了路由匹配时绑定的路由参数。可以在路由分段中定义多个路由参数,但是路由参数必须由字面值分割开来。例如, {controller=Home}{action=Index} 不是一个有效的路由,由于在 {controller} 和 {action} 之间没有字面值。路由参数必须有一个名称,也可能有更多指定的属性。

字面值而不是路由参数(例如 {id}) 和路径分隔符 / 必须匹配 URL 中的文本。文本匹配区分大小写并且基于 URL's 路由的编码。根据定界符 { 或者 } 匹配一个字面上的路由参数,通过重复字符转义定界符。例如: {{ 或者 }}

星号 * 或者 两个星号 **:

  • 可以作为路由参数的一个前缀,绑定到 URI 剩余的部分
  • 被称为 catch-all 参数。例如,blog/{**slug}:匹配所有的以 /blog 开头的以及跟有任何值得 URI;跟在 /blog 后面的值被赋值给 slug

Catch-all 参数也可以匹配空的字符串。

当路由被用来生成一个 URL,catch-all 参数会转义合适的字符,包括路径分隔符 /。例如,带有参数 { path = "my/path" } 的路由 foo/{*path} 生成 foo/my%2Fpath。注意转义的斜杠。为了保留路径分隔符,使用 ** 作为路径参数的前缀。路由 foo/{**path} 赋值 { path="my/path" } 生成 foo/my/path。

视图捕捉一个带有可选的文件扩展名的文件名称的 URL 模式的时候,需要有更多的考虑。例如,模板 files/{filename}.{ext?}。当参数 filename 和 ext 都有值得时候,两个值都会被填充。如果只有 filename 的值存在于 URL 中,路由将会匹配,因为尾部的 . 这是就是可选的。下面的 URLs 会匹配这两种路由:

  • /files/myFile.txt
  • /files/myFile

路由参数可以提供默认值,通过在参数名称后面添加等号(=)给路由参数指定。例如, {controller=Home},为 controller 指定了 Home 作为默认的值。默认的值在 URL 中没有为参数提供值的时候使用。通过在路由参数名称后面添加一个问号 (?) 来指定这个参数是可选的。例如,id?。可选参数和默认参数不同的是:

  • 提供默认值得路由参数总是会生成一个值
  • 可选参数只有在请求的 URL 中提供值的时候才会被赋予一个值

路由参数可能包含必须匹配绑定到 URL 中的路由值的约束。在路由参数名称后面添加 : 和约束名称在行内指定参数约束。如果约束要求带有参数,它们被包括在圆括号(...)中跟在约束名称后面。多个行内约束可以通过添加另外一个 : 和约束名称来指定。

约束名称和参数被传递给 IlnlineConstraintResolver 用来创建一个 IRouteConstratin 实例,这个实例在 URL 处理过程中使用。例如,路由模板 blog/(article:minlength(10) 指定了一个值为 10 的 minlength 约束。更多的关于路由约束和框架提供的一系列的约束,请查看 Route constraint reference 部分。

路由参数可能会有参数转换。路由参数在生成链接和匹配方法以及pages到URLs的时候会转换参数的值。和约束一样,参数转换可以通过在路由参数名称后面添加一个 : 和转换名称到路由参数。例如,路由模板 blog/{article:slugify} 指定一个名称为 slugify 的转换。关于更过关于参数转换的信息,请查看 Parameter transformer reference 部分。

下面的表格展示了路由模板和他们的行为:

路由模板 匹配的 URI 示例 请求 URI...
hello /hello 只匹配一个路径 /hello
{Page=Home} / 匹配并且设置 Page 为 Home
{Page=Home} /Contact 匹配并且设置 Page 为 Contact
{controller}/{action}/{id?} /Products/List 映射 Products 到 controller,List 到 action
{controller}/{action}/{id?} /Products/Details/123 映射 Products 到 controller,Details 到 action,id 的值被设置为 123
{controller=Home}/{action=Index}/{id?} / 映射到 Home controller 和 Index 方法. id 参数被忽略
{controller=Home}/{action=Index}/{id?} /Products 映射到 Products controller 和 Index 方法,id 参数被忽略

通常的,使用模板是最简单的获取路由的方式。约束和默认值也可以被指定在路由模板的外部。

复杂的分段

复杂的分段是通过以非贪婪的方式从右到左匹配文字分割的方式来处理的。例如,[Route("/a{b}c{d}")] 是一个复杂的分段路由。复杂的分段以一种特殊的方式工作,必须理解以能够正确的使用它们。这部分的示例展示了为什么复杂的分段只有在分界文本不在参数值中才能正常工作的原因。使用正则表达式,然后对于更加复杂的例子需要手动提取其中的值。

⚠️  警告:

当使用 System.Text.RegularExpressions 处理不受信任的输入的时候,传入一个超时时间。一个恶意的用户提供给 RegularExpressions 的输入可能会引起 Denial-of-Service attack. ASP.NET Core 框架的 APIs 使用 RegularExpressions 的时候传入了超时时间。

下面总结了路由处理模板 /a{b}c{d} 匹配 URL path /abcd 的步骤。| 用来使算法是怎么工作的更加形象:

  • 从右到左计算,第一个文本值是 c。因此 /abcd 从右搜索然后发现 /ab|c|d
  • 右边 (d)的所有现在匹配到路由参数 {d}
  • 从右到左计算,下一个是 a。因此 /ab|c|d 从我们结束的地方开始搜索,然后 a 被发现 /a|b|c|d
  • 右边的 (b) 现在匹配路由参数 {b}
  • 没有了剩余的文本和路由模板,因此一个匹配就完成了

这里举例一个使用相同模板 /a{b}c{d},不同 URL 路径匹配不成功的例子。| 用来更形象的展示算法的工作。这个例子使用同样的算法解释了没有匹配成功:

  • 从右到左,第一个文字是 c。因此从右开始搜索 /aabcd,发现了 /aab|c|d
  • 右边的(d)的所有都匹配了路由参数 {d}
  • 从右到左,下一个文字是 a。因此从上次我们停止的地方 /aab|c|d 开始搜索,a 在 /a|a|b|c|d 中被发现
  • 右边的 (b) 现在匹配到路由参数 {b}
  • 此时,还有一个剩余的字符 a,但是算法已经按照路由模板运行完毕,因此匹配没有成功

由于匹配算法是非贪婪的:

  • 在每一个步骤中,它匹配最小数量的文本
  • 任何情况下,在参数值内的分隔符的值都会导致不匹配

正则表达式提供了更多的匹配行为。

贪婪匹配,也叫做懒匹配,匹配最大可能的字符串。非贪婪模式匹配最小的字符串。

路由约束参考

路由约束在匹配入站 URL 和 URL path 被路由值标记进入的时候会执行。路由约束通过路由模板检测路由值,然后确认值是否可以被接受。一些路由约束使用路由值之外的数据去考虑是否一个请求可以被路由。例如,HttpMethodRouteConstraint 可以基于它的 HTTP 谓词接受或者拒绝一个请求。约束被用于路由请求和链接生成中。

⚠️  警告:

不要使用约束验证输入。如果约束被用于输入验证,无效的输入将会导致 404 Not Found 被返回。无效的输入应该产生一个带有合适错误信息的 400 Bad Request。路由约束用来消除相似路由的歧义,而不是用来验证一个特定的路由。

下面的表格展示了示例路由约束和它们期望的行为:

约束 示例 匹配的示例 备注
int {id:int} 123456789,-123456789 匹配任何的整型数据
bool {active:bool} true,False 匹配 true,false。不区分大小写
datetime {dob:datetime} 2020-01-02,2020-01-02 14:27pm 在固定区域中匹配一个有效的 DateTime 类型的值。查看处理警告。
decimal {price:decimal} 49.99,-1,000.01 在固定区域中匹配一个有效的 decimal 类型的值。查看处理警告。
double {weight:double} 1.234,-1,001.01e8 在固定区域中匹配一个有效的 double 类型的值。查看处理警告。
float {weight:float} 1.234,-1,001.01e8 在固定区域中匹配一个有效的 float 类型的值。查看处理警告。
guid {id:guid} CD2C1638-1638-72D5-1638-DEADBEEF1638 匹配一个有效的 guid 类型的值
long {ticks:long} 123456789,-123456789 匹配一个有效的 long 类型的值
minlength(value) {username:minlength(4)} Rick 长度至少为 4 的字符串
maxlength(value) {filename:maxlength(8)} MyFile 长度最多为 8 的字符串
length(length) {filename:length(12)} somefile.txt 长度为 12 的字符串
length(min,max) {filename:length(8,16)} somefile.txt 长度处于 8 -16 的字符串
min(value) {age:min(18)} 19 最小为 18 的整型数据
max(value) {age:max(120)} 91 最大为 120 的整型数据
range(min,max) {age:range(18,120)} 91 18 - 120 的整型数据
alpha {name:alpha} Rick 字符串必须包含一个或者更多的字母字符,a-z,不区分大小写
regex(expression) {ssn:regex(^\\d{{3}}-\\d{{2}}-\\d{{4}}$)} 123-45-6789 字符串必须匹配提供的正则表达式。查看定义正则表达式的提示。
required {name:required} Rick 在生成 URL 的过程中用来强制一个非空参数值

⚠️  警告:

当使用 System.Text.RegularExpressions 处理不被信任的输入时,传入一个超时时间。一个恶意的用户可能会提供一个引起 Denial-of-Service attack 的 RegularExpressions。ASP.NET Core 框架的 APIs 使用 RegularExpressions 时都会传入一个超时时间。

多个冒号分隔符可以应用于单个的参数。例如,下面的约束限制一个最小为1的整型:

[Route("users/{id:int:min(1)}")]
public User GetUserById(int id) {}

⚠️  警告:

路由约束验证 URL 并且总是使用固定的区域转换为一个 CLR 类型。例如,转换为 CLR 类型中的 int 或者 DateTime。这些约束假设 URL 不是本地化的。框架提供的路由约束不修改保存在路由值中的值。所有的路由值从 URL 中解析出来被保存为字符串。例如,float 约束试图转换一个路由值为 float 类型,但是转换只有在验证可以被转换的时候才会使用到。

约束中的正则表达式

⚠️  警告:

当使用 System.Text.RegularExpressions 处理不被信任的输入时,传入一个超时时间。一个恶意的用户可能会提供一个引起 Denial-of-Service attack 的 RegularExpressions。ASP.NET Core 框架的 APIs 使用 RegularExpressions 时都会传入一个超时时间。

约束中的正则表达式可以使用 regex(...) 在行内指定。 MapControllerRoute 一类的方法也接受对象字面值。如果使用了这种格式,字符串的值被解释为正则表达式。

下面的代码使用了行内正则表达式约束:

app.UseEndpoints(endpoints =>
{
    endpoints.MapGet("{message:regex(^\\d{{3}}-\\d{{2}}-\\d{{4}}$)}",
        context => 
        {
            return context.Response.WriteAsync("inline-constraint match");
        });
 });

下面的代码使用了字面对象指定一个正则表达式约束:

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllerRoute(
        name: "people",
        pattern: "People/{ssn}",
        constraints: new { ssn = "^\\d{3}-\\d{2}-\\d{4}$", },
        defaults: new { controller = "People", action = "List", });
});

ASP.NET Core 框架在正则表达式的构造方法中添加了 RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant 参数。查看 RegexOptions 了解这些成员的描述。

正则表达式使用分隔符和符号和路由以及 C# 语言相似。正则表达式的符号必须被转义。在约束行内使用正则表达式 ^\d{3}-\d{2}-\d{4}$,可以使用以下任意一种方法:

  • 我了转义字符串中的 \,使用 \\ 替换 C# 源文件中的字符串中出现的 \
  • Verbatim string literals

为了转义路由参数分隔符 {,},[,],在表达式中使用重复的字符,例如,{{,}},[[,]]。下面的表格展示了正则表达式和它的转义版本:

正则表达式 转义后的正则表达式
^\d{3}-\d{2}-\d{4}$ ^\\d{{3}}-\\d{{2}}-\\d{{4}}$
^[a-z]{2}$ ^[[a-z]]{{2}}$

路由中的正则表达式经常是 ^ 字符开头去匹配字符串的起始位置。正则表达式总是以 $ 结尾来匹配字符串的结尾。 ^ 和 $ 字符保证了正则表达式能够匹配全部的路由参数值。如果没有 ^ 和 $,正则表达式匹配任意的子字符串,这不是我们期望得到的。下面的表格列出了一些例子,并且解释了为什么能够匹配或者匹配失败:

表达式 字符串 是否匹配 备注
[a-z]{2} hello YES 子字符串匹配
[a-z]{2} 123abc456 YES 子字符串匹配
[a-z]{2} mz YES 匹配正则表达式
[a-z]{2} MZ YES 不区分大小写
^[a-z]{2}$ hello NO 查看上面 ^ 和 $
^[a-z]{2}$ 123abc456 NO 查看上面 ^ 和 $

更多关于正则表达式语法的信息,查看 .NET Framework Regular Expressions.

使用正则表达式可以约束参数到一些已知的可能的值上面。例如,{action:regex(^(list|get|create)$)} 仅仅匹配 action 的路由值到 list,get 或者 create。如果传递到约束字典中,字符串 ^(list|get|create)$) 是等同的。传入约束字典的约束如果不匹配任意一个一直的约束,那么任然被认为是一个正则表达式。使用模板传入的约束如果不匹配任意一个已知的约束将不被认为是正则表达式。

自定义路由约束

通过实现 IRouteConstraint 接口可以创建自定义的路由约束。接口 IRouteConstraint 包含 Match,当满足约束的时候它会返回 true,否则返回 false。自定义约束很少被用到。在实现一个自定义约束之前,考虑更直接的方法,比如模型绑定。

ASP.ENT Core 约束文件夹提供了一个很好的创建约束的例子。例如,GuidRouteConstraint。

为了使用一个自定义的 IRouteConstraint,路由约束的类型必须使用 ConstraintMap 在服务容器中注册。一个 CostraintMap 是一个映射路由约束键值和 验证这些约束 IRouteConstraint 实现的字典。应用程序的 ConstraintMap 可以在 Startup.ConfigureServices中或者作为 services.AddRouting 调用的一部分或者直接通过 services.Configure配置 RouteOptions 来更新。例如:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    services.AddRouting(options =>
    {
        options.ConstraintMap.Add("customName", typeof(MyCustomConstraint));
    });
}

上面添加的约束在下面的代码中使用:

[Route("api/[controller]")]
[ApiController]
public class TestController : ControllerBase
{
    // GET /api/test/3
    [HttpGet("{id:customName}")]
    public IActionResult Get(string id)
    {
        return ControllerContext.MyDisplayRouteInfo(id);
    }

    // GET /api/test/my/3
    [HttpGet("my/{id:customName}")]
    public IActionResult Get(int id)
    {
        return ControllerContext.MyDisplayRouteInfo(id);
    }
}

MyDisplayRouteInfo 通过 Rick.Docs.Samples.RouteInf NuGet 包提供,用来显示路由信息。

MyCustomConstraint 约束的实现阻止 0 被赋值给路由参数:

class MyCustomConstraint : IRouteConstraint
{
    private Regex _regex;

    public MyCustomConstraint()
    {
        _regex = new Regex(@"^[1-9]*$",
                            RegexOptions.CultureInvariant | RegexOptions.IgnoreCase,
                            TimeSpan.FromMilliseconds(100));
    }
    public bool Match(HttpContext httpContext, IRouter route, string routeKey,
                      RouteValueDictionary values, RouteDirection routeDirection)
    {
        if (values.TryGetValue(routeKey, out object value))
        {
            var parameterValueString = Convert.ToString(value,
                                                        CultureInfo.InvariantCulture);
            if (parameterValueString == null)
            {
                return false;
            }

            return _regex.IsMatch(parameterValueString);
        }

        return false;
    }
}

⚠️  警告:

当使用 System.Text.RegularExpressions 处理不被信任的输入时,传入一个超时时间。一个恶意的用户可能会提供一个引起 Denial-of-Service attack 的 RegularExpressions。ASP.NET Core 框架的 APIs 使用 RegularExpressions 时都会传入一个超时时间。

前面的代码:

  • 禁止0赋值给 {id}
  • 展示了实现一个自定义约束的基本示例。它不应该被用于一个正式环境的应用程序中。

下面的代码展示了一个更好的禁止0赋值给 id 的处理过程:

[HttpGet("{id}")]
public IActionResult Get(string id)
{
    if (id.Contains('0'))
    {
        return StatusCode(StatusCodes.Status406NotAcceptable);
    }

    return ControllerContext.MyDisplayRouteInfo(id);
}

上面的代码比自定义的约束 MyCustomConstraint 有以下优势:

  • 不用实现一个自定义的约束
  • 当路由参数包含 0 的时候,会返回一个更加描述性的错误信息

参数转换参考

参数转换:

  • 当使用 LinkGenerator 生成一个链接的时候执行
  • 实现了 Microsoft.AspNetCore.Routing.IOutboundParameterTransformer.
  • 使用 ConstraintMap 配置
  • 获取参数的路由值并转换为一个新的字符串值
  • 结果在生成的链接中使用转换后的值

例如,一个在模型 blog\{article:slugify} 中的自定义的 slugfy 参数转换时使用 Url.Action(new { artical = "MyTestArtical" }) 生成 blog\my-test-artical。

考虑下面 IOutboundParameterTransformer 的实现:

public class SlugifyParameterTransformer : IOutboundParameterTransformer
{
    public string TransformOutbound(object value)
    {
        if (value == null) { return null; }

        return Regex.Replace(value.ToString(), 
                             "([a-z])([A-Z])",
                             "$1-$2",
                             RegexOptions.CultureInvariant,
                             TimeSpan.FromMilliseconds(100)).ToLowerInvariant();
    }
}

在参数模型中为了使用一个参数转换,需要在 Startup.ConfigureServices 中使用 ConstraintMap 配置。如下:

public void ConfigureServices(IServiceCollection services)
{
    services.AddControllers();

    services.AddRouting(options =>
    {
        options.ConstraintMap["slugify"] = typeof(SlugifyParameterTransformer);
    });
}

ASP.NET Core 框架在一个 enpoint 被解析到的时候会使用参数转换去转换 URI。例如,参数转换会转换被用来匹配一个 area, controller,action,和page 的路由值。

例如以下代码:

routes.MapControllerRoute(
    name: "default",
    template: "{controller:slugify=Home}/{action:slugify=Index}/{id?}");

对于上面这个路由模板,方法 SubscriptionManagementController.GetAll 匹配了 URI /subscription-management/get-all。参数转换并没有更改用来生成一个链接的路由值。例如,Url.Action("GetAll", "SubscriptionManagement") 输出 /subscription-management/get-all.

ASP.NET Core 提供了供生成的路由使用的参数转换 API 的约定:

  • Microsoft.AspNetCore.Mvc.ApplicationModels.RouteTokenTransformerConvention MVC 约定应用了一个指定的参数转换到应用程序中所有属性路由。参数转换替换属性路由中的符号。更多信息请查看 Use a parameter transformer to customize token replacement.
  • Razor Pages 使用 PageRouteTransformerConvention API 约定。这个约定应用一个指定的参数转换到所有自动被发现的 Razor Pages。参数转换转换 Razor Pages 路由的目录和文件名称段。更多信息请查看 Use a parameter transformer to customize page routes.

URL 生成参考

这部分包含了 URL 生成算法的参考。在实际中,大多复杂的 URL 生成使用控制器或者 Razor Pages。查看 routing in controllers 获取更多信息。

URL 生成过程一开始调用 LinkGenerator.GetPathByAddress 或者一个相似的方法。这个方法提供一个地址,一组路由值和关于从 HttpContext 获取到的当前请求的可选的信息。

第一步就是使用地址去解析一组候选的 endpoints,这些 endpoints 使用 IEndpointAddressScheme 去匹配地址的类型。

一旦根据地址架构获取到了一组候选 endpoints,endpoints 将会被排序,然后迭代处理直到一个 URL 生成的操作成功。URL 生成不检查歧义性,第一个返回的结果就是最终的结果。

使用日志跟踪 URL 生成

跟踪 URL 生成的第一步就是设置日志等级由 Microsoft.AspNetCore.Routing 到 TRACE。LinkGenerator 记录了很多关于对解决问题有用的处理过程的详细信息。

查看 URL generation reference 关于 URL 生成的详细信息。

地址

地址的概念是 URL 生成用来绑定一个链接生成器中的一个调用到一组候选的 enpoints。

地址是随两个默认实现扩展出来的概念:

  • 使用 enpoint 名称 (string) 作为地址:
    为 MVC的路由名称提供相似的功能
    使用 IEndpointNameMetadata 作为 metadata 的类型
    根据所有注册的 enpoints 的 metadata 解析提供的字符串
    如果多个 endpoints 使用相同的名称,将会在 startup 中抛出异常
    对于通用目的的使用,推荐在控制器和 Razor Pages 之外使用
  • 使用路由值 (RouteValuesAddress)作为地址
    提供类似于控制器和 Razor Pages 遗留的 URL 生成的功能
    扩展和调试非常复杂
    提供 IUrlHelper,Tag Helpers,HTML Helpers,Action Result 等等使用的实现

地址架构的作用是通过任意条件在地址和 enpoints 匹配之间建立关联。

环境值和显式值

从当前请求中,路由从 HttpContext.Request.RouteValues 中获取路由值。和当前请求关联的值被称为环境值。为了更清晰,文档中把路由值中传递给方法的值称为显式值。

下面的例子展示了环境值和显式值。它提供了从当前请求中获取的环境值和显式值: { id = 17 }

public class WidgetController : Controller
{
    private readonly LinkGenerator _linkGenerator;

    public WidgetController(LinkGenerator linkGenerator)
    {
        _linkGenerator = linkGenerator;
    }

    public IActionResult Index()
    {
        var url = _linkGenerator.GetPathByAction(HttpContext,
                                                 null, null,
                                                 new { id = 17, });
        return Content(url);
    }

上面代码:

  • 返回了 /Widget/Index/17
  • 通过 DI (依赖注入)获取 (LinkGenerator)

下面的例子展示了不提供环境值,提供显式值: { controller = "Home", action = "Subscribe", Id = 17 }:

public IActionResult Index2()
{
    var url = _linkGenerator.GetPathByAction("Subscribe", "Home",
                                             new { id = 17, });
    return Content(url);
}

上面的方法返回 /Home/Subscribe/17

WidgetController 中下面的代码返回 /Widget/Subscribe/17:

var url = _linkGenerator.GetPathByAction("Subscribe", null,
                                         new { id = 17, });

下面的代码提供了从当前请求的环境值中获取的控制器,显式值: { action = "Edit", id = 17 }:

public class GadgetController : Controller
{
    public IActionResult Index()
    {
        var url = Url.Action("Edit", new { id = 17, });
        return Content(url);
    }

在上面的代码中:

  • /Gadget/Edit/17 被返回
  • Url 获取 IUrlHelper.
  • Action 为 action 方法生成了一个绝对路径的 URL

下面的代码提供了从当前请求中获取的环境值,以及显式值: { page = ".Edit", id = 17 }:

public class IndexModel : PageModel
{
    public void OnGet()
    {
        var url = Url.Page("./Edit", new { id = 17, });
        ViewData["URL"] = url;
    }
}

上面的代码在当 Edit Razor Page 包含以下指令的时候会设置 url 为 /Edit/17:

@page "{id:int}"

如果 Edit page 不包含 "{id:int}" 路由模板,url 就是 /Edit?id=17.

MVC 的 IUrlHelper 又增加了一层复杂性,除了下面描述的规则外:

  • IUrlHelper 总是从当前请求中获取路由值作为环境值
  • IUrlHelper.Action 总是复制当前的 action 和 controller 的路由值作为显式值,除非他们被重写
  • IUrlHelper.Page 总是复制当前 page 的路由值作为显式值,除非 page 被重写
  • IUrlHelper.Page 总是覆盖当前 handler 的路由值为 null 来作为显式值,除非被重写

用户总是对环境值的详细行为感到惊讶,因为 MVC 似乎不跟随它自己的规则。由于历史和兼容性的原因,确定的路由值,例如 action,controller,page 和 handler 都有它们自己特定的行为。

LinkGenerator.GetPathByAction 和 LinkGenerator.GetPathByPage 提供的相同功能为了兼容性复制了 IUrlHelper 的这些异常。

URL 生成处理

一旦一组候选的 enpoints 被发现了,接下来就是 URL 生成算法:

  • 迭代的处理 enpoints
  • 返回第一个成功的结果

处理过程的第一不叫做路由值验证。路由值验证通过路由决定从环境值获取到的路由值哪个应该被使用和哪个应该被忽略。每一个环境值都会被考虑是结合显示值还是被忽略。

理解环境值得最好方式是在通常情况下认为它试图节省应用程序开发者的输入。传统的,环境值使用的场景对相关的 MVC 是非常有用的:

  • 当链接到同一个控制器的其他方法时,控制器的名称不必指定
  • 当链接到同一 area 中的其他控制器时,area 的名称不必指定
  • 当链接到同一个方法时,路由值不必指定
  • 当链接到应用程序的另外一部分时,你不想携带一些对其他部分没有意义的路由值

调用 LinkGenerator 或者 IUrlHelper 返回 null 的情况通常是由于没有通过路由值验证。调试路由值验证,可以通过显式指定更多的路由值来查看问题是否解决。

路由值无效的前提是假设应用程序 URL 架构是分层的,拥有一个从左到右的分层结构。考虑一个基本的路由模板 {controller}/{action}/{id?} 可以直观的感受在实际中它是怎么工作的。对一个值的更改会使得出现在右边的所有路由值失效。这反映了关于层次结构的假设。如果应用程序中 id 有一个环境值,并且操作给控制器指定了一个不同的值:

  • id 不会被重复使用,因为 {controller} 在 {id} 的左边

一些示例展示了这个原则:

  • 如果显式值中包含了 id 的值,环境中的 id 的值就会被忽略。环境值中的 controller 和 action 可以被使用
  • 如果显式值中包含了 action 的值,任何环境值中 action 的值都会被忽略。环境值中的 controller 会被使用。如果显式值中的 action 的值和环境值中的 action 中的值不同,id 的值将不会被使用。如果 action 的值相同,则 id 的值会被使用。
  • 如果显式值中包含了 controller 的值,任何环境中的 controller 的值都被忽略。如果显式值中的 controller 和环境值得 controller 值不相同,action 和 id 的值不会被使用。如果 controller 中的值相同,action 和 id 的值会被使用。

对于现存的属性路由和专用常规路由,这一处理过程更加复杂。控制器常规路由,例如 {controller}/{action}/{id?} 使用路由参数指定了一个分层结构。 控制器和 Razor Pages 中的常规路由和属性路由:

  • 有一个分层的路由值
  • 它们不出现在路由模板中

对于这些情况, URL 生成定义了 required values 的概念。controllers 和 Razor Pages 创建的 endpoints 可以指定允许路由值验证工作的 required values。

路由值验证算法的详细信息:

  • required value 名称和路由参数结合,然后从左到右处理
  • 对于每一个参数,环境值和显式值都会被比较:
    如果环境值和显式值相同,处理过程继续
    如果有环境值没有显式值,环境值被用来生成 URL
    如果有显式值没有环境值,则拒绝环境值和后续的所有环境值
    如果环境值和显示值都存在,并且两个值不相同,则拒绝环境值和后续的所有环境值

这是,URL 生成操作已经准备好开始评估路由约束。接受的值的集合与提供给约束的默认的参数值相结合。如果约束全部通过,操作将会继续。

下一步,被接受的参数可以用来展开路由模板。路由模板的处理过程如下:

  • 从左到右
  • 每一个参数都会替代被接受的值
  • 有以下特殊情况
    如果没有被接受的值,但是参数有一个默认的值,默认的值会被使用
    如果没有被接受的值,并且参数是可选的,则过程继续
    如果缺失的可选参数的右边的路由参数有任何值,则操作失败
    连续的默认参数值和可选参数可能会被折叠

不匹配路由分段的显式的值被添加到 query 字符串中。下面的表格展示了使用路由模板 {controller}/{action}/{id?} 的情况:

环境值 显式值 结果
controller = "Home" action = "About" /Home/About
controller = "Home" controller = "Order",action="About" /Order/About
controller="Home",color="Red" action="About" /Home/About
controller="Home" action="About",color="Red" /Home/About?color=Red

路由值验证的问题

ASP.NET Core 3.0 中一些 URL 生成的架构在早期的 ASP.ENT Core 的版本中 URL 生成工作的并不好。ASP.NET Core 团队计划在未来的发布版本中添加新的特性的需求。目前,最好的解决方法就是使用传统路由。

下面的代码展示了 URL 生成架构不被路由支持的示例:

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllerRoute("default", 
                                     "{culture}/{controller=Home}/{action=Index}/{id?}");
    endpoints.MapControllerRoute("blog", "{culture}/{**slug}", 
                                      new { controller = "Blog", action = "ReadPost", });
});

在上面的代码中,culture 路由参数被用来本地化。期望的是 culture 参数总是被接受为一个环境值。然而,culture 参数不被接受为一个环境值,因为 required values 的工作方式。

  • 在 "default" 路由模板中,culture 路由参数在 controller 的左边,因此对于 controller 的更改不会验证 culture
  • 在 "blog" 路由模板中,culture 路由参数被认为是在 controller 的右边,出现在了 required values 里面

配置 endpoint metadata

下面的链接提供了配置 enpoint metadata 的更多信息:

  • Enable Cors with endpoint routing
  • 使用自定义 [MinimumAgeAuthorize] 属性的 IAuthorizationPolicyProvider sample
  • Test authentication with the [Authorize] attribute
  • RequireAuthorization
  • Selecting the scheme with the [Authorize] attribute
  • Apply policies using the [Authorize] attribute
  • Role-based authorization in ASP.NET Core

路由中主机匹配与 RequireHost

RequireHost 应用一个约束到需要指定主机的路由。RequireHost 或者 [Host] 参数可以是:

  • Host: www.domain.com,匹配任意端口的 www.domain.com
  • 带有通配符的 Host: *.domain.com,匹配任意端口自的 www.domain.com,subdomain.domain.com 或者 www.subdomain.domain.com
  • 端口:*:5000,匹配任意5000端口的 Host
  • Host 和 port: www.domain.com:5000 或者 *.domain.com:5000,匹配 host 和 port

使用 RequireHost 或者 [Host] 可以指定多个参数。匹配主机的约束验证任意的参数。例如,[Host("domain.com","*domain.com")] 匹配 domain.com,www.domain.com 和 subdomain.domain.com。

下面的代码使用 RequireHost 要求在路由中指定主机:

public void Configure(IApplicationBuilder app)
{
    app.UseRouting();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapGet("/", context => context.Response.WriteAsync("Hi Contoso!"))
            .RequireHost("contoso.com");
        endpoints.MapGet("/", context => context.Response.WriteAsync("AdventureWorks!"))
            .RequireHost("adventure-works.com");
        endpoints.MapHealthChecks("/healthz").RequireHost("*:8080");
    });
}

下面的代码在 controller 上使用 [Host] 属性要求任意指定的主机:

[Host("contoso.com", "adventure-works.com")]
public class ProductController : Controller
{
    public IActionResult Index()
    {
        return ControllerContext.MyDisplayRouteInfo();
    }

    [Host("example.com:8080")]
    public IActionResult Privacy()
    {
        return ControllerContext.MyDisplayRouteInfo();
    }
}

当 [Host] 属性在 controller 和 action 方法上都应用了的情况:

  • action 上面的属性被使用
  • controller 属性被忽略

路由性能指南

大部分的路由在 ASP.NET Core 3.0 被更新提高了性能。

当一个应用程序出现性能问题的时候,路由总是被怀疑是问题所在。路由被怀疑的原因是像 controllers 和 Razor Pages 这样的框架在它们的日志信息中报告了在框架内部花费了大量的时间。当 controllers 报告的时间和请求的总的时间有很大不同的时候:

  • 开发者消除了他们应用程序代码是问题的根源
  • 通常会怀疑是路由引起的

路由使用了成千上万的 enpoints 来测试性能。一个典型的应用程序不太可能仅仅因为太大而遇到应能问题。路由性能缓慢最常见的根本原因是由于不好的自定义的中间件引起的。

下面的代码展示了缩小延迟来源的基本技术:

public void Configure(IApplicationBuilder app, ILogger logger)
{
    app.Use(next => async context =>
    {
        var sw = Stopwatch.StartNew();
        await next(context);
        sw.Stop();

        logger.LogInformation("Time 1: {ElapsedMilliseconds}ms", sw.ElapsedMilliseconds);
    });

    app.UseRouting();

    app.Use(next => async context =>
    {
        var sw = Stopwatch.StartNew();
        await next(context);
        sw.Stop();

        logger.LogInformation("Time 2: {ElapsedMilliseconds}ms", sw.ElapsedMilliseconds);
    });

    app.UseAuthorization();

    app.Use(next => async context =>
    {
        var sw = Stopwatch.StartNew();
        await next(context);
        sw.Stop();

        logger.LogInformation("Time 3: {ElapsedMilliseconds}ms", sw.ElapsedMilliseconds);
    });

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapGet("/", async context =>
        {
            await context.Response.WriteAsync("Timing test.");
        });
    });
}

对于时间路由:

  • 在上面的代码中,时间中间件交错在每一个中间件中间
  • 在代码中添加一个唯一的标识关联时间数据

这是一种最基本的缩小延迟的方法,当延迟很严重的时候,例如超过 10ms。Time 2 减去 Time1 就是 UseRouting 中间件花费的时间。

相比前面的代码,下面的代码使用了一个更加紧凑的方法:

public sealed class MyStopwatch : IDisposable
{
    ILogger _logger;
    string _message;
    Stopwatch _sw;

    public MyStopwatch(ILogger logger, string message)
    {
        _logger = logger;
        _message = message;
        _sw = Stopwatch.StartNew();
    }

    private bool disposed = false;


    public void Dispose()
    {
        if (!disposed)
        {
            _logger.LogInformation("{Message }: {ElapsedMilliseconds}ms",
                                    _message, _sw.ElapsedMilliseconds);

            disposed = true;
        }
    }
}
public void Configure(IApplicationBuilder app, ILogger logger)
{
    int count = 0;
    app.Use(next => async context =>
    {
        using (new MyStopwatch(logger, $"Time {++count}"))
        {
            await next(context);
        }

    });

    app.UseRouting();

    app.Use(next => async context =>
    {
        using (new MyStopwatch(logger, $"Time {++count}"))
        {
            await next(context);
        }
    });

    app.UseAuthorization();

    app.Use(next => async context =>
    {
        using (new MyStopwatch(logger, $"Time {++count}"))
        {
            await next(context);
        }
    });

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapGet("/", async context =>
        {
            await context.Response.WriteAsync("Timing test.");
        });
    });
}

潜在的昂贵的路由特性

下面列表展示了相比基本路由模板开销更多的路由特性分析:

  • 正则表达式:可能会编写复杂的正则表达式,或者较少的输入就会导致长时间的运行
  • 复杂分段({x}-{y}-{z}):
    要比解析一个正则表达式 URL 路径分段更加复杂
    导致更多的子字符串的开销
    ASP.NET Core 3.0 中复杂分段逻辑并没有更新,路由的性能也没有更新
  • 异步数据获取:许多复杂的应用程序在它们的路由中都会访问数据库。ASP.NET Core 2.2 以及之前的版本路由没有提供路由访问数据库的功能。例如,IRouteConstraint,IActionConstraint 是同步的。扩展的 MatcherPolicy 和 EndpointSelectorContext 是异步的。

库作者指南

这部分包含了建立在路由之上的库编写者指南。这些细节目的是为了保证应用程序的开发者在使用库和框架扩展路由的时候能有一个好的体验。

定义 endpoints

创建一个使用路由实现 URL 匹配的框架,开始需要定义一个建立在 UesEnpoints 之上的用户体验。

保证 在 IEndpointRouteBuilder 之上开始建立。这运行用户把你的框架和其它 ASP.NET Core 特性很好的构造在一起。每一个 ASP.NET Core 模板都包含路由。假设路由已经存在并且对用户来说很熟悉。

app.UseEndpoints(endpoints =>
{
    // Your framework
    endpoints.MapMyFramework(...);

    endpoints.MapHealthChecks("/healthz");
});

保证 调用 MapMyFramework(...) 返回一个具体的实现了 IEndpointConventionBuilder 的类型。大多数的框架 Map... 方法遵循这个模型。IEndpointConventionBuilder 接口;

  • 允许组合 metadata
  • 以各种扩展方法为目标

声明你自己的类型允许你添加你自己框架特有的功能到 builder 中。封装一个框架声明的 builder 然后去调用它是可行的。

app.UseEndpoints(endpoints =>
{
    // Your framework
    endpoints.MapMyFramework(...).RequireAuthorization()
                                 .WithMyFrameworkFeature(awesome: true);

    endpoints.MapHealthChecks("/healthz");
});

考虑编写你自己的 EndpointDataSource. EndpointDataSource 用来声明和更新 endpoints 集合的低级原语。EndpointDataSource 是一个功能强大的 API,被 controllers 和 Razor Pages 使用。

路由测试包含一个不更新 data source 的基本示例。

不要试图默认注册一个 EndpointDataSource。要求用户在 UseEndpoint 中注册你的框架。路由的哲学就是默认什么都不包含, UseEndpoints 就是注册 endpoints 的地方。

创建一个路由集成的中间件

考虑定义 metadata 类型作为一个接口

保证 能够在类和方法上面使用 metadata 类型。

public interface ICoolMetadata
{
    bool IsCool { get; }
}

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class CoolMetadataAttribute : Attribute, ICoolMetadata
{
    public bool IsCool => true;
}

像 controllers 和 Razor Pages 这样的框架支持应用 metadata 属性到类型和方法。如果你声明了 metadata 类型:

  • 使得它们可以作为 attributes 被获取
  • 大多数用户熟悉应用属性

声明 metadata 类型为一个接口增加了另外一层灵活性:

  • 接口是可组合的
  • 开发者可以声明结合多个策略的类型

保证 metadata 能够被重写,就像下面展示的例子一样:

public interface ICoolMetadata
{
    bool IsCool { get; }
}

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class CoolMetadataAttribute : Attribute, ICoolMetadata
{
    public bool IsCool => true;
}

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class SuppressCoolMetadataAttribute : Attribute, ICoolMetadata
{
    public bool IsCool => false;
}

[CoolMetadata]
public class MyController : Controller
{
    public void MyCool() { }

    [SuppressCoolMetadata]
    public void Uncool() { }
}

遵守这些指南的最好的方式是避免定义 maker metadata:

  • 不要只寻找 metadata 类型的存在
  • 定义一个 metadata 的属性并且要检查这个属性

metadata 集合是根据优先级排序和支持重写的。在控制器的情况下,action 上的 metadata 是最确定的。

保证 不论有没有路由中间件都应该有用。

app.UseRouting();

app.UseAuthorization(new AuthorizationPolicy() { ... });

app.UseEndpoints(endpoints =>
{
    // Your framework
    endpoints.MapMyFramework(...).RequireAuthorization();
});

作为这个指南的一个例子,考虑使用 UseAuthorization 中间件。authorization 中间件允许你传递一个反馈策略。如果反馈策略被指定了,将会应用到:

  • 没有指定策略的 endpoints
  • 不需要匹配 endpoint 的请求

这使得 authorization 中间件在路由上下文之外也有作用。authorization 中间件可以被用做传统中间件编程。

调试诊断

对于更详细的路由调试输出,设置 Logging:LogLevel:Microsoft 为 Debug。在开发环境中,在 appsettings.Development.json 中设置日志等级:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Debug",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  }
}

你可能感兴趣的:(asp.net,服务器,java,中间件,后端)