ASP.NET Core 中的路由

作者:Ryan NowakKirk LarkinnRick Anderson

注意

此版本不是本文的最新版本。 对于当前版本,请参阅此文的 .NET 8 版本

警告

此版本的 ASP.NET Core 不再受支持。 有关详细信息,请参阅 .NET 和 .NET Core 支持策略。 对于当前版本,请参阅此文的 .NET 8 版本

重要

此信息与预发布产品相关,相应产品在商业发布之前可能会进行重大修改。 Microsoft 对此处提供的信息不提供任何明示或暗示的保证。

对于当前版本,请参阅此文的 .NET 8 版本

路由负责匹配传入的 HTTP 请求,然后将这些请求发送到应用的可执行终结点。 终结点是应用的可执行请求处理代码单元。 终结点在应用中进行定义,并在应用启动时进行配置。 终结点匹配过程可以从请求的 URL 中提取值,并为请求处理提供这些值。 通过使用应用中的终结点信息,路由还能生成映射到终结点的 URL。

应用可以使用以下内容配置路由:

  • Controllers
  • Razor Pages
  • SignalR
  • gRPC 服务
  • 启用终结点的中间件,例如运行状况检查
  • 通过路由注册的委托和 Lambda。

本文介绍 ASP.NET Core 路由的较低级别详细信息。 有关配置路由的信息,请参阅:

路由基础知识

以下代码演示路由的基本示例:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();

前面的示例包含使用 MapGet 方法的单个终结点:

  • 当 HTTP GET 请求发送到根 URL / 时:
    • 将执行请求委托。
    • Hello World! 会写入 HTTP 响应。
  • 如果请求方法不是 GET 或根 URL 不是 /,则无路由匹配,并返回 HTTP 404。

路由使用一对由 UseRoutingUseEndpoints 注册的中间件:

  • UseRouting 向中间件管道添加路由匹配。 此中间件会查看应用中定义的终结点集,并根据请求选择最佳匹配
  • UseEndpoints 向中间件管道添加终结点执行。 它会运行与所选终结点关联的委托。

应用通常不需要调用 UseRoutingUseEndpointsWebApplicationBuilder 配置中间件管道,该管道使用 UseRoutingUseEndpoints 包装在 Program.cs 中添加的中间件。 但是,应用可以通过显式调用这些方法来更改 UseRoutingUseEndpoints 的运行顺序。 例如,下面的代码显式调用 UseRouting

app.Use(async (context, next) =>
{
    // ...
    await next(context);
});

app.UseRouting();

app.MapGet("/", () => "Hello World!");

在上述代码中:

  • app.Use 的调用会注册一个在管道的开头运行的自定义中间件。
  • UseRouting 的调用将路由匹配中间件配置为在自定义中间件之后运行。
  • 使用 MapGet 注册的终结点在管道末尾运行。

如果前面的示例不包含对 UseRouting 的调用,则自定义中间件将在路由匹配中间件之后运行。

注意:直接添加到 WebApplication 的路由在管道的末端执行。

终结点

MapGet 方法用于定义终结点。 终结点可以:

  • 通过匹配 URL 和 HTTP 方法来选择。
  • 通过运行委托来执行。

可通过应用匹配和执行的终结点在 UseEndpoints 中进行配置。 例如,MapGetMapPostMapGet将请求委托连接到路由系统。 其他方法可用于将 ASP.NET Core 框架功能连接到路由系统:

下面的示例演示如何使用更复杂的路由模板进行路由:

app.MapGet("/hello/{name:alpha}", (string name) => $"Hello {name}!");

/hello/{name:alpha} 字符串是一个路由模板。 路由模板用于配置终结点的匹配方式。 在这种情况下,模板将匹配:

  • 类似 /hello/Docs 的 URL
  • /hello/ 开头、后跟一系列字母字符的任何 URL 路径。 :alpha 应用仅匹配字母字符的路由约束。 路由约束将在本文的后面介绍。

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

下面的示例演示如何通过运行状况检查和授权进行路由:

app.UseAuthentication();
app.UseAuthorization();

app.MapHealthChecks("/healthz").RequireAuthorization();
app.MapGet("/", () => "Hello World!");

前面的示例展示了如何:

  • 将授权中间件与路由一起使用。
  • 将终结点用于配置授权行为。

MapHealthChecks 调用添加运行状况检查终结点。 将 RequireAuthorization 链接到此调用会将授权策略附加到该终结点。

调用 UseAuthenticationUseAuthorization 会添加身份验证和授权中间件。 这些中间件位于 UseRoutingUseEndpoints 之间,因此它们可以:

  • 查看 UseRouting 选择的终结点。
  • UseEndpoints 发送到终结点之前应用授权策略。

终结点元数据

前面的示例中有两个终结点,但只有运行状况检查终结点附加了授权策略。 如果请求与运行状况检查终结点 /healthz 匹配,则执行授权检查。 这表明,终结点可以附加额外的数据。 此额外数据称为终结点元数据:

  • 可以通过路由感知中间件来处理元数据。
  • 元数据可以是任意的 .NET 类型。

路由概念

路由系统通过添加功能强大的终结点概念,构建在中间件管道之上。 终结点代表应用的功能单元,在路由、授权和任意数量的 ASP.NET Core 系统方面彼此不同。

ASP.NET Core 终结点定义

ASP.NET Core 终结点是:

以下代码显示了如何检索和检查与当前请求匹配的终结点:

app.Use(async (context, next) =>
{
    var currentEndpoint = context.GetEndpoint();

    if (currentEndpoint is null)
    {
        await next(context);
        return;
    }

    Console.WriteLine($"Endpoint: {currentEndpoint.DisplayName}");

    if (currentEndpoint is RouteEndpoint routeEndpoint)
    {
        Console.WriteLine($"  - Route Pattern: {routeEndpoint.RoutePattern}");
    }

    foreach (var endpointMetadata in currentEndpoint.Metadata)
    {
        Console.WriteLine($"  - Metadata: {endpointMetadata}");
    }

    await next(context);
});

app.MapGet("/", () => "Inspect Endpoint.");

如果选择了终结点,可从 HttpContext 中进行检索。 可以检查其属性。 终结点对象是不可变的,并且在创建后无法修改。 最常见的终结点类型是 RouteEndpointRouteEndpoint 包括允许自己被路由系统选择的信息。

在前面的代码中,app.Use 配置了一个内联中间件

下面的代码显示,根据管道中调用 app.Use 的位置,可能不存在终结点:

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

app.UseRouting();

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

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

app.UseEndpoints(_ => { });

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

前面的示例添加了 Console.WriteLine 语句,这些语句显示是否已选择终结点。 为清楚起见,该示例将显示名称分配给提供的 / 终结点。

前面的示例还包括对 UseRoutingUseEndpoints 的调用,以准确控制这些中间件何时在管道中运行。

使用 / URL 运行此代码将显示:

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

使用任何其他 URL 运行此代码将显示:

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

此输出说明:

  • 调用 UseRouting 之前,终结点始终为 null。
  • 如果找到匹配项,则 UseRoutingUseEndpoints 之间的终结点为非 null。
  • 如果找到匹配项,则 UseEndpoints 中间件即为终端。 稍后会在本文中定义终端中间件
  • 仅当找不到匹配项时才执行 UseEndpoints 后的中间件。

UseRouting 中间件使用 SetEndpoint 方法将终结点附加到当前上下文。 可以将 UseRouting 中间件替换为自定义逻辑,同时仍可获得使用终结点的益处。 终结点是中间件等低级别基元,不与路由实现耦合。 大多数应用都不需要将 UseRouting 替换为自定义逻辑。

UseEndpoints 中间件旨在与 UseRouting 中间件配合使用。 执行终结点的核心逻辑并不复杂。 使用 GetEndpoint 检索终结点,然后调用其 RequestDelegate 属性。

下面的代码演示中间件如何影响或响应路由:

app.UseHttpMethodOverride();
app.UseRouting();

app.Use(async (context, next) =>
{
    if (context.GetEndpoint()?.Metadata.GetMetadata<RequiresAuditAttribute>() is not null)
    {
        Console.WriteLine($"ACCESS TO SENSITIVE DATA AT: {DateTime.UtcNow}");
    }

    await next(context);
});

app.MapGet("/", () => "Audit isn't required.");
app.MapGet("/sensitive", () => "Audit required for sensitive data.")
    .WithMetadata(new RequiresAuditAttribute());
public class RequiresAuditAttribute : Attribute { }

前面的示例演示两个重要概念:

  • 中间件可以在 UseRouting 之前运行,以修改路由操作的数据。
  • 中间件可以在 UseRoutingUseEndpoints 之间运行,以便在执行终结点前处理路由结果。
    • UseRoutingUseEndpoints 之间运行的中间件:
      • 通常会检查元数据以了解终结点。
      • 通常会根据 UseAuthorizationUseCors 做出安全决策。
    • 中间件和元数据的组合允许按终结点配置策略。

前面的代码显示了支持按终结点策略的自定义中间件示例。 中间件将访问敏感数据的审核日志写入控制台。 可以将中间件配置为审核具有 元数据的终结点。 此示例演示选择加入模式,其中仅审核标记为敏感的终结点。 例如,可以反向定义此逻辑,从而审核未标记为安全的所有内容。 终结点元数据系统非常灵活。 此逻辑可以以任何适合用例的方法进行设计。

前面的示例代码旨在演示终结点的基本概念。 该示例不应在生产环境中使用。 审核日志中间件的更完整版本如下:

  • 记录到文件或数据库。
  • 包括详细信息,如用户、IP 地址、敏感终结点的名称等。

审核策略元数据 RequiresAuditAttribute 定义为一个 Attribute,便于和基于类的框架(如控制器和 SignalR)结合使用。 使用路由到代码时:

  • 元数据附有生成器 API。
  • 基于类的框架在创建终结点时,包含了相应方法和类的所有特性。

对于元数据类型,最佳做法是将它们定义为接口或特性。 接口和特性允许代码重用。 元数据系统非常灵活,无任何限制。

将终端中间件与路由进行比较

下面的示例演示了终端中间件和路由:

// Approach 1: Terminal Middleware.
app.Use(async (context, next) =>
{
    if (context.Request.Path == "/")
    {
        await context.Response.WriteAsync("Terminal Middleware.");
        return;
    }

    await next(context);
});

app.UseRouting();

// Approach 2: Routing.
app.MapGet("/Routing", () => "Routing.");

使用 Approach 1: 显示的中间件样式是终端中间件。 之所以称之为终端中间件,是因为它执行匹配的操作:

  • 前面示例中的匹配操作是用于中间件的 Path == "/" 和用于路由的 Path == "/Routing"
  • 如果匹配成功,它将执行一些功能并返回,而不是调用 next 中间件。

之所以称之为终端中间件,是因为它会终止搜索,执行一些功能,然后返回。

以下列表将终端中间件与路由进行比较:

  • 这两种方法都允许终止处理管道:
    • 中间件通过返回而不是调用 next 来终止管道。
    • 终结点始终是终端。
  • 终端中间件允许在管道中的任意位置放置中间件:
  • 终端中间件允许任意代码确定中间件匹配的时间:
    • 自定义路由匹配代码可能比较复杂,且难以正确编写。
    • 路由为典型应用提供了简单的解决方案。 大多数应用不需要自定义路由匹配代码。
  • 带有中间件的终结点接口,如 UseAuthorizationUseCors
    • 通过 UseAuthorizationUseCors 使用终端中间件需要与授权系统进行手动交互。

终结点定义以下两者:

  • 用于处理请求的委托。
  • 任意元数据的集合。 元数据用于实现横切关注点,该实现基于附加到每个终结点的策略和配置。

终端中间件可以是一种有效的工具,但可能需要:

  • 大量的编码和测试。
  • 手动与其他系统集成,以实现所需的灵活性级别。

请考虑在写入终端中间件之前与路由集成。

MapMapWhen 相集成的现有终端中间件通常会转换为路由感知终结点。 MapHealthChecks 演示了路由器软件的模式:

下面的代码演示了 MapHealthChecks 的使用方法:

app.UseAuthentication();
app.UseAuthorization();

app.MapHealthChecks("/healthz").RequireAuthorization();

前面的示例说明了为什么返回生成器对象很重要。 返回生成器对象后,应用开发者可以配置策略,如终结点的授权。 在此示例中,运行状况检查中间件不与授权系统直接集成。

元数据系统的创建目的是为了响应扩展性创建者使用终端中间件时遇到的问题。 对于每个中间件,实现自己与授权系统的集成都会出现问题。

URL 匹配

  • 是路由将传入请求匹配到终结点的过程。
  • 基于 URL 路径中的数据和标头。
  • 可进行扩展,以考虑请求中的任何数据。

当路由中间件执行时,它会设置 Endpoint,并将值从当前请求路由到 HttpContext 上的Endpoint

  • 调用 GetEndpoint 获取终结点。
  • HttpRequest.RouteValues 将获取路由值的集合。

中间件在路由中间件可以检查终结点并采取措施之后运行。 例如,授权中间件可以在终结点的元数据集合中询问授权策略。 请求处理管道中的所有中间件执行后,将调用所选终结点的委托。

终结点路由中的路由系统负责所有的调度决策。 中间件基于所选终结点应用策略,因此重要的是:

  • 任何可能影响发送或安全策略应用的决定都应在路由系统中做出。

警告

为了实现向后兼容性,执行 Controller 或 Razor Pages 终结点委托时,应根据迄今执行的请求处理将 RouteContext.RouteData 的属性设为适当的值。

在未来的版本中,会将 RouteContext 类型标记为已过时:

  • RouteData.Values 迁移到 HttpRequest.RouteValues
  • 迁移 RouteData.DataTokens 以从终结点元数据检索 IDataTokensMetadata

URL 匹配在可配置的阶段集中运行。 在每个阶段中,输出为一组匹配项。 下一阶段可以进一步缩小这一组匹配项。 路由实现不保证匹配终结点的处理顺序。 所有可能的匹配项一次性处理。 URL 匹配阶段按以下顺序出现。 ASP.NET Core:

  1. 针对终结点集及其路由模板处理 URL 路径,收集所有匹配项。
  2. 采用前面的列表并删除在应用路由约束时失败的匹配项。
  3. 采用前面的列表并删除 MatcherPolicy 实例集失败的匹配项。
  4. 使用 EndpointSelector 从前面的列表中做出最终决定。

根据以下内容设置终结点列表的优先级:

在每个阶段中处理所有匹配的终结点,直到达到 EndpointSelectorEndpointSelector 是最后一个阶段。 它从匹配项中选择最高优先级终结点作为最佳匹配项。 如果存在具有与最佳匹配相同优先级的其他匹配项,则会引发不明确的匹配异常。

路由优先顺序基于更具体的路由模板(优先级更高)进行计算。 例如,考虑模板 /hello/{message}

  • 两者都匹配 URL 路径 /hello
  • /hello 更具体,因此优先级更高。

通常,路由优先顺序非常适合为实践操作中使用的各种 URL 方案选择最佳匹配项。 仅在必要时才使用 Order 来避免多义性。

由于路由提供的扩展性种类,路由系统无法提前计算不明确的路由。 假设有一个示例,例如路由模板 /{message:alpha}/{message:int}

  • alpha 约束仅匹配字母数字字符。
  • int 约束仅匹配数字。
  • 这些模板具有相同的路由优先顺序,但没有两者均匹配的单一 URL。
  • 如果路由系统在启动时报告了多义性错误,则会阻止此有效用例。

警告

UseEndpoints 内的操作顺序并不会影响路由行为,但有一个例外。 MapControllerRouteMapAreaRoute 会根据调用顺序,自动将顺序值分配给其终结点。 这会模拟控制器的长时间行为,而无需路由系统提供与早期路由实现相同的保证。

ASP.NET Core 中的终结点路由:

  • 没有路由的概念。
  • 不提供顺序保证。 同时处理所有终结点。

路由模板优先顺序和终结点选择顺序

路由模板优先顺序是一种系统,该系统根据每个路由模板的具体程度为其分配值。 路由模板优先顺序:

  • 无需在常见情况下调整终结点的顺序。
  • 尝试匹配路由行为的常识性预期。

例如,考虑模板 /Products/List/Products/{id}。 我们可合理地假设,对于 URL 路径 /Products/List/Products/List 匹配项比 /Products/{id} 更好。 这种假设合理是因为文本段 /List 比参数段 /{id} 具有更好的优先顺序。

优先顺序工作原理的详细信息与路由模板的定义方式相耦合:

  • 具有更多段的模板则更具体。
  • 带有文本的段比参数段更具体。
  • 具有约束的参数段比没有约束的参数段更具体。
  • 复杂段与具有约束的参数段同样具体。
  • catch-all 参数是最不具体的参数。 有关 catch-all 路由的重要信息,请参阅路由模板部分中的“catch-all”。

URL 生成概念

URL 生成:

  • 是指路由基于一系列路由值创建 URL 路径的过程。
  • 允许终结点与访问它们的 URL 之间存在逻辑分隔。

终结点路由包含 LinkGenerator API。 LinkGeneratorLinkGenerator 中可用的单一实例服务。 LinkGenerator API 可在执行请求的上下文之外使用。 Mvc.IUrlHelper 和依赖 IUrlHelper 的方案(如标记帮助程序、HTML 帮助程序和操作结果)在内部使用 LinkGenerator API 提供链接生成功能。

链接生成器基于“地址”和“地址方案”概念 。 地址方案是确定哪些终结点用于链接生成的方式。 例如,许多用户熟悉的来自控制器或 Razor Pages 的路由名称和路由值方案都是作为地址方案实现的。

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

这些方法的重载接受包含 HttpContext 的参数。 这些方法在功能上等同于 Url.ActionUrl.Page,但提供了更大的灵活性和更多选项。

GetPath* 方法与 Url.ActionUrl.Page 最相似,因为它们生成包含绝对路径的 URI。 GetUri* 方法始终生成包含方案和主机的绝对 URI。 接受 HttpContext 的方法在执行请求的上下文中生成 URI。 除非重写,否则将使用来自执行请求的环境路由值、URL 基路径、方案和主机。

使用地址调用 LinkGenerator。 生成 URI 的过程分两步进行:

  1. 将地址绑定到与地址匹配的终结点列表。
  2. 计算每个终结点的 RoutePattern,直到找到与提供的值匹配的路由模式。 输出结果会与提供给链接生成器的其他 URI 部分进行组合并返回。

对任何类型的地址,LinkGenerator 提供的方法均支持标准链接生成功能。 使用链接生成器的最简便方法是通过扩展方法对特定地址类型执行操作:

扩展方法 描述
GetPathByAddress 根据提供的值生成具有绝对路径的 URI。
GetUriByAddress 根据提供的值生成绝对 URI。

警告

请注意有关调用 LinkGenerator 方法的下列含义:

  • 对于不验证传入请求的 Host 标头的应用配置,请谨慎使用 GetUri* 扩展方法。 如果未验证传入请求的 Host 标头,则可能以视图或页面中 URI 的形式将不受信任的请求输入发送回客户端。 建议所有生产应用都将其服务器配置为针对已知有效值验证 Host 标头。

  • 在中间件中将 LinkGeneratorMapMapWhen 结合使用时,请小心谨慎。 Map* 会更改执行请求的基路径,这会影响链接生成的输出。 所有 LinkGenerator API 都允许指定基路径。 指定一个空的基路径来撤消 Map* 对链接生成的影响。

中间件示例

在以下示例中,中间件使用 LinkGenerator API 创建列出存储产品的操作方法的链接。 应用中的任何类都可通过将链接生成器注入类并调用 GenerateLink 来使用链接生成器:

public class ProductsMiddleware
{
    private readonly LinkGenerator _linkGenerator;

    public ProductsMiddleware(RequestDelegate next, LinkGenerator linkGenerator) =>
        _linkGenerator = linkGenerator;

    public async Task InvokeAsync(HttpContext httpContext)
    {
        httpContext.Response.ContentType = MediaTypeNames.Text.Plain;

        var productsPath = _linkGenerator.GetPathByAction("Products", "Store");

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

路由模板

如果路由找到匹配项,{} 内的令牌定义绑定的路由参数。 可在路由段中定义多个路由参数,但必须用文本值隔开这些路由参数。 例如:

{controller=Home}{action=Index}

不是有效的路由,因为 {controller}{action} 之间没有文本值。 路由参数必须具有名称,且可能指定了其他特性。

路由参数以外的文本(例如 {id})和路径分隔符 / 必须匹配 URL 中的文本。 文本匹配区分大小写,并且基于 URL 路径已解码的表示形式。 要匹配文字路由参数分隔符({}),请通过重复该字符来转义分隔符。 例如 {{}}

星号 * 或双星号 **

  • 可用作路由参数的前缀,以绑定到 URI 的 rest。
  • 称为 catch-all 参数。 例如,blog/{**slug}
    • 匹配以 blog/ 开头并在其后面包含任何值的任何 URI。
    • blog/ 后的值分配给 blog/ 路由值。

警告

由于路由中的 bugcatch-all 参数可能无法正确匹配相应路由。 受此 Bug 影响的应用具有以下特征:

  • “全部捕获”路由,例如 {**slug}"
  • “全部捕获”路由未能匹配应与之匹配的请求。
  • 删除其他路由可使“全部捕获”路由开始运行。

请参阅 GitHub bug 1867716579,了解遇到此 bug 的示例。

.NET Core 3.1.301 SDK 及更高版本中包含此 bug 的修补程序(可选用)。 以下代码设置了一个可修复此 bug 的内部开关:

public static void Main(string[] args)
{
   AppContext.SetSwitch("Microsoft.AspNetCore.Routing.UseCorrectCatchAllBehavior", 
                         true);
   CreateHostBuilder(args).Build().Run();
}
// Remaining code removed for brevity.

全方位参数还可以匹配空字符串。

使用路由生成 URL(包括路径分隔符 /)时,catch-all 参数会转义相应的字符。 例如,路由值为 { path = "my/path" } 的路由 foo/{*path} 生成 foo/my%2Fpath。 请注意转义的正斜杠。 要往返路径分隔符,请使用 ** 路由参数前缀。 { path = "my/path" } 的路由 foo/{**path} 生成 foo/my/path

尝试捕获具有可选文件扩展名的文件名的 URL 模式还有其他注意事项。 例如,考虑模板 files/{filename}.{ext?}。 当 filenameext 的值都存在时,将填充这两个值。 如果 URL 中仅存在 filename 的值,则路由匹配,因为尾随 . 是可选的。 以下 URL 与此路由相匹配:

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

路由参数可能具有指定的默认值,方法是在参数名称后使用等号 () 隔开以指定默认值。 例如,{controller=Home}Home 定义为 controller 的默认值。 如果参数的 URL 中不存在任何值,则使用默认值。 通过在参数名称的末尾附加问号 (?) 可使路由参数成为可选项。 例如 id?。 可选值和默认路由参数之间的差异是:

  • 具有默认值的路由参数始终生成一个值。
  • 仅当请求 URL 提供值时,可选参数才具有值。

路由参数可能具有必须与从 URL 中绑定的路由值匹配的约束。 在路由参数后面添加一个 : 和约束名称可指定路由参数上的内联约束。 如果约束需要参数,将以在约束名称后括在括号 (...) 中的形式提供。 通过追加另一个 和约束名称,可指定多个内联约束。

约束名称和参数将传递给 IInlineConstraintResolver 服务,以创建 IRouteConstraint 的实例,用于处理 URL。 例如,路由模板 blog/{article:minlength(10)} 使用参数 10 指定 minlength 约束。 有关路由约束的详细信息以及框架提供的约束列表,请参阅路由约束部分。

路由参数还可以具有参数转换器。 参数转换器在生成链接以及将操作和页面匹配到 URI 时转换参数的值。 与约束类似,可在路由参数名称后面添加 : 和转换器名称,将参数变换器内联添加到路径参数。 例如,路由模板 blog/{article:slugify} 指定 slugify 转换器。 有关参数转换的详细信息,请参阅参数转换器部分。

下表演示了示例路由模板及其行为:

路由模板 示例匹配 URI 请求 URI…
hello /hello 仅匹配单个路径 /hello
{Page=Home} / 匹配并将 Page 设置为 Home
{Page=Home} /Contact 匹配并将 Page 设置为 Contact
{controller}/{action}/{id?} /Products/List 映射到 Products 控制器和 List 操作。
{controller}/{action}/{id?} /Products/Details/123 映射到 Products 控制器和 Details 操作,并将 id 设置为 123。
{controller=Home}/{action=Index}/{id?} / 映射到 Home 控制器和 Index 方法。 id 将被忽略。
{controller=Home}/{action=Index}/{id?} /Products 映射到 Products 控制器和 Index 方法。 id 将被忽略。

使用模板通常是进行路由最简单的方法。 还可在路由模板外指定约束和默认值。

复杂段

复杂段通过非贪婪的方式从右到左匹配文字进行处理。 例如,[Route("/a{b}c{d}")] 是一个复杂段。 复杂段以一种特定的方式工作,必须理解这种方式才能成功地使用它们。 本部分中的示例演示了为什么复杂段只有在分隔符文本没有出现在参数值中时才真正有效。 对于更复杂的情况,需要使用 regex,然后手动提取值。

警告

如果使用 System.Text.RegularExpressions 处理不受信任的输入,则传递一个超时。 恶意用户可能会向 RegularExpressions 提供输入,从而导致拒绝服务攻击。 使用 RegularExpressions 的 ASP.NET Core 框架 API 会传递一个超时。

这是使用模板 /a{b}c{d} 和 URL 路径 /abcd 执行路由的步骤摘要。 | 有助于可视化算法的工作方式:

  • 从右到左的第一个文本是 c。 因此从右侧搜索 /abcd,并查找 /ab|c|d
  • 右侧的所有内容 (d) 现在都与路由参数 {d} 相匹配。
  • 从右到左的下一个文本是 a。 从我们停止的地方开始搜索 /ab|c|d,然后 /|a|b|c|d 找到 a
  • 右侧的值 (b) 现在与路由参数 {b} 相匹配。
  • 没有剩余的文本,并且没有剩余的路由模板,因此这是一个匹配项。

下面是使用相同模板 /a{b}c{d} 和 URL 路径 /aabcd 的负面案例示例。 | 有助于可视化算法的工作方式。 该案例不是匹配项,可由相同算法解释:

  • 从右到左的第一个文本是 c。 因此从右侧搜索 /aabcd,并查找 /aab|c|d
  • 右侧的所有内容 (d) 现在都与路由参数 {d} 相匹配。
  • 从右到左的下一个文本是 a。 从我们停止的地方开始搜索 /aab|c|d,然后 /a|a|b|c|d 找到 a
  • 右侧的值 (b) 现在与路由参数 {b} 相匹配。
  • 此时还有剩余的文本 a,但是算法已经耗尽了要解析的路由模板,所以这不是一个匹配项。

匹配算法是非贪婪算法:

  • 它匹配每个步骤中最小可能文本量。
  • 如果分隔符值出现在参数值内,则会导致不匹配。

正则表达式可以更好地控制它们的匹配行为。

贪婪匹配,也称为最大匹配尝试,会在满足正则表达式模式的输入文本中搜寻最长的匹配。 非贪婪匹配,也称为懒惰匹配,会在满足正则表达式模式的输入文本中搜寻最短的匹配项。

使用特殊字符进行路由

使用特殊字符进行路由可能会导致意外的结果。 例如,假设控制器具有以下操作方法:

[HttpGet("{id?}/name")]
public async Task<ActionResult<string>> GetName(string id)
{
    var todoItem = await _context.TodoItems.FindAsync(id);

    if (todoItem == null || todoItem.Name == null)
    {
        return NotFound();
    }

    return todoItem.Name;
}

如果 string id 包含以下编码值,可能会出现意外结果:

ASCII Encoded
/ %2F
+

路由参数并不总是 URL 解码的。 此问题可能会在未来得到解决。 有关详细信息,请参阅此 GitHub 问题

路由约束

路由约束在传入 URL 发生匹配时执行,URL 路径标记为路由值。 路径约束通常检查通过路径模板关联的路径值,并对该值是否为可接受做出对/错决定。 某些路由约束使用路由值以外的数据来考虑是否可以路由请求。 例如,HttpMethodRouteConstraint 可以根据其 HTTP 谓词接受或拒绝请求。 约束用于路由请求和链接生成。

警告

请勿将约束用于输入验证。 如果约束用于输入验证,则无效的输入将导致 404(找不到页面)响应。 无效输入可能生成包含相应错误消息的 400 错误请求。 路由约束用于消除类似路由的歧义,而不是验证特定路由的输入。

下表演示示例路由约束及其预期行为:

约束 示例 匹配项示例 说明
int {id:int} 123456789-123456789 匹配任何整数
bool {active:bool} trueFALSE 匹配 truefalse。 不区分大小写
datetime {dob:datetime} 2016-12-312016-12-31 7:32pm 在固定区域性中匹配有效的 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 处理不受信任的输入,则传递一个超时。 恶意用户可能会向 RegularExpressions 提供输入,从而导致拒绝服务攻击。 使用 RegularExpressions 的 ASP.NET Core 框架 API 会传递一个超时。

可向单个参数应用多个用冒号分隔的约束。 例如,以下约束将参数限制为大于或等于 1 的整数值:

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

警告

验证 URL 的路由约束并将转换为始终使用固定区域性的 CLR 类型。 例如,转换为 CLR 类型 intDateTime。 这些约束假定 URL 不可本地化。 框架提供的路由约束不会修改存储于路由值中的值。 从 URL 中分析的所有路由值都将存储为字符串。 例如,float 约束会尝试将路由值转换为浮点数,但转换后的值仅用来验证其是否可转换为浮点数。

约束中的正则表达式

警告

如果使用 System.Text.RegularExpressions 处理不受信任的输入,则传递一个超时。 恶意用户可能会向 RegularExpressions 提供输入,从而导致拒绝服务攻击。 使用 RegularExpressions 的 ASP.NET Core 框架 API 会传递一个超时。

使用 regex(...) 路由约束可以将正则表达式指定为内联约束。 MapControllerRoute 系列中的方法还接受约束的对象文字。 如果使用该窗体,则字符串值将解释为正则表达式。

下面的代码使用内联 regex 约束:

app.MapGet("{message:regex(^\\d{{3}}-\\d{{2}}-\\d{{4}}$)}",
    () => "Inline Regex Constraint Matched");

下面的代码使用对象文字来指定 regex 约束:

app.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# 源文件中的 \\ 字符,以便对 \ 字符串转义字符进行转义。
  • 逐字字符串文本

要对路由参数分隔符 {}[] 进行转义,请将表达式(例如 {{}}[[]])中的字符数加倍。 下表展示了正则表达式及其转义的版本:

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

路由中使用的正则表达式通常以 ^ 字符开头,并匹配字符串的起始位置。 表达式通常以 $ 字符结尾,并匹配字符串的结尾。 ^$ 字符可确保正则表达式匹配整个路由参数值。 如果没有 ^$ 字符,正则表达式将匹配字符串内的所有子字符串,而这通常是不需要的。 下表提供了示例并说明了它们匹配或匹配失败的原因:

表达式 String 匹配 注释
[a-z]{2} hello 子字符串匹配
[a-z]{2} 123abc456 子字符串匹配
[a-z]{2} mz 匹配表达式
[a-z]{2} MZ 不区分大小写
^[a-z]{2}$ hello 参阅上述 ^$
^[a-z]{2}$ 123abc456 参阅上述 ^$

有关正则表达式语法的详细信息,请参阅 .NET Framework 正则表达式

若要将参数限制为一组已知的可能值,可使用正则表达式。 例如,{action:regex(^(list|get|create)$)} 仅将 action 路由值匹配到 listgetcreate。 如果传递到约束字典中,字符串 ^(list|get|create)$ 将等效。 已传递到约束字典且不匹配任何已知约束的约束也将被视为正则表达式。 模板内传递且不匹配任何已知约束的约束将不被视为正则表达式。

自定义路由约束

可实现 IRouteConstraint 接口来创建自定义路由约束。 IRouteConstraint 接口包含 Match,当满足约束时,它返回 true,否则返回 false

很少需要自定义路由约束。 在实现自定义路由约束之前,请考虑替代方法,如模型绑定。

ASP.NET Core Constraints 文件夹提供了创建约束的经典示例。 例如 GuidRouteConstraint

若要使用自定义 IRouteConstraint,必须在服务容器中使用应用的 ConstraintMap 注册路由约束类型。 ConstraintMap 是将路由约束键映射到验证这些约束的 IRouteConstraint 实现的目录。 应用的 ConstraintMap 可作为 AddRouting 调用的一部分在 Program.cs 中进行更新,也可以通过使用 builder.Services.Configure<RouteOptions> 直接配置 RouteOptions 进行更新。 例如:

builder.Services.AddRouting(options =>
    options.ConstraintMap.Add("noZeroes", typeof(NoZeroesRouteConstraint)));

前面的约束应用于以下代码:

[ApiController]
[Route("api/[controller]")]
public class NoZeroesController : ControllerBase
{
    [HttpGet("{id:noZeroes}")]
    public IActionResult Get(string id) =>
        Content(id);
}

实现 NoZeroesRouteConstraint 可防止将 0 用于路由参数:

public class NoZeroesRouteConstraint : IRouteConstraint
{
    private static readonly Regex _regex = new(
        @"^[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 var routeValue))
        {
            return false;
        }

        var routeValueString = Convert.ToString(routeValue, CultureInfo.InvariantCulture);

        if (routeValueString is null)
        {
            return false;
        }

        return _regex.IsMatch(routeValueString);
    }
}

警告

如果使用 System.Text.RegularExpressions 处理不受信任的输入,则传递一个超时。 恶意用户可能会向 RegularExpressions 提供输入,从而导致拒绝服务攻击。 使用 RegularExpressions 的 ASP.NET Core 框架 API 会传递一个超时。

前面的代码:

  • 阻止路由的 {id} 段中的 0
  • 显示以提供实现自定义约束的基本示例。 不应在产品应用中使用。

下面的代码是防止处理包含 0id 的更好方法:

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

    return Content(id);
}

前面的代码与 NoZeroesRouteConstraint 方法相比具有以下优势:

  • 它不需要自定义约束。
  • 当路由参数包括 0 时,它将返回更具描述性的错误。

参数转换器

参数转换器:

例如,路由模式 blog\{article:slugify}(具有 Url.Action(new { article = "MyTestArticle" }))中的自定义 slugify 参数转换器生成 blog\my-test-article

请考虑以下 IOutboundParameterTransformer 实现:

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

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

若要在路由模式中使用参数转换器,请在 Program.cs 中使用 ConstraintMap 对其进行配置:

builder.Services.AddRouting(options =>
    options.ConstraintMap["slugify"] = typeof(SlugifyParameterTransformer));

ASP.NET Core 框架使用参数转化器来转换进行终结点解析的 URI。 例如,参数转换器转换用于匹配 areacontrolleractionpage 的路由值:

app.MapControllerRoute(
    name: "default",
    pattern: "{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 约定:

URL 生成参考

本部分包含 URL 生成实现的算法的参考。 在实践中,最复杂的 URL 生成示例使用控制器或 Razor Pages。 有关其他信息,请参阅控制器中的路由

URL 生成过程首先调用 LinkGenerator.GetPathByAddress 或类似方法。 此方法提供了一个地址、一组路由值以及有关 HttpContext 中当前请求的可选信息。

第一步是使用地址解析一组候选终结点,该终结点使用与该地址类型匹配的 IEndpointAddressScheme<TAddress>

地址方案找到一组候选项后,就会以迭代方式对终结点进行排序和处理,直到 URL 生成操作成功。 URL 生成不检查多义性,返回的第一个结果就是最终结果。

使用日志记录对 URL 生成进行故障排除

对 URL 生成进行故障排除的第一步是将 Microsoft.AspNetCore.Routing 的日志记录级别设置为 TRACELinkGenerator 记录有关其处理的许多详细信息,有助于排查问题。

有关 URL 生成的详细信息,请参阅 URL 生成参考

地址

地址是 URL 生成中的概念,用于将链接生成器中的调用绑定到一组终结点。

地址是一个可扩展的概念,默认情况下有两种实现:

  • 使用终结点名称 (string) 作为地址
    • 提供与 MVC 的路由名称相似的功能。
    • 使用 IEndpointNameMetadata 元数据类型。
    • 根据所有注册的终结点的元数据解析提供的字符串。
    • 如果多个终结点使用相同的名称,启动时会引发异常。
    • 建议用于控制器和 Razor Pages 之外的常规用途。
  • 使用路由值 (RouteValuesAddress) 作为地址
    • 提供与控制器和 Razor Pages 生成旧 URL 相同的功能。
    • 扩展和调试非常复杂。
    • 提供 IUrlHelper、标记帮助程序、HTML 帮助程序、操作结果等所使用的实现。

地址方案的作用是根据任意条件在地址和匹配终结点之间建立关联:

  • 终结点名称方案执行基本字典查找。
  • 路由值方案有一个复杂的子集算法,这是集算法的最佳子集。

环境值和显式值

从当前请求开始,路由将访问当前请求 HttpContext.Request.RouteValues 的路由值。 与当前请求关联的值称为环境值。 为清楚起见,文档是指作为显式值传入方法的路由值。

下面的示例演示了环境值和显式值。 它将提供当前请求中的环境值和显式值:

public class WidgetController : ControllerBase
{
    private readonly LinkGenerator _linkGenerator;

    public WidgetController(LinkGenerator linkGenerator) =>
        _linkGenerator = linkGenerator;

    public IActionResult Index()
    {
        var indexPath = _linkGenerator.GetPathByAction(
            HttpContext, values: new { id = 17 })!;

        return Content(indexPath);
    }

    // ...

前面的代码:

下面的代码仅提供显式值,不提供任何环境值:

var subscribePath = _linkGenerator.GetPathByAction(
    "Subscribe", "Home", new { id = 17 })!;

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

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

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

下面的代码提供当前请求中的环境值和显式值中的控制器:

public class GadgetController : ControllerBase
{
    public IActionResult Index() =>
        Content(Url.Action("Edit", new { id = 17 })!);
}

在上述代码中:

  • 返回 /Gadget/Edit/17
  • Url 获取 IUrlHelper
  • Action 生成一个 URL,其中包含操作方法的绝对路径。 URL 包含指定的 action 名称和 route 值。

下面的代码提供当前请求中的环境值和显式值:

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

        // ...
    }
}

当“编辑 Razor”页包含以下页面指令时,前面的代码会将 url 设置为 /Edit/17

@page "{id:int}"

如果“编辑”页面不包含 "{id:int}" 路由模板,url/Edit?id=17

除了此处所述的规则外,MVC IUrlHelper 的行为还增加了一层复杂性:

  • IUrlHelper 始终将当前请求中的路由值作为环境值提供。
  • IUrlHelper 始终将当前 actioncontroller 路由值复制为显式值,除非由开发者重写。
  • IUrlHelper 始终将当前 page 路由值复制为显式值,除非重写。
  • IUrlHelper.Page 始终替代当前 handler 路由值,且 null 为显式值,除非重写。

用户常常对环境值的行为详细信息感到惊讶,因为 MVC 似乎不遵循自己的规则。 出于历史和兼容性原因,某些路由值(例如 actioncontrollerpagehandler)具有其自己的特殊行为。

LinkGenerator.GetPathByActionLinkGenerator.GetPathByPage 提供的等效功能与 IUrlHelper 的兼容性异常相同。

URL 生成过程

找到候选终结点集后,URL 生成算法将:

  • 以迭代方式处理终结点。
  • 返回第一个成功的结果。

此过程的第一步称为路由值失效。 路由值失效是路由决定应使用环境值中的哪些路由值以及应忽略哪些路由值的过程。 将考虑每个环境值,要么与显式值组合,要么忽略它们。

考虑环境值角色的最佳方式是在某些常见情况下,尝试保存应用程序开发者的键入内容。 就传统意义而言,环境值非常有用的情况与 MVC 相关:

  • 链接到同一控制器中的其他操作时,不需要指定控制器名称。
  • 链接到同一区域中的另一个控制器时,不需要指定区域名称。
  • 链接到相同的操作方法时,不需要指定路由值。
  • 链接到应用的其他部分时,不需要在应用的该部分中传递无意义的路由值。

如果不了解路由值失效,通常就会导致对返回 nullLinkGeneratorIUrlHelper 执行调用。 显式指定更多路由值,对路由值失效进行故障排除,以查看是否解决了问题。

路由值失效的前提是应用的 URL 方案是分层的,并按从左到右的顺序组成层次结构。 请考虑基本控制器路由模板 {controller}/{action}/{id?},以直观地了解实践操作中该模板的工作方式。 对值进行更改会使右侧显示的所有路由值都失效 。 这反映了关于层次结构的假设。 如果应用的 id 有一个环境值,则操作会为 controller 指定一个不同的值:

  • id 不会重复使用,因为 {controller}{id?} 的左侧。

演示此原则的一些示例如下:

  • 如果显式值包含 id 的值,则将忽略 id 的环境值。 可以使用 controlleraction 的环境值。
  • 如果显式值包含 action 的值,则将忽略 action 的任何环境值。 可以使用 controller 的环境值。 如果 action 的显式值不同于 action 的环境值,则不会使用 id 值。 如果 action 的显式值与 action 的环境值相同,则可以使用 id 值。
  • 如果显式值包含 controller 的值,则将忽略 controller 的任何环境值。 如果 controller 的显式值不同于 controller 的环境值,则不会使用 actionid 值。 如果 controller 的显式值与 controller 的环境值相同,则可以使用 actionid 值。

由于存在特性路由和专用传统路由,此过程将变得更加复杂。 控制器传统路由(例如 {controller}/{action}/{id?})使用路由参数指定层次结构。 对于控制器和 Razor Pages 的专用传统路由特性路由

  • 有一个路由值层次结构。
  • 它们不会出现在模板中。

对于这些情况,URL 生成将定义“所需值”这一概念。 而由控制器和 Razor Pages 创建的终结点将指定所需值,以允许路由值失效起作用。

路由值失效算法的详细信息:

  • 所需值名称与路由参数组合在一起,然后从左到右进行处理。
  • 对于每个参数,将比较环境值和显式值:
    • 如果环境值和显式值相同,则该过程将继续。
    • 如果环境值存在而显式值不存在,则在生成 URL 时使用环境值。
    • 如果环境值不存在而显式值存在,则拒绝环境值和所有后续环境值。
    • 如果环境值和显式值均存在,并且这两个值不同,则拒绝环境值和所有后续环境值。

此时,URL 生成操作就可以计算路由约束了。 接受的值集与提供给约束的参数默认值相结合。 如果所有约束都通过,则操作将继续。

接下来,接受的值可用于扩展路由模板。 处理路由模板:

  • 从左到右。
  • 每个参数都替换了接受的值。
  • 具有以下特殊情况:
    • 如果接受的值缺少一个值并且参数具有默认值,则使用默认值。
    • 如果接受的值缺少一个值并且参数是可选的,则继续处理。
    • 如果缺少的可选参数右侧的任何路由参数都具有值,则操作将失败。
    • 如果可能,连续的默认值参数和可选参数会折叠。

显式提供且与路由片段不匹配的值将添加到查询字符串中。 下表显示使用路由模板 {controller}/{action}/{id?} 时的结果。

环境值 显式值 结果
控制器 =“Home” 操作 =“About” /Home/About
控制器 =“Home” 控制器 =“Order”,操作 =“About” /Order/About
控制器 =“Home”,颜色 =“Red” 操作 =“About” /Home/About
控制器 =“Home” 操作 =“About”,颜色 =“Red” /Home/About?color=Red

可选路由参数顺序

可选的路由参数必须在所有必需的路由参数和文本之后。 在以下代码中,idname 参数必须位于 color 参数之后:

using Microsoft.AspNetCore.Mvc;

namespace WebApplication1.Controllers;

[Route("api/[controller]")]
public class MyController : ControllerBase
{
    // GET /api/my/red/2/joe
    // GET /api/my/red/2
    // GET /api/my
    [HttpGet("{color}/{id:int?}/{name?}")]
    public IActionResult GetByIdAndOptionalName(string color, int id = 1, string? name = null)
    {
        return Ok($"{color} {id} {name ?? ""}");
    }
}

路由值失效的问题

下面的代码显示了路由不支持的 URL 生成方案示例:

app.MapControllerRoute(
    "default",
    "{culture}/{controller=Home}/{action=Index}/{id?}");

app.MapControllerRoute(
    "blog",
    "{culture}/{**slug}",
    new { controller = "Blog", action = "ReadPost" });

在前面的代码中,culture 路由参数用于本地化。 需要将 culture 参数始终作为环境值接受。 但由于所需值的工作方式,不会将 culture 参数作为环境值接受:

  • "default" 路由模板中,culture 路由参数位于 controller 的左侧,因此对 controller 的更改不会使 culture 失效。
  • "blog" 路由模板中,culture 路由参数应在 controller 的右侧,这会显示在所需值中。

使用 LinkParser 解析 URL 路径

LinkParser 类添加了对将 URL 路径解析为一组路由值的支持。 ParsePathByEndpointName 方法采用终结点名称和 URL 路径,并返回从 URL 路径中提取的一组路由值。

在以下示例控制器中,GetProduct 操作使用 api/Products/{id} 的路由模板,并且 NameGetProduct

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet("{id}", Name = nameof(GetProduct))]
    public IActionResult GetProduct(string id)
    {
        // ...

在同一个控制器类中,AddRelatedProduct 操作需要一个 URL 路径 pathToRelatedProduct,该路径可作为查询字符串参数提供:

[HttpPost("{id}/Related")]
public IActionResult AddRelatedProduct(
    string id, string pathToRelatedProduct, [FromServices] LinkParser linkParser)
{
    var routeValues = linkParser.ParsePathByEndpointName(
        nameof(GetProduct), pathToRelatedProduct);
    var relatedProductId = routeValues?["id"];

    // ...

在前面的示例中,AddRelatedProduct 操作从 URL 路径中提取 id 路由值。 例如,URL 路径为 /api/Products/1 时,relatedProductId 值设置为 1。 此方法允许 API 的客户端在引用资源时使用 URL 路径,而无需了解此类 URL 的结构。

配置终结点元数据

以下链接提供有关如何配置终结点元数据的信息:

路由中与 RequireHost 匹配的主机

RequireHost 将约束应用于需要指定主机的路由。 RequireHostRequireHost 参数可以是:

  • 主机:www.domain.com匹配任何端口的 www.domain.com
  • 带有通配符的主机:*.domain.com,匹配任何端口上的 www.domain.comsubdomain.domain.comwww.subdomain.domain.com
  • 端口:*:5000 匹配任何主机的端口 5000。
  • 主机和端口:www.domain.com:5000*.domain.com:5000(匹配主机和端口)。

可以使用 RequireHost[Host] 指定多个参数。 约束匹配对任何参数均有效的主机。 例如,[Host("domain.com", "*.domain.com")] 匹配 domain.comwww.domain.comsubdomain.domain.com

以下代码使用 RequireHost 来要求路由上的指定主机:

app.MapGet("/", () => "Contoso").RequireHost("contoso.com");
app.MapGet("/", () => "AdventureWorks").RequireHost("adventure-works.com");

app.MapHealthChecks("/healthz").RequireHost("*:8080");

以下代码使用控制器上的 [Host] 特性来要求任何指定的主机:

[Host("contoso.com", "adventure-works.com")]
public class HostsController : Controller
{
    public IActionResult Index() =>
        View();

    [Host("example.com")]
    public IActionResult Example() =>
        View();
}

[Host] 属性同时应用于控制器和操作方法时:

  • 使用操作上的属性。
  • 忽略控制器属性。

警告

依赖于主机头的 API(如 HttpRequest.HostRequireHost)可能会受到客户端的欺骗。

若要防止主机和端口欺骗,请使用以下方法之一:

路由组

MapGroup 扩展方法有助于组织具有共同前缀的终结点组。 它减少了重复代码,并允许通过对添加终结点元数据RequireAuthorizationWithMetadata 等方法的单一调用来自定义整个终结点组。

例如,以下代码创建两组相似的终结点:

app.MapGroup("/public/todos")
    .MapTodosApi()
    .WithTags("Public");

app.MapGroup("/private/todos")
    .MapTodosApi()
    .WithTags("Private")
    .AddEndpointFilterFactory(QueryPrivateTodos)
    .RequireAuthorization();


EndpointFilterDelegate QueryPrivateTodos(EndpointFilterFactoryContext factoryContext, EndpointFilterDelegate next)
{
    var dbContextIndex = -1;

    foreach (var argument in factoryContext.MethodInfo.GetParameters())
    {
        if (argument.ParameterType == typeof(TodoDb))
        {
            dbContextIndex = argument.Position;
            break;
        }
    }

    // Skip filter if the method doesn't have a TodoDb parameter.
    if (dbContextIndex < 0)
    {
        return next;
    }

    return async invocationContext =>
    {
        var dbContext = invocationContext.GetArgument<TodoDb>(dbContextIndex);
        dbContext.IsPrivate = true;

        try
        {
            return await next(invocationContext);
        }
        finally
        {
            // This should only be relevant if you're pooling or otherwise reusing the DbContext instance.
            dbContext.IsPrivate = false;
        }
    };
}
public static RouteGroupBuilder MapTodosApi(this RouteGroupBuilder group)
{
    group.MapGet("/", GetAllTodos);
    group.MapGet("/{id}", GetTodo);
    group.MapPost("/", CreateTodo);
    group.MapPut("/{id}", UpdateTodo);
    group.MapDelete("/{id}", DeleteTodo);

    return group;
}

在此方案中,可以适用 201 Created 结果中 Location 标头的相对地址:

public static async Task<Created<Todo>> CreateTodo(Todo todo, TodoDb database)
{
    await database.AddAsync(todo);
    await database.SaveChangesAsync();

    return TypedResults.Created($"{todo.Id}", todo);
}

第一组终结点将仅匹配前缀为 /public/todos 并且无需任何身份验证即可访问的请求。 第二组终结点将仅匹配前缀为 /private/todos 并且需要身份验证的请求。

QueryPrivateTodos 终结点筛选器工厂是一种本地函数,用于修改路由处理程序的 TodoDb 参数,以允许访问和存储专用的 todo 数据。

路由组还支持嵌套组和具有路由参数和约束的复杂前缀模式。 在以下示例中,映射到 user 组的路由处理程序可以捕获外部组前缀中定义的 {org}{group} 路由参数。

前缀也可以为空。 这对于在不更改路由模式的情况下向一组终结点添加终结点元数据或筛选器非常有用。

var all = app.MapGroup("").WithOpenApi();
var org = all.MapGroup("{org}");
var user = org.MapGroup("{user}");
user.MapGet("", (string org, string user) => $"{org}/{user}");

向组添加筛选器或元数据的行为与在添加可能已添加到内部组或特定终结点的任何额外筛选器或元数据之前将它们单独添加到每个终结点的行为相同。

var outer = app.MapGroup("/outer");
var inner = outer.MapGroup("/inner");

inner.AddEndpointFilter((context, next) =>
{
    app.Logger.LogInformation("/inner group filter");
    return next(context);
});

outer.AddEndpointFilter((context, next) =>
{
    app.Logger.LogInformation("/outer group filter");
    return next(context);
});

inner.MapGet("/", () => "Hi!").AddEndpointFilter((context, next) =>
{
    app.Logger.LogInformation("MapGet filter");
    return next(context);
});

在上面的示例中,外部筛选器将在内部筛选器之前记录传入请求,即使它是第二个添加的。 由于筛选器应用于不同的组,因此它们相对于彼此的添加顺序并不重要。 如果应用于相同的组或特定终结点,则筛选器的添加顺序非常重要。

/outer/inner/ 的请求将记录以下内容:

/outer group filter
/inner group filter
MapGet filter

路由的性能指南

当应用出现性能问题时,人们常常会怀疑路由是问题所在。 怀疑路由的原因是,控制器和 Razor Pages 等框架在其日志记录消息中报告框架内所用的时间。 如果控制器报告的时间与请求的总时间之间存在明显的差异:

  • 开发者会将应用代码排除在问题根源之外。
  • 他们通常认为路由是问题的原因。

路由的性能使用数千个终结点进行测试。 典型的应用不太可能仅仅因为太大而遇到性能问题。 路由性能缓慢的最常见根本原因通常在于性能不佳的自定义中间件。

下面的代码示例演示了一种用于缩小延迟源的基本方法:

var logger = app.Services.GetRequiredService<ILogger<Program>>();

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

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

app.UseRouting();

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

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

app.UseAuthorization();

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

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

app.MapGet("/", () => "Timing Test.");

时间路由:

  • 使用前面代码中所示的计时中间件的副本交错执行每个中间件。
  • 添加唯一标识符,以便将计时数据与代码相关联。

这是一种可在延迟显著的情况下减少延迟的基本方法,例如超过 10ms。 从 Time 1 中减去 Time 2 会报告 UseRouting 中间件内所用的时间。

下面的代码使用一种更紧凑的方法来处理前面的计时代码:

public sealed class AutoStopwatch : IDisposable
{
    private readonly ILogger _logger;
    private readonly string _message;
    private readonly Stopwatch _stopwatch;
    private bool _disposed;

    public AutoStopwatch(ILogger logger, string message) =>
        (_logger, _message, _stopwatch) = (logger, message, Stopwatch.StartNew());

    public void Dispose()
    {
        if (_disposed)
        {
            return;
        }

        _logger.LogInformation("{Message}: {ElapsedMilliseconds}ms",
            _message, _stopwatch.ElapsedMilliseconds);

        _disposed = true;
    }
}
var logger = app.Services.GetRequiredService<ILogger<Program>>();
var timerCount = 0;

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

app.UseRouting();

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

app.UseAuthorization();

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

app.MapGet("/", () => "Timing Test.");

可能比较昂贵的路由功能

下面的列表提供了一些路由功能,这些功能相对于基本路由模板来说比较昂贵:

  • 正则表达式:可以编写复杂的正则表达式,或具有少量输入的长时间运行时间。
  • 复杂段 ({x}-{y}-{z}):
    • 比分析常规 URL 路径段贵得多。
    • 导致更多的子字符串被分配。
  • 同步数据访问:许多复杂应用都将数据库访问作为其路由的一部分。 使用异步的扩展点,如 MatcherPolicyEndpointSelectorContext

大型路由表指南

默认情况下,ASP.NET Core 使用通过内存换来 CPU 时间的路由算法。 这样可以达到良好的效果,即路由匹配时间仅依赖于要匹配的路径的长度,而不依赖于路由数。 但是,在某些情况下,当应用具有大量(数千个)路由且路由中具有大量变量前缀时,此方法可能存在问题。 例如,如果路由在路由的前几段中具有参数,例如 {parameter}/some/literal

应用不太可能出现此问题,除非:

  • 使用此模式的应用中具有大量路由。
  • 应用中具有大量路由。

如何确定应用是否出现大型路由表问题

  • 需要查找两个症状:
    • 发出第一个请求时,应用启动速度缓慢。
      • 请注意,这是必需的,但不够。 还有许多其他非路由问题可能会导致应用启动速度缓慢。 检查以下条件,准确地确定应用是否出现这种情况。
    • 应用在启动期间消耗大量内存,内存转储显示大量 Microsoft.AspNetCore.Routing.Matching.DfaNode 实例。

如何解决此问题

可以将多种技术和优化应用于可大大改善此方案的路由:

  • 如果可能,请将路由约束应用于参数,如 {parameter:int}{parameter:guid}{parameter:regex(\\d+)}
    • 这样,路由算法可以在内部优化用于匹配的结构,并显著减少使用的内存。
    • 在大多数情况下,这足以恢复到可接受的行为。
  • 更改路由以将参数移动到模板中的后几段。
    • 这样可以减少与给定路径的终结点匹配的可能“路径”数。
  • 使用动态路由并动态执行到控制器/页面的映射。
    • 可以使用 MapDynamicControllerRouteMapDynamicPageRoute 实现此操作。

路由后对中间件进行短路

当路由与终结点匹配时,它通常允许中间件管道的 rest 在调用终结点逻辑之前运行。 服务可以通过在管道中提前筛选出已知请求来减少资源使用量。 使用 ShortCircuit 扩展方法使路由立即调用终结点逻辑,然后结束请求。 例如,给定路由可能不需要通过身份验证或 CORS 中间件。 以下示例对与 /short-circuit 路由匹配的请求进行短路:

app.MapGet("/short-circuit", () => "Short circuiting!").ShortCircuit();

ShortCircuit(IEndpointConventionBuilder, Nullable<Int32>) 方法可以选择采用状态代码。

使用 MapShortCircuit 方法一次为多个路由设置短路,方法是向其传递 URL 前缀的参数数组。 例如,浏览器和机器人通常会探测服务器中的已知路径,例如 robots.txtfavicon.ico。 如果应用没有这些文件,则一行代码可以配置这两个路由:

app.MapShortCircuit(404, "robots.txt", "favicon.ico");

MapShortCircuit 返回 IEndpointConventionBuilder,以便向其添加其他路由约束,如主机筛选。

ShortCircuitMapShortCircuit 方法不会影响放置在 UseRouting 之前的中间件。 尝试将这些方法与同时具有 [Authorize][RequireCors] 元数据的终结点一起使用将导致请求失败,出现 InvalidOperationException。 此元数据由 [Authorize][EnableCors] 属性或 RequireCorsRequireAuthorization 方法应用。

若要查看将中间件进行短路的效果,请在 appsettings.Development.json 中将“Microsoft”日志记录类别设置为“信息”:

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

运行以下代码:

var app = WebApplication.Create();

app.UseHttpLogging();

app.MapGet("/", () => "No short-circuiting!");
app.MapGet("/short-circuit", () => "Short circuiting!").ShortCircuit();
app.MapShortCircuit(404, "robots.txt", "favicon.ico");

app.Run();

以下示例来自通过运行 / 终结点生成的控制台日志。 它包括日志记录中间件的输出:

info: Microsoft.AspNetCore.Routing.EndpointMiddleware[0]
      Executing endpoint 'HTTP: GET /'
info: Microsoft.AspNetCore.Routing.EndpointMiddleware[1]
      Executed endpoint 'HTTP: GET /'
info: Microsoft.AspNetCore.HttpLogging.HttpLoggingMiddleware[2]
      Response:
      StatusCode: 200
      Content-Type: text/plain; charset=utf-8
      Date: Wed, 03 May 2023 21:05:59 GMT
      Server: Kestrel
      Alt-Svc: h3=":5182"; ma=86400
      Transfer-Encoding: chunked

以下示例来自运行 /short-circuit 终结点。 它没有来自日志记录中间件的任何内容,因为中间件已短路:

info: Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware[4]
      The endpoint 'HTTP: GET /short-circuit' is being executed without running additional middleware.
info: Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware[5]
      The endpoint 'HTTP: GET /short-circuit' has been executed without running additional middleware.

库创建者指南

本部分包含基于路由构建的库创建者指南。 这些详细信息旨在确保应用开发者可以在使用扩展路由的库和框架时具有良好的体验。

定义终结点

若要创建一个使用路由进行 URL 匹配的框架,请先定义在 UseEndpoints 之上进行构建的用户体验。

之上进行构建。 由此,用户就可以用其他 ASP.NET Core 功能编写框架,而不会造成混淆。 每个 ASP.NET Core 模板都包含路由。 假定路由存在并且用户熟悉路由。

// Your framework
app.MapMyFramework(...);

app.MapHealthChecks("/healthz");

从对实现 IEndpointConventionBuilder 的调用返回密式具体类型。 大多数框架 Map... 方法都遵循此模式。 IEndpointConventionBuilder 接口:

  • 允许对元数据进行组合。
  • 面向多种扩展方法。

通过声明自己的类型,你可以将自己的框架特定功能添加到生成器中。 可以包装一个框架声明的生成器并向其转发调用。

// Your framework
app.MapMyFramework(...)
    .RequireAuthorization()
    .WithMyFrameworkFeature(awesome: true);

app.MapHealthChecks("/healthz");

请考虑编写自己的 EndpointDataSource 是用于声明和更新终结点集合的低级别基元。 EndpointDataSource 是控制器和 Razor Pages 使用的强大 API。 有关详细信息,请参阅动态终结点路由

路由测试具有非更新数据源的基本示例

考虑实现 GetGroupedEndpoints。 这提供了对运行组约定和分组终结点上的最终元数据的完全控制。 例如,这允许自定义 EndpointDataSource 实现运行添加到组的终结点筛选器

默认情况下,请不要尝试注册 。 要求用户在 UseEndpoints 中注册你的框架。 路由的理念是,默认情况下不包含任何内容,并且 UseEndpoints 是注册终结点的位置。

创建路由集成式中间件

请考虑将元数据类型定义为接口。

请实现在类和方法上使用元数据类型。

public interface ICoolMetadata
{
    bool IsCool { get; }
}

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

控制器和 Razor Pages 等框架支持将元数据特性应用到类型和方法。 如果声明元数据类型:

  • 将它们作为特性进行访问。
  • 大多数用户都熟悉应用特性。

将元数据类型声明为接口可增加另一层灵活性:

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

请实现元数据替代,如以下示例中所示:

[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() { }
}

遵循这些准则的最佳方式是避免定义标记元数据:

  • 不要只是查找元数据类型的状态。
  • 定义元数据的属性并检查该属性。

对元数据集合进行排序,并支持按优先级替代。 对于控制器,操作方法上的元数据是最具体的。

使中间件始终有用,不管有没有路由:

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

// Your framework
app.MapMyFramework(...).RequireAuthorization();

作为此准则的一个示例,请考虑 UseAuthorization 中间件。 授权中间件允许传入回退策略。 如果指定了回退策略,则适用于以下两者:

  • 无指定策略的终结点。
  • 与终结点不匹配的请求。

这使得授权中间件在路由上下文之外很有用。 授权中间件可用于传统中间件的编程。

调试诊断

要获取详细的路由诊断输出,请将 Logging:LogLevel:Microsoft 设置为 Debug。 在开发环境中,在 appsettings.Development.json 中设置日志级别:

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

其他资源

路由负责匹配传入的 HTTP 请求,然后将这些请求发送到应用的可执行终结点。 终结点是应用的可执行请求处理代码单元。 终结点在应用中进行定义,并在应用启动时进行配置。 终结点匹配过程可以从请求的 URL 中提取值,并为请求处理提供这些值。 通过使用应用中的终结点信息,路由还能生成映射到终结点的 URL。

应用可以使用以下内容配置路由:

  • Controllers
  • Razor Pages
  • SignalR
  • gRPC 服务
  • 启用终结点的中间件,例如运行状况检查
  • 通过路由注册的委托和 Lambda。

本文介绍 ASP.NET Core 路由的较低级别详细信息。 有关配置路由的信息,请参阅:

路由基础知识

以下代码演示路由的基本示例:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();

前面的示例包含使用 MapGet 方法的单个终结点:

  • 当 HTTP GET 请求发送到根 URL / 时:
    • 将执行请求委托。
    • Hello World! 会写入 HTTP 响应。
  • 如果请求方法不是 GET 或根 URL 不是 /,则无路由匹配,并返回 HTTP 404。

路由使用一对由 UseRoutingUseEndpoints 注册的中间件:

  • UseRouting 向中间件管道添加路由匹配。 此中间件会查看应用中定义的终结点集,并根据请求选择最佳匹配
  • UseEndpoints 向中间件管道添加终结点执行。 它会运行与所选终结点关联的委托。

应用通常不需要调用 UseRoutingUseEndpointsWebApplicationBuilder 配置中间件管道,该管道使用 UseRoutingUseEndpoints 包装在 Program.cs 中添加的中间件。 但是,应用可以通过显式调用这些方法来更改 UseRoutingUseEndpoints 的运行顺序。 例如,下面的代码显式调用 UseRouting

app.Use(async (context, next) =>
{
    // ...
    await next(context);
});

app.UseRouting();

app.MapGet("/", () => "Hello World!");

在上述代码中:

  • app.Use 的调用会注册一个在管道的开头运行的自定义中间件。
  • UseRouting 的调用将路由匹配中间件配置为在自定义中间件之后运行。
  • 使用 MapGet 注册的终结点在管道末尾运行。

如果前面的示例不包含对 UseRouting 的调用,则自定义中间件将在路由匹配中间件之后运行。

终结点

MapGet 方法用于定义终结点。 终结点可以:

  • 通过匹配 URL 和 HTTP 方法来选择。
  • 通过运行委托来执行。

可通过应用匹配和执行的终结点在 UseEndpoints 中进行配置。 例如,MapGetMapPostMapGet将请求委托连接到路由系统。 其他方法可用于将 ASP.NET Core 框架功能连接到路由系统:

下面的示例演示如何使用更复杂的路由模板进行路由:

app.MapGet("/hello/{name:alpha}", (string name) => $"Hello {name}!");

/hello/{name:alpha} 字符串是一个路由模板。 路由模板用于配置终结点的匹配方式。 在这种情况下,模板将匹配:

  • 类似 /hello/Docs 的 URL
  • /hello/ 开头、后跟一系列字母字符的任何 URL 路径。 :alpha 应用仅匹配字母字符的路由约束。 路由约束将在本文的后面介绍。

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

下面的示例演示如何通过运行状况检查和授权进行路由:

app.UseAuthentication();
app.UseAuthorization();

app.MapHealthChecks("/healthz").RequireAuthorization();
app.MapGet("/", () => "Hello World!");

前面的示例展示了如何:

  • 将授权中间件与路由一起使用。
  • 将终结点用于配置授权行为。

MapHealthChecks 调用添加运行状况检查终结点。 将 RequireAuthorization 链接到此调用会将授权策略附加到该终结点。

调用 UseAuthenticationUseAuthorization 会添加身份验证和授权中间件。 这些中间件位于 UseRoutingUseEndpoints 之间,因此它们可以:

  • 查看 UseRouting 选择的终结点。
  • UseEndpoints 发送到终结点之前应用授权策略。

终结点元数据

前面的示例中有两个终结点,但只有运行状况检查终结点附加了授权策略。 如果请求与运行状况检查终结点 /healthz 匹配,则执行授权检查。 这表明,终结点可以附加额外的数据。 此额外数据称为终结点元数据:

  • 可以通过路由感知中间件来处理元数据。
  • 元数据可以是任意的 .NET 类型。

路由概念

路由系统通过添加功能强大的终结点概念,构建在中间件管道之上。 终结点代表应用的功能单元,在路由、授权和任意数量的 ASP.NET Core 系统方面彼此不同。

ASP.NET Core 终结点定义

ASP.NET Core 终结点是:

以下代码显示了如何检索和检查与当前请求匹配的终结点:

app.Use(async (context, next) =>
{
    var currentEndpoint = context.GetEndpoint();

    if (currentEndpoint is null)
    {
        await next(context);
        return;
    }

    Console.WriteLine($"Endpoint: {currentEndpoint.DisplayName}");

    if (currentEndpoint is RouteEndpoint routeEndpoint)
    {
        Console.WriteLine($"  - Route Pattern: {routeEndpoint.RoutePattern}");
    }

    foreach (var endpointMetadata in currentEndpoint.Metadata)
    {
        Console.WriteLine($"  - Metadata: {endpointMetadata}");
    }

    await next(context);
});

app.MapGet("/", () => "Inspect Endpoint.");

如果选择了终结点,可从 HttpContext 中进行检索。 可以检查其属性。 终结点对象是不可变的,并且在创建后无法修改。 最常见的终结点类型是 RouteEndpointRouteEndpoint 包括允许自己被路由系统选择的信息。

在前面的代码中,app.Use 配置了一个内联中间件

下面的代码显示,根据管道中调用 app.Use 的位置,可能不存在终结点:

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

app.UseRouting();

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

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

app.UseEndpoints(_ => { });

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

前面的示例添加了 Console.WriteLine 语句,这些语句显示是否已选择终结点。 为清楚起见,该示例将显示名称分配给提供的 / 终结点。

前面的示例还包括对 UseRoutingUseEndpoints 的调用,以准确控制这些中间件何时在管道中运行。

使用 / URL 运行此代码将显示:

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

使用任何其他 URL 运行此代码将显示:

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

此输出说明:

  • 调用 UseRouting 之前,终结点始终为 null。
  • 如果找到匹配项,则 UseRoutingUseEndpoints 之间的终结点为非 null。
  • 如果找到匹配项,则 UseEndpoints 中间件即为终端。 稍后会在本文中定义终端中间件
  • 仅当找不到匹配项时才执行 UseEndpoints 后的中间件。

UseRouting 中间件使用 SetEndpoint 方法将终结点附加到当前上下文。 可以将 UseRouting 中间件替换为自定义逻辑,同时仍可获得使用终结点的益处。 终结点是中间件等低级别基元,不与路由实现耦合。 大多数应用都不需要将 UseRouting 替换为自定义逻辑。

UseEndpoints 中间件旨在与 UseRouting 中间件配合使用。 执行终结点的核心逻辑并不复杂。 使用 GetEndpoint 检索终结点,然后调用其 RequestDelegate 属性。

下面的代码演示中间件如何影响或响应路由:

app.UseHttpMethodOverride();
app.UseRouting();

app.Use(async (context, next) =>
{
    if (context.GetEndpoint()?.Metadata.GetMetadata<RequiresAuditAttribute>() is not null)
    {
        Console.WriteLine($"ACCESS TO SENSITIVE DATA AT: {DateTime.UtcNow}");
    }

    await next(context);
});

app.MapGet("/", () => "Audit isn't required.");
app.MapGet("/sensitive", () => "Audit required for sensitive data.")
    .WithMetadata(new RequiresAuditAttribute());
public class RequiresAuditAttribute : Attribute { }

前面的示例演示两个重要概念:

  • 中间件可以在 UseRouting 之前运行,以修改路由操作的数据。
  • 中间件可以在 UseRoutingUseEndpoints 之间运行,以便在执行终结点前处理路由结果。
    • UseRoutingUseEndpoints 之间运行的中间件:
      • 通常会检查元数据以了解终结点。
      • 通常会根据 UseAuthorizationUseCors 做出安全决策。
    • 中间件和元数据的组合允许按终结点配置策略。

前面的代码显示了支持按终结点策略的自定义中间件示例。 中间件将访问敏感数据的审核日志写入控制台。 可以将中间件配置为审核具有 元数据的终结点。 此示例演示选择加入模式,其中仅审核标记为敏感的终结点。 例如,可以反向定义此逻辑,从而审核未标记为安全的所有内容。 终结点元数据系统非常灵活。 此逻辑可以以任何适合用例的方法进行设计。

前面的示例代码旨在演示终结点的基本概念。 该示例不应在生产环境中使用。 审核日志中间件的更完整版本如下:

  • 记录到文件或数据库。
  • 包括详细信息,如用户、IP 地址、敏感终结点的名称等。

审核策略元数据 RequiresAuditAttribute 定义为一个 Attribute,便于和基于类的框架(如控制器和 SignalR)结合使用。 使用路由到代码时:

  • 元数据附有生成器 API。
  • 基于类的框架在创建终结点时,包含了相应方法和类的所有特性。

对于元数据类型,最佳做法是将它们定义为接口或特性。 接口和特性允许代码重用。 元数据系统非常灵活,无任何限制。

将终端中间件与路由进行比较

下面的示例演示了终端中间件和路由:

// Approach 1: Terminal Middleware.
app.Use(async (context, next) =>
{
    if (context.Request.Path == "/")
    {
        await context.Response.WriteAsync("Terminal Middleware.");
        return;
    }

    await next(context);
});

app.UseRouting();

// Approach 2: Routing.
app.MapGet("/Routing", () => "Routing.");

使用 Approach 1: 显示的中间件样式是终端中间件。 之所以称之为终端中间件,是因为它执行匹配的操作:

  • 前面示例中的匹配操作是用于中间件的 Path == "/" 和用于路由的 Path == "/Routing"
  • 如果匹配成功,它将执行一些功能并返回,而不是调用 next 中间件。

之所以称之为终端中间件,是因为它会终止搜索,执行一些功能,然后返回。

以下列表将终端中间件与路由进行比较:

  • 这两种方法都允许终止处理管道:
    • 中间件通过返回而不是调用 next 来终止管道。
    • 终结点始终是终端。
  • 终端中间件允许在管道中的任意位置放置中间件:
  • 终端中间件允许任意代码确定中间件匹配的时间:
    • 自定义路由匹配代码可能比较复杂,且难以正确编写。
    • 路由为典型应用提供了简单的解决方案。 大多数应用不需要自定义路由匹配代码。
  • 带有中间件的终结点接口,如 UseAuthorizationUseCors
    • 通过 UseAuthorizationUseCors 使用终端中间件需要与授权系统进行手动交互。

终结点定义以下两者:

  • 用于处理请求的委托。
  • 任意元数据的集合。 元数据用于实现横切关注点,该实现基于附加到每个终结点的策略和配置。

终端中间件可以是一种有效的工具,但可能需要:

  • 大量的编码和测试。
  • 手动与其他系统集成,以实现所需的灵活性级别。

请考虑在写入终端中间件之前与路由集成。

MapMapWhen 相集成的现有终端中间件通常会转换为路由感知终结点。 MapHealthChecks 演示了路由器软件的模式:

下面的代码演示了 MapHealthChecks 的使用方法:

app.UseAuthentication();
app.UseAuthorization();

app.MapHealthChecks("/healthz").RequireAuthorization();

前面的示例说明了为什么返回生成器对象很重要。 返回生成器对象后,应用开发者可以配置策略,如终结点的授权。 在此示例中,运行状况检查中间件不与授权系统直接集成。

元数据系统的创建目的是为了响应扩展性创建者使用终端中间件时遇到的问题。 对于每个中间件,实现自己与授权系统的集成都会出现问题。

URL 匹配

  • 是路由将传入请求匹配到终结点的过程。
  • 基于 URL 路径中的数据和标头。
  • 可进行扩展,以考虑请求中的任何数据。

当路由中间件执行时,它会设置 Endpoint,并将值从当前请求路由到 HttpContext 上的Endpoint

  • 调用 GetEndpoint 获取终结点。
  • HttpRequest.RouteValues 将获取路由值的集合。

中间件在路由中间件可以检查终结点并采取措施之后运行。 例如,授权中间件可以在终结点的元数据集合中询问授权策略。 请求处理管道中的所有中间件执行后,将调用所选终结点的委托。

终结点路由中的路由系统负责所有的调度决策。 中间件基于所选终结点应用策略,因此重要的是:

  • 任何可能影响发送或安全策略应用的决定都应在路由系统中做出。

警告

为了实现向后兼容性,执行 Controller 或 Razor Pages 终结点委托时,应根据迄今执行的请求处理将 RouteContext.RouteData 的属性设为适当的值。

在未来的版本中,会将 RouteContext 类型标记为已过时:

  • RouteData.Values 迁移到 HttpRequest.RouteValues
  • 迁移 RouteData.DataTokens 以从终结点元数据检索 IDataTokensMetadata

URL 匹配在可配置的阶段集中运行。 在每个阶段中,输出为一组匹配项。 下一阶段可以进一步缩小这一组匹配项。 路由实现不保证匹配终结点的处理顺序。 所有可能的匹配项一次性处理。 URL 匹配阶段按以下顺序出现。 ASP.NET Core:

  1. 针对终结点集及其路由模板处理 URL 路径,收集所有匹配项。
  2. 采用前面的列表并删除在应用路由约束时失败的匹配项。
  3. 采用前面的列表并删除 MatcherPolicy 实例集失败的匹配项。
  4. 使用 EndpointSelector 从前面的列表中做出最终决定。

根据以下内容设置终结点列表的优先级:

在每个阶段中处理所有匹配的终结点,直到达到 EndpointSelectorEndpointSelector 是最后一个阶段。 它从匹配项中选择最高优先级终结点作为最佳匹配项。 如果存在具有与最佳匹配相同优先级的其他匹配项,则会引发不明确的匹配异常。

路由优先顺序基于更具体的路由模板(优先级更高)进行计算。 例如,考虑模板 /hello/{message}

  • 两者都匹配 URL 路径 /hello
  • /hello 更具体,因此优先级更高。

通常,路由优先顺序非常适合为实践操作中使用的各种 URL 方案选择最佳匹配项。 仅在必要时才使用 Order 来避免多义性。

由于路由提供的扩展性种类,路由系统无法提前计算不明确的路由。 假设有一个示例,例如路由模板 /{message:alpha}/{message:int}

  • alpha 约束仅匹配字母数字字符。
  • int 约束仅匹配数字。
  • 这些模板具有相同的路由优先顺序,但没有两者均匹配的单一 URL。
  • 如果路由系统在启动时报告了多义性错误,则会阻止此有效用例。

警告

UseEndpoints 内的操作顺序并不会影响路由行为,但有一个例外。 MapControllerRouteMapAreaRoute 会根据调用顺序,自动将顺序值分配给其终结点。 这会模拟控制器的长时间行为,而无需路由系统提供与早期路由实现相同的保证。

ASP.NET Core 中的终结点路由:

  • 没有路由的概念。
  • 不提供顺序保证。 同时处理所有终结点。

路由模板优先顺序和终结点选择顺序

路由模板优先顺序是一种系统,该系统根据每个路由模板的具体程度为其分配值。 路由模板优先顺序:

  • 无需在常见情况下调整终结点的顺序。
  • 尝试匹配路由行为的常识性预期。

例如,考虑模板 /Products/List/Products/{id}。 我们可合理地假设,对于 URL 路径 /Products/List/Products/List 匹配项比 /Products/{id} 更好。 这种假设合理是因为文本段 /List 比参数段 /{id} 具有更好的优先顺序。

优先顺序工作原理的详细信息与路由模板的定义方式相耦合:

  • 具有更多段的模板则更具体。
  • 带有文本的段比参数段更具体。
  • 具有约束的参数段比没有约束的参数段更具体。
  • 复杂段与具有约束的参数段同样具体。
  • catch-all 参数是最不具体的参数。 有关 catch-all 路由的重要信息,请参阅路由模板部分中的“catch-all”。

URL 生成概念

URL 生成:

  • 是指路由基于一系列路由值创建 URL 路径的过程。
  • 允许终结点与访问它们的 URL 之间存在逻辑分隔。

终结点路由包含 LinkGenerator API。 LinkGeneratorLinkGenerator 中可用的单一实例服务。 LinkGenerator API 可在执行请求的上下文之外使用。 Mvc.IUrlHelper 和依赖 IUrlHelper 的方案(如标记帮助程序、HTML 帮助程序和操作结果)在内部使用 LinkGenerator API 提供链接生成功能。

链接生成器基于“地址”和“地址方案”概念 。 地址方案是确定哪些终结点用于链接生成的方式。 例如,许多用户熟悉的来自控制器或 Razor Pages 的路由名称和路由值方案都是作为地址方案实现的。

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

这些方法的重载接受包含 HttpContext 的参数。 这些方法在功能上等同于 Url.ActionUrl.Page,但提供了更大的灵活性和更多选项。

GetPath* 方法与 Url.ActionUrl.Page 最相似,因为它们生成包含绝对路径的 URI。 GetUri* 方法始终生成包含方案和主机的绝对 URI。 接受 HttpContext 的方法在执行请求的上下文中生成 URI。 除非重写,否则将使用来自执行请求的环境路由值、URL 基路径、方案和主机。

使用地址调用 LinkGenerator。 生成 URI 的过程分两步进行:

  1. 将地址绑定到与地址匹配的终结点列表。
  2. 计算每个终结点的 RoutePattern,直到找到与提供的值匹配的路由模式。 输出结果会与提供给链接生成器的其他 URI 部分进行组合并返回。

对任何类型的地址,LinkGenerator 提供的方法均支持标准链接生成功能。 使用链接生成器的最简便方法是通过扩展方法对特定地址类型执行操作:

扩展方法 描述
GetPathByAddress 根据提供的值生成具有绝对路径的 URI。
GetUriByAddress 根据提供的值生成绝对 URI。

警告

请注意有关调用 LinkGenerator 方法的下列含义:

  • 对于不验证传入请求的 Host 标头的应用配置,请谨慎使用 GetUri* 扩展方法。 如果未验证传入请求的 Host 标头,则可能以视图或页面中 URI 的形式将不受信任的请求输入发送回客户端。 建议所有生产应用都将其服务器配置为针对已知有效值验证 Host 标头。

  • 在中间件中将 LinkGeneratorMapMapWhen 结合使用时,请小心谨慎。 Map* 会更改执行请求的基路径,这会影响链接生成的输出。 所有 LinkGenerator API 都允许指定基路径。 指定一个空的基路径来撤消 Map* 对链接生成的影响。

中间件示例

在以下示例中,中间件使用 LinkGenerator API 创建列出存储产品的操作方法的链接。 应用中的任何类都可通过将链接生成器注入类并调用 GenerateLink 来使用链接生成器:

public class ProductsMiddleware
{
    private readonly LinkGenerator _linkGenerator;

    public ProductsMiddleware(RequestDelegate next, LinkGenerator linkGenerator) =>
        _linkGenerator = linkGenerator;

    public async Task InvokeAsync(HttpContext httpContext)
    {
        httpContext.Response.ContentType = MediaTypeNames.Text.Plain;

        var productsPath = _linkGenerator.GetPathByAction("Products", "Store");

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

路由模板

如果路由找到匹配项,{} 内的令牌定义绑定的路由参数。 可在路由段中定义多个路由参数,但必须用文本值隔开这些路由参数。 例如:

{controller=Home}{action=Index}

不是有效的路由,因为 {controller}{action} 之间没有文本值。 路由参数必须具有名称,且可能指定了其他特性。

路由参数以外的文本(例如 {id})和路径分隔符 / 必须匹配 URL 中的文本。 文本匹配区分大小写,并且基于 URL 路径已解码的表示形式。 要匹配文字路由参数分隔符({}),请通过重复该字符来转义分隔符。 例如 {{}}

星号 * 或双星号 **

  • 可用作路由参数的前缀,以绑定到 URI 的 rest。
  • 称为 catch-all 参数。 例如,blog/{**slug}
    • 匹配以 blog/ 开头并在其后面包含任何值的任何 URI。
    • blog/ 后的值分配给 blog/ 路由值。

警告

由于路由中的 bugcatch-all 参数可能无法正确匹配相应路由。 受此 Bug 影响的应用具有以下特征:

  • “全部捕获”路由,例如 {**slug}"
  • “全部捕获”路由未能匹配应与之匹配的请求。
  • 删除其他路由可使“全部捕获”路由开始运行。

请参阅 GitHub bug 1867716579,了解遇到此 bug 的示例。

.NET Core 3.1.301 SDK 及更高版本中包含此 bug 的修补程序(可选用)。 以下代码设置了一个可修复此 bug 的内部开关:

public static void Main(string[] args)
{
   AppContext.SetSwitch("Microsoft.AspNetCore.Routing.UseCorrectCatchAllBehavior", 
                         true);
   CreateHostBuilder(args).Build().Run();
}
// Remaining code removed for brevity.

全方位参数还可以匹配空字符串。

使用路由生成 URL(包括路径分隔符 /)时,catch-all 参数会转义相应的字符。 例如,路由值为 { path = "my/path" } 的路由 foo/{*path} 生成 foo/my%2Fpath。 请注意转义的正斜杠。 要往返路径分隔符,请使用 ** 路由参数前缀。 { path = "my/path" } 的路由 foo/{**path} 生成 foo/my/path

尝试捕获具有可选文件扩展名的文件名的 URL 模式还有其他注意事项。 例如,考虑模板 files/{filename}.{ext?}。 当 filenameext 的值都存在时,将填充这两个值。 如果 URL 中仅存在 filename 的值,则路由匹配,因为尾随 . 是可选的。 以下 URL 与此路由相匹配:

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

路由参数可能具有指定的默认值,方法是在参数名称后使用等号 () 隔开以指定默认值。 例如,{controller=Home}Home 定义为 controller 的默认值。 如果参数的 URL 中不存在任何值,则使用默认值。 通过在参数名称的末尾附加问号 (?) 可使路由参数成为可选项。 例如 id?。 可选值和默认路由参数之间的差异是:

  • 具有默认值的路由参数始终生成一个值。
  • 仅当请求 URL 提供值时,可选参数才具有值。

路由参数可能具有必须与从 URL 中绑定的路由值匹配的约束。 在路由参数后面添加一个 : 和约束名称可指定路由参数上的内联约束。 如果约束需要参数,将以在约束名称后括在括号 (...) 中的形式提供。 通过追加另一个 和约束名称,可指定多个内联约束。

约束名称和参数将传递给 IInlineConstraintResolver 服务,以创建 IRouteConstraint 的实例,用于处理 URL。 例如,路由模板 blog/{article:minlength(10)} 使用参数 10 指定 minlength 约束。 有关路由约束的详细信息以及框架提供的约束列表,请参阅路由约束部分。

路由参数还可以具有参数转换器。 参数转换器在生成链接以及将操作和页面匹配到 URI 时转换参数的值。 与约束类似,可在路由参数名称后面添加 : 和转换器名称,将参数变换器内联添加到路径参数。 例如,路由模板 blog/{article:slugify} 指定 slugify 转换器。 有关参数转换的详细信息,请参阅参数转换器部分。

下表演示了示例路由模板及其行为:

路由模板 示例匹配 URI 请求 URI…
hello /hello 仅匹配单个路径 /hello
{Page=Home} / 匹配并将 Page 设置为 Home
{Page=Home} /Contact 匹配并将 Page 设置为 Contact
{controller}/{action}/{id?} /Products/List 映射到 Products 控制器和 List 操作。
{controller}/{action}/{id?} /Products/Details/123 映射到 Products 控制器和 Details 操作,并将 id 设置为 123。
{controller=Home}/{action=Index}/{id?} / 映射到 Home 控制器和 Index 方法。 id 将被忽略。
{controller=Home}/{action=Index}/{id?} /Products 映射到 Products 控制器和 Index 方法。 id 将被忽略。

使用模板通常是进行路由最简单的方法。 还可在路由模板外指定约束和默认值。

复杂段

复杂段通过非贪婪的方式从右到左匹配文字进行处理。 例如,[Route("/a{b}c{d}")] 是一个复杂段。 复杂段以一种特定的方式工作,必须理解这种方式才能成功地使用它们。 本部分中的示例演示了为什么复杂段只有在分隔符文本没有出现在参数值中时才真正有效。 对于更复杂的情况,需要使用 regex,然后手动提取值。

警告

如果使用 System.Text.RegularExpressions 处理不受信任的输入,则传递一个超时。 恶意用户可能会向 RegularExpressions 提供输入,从而导致拒绝服务攻击。 使用 RegularExpressions 的 ASP.NET Core 框架 API 会传递一个超时。

这是使用模板 /a{b}c{d} 和 URL 路径 /abcd 执行路由的步骤摘要。 | 有助于可视化算法的工作方式:

  • 从右到左的第一个文本是 c。 因此从右侧搜索 /abcd,并查找 /ab|c|d
  • 右侧的所有内容 (d) 现在都与路由参数 {d} 相匹配。
  • 从右到左的下一个文本是 a。 从我们停止的地方开始搜索 /ab|c|d,然后 /|a|b|c|d 找到 a
  • 右侧的值 (b) 现在与路由参数 {b} 相匹配。
  • 没有剩余的文本,并且没有剩余的路由模板,因此这是一个匹配项。

下面是使用相同模板 /a{b}c{d} 和 URL 路径 /aabcd 的负面案例示例。 | 有助于可视化算法的工作方式。 该案例不是匹配项,可由相同算法解释:

  • 从右到左的第一个文本是 c。 因此从右侧搜索 /aabcd,并查找 /aab|c|d
  • 右侧的所有内容 (d) 现在都与路由参数 {d} 相匹配。
  • 从右到左的下一个文本是 a。 从我们停止的地方开始搜索 /aab|c|d,然后 /a|a|b|c|d 找到 a
  • 右侧的值 (b) 现在与路由参数 {b} 相匹配。
  • 此时还有剩余的文本 a,但是算法已经耗尽了要解析的路由模板,所以这不是一个匹配项。

匹配算法是非贪婪算法:

  • 它匹配每个步骤中最小可能文本量。
  • 如果分隔符值出现在参数值内,则会导致不匹配。

正则表达式可以更好地控制它们的匹配行为。

贪婪匹配(也称为懒惰匹配)匹配最大可能字符串。 非贪婪匹配最小可能字符串。

使用特殊字符进行路由

使用特殊字符进行路由可能会导致意外的结果。 例如,假设控制器具有以下操作方法:

[HttpGet("{id?}/name")]
public async Task<ActionResult<string>> GetName(string id)
{
    var todoItem = await _context.TodoItems.FindAsync(id);

    if (todoItem == null || todoItem.Name == null)
    {
        return NotFound();
    }

    return todoItem.Name;
}

如果 string id 包含以下编码值,可能会出现意外结果:

ASCII Encoded
/ %2F
+

路由参数并不总是 URL 解码的。 此问题可能会在未来得到解决。 有关详细信息,请参阅此 GitHub 问题

路由约束

路由约束在传入 URL 发生匹配时执行,URL 路径标记为路由值。 路径约束通常检查通过路径模板关联的路径值,并对该值是否为可接受做出对/错决定。 某些路由约束使用路由值以外的数据来考虑是否可以路由请求。 例如,HttpMethodRouteConstraint 可以根据其 HTTP 谓词接受或拒绝请求。 约束用于路由请求和链接生成。

警告

请勿将约束用于输入验证。 如果约束用于输入验证,则无效的输入将导致 404(找不到页面)响应。 无效输入可能生成包含相应错误消息的 400 错误请求。 路由约束用于消除类似路由的歧义,而不是验证特定路由的输入。

下表演示示例路由约束及其预期行为:

约束 示例 匹配项示例 说明
int {id:int} 123456789-123456789 匹配任何整数
bool {active:bool} trueFALSE 匹配 truefalse。 不区分大小写
datetime {dob:datetime} 2016-12-312016-12-31 7:32pm 在固定区域性中匹配有效的 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 处理不受信任的输入,则传递一个超时。 恶意用户可能会向 RegularExpressions 提供输入,从而导致拒绝服务攻击。 使用 RegularExpressions 的 ASP.NET Core 框架 API 会传递一个超时。

可向单个参数应用多个用冒号分隔的约束。 例如,以下约束将参数限制为大于或等于 1 的整数值:

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

警告

验证 URL 的路由约束并将转换为始终使用固定区域性的 CLR 类型。 例如,转换为 CLR 类型 intDateTime。 这些约束假定 URL 不可本地化。 框架提供的路由约束不会修改存储于路由值中的值。 从 URL 中分析的所有路由值都将存储为字符串。 例如,float 约束会尝试将路由值转换为浮点数,但转换后的值仅用来验证其是否可转换为浮点数。

约束中的正则表达式

警告

如果使用 System.Text.RegularExpressions 处理不受信任的输入,则传递一个超时。 恶意用户可能会向 RegularExpressions 提供输入,从而导致拒绝服务攻击。 使用 RegularExpressions 的 ASP.NET Core 框架 API 会传递一个超时。

使用 regex(...) 路由约束可以将正则表达式指定为内联约束。 MapControllerRoute 系列中的方法还接受约束的对象文字。 如果使用该窗体,则字符串值将解释为正则表达式。

下面的代码使用内联 regex 约束:

app.MapGet("{message:regex(^\\d{{3}}-\\d{{2}}-\\d{{4}}$)}",
    () => "Inline Regex Constraint Matched");

下面的代码使用对象文字来指定 regex 约束:

app.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# 源文件中的 \\ 字符,以便对 \ 字符串转义字符进行转义。
  • 逐字字符串文本

要对路由参数分隔符 {}[] 进行转义,请将表达式(例如 {{}}[[]])中的字符数加倍。 下表展示了正则表达式及其转义的版本:

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

路由中使用的正则表达式通常以 ^ 字符开头,并匹配字符串的起始位置。 表达式通常以 $ 字符结尾,并匹配字符串的结尾。 ^$ 字符可确保正则表达式匹配整个路由参数值。 如果没有 ^$ 字符,正则表达式将匹配字符串内的所有子字符串,而这通常是不需要的。 下表提供了示例并说明了它们匹配或匹配失败的原因:

表达式 String 匹配 注释
[a-z]{2} hello 子字符串匹配
[a-z]{2} 123abc456 子字符串匹配
[a-z]{2} mz 匹配表达式
[a-z]{2} MZ 不区分大小写
^[a-z]{2}$ hello 参阅上述 ^$
^[a-z]{2}$ 123abc456 参阅上述 ^$

有关正则表达式语法的详细信息,请参阅 .NET Framework 正则表达式

若要将参数限制为一组已知的可能值,可使用正则表达式。 例如,{action:regex(^(list|get|create)$)} 仅将 action 路由值匹配到 listgetcreate。 如果传递到约束字典中,字符串 ^(list|get|create)$ 将等效。 已传递到约束字典且不匹配任何已知约束的约束也将被视为正则表达式。 模板内传递且不匹配任何已知约束的约束将不被视为正则表达式。

自定义路由约束

可实现 IRouteConstraint 接口来创建自定义路由约束。 IRouteConstraint 接口包含 Match,当满足约束时,它返回 true,否则返回 false

很少需要自定义路由约束。 在实现自定义路由约束之前,请考虑替代方法,如模型绑定。

ASP.NET Core Constraints 文件夹提供了创建约束的经典示例。 例如 GuidRouteConstraint

若要使用自定义 IRouteConstraint,必须在服务容器中使用应用的 ConstraintMap 注册路由约束类型。 ConstraintMap 是将路由约束键映射到验证这些约束的 IRouteConstraint 实现的目录。 应用的 ConstraintMap 可作为 AddRouting 调用的一部分在 Program.cs 中进行更新,也可以通过使用 builder.Services.Configure<RouteOptions> 直接配置 RouteOptions 进行更新。 例如:

builder.Services.AddRouting(options =>
    options.ConstraintMap.Add("noZeroes", typeof(NoZeroesRouteConstraint)));

前面的约束应用于以下代码:

[ApiController]
[Route("api/[controller]")]
public class NoZeroesController : ControllerBase
{
    [HttpGet("{id:noZeroes}")]
    public IActionResult Get(string id) =>
        Content(id);
}

实现 NoZeroesRouteConstraint 可防止将 0 用于路由参数:

public class NoZeroesRouteConstraint : IRouteConstraint
{
    private static readonly Regex _regex = new(
        @"^[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 var routeValue))
        {
            return false;
        }

        var routeValueString = Convert.ToString(routeValue, CultureInfo.InvariantCulture);

        if (routeValueString is null)
        {
            return false;
        }

        return _regex.IsMatch(routeValueString);
    }
}

警告

如果使用 System.Text.RegularExpressions 处理不受信任的输入,则传递一个超时。 恶意用户可能会向 RegularExpressions 提供输入,从而导致拒绝服务攻击。 使用 RegularExpressions 的 ASP.NET Core 框架 API 会传递一个超时。

前面的代码:

  • 阻止路由的 {id} 段中的 0
  • 显示以提供实现自定义约束的基本示例。 不应在产品应用中使用。

下面的代码是防止处理包含 0id 的更好方法:

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

    return Content(id);
}

前面的代码与 NoZeroesRouteConstraint 方法相比具有以下优势:

  • 它不需要自定义约束。
  • 当路由参数包括 0 时,它将返回更具描述性的错误。

参数转换器

参数转换器:

例如,路由模式 blog\{article:slugify}(具有 Url.Action(new { article = "MyTestArticle" }))中的自定义 slugify 参数转换器生成 blog\my-test-article

请考虑以下 IOutboundParameterTransformer 实现:

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

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

若要在路由模式中使用参数转换器,请在 Program.cs 中使用 ConstraintMap 对其进行配置:

builder.Services.AddRouting(options =>
    options.ConstraintMap["slugify"] = typeof(SlugifyParameterTransformer));

ASP.NET Core 框架使用参数转化器来转换进行终结点解析的 URI。 例如,参数转换器转换用于匹配 areacontrolleractionpage 的路由值:

app.MapControllerRoute(
    name: "default",
    pattern: "{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 约定:

URL 生成参考

本部分包含 URL 生成实现的算法的参考。 在实践中,最复杂的 URL 生成示例使用控制器或 Razor Pages。 有关其他信息,请参阅控制器中的路由

URL 生成过程首先调用 LinkGenerator.GetPathByAddress 或类似方法。 此方法提供了一个地址、一组路由值以及有关 HttpContext 中当前请求的可选信息。

第一步是使用地址解析一组候选终结点,该终结点使用与该地址类型匹配的 IEndpointAddressScheme<TAddress>

地址方案找到一组候选项后,就会以迭代方式对终结点进行排序和处理,直到 URL 生成操作成功。 URL 生成不检查多义性,返回的第一个结果就是最终结果。

使用日志记录对 URL 生成进行故障排除

对 URL 生成进行故障排除的第一步是将 Microsoft.AspNetCore.Routing 的日志记录级别设置为 TRACELinkGenerator 记录有关其处理的许多详细信息,有助于排查问题。

有关 URL 生成的详细信息,请参阅 URL 生成参考

地址

地址是 URL 生成中的概念,用于将链接生成器中的调用绑定到一组终结点。

地址是一个可扩展的概念,默认情况下有两种实现:

  • 使用终结点名称 (string) 作为地址
    • 提供与 MVC 的路由名称相似的功能。
    • 使用 IEndpointNameMetadata 元数据类型。
    • 根据所有注册的终结点的元数据解析提供的字符串。
    • 如果多个终结点使用相同的名称,启动时会引发异常。
    • 建议用于控制器和 Razor Pages 之外的常规用途。
  • 使用路由值 (RouteValuesAddress) 作为地址
    • 提供与控制器和 Razor Pages 生成旧 URL 相同的功能。
    • 扩展和调试非常复杂。
    • 提供 IUrlHelper、标记帮助程序、HTML 帮助程序、操作结果等所使用的实现。

地址方案的作用是根据任意条件在地址和匹配终结点之间建立关联:

  • 终结点名称方案执行基本字典查找。
  • 路由值方案有一个复杂的子集算法,这是集算法的最佳子集。

环境值和显式值

从当前请求开始,路由将访问当前请求 HttpContext.Request.RouteValues 的路由值。 与当前请求关联的值称为环境值。 为清楚起见,文档是指作为显式值传入方法的路由值。

下面的示例演示了环境值和显式值。 它将提供当前请求中的环境值和显式值:

public class WidgetController : ControllerBase
{
    private readonly LinkGenerator _linkGenerator;

    public WidgetController(LinkGenerator linkGenerator) =>
        _linkGenerator = linkGenerator;

    public IActionResult Index()
    {
        var indexPath = _linkGenerator.GetPathByAction(
            HttpContext, values: new { id = 17 })!;

        return Content(indexPath);
    }

    // ...

前面的代码:

下面的代码仅提供显式值,不提供任何环境值:

var subscribePath = _linkGenerator.GetPathByAction(
    "Subscribe", "Home", new { id = 17 })!;

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

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

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

下面的代码提供当前请求中的环境值和显式值中的控制器:

public class GadgetController : ControllerBase
{
    public IActionResult Index() =>
        Content(Url.Action("Edit", new { id = 17 })!);
}

在上述代码中:

  • 返回 /Gadget/Edit/17
  • Url 获取 IUrlHelper
  • Action 生成一个 URL,其中包含操作方法的绝对路径。 URL 包含指定的 action 名称和 route 值。

下面的代码提供当前请求中的环境值和显式值:

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

        // ...
    }
}

当“编辑 Razor”页包含以下页面指令时,前面的代码会将 url 设置为 /Edit/17

@page "{id:int}"

如果“编辑”页面不包含 "{id:int}" 路由模板,url/Edit?id=17

除了此处所述的规则外,MVC IUrlHelper 的行为还增加了一层复杂性:

  • IUrlHelper 始终将当前请求中的路由值作为环境值提供。
  • IUrlHelper 始终将当前 actioncontroller 路由值复制为显式值,除非由开发者重写。
  • IUrlHelper 始终将当前 page 路由值复制为显式值,除非重写。
  • IUrlHelper.Page 始终替代当前 handler 路由值,且 null 为显式值,除非重写。

用户常常对环境值的行为详细信息感到惊讶,因为 MVC 似乎不遵循自己的规则。 出于历史和兼容性原因,某些路由值(例如 actioncontrollerpagehandler)具有其自己的特殊行为。

LinkGenerator.GetPathByActionLinkGenerator.GetPathByPage 提供的等效功能与 IUrlHelper 的兼容性异常相同。

URL 生成过程

找到候选终结点集后,URL 生成算法将:

  • 以迭代方式处理终结点。
  • 返回第一个成功的结果。

此过程的第一步称为路由值失效。 路由值失效是路由决定应使用环境值中的哪些路由值以及应忽略哪些路由值的过程。 将考虑每个环境值,要么与显式值组合,要么忽略它们。

考虑环境值角色的最佳方式是在某些常见情况下,尝试保存应用程序开发者的键入内容。 就传统意义而言,环境值非常有用的情况与 MVC 相关:

  • 链接到同一控制器中的其他操作时,不需要指定控制器名称。
  • 链接到同一区域中的另一个控制器时,不需要指定区域名称。
  • 链接到相同的操作方法时,不需要指定路由值。
  • 链接到应用的其他部分时,不需要在应用的该部分中传递无意义的路由值。

如果不了解路由值失效,通常就会导致对返回 nullLinkGeneratorIUrlHelper 执行调用。 显式指定更多路由值,对路由值失效进行故障排除,以查看是否解决了问题。

路由值失效的前提是应用的 URL 方案是分层的,并按从左到右的顺序组成层次结构。 请考虑基本控制器路由模板 {controller}/{action}/{id?},以直观地了解实践操作中该模板的工作方式。 对值进行更改会使右侧显示的所有路由值都失效 。 这反映了关于层次结构的假设。 如果应用的 id 有一个环境值,则操作会为 controller 指定一个不同的值:

  • id 不会重复使用,因为 {controller}{id?} 的左侧。

演示此原则的一些示例如下:

  • 如果显式值包含 id 的值,则将忽略 id 的环境值。 可以使用 controlleraction 的环境值。
  • 如果显式值包含 action 的值,则将忽略 action 的任何环境值。 可以使用 controller 的环境值。 如果 action 的显式值不同于 action 的环境值,则不会使用 id 值。 如果 action 的显式值与 action 的环境值相同,则可以使用 id 值。
  • 如果显式值包含 controller 的值,则将忽略 controller 的任何环境值。 如果 controller 的显式值不同于 controller 的环境值,则不会使用 actionid 值。 如果 controller 的显式值与 controller 的环境值相同,则可以使用 actionid 值。

由于存在特性路由和专用传统路由,此过程将变得更加复杂。 控制器传统路由(例如 {controller}/{action}/{id?})使用路由参数指定层次结构。 对于控制器和 Razor Pages 的专用传统路由特性路由

  • 有一个路由值层次结构。
  • 它们不会出现在模板中。

对于这些情况,URL 生成将定义“所需值”这一概念。 而由控制器和 Razor Pages 创建的终结点将指定所需值,以允许路由值失效起作用。

路由值失效算法的详细信息:

  • 所需值名称与路由参数组合在一起,然后从左到右进行处理。
  • 对于每个参数,将比较环境值和显式值:
    • 如果环境值和显式值相同,则该过程将继续。
    • 如果环境值存在而显式值不存在,则在生成 URL 时使用环境值。
    • 如果环境值不存在而显式值存在,则拒绝环境值和所有后续环境值。
    • 如果环境值和显式值均存在,并且这两个值不同,则拒绝环境值和所有后续环境值。

此时,URL 生成操作就可以计算路由约束了。 接受的值集与提供给约束的参数默认值相结合。 如果所有约束都通过,则操作将继续。

接下来,接受的值可用于扩展路由模板。 处理路由模板:

  • 从左到右。
  • 每个参数都替换了接受的值。
  • 具有以下特殊情况:
    • 如果接受的值缺少一个值并且参数具有默认值,则使用默认值。
    • 如果接受的值缺少一个值并且参数是可选的,则继续处理。
    • 如果缺少的可选参数右侧的任何路由参数都具有值,则操作将失败。
    • 如果可能,连续的默认值参数和可选参数会折叠。

显式提供且与路由片段不匹配的值将添加到查询字符串中。 下表显示使用路由模板 {controller}/{action}/{id?} 时的结果。

环境值 显式值 结果
控制器 =“Home” 操作 =“About” /Home/About
控制器 =“Home” 控制器 =“Order”,操作 =“About” /Order/About
控制器 =“Home”,颜色 =“Red” 操作 =“About” /Home/About
控制器 =“Home” 操作 =“About”,颜色 =“Red” /Home/About?color=Red

可选路由参数顺序

可选的路由参数必须在所有必需的路由参数之后。 在以下代码中,idname 参数必须位于 color 参数之后:

using Microsoft.AspNetCore.Mvc;

namespace WebApplication1.Controllers;

[Route("api/[controller]")]
public class MyController : ControllerBase
{
    // GET /api/my/red/2/joe
    // GET /api/my/red/2
    // GET /api/my
    [HttpGet("{color}/{id:int?}/{name?}")]
    public IActionResult GetByIdAndOptionalName(string color, int id = 1, string? name = null)
    {
        return Ok($"{color} {id} {name ?? ""}");
    }
}

路由值失效的问题

下面的代码显示了路由不支持的 URL 生成方案示例:

app.MapControllerRoute(
    "default",
    "{culture}/{controller=Home}/{action=Index}/{id?}");

app.MapControllerRoute(
    "blog",
    "{culture}/{**slug}",
    new { controller = "Blog", action = "ReadPost" });

在前面的代码中,culture 路由参数用于本地化。 需要将 culture 参数始终作为环境值接受。 但由于所需值的工作方式,不会将 culture 参数作为环境值接受:

  • "default" 路由模板中,culture 路由参数位于 controller 的左侧,因此对 controller 的更改不会使 culture 失效。
  • "blog" 路由模板中,culture 路由参数应在 controller 的右侧,这会显示在所需值中。

使用 LinkParser 解析 URL 路径

LinkParser 类添加了对将 URL 路径解析为一组路由值的支持。 ParsePathByEndpointName 方法采用终结点名称和 URL 路径,并返回从 URL 路径中提取的一组路由值。

在以下示例控制器中,GetProduct 操作使用 api/Products/{id} 的路由模板,并且 NameGetProduct

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet("{id}", Name = nameof(GetProduct))]
    public IActionResult GetProduct(string id)
    {
        // ...

在同一个控制器类中,AddRelatedProduct 操作需要一个 URL 路径 pathToRelatedProduct,该路径可作为查询字符串参数提供:

[HttpPost("{id}/Related")]
public IActionResult AddRelatedProduct(
    string id, string pathToRelatedProduct, [FromServices] LinkParser linkParser)
{
    var routeValues = linkParser.ParsePathByEndpointName(
        nameof(GetProduct), pathToRelatedProduct);
    var relatedProductId = routeValues?["id"];

    // ...

在前面的示例中,AddRelatedProduct 操作从 URL 路径中提取 id 路由值。 例如,URL 路径为 /api/Products/1 时,relatedProductId 值设置为 1。 此方法允许 API 的客户端在引用资源时使用 URL 路径,而无需了解此类 URL 的结构。

配置终结点元数据

以下链接提供有关如何配置终结点元数据的信息:

路由中与 RequireHost 匹配的主机

RequireHost 将约束应用于需要指定主机的路由。 RequireHostRequireHost 参数可以是:

  • 主机:www.domain.com匹配任何端口的 www.domain.com
  • 带有通配符的主机:*.domain.com,匹配任何端口上的 www.domain.comsubdomain.domain.comwww.subdomain.domain.com
  • 端口:*:5000 匹配任何主机的端口 5000。
  • 主机和端口:www.domain.com:5000*.domain.com:5000(匹配主机和端口)。

可以使用 RequireHost[Host] 指定多个参数。 约束匹配对任何参数均有效的主机。 例如,[Host("domain.com", "*.domain.com")] 匹配 domain.comwww.domain.comsubdomain.domain.com

以下代码使用 RequireHost 来要求路由上的指定主机:

app.MapGet("/", () => "Contoso").RequireHost("contoso.com");
app.MapGet("/", () => "AdventureWorks").RequireHost("adventure-works.com");

app.MapHealthChecks("/healthz").RequireHost("*:8080");

以下代码使用控制器上的 [Host] 特性来要求任何指定的主机:

[Host("contoso.com", "adventure-works.com")]
public class HostsController : Controller
{
    public IActionResult Index() =>
        View();

    [Host("example.com")]
    public IActionResult Example() =>
        View();
}

[Host] 属性同时应用于控制器和操作方法时:

  • 使用操作上的属性。
  • 忽略控制器属性。

路由组

MapGroup 扩展方法有助于组织具有共同前缀的终结点组。 它减少了重复代码,并允许通过对添加终结点元数据RequireAuthorizationWithMetadata 等方法的单一调用来自定义整个终结点组。

例如,以下代码创建两组相似的终结点:

app.MapGroup("/public/todos")
    .MapTodosApi()
    .WithTags("Public");

app.MapGroup("/private/todos")
    .MapTodosApi()
    .WithTags("Private")
    .AddEndpointFilterFactory(QueryPrivateTodos)
    .RequireAuthorization();


EndpointFilterDelegate QueryPrivateTodos(EndpointFilterFactoryContext factoryContext, EndpointFilterDelegate next)
{
    var dbContextIndex = -1;

    foreach (var argument in factoryContext.MethodInfo.GetParameters())
    {
        if (argument.ParameterType == typeof(TodoDb))
        {
            dbContextIndex = argument.Position;
            break;
        }
    }

    // Skip filter if the method doesn't have a TodoDb parameter.
    if (dbContextIndex < 0)
    {
        return next;
    }

    return async invocationContext =>
    {
        var dbContext = invocationContext.GetArgument<TodoDb>(dbContextIndex);
        dbContext.IsPrivate = true;

        try
        {
            return await next(invocationContext);
        }
        finally
        {
            // This should only be relevant if you're pooling or otherwise reusing the DbContext instance.
            dbContext.IsPrivate = false;
        }
    };
}
public static RouteGroupBuilder MapTodosApi(this RouteGroupBuilder group)
{
    group.MapGet("/", GetAllTodos);
    group.MapGet("/{id}", GetTodo);
    group.MapPost("/", CreateTodo);
    group.MapPut("/{id}", UpdateTodo);
    group.MapDelete("/{id}", DeleteTodo);

    return group;
}

在此方案中,可以适用 201 Created 结果中 Location 标头的相对地址:

public static async Task<Created<Todo>> CreateTodo(Todo todo, TodoDb database)
{
    await database.AddAsync(todo);
    await database.SaveChangesAsync();

    return TypedResults.Created($"{todo.Id}", todo);
}

第一组终结点将仅匹配前缀为 /public/todos 并且无需任何身份验证即可访问的请求。 第二组终结点将仅匹配前缀为 /private/todos 并且需要身份验证的请求。

QueryPrivateTodos 终结点筛选器工厂是一种本地函数,用于修改路由处理程序的 TodoDb 参数,以允许访问和存储专用的 todo 数据。

路由组还支持嵌套组和具有路由参数和约束的复杂前缀模式。 在以下示例中,映射到 user 组的路由处理程序可以捕获外部组前缀中定义的 {org}{group} 路由参数。

前缀也可以为空。 这对于在不更改路由模式的情况下向一组终结点添加终结点元数据或筛选器非常有用。

var all = app.MapGroup("").WithOpenApi();
var org = all.MapGroup("{org}");
var user = org.MapGroup("{user}");
user.MapGet("", (string org, string user) => $"{org}/{user}");

向组添加筛选器或元数据的行为与在添加可能已添加到内部组或特定终结点的任何额外筛选器或元数据之前将它们单独添加到每个终结点的行为相同。

var outer = app.MapGroup("/outer");
var inner = outer.MapGroup("/inner");

inner.AddEndpointFilter((context, next) =>
{
    app.Logger.LogInformation("/inner group filter");
    return next(context);
});

outer.AddEndpointFilter((context, next) =>
{
    app.Logger.LogInformation("/outer group filter");
    return next(context);
});

inner.MapGet("/", () => "Hi!").AddEndpointFilter((context, next) =>
{
    app.Logger.LogInformation("MapGet filter");
    return next(context);
});

在上面的示例中,外部筛选器将在内部筛选器之前记录传入请求,即使它是第二个添加的。 由于筛选器应用于不同的组,因此它们相对于彼此的添加顺序并不重要。 如果应用于相同的组或特定终结点,则筛选器的添加顺序非常重要。

/outer/inner/ 的请求将记录以下内容:

/outer group filter
/inner group filter
MapGet filter

路由的性能指南

当应用出现性能问题时,人们常常会怀疑路由是问题所在。 怀疑路由的原因是,控制器和 Razor Pages 等框架在其日志记录消息中报告框架内所用的时间。 如果控制器报告的时间与请求的总时间之间存在明显的差异:

  • 开发者会将应用代码排除在问题根源之外。
  • 他们通常认为路由是问题的原因。

路由的性能使用数千个终结点进行测试。 典型的应用不太可能仅仅因为太大而遇到性能问题。 路由性能缓慢的最常见根本原因通常在于性能不佳的自定义中间件。

下面的代码示例演示了一种用于缩小延迟源的基本方法:

var logger = app.Services.GetRequiredService<ILogger<Program>>();

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

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

app.UseRouting();

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

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

app.UseAuthorization();

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

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

app.MapGet("/", () => "Timing Test.");

时间路由:

  • 使用前面代码中所示的计时中间件的副本交错执行每个中间件。
  • 添加唯一标识符,以便将计时数据与代码相关联。

这是一种可在延迟显著的情况下减少延迟的基本方法,例如超过 10ms。 从 Time 1 中减去 Time 2 会报告 UseRouting 中间件内所用的时间。

下面的代码使用一种更紧凑的方法来处理前面的计时代码:

public sealed class AutoStopwatch : IDisposable
{
    private readonly ILogger _logger;
    private readonly string _message;
    private readonly Stopwatch _stopwatch;
    private bool _disposed;

    public AutoStopwatch(ILogger logger, string message) =>
        (_logger, _message, _stopwatch) = (logger, message, Stopwatch.StartNew());

    public void Dispose()
    {
        if (_disposed)
        {
            return;
        }

        _logger.LogInformation("{Message}: {ElapsedMilliseconds}ms",
            _message, _stopwatch.ElapsedMilliseconds);

        _disposed = true;
    }
}
var logger = app.Services.GetRequiredService<ILogger<Program>>();
var timerCount = 0;

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

app.UseRouting();

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

app.UseAuthorization();

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

app.MapGet("/", () => "Timing Test.");

可能比较昂贵的路由功能

下面的列表提供了一些路由功能,这些功能相对于基本路由模板来说比较昂贵:

  • 正则表达式:可以编写复杂的正则表达式,或具有少量输入的长时间运行时间。
  • 复杂段 ({x}-{y}-{z}):
    • 比分析常规 URL 路径段贵得多。
    • 导致更多的子字符串被分配。
  • 同步数据访问:许多复杂应用都将数据库访问作为其路由的一部分。 使用异步的扩展点,如 MatcherPolicyEndpointSelectorContext

大型路由表指南

默认情况下,ASP.NET Core 使用通过内存换来 CPU 时间的路由算法。 这样可以达到良好的效果,即路由匹配时间仅依赖于要匹配的路径的长度,而不依赖于路由数。 但是,在某些情况下,当应用具有大量(数千个)路由且路由中具有大量变量前缀时,此方法可能存在问题。 例如,如果路由在路由的前几段中具有参数,例如 {parameter}/some/literal

应用不太可能出现此问题,除非:

  • 使用此模式的应用中具有大量路由。
  • 应用中具有大量路由。

如何确定应用是否出现大型路由表问题

  • 需要查找两个症状:
    • 发出第一个请求时,应用启动速度缓慢。
      • 请注意,这是必需的,但不够。 还有许多其他非路由问题可能会导致应用启动速度缓慢。 检查以下条件,准确地确定应用是否出现这种情况。
    • 应用在启动期间消耗大量内存,内存转储显示大量 Microsoft.AspNetCore.Routing.Matching.DfaNode 实例。

如何解决此问题

可以将多种技术和优化应用于可大大改善此方案的路由:

  • 如果可能,请将路由约束应用于参数,如 {parameter:int}{parameter:guid}{parameter:regex(\\d+)}
    • 这样,路由算法可以在内部优化用于匹配的结构,并显著减少使用的内存。
    • 在大多数情况下,这足以恢复到可接受的行为。
  • 更改路由以将参数移动到模板中的后几段。
    • 这样可以减少与给定路径的终结点匹配的可能“路径”数。
  • 使用动态路由并动态执行到控制器/页面的映射。
    • 可以使用 MapDynamicControllerRouteMapDynamicPageRoute 实现此操作。

库创建者指南

本部分包含基于路由构建的库创建者指南。 这些详细信息旨在确保应用开发者可以在使用扩展路由的库和框架时具有良好的体验。

定义终结点

若要创建一个使用路由进行 URL 匹配的框架,请先定义在 UseEndpoints 之上进行构建的用户体验。

之上进行构建。 由此,用户就可以用其他 ASP.NET Core 功能编写框架,而不会造成混淆。 每个 ASP.NET Core 模板都包含路由。 假定路由存在并且用户熟悉路由。

// Your framework
app.MapMyFramework(...);

app.MapHealthChecks("/healthz");

从对实现 IEndpointConventionBuilder 的调用返回密式具体类型。 大多数框架 Map... 方法都遵循此模式。 IEndpointConventionBuilder 接口:

  • 允许对元数据进行组合。
  • 面向多种扩展方法。

通过声明自己的类型,你可以将自己的框架特定功能添加到生成器中。 可以包装一个框架声明的生成器并向其转发调用。

// Your framework
app.MapMyFramework(...)
    .RequireAuthorization()
    .WithMyFrameworkFeature(awesome: true);

app.MapHealthChecks("/healthz");

请考虑编写自己的 EndpointDataSource 是用于声明和更新终结点集合的低级别基元。 EndpointDataSource 是控制器和 Razor Pages 使用的强大 API。

路由测试具有非更新数据源的基本示例

考虑实现 GetGroupedEndpoints。 这提供了对运行组约定和分组终结点上的最终元数据的完全控制。 例如,这允许自定义 EndpointDataSource 实现运行添加到组的终结点筛选器

默认情况下,请不要尝试注册 。 要求用户在 UseEndpoints 中注册你的框架。 路由的理念是,默认情况下不包含任何内容,并且 UseEndpoints 是注册终结点的位置。

创建路由集成式中间件

请考虑将元数据类型定义为接口。

请实现在类和方法上使用元数据类型。

public interface ICoolMetadata
{
    bool IsCool { get; }
}

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

控制器和 Razor Pages 等框架支持将元数据特性应用到类型和方法。 如果声明元数据类型:

  • 将它们作为特性进行访问。
  • 大多数用户都熟悉应用特性。

将元数据类型声明为接口可增加另一层灵活性:

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

请实现元数据替代,如以下示例中所示:

[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() { }
}

遵循这些准则的最佳方式是避免定义标记元数据:

  • 不要只是查找元数据类型的状态。
  • 定义元数据的属性并检查该属性。

对元数据集合进行排序,并支持按优先级替代。 对于控制器,操作方法上的元数据是最具体的。

使中间件始终有用,不管有没有路由:

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

// Your framework
app.MapMyFramework(...).RequireAuthorization();

作为此准则的一个示例,请考虑 UseAuthorization 中间件。 授权中间件允许传入回退策略。 如果指定了回退策略,则适用于以下两者:

  • 无指定策略的终结点。
  • 与终结点不匹配的请求。

这使得授权中间件在路由上下文之外很有用。 授权中间件可用于传统中间件的编程。

调试诊断

要获取详细的路由诊断输出,请将 Logging:LogLevel:Microsoft 设置为 Debug。 在开发环境中,在 appsettings.Development.json 中设置日志级别:

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

其他资源

路由负责匹配传入的 HTTP 请求,然后将这些请求发送到应用的可执行终结点。 终结点是应用的可执行请求处理代码单元。 终结点在应用中进行定义,并在应用启动时进行配置。 终结点匹配过程可以从请求的 URL 中提取值,并为请求处理提供这些值。 通过使用应用中的终结点信息,路由还能生成映射到终结点的 URL。

应用可以使用以下内容配置路由:

  • Controllers
  • Razor Pages
  • SignalR
  • gRPC 服务
  • 启用终结点的中间件,例如运行状况检查
  • 通过路由注册的委托和 Lambda。

本文介绍 ASP.NET Core 路由的较低级别详细信息。 有关配置路由的信息,请参阅:

路由基础知识

以下代码演示路由的基本示例:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello World!");

app.Run();

前面的示例包含使用 MapGet 方法的单个终结点:

  • 当 HTTP GET 请求发送到根 URL / 时:
    • 将执行请求委托。
    • Hello World! 会写入 HTTP 响应。
  • 如果请求方法不是 GET 或根 URL 不是 /,则无路由匹配,并返回 HTTP 404。

路由使用一对由 UseRoutingUseEndpoints 注册的中间件:

  • UseRouting 向中间件管道添加路由匹配。 此中间件会查看应用中定义的终结点集,并根据请求选择最佳匹配
  • UseEndpoints 向中间件管道添加终结点执行。 它会运行与所选终结点关联的委托。

应用通常不需要调用 UseRoutingUseEndpointsWebApplicationBuilder 配置中间件管道,该管道使用 UseRoutingUseEndpoints 包装在 Program.cs 中添加的中间件。 但是,应用可以通过显式调用这些方法来更改 UseRoutingUseEndpoints 的运行顺序。 例如,下面的代码显式调用 UseRouting

app.Use(async (context, next) =>
{
    // ...
    await next(context);
});

app.UseRouting();

app.MapGet("/", () => "Hello World!");

在上述代码中:

  • app.Use 的调用会注册一个在管道的开头运行的自定义中间件。
  • UseRouting 的调用将路由匹配中间件配置为在自定义中间件之后运行。
  • 使用 MapGet 注册的终结点在管道末尾运行。

如果前面的示例不包含对 UseRouting 的调用,则自定义中间件将在路由匹配中间件之后运行。

终结点

MapGet 方法用于定义终结点。 终结点可以:

  • 通过匹配 URL 和 HTTP 方法来选择。
  • 通过运行委托来执行。

可通过应用匹配和执行的终结点在 UseEndpoints 中进行配置。 例如,MapGetMapPostMapGet将请求委托连接到路由系统。 其他方法可用于将 ASP.NET Core 框架功能连接到路由系统:

下面的示例演示如何使用更复杂的路由模板进行路由:

app.MapGet("/hello/{name:alpha}", (string name) => $"Hello {name}!");

/hello/{name:alpha} 字符串是一个路由模板。 路由模板用于配置终结点的匹配方式。 在这种情况下,模板将匹配:

  • 类似 /hello/Docs 的 URL
  • /hello/ 开头、后跟一系列字母字符的任何 URL 路径。 :alpha 应用仅匹配字母字符的路由约束。 路由约束将在本文的后面介绍。

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

下面的示例演示如何通过运行状况检查和授权进行路由:

app.UseAuthentication();
app.UseAuthorization();

app.MapHealthChecks("/healthz").RequireAuthorization();
app.MapGet("/", () => "Hello World!");

前面的示例展示了如何:

  • 将授权中间件与路由一起使用。
  • 将终结点用于配置授权行为。

MapHealthChecks 调用添加运行状况检查终结点。 将 RequireAuthorization 链接到此调用会将授权策略附加到该终结点。

调用 UseAuthenticationUseAuthorization 会添加身份验证和授权中间件。 这些中间件位于 UseRoutingUseEndpoints 之间,因此它们可以:

  • 查看 UseRouting 选择的终结点。
  • UseEndpoints 发送到终结点之前应用授权策略。

终结点元数据

前面的示例中有两个终结点,但只有运行状况检查终结点附加了授权策略。 如果请求与运行状况检查终结点 /healthz 匹配,则执行授权检查。 这表明,终结点可以附加额外的数据。 此额外数据称为终结点元数据:

  • 可以通过路由感知中间件来处理元数据。
  • 元数据可以是任意的 .NET 类型。

路由概念

路由系统通过添加功能强大的终结点概念,构建在中间件管道之上。 终结点代表应用的功能单元,在路由、授权和任意数量的 ASP.NET Core 系统方面彼此不同。

ASP.NET Core 终结点定义

ASP.NET Core 终结点是:

以下代码显示了如何检索和检查与当前请求匹配的终结点:

app.Use(async (context, next) =>
{
    var currentEndpoint = context.GetEndpoint();

    if (currentEndpoint is null)
    {
        await next(context);
        return;
    }

    Console.WriteLine($"Endpoint: {currentEndpoint.DisplayName}");

    if (currentEndpoint is RouteEndpoint routeEndpoint)
    {
        Console.WriteLine($"  - Route Pattern: {routeEndpoint.RoutePattern}");
    }

    foreach (var endpointMetadata in currentEndpoint.Metadata)
    {
        Console.WriteLine($"  - Metadata: {endpointMetadata}");
    }

    await next(context);
});

app.MapGet("/", () => "Inspect Endpoint.");

如果选择了终结点,可从 HttpContext 中进行检索。 可以检查其属性。 终结点对象是不可变的,并且在创建后无法修改。 最常见的终结点类型是 RouteEndpointRouteEndpoint 包括允许自己被路由系统选择的信息。

在前面的代码中,app.Use 配置了一个内联中间件

下面的代码显示,根据管道中调用 app.Use 的位置,可能不存在终结点:

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

app.UseRouting();

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

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

app.UseEndpoints(_ => { });

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

前面的示例添加了 Console.WriteLine 语句,这些语句显示是否已选择终结点。 为清楚起见,该示例将显示名称分配给提供的 / 终结点。

前面的示例还包括对 UseRoutingUseEndpoints 的调用,以准确控制这些中间件何时在管道中运行。

使用 / URL 运行此代码将显示:

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

使用任何其他 URL 运行此代码将显示:

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

此输出说明:

  • 调用 UseRouting 之前,终结点始终为 null。
  • 如果找到匹配项,则 UseRoutingUseEndpoints 之间的终结点为非 null。
  • 如果找到匹配项,则 UseEndpoints 中间件即为终端。 稍后会在本文中定义终端中间件
  • 仅当找不到匹配项时才执行 UseEndpoints 后的中间件。

UseRouting 中间件使用 SetEndpoint 方法将终结点附加到当前上下文。 可以将 UseRouting 中间件替换为自定义逻辑,同时仍可获得使用终结点的益处。 终结点是中间件等低级别基元,不与路由实现耦合。 大多数应用都不需要将 UseRouting 替换为自定义逻辑。

UseEndpoints 中间件旨在与 UseRouting 中间件配合使用。 执行终结点的核心逻辑并不复杂。 使用 GetEndpoint 检索终结点,然后调用其 RequestDelegate 属性。

下面的代码演示中间件如何影响或响应路由:

app.UseHttpMethodOverride();
app.UseRouting();

app.Use(async (context, next) =>
{
    if (context.GetEndpoint()?.Metadata.GetMetadata<RequiresAuditAttribute>() is not null)
    {
        Console.WriteLine($"ACCESS TO SENSITIVE DATA AT: {DateTime.UtcNow}");
    }

    await next(context);
});

app.MapGet("/", () => "Audit isn't required.");
app.MapGet("/sensitive", () => "Audit required for sensitive data.")
    .WithMetadata(new RequiresAuditAttribute());
public class RequiresAuditAttribute : Attribute { }

前面的示例演示两个重要概念:

  • 中间件可以在 UseRouting 之前运行,以修改路由操作的数据。
  • 中间件可以在 UseRoutingUseEndpoints 之间运行,以便在执行终结点前处理路由结果。
    • UseRoutingUseEndpoints 之间运行的中间件:
      • 通常会检查元数据以了解终结点。
      • 通常会根据 UseAuthorizationUseCors 做出安全决策。
    • 中间件和元数据的组合允许按终结点配置策略。

前面的代码显示了支持按终结点策略的自定义中间件示例。 中间件将访问敏感数据的审核日志写入控制台。 可以将中间件配置为审核具有 元数据的终结点。 此示例演示选择加入模式,其中仅审核标记为敏感的终结点。 例如,可以反向定义此逻辑,从而审核未标记为安全的所有内容。 终结点元数据系统非常灵活。 此逻辑可以以任何适合用例的方法进行设计。

前面的示例代码旨在演示终结点的基本概念。 该示例不应在生产环境中使用。 审核日志中间件的更完整版本如下:

  • 记录到文件或数据库。
  • 包括详细信息,如用户、IP 地址、敏感终结点的名称等。

审核策略元数据 RequiresAuditAttribute 定义为一个 Attribute,便于和基于类的框架(如控制器和 SignalR)结合使用。 使用路由到代码时:

  • 元数据附有生成器 API。
  • 基于类的框架在创建终结点时,包含了相应方法和类的所有特性。

对于元数据类型,最佳做法是将它们定义为接口或特性。 接口和特性允许代码重用。 元数据系统非常灵活,无任何限制。

将终端中间件与路由进行比较

下面的示例演示了终端中间件和路由:

// Approach 1: Terminal Middleware.
app.Use(async (context, next) =>
{
    if (context.Request.Path == "/")
    {
        await context.Response.WriteAsync("Terminal Middleware.");
        return;
    }

    await next(context);
});

app.UseRouting();

// Approach 2: Routing.
app.MapGet("/Routing", () => "Routing.");

使用 Approach 1: 显示的中间件样式是终端中间件。 之所以称之为终端中间件,是因为它执行匹配的操作:

  • 前面示例中的匹配操作是用于中间件的 Path == "/" 和用于路由的 Path == "/Routing"
  • 如果匹配成功,它将执行一些功能并返回,而不是调用 next 中间件。

之所以称之为终端中间件,是因为它会终止搜索,执行一些功能,然后返回。

以下列表将终端中间件与路由进行比较:

  • 这两种方法都允许终止处理管道:
    • 中间件通过返回而不是调用 next 来终止管道。
    • 终结点始终是终端。
  • 终端中间件允许在管道中的任意位置放置中间件:
  • 终端中间件允许任意代码确定中间件匹配的时间:
    • 自定义路由匹配代码可能比较复杂,且难以正确编写。
    • 路由为典型应用提供了简单的解决方案。 大多数应用不需要自定义路由匹配代码。
  • 带有中间件的终结点接口,如 UseAuthorizationUseCors
    • 通过 UseAuthorizationUseCors 使用终端中间件需要与授权系统进行手动交互。

终结点定义以下两者:

  • 用于处理请求的委托。
  • 任意元数据的集合。 元数据用于实现横切关注点,该实现基于附加到每个终结点的策略和配置。

终端中间件可以是一种有效的工具,但可能需要:

  • 大量的编码和测试。
  • 手动与其他系统集成,以实现所需的灵活性级别。

请考虑在写入终端中间件之前与路由集成。

MapMapWhen 相集成的现有终端中间件通常会转换为路由感知终结点。 MapHealthChecks 演示了路由器软件的模式:

下面的代码演示了 MapHealthChecks 的使用方法:

app.UseAuthentication();
app.UseAuthorization();

app.MapHealthChecks("/healthz").RequireAuthorization();

前面的示例说明了为什么返回生成器对象很重要。 返回生成器对象后,应用开发者可以配置策略,如终结点的授权。 在此示例中,运行状况检查中间件不与授权系统直接集成。

元数据系统的创建目的是为了响应扩展性创建者使用终端中间件时遇到的问题。 对于每个中间件,实现自己与授权系统的集成都会出现问题。

URL 匹配

  • 是路由将传入请求匹配到终结点的过程。
  • 基于 URL 路径中的数据和标头。
  • 可进行扩展,以考虑请求中的任何数据。

当路由中间件执行时,它会设置 Endpoint,并将值从当前请求路由到 HttpContext 上的Endpoint

  • 调用 GetEndpoint 获取终结点。
  • HttpRequest.RouteValues 将获取路由值的集合。

中间件在路由中间件可以检查终结点并采取措施之后运行。 例如,授权中间件可以在终结点的元数据集合中询问授权策略。 请求处理管道中的所有中间件执行后,将调用所选终结点的委托。

终结点路由中的路由系统负责所有的调度决策。 中间件基于所选终结点应用策略,因此重要的是:

  • 任何可能影响发送或安全策略应用的决定都应在路由系统中做出。

警告

为了实现向后兼容性,执行 Controller 或 Razor Pages 终结点委托时,应根据迄今执行的请求处理将 RouteContext.RouteData 的属性设为适当的值。

在未来的版本中,会将 RouteContext 类型标记为已过时:

  • RouteData.Values 迁移到 HttpRequest.RouteValues
  • 迁移 RouteData.DataTokens 以从终结点元数据检索 IDataTokensMetadata

URL 匹配在可配置的阶段集中运行。 在每个阶段中,输出为一组匹配项。 下一阶段可以进一步缩小这一组匹配项。 路由实现不保证匹配终结点的处理顺序。 所有可能的匹配项一次性处理。 URL 匹配阶段按以下顺序出现。 ASP.NET Core:

  1. 针对终结点集及其路由模板处理 URL 路径,收集所有匹配项。
  2. 采用前面的列表并删除在应用路由约束时失败的匹配项。
  3. 采用前面的列表并删除 MatcherPolicy 实例集失败的匹配项。
  4. 使用 EndpointSelector 从前面的列表中做出最终决定。

根据以下内容设置终结点列表的优先级:

在每个阶段中处理所有匹配的终结点,直到达到 EndpointSelectorEndpointSelector 是最后一个阶段。 它从匹配项中选择最高优先级终结点作为最佳匹配项。 如果存在具有与最佳匹配相同优先级的其他匹配项,则会引发不明确的匹配异常。

路由优先顺序基于更具体的路由模板(优先级更高)进行计算。 例如,考虑模板 /hello/{message}

  • 两者都匹配 URL 路径 /hello
  • /hello 更具体,因此优先级更高。

通常,路由优先顺序非常适合为实践操作中使用的各种 URL 方案选择最佳匹配项。 仅在必要时才使用 Order 来避免多义性。

由于路由提供的扩展性种类,路由系统无法提前计算不明确的路由。 假设有一个示例,例如路由模板 /{message:alpha}/{message:int}

  • alpha 约束仅匹配字母数字字符。
  • int 约束仅匹配数字。
  • 这些模板具有相同的路由优先顺序,但没有两者均匹配的单一 URL。
  • 如果路由系统在启动时报告了多义性错误,则会阻止此有效用例。

警告

UseEndpoints 内的操作顺序并不会影响路由行为,但有一个例外。 MapControllerRouteMapAreaRoute 会根据调用顺序,自动将顺序值分配给其终结点。 这会模拟控制器的长时间行为,而无需路由系统提供与早期路由实现相同的保证。

ASP.NET Core 中的终结点路由:

  • 没有路由的概念。
  • 不提供顺序保证。 同时处理所有终结点。

路由模板优先顺序和终结点选择顺序

路由模板优先顺序是一种系统,该系统根据每个路由模板的具体程度为其分配值。 路由模板优先顺序:

  • 无需在常见情况下调整终结点的顺序。
  • 尝试匹配路由行为的常识性预期。

例如,考虑模板 /Products/List/Products/{id}。 我们可合理地假设,对于 URL 路径 /Products/List/Products/List 匹配项比 /Products/{id} 更好。 这种假设合理是因为文本段 /List 比参数段 /{id} 具有更好的优先顺序。

优先顺序工作原理的详细信息与路由模板的定义方式相耦合:

  • 具有更多段的模板则更具体。
  • 带有文本的段比参数段更具体。
  • 具有约束的参数段比没有约束的参数段更具体。
  • 复杂段与具有约束的参数段同样具体。
  • catch-all 参数是最不具体的参数。 有关 catch-all 路由的重要信息,请参阅路由模板部分中的“catch-all”。

URL 生成概念

URL 生成:

  • 是指路由基于一系列路由值创建 URL 路径的过程。
  • 允许终结点与访问它们的 URL 之间存在逻辑分隔。

终结点路由包含 LinkGenerator API。 LinkGeneratorLinkGenerator 中可用的单一实例服务。 LinkGenerator API 可在执行请求的上下文之外使用。 Mvc.IUrlHelper 和依赖 IUrlHelper 的方案(如标记帮助程序、HTML 帮助程序和操作结果)在内部使用 LinkGenerator API 提供链接生成功能。

链接生成器基于“地址”和“地址方案”概念 。 地址方案是确定哪些终结点用于链接生成的方式。 例如,许多用户熟悉的来自控制器或 Razor Pages 的路由名称和路由值方案都是作为地址方案实现的。

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

这些方法的重载接受包含 HttpContext 的参数。 这些方法在功能上等同于 Url.ActionUrl.Page,但提供了更大的灵活性和更多选项。

GetPath* 方法与 Url.ActionUrl.Page 最相似,因为它们生成包含绝对路径的 URI。 GetUri* 方法始终生成包含方案和主机的绝对 URI。 接受 HttpContext 的方法在执行请求的上下文中生成 URI。 除非重写,否则将使用来自执行请求的环境路由值、URL 基路径、方案和主机。

使用地址调用 LinkGenerator。 生成 URI 的过程分两步进行:

  1. 将地址绑定到与地址匹配的终结点列表。
  2. 计算每个终结点的 RoutePattern,直到找到与提供的值匹配的路由模式。 输出结果会与提供给链接生成器的其他 URI 部分进行组合并返回。

对任何类型的地址,LinkGenerator 提供的方法均支持标准链接生成功能。 使用链接生成器的最简便方法是通过扩展方法对特定地址类型执行操作:

扩展方法 描述
GetPathByAddress 根据提供的值生成具有绝对路径的 URI。
GetUriByAddress 根据提供的值生成绝对 URI。

警告

请注意有关调用 LinkGenerator 方法的下列含义:

  • 对于不验证传入请求的 Host 标头的应用配置,请谨慎使用 GetUri* 扩展方法。 如果未验证传入请求的 Host 标头,则可能以视图或页面中 URI 的形式将不受信任的请求输入发送回客户端。 建议所有生产应用都将其服务器配置为针对已知有效值验证 Host 标头。

  • 在中间件中将 LinkGeneratorMapMapWhen 结合使用时,请小心谨慎。 Map* 会更改执行请求的基路径,这会影响链接生成的输出。 所有 LinkGenerator API 都允许指定基路径。 指定一个空的基路径来撤消 Map* 对链接生成的影响。

中间件示例

在以下示例中,中间件使用 LinkGenerator API 创建列出存储产品的操作方法的链接。 应用中的任何类都可通过将链接生成器注入类并调用 GenerateLink 来使用链接生成器:

public class ProductsMiddleware
{
    private readonly LinkGenerator _linkGenerator;

    public ProductsMiddleware(RequestDelegate next, LinkGenerator linkGenerator) =>
        _linkGenerator = linkGenerator;

    public async Task InvokeAsync(HttpContext httpContext)
    {
        httpContext.Response.ContentType = MediaTypeNames.Text.Plain;

        var productsPath = _linkGenerator.GetPathByAction("Products", "Store");

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

路由模板

如果路由找到匹配项,{} 内的令牌定义绑定的路由参数。 可在路由段中定义多个路由参数,但必须用文本值隔开这些路由参数。 例如:

{controller=Home}{action=Index}

不是有效的路由,因为 {controller}{action} 之间没有文本值。 路由参数必须具有名称,且可能指定了其他特性。

路由参数以外的文本(例如 {id})和路径分隔符 / 必须匹配 URL 中的文本。 文本匹配区分大小写,并且基于 URL 路径已解码的表示形式。 要匹配文字路由参数分隔符({}),请通过重复该字符来转义分隔符。 例如 {{}}

星号 * 或双星号 **

  • 可用作路由参数的前缀,以绑定到 URI 的 rest。
  • 称为 catch-all 参数。 例如,blog/{**slug}
    • 匹配以 blog/ 开头并在其后面包含任何值的任何 URI。
    • blog/ 后的值分配给 blog/ 路由值。

警告

由于路由中的 bugcatch-all 参数可能无法正确匹配相应路由。 受此 Bug 影响的应用具有以下特征:

  • “全部捕获”路由,例如 {**slug}"
  • “全部捕获”路由未能匹配应与之匹配的请求。
  • 删除其他路由可使“全部捕获”路由开始运行。

请参阅 GitHub bug 1867716579,了解遇到此 bug 的示例。

.NET Core 3.1.301 SDK 及更高版本中包含此 bug 的修补程序(可选用)。 以下代码设置了一个可修复此 bug 的内部开关:

public static void Main(string[] args)
{
   AppContext.SetSwitch("Microsoft.AspNetCore.Routing.UseCorrectCatchAllBehavior", 
                         true);
   CreateHostBuilder(args).Build().Run();
}
// Remaining code removed for brevity.

全方位参数还可以匹配空字符串。

使用路由生成 URL(包括路径分隔符 /)时,catch-all 参数会转义相应的字符。 例如,路由值为 { path = "my/path" } 的路由 foo/{*path} 生成 foo/my%2Fpath。 请注意转义的正斜杠。 要往返路径分隔符,请使用 ** 路由参数前缀。 { path = "my/path" } 的路由 foo/{**path} 生成 foo/my/path

尝试捕获具有可选文件扩展名的文件名的 URL 模式还有其他注意事项。 例如,考虑模板 files/{filename}.{ext?}。 当 filenameext 的值都存在时,将填充这两个值。 如果 URL 中仅存在 filename 的值,则路由匹配,因为尾随 . 是可选的。 以下 URL 与此路由相匹配:

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

路由参数可能具有指定的默认值,方法是在参数名称后使用等号 () 隔开以指定默认值。 例如,{controller=Home}Home 定义为 controller 的默认值。 如果参数的 URL 中不存在任何值,则使用默认值。 通过在参数名称的末尾附加问号 (?) 可使路由参数成为可选项。 例如 id?。 可选值和默认路由参数之间的差异是:

  • 具有默认值的路由参数始终生成一个值。
  • 仅当请求 URL 提供值时,可选参数才具有值。

路由参数可能具有必须与从 URL 中绑定的路由值匹配的约束。 在路由参数后面添加一个 : 和约束名称可指定路由参数上的内联约束。 如果约束需要参数,将以在约束名称后括在括号 (...) 中的形式提供。 通过追加另一个 和约束名称,可指定多个内联约束。

约束名称和参数将传递给 IInlineConstraintResolver 服务,以创建 IRouteConstraint 的实例,用于处理 URL。 例如,路由模板 blog/{article:minlength(10)} 使用参数 10 指定 minlength 约束。 有关路由约束的详细信息以及框架提供的约束列表,请参阅路由约束部分。

路由参数还可以具有参数转换器。 参数转换器在生成链接以及将操作和页面匹配到 URI 时转换参数的值。 与约束类似,可在路由参数名称后面添加 : 和转换器名称,将参数变换器内联添加到路径参数。 例如,路由模板 blog/{article:slugify} 指定 slugify 转换器。 有关参数转换的详细信息,请参阅参数转换器部分。

下表演示了示例路由模板及其行为:

路由模板 示例匹配 URI 请求 URI…
hello /hello 仅匹配单个路径 /hello
{Page=Home} / 匹配并将 Page 设置为 Home
{Page=Home} /Contact 匹配并将 Page 设置为 Contact
{controller}/{action}/{id?} /Products/List 映射到 Products 控制器和 List 操作。
{controller}/{action}/{id?} /Products/Details/123 映射到 Products 控制器和 Details 操作,并将 id 设置为 123。
{controller=Home}/{action=Index}/{id?} / 映射到 Home 控制器和 Index 方法。 id 将被忽略。
{controller=Home}/{action=Index}/{id?} /Products 映射到 Products 控制器和 Index 方法。 id 将被忽略。

使用模板通常是进行路由最简单的方法。 还可在路由模板外指定约束和默认值。

复杂段

复杂段通过非贪婪的方式从右到左匹配文字进行处理。 例如,[Route("/a{b}c{d}")] 是一个复杂段。 复杂段以一种特定的方式工作,必须理解这种方式才能成功地使用它们。 本部分中的示例演示了为什么复杂段只有在分隔符文本没有出现在参数值中时才真正有效。 对于更复杂的情况,需要使用 regex,然后手动提取值。

警告

如果使用 System.Text.RegularExpressions 处理不受信任的输入,则传递一个超时。 恶意用户可能会向 RegularExpressions 提供输入,从而导致拒绝服务攻击。 使用 RegularExpressions 的 ASP.NET Core 框架 API 会传递一个超时。

这是使用模板 /a{b}c{d} 和 URL 路径 /abcd 执行路由的步骤摘要。 | 有助于可视化算法的工作方式:

  • 从右到左的第一个文本是 c。 因此从右侧搜索 /abcd,并查找 /ab|c|d
  • 右侧的所有内容 (d) 现在都与路由参数 {d} 相匹配。
  • 从右到左的下一个文本是 a。 从我们停止的地方开始搜索 /ab|c|d,然后 /|a|b|c|d 找到 a
  • 右侧的值 (b) 现在与路由参数 {b} 相匹配。
  • 没有剩余的文本,并且没有剩余的路由模板,因此这是一个匹配项。

下面是使用相同模板 /a{b}c{d} 和 URL 路径 /aabcd 的负面案例示例。 | 有助于可视化算法的工作方式。 该案例不是匹配项,可由相同算法解释:

  • 从右到左的第一个文本是 c。 因此从右侧搜索 /aabcd,并查找 /aab|c|d
  • 右侧的所有内容 (d) 现在都与路由参数 {d} 相匹配。
  • 从右到左的下一个文本是 a。 从我们停止的地方开始搜索 /aab|c|d,然后 /a|a|b|c|d 找到 a
  • 右侧的值 (b) 现在与路由参数 {b} 相匹配。
  • 此时还有剩余的文本 a,但是算法已经耗尽了要解析的路由模板,所以这不是一个匹配项。

匹配算法是非贪婪算法:

  • 它匹配每个步骤中最小可能文本量。
  • 如果分隔符值出现在参数值内,则会导致不匹配。

正则表达式可以更好地控制它们的匹配行为。

贪婪匹配(也称为懒惰匹配)匹配最大可能字符串。 非贪婪匹配最小可能字符串。

使用特殊字符进行路由

使用特殊字符进行路由可能会导致意外的结果。 例如,假设控制器具有以下操作方法:

[HttpGet("{id?}/name")]
public async Task<ActionResult<string>> GetName(string id)
{
    var todoItem = await _context.TodoItems.FindAsync(id);

    if (todoItem == null || todoItem.Name == null)
    {
        return NotFound();
    }

    return todoItem.Name;
}

如果 string id 包含以下编码值,可能会出现意外结果:

ASCII Encoded
/ %2F
+

路由参数并不总是 URL 解码的。 此问题可能会在未来得到解决。 有关详细信息,请参阅此 GitHub 问题

路由约束

路由约束在传入 URL 发生匹配时执行,URL 路径标记为路由值。 路径约束通常检查通过路径模板关联的路径值,并对该值是否为可接受做出对/错决定。 某些路由约束使用路由值以外的数据来考虑是否可以路由请求。 例如,HttpMethodRouteConstraint 可以根据其 HTTP 谓词接受或拒绝请求。 约束用于路由请求和链接生成。

警告

请勿将约束用于输入验证。 如果约束用于输入验证,则无效的输入将导致 404(找不到页面)响应。 无效输入可能生成包含相应错误消息的 400 错误请求。 路由约束用于消除类似路由的歧义,而不是验证特定路由的输入。

下表演示示例路由约束及其预期行为:

约束 示例 匹配项示例 说明
int {id:int} 123456789-123456789 匹配任何整数
bool {active:bool} trueFALSE 匹配 truefalse。 不区分大小写
datetime {dob:datetime} 2016-12-312016-12-31 7:32pm 在固定区域性中匹配有效的 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 处理不受信任的输入,则传递一个超时。 恶意用户可能会向 RegularExpressions 提供输入,从而导致拒绝服务攻击。 使用 RegularExpressions 的 ASP.NET Core 框架 API 会传递一个超时。

可向单个参数应用多个用冒号分隔的约束。 例如,以下约束将参数限制为大于或等于 1 的整数值:

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

警告

验证 URL 的路由约束并将转换为始终使用固定区域性的 CLR 类型。 例如,转换为 CLR 类型 intDateTime。 这些约束假定 URL 不可本地化。 框架提供的路由约束不会修改存储于路由值中的值。 从 URL 中分析的所有路由值都将存储为字符串。 例如,float 约束会尝试将路由值转换为浮点数,但转换后的值仅用来验证其是否可转换为浮点数。

约束中的正则表达式

警告

如果使用 System.Text.RegularExpressions 处理不受信任的输入,则传递一个超时。 恶意用户可能会向 RegularExpressions 提供输入,从而导致拒绝服务攻击。 使用 RegularExpressions 的 ASP.NET Core 框架 API 会传递一个超时。

使用 regex(...) 路由约束可以将正则表达式指定为内联约束。 MapControllerRoute 系列中的方法还接受约束的对象文字。 如果使用该窗体,则字符串值将解释为正则表达式。

下面的代码使用内联 regex 约束:

app.MapGet("{message:regex(^\\d{{3}}-\\d{{2}}-\\d{{4}}$)}",
    () => "Inline Regex Constraint Matched");

下面的代码使用对象文字来指定 regex 约束:

app.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# 源文件中的 \\ 字符,以便对 \ 字符串转义字符进行转义。
  • 逐字字符串文本

要对路由参数分隔符 {}[] 进行转义,请将表达式(例如 {{}}[[]])中的字符数加倍。 下表展示了正则表达式及其转义的版本:

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

路由中使用的正则表达式通常以 ^ 字符开头,并匹配字符串的起始位置。 表达式通常以 $ 字符结尾,并匹配字符串的结尾。 ^$ 字符可确保正则表达式匹配整个路由参数值。 如果没有 ^$ 字符,正则表达式将匹配字符串内的所有子字符串,而这通常是不需要的。 下表提供了示例并说明了它们匹配或匹配失败的原因:

表达式 String 匹配 注释
[a-z]{2} hello 子字符串匹配
[a-z]{2} 123abc456 子字符串匹配
[a-z]{2} mz 匹配表达式
[a-z]{2} MZ 不区分大小写
^[a-z]{2}$ hello 参阅上述 ^$
^[a-z]{2}$ 123abc456 参阅上述 ^$

有关正则表达式语法的详细信息,请参阅 .NET Framework 正则表达式

若要将参数限制为一组已知的可能值,可使用正则表达式。 例如,{action:regex(^(list|get|create)$)} 仅将 action 路由值匹配到 listgetcreate。 如果传递到约束字典中,字符串 ^(list|get|create)$ 将等效。 已传递到约束字典且不匹配任何已知约束的约束也将被视为正则表达式。 模板内传递且不匹配任何已知约束的约束将不被视为正则表达式。

自定义路由约束

可实现 IRouteConstraint 接口来创建自定义路由约束。 IRouteConstraint 接口包含 Match,当满足约束时,它返回 true,否则返回 false

很少需要自定义路由约束。 在实现自定义路由约束之前,请考虑替代方法,如模型绑定。

ASP.NET Core Constraints 文件夹提供了创建约束的经典示例。 例如 GuidRouteConstraint

若要使用自定义 IRouteConstraint,必须在服务容器中使用应用的 ConstraintMap 注册路由约束类型。 ConstraintMap 是将路由约束键映射到验证这些约束的 IRouteConstraint 实现的目录。 应用的 ConstraintMap 可作为 AddRouting 调用的一部分在 Program.cs 中进行更新,也可以通过使用 builder.Services.Configure<RouteOptions> 直接配置 RouteOptions 进行更新。 例如:

builder.Services.AddRouting(options =>
    options.ConstraintMap.Add("noZeroes", typeof(NoZeroesRouteConstraint)));

前面的约束应用于以下代码:

[ApiController]
[Route("api/[controller]")]
public class NoZeroesController : ControllerBase
{
    [HttpGet("{id:noZeroes}")]
    public IActionResult Get(string id) =>
        Content(id);
}

实现 NoZeroesRouteConstraint 可防止将 0 用于路由参数:

public class NoZeroesRouteConstraint : IRouteConstraint
{
    private static readonly Regex _regex = new(
        @"^[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 var routeValue))
        {
            return false;
        }

        var routeValueString = Convert.ToString(routeValue, CultureInfo.InvariantCulture);

        if (routeValueString is null)
        {
            return false;
        }

        return _regex.IsMatch(routeValueString);
    }
}

警告

如果使用 System.Text.RegularExpressions 处理不受信任的输入,则传递一个超时。 恶意用户可能会向 RegularExpressions 提供输入,从而导致拒绝服务攻击。 使用 RegularExpressions 的 ASP.NET Core 框架 API 会传递一个超时。

前面的代码:

  • 阻止路由的 {id} 段中的 0
  • 显示以提供实现自定义约束的基本示例。 不应在产品应用中使用。

下面的代码是防止处理包含 0id 的更好方法:

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

    return Content(id);
}

前面的代码与 NoZeroesRouteConstraint 方法相比具有以下优势:

  • 它不需要自定义约束。
  • 当路由参数包括 0 时,它将返回更具描述性的错误。

参数转换器

参数转换器:

例如,路由模式 blog\{article:slugify}(具有 Url.Action(new { article = "MyTestArticle" }))中的自定义 slugify 参数转换器生成 blog\my-test-article

请考虑以下 IOutboundParameterTransformer 实现:

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

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

若要在路由模式中使用参数转换器,请在 Program.cs 中使用 ConstraintMap 对其进行配置:

builder.Services.AddRouting(options =>
    options.ConstraintMap["slugify"] = typeof(SlugifyParameterTransformer));

ASP.NET Core 框架使用参数转化器来转换进行终结点解析的 URI。 例如,参数转换器转换用于匹配 areacontrolleractionpage 的路由值:

app.MapControllerRoute(
    name: "default",
    pattern: "{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 约定:

URL 生成参考

本部分包含 URL 生成实现的算法的参考。 在实践中,最复杂的 URL 生成示例使用控制器或 Razor Pages。 有关其他信息,请参阅控制器中的路由

URL 生成过程首先调用 LinkGenerator.GetPathByAddress 或类似方法。 此方法提供了一个地址、一组路由值以及有关 HttpContext 中当前请求的可选信息。

第一步是使用地址解析一组候选终结点,该终结点使用与该地址类型匹配的 IEndpointAddressScheme<TAddress>

地址方案找到一组候选项后,就会以迭代方式对终结点进行排序和处理,直到 URL 生成操作成功。 URL 生成不检查多义性,返回的第一个结果就是最终结果。

使用日志记录对 URL 生成进行故障排除

对 URL 生成进行故障排除的第一步是将 Microsoft.AspNetCore.Routing 的日志记录级别设置为 TRACELinkGenerator 记录有关其处理的许多详细信息,有助于排查问题。

有关 URL 生成的详细信息,请参阅 URL 生成参考

地址

地址是 URL 生成中的概念,用于将链接生成器中的调用绑定到一组终结点。

地址是一个可扩展的概念,默认情况下有两种实现:

  • 使用终结点名称 (string) 作为地址
    • 提供与 MVC 的路由名称相似的功能。
    • 使用 IEndpointNameMetadata 元数据类型。
    • 根据所有注册的终结点的元数据解析提供的字符串。
    • 如果多个终结点使用相同的名称,启动时会引发异常。
    • 建议用于控制器和 Razor Pages 之外的常规用途。
  • 使用路由值 (RouteValuesAddress) 作为地址
    • 提供与控制器和 Razor Pages 生成旧 URL 相同的功能。
    • 扩展和调试非常复杂。
    • 提供 IUrlHelper、标记帮助程序、HTML 帮助程序、操作结果等所使用的实现。

地址方案的作用是根据任意条件在地址和匹配终结点之间建立关联:

  • 终结点名称方案执行基本字典查找。
  • 路由值方案有一个复杂的子集算法,这是集算法的最佳子集。

环境值和显式值

从当前请求开始,路由将访问当前请求 HttpContext.Request.RouteValues 的路由值。 与当前请求关联的值称为环境值。 为清楚起见,文档是指作为显式值传入方法的路由值。

下面的示例演示了环境值和显式值。 它将提供当前请求中的环境值和显式值:

public class WidgetController : ControllerBase
{
    private readonly LinkGenerator _linkGenerator;

    public WidgetController(LinkGenerator linkGenerator) =>
        _linkGenerator = linkGenerator;

    public IActionResult Index()
    {
        var indexPath = _linkGenerator.GetPathByAction(
            HttpContext, values: new { id = 17 })!;

        return Content(indexPath);
    }

    // ...

前面的代码:

下面的代码仅提供显式值,不提供任何环境值:

var subscribePath = _linkGenerator.GetPathByAction(
    "Subscribe", "Home", new { id = 17 })!;

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

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

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

下面的代码提供当前请求中的环境值和显式值中的控制器:

public class GadgetController : ControllerBase
{
    public IActionResult Index() =>
        Content(Url.Action("Edit", new { id = 17 })!);
}

在上述代码中:

  • 返回 /Gadget/Edit/17
  • Url 获取 IUrlHelper
  • Action 生成一个 URL,其中包含操作方法的绝对路径。 URL 包含指定的 action 名称和 route 值。

下面的代码提供当前请求中的环境值和显式值:

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

        // ...
    }
}

当“编辑 Razor”页包含以下页面指令时,前面的代码会将 url 设置为 /Edit/17

@page "{id:int}"

如果“编辑”页面不包含 "{id:int}" 路由模板,url/Edit?id=17

除了此处所述的规则外,MVC IUrlHelper 的行为还增加了一层复杂性:

  • IUrlHelper 始终将当前请求中的路由值作为环境值提供。
  • IUrlHelper 始终将当前 actioncontroller 路由值复制为显式值,除非由开发者重写。
  • IUrlHelper 始终将当前 page 路由值复制为显式值,除非重写。
  • IUrlHelper.Page 始终替代当前 handler 路由值,且 null 为显式值,除非重写。

用户常常对环境值的行为详细信息感到惊讶,因为 MVC 似乎不遵循自己的规则。 出于历史和兼容性原因,某些路由值(例如 actioncontrollerpagehandler)具有其自己的特殊行为。

LinkGenerator.GetPathByActionLinkGenerator.GetPathByPage 提供的等效功能与 IUrlHelper 的兼容性异常相同。

URL 生成过程

找到候选终结点集后,URL 生成算法将:

  • 以迭代方式处理终结点。
  • 返回第一个成功的结果。

此过程的第一步称为路由值失效。 路由值失效是路由决定应使用环境值中的哪些路由值以及应忽略哪些路由值的过程。 将考虑每个环境值,要么与显式值组合,要么忽略它们。

考虑环境值角色的最佳方式是在某些常见情况下,尝试保存应用程序开发者的键入内容。 就传统意义而言,环境值非常有用的情况与 MVC 相关:

  • 链接到同一控制器中的其他操作时,不需要指定控制器名称。
  • 链接到同一区域中的另一个控制器时,不需要指定区域名称。
  • 链接到相同的操作方法时,不需要指定路由值。
  • 链接到应用的其他部分时,不需要在应用的该部分中传递无意义的路由值。

如果不了解路由值失效,通常就会导致对返回 nullLinkGeneratorIUrlHelper 执行调用。 显式指定更多路由值,对路由值失效进行故障排除,以查看是否解决了问题。

路由值失效的前提是应用的 URL 方案是分层的,并按从左到右的顺序组成层次结构。 请考虑基本控制器路由模板 {controller}/{action}/{id?},以直观地了解实践操作中该模板的工作方式。 对值进行更改会使右侧显示的所有路由值都失效 。 这反映了关于层次结构的假设。 如果应用的 id 有一个环境值,则操作会为 controller 指定一个不同的值:

  • id 不会重复使用,因为 {controller}{id?} 的左侧。

演示此原则的一些示例如下:

  • 如果显式值包含 id 的值,则将忽略 id 的环境值。 可以使用 controlleraction 的环境值。
  • 如果显式值包含 action 的值,则将忽略 action 的任何环境值。 可以使用 controller 的环境值。 如果 action 的显式值不同于 action 的环境值,则不会使用 id 值。 如果 action 的显式值与 action 的环境值相同,则可以使用 id 值。
  • 如果显式值包含 controller 的值,则将忽略 controller 的任何环境值。 如果 controller 的显式值不同于 controller 的环境值,则不会使用 actionid 值。 如果 controller 的显式值与 controller 的环境值相同,则可以使用 actionid 值。

由于存在特性路由和专用传统路由,此过程将变得更加复杂。 控制器传统路由(例如 {controller}/{action}/{id?})使用路由参数指定层次结构。 对于控制器和 Razor Pages 的专用传统路由特性路由

  • 有一个路由值层次结构。
  • 它们不会出现在模板中。

对于这些情况,URL 生成将定义“所需值”这一概念。 而由控制器和 Razor Pages 创建的终结点将指定所需值,以允许路由值失效起作用。

路由值失效算法的详细信息:

  • 所需值名称与路由参数组合在一起,然后从左到右进行处理。
  • 对于每个参数,将比较环境值和显式值:
    • 如果环境值和显式值相同,则该过程将继续。
    • 如果环境值存在而显式值不存在,则在生成 URL 时使用环境值。
    • 如果环境值不存在而显式值存在,则拒绝环境值和所有后续环境值。
    • 如果环境值和显式值均存在,并且这两个值不同,则拒绝环境值和所有后续环境值。

此时,URL 生成操作就可以计算路由约束了。 接受的值集与提供给约束的参数默认值相结合。 如果所有约束都通过,则操作将继续。

接下来,接受的值可用于扩展路由模板。 处理路由模板:

  • 从左到右。
  • 每个参数都替换了接受的值。
  • 具有以下特殊情况:
    • 如果接受的值缺少一个值并且参数具有默认值,则使用默认值。
    • 如果接受的值缺少一个值并且参数是可选的,则继续处理。
    • 如果缺少的可选参数右侧的任何路由参数都具有值,则操作将失败。
    • 如果可能,连续的默认值参数和可选参数会折叠。

显式提供且与路由片段不匹配的值将添加到查询字符串中。 下表显示使用路由模板 {controller}/{action}/{id?} 时的结果。

环境值 显式值 结果
控制器 =“Home” 操作 =“About” /Home/About
控制器 =“Home” 控制器 =“Order”,操作 =“About” /Order/About
控制器 =“Home”,颜色 =“Red” 操作 =“About” /Home/About
控制器 =“Home” 操作 =“About”,颜色 =“Red” /Home/About?color=Red

路由值失效的问题

下面的代码显示了路由不支持的 URL 生成方案示例:

app.MapControllerRoute(
    "default",
    "{culture}/{controller=Home}/{action=Index}/{id?}");

app.MapControllerRoute(
    "blog",
    "{culture}/{**slug}",
    new { controller = "Blog", action = "ReadPost" });

在前面的代码中,culture 路由参数用于本地化。 需要将 culture 参数始终作为环境值接受。 但由于所需值的工作方式,不会将 culture 参数作为环境值接受:

  • "default" 路由模板中,culture 路由参数位于 controller 的左侧,因此对 controller 的更改不会使 culture 失效。
  • "blog" 路由模板中,culture 路由参数应在 controller 的右侧,这会显示在所需值中。

使用 LinkParser 解析 URL 路径

LinkParser 类添加了对将 URL 路径解析为一组路由值的支持。 ParsePathByEndpointName 方法采用终结点名称和 URL 路径,并返回从 URL 路径中提取的一组路由值。

在以下示例控制器中,GetProduct 操作使用 api/Products/{id} 的路由模板,并且 NameGetProduct

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet("{id}", Name = nameof(GetProduct))]
    public IActionResult GetProduct(string id)
    {
        // ...

在同一个控制器类中,AddRelatedProduct 操作需要一个 URL 路径 pathToRelatedProduct,该路径可作为查询字符串参数提供:

[HttpPost("{id}/Related")]
public IActionResult AddRelatedProduct(
    string id, string pathToRelatedProduct, [FromServices] LinkParser linkParser)
{
    var routeValues = linkParser.ParsePathByEndpointName(
        nameof(GetProduct), pathToRelatedProduct);
    var relatedProductId = routeValues?["id"];

    // ...

在前面的示例中,AddRelatedProduct 操作从 URL 路径中提取 id 路由值。 例如,URL 路径为 /api/Products/1 时,relatedProductId 值设置为 1。 此方法允许 API 的客户端在引用资源时使用 URL 路径,而无需了解此类 URL 的结构。

配置终结点元数据

以下链接提供有关如何配置终结点元数据的信息:

路由中与 RequireHost 匹配的主机

RequireHost 将约束应用于需要指定主机的路由。 RequireHostRequireHost 参数可以是:

  • 主机:www.domain.com匹配任何端口的 www.domain.com
  • 带有通配符的主机:*.domain.com,匹配任何端口上的 www.domain.comsubdomain.domain.comwww.subdomain.domain.com
  • 端口:*:5000 匹配任何主机的端口 5000。
  • 主机和端口:www.domain.com:5000*.domain.com:5000(匹配主机和端口)。

可以使用 RequireHost[Host] 指定多个参数。 约束匹配对任何参数均有效的主机。 例如,[Host("domain.com", "*.domain.com")] 匹配 domain.comwww.domain.comsubdomain.domain.com

以下代码使用 RequireHost 来要求路由上的指定主机:

app.MapGet("/", () => "Contoso").RequireHost("contoso.com");
app.MapGet("/", () => "AdventureWorks").RequireHost("adventure-works.com");

app.MapHealthChecks("/healthz").RequireHost("*:8080");

以下代码使用控制器上的 [Host] 特性来要求任何指定的主机:

[Host("contoso.com", "adventure-works.com")]
public class HostsController : Controller
{
    public IActionResult Index() =>
        View();

    [Host("example.com")]
    public IActionResult Example() =>
        View();
}

[Host] 属性同时应用于控制器和操作方法时:

  • 使用操作上的属性。
  • 忽略控制器属性。

路由的性能指南

当应用出现性能问题时,人们常常会怀疑路由是问题所在。 怀疑路由的原因是,控制器和 Razor Pages 等框架在其日志记录消息中报告框架内所用的时间。 如果控制器报告的时间与请求的总时间之间存在明显的差异:

  • 开发者会将应用代码排除在问题根源之外。
  • 他们通常认为路由是问题的原因。

路由的性能使用数千个终结点进行测试。 典型的应用不太可能仅仅因为太大而遇到性能问题。 路由性能缓慢的最常见根本原因通常在于性能不佳的自定义中间件。

下面的代码示例演示了一种用于缩小延迟源的基本方法:

var logger = app.Services.GetRequiredService<ILogger<Program>>();

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

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

app.UseRouting();

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

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

app.UseAuthorization();

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

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

app.MapGet("/", () => "Timing Test.");

时间路由:

  • 使用前面代码中所示的计时中间件的副本交错执行每个中间件。
  • 添加唯一标识符,以便将计时数据与代码相关联。

这是一种可在延迟显著的情况下减少延迟的基本方法,例如超过 10ms。 从 Time 1 中减去 Time 2 会报告 UseRouting 中间件内所用的时间。

下面的代码使用一种更紧凑的方法来处理前面的计时代码:

public sealed class AutoStopwatch : IDisposable
{
    private readonly ILogger _logger;
    private readonly string _message;
    private readonly Stopwatch _stopwatch;
    private bool _disposed;

    public AutoStopwatch(ILogger logger, string message) =>
        (_logger, _message, _stopwatch) = (logger, message, Stopwatch.StartNew());

    public void Dispose()
    {
        if (_disposed)
        {
            return;
        }

        _logger.LogInformation("{Message}: {ElapsedMilliseconds}ms",
            _message, _stopwatch.ElapsedMilliseconds);

        _disposed = true;
    }
}
var logger = app.Services.GetRequiredService<ILogger<Program>>();
var timerCount = 0;

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

app.UseRouting();

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

app.UseAuthorization();

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

app.MapGet("/", () => "Timing Test.");

可能比较昂贵的路由功能

下面的列表提供了一些路由功能,这些功能相对于基本路由模板来说比较昂贵:

  • 正则表达式:可以编写复杂的正则表达式,或具有少量输入的长时间运行时间。
  • 复杂段 ({x}-{y}-{z}):
    • 比分析常规 URL 路径段贵得多。
    • 导致更多的子字符串被分配。
  • 同步数据访问:许多复杂应用都将数据库访问作为其路由的一部分。 使用异步的扩展点,如 MatcherPolicyEndpointSelectorContext

大型路由表指南

默认情况下,ASP.NET Core 使用通过内存换来 CPU 时间的路由算法。 这样可以达到良好的效果,即路由匹配时间仅依赖于要匹配的路径的长度,而不依赖于路由数。 但是,在某些情况下,当应用具有大量(数千个)路由且路由中具有大量变量前缀时,此方法可能存在问题。 例如,如果路由在路由的前几段中具有参数,例如 {parameter}/some/literal

应用不太可能出现此问题,除非:

  • 使用此模式的应用中具有大量路由。
  • 应用中具有大量路由。

如何确定应用是否出现大型路由表问题

  • 需要查找两个症状:
    • 发出第一个请求时,应用启动速度缓慢。
      • 请注意,这是必需的,但不够。 还有许多其他非路由问题可能会导致应用启动速度缓慢。 检查以下条件,准确地确定应用是否出现这种情况。
    • 应用在启动期间消耗大量内存,内存转储显示大量 Microsoft.AspNetCore.Routing.Matching.DfaNode 实例。

如何解决此问题

可以将多种技术和优化应用于可大大改善此方案的路由:

  • 如果可能,请将路由约束应用于参数,如 {parameter:int}{parameter:guid}{parameter:regex(\\d+)}
    • 这样,路由算法可以在内部优化用于匹配的结构,并显著减少使用的内存。
    • 在大多数情况下,这足以恢复到可接受的行为。
  • 更改路由以将参数移动到模板中的后几段。
    • 这样可以减少与给定路径的终结点匹配的可能“路径”数。
  • 使用动态路由并动态执行到控制器/页面的映射。
    • 可以使用 MapDynamicControllerRouteMapDynamicPageRoute 实现此操作。

库创建者指南

本部分包含基于路由构建的库创建者指南。 这些详细信息旨在确保应用开发者可以在使用扩展路由的库和框架时具有良好的体验。

定义终结点

若要创建一个使用路由进行 URL 匹配的框架,请先定义在 UseEndpoints 之上进行构建的用户体验。

之上进行构建。 由此,用户就可以用其他 ASP.NET Core 功能编写框架,而不会造成混淆。 每个 ASP.NET Core 模板都包含路由。 假定路由存在并且用户熟悉路由。

// Your framework
app.MapMyFramework(...);

app.MapHealthChecks("/healthz");

从对实现 IEndpointConventionBuilder 的调用返回密式具体类型。 大多数框架 Map... 方法都遵循此模式。 IEndpointConventionBuilder 接口:

  • 允许对元数据进行组合。
  • 面向多种扩展方法。

通过声明自己的类型,你可以将自己的框架特定功能添加到生成器中。 可以包装一个框架声明的生成器并向其转发调用。

// Your framework
app.MapMyFramework(...)
    .RequireAuthorization()
    .WithMyFrameworkFeature(awesome: true);

app.MapHealthChecks("/healthz");

请考虑编写自己的 EndpointDataSource 是用于声明和更新终结点集合的低级别基元。 EndpointDataSource 是控制器和 Razor Pages 使用的强大 API。

路由测试具有非更新数据源的基本示例

默认情况下,请不要尝试注册 。 要求用户在 UseEndpoints 中注册你的框架。 路由的理念是,默认情况下不包含任何内容,并且 UseEndpoints 是注册终结点的位置。

创建路由集成式中间件

请考虑将元数据类型定义为接口。

请实现在类和方法上使用元数据类型。

public interface ICoolMetadata
{
    bool IsCool { get; }
}

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

控制器和 Razor Pages 等框架支持将元数据特性应用到类型和方法。 如果声明元数据类型:

  • 将它们作为特性进行访问。
  • 大多数用户都熟悉应用特性。

将元数据类型声明为接口可增加另一层灵活性:

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

请实现元数据替代,如以下示例中所示:

[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() { }
}

遵循这些准则的最佳方式是避免定义标记元数据:

  • 不要只是查找元数据类型的状态。
  • 定义元数据的属性并检查该属性。

对元数据集合进行排序,并支持按优先级替代。 对于控制器,操作方法上的元数据是最具体的。

使中间件始终有用,不管有没有路由:

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

// Your framework
app.MapMyFramework(...).RequireAuthorization();

作为此准则的一个示例,请考虑 UseAuthorization 中间件。 授权中间件允许传入回退策略。 如果指定了回退策略,则适用于以下两者:

  • 无指定策略的终结点。
  • 与终结点不匹配的请求。

这使得授权中间件在路由上下文之外很有用。 授权中间件可用于传统中间件的编程。

调试诊断

要获取详细的路由诊断输出,请将 Logging:LogLevel:Microsoft 设置为 Debug。 在开发环境中,在 appsettings.Development.json 中设置日志级别:

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

其他资源

路由负责匹配传入的 HTTP 请求,然后将这些请求发送到应用的可执行终结点。 终结点是应用的可执行请求处理代码单元。 终结点在应用中进行定义,并在应用启动时进行配置。 终结点匹配过程可以从请求的 URL 中提取值,并为请求处理提供这些值。 通过使用应用中的终结点信息,路由还能生成映射到终结点的 URL。

应用可以使用以下内容配置路由:

  • Controllers
  • Razor Pages
  • SignalR
  • gRPC 服务
  • 启用终结点的中间件,例如运行状况检查
  • 通过路由注册的委托和 Lambda。

本文档介绍 ASP.NET Core 路由的较低级别详细信息。 有关配置路由的信息,请参阅:

本文档中所述的终结点路由系统适用于 ASP.NET Core 3.0 及更高版本。 有关以前基于 IRouter 的路由系统信息,请使用以下方法之一选择 ASP.NET Core 2.1 版本:

查看或下载示例代码如何下载

此文档的下载示例由特定 Startup 类启用。 若要运行特定的示例,请修改 Program.cs,以便调用所需的 Startup 类。

路由基础知识

所有 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!");
        });
    });
}

路由使用一对由 UseRoutingUseEndpoints 注册的中间件:

  • UseRouting 向中间件管道添加路由匹配。 此中间件会查看应用中定义的终结点集,并根据请求选择最佳匹配
  • UseEndpoints 向中间件管道添加终结点执行。 它会运行与所选终结点关联的委托。

前面的示例包含使用 MapGet 方法的单一路由到代码终结点:

  • 当 HTTP GET 请求发送到根 URL / 时:
    • 将执行显示的请求委托。
    • Hello World! 会写入 HTTP 响应。 默认情况下,根 URL /https://localhost:5001/
  • 如果请求方法不是 GET 或根 URL 不是 /,则无路由匹配,并返回 HTTP 404。

终结点

MapGet 方法用于定义终结点。 终结点可以:

  • 通过匹配 URL 和 HTTP 方法来选择。
  • 通过运行委托来执行。

可通过应用匹配和执行的终结点在 UseEndpoints 中进行配置。 例如,MapGetMapPostMapGet将请求委托连接到路由系统。 其他方法可用于将 ASP.NET Core 框架功能连接到路由系统:

下面的示例演示如何使用更复杂的路由模板进行路由:

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} 字符串是一个路由模板。 用于配置终结点的匹配方式。 在这种情况下,模板将匹配:

  • 类似 /hello/Ryan 的 URL
  • /hello/ 开头、后跟一系列字母字符的任何 URL 路径。 :alpha 应用仅匹配字母字符的路由约束。 路由约束将在本文档的后面详细介绍。

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

本文档中所述的终结点路由系统是新版本 ASP.NET Core 3.0。 但是,所有版本的 ASP.NET Core 都支持相同的路由模板功能和路由约束。

下面的示例演示如何通过运行状况检查和授权进行路由:

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!");
        });
    });
}

若要查看翻译为非英语语言的代码注释,请在 此 GitHub 讨论问题中告诉我们。

前面的示例展示了如何:

  • 将授权中间件与路由一起使用。
  • 将终结点用于配置授权行为。

MapHealthChecks 调用添加运行状况检查终结点。 将 RequireAuthorization 链接到此调用会将授权策略附加到该终结点。

调用 UseAuthenticationUseAuthorization 会添加身份验证和授权中间件。 这些中间件位于 UseRoutingUseEndpoints 之间,因此它们可以:

  • 查看 UseRouting 选择的终结点。
  • UseEndpoints 发送到终结点之前应用授权策略。

终结点元数据

前面的示例中有两个终结点,但只有运行状况检查终结点附加了授权策略。 如果请求与运行状况检查终结点 /healthz 匹配,则执行授权检查。 这表明,终结点可以附加额外的数据。 此额外数据称为终结点元数据:

  • 可以通过路由感知中间件来处理元数据。
  • 元数据可以是任意的 .NET 类型。

路由概念

路由系统通过添加功能强大的终结点概念,构建在中间件管道之上。 终结点代表应用的功能单元,在路由、授权和任意数量的 ASP.NET Core 系统方面彼此不同。

ASP.NET Core 终结点定义

ASP.NET Core 终结点是:

以下代码显示了如何检索和检查与当前请求匹配的终结点:

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!");
        });
    });
}

如果选择了终结点,可从 HttpContext 中进行检索。 可以检查其属性。 终结点对象是不可变的,并且在创建后无法修改。 最常见的终结点类型是 RouteEndpointRouteEndpoint 包括允许自己被路由系统选择的信息。

在前面的代码中,app.Use 配置了一个内联中间件

下面的代码显示,根据管道中调用 app.Use 的位置,可能不存在终结点:

// 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 语句,这些语句显示是否已选择终结点。 为清楚起见,该示例将显示名称分配给提供的 / 终结点。

使用 / URL 运行此代码将显示:

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

使用任何其他 URL 运行此代码将显示:

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

此输出说明:

  • 调用 UseRouting 之前,终结点始终为 null。
  • 如果找到匹配项,则 UseRoutingUseEndpoints 之间的终结点为非 null。
  • 如果找到匹配项,则 UseEndpoints 中间件即为终端。 稍后会在本文档中定义终端中间件
  • 仅当找不到匹配项时才执行 UseEndpoints 后的中间件。

UseRouting 中间件使用 UseRouting 方法将终结点附加到当前上下文。 可以将 UseRouting 中间件替换为自定义逻辑,同时仍可获得使用终结点的益处。 终结点是中间件等低级别基元,不与路由实现耦合。 大多数应用都不需要将 UseRouting 替换为自定义逻辑。

UseEndpoints 中间件旨在与 UseRouting 中间件配合使用。 执行终结点的核心逻辑并不复杂。 使用 GetEndpoint 检索终结点,然后调用其 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<AuditPolicyAttribute>()?.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 之前运行,以修改路由操作的数据。
  • 中间件可以在 UseRoutingUseEndpoints 之间运行,以便在执行终结点前处理路由结果。
    • UseRoutingUseEndpoints 之间运行的中间件:
      • 通常会检查元数据以了解终结点。
      • 通常会根据 UseAuthorizationUseCors 做出安全决策。
    • 中间件和元数据的组合允许按终结点配置策略。

前面的代码显示了支持按终结点策略的自定义中间件示例。 中间件将访问敏感数据的审核日志写入控制台。 可以将中间件配置为审核具有 元数据的终结点。 此示例演示选择加入模式,其中仅审核标记为敏感的终结点。 例如,可以反向定义此逻辑,从而审核未标记为安全的所有内容。 终结点元数据系统非常灵活。 此逻辑可以以任何适合用例的方法进行设计。

前面的示例代码旨在演示终结点的基本概念。 该示例不应在生产环境中使用。 审核日志中间件的更完整版本如下:

  • 记录到文件或数据库。
  • 包括详细信息,如用户、IP 地址、敏感终结点的名称等。

审核策略元数据 AuditPolicyAttribute 定义为一个 Attribute,便于和基于类的框架(如控制器和 SignalR)结合使用。 使用路由到代码时:

  • 元数据附有生成器 API。
  • 基于类的框架在创建终结点时,包含了相应方法和类的所有特性。

对于元数据类型,最佳做法是将它们定义为接口或特性。 接口和特性允许代码重用。 元数据系统非常灵活,无任何限制。

比较终端中间件和路由

下面的代码示例对比使用中间件和使用路由:

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!");
        });
    });
}

使用 Approach 1: 显示的中间件样式是终端中间件。 之所以称之为终端中间件,是因为它执行匹配的操作:

  • 前面示例中的匹配操作是用于中间件的 Path == "/" 和用于路由的 Path == "/Movie"
  • 如果匹配成功,它将执行一些功能并返回,而不是调用 next 中间件。

之所以称之为终端中间件,是因为它会终止搜索,执行一些功能,然后返回。

比较终端中间件和路由:

  • 这两种方法都允许终止处理管道:
    • 中间件通过返回而不是调用 next 来终止管道。
    • 终结点始终是终端。
  • 终端中间件允许在管道中的任意位置放置中间件:
  • 终端中间件允许任意代码确定中间件匹配的时间:
    • 自定义路由匹配代码可能比较复杂,且难以正确编写。
    • 路由为典型应用提供了简单的解决方案。 大多数应用不需要自定义路由匹配代码。
  • 带有中间件的终结点接口,如 UseAuthorizationUseCors
    • 通过 UseAuthorizationUseCors 使用终端中间件需要与授权系统进行手动交互。

终结点定义以下两者:

  • 用于处理请求的委托。
  • 任意元数据的集合。 元数据用于实现横切关注点,该实现基于附加到每个终结点的策略和配置。

终端中间件可以是一种有效的工具,但可能需要:

  • 大量的编码和测试。
  • 手动与其他系统集成,以实现所需的灵活性级别。

请考虑在写入终端中间件之前与路由集成。

MapMapWhen 相集成的现有终端中间件通常会转换为路由感知终结点。 MapHealthChecks 演示了路由器软件的模式:

下面的代码演示了 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!");
        });
    });
}

前面的示例说明了为什么返回生成器对象很重要。 返回生成器对象后,应用开发者可以配置策略,如终结点的授权。 在此示例中,运行状况检查中间件不与授权系统直接集成。

元数据系统的创建目的是为了响应扩展性创建者使用终端中间件时遇到的问题。 对于每个中间件,实现自己与授权系统的集成都会出现问题。

URL 匹配

  • 是路由将传入请求匹配到终结点的过程。
  • 基于 URL 路径中的数据和标头。
  • 可进行扩展,以考虑请求中的任何数据。

当路由中间件执行时,它会设置 Endpoint,并将值从当前请求路由到 HttpContext 上的Endpoint

  • 调用 GetEndpoint 获取终结点。
  • HttpRequest.RouteValues 将获取路由值的集合。

中间件在路由中间件可以检查终结点并采取措施之后运行。 例如,授权中间件可以在终结点的元数据集合中询问授权策略。 请求处理管道中的所有中间件执行后,将调用所选终结点的委托。

终结点路由中的路由系统负责所有的调度决策。 中间件基于所选终结点应用策略,因此重要的是:

  • 任何可能影响发送或安全策略应用的决定都应在路由系统中做出。

警告

对于后向兼容性,执行 Controller 或 Razor Pages 终结点委托时,会根据迄今执行的请求处理将 RouteContext.RouteData 的属性设为适当的值。

在未来的版本中,会将 RouteContext 类型标记为已过时:

  • RouteData.Values 迁移到 HttpRequest.RouteValues
  • 迁移 RouteData.DataTokens 以从终结点元数据检索 RouteData.DataTokens

URL 匹配在可配置的阶段集中运行。 在每个阶段中,输出为一组匹配项。 下一阶段可以进一步缩小这一组匹配项。 路由实现不保证匹配终结点的处理顺序。 所有可能的匹配项一次性处理。 URL 匹配阶段按以下顺序出现。 ASP.NET Core:

  1. 针对终结点集及其路由模板处理 URL 路径,收集所有匹配项。
  2. 采用前面的列表并删除在应用路由约束时失败的匹配项。
  3. 采用前面的列表并删除 MatcherPolicy 实例集失败的匹配项。
  4. 使用 EndpointSelector 从前面的列表中做出最终决定。

根据以下内容设置终结点列表的优先级:

在每个阶段中处理所有匹配的终结点,直到达到 EndpointSelectorEndpointSelector 是最后一个阶段。 它从匹配项中选择最高优先级终结点作为最佳匹配项。 如果存在具有与最佳匹配相同优先级的其他匹配项,则会引发不明确的匹配异常。

路由优先顺序基于更具体的路由模板(优先级更高)进行计算。 例如,考虑模板 /hello/{message}

  • 两者都匹配 URL 路径 /hello
  • /hello 更具体,因此优先级更高。

通常,路由优先顺序非常适合为实践操作中使用的各种 URL 方案选择最佳匹配项。 仅在必要时才使用 Order 来避免多义性。

由于路由提供的扩展性种类,路由系统无法提前计算不明确的路由。 假设有一个示例,例如路由模板 /{message:alpha}/{message:int}

  • alpha 约束仅匹配字母数字字符。
  • int 约束仅匹配数字。
  • 这些模板具有相同的路由优先顺序,但没有两者均匹配的单一 URL。
  • 如果路由系统在启动时报告了多义性错误,则会阻止此有效用例。

警告

UseEndpoints 内的操作顺序并不会影响路由行为,但有一个例外。 MapControllerRouteMapAreaRoute 会根据调用顺序,自动将顺序值分配给其终结点。 这会模拟控制器的长时间行为,而无需路由系统提供与早期路由实现相同的保证。

在路由的早期实现中,可以实现具有依赖于路由处理顺序的路由扩展性。 ASP.NET Core 3.0 及更高版本中的终结点路由:

  • 没有路由的概念。
  • 不提供顺序保证。 同时处理所有终结点。

路由模板优先顺序和终结点选择顺序

路由模板优先顺序是一种系统,该系统根据每个路由模板的具体程度为其分配值。 路由模板优先顺序:

  • 无需在常见情况下调整终结点的顺序。
  • 尝试匹配路由行为的常识性预期。

例如,考虑模板 /Products/List/Products/{id}。 我们可合理地假设,对于 URL 路径 /Products/List/Products/List 匹配项比 /Products/{id} 更好。 这种假设合理是因为文本段 /List 比参数段 /{id} 具有更好的优先顺序。

优先顺序工作原理的详细信息与路由模板的定义方式相耦合:

  • 具有更多段的模板则更具体。
  • 带有文本的段比参数段更具体。
  • 具有约束的参数段比没有约束的参数段更具体。
  • 复杂段与具有约束的参数段同样具体。
  • catch-all 参数是最不具体的参数。 有关 catch-all 路由的重要信息,请参阅 路由模板参考 中的“catch-all”。

有关确切值的参考,请参阅 GitHub 上的源代码

URL 生成概念

URL 生成:

  • 是指路由基于一系列路由值创建 URL 路径的过程。
  • 允许终结点与访问它们的 URL 之间存在逻辑分隔。

终结点路由包含 LinkGenerator API。 LinkGeneratorLinkGenerator 中可用的单一实例服务。 LinkGenerator API 可在执行请求的上下文之外使用。 Mvc.IUrlHelper 和依赖 IUrlHelper 的方案(如标记帮助程序、HTML 帮助程序和操作结果)在内部使用 LinkGenerator API 提供链接生成功能。

链接生成器基于“地址”和“地址方案”概念 。 地址方案是确定哪些终结点用于链接生成的方式。 例如,许多用户熟悉的来自控制器或 Razor Pages 的路由名称和路由值方案都是作为地址方案实现的。

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

这些方法的重载接受包含 HttpContext 的参数。 这些方法在功能上等同于 Url.ActionUrl.Page,但提供了更大的灵活性和更多选项。

GetPath* 方法与 Url.ActionUrl.Page 最相似,因为它们生成包含绝对路径的 URI。 GetUri* 方法始终生成包含方案和主机的绝对 URI。 接受 HttpContext 的方法在执行请求的上下文中生成 URI。 除非重写,否则将使用来自执行请求的环境路由值、URL 基路径、方案和主机。

使用地址调用 LinkGenerator。 生成 URI 的过程分两步进行:

  1. 将地址绑定到与地址匹配的终结点列表。
  2. 计算每个终结点的 RoutePattern,直到找到与提供的值匹配的路由模式。 输出结果会与提供给链接生成器的其他 URI 部分进行组合并返回。

对任何类型的地址,LinkGenerator 提供的方法均支持标准链接生成功能。 使用链接生成器的最简便方法是通过扩展方法对特定地址类型执行操作:

扩展方法 描述
GetPathByAddress 根据提供的值生成具有绝对路径的 URI。
GetUriByAddress 根据提供的值生成绝对 URI。

警告

请注意有关调用 LinkGenerator 方法的下列含义:

  • 对于不验证传入请求的 Host 标头的应用配置,请谨慎使用 GetUri* 扩展方法。 如果未验证传入请求的 Host 标头,则可能以视图或页面中 URI 的形式将不受信任的请求输入发送回客户端。 建议所有生产应用都将其服务器配置为针对已知有效值验证 Host 标头。

  • 在中间件中将 LinkGeneratorMapMapWhen 结合使用时,请小心谨慎。 Map* 会更改执行请求的基路径,这会影响链接生成的输出。 所有 LinkGenerator API 都允许指定基路径。 指定一个空的基路径来撤消 Map* 对链接生成的影响。

中间件示例

在以下示例中,中间件使用 LinkGenerator API 创建列出存储产品的操作方法的链接。 应用中的任何类都可通过将链接生成器注入类并调用 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 路径已解码的表示形式。 要匹配文字路由参数分隔符({}),请通过重复该字符来转义分隔符。 例如 {{}}

星号 * 或双星号 **

  • 可用作路由参数的前缀,以绑定到 URI 的 rest。
  • 称为 catch-all 参数。 例如,blog/{**slug}
    • 匹配以 /blog 开头并在其后面包含任何值的任何 URI。
    • /blog 后的值分配给 /blog 路由值。

警告

由于路由中的 bugcatch-all 参数可能无法正确匹配相应路由。 受此 Bug 影响的应用具有以下特征:

  • “全部捕获”路由,例如 {**slug}"
  • “全部捕获”路由未能匹配应与之匹配的请求。
  • 删除其他路由可使“全部捕获”路由开始运行。

请参阅 GitHub bug 1867716579,了解遇到此 bug 的示例。

.NET Core 3.1.301 SDK 及更高版本中包含此 bug 的修补程序(可选用)。 以下代码设置了一个可修复此 bug 的内部开关:

public static void Main(string[] args)
{
   AppContext.SetSwitch("Microsoft.AspNetCore.Routing.UseCorrectCatchAllBehavior", 
                         true);
   CreateHostBuilder(args).Build().Run();
}
// Remaining code removed for brevity.

全方位参数还可以匹配空字符串。

使用路由生成 URL(包括路径分隔符 /)时,catch-all 参数会转义相应的字符。 例如,路由值为 { path = "my/path" } 的路由 foo/{*path} 生成 foo/my%2Fpath。 请注意转义的正斜杠。 要往返路径分隔符,请使用 ** 路由参数前缀。 { path = "my/path" } 的路由 foo/{**path} 生成 foo/my/path

尝试捕获具有可选文件扩展名的文件名的 URL 模式还有其他注意事项。 例如,考虑模板 files/{filename}.{ext?}。 当 filenameext 的值都存在时,将填充这两个值。 如果 URL 中仅存在 filename 的值,则路由匹配,因为尾随 . 是可选的。 以下 URL 与此路由相匹配:

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

路由参数可能具有指定的默认值,方法是在参数名称后使用等号 () 隔开以指定默认值。 例如,{controller=Home}Home 定义为 controller 的默认值。 如果参数的 URL 中不存在任何值,则使用默认值。 通过在参数名称的末尾附加问号 (?) 可使路由参数成为可选项。 例如 id?。 可选值和默认路由参数之间的差异是:

  • 具有默认值的路由参数始终生成一个值。
  • 仅当请求 URL 提供值时,可选参数才具有值。

路由参数可能具有必须与从 URL 中绑定的路由值匹配的约束。 在路由参数后面添加一个 : 和约束名称可指定路由参数上的内联约束。 如果约束需要参数,将以在约束名称后括在括号 (...) 中的形式提供。 通过追加另一个 和约束名称,可指定多个内联约束。

约束名称和参数将传递给 IInlineConstraintResolver 服务,以创建 IRouteConstraint 的实例,用于处理 URL。 例如,路由模板 blog/{article:minlength(10)} 使用参数 10 指定 minlength 约束。 有关路由约束详情以及框架提供的约束列表,请参阅路由约束引用部分。

路由参数还可以具有参数转换器。 参数转换器在生成链接以及将操作和页面匹配到 URI 时转换参数的值。 与约束类似,可在路由参数名称后面添加 : 和转换器名称,将参数变换器内联添加到路径参数。 例如,路由模板 blog/{article:slugify} 指定 slugify 转换器。 有关参数转换的详细信息,请参阅参数转换器参考部分。

下表演示了示例路由模板及其行为:

路由模板 示例匹配 URI 请求 URI…
hello /hello 仅匹配单个路径 /hello
{Page=Home} / 匹配并将 Page 设置为 Home
{Page=Home} /Contact 匹配并将 Page 设置为 Contact
{controller}/{action}/{id?} /Products/List 映射到 Products 控制器和 List 操作。
{controller}/{action}/{id?} /Products/Details/123 映射到 Products 控制器和 Details 操作,并将 id 设置为 123。
{controller=Home}/{action=Index}/{id?} / 映射到 Home 控制器和 Index 方法。 id 将被忽略。
{controller=Home}/{action=Index}/{id?} /Products 映射到 Products 控制器和 Index 方法。 id 将被忽略。

使用模板通常是进行路由最简单的方法。 还可在路由模板外指定约束和默认值。

复杂段

复杂段通过非贪婪的方式从右到左匹配文字进行处理。 例如,[Route("/a{b}c{d}")] 是一个复杂段。 复杂段以一种特定的方式工作,必须理解这种方式才能成功地使用它们。 本部分中的示例演示了为什么复杂段只有在分隔符文本没有出现在参数值中时才真正有效。 对于更复杂的情况,需要使用 regex,然后手动提取值。

警告

如果使用 System.Text.RegularExpressions 处理不受信任的输入,则传递一个超时。 恶意用户可能会向 RegularExpressions 提供输入,从而导致拒绝服务攻击。 使用 RegularExpressions 的 ASP.NET Core 框架 API 会传递一个超时。

这是使用模板 /a{b}c{d} 和 URL 路径 /abcd 执行路由的步骤摘要。 | 有助于可视化算法的工作方式:

  • 从右到左的第一个文本是 c。 因此从右侧搜索 /abcd,并查找 /ab|c|d
  • 右侧的所有内容 (d) 现在都与路由参数 {d} 相匹配。
  • 从右到左的下一个文本是 a。 从我们停止的地方开始搜索 /ab|c|d,然后 /|a|b|c|d 找到 a
  • 右侧的值 (b) 现在与路由参数 {b} 相匹配。
  • 没有剩余的文本,并且没有剩余的路由模板,因此这是一个匹配项。

下面是使用相同模板 /a{b}c{d} 和 URL 路径 /aabcd 的负面案例示例。 | 有助于可视化算法的工作方式。 该案例不是匹配项,可由相同算法解释:

  • 从右到左的第一个文本是 c。 因此从右侧搜索 /aabcd,并查找 /aab|c|d
  • 右侧的所有内容 (d) 现在都与路由参数 {d} 相匹配。
  • 从右到左的下一个文本是 a。 从我们停止的地方开始搜索 /aab|c|d,然后 /a|a|b|c|d 找到 a
  • 右侧的值 (b) 现在与路由参数 {b} 相匹配。
  • 此时还有剩余的文本 a,但是算法已经耗尽了要解析的路由模板,所以这不是一个匹配项。

匹配算法是非贪婪算法:

  • 它匹配每个步骤中最小可能文本量。
  • 如果分隔符值出现在参数值内,则会导致不匹配。

正则表达式可以更好地控制它们的匹配行为。

贪婪匹配(也称为懒惰匹配)匹配最大可能字符串。 非贪婪匹配最小可能字符串。

路由约束参考

路由约束在传入 URL 发生匹配时执行,URL 路径标记为路由值。 路径约束通常检查通过路径模板关联的路径值,并对该值是否为可接受做出对/错决定。 某些路由约束使用路由值以外的数据来考虑是否可以路由请求。 例如,HttpMethodRouteConstraint 可以根据其 HTTP 谓词接受或拒绝请求。 约束用于路由请求和链接生成。

警告

请勿将约束用于输入验证。 如果约束用于输入验证,则无效的输入将导致 404(找不到页面)响应。 无效输入可能生成包含相应错误消息的 400 错误请求。 路由约束用于消除类似路由的歧义,而不是验证特定路由的输入。

下表演示示例路由约束及其预期行为:

约束 示例 匹配项示例 说明
int {id:int} 123456789-123456789 匹配任何整数
bool {active:bool} trueFALSE 匹配 truefalse。 不区分大小写
datetime {dob:datetime} 2016-12-312016-12-31 7:32pm 在固定区域性中匹配有效的 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 处理不受信任的输入,则传递一个超时。 恶意用户可能会向 RegularExpressions 提供输入,从而导致拒绝服务攻击。 使用 RegularExpressions 的 ASP.NET Core 框架 API 会传递一个超时。

可向单个参数应用多个用冒号分隔的约束。 例如,以下约束将参数限制为大于或等于 1 的整数值:

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

警告

验证 URL 的路由约束并将转换为始终使用固定区域性的 CLR 类型。 例如,转换为 CLR 类型 intDateTime。 这些约束假定 URL 不可本地化。 框架提供的路由约束不会修改存储于路由值中的值。 从 URL 中分析的所有路由值都将存储为字符串。 例如,float 约束会尝试将路由值转换为浮点数,但转换后的值仅用来验证其是否可转换为浮点数。

约束中的正则表达式

警告

如果使用 System.Text.RegularExpressions 处理不受信任的输入,则传递一个超时。 恶意用户可能会向 RegularExpressions 提供输入,从而导致拒绝服务攻击。 使用 RegularExpressions 的 ASP.NET Core 框架 API 会传递一个超时。

使用 regex(...) 路由约束可以将正则表达式指定为内联约束。 MapControllerRoute 系列中的方法还接受约束的对象文字。 如果使用该窗体,则字符串值将解释为正则表达式。

下面的代码使用内联 regex 约束:

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

下面的代码使用对象文字来指定 regex 约束:

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# 源文件中的 \\ 字符,以便对 \ 字符串转义字符进行转义。
  • 逐字字符串文本

要对路由参数分隔符 {}[] 进行转义,请将表达式(例如 {{}}[[]])中的字符数加倍。 下表展示了正则表达式及其转义的版本:

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

路由中使用的正则表达式通常以 ^ 字符开头,并匹配字符串的起始位置。 表达式通常以 $ 字符结尾,并匹配字符串的结尾。 ^$ 字符可确保正则表达式匹配整个路由参数值。 如果没有 ^$ 字符,正则表达式将匹配字符串内的所有子字符串,而这通常是不需要的。 下表提供了示例并说明了它们匹配或匹配失败的原因:

表达式 String 匹配 注释
[a-z]{2} hello 子字符串匹配
[a-z]{2} 123abc456 子字符串匹配
[a-z]{2} mz 匹配表达式
[a-z]{2} MZ 不区分大小写
^[a-z]{2}$ hello 参阅上述 ^$
^[a-z]{2}$ 123abc456 参阅上述 ^$

有关正则表达式语法的详细信息,请参阅 .NET Framework 正则表达式

若要将参数限制为一组已知的可能值,可使用正则表达式。 例如,{action:regex(^(list|get|create)$)} 仅将 action 路由值匹配到 listgetcreate。 如果传递到约束字典中,字符串 ^(list|get|create)$ 将等效。 已传递到约束字典且不匹配任何已知约束的约束也将被视为正则表达式。 模板内传递且不匹配任何已知约束的约束将不被视为正则表达式。

自定义路由约束

可实现 IRouteConstraint 接口来创建自定义路由约束。 IRouteConstraint 接口包含 Match,当满足约束时,它返回 true,否则返回 false

很少需要自定义路由约束。 在实现自定义路由约束之前,请考虑替代方法,如模型绑定。

ASP.NET Core Constraints 文件夹提供了创建约束的经典示例。 例如 GuidRouteConstraint

若要使用自定义 IRouteConstraint,必须在服务容器中使用应用的 ConstraintMap 注册路由约束类型。 ConstraintMap 是将路由约束键映射到验证这些约束的 IRouteConstraint 实现的目录。 应用的 ConstraintMap 可作为 ConstraintMap 调用的一部分在 Startup.ConfigureServices 中进行更新,也可以通过使用 services.Configure<RouteOptions> 直接配置 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);
    }
}

MyDisplayRouteInfoRick.Docs.Samples.RouteInfo 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 处理不受信任的输入,则传递一个超时。 恶意用户可能会向 RegularExpressions 提供输入,从而导致拒绝服务攻击。 使用 RegularExpressions 的 ASP.NET Core 框架 API 会传递一个超时。

前面的代码:

  • 阻止路由的 {id} 段中的 0
  • 显示以提供实现自定义约束的基本示例。 不应在产品应用中使用。

下面的代码是防止处理包含 0id 的更好方法:

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

    return ControllerContext.MyDisplayRouteInfo(id);
}

前面的代码与 MyCustomConstraint 方法相比具有以下优势:

  • 它不需要自定义约束。
  • 当路由参数包括 0 时,它将返回更具描述性的错误。

参数转换器参考

参数转换器:

例如,路由模式 blog\{article:slugify}(具有 Url.Action(new { article = "MyTestArticle" }))中的自定义 slugify 参数转换器生成 blog\my-test-article

请考虑以下 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 框架使用参数转化器来转换进行终结点解析的 URI。 例如,参数转换器转换用于匹配 areacontrolleractionpage 的路由值。

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 约定:

URL 生成参考

本部分包含 URL 生成实现的算法的参考。 在实践中,最复杂的 URL 生成示例使用控制器或 Razor Pages。 有关其他信息,请参阅控制器中的路由

URL 生成过程首先调用 GetPathByAddress 或类似方法。 此方法提供了一个地址、一组路由值以及有关 HttpContext 中当前请求的可选信息。

第一步是使用地址解析一组候选终结点,该终结点使用与该地址类型匹配的 IEndpointAddressScheme<TAddress>

地址方案找到一组候选项后,就会以迭代方式对终结点进行排序和处理,直到 URL 生成操作成功。 URL 生成不检查多义性,返回的第一个结果就是最终结果。

使用日志记录对 URL 生成进行故障排除

对 URL 生成进行故障排除的第一步是将 Microsoft.AspNetCore.Routing 的日志记录级别设置为 TRACELinkGenerator 记录有关其处理的许多详细信息,有助于排查问题。

有关 URL 生成的详细信息,请参阅 URL 生成参考

地址

地址是 URL 生成中的概念,用于将链接生成器中的调用绑定到一组终结点。

地址是一个可扩展的概念,默认情况下有两种实现:

  • 使用终结点名称 (string) 作为地址
    • 提供与 MVC 的路由名称相似的功能。
    • 使用 IEndpointNameMetadata 元数据类型。
    • 根据所有注册的终结点的元数据解析提供的字符串。
    • 如果多个终结点使用相同的名称,启动时会引发异常。
    • 建议用于控制器和 Razor Pages 之外的常规用途。
  • 使用路由值 (RouteValuesAddress) 作为地址
    • 提供与控制器和 Razor Pages 生成旧 URL 相同的功能。
    • 扩展和调试非常复杂。
    • 提供 IUrlHelper、标记帮助程序、HTML 帮助程序、操作结果等所使用的实现。

地址方案的作用是根据任意条件在地址和匹配终结点之间建立关联:

  • 终结点名称方案执行基本字典查找。
  • 路由值方案有一个复杂的子集算法,这是集算法的最佳子集。

环境值和显式值

从当前请求开始,路由将访问当前请求 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);
    }

前面的代码:

下面的代码不提供环境值和显式值:{ 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 生成一个 URL,其中包含操作方法的绝对路径。 URL 包含指定的 action 名称和 route 值。

下面的代码提供当前请求中的环境值和显式值:{ page = "./Edit, id = 17, }

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

当“编辑 Razor”页包含以下页面指令时,前面的代码会将 url 设置为 /Edit/17

@page "{id:int}"

如果“编辑”页面不包含 "{id:int}" 路由模板,url/Edit?id=17

除了此处所述的规则外,MVC IUrlHelper 的行为还增加了一层复杂性:

  • IUrlHelper 始终将当前请求中的路由值作为环境值提供。
  • IUrlHelper 始终将当前 actioncontroller 路由值复制为显式值,除非由开发者重写。
  • IUrlHelper 始终将当前 page 路由值复制为显式值,除非重写。
  • IUrlHelper.Page 始终替代当前 handler 路由值,且 null 为显式值,除非重写。

用户常常对环境值的行为详细信息感到惊讶,因为 MVC 似乎不遵循自己的规则。 出于历史和兼容性原因,某些路由值(例如 actioncontrollerpagehandler)具有其自己的特殊行为。

LinkGenerator.GetPathByActionLinkGenerator.GetPathByPage 提供的等效功能与 IUrlHelper 的兼容性异常相同。

URL 生成过程

找到候选终结点集后,URL 生成算法将:

  • 以迭代方式处理终结点。
  • 返回第一个成功的结果。

此过程的第一步称为路由值失效。 路由值失效是路由决定应使用环境值中的哪些路由值以及应忽略哪些路由值的过程。 将考虑每个环境值,要么与显式值组合,要么忽略它们。

考虑环境值角色的最佳方式是在某些常见情况下,尝试保存应用程序开发者的键入内容。 就传统意义而言,环境值非常有用的情况与 MVC 相关:

  • 链接到同一控制器中的其他操作时,不需要指定控制器名称。
  • 链接到同一区域中的另一个控制器时,不需要指定区域名称。
  • 链接到相同的操作方法时,不需要指定路由值。
  • 链接到应用的其他部分时,不需要在应用的该部分中传递无意义的路由值。

如果不了解路由值失效,通常就会导致对返回 nullLinkGeneratorIUrlHelper 执行调用。 显式指定更多路由值,对路由值失效进行故障排除,以查看是否解决了问题。

路由值失效的前提是应用的 URL 方案是分层的,并按从左到右的顺序组成层次结构。 请考虑基本控制器路由模板 {controller}/{action}/{id?},以直观地了解实践操作中该模板的工作方式。 对值进行更改会使右侧显示的所有路由值都失效 。 这反映了关于层次结构的假设。 如果应用的 id 有一个环境值,则操作会为 controller 指定一个不同的值:

  • id 不会重复使用,因为 {controller}{id?} 的左侧。

演示此原则的一些示例如下:

  • 如果显式值包含 id 的值,则将忽略 id 的环境值。 可以使用 controlleraction 的环境值。
  • 如果显式值包含 action 的值,则将忽略 action 的任何环境值。 可以使用 controller 的环境值。 如果 action 的显式值不同于 action 的环境值,则不会使用 id 值。 如果 action 的显式值与 action 的环境值相同,则可以使用 id 值。
  • 如果显式值包含 controller 的值,则将忽略 controller 的任何环境值。 如果 controller 的显式值不同于 controller 的环境值,则不会使用 actionid 值。 如果 controller 的显式值与 controller 的环境值相同,则可以使用 actionid 值。

由于存在特性路由和专用传统路由,此过程将变得更加复杂。 控制器传统路由(例如 {controller}/{action}/{id?})使用路由参数指定层次结构。 对于控制器和 Razor Pages 的专用传统路由特性路由

  • 有一个路由值层次结构。
  • 它们不会出现在模板中。

对于这些情况,URL 生成将定义“所需值”这一概念。 而由控制器和 Razor Pages 创建的终结点将指定所需值,以允许路由值失效起作用。

路由值失效算法的详细信息:

  • 所需值名称与路由参数组合在一起,然后从左到右进行处理。
  • 对于每个参数,将比较环境值和显式值:
    • 如果环境值和显式值相同,则该过程将继续。
    • 如果环境值存在而显式值不存在,则在生成 URL 时使用环境值。
    • 如果环境值不存在而显式值存在,则拒绝环境值和所有后续环境值。
    • 如果环境值和显式值均存在,并且这两个值不同,则拒绝环境值和所有后续环境值。

此时,URL 生成操作就可以计算路由约束了。 接受的值集与提供给约束的参数默认值相结合。 如果所有约束都通过,则操作将继续。

接下来,接受的值可用于扩展路由模板。 处理路由模板:

  • 从左到右。
  • 每个参数都替换了接受的值。
  • 具有以下特殊情况:
    • 如果接受的值缺少一个值并且参数具有默认值,则使用默认值。
    • 如果接受的值缺少一个值并且参数是可选的,则继续处理。
    • 如果缺少的可选参数右侧的任何路由参数都具有值,则操作将失败。
    • 如果可能,连续的默认值参数和可选参数会折叠。

显式提供且与路由片段不匹配的值将添加到查询字符串中。 下表显示使用路由模板 {controller}/{action}/{id?} 时的结果。

环境值 显式值 结果
控制器 =“Home” 操作 =“About” /Home/About
控制器 =“Home” 控制器 =“Order”,操作 =“About” /Order/About
控制器 =“Home”,颜色 =“Red” 操作 =“About” /Home/About
控制器 =“Home” 操作 =“About”,颜色 =“Red” /Home/About?color=Red

路由值失效的问题

从 ASP.NET Core 3.0 开始,早期 ASP.NET Core 版本中使用的一些 URL 生成方案不适用于 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 参数作为环境值接受:

  • "default" 路由模板中,culture 路由参数位于 controller 的左侧,因此对 controller 的更改不会使 culture 失效。
  • "blog" 路由模板中,culture 路由参数应在 controller 的右侧,这会显示在所需值中。

配置终结点元数据

以下链接提供有关配置终结点元数据的信息:

路由中与 RequireHost 匹配的主机

RequireHost 将约束应用于需要指定主机的路由。 RequireHostRequireHost 参数可以是:

  • 主机:www.domain.com匹配任何端口的 www.domain.com
  • 带有通配符的主机:*.domain.com,匹配任何端口上的 www.domain.comsubdomain.domain.comwww.subdomain.domain.com
  • 端口:*:5000 匹配任何主机的端口 5000。
  • 主机和端口:www.domain.com:5000*.domain.com:5000(匹配主机和端口)。

可以使用 RequireHost[Host] 指定多个参数。 约束匹配对任何参数均有效的主机。 例如,[Host("domain.com", "*.domain.com")] 匹配 domain.comwww.domain.comsubdomain.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");
    });
}

以下代码使用控制器上的 [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] 属性同时应用于控制器和操作方法时:

  • 使用操作上的属性。
  • 忽略控制器属性。

路由的性能指南

大多数路由在 ASP.NET Core 3.0 中都进行了更新,以提高性能。

当应用出现性能问题时,人们常常会怀疑路由是问题所在。 怀疑路由的原因是,控制器和 Razor Pages 等框架在其日志记录消息中报告框架内所用的时间。 如果控制器报告的时间与请求的总时间之间存在明显的差异:

  • 开发者会将应用代码排除在问题根源之外。
  • 他们通常认为路由是问题的原因。

路由的性能使用数千个终结点进行测试。 典型的应用不太可能仅仅因为太大而遇到性能问题。 路由性能缓慢的最常见根本原因通常在于性能不佳的自定义中间件。

下面的代码示例演示了一种用于缩小延迟源的基本方法:

public void Configure(IApplicationBuilder app, ILogger<Startup> 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 1 中减去 Time 2 会报告 UseRouting 中间件内所用的时间。

下面的代码使用一种更紧凑的方法来处理前面的计时代码:

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

    public MyStopwatch(ILogger<Startup> 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<Startup> 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 及更低版本的路由可能未提供适当的扩展点,因此无法支持数据库访问路由。 例如,IRouteConstraintIActionConstraint 是同步的。 扩展点(如 MatcherPolicyEndpointSelectorContext)是异步的。

库创建者指南

本部分包含基于路由构建的库创建者指南。 这些详细信息旨在确保应用开发者可以在使用扩展路由的库和框架时具有良好的体验。

定义终结点

若要创建一个使用路由进行 URL 匹配的框架,请先定义在 UseEndpoints 之上进行构建的用户体验。

之上进行构建。 由此,用户就可以用其他 ASP.NET Core 功能编写框架,而不会造成混淆。 每个 ASP.NET Core 模板都包含路由。 假定路由存在并且用户熟悉路由。

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

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

从对实现 IEndpointConventionBuilder 的调用返回密式具体类型。 大多数框架 Map... 方法都遵循此模式。 IEndpointConventionBuilder 接口:

  • 允许元数据的可组合性。
  • 面向多种扩展方法。

通过声明自己的类型,你可以将自己的框架特定功能添加到生成器中。 可以包装一个框架声明的生成器并向其转发调用。

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

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

请考虑编写自己的 EndpointDataSource 是用于声明和更新终结点集合的低级别基元。 EndpointDataSource 是控制器和 Razor Pages 使用的强大 API。

路由测试具有非更新数据源的基本示例

默认情况下,请不要尝试注册 。 要求用户在 UseEndpoints 中注册你的框架。 路由的理念是,默认情况下不包含任何内容,并且 UseEndpoints 是注册终结点的位置。

创建路由集成式中间件

请考虑将元数据类型定义为接口。

请实现在类和方法上使用元数据类型。

public interface ICoolMetadata
{
    bool IsCool { get; }
}

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

控制器和 Razor Pages 等框架支持将元数据特性应用到类型和方法。 如果声明元数据类型:

  • 将它们作为特性进行访问。
  • 大多数用户都熟悉应用特性。

将元数据类型声明为接口可增加另一层灵活性:

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

请实现元数据替代,如以下示例中所示:

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() { }
}

遵循这些准则的最佳方式是避免定义标记元数据:

  • 不要只是查找元数据类型的状态。
  • 定义元数据的属性并检查该属性。

对元数据集合进行排序,并支持按优先级替代。 对于控制器,操作方法上的元数据是最具体的。

使中间件始终有用,不管有没有路由。

app.UseRouting();

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

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

作为此准则的一个示例,请考虑 UseAuthorization 中间件。 授权中间件允许传入回退策略。 如果指定了回退策略,则适用于以下两者:

  • 无指定策略的终结点。
  • 与终结点不匹配的请求。

这使得授权中间件在路由上下文之外很有用。 授权中间件可用于传统中间件的编程。

调试诊断

要获取详细的路由诊断输出,请将 Logging:LogLevel:Microsoft 设置为 Debug。 在开发环境中,在 appsettings.Development.json 中设置日志级别:

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