为什么ASP.NET Core的路由处理器可以使用一个任意类型的Delegate

.NET
95
0
0
2024-06-19

毫不夸张地说,路由是ASP.NET Core最为核心的部分。路由的本质就是注册一系列终结点(Endpoint),每个终结点可以视为“路由模式”和“请求处理器”的组合,它们分别用来“选择”和“处理”请求。请求处理器通过RequestDelegate来表示,但是当我们在进行路由编程的时候,却可以使用任意类型的Delegate作为处理器器,这一切的背后是如何实现的呢?

一、指定任意类型的委托处理路由请求

路由终结点总是采用一个RequestDelegate委托作为请求处理器,上面介绍的这一系列终结点注册的方法提供的也都是RequestDelegate委托。实际上IEndpointConventionBuilder接口还定义了如下这些用来注册终结点的扩展方法,它们接受任意类型的委托作为处理器。

public static class EndpointRouteBuilderExtensions
{
    public static RouteHandlerBuilder Map(this IEndpointRouteBuilder endpoints, string pattern, Delegate handler);
    public static RouteHandlerBuilder Map(this IEndpointRouteBuilder endpoints, RoutePattern pattern, Delegate handler);
    public static RouteHandlerBuilder MapMethods(this IEndpointRouteBuilder endpoints, string pattern, IEnumerable<string> httpMethods, Delegate handler);
    public static RouteHandlerBuilder MapGet(this IEndpointRouteBuilder endpoints, string pattern, Delegate handler);
    public static RouteHandlerBuilder MapPost(this IEndpointRouteBuilder endpoints, string pattern, Delegate handler);
    public static RouteHandlerBuilder MapPut(this IEndpointRouteBuilder endpoints, string pattern, Delegate handler);
    public static RouteHandlerBuilder MapDelete(this IEndpointRouteBuilder endpoints, string pattern, Delegate handler);
}

由于表示路由终结点的RouteEndpoint对象总是将RequestDelegate委托作为请求处理器,所以上述这些扩展方法提供的Delegate对象最终还得转换成RequestDelegate类型,两者之间的适配或者类型转换是由如下这个RequestDelegateFactory类型的Create方法完成的。这个方法根据提供的Delegate对象创建一个RequestDelegateResult对象,后者不仅封装了转换生成的RequestDelegate委托,终结点的元数据集合也在其中。RequestDelegateFactoryOptions是为处理器转换提供的配置选项。

public static class RequestDelegateFactory
{
    public static RequestDelegateResult Create(Delegate handler,RequestDelegateFactoryOptions options = null);
}

public sealed class RequestDelegateResult
{
    public RequestDelegate RequestDelegate { get; }
    public IReadOnlyList<object> EndpointMetadata { get; }

    public RequestDelegateResult(RequestDelegate requestDelegate,   IReadOnlyList<object> metadata);
}

public sealed class RequestDelegateFactoryOptions
{
    public IServiceProvider ServiceProvider { get; set; }
    public IEnumerable<string> RouteParameterNames { get; set; }
    public bool ThrowOnBadRequest { get; set; }
    public bool DisableInferBodyFromParameters { get; set; }
}

我并不打算详细介绍从Delegate向RequestDelegate转换的具体流程,而是通过几个简单的实例演示一下提供的各种类型的委托是如何执行的,这里主要涉及“参数绑定”和“返回值处理”两方面的处理策略。

二、参数绑定

既然可以将一个任意类型的委托终结点的处理器,意味着路由系统在执行委托的时候能够自行绑定其输入参数。这里采用的参数绑定策略与ASP.NET MVC的“模型绑定”如出一辙。当定义某个用来处理请求的方法时,我们可以在输入参数上标注一些特性显式指定绑定数据的来源,这些特性大都实现了如下这些接口。从接口命名可以看出,它们表示绑定的目标参数的原始数据分别来源于路由参数、查询字符串、请求报头、请求主体以及依赖注入容器提供的服务。

public interface IFromRouteMetadata
{
    string Name { get; }
}

public interface IFromQueryMetadata
{
    string Name { get; }
}

public interface IFromHeaderMetadata
{
    string Name { get; }
}

public interface IFromBodyMetadata
{
    bool AllowEmpty { get; }
}

public interface IFromServiceMetadata
{
}

如下这些特性实现了上面这几个接口,它们都定义在“Microsoft.AspNetCore.Mvc”命名空间下,因为它们原本是为了ASP.NET MVC下的模型绑定服务的。值得一提的是FromQueryAttribute特性不被支持,不知道是刻意为之还是把这个漏掉了。

[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
public class FromRouteAttribute : Attribute, IBindingSourceMetadata, IModelNameProvider, IFromRouteMetadata
{
    public BindingSource BindingSource { get; }
    public string Name { get; set; }
}

[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
public class FromQueryAttribute : Attribute, IBindingSourceMetadata, IModelNameProvider, IFromQueryMetadata
{

    public BindingSource BindingSource { get; }
    public string Name { get; set; }
}

[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
public class FromHeaderAttribute : Attribute, IBindingSourceMetadata, IModelNameProvider, IFromHeaderMetadata
{
    public BindingSource BindingSource { get; }
    public string Name { get; set; }
}

[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
public class FromBodyAttribute : Attribute, IBindingSourceMetadata, IConfigureEmptyBodyBehavior, IFromBodyMetadata
{
    public BindingSource BindingSource { get; }
    public EmptyBodyBehavior EmptyBodyBehavior { get; set; }
    bool IFromBodyMetadata.AllowEmpty { get; }
}

[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = true)]
public class FromServicesAttribute : Attribute, IBindingSourceMetadata, IFromServiceMetadata
{
    public BindingSource BindingSource { get; }
}

如下这个演示程序调用WebApplication对象的MapPost方法注册了一个采用“/{foo}”作为模板的终结点。作为终结点处理器的委托指向静态方法Handle,我们为这个方法定义了五个参数,分别标注了上述五个特性。我们将五个参数组合成一个匿名对象作为返回值。

using Microsoft.AspNetCore.Mvc;
var app = WebApplication.Create();
app.MapPost("/{foo}", Handle);
app.Run();

static object Handle(
    [FromRoute] string foo,
    [FromQuery] int bar,
    [FromHeader] string host,
    [FromBody] Point point,
    [FromServices] IHostEnvironment environment)
    => new { Foo = foo, Bar = bar, Host = host, Point = point, Environment = environment.EnvironmentName };

public class Point
{
    public int X { get; set; }
    public int Y { get; set; }
}

程序启动之后,我们针对“http://localhost:5000/abc?bar=123”这个URL发送了一个POST请求,请求的主体内容为一个Point对象序列化成生成的JSON。如下所示的是请求报文和响应报文的内容,可以看出Handle方法的foo和bar参数分别绑定的是路由参数“foo”和查询字符串“bar”的值,参数host绑定的是请求的Host报头,参数point是请求主体内容反序列化的结果,参数environment则是由针对当前请求的IServiceProvider对象提供的服务。

POST http://localhost:5000/abc?bar=123 HTTP/1.1
Content-Type: application/json
Host: localhost:5000
Content-Length: 18

{"x":123, "y":456}
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 06 Nov 2021 11:55:54 GMT
Server: Kestrel
Content-Length: 100

{"foo":"abc","bar":123,"host":"localhost:5000","point":{"x":123,"y":456},"environment":"Production"}

如果请求处理器方法的参数没有显式指定绑定数据的来源,路由系统也能根据参数的类型尽可能地从当前HttpContext上下文中提取相应的内容予以绑定。针对如下这几个类型,对应参数的绑定源是明确的。

  • HttpContext:绑定为当前HttpContext上下文。
  • HttpRequest:绑定为当前HttpContext上下文的Request属性。
  • HttpResponse: 绑定为当前HttpContext上下文的Response属性。
  • ClaimsPrincipal: 绑定为当前HttpContext上下文的User属性。
  • CancellationToken: 绑定为当前HttpContext上下文的RequestAborted属性。

上述的绑定规则体现在如下演示程序的调试断言中。这个演示实例还体现了另一个绑定规则,那就是只要当前请求的IServiceProvider能够提供对应的服务,对应参数(“httpContextAccessor”)上标注的FromSerrvicesAttribute特性不是必要的。但是倘若缺少对应的服务注册,请求的主体内容会一般会作为默认的数据来源,所以FromSerrvicesAttribute特性最好还是显式指定为好。对于我们演示的这个例子,如果我们将前面针对AddHttpContextAccessor方法的调用移除,对应参数的绑定自然会失败,但是错误消息并不是我们希望看到的。

using System.Diagnostics;
using System.Security.Claims;

var builder = WebApplication.CreateBuilder();
builder.Services.AddHttpContextAccessor();
var app = builder.Build();
app.MapGet("/", Handle);
app.Run();

static void Handle(
    HttpContext httpContext,
    HttpRequest request,
    HttpResponse response,
    ClaimsPrincipal user,
    CancellationToken cancellationToken,
    IHttpContextAccessor httpContextAccessor)
{
    var currentContext = httpContextAccessor.HttpContext;
    Debug.Assert(ReferenceEquals(httpContext, currentContext));
    Debug.Assert(ReferenceEquals(request, currentContext.Request));
    Debug.Assert(ReferenceEquals(response, currentContext.Response));
    Debug.Assert(ReferenceEquals(user, currentContext.User));
    Debug.Assert(cancellationToken == currentContext.RequestAborted);
}

对于字符串类型的参数,路由参数和查询字符串是两个候选数据源,前者具有更高的优先级。也就是说如果路由参数和查询字符串均提供了某个参数的值,此时会优先选择路由参数提供的值。我个人倒觉得两种绑定源的优先顺序应该倒过来,查询字符串优先级似乎应该更高。对于我们自定义的类型,对应参数默认由请求主体内容反序列生成。由于请求的主体内容只有一份,所以不能出现多个参数都来源请求主体内容的情况,所以下面代码注册的终结点处理器是不合法的。

var app = WebApplication.Create();
app.MapGet("/", (Point p1, Point p2) => { });
app.Run();

public class Point
{
    public int X { get; set; }
    public int Y { get; set; }
}

如果我们在某个类型中定义了一个名为TryParse的静态方法将指定的字符串表达式转换成当前类型的实例,路由系统在对该类型的参数进行绑定的时候会优先从路由参数和查询字符串中提取相应的内容,并通过调用这个方法生成绑定的参数。

var app = WebApplication.Create();
app.MapGet("/", (Point foobar) => foobar);
app.Run();

public class Point
{
    public int X { get; set; }
    public int Y { get; set; }

    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }
    public static bool TryParse(string expression, out Point? point)
    {
        var split = expression.Trim('(', ')').Split(',');
        if (split.Length == 2 && int.TryParse(split[0], out var x) && int.TryParse(split[1], out var y))
        {
            point = new Point(x, y);
            return true;
        }
        point = null;
        return false;
    }
}

上面的演示程序为自定义的Point类型定义了一个静态的TryParse方法使我们可以将一个以“(x,y)”形式定义的表达式转换成Point对象。注册的终结点处理器委托以该类型为参数,指定的参数名称为“foobar”。我们在发送的请求中以查询字符串的形式提供对应的表达式“(123,456)”,从返回的内容可以看出参数得到了成功绑定。

图1 TryParse方法针对参数绑定的影响

如果某种类型的参数具有特殊的绑定方式,我们还可以将具体的绑定实现在一个按照约定定义的BindAsync方法中。按照约定,这个BindAsync应该定义成返回类型为ValueTask<T>的静态方法,它可以拥有一个类型为HttpContext的参数,也可以额外提供一个ParameterInfo类型的参数,这两个参数分别与当前HttpContext上下文和描述参数的ParameterInfo对象绑定。前面演示实例中为Point类型定义了一个TryParse方法可以替换成如下这个 BingAsync方法。

public class Point
{
    public int X { get; set; }
    public int Y { get; set; }

    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }

    public static ValueTask<Point?> BindAsync(HttpContext httpContext, ParameterInfo parameter)
    {
        Point? point = null;
        var name = parameter.Name;
        var value = httpContext.GetRouteData().Values.TryGetValue(name!, out var v)
            ? v
            : httpContext.Request.Query[name!].SingleOrDefault();

        if (value is string expression)
        {
            var split = expression.Trim('(', ')')?.Split(',');
            if (split?.Length == 2 && int.TryParse(split[0], out var x) && int.TryParse(split[1], out var y))
            {
                point = new Point(x, y);
            }
        }
        return new ValueTask<Point?>(point);
    }
}

三、返回值处理

作为终结点处理器的委托对象不仅对输入参数没有要求,它还可以返回任意类型的对象。如果返回类型为Void、Task或者ValueTask,均表示没有返回值。如果返回类型为String、Task<String>或者ValueTask<String>,返回的字符串将直接作为响应的主体内容,响应的媒体类型会被设置为“text/plain”。对于其他类型的返回值(包括Task<T>或者ValueTask<T>),默认情况都会序列化成JSON作为响应的主体内容,响应的媒体类型会被设置为“application/json”,即使返回的是原生类型(比如Int32)也是如此。

var app = WebApplication.Create();
app.MapGet("/foo", () => "123");
app.MapGet("/bar", () => 123);
app.MapGet("/baz", () => new Point {  X = 123, Y = 456});
app.Run();

public class Point
{
    public int X { get; set; }
    public int Y { get; set; }
}

上面的演示程序注册了三个终结点,作为处理器的返回值分别为字符串、整数和Point对象。如果我们针对这三个终结点发送对应的GET请求,将得到如下所示的响应。

HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Date: Sun, 07 Nov 2021 01:13:47 GMT
Server: Kestrel
Content-Length: 3

123
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sun, 07 Nov 2021 01:14:11 GMT
Server: Kestrel
Content-Length: 3

123
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sun, 07 Nov 2021 01:14:26 GMT
Server: Kestrel
Content-Length: 17

{"x":123,"y":456}

如果曾经从事过ASP.NET MVC应用的开发,应该对IActionResult接口感到很熟悉。定义在Controller类型中的Action方法一般返回会IActionResult(或者Task<IActionResult>和ValueTask<IActionResult>)对象。当Action方法执行结束后,MVC框架会直接调用返回的IActionResult对象的ExecuteResultAsync方法完整最终针对响应的处理。相同的设计同样被“移植”到这里,并为此定义了如下这个IResult接口。

public interface IResult
{
    Task ExecuteAsync(HttpContext httpContext);
}

如果终结点处理器方法返回一个IResult对象或者返回一个Task<T>或ValueTask<T>(T实现了IResult接口),那么IResult对象ExecuteAsync方法将用来完成后续针对响应的处理工作。IResult接口具有一系列的原生实现类型,不过它们大都被定义成了内部类型。虽然我们不能直接调用构造函数构建它们,但是我们可以通过调用定义在Results类型中的如下这些静态方法来使用它们。

public static class Results
{
    public static IResult Accepted(string uri = null, object value = null);
    public static IResult AcceptedAtRoute(string routeName = null, object routeValues = null, object value = null);
    public static IResult BadRequest(object error = null);
    public static IResult Bytes(byte[] contents, string contentType = null, string fileDownloadName = null, bool enableRangeProcessing = false, DateTimeOffset? lastModified = default, EntityTagHeaderValue entityTag = null);
    public static IResult Challenge(AuthenticationProperties properties = null, IList<string> authenticationSchemes = null);
    public static IResult Conflict(object error = null);
    public static IResult Content(string content, MediaTypeHeaderValue contentType);
    public static IResult Content(string content, string contentType = null, Encoding contentEncoding = null);
    public static IResult Created(string uri, object value);
    public static IResult Created(Uri uri, object value);
    public static IResult CreatedAtRoute(string routeName = null, object routeValues = null, object value = null);
    public static IResult File(byte[] fileContents, string contentType = null, string fileDownloadName = null, bool enableRangeProcessing = false, DateTimeOffset? lastModified = default, EntityTagHeaderValue entityTag = null);
    public static IResult File(Stream fileStream, string contentType = null, string fileDownloadName = null, DateTimeOffset? lastModified = default, EntityTagHeaderValue entityTag = null, bool enableRangeProcessing = false);
    public static IResult File(string path, string contentType = null, string fileDownloadName = null, DateTimeOffset? lastModified = default, EntityTagHeaderValue entityTag = null, bool enableRangeProcessing = false);
    public static IResult Forbid(AuthenticationProperties properties = null, IList<string> authenticationSchemes = null);
    public static IResult Json(object data, JsonSerializerOptions options = null, string contentType = null, int? statusCode = default);
    public static IResult LocalRedirect(string localUrl, bool permanent = false, bool preserveMethod = false);
    public static IResult NoContent();
    public static IResult NotFound(object value = null);
    public static IResult Ok(object value = null);
    public static IResult Problem(string detail = null, string instance = null, int? statusCode = default, string title = null, string type = null);
    public static IResult Redirect(string url, bool permanent = false, bool preserveMethod = false);
    public static IResult RedirectToRoute(string routeName = null, object routeValues = null, bool permanent = false, bool preserveMethod = false, string fragment = null);
    public static IResult SignIn(ClaimsPrincipal principal, AuthenticationProperties properties = null, string authenticationScheme = null);
    public static IResult SignOut(AuthenticationProperties properties = null, IList<string> authenticationSchemes = null);
    public static IResult StatusCode(int statusCode);
    public static IResult Stream(Stream stream, string contentType = null, string fileDownloadName = null, DateTimeOffset? lastModified = default, EntityTagHeaderValue entityTag = null, bool enableRangeProcessing = false);
    public static IResult Text(string content, string contentType = null, Encoding contentEncoding = null);
    public static IResult Unauthorized();
    public static IResult UnprocessableEntity(object error = null);
    public static IResult ValidationProblem(IDictionary<string, string[]> errors, string detail = null, string instance = null, int? statusCode = default, string title = null, string type = null);
}