ch02 探究 Minimal API 和其进阶 内容
本章会介绍 .NET 6 中 Minimal API 的相关主题. 描述其与基于控制器的 Web API 的不同. 同时介绍新方法的优缺点.
本章中覆盖下面的内容:
- 路由
- 参数绑定
- 探索响应
- 控制序列化
- 构建一个 minmal api 项目
技术要求
按照下面的方式创建
使用 VS22
使用 VSCode
dotnet new webapi -minimal -o Chapter02
然后可以移除关于 WeatherForecast 案例的代码. 代码也可以在 Github 中找到.
路由
按照微软官方文档中路由的定义:
路由负责匹配传入的 HTTP 请求,然后将这些请求发送到应用的可执行终结点。 终结点是应用的可执行请求处理代码单元。 终结点在应用中进行定义,并在应用启动时进行配置。 终结点匹配过程可以从请求的 URL 中提取值,并为请求处理提供这些值。 通过使用应用中的终结点信息,路由还能生成映射到终结点的 URL。
在基于控制器的 WebAPI 中, 路由通过 Startup.cs
文件中的 UseEndpoints()
方法来定义, 或者使用动作方法上的数据注解 Route
, HttpGet
, HttpPost
, HttpPut
, HttpPatch
, HttpDelete
等.
如 ch01 介绍了, 路由可以使用 Map*
方法来创建:
app.MapGet("/hello-get", () => "[GET] Hello World!");
app.MapPost("/hello-post", () => "[POST] Hello World!");
app.MapPut("/hello-put", () => "[PUT] Hello World!");
app.MapDelete("/hello-delete", () => "[DELETE] HelloWorld!");
这里定义了四个端点, 并针对四个 HTTP 方法.
还可以使用同一个路由定义不同的动作方法.
只要我们将端点添加到应用中 (例如
MapGet()
),UseRouting()
会自动的添加到中间管道的开始位置,UseEndpoints()
会添加到管道最后.
ASP.NET Core 6.0 提供了通用的 Map*
方法来实现常用的 HTTP 动作方法. 如果需要其他动词, 可以使用 MapMethods
方法.
app.MapMethods("/hello-patch", new[] { HttpMethods.Patch },
() => "[PATCH] Hello World!");
app.MapMethods("/hello-head", new[] { HttpMethods.Head },
() => "[HEAD] Hello World!");
app.MapMethods("/hello-options", new[] { HttpMethods.Options },
() => "[OPTIONS] Hello World!");
MapMethods 方法第一个参数定义路由表, 第二个参数也是字符串枚举器, 用于定义动作方法, 第三个参数为处理函数.
下面我会讨论如何有效的利用路由, 以及如何控制它的行为.
路由处理程序
当匹配 URL 后, 方法就会执行, 匹配与参数与约束有关, 这个方法便是路由处理程序 (route handler).
路由处理程序可以是 Lambda 表达式, 局部函数, 实例方法, 静态方法, 而且无论是异步或同步.
然后作者一一举例, 这里省略
路由参数
我们可以在路由上定义路由参数, 然后再处理函数上定义参数. 请求会自动进行参数填充:
app.MapGet("/users/{username}/products/{productId}",
(string username, int productId) =>
$"The Username is {username} and the product Id is {productId}");
路由可以包含很多参数. 当请求到该路由时, 参数会被捕获, 然后转换, 最后传入处理函数中.
因此处理函数总是可以获得类型正确的参数.
如果路由中的数据无法正确的进行类型转换, 会抛出 BadHttpRequestException
类型的异常. API 会返回 400 Bad Request
的消息.
路由约束
jk: 细节还是要看 Freeman 的书. 不过作者
路由约束用于限制参数校验类型. 约束可以让我们指定参数类型, 例如字符串, GUID 等.
要提供约束, 只需要在路由参数名后面使用冒号, 然后加上约束类型即可.
app.MapGet("/users/{id:int}", (int id) => $"The user Id is {id}");
app.MapGet("/users/{id:guid}", (Guid id) => $"The user Guid {id}");
Minimal API 支持所有之前 ASP.NET Core 中已有的路由约束. 详情可以参考: 路由约束
如果请求没有被路由匹配上, 不会发出异常, 而是得到一个 404 Not Found
的消息.
没有在路由中提供的参数默认会从 查询字符串中获取
app.MapGet("/hello", (string name) => $"Hello, {name}!");
下一节会详细讨论参数绑定:
- 在哪里检索路由参数
- 如果修改他们的名字
- 如何包含可选路由参数
参数绑定
参数绑定是转换请求数据的过程 (例如 URL 路径, 查询字符串, 或请求体). 将转换的请求数据转换成强类型的数据, 并传递给处理程序所使用. ASP.NET Core Minimal API 支持下面的参数绑定:
- 路由值
- 查询字符串
- 请求头
- 请求体 (例如 JSON, 默认唯一支持的格式)
- 服务提供程序 (作为依赖注入获取)
我们会在第4章讨论依赖注入.
在阅读本章后, 你就知道我们可以根据输入来指定使用哪种方式的绑定. 但遗憾的是, 当前版本, Minimal API 还没有原生支持 Form 绑定. 也就是说, 暂不支持 IFormFile.
要理解参数绑定, 看下面代码:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<PeopleService>();
var app = builder.Build();
app.MapPut("/people/{id:int}", (int id, bool notify, Person person, PeopleService peopleService) => { });
app.Run();
public class PeopleService { }
public record class Person(string FirstName, string LastName);
传入到处理程序的参数会按照下面的方式进行转换:
参数 | 源 |
---|---|
id | 路由 |
notify | 查询参数 (大小写不敏感) |
person | 请求体 (JSON) |
peopleService | Service provider |
如我们所看到的, ASP.NET Core 会自动的理解在哪里来搜索绑定的参数, 它是基于路由的匹配模式与需要的参数类型来判断的. 例如复杂类型的 Person, 会在请求体中获取.
如果需要, 在以前的 ASP.NET Core 中可以使用特性来显式的指定参数绑定来源, 或者可选, 或使用不同的名字等. 参考下面的端点:
app.MapGet("/search", (string q) => {});
这个 API 可以使用 /search?q=text
来匹配. 但是参数 q
的命名不太好, 它的描述信息不够, 所以使用 [FromQueryAttribute]
来修饰
app.MapGet("/search", ([FromQuery(Name = "q")] string searchText) => {});
这样 API 中依旧使用 q 作为查询字符串的参数, 而处理程序中可以使用更有意义的 searchText
作为参数.
注意:
根据标准, GET, DELETE, HEAD, 以及 OPTIONS 的 HTTP 请求都不应该有请求体. 但是如果你需要, 则使用
[FromBody]
特性来修饰参数. 否则你会获得InvalidOperationException
错误. 请记住这是一个不好的习惯.
默认情况下, 路由处理程序中的参数都是必须的. 因此若找到了匹配的路由, 但是没有提供所有的参数, 也是会报错的. 例如:
app.MapGet("/people", (int pageIndex, int itemsPerPage) => {});
如果请求时没有携带查询字符串参数 pageIndex
和 itemsPerPage
, 则会获得 BadHttpRequestException
的异常, 并返回 400 Bad Request
的响应.
要让参数可选, 只需要将参数定义成可空类型, 或提供默认值即可. 后一种方案更为普遍. 但是如此一来就无法使用 Lambda 表达式了, 我们需要另一种方法
string SearchMethod(int pageIndex = 0, int itemsPerPage = 50)
=> $"Sample result for page {pageIndex} getting {itemsPerPage} elements";
app.MapGet("/people", SearchMethod);
jk: 至少现在不支持这个语法.
本例中我们使用了查询字符串, 但是同样的规则适用于所有绑定.
请注意, 如果使用了可空引用类型 (.NET 6 的项目默认支持), 那么需要将参数声明为可空, 否则也会获得 BadHttpRequestException
错误.
app.MapGet("/people", (string? orderBy) => {});
特殊的绑定
基于控制器的 WebAPI, 控制器继承自 Microsoft.AspNetCore.Mvc.ControllerBase
, 它可以访问到诸多属性, 包括请求与响应上下文: HttpContext
, Request
, Response
, User
. 在 Minimal API 中没有控制器, 但是也可以访问这些数据, 这些数据都以特殊绑定的形式传递给处理函数了.
app.MapGet("/products", (HttpContext context, HttpRequest req, HttpResponse res, ClaimsPrincpal user) => {});
我们也可以使用
IHttpContextAccessor
接口来访问这些所有的对象, 如同之前的 ASP.NET Core 一样.jk: 所以本书其实需要一定基础的, 至少需要了解早期的诸多内容. 确实 Minimal API 简化了很多.
自定义绑定
优势系统提供的绑定无法满足我们的需求. 在 MInimal API 中没有提供 IModelBinderProvider
和 IModelBinder
接口, 但是我们有两种选择来实现自定义绑定.
重要
基于控制的项目中,
IModelBinderProvider
和IModelBuilder
接口允许我们定义映射, 将请求数据映射到 Model 上. 默认情况下, 模型定义使用的都是基本类型, 如果需要, 可以创建自定义 provider 来扩展系统的方法. 详细信息可以参数: 自定义模型绑定.
如果我们需要绑定来自于路由, 查询字符串, 请求头, 到自定义类型上, 我们可以在类型上提供静态方法 TryParse
:
// GET /navigate?location=43.8427,7.8527
app.MapGet("/navigation", (Location location) => {});
public class Location {
public double Latitude { get; set; }
public double Longtitude { get; set; }
public static bool TryParse(string? value, IFormatProvider? provider, out Location? location) {
if (!string.IsNullOrWhiteSpace(value)) {
var values = value.Split(',', StringSplitOptions.RemoveEmptyEntries);
if (values.Length == 2
&& double.TryParse(values[0], NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var latitude)
&& double.TryParse(values[1], NumberStypes.AllowDecimalPoint, CultureInfo.InvariantCulture, out var longitude)
) {
location = new Location {
Latitude = latitude,
Longitude = longitude
};
return true;
}
}
location = null;
return false;
}
}
作者对代码做了一些解释 (略)
重要
当 minimal api 发现类型中包含
TryParse
方法时, 即使它是复杂类型, 都会假定它是来自路由或查询字符串. 我们可以使用[FromHeader]
来修改绑定源. 任何情况下,TryParse
都不会在请求体中调用.
如果想要完全控制绑定的执行, 可以在类型中实现静态方法 BindAsync
, 这种方式不常用, 但是有时会很方便.
// POST /navigate?lat=43.8427&lon=7.8527
app.MapPost("/navigate", (Location location) => {});
public class Location {
// ..
public static ValueTask<Location?> BindAsync(HttpContext context, ParameterInfo parameter) {
if (double.TryParse(context.Request.Query["lat"], ...)
&& double.TryParse(context.Request.Query["lon"], ...)) {
var location = new Location { ... }
return ValueTask.FromResult<Location?>(location);
}
return ValueTask.FromResult<Location?>(null);
}
}
可见 BindAsync
方法可以读取完整的 HttpContext
数据, 那么我们可以完全掌握请求数据, 来处理自己想要的数据.
甚至是读取完整的请求体来进行数据转换. 如果需要处理非 JSON 格式的请求体时就非常有用了.
前文介绍过, 默认只支持 JSON 格式的请求体.
如果 BindAsync
返回 null
, 表示处理函数没有接收到该数据, 通常会返回 404 Bad Request
消息.
抛出
HttpBadRequestException
异常.
重要
我们不用同时实现
TryParse
和BindAsync
方法, 如果同时定义两个方法,BindAsync
有更高的优先级 (即TryeParse
永远不会被调用).
现在我们已经知道怎么处理绑定了, 下面看看响应式怎么处理的.
探究响应
如同基于控制器的 WebAPI, 在 Minimal API 中, 我们可以直接返回字符串或某个类 (同步或异步的都可以).
- 如果返回一个字符串, 框架会直接将字符串写入响应. 并将响应类型设置为
text/plain
, 同时返回200 OK
的响应状态码. - 如果使用类, 对象会被序列化为 JSON 格式, 然后发送到响应体, 同时设置响应类型为
application/json
, 并返回200 OK
状态码.
如果需要自定义响应状态码, 响应类型等, 可以使用静态类 Results
, 它允许我们返回 IResult
接口的数据.
Results
对于 Minimal API, 如同 IActionResult
对应于控制器.
app.MapGet("/ok", () => Results.Ok(new Person("Donald", "Duck")));
app.MapGet("/notfound", () => Results.NotFound());
app.MapPost("/badrequest", () => {
// Creates a 400 response with a JSON body.
return Results.BadRequest(new { ErrorMessage = "Unable to complete the request" });
});
app.MapGet("/download", (string fileName) => Results.File(fileName));
// ...
record class Person(string FirstName, string LastName);
每一个 Results
都负责设置响应类型与响应状态码, 这些属于与对应的方法含义相同. 例如 NotFound()
方法会返回 404 Not Found
. 许多方法都可以返回对象, 那么会将对象序列化成 JSON 格式, 并使用 application/json
类型.
现在的 Minimal API 暂时还不支持内容协商. 只有少部分方法允许我们设置 Content-Type
. 例如, 当我们使用 Results.Bytes()
, Results.Stream()
, Results.File()
读取文件时, 或使用 Results.Text()
和 Results.Content()
时. 其他情况, 我们在处理复杂对象时, 都是响应 JSON 格式. 这是一种精确的设计, 因为大多数开发者很少需要使用其他的媒体类型. 由于没有内容协商, Minimal API 性能更好.
然而, 有些情况依旧不满足时, 我们需要自定义响应类型. 例如需要返回 XML 的时候. 我们可以使用 Results.Content()
方法. 该方法有很多重载, 一个典型的重载如下, 它可以设置响应内容 (字符串形式), 响应类型, 字符编码格式, 以及状态码
public static IResult Content(
string? content,
string? contentType = null,
Encoding? contentEncoding = null,
int? statusCode = null
);
若必须实现一些自定义的响应, 可以自定义一个 IResult
类型. 例如我们需要将对象序列化成 XML. 我们可以派生 IResult
, 创建一个 XmlResult
类
public class XmlResult: IResult {
private readonly object value;
public XmlResult(object value) {
this.value = value;
}
public Task ExecuteAsync(HttpContext httpContext) {
using var writer = new StreamWriter();
var serializer = new XMLSerializer(value.GetType());
serializer.Serialize(writer, value);
var xml = writer.ToString();
httpContext.Response.ContentType = MediaTypeNames.Application.Xml;
httpContext.Response.ContentLength = Encoding.UTF8.GetByteCOunt(xml);
return httpContext.Response.WriteAsync(xml);
}
}
该结构定了了 ExecuteAsync
方法, 该方法接收 HttpContext
参数. 然后就可以在处理函数中使用它.
最佳的实践方式是, 为 IResultExtensions
接口提供扩展方法, 例如:
public static class ResultExtensions {
public static IResult Xml(this IResultExtensions resultExtensions, object value) => new XmlResult(value);
}
然后在处理函数中就可以:
app.MapGet("/xml", () => Results.Extensions.Xml(new City { Name = "Taggia" }));
public record class City {
public string? Name { get; set; }
}
虽然这个过程可以使用 Result.Content()
来实现, 但是, 实现该方法后, 可以在其他地方使用, 而不需要再次设置响应头的数据, 以及序列化的步骤.
小提示 如果需要对类型进行校验, 可以检查
HttpRequest
对象中请求头中的Accept
. 然后创建相关的响应.
在介绍处理程序如何响应后, 下面介绍如何控制数据的序列化与反序列化.
控制序列化
如前文所述, Minimal API 默认只支持 JSON 格式的序列化, 而该序列化和反序列化是由 System.Text.Json
来实现的.
在基于控制器的 WebAPI 中, 我们可以使用 JSON.NET 来代替默认的库. 但是在 Minimal API 中不能 (无法替换序列化器).
内置序列化器可以使用下面选项
- 序列化过程中大小写不敏感
- 驼峰命名策略
- 支持带引号的数字 (数字属性的字符串表示)
有关 System.Text.Json
命名空间的 API 可以参考文档.
在基于 控制器的 WebAPI 中, 自定义可以在 AddControllers()
方法后, 利用流式 API, 调用 AddJsonOptions()
来实现.
在 Minimal API 中没有控制器, 所以无法使用该方式, 我们需要为 JsonOptions
显式的实现 Configure
方法. 考虑下面代码:
app.MapGet("/product", () => {
var product = new Product("Apple", null, 0.42, 6);
return Results.Ok(product);
});
public record class Product(string Name, string? Description, double UnitPrice, int Quantity) {
public double TotalPrice => UnitPrice * Quantity;
}
该接口返回的结果是:
{
"name": "Apple",
"description": null,
"unitPrice": 0.42,
"quantity": 6,
"totalPrice": 2.52
}
然后使用下面的代码配置 JsonOptions
builder.Services.Configure<Microsoft.AspNetCore.Http.Json.JsonOptions>(options => {
options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
options.SerializerOptions.IgnoreReadOnlyProperties = true;
});
然后返回的结果是:
{
"name": "Apple",
"unitPrice": 0.42,
"quantity": 6
}
description
没有, 因为其值为 null
; totalPrice
没有, 是因为它只读.
JsonOptions
另一个典型的用法是将其作用到每一次序列化与反序列化中. 例如 JsonstringEnumConverter
将枚举与字符串互相转换.
重要
请注意在 Minimal API 中的
JsonOptions
是在Microsoft.AspNetCore.Http.Json
命名空间下. 而不是在Microsoft.AspNetCore.Mvc
命名空间下. 后一个是用于控制器中.
由于只支持 Json 格式, 如果没有显示的定义对其他格式的支持, Minimal API 会自动对 body 进行校验, 并会有下列情况:
问题 | 响应状态码 |
---|---|
Content-Type 没有设置为 application/json | 415 |
无法以 JOSN 的格式读取 body | 400 |
这种情况下, 由于请求体校验失败, 那么对应的请求处理函数不会被执行 (其实就是没有进入), 从而会得到上表中的响应.
现在已经介绍了 Minimal API 的所有内容, 下面的问题是如何正确的设计, 来避免一些错误.
Minimal API 项目架构
至此, 我们可以直接在 Program.cs
文件中编写处理函数. 这个模式只需要一个文件写完所有的内容.
这个方式对于较大规模的项目来说不易于维护 (非结构化). 如果端点数少, 这么操作没问题. 因此建议使用不同文件维护处理程序.
考虑下面代码:
app.MapGet("/api/people", (PeopleService peopelService) => {});
app.MapGet("/api/people/{id:guid}", (Guid id, PeopleService peopleService) => {});
app.MapPost("/api/people", (People people, PeopleService peopleService) => {});
app.MapPut("/api/people/{id:guid}", (Guid id, People people, PeopleService peopleService) => {});
app.MapDelete("/api/people/{id:guid}", (Guid id, PeopleService peopleService) => {});
既是将处理逻辑封装到 PeopleService
中, 这个文件依旧容易在后续开发中无限变大, 最后变得无法维护.
最好的处理办法不是使用 Lambda
表达式, 而是使用一个类来承载所有的路由处理函数:
public class PeopleHandler {
public static void MapEndpoints(IEndpointRouteBuilder app) {
app.MapGet("/api/people", GetList);
app.MapGet("/api/people/{id:guid}", Get);
app.MapPost("/api/people", Insert);
app.MapPut("/api/people/{id:guid}", Update);
app.MapDelete("/api/people/{id:guid}", Delete);
}
private static IResult GetList(PeopleService peopleService) { /* ... */ }
private static IResult Get(Guid id, PeopleService peopleService) { /* ... */ }
private static IResult Insert(Person person, PeopleService people) { /* ... */ }
private static IResult Update(Guid id, Person person, PeopleService people) { /* ... */ }
private static IResult Delete(Guid id) { /* ... */ }
}
然后 Program.cs
中修改为:
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
PeopleHandler.MapEndpoints(app);
app.Run();
进一步
该方法只是在说明需要组织好 Minimal API 项目. 但是每当扩展端点的时候, 都需要在 Program.cs
中添加一刚行代码.
但是使用接口, 并加上一点点反射, 就可以创建出更易于维护与理解的代码.
考虑下面接口:
public interface IEndpointRouteHandler {
public void MapEndpoints(IEndpointRouteBuilder app);
}
然后实现该类:
public class PeopleHandler: IEndpointRouteHandler {
public void MapEndpoints(IEndpointRouteBuilder app) {
// ...
}
}
注意, 该方法从接口而来, 不再是静态方法
下面我们需要另一个扩展, 使用反射来扫描程序集中的所有类, 然后自动的调用 MapEndpoints 方法:
public static class IEndpointRouteBuilderExtensions {
public static void MapEndpoints(this IEndpointRouteBuilder app, Assembly assembly) {
var endpointRouteHandlerInterfaceType = typeof(IEndpointRouteHandler);
var endpointRouteHandlerTypes = assembly
.GetTypes()
.Where(t => t.IsClass && !t.IsAbstract && !t.IsGenericType
&& t.GetConstructor(Type.EmptyTypes) != null
&& endpointRouteHandlerInterfaceType.IsAssignableFrom(t)) {
foreach(var endpointRouteHandlerType in endpointRouteHandlerTypes) {
var instantiatedType = (IEndpointRouteHandler)Activator.CreateInstance(endpointRouteHandlerType)!;
instantiatedType.MapEndpoints(app);
}
}
}
}
有关反射的细节可以参考文档 https://docs.microsoft.com/dotnet/csharp/programming-guide/concepts/reflection.
最后更新 Program.cs
app.MapEndpoints(Assembly.GetExcutingAssembly());
app.Run();