爲什麼ASP.NET Core的路由處理器可以使用一個任意類型的Delegate

毫不誇張地說,路由是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)”,從返回的內容可以看出參數得到了成功綁定。

image

圖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);
    }
}

三、返回值處理

作爲終結點處理器的委託對象不僅對輸入參數沒有要求,它還可以返回任意類型的對象。如果返回類型爲VoidTask或者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);
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章