使用 FluentValidation 做数据校验
适用范围:ASP.NET Core 8 及以上版本,FluentValidation 11.x / 12.x。基础的模型校验概念见 数据校验。
1. 为什么需要它
DataAnnotations 把规则写在 DTO 的属性上,简单直接,但规则一复杂就会露出短板:
- 跨字段规则只能靠
IValidatableObject,最终变成一大段if判断; - 条件规则("只有当 A 为某值时才校验 B")没有表达方式;
- 规则依赖服务时,需要在特性里
GetRequiredService,让校验特性和容器耦合; - 规则难以单独做单元测试。
FluentValidation 把校验规则抽成独立的类,用链式 API 描述。规则与模型分离,可以注入服务,也可以像普通类一样测试。
代价也要说清楚:规则不再体现在 DTO 上,OpenAPI / Swagger 不会自动带上这些约束,接口文档里看不到必填标记;同时多引入一个依赖,团队需要统一写法。
2. 安装与注册
dotnet add package FluentValidation.AspNetCore
FluentValidation.AspNetCore 包提供的"自动校验"(AddFluentValidationAutoValidation)已被官方标记为弃用,原因是它挂在 MVC 的模型校验管道上,行为难以预测、也无法用于 Minimal API。
现在的推荐做法是显式调用校验器:在控制器里注入 IValidator<T>,或在过滤器里统一调用。本文按这个思路来写。如果只用显式调用,其实只依赖 FluentValidation.DependencyInjectionExtensions 包就够了。
在容器中注册程序集里的所有校验器:
using FluentValidation;
builder.Services.AddValidatorsFromAssemblyContaining<CreateTaskRequestValidator>();
这个扩展方法会扫描指定程序集,把所有 AbstractValidator<T> 的实现注册为 IValidator<T>,默认生命周期是 Scoped——这一点很重要,因为校验器里经常需要注入仓储等 Scoped 服务。需要改成单例时:
builder.Services.AddValidatorsFromAssemblyContaining<CreateTaskRequestValidator>(
lifetime: ServiceLifetime.Singleton);
3. 编写第一个校验器
规则写在 AbstractValidator<T> 的构造函数里:
using FluentValidation;
public sealed class CreateTaskRequestValidator : AbstractValidator<CreateTaskRequest>
{
public CreateTaskRequestValidator()
{
RuleFor(x => x.TaskCode)
.NotEmpty().WithMessage("任务编号不能为空。")
.Length(4, 32).WithMessage("任务编号长度应在 4 到 32 个字符之间。")
.Matches("^[A-Z0-9-]+$").WithMessage("任务编号只能包含大写字母、数字和短横线。");
RuleFor(x => x.Priority)
.InclusiveBetween(1, 9999);
RuleFor(x => x.LocationCode)
.NotEmpty()
.Matches(@"^[A-Z]{2}-\d{3}$").WithMessage("货位格式应为 AB-001。");
}
}
对应的 DTO 上不需要任何特性:
public sealed class CreateTaskRequest
{
public string TaskCode { get; set; } = string.Empty;
public int Priority { get; set; }
public string LocationCode { get; set; } = string.Empty;
public string? NotifyEmail { get; set; }
public List<OrderLineDto> Lines { get; set; } = [];
}
3.1 链式规则的短路行为
默认情况下,同一个 RuleFor 链上的规则会全部执行,一个字段可能返回多条错误。这在上面的例子里会造成重复提示:TaskCode 为空时,NotEmpty、Length、Matches 三条都会失败。
用 Cascade 在第一个失败处停止:
RuleFor(x => x.TaskCode)
.Cascade(CascadeMode.Stop)
.NotEmpty().WithMessage("任务编号不能为空。")
.Length(4, 32)
.Matches("^[A-Z0-9-]+$");
也可以在校验器里统一设置,或者全局设置:
public CreateTaskRequestValidator()
{
ClassLevelCascadeMode = CascadeMode.Continue; // 字段之间:全部校验
RuleLevelCascadeMode = CascadeMode.Stop; // 字段内部:遇错即停
}
ValidatorOptions.Global.DefaultRuleLevelCascadeMode = CascadeMode.Stop;
一般建议:字段之间 Continue(一次返回所有出错字段,前端可一次标红),字段内部 Stop(同一字段只给一条最有用的提示)。
4. 常用规则
| 写法 | 说明 |
|---|---|
.NotNull() / .NotEmpty() | 非 null;非 null 且非空串、非空集合、非默认值 |
.Length(min, max) / .MaximumLength(n) | 字符串长度 |
.InclusiveBetween(a, b) / .ExclusiveBetween(a, b) | 数值区间,含 / 不含边界 |
.GreaterThan(0) / .LessThanOrEqualTo(x) | 比较,参数可以是另一个属性的表达式 |
.Matches(pattern) | 正则 |
.EmailAddress() / .CreditCard() | 常见格式 |
.IsInEnum() | 值必须是枚举中已定义的成员 |
.Must(predicate) | 任意自定义谓词 |
.MustAsync(predicate) | 异步谓词,用于需要查库的场景 |
.SetValidator(otherValidator) | 委托给另一个校验器,用于嵌套对象 |
.When(condition) / .Unless(condition) | 规则生效的条件 |
.NotEmpty() 对值类型的判断是"不等于默 认值",所以 int Quantity 写 .NotEmpty() 意味着不允许为 0,这常常不是本意——想表达"必须大于 0"就直接写 .GreaterThan(0)。
5. 条件规则
单条规则加条件:
RuleFor(x => x.NotifyEmail)
.EmailAddress()
.When(x => !string.IsNullOrEmpty(x.NotifyEmail));
一组规则共享条件,用 When 块,避免逐条重复:
When(x => x.TaskType == TaskType.Transfer, () =>
{
RuleFor(x => x.FromLocation).NotEmpty().WithMessage("移库任务必须指定起始货位。");
RuleFor(x => x.ToLocation).NotEmpty().WithMessage("移库任务必须指定目标货位。");
})
.Otherwise(() =>
{
RuleFor(x => x.FromLocation).Empty().WithMessage("非移库任务不应指定起始货位。");
});
跨字段比较可以直接写表达式:
RuleFor(x => x.EndTime)
.GreaterThan(x => x.BeginTime).WithMessage("结束时间必须晚于开始时间。");
RuleFor(x => x)
.Must(x => (x.EndTime - x.BeginTime).TotalDays <= 31)
.WithMessage("查询区间不能超过 31 天。")
.WithName(nameof(CreateTaskRequest.EndTime)); // 让错误挂到具体字段上
对整个对象写规则(RuleFor(x => x))时,错误键默认为空字符串,前端无法定位。记得用 WithName 指定一个字段名。
6. 嵌套对象与集合
嵌套对象委托给它自己的校验器:
public sealed class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderRequestValidator()
{
RuleFor(x => x.OrderNo).NotEmpty();
RuleFor(x => x.Receiver)
.NotNull().WithMessage("收货信息不能为空。")
.SetValidator(new AddressDtoValidator()!);
RuleFor(x => x.Lines)
.NotEmpty().WithMessage("订单至少包含一个明细行。")
.Must(lines => lines.Count <= 500).WithMessage("单次提交的明细行不能超过 500 行。");
RuleForEach(x => x.Lines).SetValidator(new OrderLineDtoValidator());
}
}
public sealed class OrderLineDtoValidator : AbstractValidator<OrderLineDto>
{
public OrderLineDtoValidator()
{
RuleFor(x => x.Sku).NotEmpty().MaximumLength(64);
RuleFor(x => x.Quantity).GreaterThan(0).LessThanOrEqualTo(10000);
}
}
集合元素的错误属性名会带上索引,与 MVC 的默认格式一致:
Lines[0].Quantity
Lines[1].Sku
批量接口一定要先限制集合长度再逐元素校验,否则一个超大请求会构造出巨大的错误列表。
RuleForEach 也支持内联规则和条件:
RuleForEach(x => x.Lines)
.Where(line => line.Quantity > 0)
.ChildRules(line =>
{
line.RuleFor(l => l.Sku).NotEmpty();
});
7. 自定义规则
7.1 Must 与 Custom
简单判断用 Must:
RuleFor(x => x.TaskCode)
.Must(code => !code.Contains(' '))
.WithMessage("任务编号不能包含空格。");
需要根据不同情况给不同消息时,用 Custom:
RuleFor(x => x.LocationCode).Custom((code, context) =>
{
if (code.Length != 6)
{
context.AddFailure("货位编码长度必须为 6 位。");
return;
}
if (!char.IsLetter(code[0]))
{
context.AddFailure("货位编码必须以字母开头。");
}
});
7.2 抽成可复用的扩展方法
同一条规则在多个校验器里出现时,写成 IRuleBuilder 的扩展方法:
public static class ValidatorExtensions
{
public static IRuleBuilderOptions<T, string> LocationCode<T>(
this IRuleBuilder<T, string> ruleBuilder)
{
return ruleBuilder
.NotEmpty().WithMessage("货位编码不能为空。")
.Matches(@"^[A-Z]{2}-\d{3}$").WithMessage("货位格式应为 AB-001。");
}
}
使用时和内置规则没有区别:
RuleFor(x => x.LocationCode).LocationCode();
RuleFor(x => x.TargetLocation).LocationCode();
7.3 注入服务与异步规则
校验器是普通类,构造函数注入即可:
public sealed class CreateTaskRequestValidator : AbstractValidator<CreateTaskRequest>
{
public CreateTaskRequestValidator(ILocationRepository locations)
{
RuleFor(x => x.LocationCode)
.LocationCode()
.MustAsync(async (code, token) => await locations.ExistsAsync(code, token))
.WithMessage("货位不存在。");
}
}
用到 MustAsync 时,必须调用 ValidateAsync,用同步的 Validate 会抛 AsyncValidatorInvokedSynchronouslyException。
这里要提醒一句:查库判断"货位是否存在""编号是否重复"已经踩在业务规则的边界上。放在校验器里的好处是错误格式统一、前端处理一致;坏处是并发下仍需在写入时依赖数据库约束兜底——校验通过到真正落库之间存在时间窗。把它当作提前给出的友好提示,而不是唯一防线。
8. 在控制器中调用
注入 IValidator<T>,失败时把错误写进 ModelState,返回与 DataAnnotations 一致的 ValidationProblemDetails:
using FluentValidation;
using FluentValidation.AspNetCore;
[ApiController]
[Route("api/[controller]")]
public sealed class TasksController(
IValidator<CreateTaskRequest> validator,
ITaskService taskService) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Create(CreateTaskRequest request, CancellationToken ct)
{
var result = await validator.ValidateAsync(request, ct);
if (!result.IsValid)
{
result.AddToModelState(ModelState);
return ValidationProblem(ModelState);
}
await taskService.CreateAsync(request, ct);
return Ok();
}
}
AddToModelState 是 FluentValidation.AspNetCore 提供的扩展方法。不引这个包的话,手动转换也很简单:
foreach (var error in result.Errors)
{
ModelState.AddModelError(error.PropertyName, error.ErrorMessage);
}