ASP.NET Core 6框架揭秘实例演示[30]:利用路由开发REST API

借助路由系统提供的请求URL模式与对应终结点之间的映射关系,我们可以将具有相同URL模式的请求分发给与之匹配的终结点进行处理。ASP.NET的路由是通过EndpointRoutingMiddleware和EndpointMiddleware这两个中间件协作完成的,它们在ASP.NET平台上具有举足轻重的地位,MVC和gRPC框架,Dapr的Actor和发布订阅编程模式都建立在路由系统之上。Minimal API更是将提升到了前所未有的高度,是我们直接在路由系统基础上定义REST API。

[S2001]注册路由终结点 (源代码
[S2002]以内联方式设置路由参数的约束(源代码
[S2003]定义可缺省的路由参数(源代码
[S2004]为路由参数指定默认值(源代码
[S2005]一个路径分段定义多个路由参数(源代码
[S2006]一个路由参数跨越多个路径分段(源代码
[S2007]主机名绑定(源代码
[S2008]将终结点处理定义为任意类型的委托(源代码
[S2009]IResult 的应用(源代码

[S2001]注册路由终结点

我们演示的这个ASP.NET应用是一个简易版的天气预报站点。服务端利用注册的一个终结点来提供某个城市在未来N天之内的天气信息,对应城市(采用电话区号表示)和天数直接至于请求URL的路径中。如图1所示,为了得到成都未来两天的天气信息,我们将发送请求的路径设置为“weather/028/2”。路径为“weather/0512/4”的请求返回就是苏州未来4天的天气信息(S2001)。

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天的天气状况,我们可以采用图20-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("//0.0.0.0:6666");
app.Urls.Add("//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");
}