ASP.NET Core 6框架揭祕範例演示[31]:路由"高階"用法

2022-08-02 12:00:41

ASP.NET的路由是通過EndpointRoutingMiddleware和EndpointMiddleware這兩個中介軟體共同作業完成的,它們在ASP.NET平臺上具有舉足輕重的地位,MVC和gRPC框架,Dapr的Actor和釋出訂閱程式設計模式都建立在路由系統之上。Minimal API更是將提升到了前所未有的高度,上一篇通過9個範例演示了基於路由的REST API開發,本篇演示一些「高階」的用法。

[S2010]解析路由模式 (原始碼
[S2011]利用多箇中介軟體來構建終結點處理器(原始碼
[S2012]在引數上標註特性來決定繫結的資料來源(原始碼
[S2013]預設的引數繫結規則(原始碼
[S2014]針對TryPar[Se方法的引數繫結(原始碼
[S2015]針對BindA[Sync方法的引數繫結(原始碼
[S2016]自定義路由約束(原始碼

[S2010]解析路由模式

下面我們通過一個簡單的範例演示如何利用RoutePatternFactory物件解析指定的路由模板,並生成對應的RoutePattern物件。我們定義瞭如下所示的Format方法將指定的RoutePattern物件格式化成一個字串。

static string Format(RoutePattern pattern)
{
    var builder = new StringBuilder();
    builder.AppendLine($"RawText:{pattern.RawText}");
    builder.AppendLine($"InboundPrecedence:{pattern.InboundPrecedence}");
    builder.AppendLine($"OutboundPrecedence:{pattern.OutboundPrecedence}");
    var segments = pattern.PathSegments;
    builder.AppendLine("Segments");
    foreach (var segment in segments)
    {
        foreach (var part in segment.Parts)
        {
            builder.AppendLine($"\t{ToString(part)}");
        }
    }
    builder.AppendLine("Defaults");
    foreach (var @default in pattern.Defaults)
    {
        builder.AppendLine($"\t{@default.Key} = {@default.Value}");
    }

    builder.AppendLine("ParameterPolicies ");
    foreach (var policy in pattern.ParameterPolicies)
    {
        builder.AppendLine( $"\t{policy.Key} = {string.Join(',',policy.Value.Select(it => it.Content))}");
    }

    builder.AppendLine("RequiredValues");
    foreach (var required in pattern.RequiredValues)
    {
        builder.AppendLine($"\t{required.Key} = {required.Value}");
    }

    return builder.ToString();

    static string ToString(RoutePatternPart part)
        => part switch
        {
            RoutePatternLiteralPart literal => $"Literal: {literal.Content}",
            RoutePatternSeparatorPart separator => $"Separator: {separator.Content}",
            RoutePatternParameterPart parameter => @$"Parameter: Name = {parameter.Name}; Default = {parameter.Default}; IsOptional = { parameter.IsOptional}; IsCatchAll = { parameter.IsCatchAll};ParameterKind = { parameter.ParameterKind}",
            _ => throw new ArgumentException("Invalid RoutePatternPart.")
        };
}

如下的演示程式呼叫了RoutePatternFactory 型別的靜態方法Parse解析指定的路由模板「weather/{city:regex(^0\d{{2,3}}$)=010}/{days:int:range(1,4)=4}/{detailed?}」生成一個RoutePattern物件,我們在呼叫該方法時還指定了requiredValues引數。我們呼叫建立的WebApplication物件的MapGet方法註冊了針對根路徑「/」的終結點,對應的處理器直接返回RoutePattern物件格式化生成的字串。

using Microsoft.AspNetCore.Routing.Patterns;
using System.Text;

var template =@"weather/{city:regex(^0\d{{2,3}}$)=010}/{days:int:range(1,4)=4}/{detailed?}";
var pattern = RoutePatternFactory.Parse(
    pattern: template,
    defaults: null,
    parameterPolicies: null,
    requiredValues: new { city = "010", days = 4 });

var app = WebApplication.Create();
app.MapGet("/", ()=> Format(pattern));
app.Run();

如果利用瀏覽器存取啟動後的應用程式,回到得到如圖1所示結果,它結構化地展示了路由模式的原始文字、出入棧路由匹配權重、每個段的組成、路由引數的預設值和引數策略,以及生成URL必須提供的預設引數值。

image

圖1 針對路由模式的解析

[S2011]利用多箇中介軟體來構建終結點處理器

如果某個終結點針對請求處理的邏輯相對複雜,需要多箇中介軟體協同完成,我們可以呼叫IEndpointRouteBuilder 物件的CreateApplicationBuilder方法建立一個新的IApplicationBuilder物件,並將這些中介軟體註冊到這個該物件上,最後利用它這些中介軟體轉換成RequestDelegate委託。

var app = WebApplication.Create();
IEndpointRouteBuilder routeBuilder = app;
app.MapGet("/foobar", routeBuilder.CreateApplicationBuilder()
    .Use(FooMiddleware)
    .Use(BarMiddleware)
    .Use(BazMiddleware)
    .Build());
app.Run();

static async Task FooMiddleware(HttpContext context,RequestDelegate next)
{
    await context.Response.WriteAsync("Foo=>");
    await next(context);
};
static async Task BarMiddleware(HttpContext context, RequestDelegate next)
{
    await context.Response.WriteAsync("Bar=>");
    await next(context);
};
static Task BazMiddleware(HttpContext context, RequestDelegate next) => context.Response.WriteAsync("Baz");

上面的演示程式註冊了一個路徑模板為「foobar」的路由,並註冊了三個中介軟體來處理路由的請求。該演示程式啟動之後,如果我們利用瀏覽器對路由地址「/foobar」發起請求,將會得到如圖2所示的輸出結果。呈現出來的字串是通過註冊的三個中介軟體(FooMiddleware、BarMiddleware和BazMiddleware)輸出內容組合而成。

image

圖2 輸出結果

[S2012]在引數上標註特性來決定繫結的資料來源

如下這個演示程式呼叫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物件提供的服務(S2012)。

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"}

[S2013]預設的引數繫結規則

如果請求處理器方法的引數沒有顯式指定繫結資料的來源,路由系統也能根據引數的型別儘可能地從當前HttpContext上下文中提取相應的內容予以繫結。針對如下這幾個型別,對應引數的繫結源是明確的。

  • HttpContext:繫結為當前HttpContext上下文。
  • HttpRequest:繫結為當前HttpContext上下文的Request屬性。
  • HttpResponse: 繫結為當前HttpContext上下文的Response屬性。
  • ClaimsPrincipal: 繫結為當前HttpContext上下文的User屬性。
  • CancellationToken: 繫結為當前HttpContext上下文的RequestAborted屬性。

上述的繫結規則體現在如下演示程式的偵錯斷言中。這個演示範例還體現了另一個繫結規則,那就是隻要當前請求的IServiceProvider能夠提供對應的服務,對應引數(「httpContextAccessor」)上標註的FromSerrvicesAttribute特性不是必要的。但是倘若缺少對應的服務註冊,請求的主體內容會一般會作為預設的資料來源,所以FromSerrvicesAttribute特性最好還是顯式指定為好。對於我們演示的這個例子,如果我們將前面針對AddHttpContextAccessor方法的呼叫移除,對應引數的繫結自然會失敗,但是錯誤訊息並不是我們希望看到的(S2013)。

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

[S2014]針對TryParse方法的引數繫結

如果我們在某個型別中定義了一個名為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

圖3 TryParse方法針對引數繫結的影響

[S2015]針對BindAsync方法的引數繫結

如果某種型別的引數具有特殊的繫結方式,我們還可以將具體的繫結實現在一個按照約定定義的BindAsync方法中。按照約定,這個BindAsync應該定義成返回型別為ValueTask<T>的靜態方法,它可以擁有一個型別為HttpContext的引數,也可以額外提供一個ParameterInfo型別的引數,這兩個引數分別與當前HttpContext上下文和描述引數的ParameterInfo物件繫結。前面演示範例中為Point型別定義了一個TryParse方法可以替換成如下這個 BingAsync方法(S2015)。

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

[S2016]自定義路由約束

我們可以使用預定義的IRouteConstraint實現型別完成一些常用的約束,但是在一些對路由引數具有特定約束的應用場景中,我們不得不建立自定義的約束型別。舉個例子,如果需要對資源提供針對多語言的支援,最好的方式是在請求的URL中提供對應的Culture。為了確保包含在URL中的是一個合法有效的Culture,最好為此定義相應的約束。下面將通過一個簡單的範例來演示如何建立這樣一個用於驗證Culture的自定義路由約束。我們建立了一個提供基於不同語言資源的API。我們將資原始檔作為文字資源進行儲存,如圖4所示,我們建立了兩個資原始檔 (Resources.resx和Resources.zh.resx),並定義了一個名為hello的文字資源條目。

image

圖4 儲存文字資源的兩個資原始檔

如下演示程式中註冊了一個模板為「resources/{lang:culture}/{resourceName:required}」的終結點。路由引數「{resourceName}」表示資源條目的名稱(比如「hello」),另一個路由引數「{lang}」表示指定的語言,約束表示式名稱culture對應的就是我們自定義的針對語言文化的約束型別CultureConstraint。因為這是一個自定義的路由約束,我們通過呼叫IServiceCollection介面的Configure<TOptions>方法將此約束採用的表示式名稱(「culture」)和CultureConstraint型別之間的對映關係新增到RouteOptions設定選項中。

using App;
using App.Properties;
using System.Globalization;

var builder = WebApplication.CreateBuilder();
var template = "resources/{lang:culture}/{resourceName:required}";
builder.Services.Configure<RouteOptions>(options => options.ConstraintMap.Add("culture", typeof(CultureConstraint)));
var app = builder.Build();
app.MapGet(template, GetResource);
app.Run();

static IResult GetResource(string lang, string resourceName)
{
    CultureInfo.CurrentUICulture = new CultureInfo(lang);
    var text = Resources.ResourceManager.GetString(resourceName);
    return string.IsNullOrEmpty(text)? Results.NotFound(): Results.Content(text);
}

該終結點的處理方法GetResource定義了兩個引數,我們知道它們會自動繫結為同名的路由引數。由於系統自動根據當前執行緒的UICulture來選擇對應的資原始檔,我們對CultureInfo型別的CurrentUICulture靜態屬性進行了設定。如果從資原始檔將對應的文字提取出來,我們將建立一個ContentResult物件並返回。應用啟動之後,我們可以利用瀏覽器指定匹配的URL獲取對應語言的文字。如圖5所示,如果指定一個不合法的語言(如「xx」),將會違反我們自定義的約束,此時就會得到一個狀態碼為「404 Not Found」的響應。

image

圖5 採用相應的URL得到某個資源針對某種語言的內容

我們來看看針對語言文化的路由約束CultureConstraint究竟做了什麼。如下面的程式碼片段所示,我們在Match方法中會試圖獲取作為語言文化內容的路由引數值,如果存在這樣的路由引數,就可以利用它建立一個CultureInfo物件。如果這個CultureInfo物件的EnglishName屬性名不以「Unknown Language」字串作為字首,我們就認為指定的是合法的語言檔案。

public class CultureConstraint : IRouteConstraint
{
    public bool Match(HttpContext? httpContext, IRouter? route, string routeKey,RouteValueDictionary values, RouteDirection routeDirection)
    {
        try
        {
            if (values.TryGetValue(routeKey, out var value) && value is not null)
            {
                return !new CultureInfo((string)value)
                    .EnglishName.StartsWith("Unknown Language");
            }
            return false;
        }
        catch
        {
            return false;
        }
    }
}