【.NET Core框架】终结点路由(Routing)

介绍

早期路由系统

.NET Core2.2之前的框架中,中间件管道的结尾有一个Router中间件,也就是路由中间件,这个路由中间件会把HTTP请求和路由数据发送给MVC的一个组件,它叫做MVC Router Handler。
这个MVC 路由 Handler就会使用这些路由数据来决定哪个Controller的Action方法应该来负责处理这个请求。

早期路由系统的弊端:
在Router中间件之前的中间件不知道请求被哪个Controller/Action处理了。如果你在中间件中想知道被哪个Action处理了,必须将逻辑写在Router中间件中,导致职责耦合,或者是写在过滤器中。
在core中授权中间件、CORS中间件都支持策略,通过Contrller上的Attribute获取对应策略,所以需要在Router中间件之前知道请求被路由到哪个Controller/Action,以及标识的Attribute等信息

Endpoint Routing(终结点路由系统)

Endpoint routing 路由系统解决了以上问题。终结点路由是通过EndpointRoutingMiddleware和EndpointMiddleware这两个中间件协作完成的,这两个中间件类型都定义在NuGet包“Microsoft.AspNetCore.Routing”中。

  • app.UseRouting():用于向中间件管道添加路由匹配中间件(EndpointRoutingMiddleware)。该中间件会检查应用中定义的终结点列表,然后通过匹配 URL 和 HTTP 方法来选择最佳的终结点。简单说,该中间件的作用是根据一定规则来选择出终结点
  • app.UseEndpoints()。用于向中间件管道添加终结点中间件(EndpointMiddleware)。可以向该中间件的终结点列表中添加终结点,并配置这些终结点要执行的委托,该中间件会负责运行由EndpointRoutingMiddleware中间件选择的终结点所关联的委托。简单说,该中间件用来执行所选择的终结点委托,从而让其生成响应的

这样做的好处就是,我们可以在选择端点和执行端点的中间位置插入其它的中间件。这样的话,插入到中间位置的中间件就会知道哪个端点被选取了,而且它也有可能会选择其它的端点
程序启动的时候会把所有的Controller 中的Action 映射存储到routeOptions 的集合中,Action 映射成Endpoint终结者 的RequestDelegate 委托属性,最后通过UseEndPoints 添加EndpointMiddleware 中间件进行执行,同时这个中间件中的Endpoint 终结点路由已经是通过Routing匹配后的路由。

Endpoint

Endpoint类中包含一个请求的委托(Request Delegate)和元数据。终结点通过RequestDelegate对象来处理路由过来的请求。
而在MVC的上下文中,这个请求委托就是一个包装类,它包装了一个方法,这个方法可以实例化一个Controller并执行选中的Action方法。
Endpoint还包含元数据,这些元数据用来决定他们的请求委托是否应该用于当前的请求,还是另有其它用途,路由过程中的很多行为都可以通过相应的元数据来控制。

终结点有以下特点:

  • 可执行:含有RequestDelegate委托
  • 可扩展:含有Metadata元数据集合
  • 可选择:可选的包含路由信息
  • 可枚举:通过DI容器,查找EndpointDataSource来展示终结点集合。

第一个HTTP请求进来的时候,Endpoint Routing中间件就会把请求映射到一个Endpoint上。它会使用之App启动时创建好的EndpointDataSource,来遍历查找所有可用的Endpoint,并检查和它关联的路由以及元数据,来找到最匹配的Endpoint。
一旦某个Endpoint实例被选中,它就会被附加在请求的对象上,这样它就可以被后续的中间件所使用了。
最后在管道的尽头,当 Endpoint中间件运行的时候,它就会执行Endpoint所关联的请求委托。这个请求委托就会触发和实例化选中的Controller和Action方法,并产生响应。最后响应再从中间件管道原路返回。

EndpointRoutingMiddleware(Endpoint Routing中间件)

Endpoint Routing 中间件会分析进来的请求,并把它和在程序中注册的Endpoints进行比较。它会使用这些 Endpoints 上面的元数据来决定哪个是处理该请求的最佳人选。然后,这个选中的Endpoint 就会被赋给请求的对象,而其它后续的中间件就可以根据这个选中的Endpoint,来做一些自己的决策。在所有的中间件都执行完之后,这个被选中的Endpoint最终将被 Endpoint中间件所执行,而与之关联的Action方法就会被执行。
如果中间件中需要获取到Controller、Action信息,将中间件放到Endpoint Routing和Endpoint两个中间件之间,通过以下代码获取信息

Endpoint endpoint = httpContext.GetEndpoint();
ControllerActionDescriptor controllerActionDescriptor = endpoint.Metadata.GetMetadata<ControllerActionDescriptor>();
var controllerType = controllerActionDescriptor.ControllerTypeInfo;//Controller Type
var allowAnonymous = endpoint.Metadata.GetMetadata<IAllowAnonymous>();//AllowAnonymousAttribute

在中间件管道中获取路由选择的终结点

在中间件管道中,我们可以通过HttpContext来检索终结点等信息。需要注意的是,终结点对象在创建完毕后,是不可变的,无法修改。

  • 在调用UseRouting之前,你可以注册一些用于修改路由操作的数据,比如UseRewriter、UseHttpMethodOverride、UsePathBase等。
  • 在调用UseRouting和UseEndpoints之间,可以注册一些用于提前处理路由结果的中间件,如UseAuthentication、UseAuthorization、UseCors等。

我们一起看下面的代码:

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.Use(next => context =>
    {
        // 在 UseRouting 调用前,始终为 null
        Console.WriteLine($"1. Endpoint: {context.GetEndpoint()?.DisplayName ?? "null"}");
        return next(context);
    });

    // EndpointRoutingMiddleware 调用 SetEndpoint 来设置终结点
    app.UseRouting();

    app.Use(next => context =>
    {
        // 如果路由匹配到了终结点,那么此处就不为 null,否则,还是 null
        Console.WriteLine($"2. Endpoint: {context.GetEndpoint()?.DisplayName ?? "null"}");
        return next(context);
    });

    // EndpointMiddleware 通过 GetEndpoint 方法获取终结点,
    // 然后执行该终结点的 RequestDelegate 委托
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapGet("/", context =>
        {
            // 匹配到了终结点,肯定不是 null
            Console.WriteLine($"3. Endpoint: {context.GetEndpoint()?.DisplayName ?? "null"}");
            return Task.CompletedTask;
        }).WithDisplayName("Custom Display Name");  // 自定义终结点名称
    });

    app.Use(next => context =>
    {
        // 只有当路由没有匹配到终结点时,才会执行这里
        Console.WriteLine($"4. Endpoint: {context.GetEndpoint()?.DisplayName ?? "null"}");
        return next(context);
    });
}

当访问/时,输出为:

1. Endpoint: null
2. Endpoint: Custom Display Name
3. Endpoint: Custom Display Name

当访问其他不匹配的URL时,输出为:

1. Endpoint: null
2. Endpoint: null
4. Endpoint: null

当路由匹配到了终结点时,EndpointMiddleware则是该路由的终端中间件;当未匹配到终结点时,会继续执行后面的中间件。

配置终结点委托

可以通过以下方法将委托关联到终结点

  • MapGet
  • MapPost
  • MapPut
  • MapDelete
  • MapHealthChecks
    其他类似“MapXXX”的方法
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.UseRouting();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapGet("/", async context => await context.Response.WriteAsync("get"));
        endpoints.MapPost("/", async context => await context.Response.WriteAsync("post"));
        endpoints.MapPut("/", async context => await context.Response.WriteAsync("put"));
        endpoints.MapDelete("/", async context => await context.Response.WriteAsync("delete"));
        endpoints.MapHealthChecks("/healthChecks");
        endpoints.MapControllers();
    });
}

路由模板

规则:

  • 通过{}来绑定路由参数,如:
  • 将?作为参数后缀,以指示该参数是可选的,如:
  • 通过=设置默认值,如:{name=jjj} 表示name的默认值是jjj
  • 通过:添加内联约束,如:{id:int},后面追加:可以添加多个内联约束,如:
  • 多个路由参数间必须通过文本或分隔符分隔,例如 {a}{b} 就不符合规则,可以修改为类似 {a}+-{b} 或 {a}/{b} 的形式
  • 先举个例子,/book/{name}中的{name}为路由参数,book为非路由参数文本。
  • 如果要匹配{或},则使用{{或}}进行转义

catch-all参数

一个URL可以通过分隔符“/”划分为多个路径分段(Segment),路由模板中定义的路由参数一般来说会占据某个独立的分段(如“weather/{city}/{days}”)。但也有例外情况,我们既可以在一个单独的路径分段中定义多个路由参数,也可以让一个路由参数跨越多个连续的路径分段。
一个分段包含多个路由参数:/weather/{city}/{year}.{month}.{day}
一个参数跨越多个分段,我们只能采用定义“通配符”的形式来达到这个目的,通配符路由参数采用{*variable}或者{**variable}的形式,星号表示路径“余下的部分”,所以这样的路由参数只能出现在模板的尾端:/weather/{city}/{*date}
*和**在一般使用上没有什么区别,它们仅仅在使用LinkGenerator时会有不同,如id = abc/def,当使用/Book/{*id}模板时,会生成/Book/abc%2Fdef,当使用/Book/{**id}模板时,会生成/Book/abc/def。

路由约束

约束类型

int:如{age:int}
bool
datetime
decimal
double
float
guid
long
minlength:要求参数值表示的字符串不小于指定的长度{age:minlength(2)}
maxlength:要求参数值表示的字符串不大于指定的长度
length:要求参数值表示的字符串长度限于指定的区间范围{age:length(2,3)}
min:最小值
max:最大值
range:要求参数值介于指定的区间范围
alpha:所有字符必须是字母
required:要求参数值不能是空字符串
file:要求参数值可以作为一个包含扩展名的文件名
nonfile:要求参数值不是文件名,与上相反
regex:正则表达式约束::regex(正则表达式)

正则表达式路由约束

通过regex(expression)来设置正则表达式约束
另外,还需要注意对某些字符进行转义:

  • \替换为\\
  • {替换为{{, }替换为}}
  • [替换为[[,]替换为]]

指定 regex 约束的两种方式:

// 内联方式
app.UseEndpoints(endpoints =>
{
    endpoints.MapGet("{message:regex(^\\d{{3}}-\\d{{2}}-\\d{{4}}$)}",
        context => 
        {
            return context.Response.WriteAsync("inline-constraint match");
        });
 });
 
// 变量声明方式
app.UseEndpoints(endpoints =>
{
    endpoints.MapControllerRoute(
        name: "people",
        pattern: "People/{ssn}",
        constraints: new { ssn = "^\\d{3}-\\d{2}-\\d{4}$", },
        defaults: new { controller = "People", action = "List", });
}); 

路由模板优先级

考虑一下,有两个路由模板:/Book/List和/Book/{id},当url为/Book/List时,会选择哪个呢?从结果我们可以得知,是模板/Book/List。它是根据以下规则来确定的:

  • 越具体的模板优先级越高
  • 包含更多匹配段的模板更具体
  • 含有文本的段比参数段更具体
  • 具有约束的参数段比没有约束的参数段更具体
  • 复杂段和具有约束的段同样具体
  • catch-all参数段是最不具体的

属性路由

webapi更适合属性路由,添加以下代码可以支持属性路由

EndpointMiddleware(Endpoint中间件)

EndpointMiddleware的逻辑很简单,仅仅是执行选中的Endpoint中的RequestDelegate方法

源码:(删掉了日志相关代码)

    internal sealed class EndpointMiddleware
    {
        internal const string AuthorizationMiddlewareInvokedKey = "__AuthorizationMiddlewareWithEndpointInvoked";
        internal const string CorsMiddlewareInvokedKey = "__CorsMiddlewareWithEndpointInvoked";

        private readonly ILogger _logger;
        private readonly RequestDelegate _next;
        private readonly RouteOptions _routeOptions;

        public EndpointMiddleware(
            ILogger<EndpointMiddleware> logger,
            RequestDelegate next,
            IOptions<RouteOptions> routeOptions)
        {
            _logger = logger ?? throw new ArgumentNullException(nameof(logger));
            _next = next ?? throw new ArgumentNullException(nameof(next));
            _routeOptions = routeOptions?.Value ?? throw new ArgumentNullException(nameof(routeOptions));
        }

        public Task Invoke(HttpContext httpContext)
        {
            var endpoint = httpContext.GetEndpoint();
            if (endpoint?.RequestDelegate != null)
            {
                if (!_routeOptions.SuppressCheckForUnhandledSecurityMetadata)
                {
                    if (endpoint.Metadata.GetMetadata<IAuthorizeData>() != null &&
                        !httpContext.Items.ContainsKey(AuthorizationMiddlewareInvokedKey))
                    {
                        ThrowMissingAuthMiddlewareException(endpoint);
                    }

                    if (endpoint.Metadata.GetMetadata<ICorsMetadata>() != null &&
                        !httpContext.Items.ContainsKey(CorsMiddlewareInvokedKey))
                    {
                        ThrowMissingCorsMiddlewareException(endpoint);
                    }
                }
                try
                {
                    var requestTask = endpoint.RequestDelegate(httpContext);
                    if (!requestTask.IsCompletedSuccessfully)
                    {
                        return AwaitRequestTask(endpoint, requestTask, _logger);
                    }
                }
                catch (Exception exception)
                {
                    return Task.FromException(exception);
                }

                return Task.CompletedTask;
            }

            return _next(httpContext);

            static async Task AwaitRequestTask(Endpoint endpoint, Task requestTask, ILogger logger)
            {
                    await requestTask;
            }
        }
    }

ControllerActionEndpointDataSource

ControllerActionEndpointDataSource里面存储着在应用程序里注册的路由模板
ControllerActionEndpointDataSource类作为应用程序级别的服务被创建了
这个类里面有一个叫做CreateEndpoints()的方法,它会获取所有Controller的Action方法
然后针对每个Action方法,它会创建一个Endpoint实例。这些Endpoint实例就是包装了Controller和Action方法的执行的请求委托(Request Delegate)
而针对每个Endpoint,它要么与某个按约定的路由模板相关联,要么与某个Controller Action上的Attribute路由信息相关联。而这些路由在稍后就会被用来将Endpoint与进来的请求进行匹配。

参考:
https://www.cnblogs.com/artech/p/endpoint-middleware-01.html
https://www.cnblogs.com/cgzl/p/12561571.html
https://www.cnblogs.com/xiaoxiaotank/p/15468491.html

posted @ 2020-12-08 22:20  .Neterr  阅读(782)  评论(1编辑  收藏  举报