ASP.NET Core 6框架揭祕實例演示[30]:利用路由開發REST API

藉助路由系統提供的請求URL模式與對應終結點之間的映射關係,我們可以將具有相同URL模式的請求分發給與之匹配的終結點進行處理。ASP.NET的路由是通過EndpointRoutingMiddleware和EndpointMiddleware這兩個中間件協作完成的,它們在ASP.NET平臺上具有舉足輕重的地位,MVC和gRPC框架,Dapr的Actor和發佈訂閱編程模式都建立在路由系統之上。Minimal API更是將提升到了前所未有的高度,是我們直接在路由系統基礎上定義REST API。(本篇提供的實例已經彙總到《ASP.NET Core 6框架揭祕-實例演示版》)

[S2001]註冊路由終結點 (源代碼
[S2002]以內聯方式設置路由參數的約束(源代碼
[S2003]定義可缺省的路由參數(源代碼
[S2004]爲路由參數指定默認值(源代碼
[S2005]一個路徑分段定義多個路由參數(源代碼
[S2006]一個路由參數跨越多個路徑分段(源代碼
[S2007]主機名綁定(源代碼
[S2008]將終結點處理定義爲任意類型的委託(源代碼
[S2009]IResult 的應用(源代碼

[S2001]註冊路由終結點

我們演示的這個ASP.NET應用是一個簡易版的天氣預報站點。服務端利用註冊的一個終結點來提供某個城市在未來N天之內的天氣信息,對應城市(採用電話區號表示)和天數直接至於請求URL的路徑中。如圖1所示,爲了得到成都未來兩天的天氣信息,我們將發送請求的路徑設置爲“weather/028/2”。路徑爲“weather/0512/4”的請求返回就是蘇州未來4天的天氣信息。

image

圖1 獲取天氣預報信息

演示程序定義瞭如下這個WeatherReport記錄類型來表示某個城市在某段時間範圍內的天氣報告。如代碼片段所示,某一天的天氣體現爲一個WeatherInfo記錄。簡單起見,我們讓WeatherInfo記錄只攜帶基本天氣狀況和氣溫區間的信息。

public readonly record struct WeatherInfo(string Condition, double HighTemperature, double LowTemperature);
public readonly record struct WeatherReport(string CityCode, string CityName,IDictionary<DateTime, WeatherInfo> WeatherInfos);

我們定義瞭如下這個工具類型WeatherReportUtility,兩個Generate方法會根據指定的城市代碼和天數/日期生成一份由WeatherReport對象表示的天氣報告。爲了將這份報告呈現在網頁上,我們定義了另一個RenderAsync方法將指定的WeatherReport轉換成HTML,並利用指定的HttpContext上下文將它作爲響應內容,具體的HTML內容由AsHtml方法生成。

public static class WeatherReportUtility
{
    private static readonly Random _random = new();
    private static readonly Dictionary<string, string> _cities = new()
    {
        ["010"] = "北京",
        ["028"] = "成都",
        ["0512"] = "蘇州"
    };
    private static readonly string[] _conditions = new string[] { "", "多雲", "小雨" };
    public static WeatherReport Generate(string city, int days)
    {
        var report = new WeatherReport(city, _cities[city],  new Dictionary<DateTime, WeatherInfo>());
        for (int i = 0; i < days; i++)
        {
            report.WeatherInfos[DateTime.Today.AddDays(i + 1)] = new WeatherInfo(_conditions[_random.Next(0, 2)], _random.Next(20, 30), _random.Next(10, 20));
        }
        return report;
    }
    public static WeatherReport Generate(string city, DateTime date)
    {
        var report = new WeatherReport(city, _cities[city],  new Dictionary<DateTime, WeatherInfo>());
        report.WeatherInfos[date] = new WeatherInfo(_conditions[_random.Next(0, 2)], _random.Next(20, 30), _random.Next(10, 20));
        return report;
    }
    public static Task RenderAsync(HttpContext context, WeatherReport report)
    {
        context.Response.ContentType = "text/html;charset=utf-8";
        return context.Response.WriteAsync(AsHtml(report));
    }

    public static string AsHtml(WeatherReport report)
    {
        return @$"
<html>
<head><title>Weather</title></head>
<body>
<h3>{report.CityName}</h3>
{AsHtml(report.WeatherInfos)}
</body>
</html>
";
        static string AsHtml(IDictionary<DateTime, WeatherInfo> dictionary)
        {
            var builder = new StringBuilder();
            foreach (var kv in dictionary)
            {
                var date = kv.Key.ToString("yyyy-MM-dd");
                var tempFrom = $"{kv.Value.LowTemperature}℃ ";
                var tempTo = $"{kv.Value.HighTemperature}℃ ";
                builder.Append( $"{date}: {kv.Value.Condition} ({tempFrom}~{tempTo})<br/></br>");
            }
            return builder.ToString();
        }
    }
}

Minimal API會默認添加針對路由的服務註冊,完成路由的兩個中間件(RoutingMiddleware和EndpointRoutingMiddleware)也會在自動註冊到創建的WebApplication對象上。WebApplication類型同時實現了IEndpointRouteBuilder接口,我們只需要利用它註冊相應的終結點就可以了。如下的演示程序調用了WebApplication對象的MapGet方法註冊了一個僅針對GET請求的終結點,終結點採用的路徑模板爲“weather/{city}/{days}”,攜帶的兩個路由參數({city}和{days})分別代表目標城市代碼(區號)和天數。

using App;
var app = WebApplication.Create();
app.MapGet("weather/{city}/{days}", ForecastAsync);
app.Run();

static Task ForecastAsync(HttpContext context)
{
    var routeValues = context.GetRouteData().Values;
    var city = routeValues["city"]!.ToString();
    var days = int.Parse(routeValues["days"]!.ToString()!);
    var report = WeatherReportUtility.Generate(city!, days);
    return WeatherReportUtility.RenderAsync(context, report);
}

註冊中間件採用的處理器是一個RequestDelegate委託,我們將它指向ForecastAsync方法。該方法調用HttpContext上下文的GetRouteData方法得到承載“路由數據”的RouteData對象,後者的Values屬性返回路由參數字典。我們從中提取出代表城市代碼和天數的路由參數,並創建出對應的天氣報告,最後將其轉換成HTML作爲響應內容。

[S2002]以內聯方式設置路由參數的約束

上面的演示實例註冊的路由模板中定義了兩個參數({city}和{days}),分別表示獲取天氣預報的目標城市對應的區號和天數。區號應該具有一定的格式(以零開始的3~4位數字),而天數除了必須是一個整數,還應該具有一定的範圍。由於沒有對這兩個路由參數坐任何約束,所以請求URL攜帶的任何字符都是有效的。ForecastAsync方法也並沒有對提取的路由參數做任何驗證,所以在執行過程中面對不合法的輸入會直接拋出異常。

爲了確保路由參數值的有效性,在進行中間件註冊時可以採用內聯(Inline)的方式直接將相應的約束規則定義在路由模板中。ASP.NET爲常用的驗證規則定義了相應的約束表達式,我們可以根據需要爲某個路由參數指定一個或者多個約束表達式。如下面的代碼片段所示,我們爲路由參數“{city}”指定了一個基於“區號”的正則表達式(“:regex(^0[1-9]{{2,3}}$)”)。另一個路由參數{days}則應用了兩個約束,一個是針對數據類型的約束(“:int”),另一個是針對區間的約束(“:range(1,4)”)。

using App;
var template = @"weather/{city:regex(^0\d{{2,3}}$)}/{days:int:range(1,4)}";
var app = WebApplication.Create();
app.MapGet(template, ForecastAsync);
app.Run();

如果在註冊路由時應用了約束,那麼RoutingMiddleware中間件在進行路由解析時除了要求請求路徑必須與路由模板具有相同的模式,還要求攜帶的數據滿足對應路由參數的約束條件。如果不能同時滿足這兩個條件,RoutingMiddleware中間件將無法選擇一個終結點來處理當前請求。對於我們演示的這個實例來說,如果提供的是一個不合法的區號(1014)和預報天數(5),那麼客戶端都將得到圖2所示的狀態碼爲“404 Not Found”的響應。

image

圖2 不滿足路由約束而返回的“404 Not Found”響應

[S2003]定義可缺省的路由參數

路由模板(如“weather/{city}/{days}”)可以包含靜態的字符(如“weather”),也可以包含動態的參數(如{city}和{days}),我們將後者稱爲路由參數。並非每個路由參數都必須有請求URL對應的部分來指定,如果賦予路由參數一個默認值,那麼它在請求URL中就是可以缺省的。對上面演示的實例來說,我們可以採用如下方式在路由參數名後面添加一個問號(“?”)將原本必需的路由參數變成可以缺省的默認參數的。可以缺省的路由參數與在方法中定義可缺省的(Optional)params參數一樣,只能出現在路由模板尾部。

using App;

var template = "weather/{city?}/{days?}";
var app = WebApplication.Create();
app.MapGet(template, ForecastAsync);
app.Run();

static Task ForecastAsync(HttpContext context)
{
    var routeValues = context.GetRouteData().Values;
    var city = routeValues.TryGetValue("city", out var v1) ? v1!.ToString() : "010";
    var days = routeValues.TryGetValue("days", out var v2) ? v1!.ToString() : "4";
    var report = WeatherReportUtility.Generate(city!, int.Parse(days!));
    return WeatherReportUtility.RenderAsync(context, report);
}

既然路由變量佔據的部分路徑是可以缺省的,那麼即使請求的URL不具有對應的值(如“weather”和“weather/010”),它與路由規則也是匹配的,但此時在路由參數字典中是找不到它們的。此時我們不得不對處理請求的ForecastAsync方法進行相應的改動。針對上述改動,如果希望獲取北京未來4天的天氣狀況,我們可以採用圖3所示的三種URL(“weather”、“weather/010”和“weather/010/4”),這三個請求的URL本質上是完全等效的。

image

圖3 不同URL針對默認路由參數的等效性

[S2004]爲路由參數指定默認值

實際上可缺省路由參數默認值的設置還有一種更簡單的方式,那就是按照如下所示的方式直接將默認值定義在路由模板中。這樣針對ForecastAsync方法的改動就完全沒有必要。

using App;

var template = @"weather/{city=010}/{days=4}";
var app = WebApplication.Create();
app.MapGet(template, ForecastAsync);
app.Run();

static Task ForecastAsync(HttpContext context)
{
    var routeValues = context.GetRouteData().Values;
    var city = routeValues["city"]!.ToString();
    var days = int.Parse(routeValues["days"]!.ToString()!);
    var report = WeatherReportUtility.Generate(city!, days);
    return WeatherReportUtility.RenderAsync(context, report);
}

[S2005]一個路徑分段定義多個路由參數

一個URL可以通過分隔符“/”劃分爲多個路徑分段(Segment),路由參數一般來說會佔據某個獨立的分段(如“weather/{city}/{days}”)。但也有例外情況,我們既可以在一個單獨的路徑分段中定義多個路由參數,也可以讓一個路由參數跨越多個連續的路徑分段。以我們的演示程序爲例,我們需要設計一種路徑模式來獲取某個城市某一天的天氣信息,如使用“/weather/010/2019.11.11”這樣URL獲取北京在2019年11月11日的天氣,對應模板爲“/weather/{city}/{year}.{month}.{day}”。

using App;

var template = "weather/{city}/{year}.{month}.{day}";
var app = WebApplication.Create();
app.MapGet(template, ForecastAsync);
app.Run();

static Task ForecastAsync(HttpContext context)
{
    var routeValues = context.GetRouteData().Values;
    var city = routeValues["city"]!.ToString();
    var year = int.Parse(routeValues["year"]!.ToString()!);
    var month = int.Parse(routeValues["month"]!.ToString()!);
    var day = int.Parse(routeValues["day"]!.ToString()!);
    var report = WeatherReportUtility.Generate(city!, new DateTime(year,month,day));
    return WeatherReportUtility.RenderAsync(context, report);
}

對於修改後的程序,如果採用“/weather/{city}/{yyyy}.{mm}.{dd}”這樣的URL,我們就可以獲取某個城市指定日期的天氣。如圖4所示,我們採用請求路徑“/weather/010/2019.11.11”可以獲取北京在2019年11月11日的天氣。

image

圖4 一個路徑分段定義多個路由參數

[S2006]一個路由參數跨越多個路徑分段

上面設計的路由模板採用“.”作爲日期分隔符,如果採用“/”作爲日期分隔符(如2019/11/11),這個路由默認應該如何定義呢?由於“/”同時也是路徑分隔符,就意味着同一個路由參數跨越了多個路徑分段,這種情況只能採用“通配符”的形式才能達成我們的目標。通配符路由參數採用{*variable}或者{**variable}的形式,星號(*)表示路徑“餘下的部分”,所以這樣的路由參數也只能出現在模板的尾端。演示程序的路由模板可以定義成“/weather/{city}/{*date}”。

using App;
using System.Globalization;

var template = "weather/{city}/{*date}";
var app = WebApplication.Create();
app.MapGet(template, ForecastAsync);
app.Run();

static Task ForecastAsync(HttpContext context)
{
    var routeValues = context.GetRouteData().Values;
    var city = routeValues["city"]!.ToString();
    var date = DateTime.ParseExact(routeValues["date"]?.ToString()!,"yyyy/MM/dd",CultureInfo.InvariantCulture);
    var report = WeatherReportUtility.Generate(city!, date);
    return WeatherReportUtility.RenderAsync(context, report);
}

我們可以對程序做如上修改來使用新的URL模板(“/weather/{city}/{*date}”)。爲了得到北京在2019年11月11日的天氣,請求的URL可以替換成“/weather/010/2019/11/11”,返回的天氣信息如圖5所示。

image

圖5 一個路由參數跨越多個路徑分段

[S2007]主機名綁定

一般來說,在利用某路由終結點與待路由的請求進行匹配的時候只需要考慮請求地址的路徑部分,並忽略主機(Host)名稱和端口號,但是一定要加上針對主機名稱(含端口)的匹配策略也未嘗不可。在如下這個演示程序中,我們通過調用MapGet擴展方法爲根路徑“/”添加了三個路由終結點,並調用該方法返回的IEndpointConventionBuilder對象的RequireHost擴展方法綁定了對應的主機名(“*.artech.com”、“www.foo.artech.com”和“www.foo.artech.com:9999”)。指定的第一個主機名包含一個前置通配符“*”,最後一個則指定了端口號。註冊的這三個終結點會直接將指定的主機名作爲響應內容。

var app = WebApplication.Create();
app.Urls.Add("http://0.0.0.0:6666");
app.Urls.Add("http://0.0.0.0:9999");
app
    .MapHost("*.artech.com")
    .MapHost("www.foo.artech.com")
    .MapHost("www.foo.artech.com:9999");
app.Run();

internal static class Extensions
{
    public static IEndpointRouteBuilder MapHost(this IEndpointRouteBuilder endpoints,string host)
    {
        endpoints.MapGet("/", context => context.Response.WriteAsync(host)).RequireHost(host);
        return endpoints;
    }
}

爲了能夠在本機採用不同的域名對演示應用發起請求,我們通過修改Hosts文件的方式將本地地址(“127.0.0.1”)映射爲多個不同的域名。我們以管理員(Administrator)身份打開文件Hosts “%windir%\System32\drivers\etc\hosts”,並以如下所示的方式添加了針對兩個域名的映射。

127.0.0.1 www.foo.artech.com
127.0.0.1 www.bar.artech.com

應用啓動之後,我們利用瀏覽器使用不同的域名和端口對其發起請求,並得到如圖6所示的輸出結果。輸出的內容不僅僅體現了終結點選擇過程中針對主機名的過濾,還體現了終結點選擇策略的一個重要的特性,那就是路由系統總是試圖選擇一個與當前請求匹配度最高的終結點,而不是選擇第一個匹配的終結點。

image

圖6 主機名綁定

[S2008]將終結點處理定義爲任意類型的委託

上面的例子都直接使用一個RequestDelegate委託作爲終結點的處理器,實際上我們在註冊終結點時可以將處理器設置爲任何類型的委託都可以。當路由請求分發給註冊的委託進行處理器時,會盡可能地從當前HttpContext上下文中提取相應的數據對委託的輸入參數進行綁定。對於委託的執行結果,路由系統也會按照預定義的規則“智能”地將它應用到針對請求的響應中。按照這個規則,我們演示程序中用來處理請求的ForecastAsync方法可以簡寫成如下形式。第一個參數會自動綁定爲當前HttpContext上下文,後面的兩個參數則自動與同名的路由參數進行綁定。

using App;

var app = WebApplication.Create();
app.MapGet("weather/{city}/{days}", ForecastAsync);
app.Run();

static Task ForecastAsync(HttpContext context, string city, int days)
{
    var report = WeatherReportUtility.Generate(city,days);
    return WeatherReportUtility.RenderAsync(context, report);
}

[S2009]IResult 的應用

不論終結點處理器的委託返回何種類型的對象,路由系統總能做出對應的處理。比如對於返回的字符串會直接作爲響應的主體內容,並將Content-Type報頭設置爲“text/plain”。如果希望對返回對象具有明確的控制,最好返回一個IResult對象(或者Task<IResult>和ValueTask<IResult>),IResult相當ASP.NET MVC中的IActionResult。我們演示程序中的ForecastAsync方法也可以改寫成如下這個返回類型爲IResult的Forecast方法,該方法通過調用Results類型的靜態Content方法返回一個ContentResult對象,它將天氣報告轉換成的HTML作爲響應類型,Content-Type報頭設置爲 “text/html” 。

using App;

var app = WebApplication.Create();
app.MapGet("weather/{city}/{days}", Forecast);
app.Run();

static IResult Forecast(HttpContext context, string city, int days)
{
    var report = WeatherReportUtility.Generate(city,days);
    return Results.Content(WeatherReportUtility.AsHtml(report), "text/html");
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章