1. 程式人生 > >ASP.NET Core路由中介軟體[1]: 終結點與URL的對映

ASP.NET Core路由中介軟體[1]: 終結點與URL的對映

藉助路由系統提供的請求URL模式與對應終結點(Endpoint)之間的對映關係,我們可以將具有相同URL模式的請求分發給應用的終結點進行處理。ASP.NET Core的路由是通過EndpointRoutingMiddleware和EndpointMiddleware這兩個中介軟體協作完成的,它們在ASP.NET Core平臺上具有舉足輕重的地位,因為ASP.NET Core MVC框架就建立在這個中介軟體之上。可以將一個ASP.NET Core應用視為一組終結點的組合,所謂的終結點可以理解為能夠通過HTTP請求的形式訪問的遠端服務。每個終結點通過RequestDelegate物件來處理路由過來的請求。ASP.NET Core的路由是通過EndpointRoutingMiddleware和EndpointMiddleware這兩個中介軟體來實現的,這兩個中介軟體型別都定義在NuGet包“Microsoft.AspNetCore.Routing”中。為了使讀者對實現在RouterMiddleware的路由功能有一個大體的認識,下面先演示幾個簡單的例項。[更多關於ASP.NET Core的文章請點這裡]

目錄
一、路由註冊
二、設定內聯約束
三、預設路由引數
四、特殊的路由引數

一、路由註冊

我們演示的這個ASP.NET Core應用是一個簡易版的天氣預報站點。如果使用者希望獲取某個城市在未來N天之內的天氣資訊,他可以直接利用瀏覽器傳送一個GET請求並將對應城市(採用電話區號表示)和天數設定在URL中。如下圖所示,為了得到成都未來兩天的天氣資訊,我們將傳送請求的路徑設定為“weather/028/2”。對於採用路徑“weather/0512/4”的請求,返回的自然就是蘇州未來4天的天氣資訊。

為了開發這個簡單的應用,我們定義瞭如下所示的WeatherReport型別,表示某個城市在某段時間範圍內的天氣。如下面的程式碼片段所示,我們還定義了另一個WeatherInfo型別,表示具體某一天的天氣。簡單起見,我們讓WeatherInfo物件只攜帶基本天氣狀況和氣溫區間的資訊。建立一個WeatherReport物件時,我們會隨機生成這些天氣資訊。

public class WeatherReport
{
    private static string[] _conditions = new string[] { "晴", "多雲", "小雨" };
    private static Random _random = new Random();

    public string City { get; }
    public IDictionary<DateTime, WeatherInfo> WeatherInfos { get; }

    public WeatherReport(string city, int days)
    {
        City = city;
        WeatherInfos = new Dictionary<DateTime, WeatherInfo>();
        for (int i = 0; i < days; i++)
        {
            WeatherInfos[DateTime.Today.AddDays(i + 1)] = new WeatherInfo
            {
                Condition = _conditions[_random.Next(0, 2)],
                HighTemperature = _random.Next(20, 30),
                LowTemperature = _random.Next(10, 20)
            };
        }
    }

    public WeatherReport(string city, DateTime date)
    {
        City = city;
        WeatherInfos = new Dictionary<DateTime, WeatherInfo>
        {
            [date] = new WeatherInfo
            {
                Condition = _conditions[_random.Next(0, 2)],
                HighTemperature = _random.Next(20, 30),
                LowTemperature = _random.Next(10, 20)
            }
        };
    }

    public class WeatherInfo
    {
        public string Condition { get; set; }
        public double HighTemperature { get; set; }
        public double LowTemperature { get; set; }
    }
}

由於用於處理請求的處理器最終體現為一個RequestDelegate物件,所以我們定義瞭如下一個與這個委託型別具有一致宣告的WeatherForecast方法來處理對應的請求。如下面的程式碼片段所示,我們在這個方法中直接呼叫HttpContext的GetRouteData擴充套件方法提取RoutingMiddleware中介軟體在路由解析過程中設定的路由引數。GetRouteData擴充套件方法返回的是一個具有字典結構的物件,它的Key和Value分別代表路由引數的名稱與值,通過預先定義的引數名(city和days)可以得到目標城市和預報天數。

public class Program
{
    private static Dictionary<string, string> _cities = new Dictionary<string, string>
    {
        ["010"] = "北京",
        ["028"] = "成都",
        ["0512"] = "蘇州"
    };

    public static async Task WeatherForecast(HttpContext context)
    {
        var city = (string)context.GetRouteData().Values["city"];
        city = _cities[city];
        int days = int.Parse(context.GetRouteData().Values["days"].ToString());
        var report = new WeatherReport(city, days);
        await RendWeatherAsync(context, report);
    }

    private static async Task RendWeatherAsync(HttpContext context, WeatherReport report)
    {
        context.Response.ContentType = "text/html;charset=utf-8";
        await context.Response.WriteAsync("<html><head><title>Weather</title></head><body>");
        await context.Response.WriteAsync($"<h3>{report.city}</h3>");
        foreach (var it in report.WeatherInfos)
        {
            await context.Response.WriteAsync($"{it.Key.ToString("yyyy-MM-dd")}:");
            await context.Response.WriteAsync($"{it.Value.Condition}({ it.Value.LowTemperature}℃ ~ { it.Value.HighTemperature}℃)< br />< br /> ");
        }
        await context.Response.WriteAsync("</body></html>");
    }    
    ...
}

有了這兩個核心引數之後,我們可以據此生成一個WeatherReport物件,並將它攜帶的天氣資訊以一個HTML文件的形式響應給客戶端,圖15-1就是這個HTML文件在瀏覽器上的呈現效果。由於目標城市最初以電話區號的形式體現,所以在呈現天氣資訊的過程中我們還會根據區號獲取具體城市的名稱。簡單起見,我們利用一個簡單的字典來維護區號和城市之間的關係,並且只儲存了3個城市而已。

下面完成所需的路由註冊工作。如下面的程式碼片段所示,我們呼叫IApplicationBuilder的UseRouting方法和UseEndpoints方法分別完成針對EndpointRoutingMiddleware與EndpointMiddleware這兩個終結點的註冊。由於它們在進行路由解析過程中需要使用一些服務,所以可以呼叫IServiceCollection的AddRouting擴充套件方法來對它們進行註冊。

public class Program
{
    public static void Main()
    {
        Host.CreateDefaultBuilder()
            .ConfigureWebHostDefaults(builder => builder
                .ConfigureServices(svcs => svcs.AddRouting())
                .Configure(app => app
                    .UseRouting()
                    .UseEndpoints(endpoints=> endpoints.MapGet("weather/{city}/{days}", WeatherForecast))))
            .Build()
            .Run();
    }
}

UseEndpoints方法提供了一個Action<IEndpointRouteBuilder>型別的引數,我們利用這個引數呼叫IEndpointRouteBuilder的MapGet方法提供了一個路由模板與對應處理器之間的對映。我們指定的路徑模板為“weather/{city}/{days}”,其中攜帶兩個路由引數({city}和{days}),分別代表獲取天氣預報的目標城市和天數。由於針對天氣請求的處理實現在WeatherForecast方法中,所以將指向這個方法的RequestDelegate物件作為第二個引數。MapGet的字尾“Get”表示HTTP方法,這意味著與指定路由模板的模式相匹配的GET請求才會被路由到WeatherForecast方法對應的終結點。

二、設定內聯約束

上面的演示例項註冊的路由模板中定義了兩個引數({city}和{days}),分別表示獲取天氣預報的目標城市對應的區號和天數。區號應該具有一定的格式(以零開始的3~4位數字),而天數除了必須是一個整數,還應該具有一定的範圍。由於我們在註冊的時候並沒有為這個兩個路由引數的值做任何約束,所以請求URL攜帶的任何字元都是有效的。而處理請求的WeatherForecast方法也並沒有對提取的資料做任何驗證,所以在執行過程中面對不合法的輸入會直接丟擲異常。如下圖所示,由於請求URL(“/weather/0512/iv”)指定的天數不合法,所以客戶端接收到一個狀態為“500 Internal Server Error”的響應。

為了確保路由引數值的有效性,在進行路由註冊時可以採用內聯(Inline)的方式直接將相應的約束規則定義在路由模板中。ASP.NET Core為常用的驗證規則定義了相應的約束表示式,我們可以根據需要為某個路由引數指定一個或者多個約束表示式。如下面的程式碼片段所示,為了確保URL攜帶的是合法的區號,我們為路由引數{city}指定了一個針對正則表示式的約束(:regex(^0[1-9]{{2,3}}$))。由於路由模板在被解析時會將{value}這樣的字元理解為路由引數,如果約束表示式需要使用字元“{}”(如正則表示式^0[1-9]{2,3}$)),就需要採用“{{}}”進行轉義。而路由引數{days}則應用了兩個約束:第一個是針對資料型別的約束(:int),它要求引數值必須是一個整數;第二個是針對區間的約束(:range(1,4)),意味著我們的應用最多隻提供未來4天的天氣。

public class Program
{
    public static void Main()
    {
        var template = @"weather/{city:regex(^0\d{{2,3}}$)}/{days:int:range(1,4)}";
        Host.CreateDefaultBuilder()
            .ConfigureWebHostDefaults(builder => builder
                .ConfigureServices(svcs => svcs.AddRouting())
                .Configure(app => app
                    .UseRouting()
                    .UseEndpoints(routes => routes.MapGet(template, WeatherForecast))))
            .Build()
            .Run();    
    }
    ...
}

如果在註冊路由時應用了約束,那麼RoutingMiddleware中介軟體在進行路由解析時除了要求請求路徑必須與路由模板具有相同的模式,還要求攜帶的資料滿足對應路由引數的約束條件。如果不能同時滿足這兩個條件,RoutingMiddleware中介軟體將無法選擇一個終結點來處理當前請求,在此情況下它會將請求直接遞交給後續中介軟體進行處理。對於我們演示的這個例項來說,如果提供的是一個不合法的區號(1024)和預報天數(5),那麼客戶端都將得到下圖所示的狀態碼為“404 Not Found”的響應。

三、預設路由引數

路由註冊時提供的路由模板(如“weather/{city}/{days}”)可以包含靜態的字元(如weather),也可以包含動態的引數(如{city}和{days}),我們將後者稱為路由引數。並非每個路由引數都是必需的,有的路由引數是預設的。還是以上面演示的例項來說,我們可以採用如下方式在路由引數名後面新增一個問號(?)將原本必需的路由引數變成可以預設的。預設的路由引數只能出現在路由模板尾部,這個應該不難理解。

public class Program
{    
    public static void Main()
    {
        var template = "weather/{city?}/{days?}";
        Host.CreateDefaultBuilder()
            .ConfigureWebHostDefaults(builder => builder
                .ConfigureServices(svcs => svcs.AddRouting())
                .Configure(app => app
                    .UseRouting()
                    .UseEndpoints(routes => routes.MapGet(template, WeatherForecast))))
            .Build()
            .Run();
    }
    ...
}

既然路由變數佔據的部分路徑是可以預設的,那麼即使請求的URL不具有對應的內容(如“weather”和“weather/010”),它與路由規則也是匹配的,但此時在路由引數字典中是找不到它們的。由於表示目標城市和預測天數的兩個路由引數都是預設的,所以需要對處理請求的WeatherForecast方法做相應的改動。下面的程式碼片段表明:如果請求URL為了顯式提供對應引數的資料,那麼它們的預設值分別為010(北京)和4(天),也就是說,應用預設提供北京未來4天的天氣。

public class Program
{    
    public static async Task WeatherForecast(HttpContext context)
    {
        var routeValues = context.GetRouteData().Values;
        var city = routeValues.TryGetValue("city", out var v1)
            ? (string)v1
            : "010";
        city = _cities[city];
        var days = routeValues.TryGetValue("days", out var v2)
            ? int.Parse(v2.ToString())
            : 4;          
        var report = new WeatherReport(city, days); 
        await RendWeatherAsync(context, report);
    }
    ...
}

針對上述改動,如果希望獲取北京未來4天的天氣狀況,我們可以採用下圖所示的3種URL(“weather”、“weather/010”和“weather/010/4”),它們是完全等效的。

上面的程式相當於在進行請求處理時給予了預設路由引數一個預設值,實際上,路由引數預設值的設定還有一種更簡單的方式,那就是按照如下所示的方式直接將預設值定義在路由模板中。如果採用這樣的路由註冊方式,針對WeatherForecast方法的改動就完全沒有必要。

public class Program
{
    public static void Main()
    {
        var template = "weather/{city=010}/{days=4}";
        Host.CreateDefaultBuilder()
            .ConfigureWebHostDefaults(builder => builder
                .ConfigureServices(svcs => svcs.AddRouting())
                .Configure(app => app
                    .UseRouting()
                    .UseEndpoints(routes => routes.MapGet(template, WeatherForecast))))
            .Build()
            .Run();
    }
    ...
}

四、特殊的路由引數

一個URL可以通過分隔符“/”劃分為多個路徑分段(Segment),路由模板中定義的路由引數一般來說會佔據某個獨立的分段(如“weather/{city}/{days}”)。但也有例外情況,我們既可以在一個單獨的路徑分段中定義多個路由引數,也可以讓一個路由引數跨越多個連續的路徑分段。

下面先介紹在一個獨立的路徑分段中定義多個路由引數的情況。同樣以前面演示的獲取天氣預報的路徑為例,假設設計一種路徑模式來獲取某個城市某一天的天氣資訊,如“/weather/010/2019.11.11”這樣一個URL可以獲取北京在2019年11月11日的天氣,那麼路由模板為“/weather/{city}/{year}.{month}.{day}”。

public class Program
{
    public static void Main()
    {
        var template = "weather/{city}/{year}.{month}.{day}";
        Host.CreateDefaultBuilder().ConfigureWebHostDefaults(builder => builder
            .ConfigureServices(svcs => svcs.AddRouting())
            .Configure(app => app.UseRouter(builder => builder.MapGet(template, WeatherForecast))))
            .Build()
            .Run();
    }

    public static async Task WeatherForecast(HttpContext context)
    {
        var values = context.GetRouteData().Values;
        var city = values["city"].ToString();
        city = _cities[city];
        int year = int.Parse(values["year"].ToString());
        int month = int.Parse(values["month"].ToString());
        int day = int.Parse(values["day"].ToString());
        var report = new WeatherReport(city, new DateTime(year, month, day));
        await RendWeatherAsync(context, report);
    }
    ...
}

由於URL採用了新的設計,所以我們按照如上形式對相關程式進行了相應的修改。現在我們採用“/weather/{city}/{yyyy}.{mm}.{dd}”這樣的URL,就可以獲取某個城市指定日期的天氣。如下圖所示,我們採用請求路徑“/weather/010/2019.11.11”可以獲取北京在2019年11月11日的天氣。

對於上面設計的這個URL來說,我們採用“.”作為日期分隔符,如果採用“/”作為日期分隔符(如2019/11/11),這個路由預設應該如何定義?由於“/”也是路徑分隔符,如果表示日期的路由變數也採用相同的分隔符,就意味著同一個路由引數跨越了多個路徑分段,我們只能採用定義“萬用字元”的形式來達到這個目的。萬用字元路由引數採用{*variable}或者{**variable}的形式,星號(*)表示路徑“餘下的部分”,所以這樣的路由引數只能出現在模板的尾端。對我們的例項來說,路由模板可以定義成“/weather/{city}/{*date}”。

public class Program
{
    public static void Main()
    {
        var template = "weather/{city}/{*date}";
        Host.CreateDefaultBuilder()
            .ConfigureWebHostDefaults(builder => builder
                .ConfigureServices(svcs => svcs.AddRouting())
                .Configure(app => app
                    .UseRouting()
                    .UseEndpoints(routes => routes.MapGet(template, WeatherForecast))))
            .Build()
            .Run();
    }

    public static async Task WeatherForecast(HttpContext context)
    {
        var values = context.GetRouteData().Values;
        var city = values["city"].ToString();
        city = _cities[city];
        var date = DateTime.ParseExact(values["date"].ToString(), "yyyy/MM/dd", CultureInfo.InvariantCulture);
        var report = new WeatherReport(city, date);
        await RendWeatherAsync(context, report);
    }
    ...
}

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