ASP.NET Core 中的数据校验
适用范围:ASP.NET Core 8 及以上版本。
1. 校验解决的是什么问题
服务端不能相信任何来自外部的输入。客户端可能漏传字段、传了超范围的数值、传了格式不对的日期,也可能是被篡改的请求。数据校验的职责,就是在业务代码执行之前把这些不合法的输入拦下来,并给调用方一个明确的错误说明。
需要先区分两类“校验”,它们的处理方式完全不同:
| 类型 | 判断依据 | 典型例子 | 建议返回 |
|---|---|---|---|
| 输入校验 | 只看请求本身 | 字段必填、字符串长度、数值范围、日期格式 | 400 Bad Request |
| 业务规则校验 | 需要查数据库或领域状态 | 库存不足、货位已占用、任务状态不允许取消 | 409 / 422,或领域异常 |
本文讨论的是第一类。第二类属于业务逻辑,应该放在 Service 或领域层,不要塞进 ValidationAttribute 里——那样会让校验特性依赖数据库,既难测试也难复用。
2. 校验在请求管道中的位置
HTTP 请求
↓
路由匹配
↓
模型绑定(Model Binding):把 JSON / 查询串 / 路由值填入参数对象
↓
模型校验(Model Validation):对绑定后的对象逐个节点执行规则
↓
ModelState.IsValid ?
├── 否 → [ApiController] 自动返回 400 + ValidationProblemDetails
└── 是 → 进入 Action 方法
有两点值得注意:
- 校验发生在绑定之后。如果客户端给
int Age传了"abc",那是绑定阶段就失败了,同样会写入ModelState,错误消 息是绑定器给出的类型转换失败,而不是你写的[Range]消息。 - 绑定失败的字段仍会继续校验。所以一个字段可能同时出现“类型不匹配”和“必填”两条错误。
3. DataAnnotations:内置校验特性
校验特性来自 System.ComponentModel.DataAnnotations 命名空间,直接标注在模型属性上。
using System.ComponentModel.DataAnnotations;
public sealed class CreateTaskRequest
{
[Required(ErrorMessage = "任务编号不能为空。")]
[StringLength(32, MinimumLength = 4)]
public string TaskCode { get; set; } = string.Empty;
[Range(1, 9999)]
public int Priority { get; set; }
[RegularExpression(@"^[A-Z]{2}-\d{3}$", ErrorMessage = "货位格式应为 AB-001。")]
public string LocationCode { get; set; } = string.Empty;
[EmailAddress]
public string? NotifyEmail { get; set; }
[Url]
public string? CallbackUrl { get; set; }
}
常用特性一览:
| 特性 | 作用 |
|---|---|
[Required] | 值不能为 null;字符串默认也不能为空串(可用 AllowEmptyStrings = true 放开) |
[Range(min, max)] | 数值或实现了 IComparable 的类型的范围 |
[StringLength(max, MinimumLength = n)] | 字符串长度上下限 |
[MinLength] / [MaxLength] | 字符串或集合的元素个数 |
[Length(min, max)] | .NET 8 起,一次性指定字符串或集合的长度区间 |
[RegularExpression] | 正则匹配 |
[EmailAddress] / [Phone] / [Url] / [CreditCard] | 常见格式 |
[Compare(nameof(Other))] | 与另一属性相等,常用于确认密码 |
[AllowedValues] / [DeniedValues] | .NET 8 起,白名单 / 黑名单取值 |
[Base64String] | .NET 8 起,校验是否为合法 Base64 |
几个容易被忽略的细节:
// 排除边界值:价格必须大于 0,而不是大于等于 0
[Range(0, double.MaxValue, MinimumIsExclusive = true)]
public decimal Price { get; set; }
// 对 TimeSpan、DateTime 这类类型,用字符串形式指定范围
[Range(typeof(TimeSpan), "00:00:01", "01:00:00")]
public TimeSpan Timeout { get; set; }
// 让某个属性完全跳过校验(例如由服务端回填的字段)
[ValidateNever]
public string InternalId { get; set; } = string.Empty;
[ValidateNever] 位于 Microsoft.AspNetCore.Mvc.ModelBinding.Validation 命名空间。
正则校验要特别小心回溯攻击。对用户可控的长字符串,先用 [StringLength] 限长,再做正则匹配;复杂正则建议改写成代码判断。
4. [ApiController] 与自动 400 响应
在控制器上标注 [ApiController] 后,框架会自动加入一个过滤器:只要 ModelState.IsValid 为 false,请求就不会进入 Action,而是直接返回 400。
[ApiController]
[Route("api/[controller]")]
public sealed class TasksController : ControllerBase
{
[HttpPost]
public IActionResult Create(CreateTaskRequest request)
{
// 走到这里说明 ModelState 一定是有效的,无需再写 if (!ModelState.IsValid)
return Ok();
}
}
默认返回体是 ValidationProblemDetails(RFC 7807 格式):
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "00-8f1b...-00",
"errors": {
"TaskCode": ["任务编号不能为空。"],
"Priority": ["The field Priority must be between 1 and 9999."]
}
}
errors 是“字段名 → 错误消息数组”的字典,前端可以直接按字段名把消息贴到对应输入框上,这是保留默认格式的最大价值。
4.1 统一自定义响应格式
如果项目要求所有接口返回统一信封(比如 { code, message, data }),替换 InvalidModelStateResponseFactory 即可,不要在每个 Action 里手写:
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var errors = context.ModelState
.Where(entry => entry.Value?.Errors.Count > 0)
.ToDictionary(
entry => entry.Key,
entry => entry.Value!.Errors.Select(e => e.ErrorMessage).ToArray());
return new BadRequestObjectResult(new
{
code = "VALIDATION_FAILED",
message = "请求参数校验失败。",
data = errors,
});
};
});
4.2 关闭自动校验
极少数场景需要自己控制(例如要把校验错误和业务错误合并成一份报告):
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.SuppressModelStateInvalidFilter = true;
});
关闭后必须自己检查,否则非法数据会直接流进业务代码:
[HttpPost]
public IActionResult Create(CreateTaskRequest request)
{
if (!ModelState.IsValid)
{
return ValidationProblem(ModelState);
}
return Ok();
}
ControllerBase.ValidationProblem(ModelState) 会生成与默认行为一致的 ValidationProblemDetails。
4.3 在 Action 里补充错误
业务层发现问题时,也可以把错误并入同一份结构返回:
if (await _repository.ExistsAsync(request.TaskCode))
{
ModelState.AddModelError(nameof(request.TaskCode), "任务编号已存在。");
return ValidationProblem(ModelState);
}