跳到主要内容

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.IsValidfalse,请求就不会进入 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 文档。

另外注意,值类型(intDateTime 等)永远不为 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 里写中文是完全可以接受的,不必引入资源文件的复杂度。