ch03 使用 Minimal API
本章, 我们会尝试一些可用的高级开发技术, 这些技术在以前的 .NET 中就有. 我们会涉及4个不太有关联的技术.
我们会涵盖有关生产力, 与前段接口的配置管理.
每一个开发者, 都会遇到本章描述的问题. 开发者会为 API 编写文档, 会将 API 交给 JavaScript 前端. 会尝试处理错误. 会根据参数配置应用.
本章涉及的主题:
- 探索 Swagger
- 支持 CORS
- 使用全局 API 设置
- 错误处理
技术要求
首先要安装 .NET
为测试 CORS, 还需要一个部署在另一个地址的前端项目
作者会使用 LiveReloadServer, 可以使用下面命令安装:
dotnet tool install -g LiveReloadServer
所有代码可以在 GitHub 中找到.
探索 Swagger
Swagger 已经进入程序员的开发习惯中了. 并且已经集成到 VS 模板中.
Swagger 是一个基于 OpenAPI 规范的工具, 允许你将 API 文档化, 并以 Web 应用形式来部署. 细节可以参考 xxx
书中给定的连接已经无法访问, 可以访问 https://www.openapis.org/ 查看
然后引用了一段规范中的介绍:
OpenAPI 规范让远程的 API 通过 HTTP 或 类似 HTTP 的协议来描述.
API 定义了两个软件之间的一种交互方式, 就像用户接口定义了如何与程序进行交互一样.
一个 API 由可访问它的方法 (发起请求), 它的参数, 返回值, 以及它需要的参数数据格式来组成. 这一点与用户如何与手机应用进行交互一样, 手机应用提供了按钮, 滑动条, 文本框用户接口来约定用户的交互.
VS 模板中的 Swagger
这里是用 VS 创建项目, 选择 OpenAPI 支持后, 会自动安装相关包, 以及配置 SwaggerUI 以供使用. 细节略.
主要动作为:
安装 Nuget 包:
Swashbuckle.AspNetCore
在
Program.cs
代码中执行两组代码:一组代码注入服务
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen();
一组代码启用中间件
if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); }
注意 强烈不建议将 Swagger 公开在生产环境中, 因为不安全. 容易被攻击.
OpenAPI Generator
在 Swagger 中, 特别是使用 OpenAPI 标准时, 可以自动生成客户端, 该客户端可以连接 web 应用. 客户端可以为多语言生成, 也可以为开发工具生成. 开发人员都知道, 测试 API 的客户端编写一起来很乏味漫长. 但是 Swagger 可以自动为其生成所有.
@openapitools/openapi-generator-cli
npm 包是非常有名的 OpenAPI Generator (OpenAPI 生成器). 你可以在 https://openapi-generator.tech/ 找到它.
使用这个工具, 你可以为你的编程语言生成客户端, 并加载测试工具. 例如 JMeter 和 K6.
你不用在本地安装, 如果可以在本机访问到引用的 URL, 可以使用 Docker 容器, 命令如下:
docker run --rm \
-v ${PWD}:/local openapitools/openapi-generator-cli generate \
-i /local/petstore.yaml \
-g go \
-o /local/out/go
可以在官网文档中找到该命令
上述命令会使用 Docker 卷中加载的 petstore.yaml 文件中 OpenAPI 的定义来生成 Go 语言的客户端.
下面看看 .NET6 中, 以及 Minimal API 中怎么使用.
这里好像只是在介绍 Swagger 的背景, 以及一个比较有名的生成工具.
Minimal API 中的 Swagger
在 ASP.NET Web API 中, 用 C# 语言编写的方法, 使用三反斜线注释来描述文档.
而文档是用来描述接口的. 同时 ProducesResponseType
注解会帮助 Swagger 了解到访问接口可能获得的响应状态码.
这里有之前的忙点, 就是可能出现的响应状态码
/// <summary>
/// 创建联系
/// </summary>
/// <param name="contact"></param>
/// <returns>一个新创建的联系</returns>
/// <response code="201">返回新创建的联系</response>
/// <response code="400">如果 contact 为 null</response>
[HttpPost]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Create(Contact contactItem) {
_context.Contacts.Add(contactItem);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(Get), new { id = contactItem.Id }, contactItem);
}
Swagger 不仅仅在方法上提供注释信息, 还为使用 API 的开发者提供文档说明. 它提供对参数的描述. 但是不幸的是在 Minimal API 中不支持.
下面看看怎么在 Swagger 中使用:
builder.Services.AddSwaggerGen(c => {
c.SwaggerDoc("v1", new () {
Title = builder.Environment.ApplicationName,
Version = "v1",
Contact = new () {
Name = "PacktAuthor",
Email = "authors@packtpub.com",
Url = new Uri("https://www.packtpub.com"),
},
Description = "PacktPub Minimal API - Swagger",
License = new Microsoft.OpenApi.Models.OpenApiLicense(),
TermsOfService = new ("https://www.packtpub.com")
});
});
这段代码对 Swagger 进行了配置, 并生成了 Swagger 信息. 我们所提供的信息会补充 SwaggerUI. 基础信息是 Title, 其他都是可选.
UseSwaggerUI()
方法自动配置 UI 的位置, 并设置基于 OpenApi 格式, 描述 API 的 JSON 文件. 如下图所示:
比较疑惑和在意的是, Minimal API 是无法生成 Swagger 吗? 经测试不是这样, 那么配置这个的意义是什么?
经过测试, 没办法为接口添加注释.
我们可以在 /swagger/v1/swagger.json
路径中看到联系信息.
联系信息已有, 但不会有任何报告, 因为我们什么也没做.
我们可以在右上角选择 API 版本.
我们可以自定义 Swagger URL, 并在新的路径上插入文档. 重点是重新定义 SwaggerEndpoint
即可
app.UseSwaggerUI(c => {
c.SwaggerEndpoint("/swagger/v1/swagger.json", $"{builder.Environment.ApplicationName} v1");
});
这里修改的是右上角的下拉选择框中的文本
然后继续, 开始添加端点, 以及业务逻辑.
有一点非常重要, 即定义 RouteHandlerBuilder
, 因为它允许我们描述所有的端点属性, 这些端点就是我们在代码中编写的端点.
Swagger 中应该尽可能丰富. 但是不幸的是 WebAPI 中描述的功能有限.
Minimal API 的版本
minimal api 的版本不在框架功能力处理. 也就是说, 即使是 Swagger 也无法处理 UI 端 API 的版本. 因此版本选择中只有一条可供选择.
Swagger 特性
Swagger features
我们可以发现 Swagger 中并没有提供所有的功能. 下面来看看可用的替代方案. 要描述端点可能的输出内容, 我们可以在处理程序后调用函数, 例如 Produces
或 WithTags
.
Produces
函数描述端点的所有可能的响应, 这些响应应该被客户端所处理. 我们可以添加操作 ID 的名字, 该信息不会出现在 Swagger 界面中, 但是客户端会使用该名字来创建方法来调用端点. OperationId
是操作的唯一名字. (jk: 有点不知道做什么用???)
从 api 描述中排除一个端点, 只需要调用 ExcludeFromDescription()
. 该方法罕见使用, 但是有一种情况例外. 即不想将接口暴露给前端开发者. 因为这个接口另有他用.
最终, 我们可以对接口端点进行标记与分段, 以便更好的管理.
app.MapGet("/sampleresponse", () => {
return Results.Ok(new ResponseData("My Response"));
})
.Produces<ResponseData>(StatusCodes.Status200OK)
.WithTags("Sample")
.WithName("SampleResponseOperation"); // operation ids to Open API
app.MapGet("/sampleresponseskipped", () => {
return Results.Ok(new ResponseData("My Response Skipped"));
})
.ExcludeFromDescription();
app.MapGet("/{id}", (int id) => Results.Ok(id));
app.MapPost("/", (ResponseData data) => Results.Ok(data))
.Accepts<ResponseData>(MediaTypeNames.Application.Json);
简单说:
- 执行了
ExcludeFromDescription
方法的 API 不会显示在 Swagger 中.- 执行了
WithTags
方法的 API 会重新分到这一组.- 执行了
Produces
方法的会在 API 返回类型中添加对应描述.- 还有很多方法
三反斜线的注释语法在 Minimal API 中不支持, 似乎天生不具备该功能.
Swagger 不仅仅只有 UI, 还有对应的 JSON 文件, 这个 JSON 文件也是支持 OpenAPI 规范的.
本节我们了解到如何配置 Swagger, 以及它不支持什么.
后续章节中我们会继续介绍如何配置 OpenAPI, 包括 OpenId Connect 标准, 以及通过 API Key 鉴权.
Swagger 还会将端点需要的模型也显示出来. 后续会介绍如何处理这些对象, 如果验证, 与定义. 这些在第 6 章 探索校验与映射 中介绍.
Swagger OperationFilter
Operation Filter 允许你向 Swagger 中显示的所有操作添加行为. 基于前面的案例, 看看如何为某个调用添加 HTTP header, 通过 OperationId
进行过滤.
当你定义一个 Operation Filter 的时候, 你可以设置基于什么进行过滤, 例如路由, tags, 或 OperationId 等.
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace demo;
public class CorrelationIdOperationFilter : IOperationFilter {
private readonly IWebHostEnvironment environment;
public CorrelationIdOperationFilter(IWebHostEnvironment environment) => this.environment = environment;
/// <summary>
/// Apply header in parameter Swagger.
/// We add default value in parameter for developer environment
/// </summary>
/// <param name="operation"></param>
/// <param name="context"></param>
public void Apply(OpenApiOperation operation, OperationFilterContext context) {
if (operation.Parameters == null) {
operation.Parameters = new List<OpenApiParameter>();
}
if (operation.OperationId == "SampleResponseOperation") {
operation.Parameters.Add(new OpenApiParameter {
Name = "x-correlation-id",
In = ParameterLocation.Header,
Required = false,
Schema = new OpenApiSchema { Type = "String", Default = new OpenApiString("42") }
});
}
}
}
说明:
- 定义操作过滤器 (operation filter), 需要从
IOperationFilter
接口进行派生. - 构造器支持依赖注入.
- 过滤器使用一个方法
Apply
来实现, 它有两个参数:OpenApiOperation
, 操作对象, 可以使用它来添加参数, 检查 ID 或当前调用OperationFilterContext
, filter 上下文允许你访问ApiDescription
, 你可以从中找到当前端点的 URL.
最后需要启用时, 在 AddSwaggerGen()
中进行配置
builder.Services.AddSwaggerGen(c => {
c.OperationFilter<CorrelationIdOperationFilter>();
});
注意, 在过滤的时候需要注意最好给端点添加
.WithName()
, 这样就好过滤一些.本质上相当于:
- 基于控制的可以使用 三反斜线 来添加注释, 用于生成 Swagger 文档.
- Minimal API 需要一些列
With
方法来手动添加.- Minimal API 还可以附加
FIlter
来添加.
最后作者还是建议在生产模式下不要启用 Swagger.
本节介绍了如何启用 UI 工具来描述 API, 以及测试 API. 下一节, 我们将看到如何实现 SPA 和后台的 CORS 功能.
启用 CORS
CORS 是一种安全机制, 用于在非同源策略 (same-origin policy)下, 阻止 HTTP/S 请求.
浏览器会阻止非同源的访问, 而今前后分离的项目往往非同源.
使用场景:
- 一个是基于 SPA 的场景, 前后分离.
- 一个是基于微服务的场景, 多个 API 后端.
无论什么场景, CORS 的问题是存在的, 那么就需要解决.
好像我还真没系统的了解过这个内容.
HTTP 请求中的 CORS 流程
重点是浏览器组织了非同源的数据. 这也是为什么测试 API 一定要用到工具 (而不是用浏览器).
作者使用两个接口, 一个允许 CORS, 一个不允许, 然后比较两个请求浏览器的响应, 以及两个响应的报文进行描述.
允许 CORS 的响应报文中多出三个响应头:
Acces-Control-Allow-Headers
Access-Control-Allow-Methods
Access-Control-ALlow-Origin
浏览器使用这些响应头判断是否放开阻塞.
设置 CORS 策略
在 .NET 6 应用中, 可以有很多方式对 CORS 进行配置. 可以通过授权策略来定义, 也可以使用扩展方法, 亦或注解.
下面一个个解释.
CorsPolicyBuilder
类允许我们在 CORS 策略中定义什么被允许, 什么不被允许.
因此有不同的设置方法, 例如:
AllowAnyHeader
AllowAnyMethod
AllowAnyOrigin
AllowCredentials
特殊的是最后一个方法, 允许包含带有鉴权认证 (authentication credential) 的 cookie.
关于 CORS 不建议使用上述 Any
的方法, 这样不安全. 推荐使用:
WithWExposedHeaders
WithHeaders
WithOrigins
然后作者使用 livereloadserver
搭建了一个前端项目, 并使用三个按钮来发送不同的请求进行测试.
配置默认策略
下面实现一个策略 (policy), 将其定义为 Default
. 然后调用 UseCors
方法来启用.
var corsPolicy = new CorsPolicyBuilder("http://localhost:5200")
.AllowAnyHeader()
.AllowAnyMethod()
.Build();
builder.Services.AddCors(c => c.AddDefaultPolicy(corsPolicy));
...
app.UseCors();
配置自定义策略
我们可以在程序中定义多个策略, 并按需启用. 在微服务场景中, 在一些大型的项目结构中会用到.
与之前的案例区别在于
- 将策略添加至服务中时需要定义名字
- 在
UseCors
的时候使用名字
...
builder.Services.AddPolicy(c => c.AddPolicy("MyCustomPolicy", corsPolicy));
...
app.UseCors("MyCustomPolicy");
下一步看看如何将单个策略应用到指定端点上.
有两个方法:
- 一个是在端点映射后调用
RequireCors
方法, 并指定 Policy 名字即可. 该方法派生自IEndpointConventionBuilder
. - 一个是在 handler 上使用
EnableCors
注解.
使用扩展设置 CORS
即使用策略名调用 RequireCors()
方法. 如果不提供名字, 表示使用默认 (Default) 策略.
app.MapGet(...).RequireCors("策略名");
使用注解设置 CORS
即在处理方法上使用 [EnableCors("策略名")]
app.MapGet("...", [EnableCors("MyCustomPolicy")] () => {});
有别于基于控制器的 API, Minimal API 中无法将策略加载到控制器下的所有方法, 因为没有控制器. 也无法分组管理控制器.
使用全局 API 设置
现在配置应用程序都是使用 options
模式.
从 .NET Core 诞生开始, 标准就从 Web.config
移到 appsettings.json
中了. 但是, 配置也可以从其他位置读取, 例如其他格式的文件. 可以是早期的 .ini
文件, 或自定义格式文件.
在 Minimal API 中, options 模式没有改变, 只是可以利用一下 appsettings.json
文件结构.
比较疑惑, 这是标准用法, 还是小技巧?
在 .NET 6 中配置
由 IConfiguration
实现的对象可以读取配置文件, 但不仅仅如此. 细节可以参考文档.
IConfiguration
和 IOptions
接口被定义来从不同 provider 中读取数据. 该接口不适合在运行时读取编辑配置文件.
可以动过 builder
提供的 Configuration
来获得 IConfiguration
. 该接口实现的对象提供了所有需要读取数据, 对象, 以及连接字符串所需要的方法.
下面的操作属于作者的开发经验, 希望可以定义一个最佳实践, 将类定义到配置文件中去.
作者将会定义一些包含在 appsettings
文件中的类.
配置类
public class MyCustomObject {
public string? CustomProperty { get; init; }
}
public class MyCustomStartupObject {
public string? CustomProperty { get; init; }
}
然后设置 appsettings.json 文件:
appsettings.json 定义
{
"MyCustomValue": "MyCustomValue0",
"MyCustomObject" : {
"CustomProperty": "PropertyValue1"
},
"MyCustomStartupObject" : {
"CustomProperty": "PropertyValue2"
},
"ConnectionStrings": {
"Default": "MyConnectionStringValueInAppSettings"
}
}
然后我们需要执行一些操作.
首先我们创建一个 startupConfig
实例的对象, 该对象的类型是 MyCustomStartupObject
. 使用 IConfiguration
, 从配置文件的 MyCustomStartupObject
节点读取数据, 然后将其转化为对象.
var startupConfig = builder.Configuration
.GetSection(nameof(MyCustomStartupObject)).Get<MyCustomStartupObject>();
除了直接通过 builder
来访问 Configuration
, 还可以在 各个 handler 中直接注入 IConfiguration
.
app.MapGet("/read/configuration", (IConfiguration configuration) => {
var startupConfig = configuration
.GetSection(nameof(MyCustomStartupObject)).Get<MyCustomStartupObject>();
return Results.Ok<MyCustomStartupObject>(startupConfig);
});
然后, 我们可以使用下面代码来读取根节点的数据
var myCustomValue = configuration.GetValue<string>("MyCustomValue");
读取连接字符串使用系统内置方法
var connStr = configuration.GetConnectionString("Default");
在 appsettings.json 文件中, ConnectionStrings
是一个特殊的节, 允许定义多个连接字符串, 用名字进行区分. 代码中可以根据名字来读取不同的连接字符串.
在 Azure AppService 中配置连接字符串需要带上前缀, 用于描述不同的 SQL provider. 细节可以参考文档.
运行中, 连接字符串可用下面类型:
- SQLServer: SQLCONNSTR_
- MySQL: MYSQLCONNSTR_
- SQLAzure: SQLAZURECONNSTR_
- Custom: CUSTOMCONNSTR_
- PostgreSQL: POSTGRESQLCONNSTR_
配置文件的优先级
appsettings.json
文件允许根据运行环境进行管理. 文件格式为: appsettings.{ENVIRONMENT}.json
.
根文件, 即 appsettings.json
仅用于生产环境.
jk: 有一个疑惑, 这个环境名是否可以配置? 经过测试, 确实可以.
- 定义一个文件
appsettings.CustomEnv.json
.- 然后运行代码使用
dotnet run --environment CustomEnv
来启动程序.这样读取到的数据就是从对应环境的配置文件中获得.
下一步来看看 Options 模式. 框架提供了一些对象来加载配置信息.
Options 模式
简单来说, 还是为 appsettings.json
中的某个节定义类型. 然后利用下面方式进行注入:
builder.Services.Configure<TSection>(builder.Configuration.GetSection(nameof(TSection)));
// 或
builder.Services.Configure<TSection>("name", builder.Configuration.GetSection(nameof(TSection)));
后一个方法被称为命名选项 (named options).
但是 options 模式中有不同的接口来实现, 不同的接口有不同的功能.
参考代码:
// 定义不同的类型
public class OptionBasic {
public string? Value { get; init; }
}
public class OptionSnapshot {
public string? Value { get; init; }
}
public class OptionMonitor {
public string? Value { get; init; }
}
public class OptionCustomName {
public string? Value { get; init; }
}
// 在 Program.cs 中注入
builder.Services.Configure<OptionBasic>(builder.Configuration.GetSection("OptionBasic"));
builder.Services.Configure<OptionMonitor>(builder.Configuration.GetSection("OptionMonitor"));
builder.Services.Configure<OptionSnapshot>(builder.Configuration.GetSection("OptionSnapshot"));
builder.Services.Configure<OptionCustomName>("CustomName1", builder.Configuration.GetSection("CustomName1"));
builder.Services.Configure<OptionCustomName>("CustomName2", builder.Configuration.GetSection("CustomName2"));
不同的选项接口
不同的接口可以使用不同记录定义. 有些支持命名选项, 有些不支持.
IOptions<TOptions>
- 不支持
- 不支持在 app 启动后读取配置
- Named Options
- 以单例模式注册, 可以注入到任意服务的生命周期中.
- 不支持
IOptionsSnapshot<TOptions>
- 针对每一个请求都需要重新计算的场合下很有用.
- 以 Scoped 的方式注册, 从而无法注入到单例服务中.
- 支持 Named Options
IOptionsMonitor<TOptions>
- 用于管理与检索
TOptions
实例的选项通知 (jk: ?????) - 以单例形式注册, 可注入到所有服务中.
- 支持:
- 改变通知
- Named Options
- 重新加载配置
- 选择性失效 (
IOptionsMonitorCache<TOptions>
)
- 用于管理与检索
我们需要使用 IOptionsFactory<TOptions>
, 它负责创建新的 Options 实例. 它只有一个 Create
方法. 默认的实现支持所有注册的 IConfigureOptions<TOptions>
和 IPostConfigureOptions<TOptions>
, 并且会首先执行所有配置. 细节可以参考文档.
jk: 不知云云.
Configure
方法可以跟在其他方法后, 在配置管道中. 这个方法名为 PostConfigure
, 它用于修改配置, 在每次配置或重读的时候. 下面是一个案例:
builder.Services.PostConfigure<MyConfigOptions>(myOptions => {
myOptions.Key1 = "my_new_value_post_configuration";
});
jk: 完全不知在说什么...
Putting it all together
定义了这么多接口, 需要一个示例来看看 Options 是如何工作的.
下面看看前面三个接口的用法, 以及包含 Create
方法, 并使用 named options 功能的, 获得当前对象实例的 IOptionsFactory
的用法.
这个翻译太影响理解
app.MapGet("/read/options", (IOptions<OptionBasic> optionsBasic,
IOptionsMonitor<OptionMonitor> optionsMonitor,
IOPtionsSnapshot<OptionSnapshot> optionsSnapshot,
IOptionsFactory<OptionCustomName> optionsFactory
) => {
return Results.Ok(new {
Basic = optionsBasic.Value,
Monitor = optionsMonitor.CurrentValue,
Snapshot = optionsSnapshot.Value,
Custom1 = optionsFactory.Create("CustomName1"),
Custom2 = optionsFactory.Create("CustomName2")
});
}).WithName("ReadOptions");
jk: 基本上可以判断, 在 appsetting.json 文件中, 存在与对应类名相同的节点, 即可被读取出来. 但是这些 Options 的接口有和意义???
请求该接口后返回:
作者说, 每一个接口都有不同的声明周期.
基本上理解了其用法:
简单来说, 就是使用对象映射到配置文件中, 这个定义类与之前用
Configuration
的Get<T>
来读取基本差不多.但是 options 模式的优势是, 支持更新. 也就是说配置文件在更新后, 不用重启应用就可以更新配置文件.
这个在线上环境上很好用. 只是用法上需要几个步骤
定义类是需要有的.
然后使用
Configure<T>
方法来注册. 注册方法有两种: 一个使用类名来定义配置文件中的节点名, 另一个是使用自定义名字进行映射 (这个方式被称为命名选项 (named options)).注册后就可以进行注入了. 但是注入不是直接用类型来注入, 而是基于 4 个泛型接口来注入:
IOptions<TOption>
IOptionsMonitor<TOption>
IOptionsSnapshot<TOptions>
IOptionsFactory<TOptions>
不同的在于更新, 以及生命周期.
IOptions 和验证
最后, 也并非是终结, 是对存储与配置文件中的数据的校验功能. 这个功能在需要发布前进行手动或代码校验时很有用.
jk: 简单说就是对配置文件中的数据进行校验
因为在模型上可以使用注解来提供约束, 在使用配置数据序列化为模型实例的时候, 可以获得校验结果. 从而对校验失败的抛出异常.
早期的配置文件如果有问题, 程序时无法启动的. 现在使用 Options 模式, 只有在使用配置文件中数据的时候才会去解析.
下面是一个案例:
builder.Services.AddOptions<ConfigWithValidation>().Build(builder.Configuration.GetSection(...))
.ValidateDataAnnotations();
...
app.MapGet("/read/options", (IOptions<ConfigWithValidation>) optionsValidation => { ... });
在读取 (注入)
IOptions
数据的时候会进行校验, 失败时会抛出错误.
与案例对应的类型定义为:
public class ConfigWithValidation {
[RegularExpression(@"^([\w\.\-]+)@([\w\-]+)((\.(\w){2,})+)$")]
public string? Email { get; set; }
[Range(0, 100, ErrorMessage = "值 {0} 必须在 {1}, {2} 之间.")]
public int NumericRange { get; set; }
}
而配置文件片段为:
"ConfigWithValidation": {
"Email": "jk@jklib.org",
"Numerange": 1001
}
注意
该模式不适用于所有场景, 这个校验只是格式上的校验. 并不能保证数据的可用, 利用数据库连接字符串.
而且需要注意, 这个如果出现错误, 是在运行时抛出.
到此已经介绍了 appsettings.json 的配置处理. 那么其他配置源的管理下一节介绍.
配置源 (略, 待整理 Docker 或 Azure)
如一开始所介绍的, IConfiguration
以及各种 IOptions
不仅仅用于 appsettings
, 还可以用于其他源.
每一种源都有其自身特性, 访问对象的语法在 provider 的作用下是类似的. 本节看看如何处理动态结构的 JSON.
下面看看两个非常通用的案例.
这里一个介绍了在 Azure 中的用法, 一个介绍了 Docker 中的用法. 这里先暂时略去
处理错误
错误处理是每一个应用都应该具备的特性. 错误消息有助于开发与修改.
下面看看框架所提供的功能.
传统方法
.NET 为 Minimal API 提供了相同的工具, 支持我们使用传统的开发方式实现. 一个开发者异常页面 (a Developer Exception Page). 这只是一个以纯文本形式报错的中间件. 该中间件不能从 ASP.NET 管道中移除, 并且只在开发环境中运行 (参考文档).
下图是 Minimal API 异常处理管道
如果代码中出现异常, 捕获它的办法只能是通过将响应发送到客户端之前激活的中间件.
异常处理中间件有一定规范, 可以按照下面方式实现:
app.UseExceptionHandler(exceptionHandlerApp => {
exceptionHandlerApp.Run(async context => {
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.ContentType = Application.Json;
var exceptionHandlerPathFeature = context.Features.Get<IExceptionHandlerPathFeature>()!;
var errorMessage = new {
Message = exceptionHandlerPathFeature.Error.Message
};
await context.Response.WriteAsync(JsonSerializer.Serialize(serorMessage));
if (exceptionHandlerPathFeature?.Error is FileNotFoundException) {
await context.Response.WriteAsync("文件未找到");
}
if (exceptionHandlerPathFeature?.Path == "/") {
await context.Response.WriteAsync("Page: Home");
}
});
});
这只是一种方案, 但基本上就是利用 UseExceptionHandler
方法来处理流程.
通过 context.Features.Get<IExceptionHandlerPathFeature>()
可以访问 error stack.
可以在某个方法中抛出一个异常进行测试. 运行过程中, 如果出现异常 (代码中, 或库内部中), 异常中间件会介入, 并按照代码流程返回消息.
问题细节和 IETF 标准
有些时候, 响应状态码不足以描述问题. 在 2016 年引入了描述 HTTP Api 问题细节的标准, 即 IETF 标准. 它会返回一个标准字段的 JSON 格式的数据.
该标准定义成 JSON 或 XML 格式. 专门用于描述 HTTP API 的问题信息. 规范详情可以参考文档.
在 .NET 中有一个 Nuget 包来提供该功能: Hellang.Middleware.ProblemDetails
. 可以在 nuget
站点下载.
下面看看如何使用:
...
builder.Services.TryAddSignleton<IActionResultExecutor<ObjectResult>, ProblemDetailsResultExecutor>();
builder.Services.AddProblemDetails(options => {
options.MapToStatusCode<NotImplementedException>(StatusCodes.Status501NotImplemented);
});
...
app.UseProblemDetails();
注意与上一节的配置冲突, 会默认启用上一节的内容.
然后创建需要的类型. IActionResultExecutor
并非管道中内置的对象, 需要手动实现, 同时也需要手动实现相应类型:
public class ProblemDetailsResultExecutor: IActionResultExecutor<ObjectResult> {
public virtual Task ExecuteAsync(ActionContext context, ObjectResult result) {
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(result);
var executor = Results.Json(result.Value, null, "application/problem+json", result.StatusCode);
return executor.ExecuteAsync(context.HttpContext);
}
}
在 Program.cs
文件中记性必要配置, 可以映射不同的错误至响应状态码. 例如:
builder.Services.AddProblemDetails(options => {
options.MapToStatusCode<NotImplementedException>(StatusCodes.Status501NotImplemented);
});
最后可以派生出带有附加字段的 ProblemDetails
类型, 然后使用基类方法.
app.MapGet("/problems", () => {
return Results.Problem(detail: "这段代码会添加到 `detail` 字段中");
})
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.WithName("Problems");
app.MapGet("/custom-error", () => {
var problem = new OutOfCreditProblemDetails {
Type = "https://example.com/probs/out-of-credit",
Title = "You do not have enough credit.",
Detail = "Your current balance is 30, but that costs 50.",
Instance = "/account/12345/msgs/abc",
Balance = 30.0m,
Accounts = { "/account/12345", "/account/67890" }
};
return Results.Problem(problem);
})
.Produces<OutOfCreditProblemDetails>(StatusCodes.Status400BadRequest)
.WithName("CreditProblems");
...
public class OutOfCreditProblemDetail: ProblemDetails {
public decimal Balance { get; set; }
public ICollection<string> Accounts { get; set; } = new List<string>();
}