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);
}
5. 可空引用类型与 [Required] 的坑
这是实际项目里最常见的困惑。当项目启用了可空引用类型(<Nullable>enable</Nullable>,新模板默认开启)时,MVC 会把非可空的引用类型属性隐式当作必填,即使你没写 [Required]。
public sealed class CreateTaskRequest
{
public string TaskCode { get; set; } = string.Empty; // 隐式必填
public string? Remark { get; set; } // 可选
}
带来的直接后果是:客户端不传 TaskCode 时会收到一条英文的框架默认消息 The TaskCode field is required.,而你以为自己没加校验。
三种处理方式:
// 方式一(推荐):显式写出 [Required],同时提供自己的错误消息
[Required(ErrorMessage = "任务编号不能为空。")]
public string TaskCode { get; set; } = string.Empty;
// 方式二:属性声明为可空,表示确实可选
public string? TaskCode { get; set; }
// 方式三:全局关闭隐式必填,回到“不写特性就不校验”的行为
builder.Services.AddControllers(options =>
{
options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true;
});
建议选方式一:让校验规则在代码里显式可见,比依赖可空注解的副作用更可靠,也便于生成 OpenAPI 文档。
另外注意,值类型(int、DateTime 等)永远不为 null,加 [Required] 没有意义——客户端不传时它会是默认值 0。要区分“没传”和“传了 0”,把属性声明为 int? 并加 [Required]。
6. 嵌套对象与集合
校验会自动递归到复杂类型的属性和集合元素,不需要额外配置:
public sealed class CreateOrderRequest
{
[Required]
public string OrderNo { get; set; } = string.Empty;
[Required]
public AddressDto? Receiver { get; set; }
[MinLength(1, ErrorMessage = "订单至少包含一个明细行。")]
public List<OrderLineDto> Lines { get; set; } = [];
}
public sealed class OrderLineDto
{
[Required]
public string Sku { get; set; } = string.Empty;
[Range(1, 10000)]
public int Quantity { get; set; }
}
集合元素的错误键会带上索引,前端据此定位到具体行:
{
"errors": {
"Lines[0].Quantity": ["The field Quantity must be between 1 and 10000."],
"Lines[1].Sku": ["The Sku field is required."]
}
}
两个防护点:
- 递归深度默认上限为 32 层,可通过
MvcOptions.MaxValidationDepth调整。超限会抛出InvalidOperationException,通常意味着模型里存在循环引用。 - 收集到的错误数量默认上限为 200(
MvcOptions.MaxModelValidationErrors)。对可能包含上千行明细的批量导入接口,应先用[MaxLength]限制集合长度,避免为一个超大请求构造巨大的错误字典。
7. 自定义校验规则
7.1 自定义 ValidationAttribute
规则可复用、且只依赖单个属性值时,写特性最合适:
[AttributeUsage(AttributeTargets.Property | AttributeTargets.Parameter)]
public sealed class NotFutureDateAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(
object? value,
ValidationContext validationContext)
{
if (value is null)
{
return ValidationResult.Success; // 是否必填交给 [Required] 判断
}
if (value is not DateTime date)
{
return new ValidationResult($"{validationContext.DisplayName} 不是有效的日期。");
}
return date <= DateTime.UtcNow
? ValidationResult.Success
: new ValidationResult($"{validationContext.DisplayName} 不能晚于当前时间。");
}
}
使用方式与内置特性一致:
[NotFutureDate]
public DateTime OccurredAt { get; set; }
两个约定值得遵守:值为 null 时返回成功,把“必填”这件事交给 [Required],职责才不会重叠;错误消息里用 validationContext.DisplayName,它会自动采用 [Display(Name = "...")] 指定的中文名。
需要在特性里用到服务(比如查字典表)时,可以从校验上下文取:
var repository = validationContext.GetRequiredService<ILocationRepository>();
MVC 在构造 ValidationContext 时注入了请求作用域的服务提供程序,所以这样能拿到 Scoped 服务。但如前所述,只有在规则确实属于“输入格式”而非业务状态时才这么做。
7.2 IValidatableObject:跨字段规则
规则涉及多个属性时,特性无能为力,让模型实现 IValidatableObject:
public sealed class QueryRangeRequest : IValidatableObject
{
[Required]
public DateTime? BeginTime { get; set; }
[Required]
public DateTime? EndTime { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (BeginTime is null || EndTime is null)
{
yield break; // 缺失字段已由 [Required] 报错
}
if (BeginTime > EndTime)
{
yield return new ValidationResult(
"开始时间不能晚于结束时间。",
[nameof(BeginTime), nameof(EndTime)]);
}
if ((EndTime.Value - BeginTime.Value).TotalDays > 31)
{
yield return new ValidationResult(
"查询区间不能超过 31 天。",
[nameof(BeginTime), nameof(EndTime)]);
}
}
}
ValidationResult 的第二个参数是相关字段名列表,决定错误挂在 errors 字典的哪些键下。留空则挂到一个空键上,前端不好处理。
注意执行顺序:属性级特性全部通过后,才会调用 Validate 方法。所以方法里通常不需要重复判空,但为了防御,对可空属性仍应先 yield break。
当一个模型里出现三条以上跨字段或条件规则时,IValidatableObject 会迅速变成一大段 if 判断,此时更适合把规则搬到独立的校验器类里,见 FluentValidation。
8. 错误消息本地化
内置特性的默认消息是英文。除了逐个写 ErrorMessage,还可以用资源文件统一管理:
builder.Services
.AddControllers()
.AddDataAnnotationsLocalization();
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
配合 [Display(Name = "...")] 让消息里的字段名也变成中文:
[Display(Name = "任务编号")]
[Required(ErrorMessage = "{0}不能为空。")]
[StringLength(32, ErrorMessage = "{0}长度不能超过 {1} 个字符。")]
public string TaskCode { get; set; } = string.Empty;
消息模板中 {0} 是字段显示名,后续占位符是特性参数([StringLength] 的 {1} 是最大长度,[Range] 的 {1}、{2} 是上下限)。
对只服务单一语言的内部系统,直接在 ErrorMessage 里写中文是完全可以接受的,不必引入资源文件的复杂度。