ch06 探索验证与映射
本章主要介绍
- 如果对数据进行校验.
- 如何在 MinimalApi 中映射数据.
基本要求 (略)
- 创建 ASP.NET Core 6.0 Web API 应用. 可以参考第二章.
- 如果使用命令行创建的项目, 注意修改当前目录.
- 参考代码可以在 GitHub 中下载.
处理数据验证
Handling validation
在任何软件中, 数据校验都是非常重要的. 在 Web API 中, 执行校验来验证传给端点的数据是否复核规则. 然后作者举了一个例子.
在基于控制器的项目中, 可以使用属性注解的方式, 来添加模型校验. 事实上 ApiController
特性会在模型验证失败的时候直接返回 400 Bad Request 作为响应. 因此在基于控制器的项目中, 我们实际上不需要进行数据校验, 因为模型验证失败, 端点是不会被执行的.
注意:
ApiController
特性允许自动模型校验时因为ModelStateInvalidateFilter
过滤器.
遗憾的是, Minimal API 中没有内置验证. IModelValidator
和所有相关的对象无法使用. 因此没有 ModelState
属性. 也因与此, 无法在验证失败时在端点抛出异常, 返回 400 Bad Request 相应.
然后作者例举一段代码, 创建一个端点, 提供一个 Person 类型, 并添加了类型注解, 来约束属性. 并告知, 端点不会进行校验, 并且端点依旧会执行.
但是, 既是在 Minimal API 中, 每一个路由也需要进行参数校验, 并当模型验证失败时返回响应的错误响应. 处理颁发有两种:
- 实现一套兼容现有注解的验证库, 这样就可以继续使用之前的注解方法来进行验证
- 使用第三方的解决方案, 例如后面集成流式验证章节会使用的
FluentValidation
补充: Demo 演示
补充的示例演示, 直接来源于书中作者引入的案例. 仅仅将过程描述补全.
创建项目 (使用的是 .NET 8)
mkdir dotnet8-demo
cd dotnet8-demo
dotnet new webapi -n WebApiDemo
然后使用 VSCode 打开, 修改 Program.cs
中的代码:
using System.ComponentModel.DataAnnotations;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapPost("/people", (Person person) => {
Console.WriteLine("代码依旧会执行");
return Results.NoContent();
});
app.Run();
public class Person {
[Required]
[MaxLength(30)]
public string FirstName { get; set; }
[Required]
[MaxLength(30)]
public string LastName { get; set; }
[EmailAddress]
[StringLength(100, MinimumLength = 6)]
public string Email { get; set; }
}
然后编辑附带生成的 http
文件, 注意需要安装 RESTClient 插件.
@WebApiDemo_HostAddress = http://localhost:5110
POST {{WebApiDemo_HostAddress}}/people/
Content-Type: application/json
{}
###
运行项目, 会提示警告, 暂不用考虑.
dotnet run
使用 http 文件中的 send request 发送请求. 发现代码可以正常运行.
设置端点运行后, 监视端点处理函数中的参数:
结论是: 注解不会被校验, 不会报错, 而且端点处理函数会正常执行.
注入发起请求时携带的参数
{}
, 如果不提供参数, 是会出现异常的.
在为传入参数时, 会抛出异常, 并且不会命中端点, 端点不会被执行. 如果要支持命中端点, 则需要使用可空参数
app.MapPost("/people", (Person? person) => {
Console.WriteLine("代码依旧会执行");
return Results.NoContent();
});
使用数据注解来执行验证
如果要使用原有类型注解来进行校验, 需要使用反射技术. 读取模型中所有的 validation attribute. 然后调用其 IsValid
方法. 该方法是 ValidationAttribute
基类所提供的.
该方式简化了 ASP.NET Core 的处理, 事实上基于控制器的项目就是这么处理的.
我们可以在 Minimal API 中实现这个功能, 但是也可以使用一个库: MiniValidation
GITHUB: https://github.com/DamianEdwards/MiniValidation
NUGET: https://www.nuget.org/packages/MiniValidation
在作者写书时, 该库还是预览版
nuget 给出了比较详细的使用方法.
简单来说, 该库提供了一个静态方法, 用于返回校验结果, 利用 out
参数返回错误信息
bool isValid = MiniValidator.TryValidate(对象, out var errors);
if (!isValid) {
return Results.ValidationProblem(errors);
}
...
除了会用这个方式, 还可以使用流式方法 (fluent approach), 这样可以将验证与模型完全解耦.
集成 FluentValidation
编写代码应该考虑如何组织代码, 按照作者的意思, 注解来进行校验很方便, 但是代码过于分散, 维护不易.
FluentValidation
库就是因此而诞生的, 它使用流式 API 和正则表达式来进行校验.
该库隶属于 .NET Foundation.
- GitHub 地址为: https://github.com/FluentValidation/FluentValidation
- Nuget 链接为: https://www.nuget.org/packages/FluentValidation
- 集成 AspNetCore 的 Nuget 包: https://www.nuget.org/packages/FluentValidation.AspNetCore
该库可用于各种类型的项目, 但 AspNetCore 对应的包提供了更多易于集成的扩展方法.
该库可以很好的支持解耦. 不需要从 ValidationAttribute 进行派生. 同时也支持标准错误信息的本地化.
下面看看如何在项目中集成 FluentValidation
.
首先, 安装依赖库: dotnet add package FluentValidation.DependencyInjectionExtensions
然后重写 Person
对象的验证规则. 将规则在 PersonValidator
类中.
public class PersonValidator: AbstractValidator<Person> {
public PersonValidator() {
RuleFor(p => p.FirstName)
.NotEmpty().MaximumLength(30);
RuleFor(p => p.LastName)
.NotEmpty().MaximumLength(30);
RuleFor(p => p.Email)
.EmailAddress().Length(6, 100);
}
}
然后作者对该代码进行了解释.
下一步是将 validator 注册到 service provider 中:
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
该方法会自动注册对应类型中, 派生自 AbstractValidator<T>
的所有类型. 而 AbstractValidator<T>
实现了 IValidator<T>
接口, 因此使用的时候直接用该类型即可.
下面修改端点路由部分. 请注意 MinimalAPI 是不会自动校验的.
app.MapPost("/people", async (Person person, IValidator<Person> validator) => {
var validationResult = await validator.ValidateAsync(person);
if (!validationResult.IsValid) {
return Results.ValidationProblem(validationResult.ToDictionary());
}
...
});
然后作者对上述代码进行了说明.
然后演示了请求与响应的结果. 但是默认错误提示采用的意大利文 (书中这么描述).
那么如果需要自定义错误提示, 在流式 API 中使用 WithMessage()
方法进行定义:
在 Swagger 中添加验证信息
无论采用什么验证方案, 都需要更新文档, 以告知该请求可能会出现什么问题. 通过在端点方法后调用 ProducesValidationProblem()
方法来实现.
app.MapPost("/people", (Person person) => {
// ...
})
.Produces(StatusCodes.Status204NoContent)
.ProducesValidationProblem();
如此, 在 Swagger 文档中就会添加一个 400 Bad Request 是状态响应.
并且在文档后最后, 会显示参数的结构, 以供参考使用. 但是 FluentValidation
定义的约束不会显示在类型结构中.
可以使用 MicroElements.Swashbuckle.FluentValidation
:
- GitHub: https://github.com/micro-elements/MicroElements.Swashbuckle.FluentValidation
- NuGet: https://www.nuget.org/packages/MicroElements.Swashbuckle.FluentValidation
然后使用代码:
builder.Services.AddFluentValidationRulesToSwagger();
这样, 显示在 Swagger 中的 Json Schema 就会利用反射来读取验证规则, 与使用注解的方式一样. 但是需要注意, 并非支持所有的 FluentValidation
, 细节可以参考 GitHub.
在 Api 与客户端直接进行数据映射
处理 API 中有一个规则: 不要将内部的模型数据暴露给调用者.
这一目的是为了将客户端对外公开的数据结构与我们系统内部的数据结构解耦. 两者可以并行演化 (发展), 从而不会因为系统重构等行为造成系统无法使用.
逻辑上就是提供提供一个抽象层, 以屏蔽底层数据结构的变化.
因此, 整个过程就是需要在两个不同的类型之间建立映射关系.
基于此, 需要达到两个目标:
- 修改我们内部对象结构时, 不会影响到对外公开的数据类型.
- 在更新与客户端通信的对象结构时, 不需要调整内部的数据结构.
换句话说就是, 将一个对象转换为另一个对象. 字面上讲, 就是将一个对象的属性拷贝, 转换, 然后赋值给另一个对象.
这个操作比较枯燥, 编码与测试也很枯燥. 但是需要理解其重要性, 同时需要尝试将其适配到各种场景.
下面是一个 DEMO 场景
考虑下面的对象, 该对象会基于 EFCore 存储与数据库中:
public class PersonEntity {
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime BirthDate { get; set; }
public string City { get; set; }
}
我们有一个端点需要获得 Person
列表, 或指定的某个 Person
.
第一种情况是直接返回 PersonEntity
给调用者, 下面是简化的代码, 用于理解该场景:
app.MapGet("/people/{id:int}", (int id) => {
// 实际开发中, 应该使用 id 到数据库中查询该 Person
var person = new PersonEntity();
return Results.Ok(person);
})
.Produces(StatusCodes.Status200OK, typeof(PersonEntity));
那么此时一旦因为各种原因修改了实体数据, 那么调用接口的客户端也会获得我们更新的数据. 但事实上我们并不希望将该数据公开, 如果我们使用数据转换对象 (data transformation object, DTO), 那么就不会存在该问题.
public class PersonDto {
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public DateTime BirthDate { get; set; }
public string City { get; set; }
}
这样, 端点只需要返回 PersonDto
, 而不是 PersonEntity
. 处理时需要在两个对象间进行转换.
乍看起来, 这里是无用的重复代码行为. 但考虑到代码的发展:
- 可能根据需求数据库中会将用户的 city 以另一个实体的形式进行表示
- 亦或需要保护用户隐私, 不返回 BirthDate 等信息
等等一系列变化后, 这么处理可以保证对外的无感交互.
当然, 映射是双向的. 在本例中, 需要从 PersonDto
映射到 PersonEntity
上, 也需要从 PersonEntity
映射到 PersonDto
上.
实现两方映射, 可以自定义实现, 也可以使用第三方库.
执行自定义映射
前面介绍过, 映射就是从源对象读取属性, 然后进行适当转换后, 赋值给目标对象. 最简单的方式便是手动实现.
这里没什么好说的, 但需要注意复杂属性的成员需要递归访问. 这里建议使用扩展方法, 来进行实现.
代码实现可以参考 GitHub 中的代码示例.
这个方法性能最好, 因为不会依赖于其他库, 例如反射等. 所有的逻辑都是手动编写.
但也存在缺点 (drawback): 每次更新模型, 添加属性的时候, 这个转换都需要实现一遍.
可以使用第三方库, 以简化这个行为, 但代价是会损耗一点性能. 例如使用 AutoMapper
.
使用 AutoMapper 来处理映射
AutoMapper
也许是 .NET
平台上最有名的映射框架了. 他使用流式 API 来处理源对象的属性与目标对象属性之间的映射, 基于约定的匹配的算法来进行处理数据. 与 FluentValidation
一样, 它也是 .NET Foundation 的一部分.
下面快速看看如何在 Minimal API 中集成 AutoMapper
.
其完整文档地址是: https://docs.automapper.org/en/stable/
基于前面的代码, 现在需要做的是, 告诉 AutoMapper
如何执行映射. 需要几个步骤. 但是比较推荐的做法是, 从 Profile
派生类型, 然后再将配置写在构造函数中. Profile
类是 AutoMapper
库所提供的.
public class PersonProfile: Profile {
public PersonProfile() {
CreateMap<PersonEntity, PersonDto>();
}
}
仅仅这一句话即可. 这一段代码即表示, 我们需要将 PersonEntity
映射到 PersonDto
上. 上文提到过, AutoMapper
是基于约定的, 这便是, 源类型中与目标类型中同名的属性会自动进行匹配, 并会自动进行兼容的类型转换.
例如源类型是
int
类型, 目标同名的类型是double
, 它会自动进行转换.
也就是说, 只要属性名相同, 不需要显式执行任何代码. 但是本例中, 作者还是添加了代码:
public class PersonProfile: Profile {
public PersonProfile() {
CreateMap<PersonEntity, PersonDto>()
.ForMember(dst => dst.Age, opt =>
opt.MapFrom(src => CalculateAge(src.BirthDate)))
.ForMember(dst => dst.City, opts =>
opts.MapFrom(src => src.Addresss.City));
}
private static int CalcateAge(DateTime dateOfBirth) {
var today = DateTime.Today;
var age = today.Year - dateOfBirth.Year;
if (today.DayOfYear < dateOfBirth.dayOfYear) {
age--;
}
return age;
}
}
代码中, Id
, FirstName
, LastName
不用编写代码, 因为两个类型有同名的属性. 只需要调用 ForMember
方法, 使用转换表达式来处理 dst.Age
和 dst.City
.
现在, 我们已经定义了转换 profile, 我们需要在 ASP.NET 中注册才可以使用. 与 FluentValidation
一样, 我们可以在 IServiceCollection
上使用扩展方法:
builder.Services.AddAutoMapper(typeof(Program).Assembly);
该方法会自动注册程序集 typeof(Program).Assembly
中所有派自 Profile
的类. 然后可以在端点方法中直接通过依赖注入来使用 IMapper
接口:
app.MapGet("/people/{id:int}", (int id, IMapper mapper) => {
var personEntity = new PersonEntity();
// ...
var personDto = mapper.Map<PersonDto>(personEntity);
return Results.Ok(personDto);
})
.Produces(StatusCodes.Status200OK, typeof(PersonDto));
然后作者对代码进行了解释.
然后说明了 AutoMapper
可以大大提高维护的效率. 重要的是只要两个对象的属性名相同, 就可以进行映射. 并且这个过程支持递归属性.
这个功能的缺点是性能的损失. AutoMapper
基于运行时动态代码的执行. 其中使用了反射. 然后作者解释道 AutoMapper
是很强大的库, 并且内部采用缓存等算法尽可能处理了性能的问题. 但是在使用中尽量不要滥用, 否则还是会有不少性能的开销.
小结
验证与映射是两个很重要的主题. Minimal API 没有内置这两个任务, 我们需要知道怎么来处理. 我们需要知道怎么使用注解, 以及 FluentValidation
来实现校验, 以及如何使用 AutoMapper
来进行类型映射.
下一章介绍如何在 Minimal API 中集成数据访问层, 我们会介绍 EFCore 的使用.