参数校验
上一章留下了一个问题:请求体缺少 title 时,程序照样创建了一个标题为 null 的待办事项。本节的新概念是声明式校验(declarative validation):在类型和参数上声明规则,由框架在调用处理程序之前统一检查,不合法的请求直接返回 400。
using System.ComponentModel.DataAnnotations;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddValidation();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
List<Todo> todos = [];
var nextId = 1;
app.MapGet("/todos", ([Range(1, 50)] int pageSize = 10) => todos.Take(pageSize));
app.MapPost("/todos", (CreateTodo input) =>
{
var todo = new Todo(nextId++, input.Title, input.Priority, Done: false);
todos.Add(todo);
return todo;
});
app.Run();
public record CreateTodo(
[Required, StringLength(50)] string Title,
[Range(1, 5)] int Priority);
record Todo(int Id, string Title, int Priority, bool Done);和上一章相比,只多了三处:第 7 行注册校验服务,第 31~33 行给 CreateTodo 的属性加上规则,第 20 行给查询参数加上规则。处理程序本身一行没改。
运行与验证
dotnet run正常的请求不受影响:
curl -X POST http://localhost:5080/todos \
-H "Content-Type: application/json" \
-d '{"title":"Buy milk","priority":2}'{"id":1,"title":"Buy milk","priority":2,"done":false}再发送上一章那个缺少 title 的请求:
curl -i -X POST http://localhost:5080/todos \
-H "Content-Type: application/json" \
-d '{"priority":3}'HTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8
{"title":"One or more validation errors occurred.","errors":{"Title":["The Title field is required."]}}这次被拒绝了。多个字段同时出错时,所有错误会一次性返回:
curl -X POST http://localhost:5080/todos \
-H "Content-Type: application/json" \
-d '{"title":"","priority":9}'{"title":"One or more validation errors occurred.","errors":{"Title":["The Title field is required."],"Priority":["The field Priority must be between 1 and 5."]}}查询参数同样会被检查:
curl "http://localhost:5080/todos?pageSize=100"{"title":"One or more validation errors occurred.","errors":{"pageSize":["The field pageSize must be between 1 and 50."]}}用特性声明规则
using System.ComponentModel.DataAnnotations;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddValidation();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
List<Todo> todos = [];
var nextId = 1;
app.MapGet("/todos", ([Range(1, 50)] int pageSize = 10) => todos.Take(pageSize));
app.MapPost("/todos", (CreateTodo input) =>
{
var todo = new Todo(nextId++, input.Title, input.Priority, Done: false);
todos.Add(todo);
return todo;
});
app.Run();
public record CreateTodo(
[Required, StringLength(50)] string Title,
[Range(1, 5)] int Priority);
record Todo(int Id, string Title, int Priority, bool Done);方括号中的 [Required]、[StringLength(50)]、[Range(1, 5)] 叫特性(attribute),是一种附加在代码上的元数据。这几个特性来自第 1 行引入的 System.ComponentModel.DataAnnotations 命名空间,统称数据注解(data annotations):
| 特性 | 规则 |
|---|---|
[Required] | 必须提供,不能是 null;对字符串来说也不能是空字符串 |
[StringLength(50)] | 字符串长度不超过 50 |
[Range(1, 5)] | 数值在 1 到 5 之间(含两端) |
[MinLength]、[MaxLength] | 字符串或集合的最小、最大长度 |
[EmailAddress]、[Url] | 邮箱、网址格式 |
[RegularExpression] | 匹配正则表达式 |
多个特性可以写在同一对方括号里,用逗号分隔,比如 [Required, StringLength(50)]。
为什么用声明式,而不是在处理程序里写 if?
- 规则和数据放在一起:看到
CreateTodo就知道它的全部约束,不用翻遍所有处理程序。 - 处理程序保持干净:进入处理程序时,
input一定是合法的,业务代码不需要再防御。 - 错误格式统一:所有端点返回同样结构的错误,客户端只需要写一套处理逻辑。
- 进入文档:规则会写进 OpenAPI 文档,比如
pageSize参数的描述里会出现"minimum": 1和"maximum": 50。
FastAPI 对照
这相当于 Pydantic 的 Field(min_length=1, max_length=50)、Field(ge=1, le=5),以及查询参数上的 Query(ge=1, le=50)。区别在于 FastAPI 的校验是 Pydantic 内置的;ASP.NET Core 中数据模型(record)和校验机制是分开的,需要显式开启。
注意
CreateTodo 必须声明为 public(第 31 行)。.NET 10 的校验由源代码生成器(source generator)在编译时为参与校验的类型生成代码,而它只处理公开的类型。
如果去掉 public,写成 record CreateTodo(...),项目照样能编译,请求体却完全不会被校验——没有错误,没有警告,也没有日志。这是很容易踩到的坑:如果你发现校验规则没有生效,首先检查类型是不是 public。
开启校验
using System.ComponentModel.DataAnnotations;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddValidation();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
List<Todo> todos = [];
var nextId = 1;
app.MapGet("/todos", ([Range(1, 50)] int pageSize = 10) => todos.Take(pageSize));
app.MapPost("/todos", (CreateTodo input) =>
{
var todo = new Todo(nextId++, input.Title, input.Priority, Done: false);
todos.Add(todo);
return todo;
});
app.Run();
public record CreateTodo(
[Required, StringLength(50)] string Title,
[Range(1, 5)] int Priority);
record Todo(int Id, string Title, int Priority, bool Done);AddValidation() 把校验功能注册到应用中。之后,每个端点在调用处理程序之前,都会按照参数上的特性检查输入:
- 路由、查询字符串、请求体等来源的值先完成绑定(上一章的内容);
- 按特性检查每个参数,以及参数对象的每个属性;
- 有任何错误,就返回 400 和全部错误信息,处理程序不会被调用;
- 全部通过,才调用处理程序。
注意第 2 步和上一章的区别:绑定负责"格式对不对"("high" 能不能转成 int),校验负责"内容合不合理"(9 是否在 1 到 5 之间)。前者失败时,框架根本构造不出 CreateTodo 对象,也就轮不到校验。
技术细节
这套内置校验功能是 .NET 10 新增的,之前的版本需要借助第三方库或手写代码。源代码生成器在编译期发现可校验类型并生成相关元数据,运行时由校验组件执行规则。实现仍会通过反射(reflection,在运行时读取类型、属性等信息)获取属性和校验特性,并非完全没有反射。生成的元数据和裁剪支持让它可以用于原生 AOT(提前编译为本机代码,在「进阶」部分介绍);AOT 不等于完全不能使用反射。
校验查询参数
using System.ComponentModel.DataAnnotations;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddValidation();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
List<Todo> todos = [];
var nextId = 1;
app.MapGet("/todos", ([Range(1, 50)] int pageSize = 10) => todos.Take(pageSize));
app.MapPost("/todos", (CreateTodo input) =>
{
var todo = new Todo(nextId++, input.Title, input.Priority, Done: false);
todos.Add(todo);
return todo;
});
app.Run();
public record CreateTodo(
[Required, StringLength(50)] string Title,
[Range(1, 5)] int Priority);
record Todo(int Id, string Title, int Priority, bool Done);特性不只能用在 record 的属性上,也可以直接加在处理程序的参数上。[Range(1, 50)] 限制了每页最多 50 条,防止客户端一次请求过多数据。
路由参数、查询参数、请求头都可以这样校验。回忆「路由参数」一章的建议:"取值不合法"应该返回带说明的 400,而不是用路由约束返回 404——这里就是实现它的地方。
错误响应的格式
校验失败的响应体结构如下:
{
"title": "One or more validation errors occurred.",
"errors": {
"Title": ["The Title field is required."],
"Priority": ["The field Priority must be between 1 and 5."]
}
}errors 是一个字典:键是出错的字段名,值是该字段的所有错误消息。这个结构遵循一个名为 Problem Details 的标准格式,「状态码与错误处理」一章会详细介绍它,并用它统一常见错误响应。
提示
默认的错误消息是英文的。每个特性都可以通过 ErrorMessage 自定义消息,例如 [Range(1, 5, ErrorMessage = "Priority must be between 1 and 5")]。
总结
builder.Services.AddValidation()开启 .NET 10 内置的校验,校验在处理程序执行之前进行。- 在 record 的属性或处理程序的参数上用数据注解(
[Required]、[StringLength]、[Range]等)声明规则。 - 校验失败返回 400,响应体中的
errors按字段列出所有错误;处理程序不会被调用。 - 绑定检查格式,校验检查内容;规则会同时写进 OpenAPI 文档。
- 参与校验的类型必须是
public,否则校验会被静默跳过。
下一章:Header 与 Cookie——读取请求的其他部分。上一章:请求体。
