跳到主要内容

使用 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 为空时,NotEmptyLengthMatches 三条都会失败。

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();
}
}

AddToModelStateFluentValidation.AspNetCore 提供的扩展方法。不引这个包的话,手动转换也很简单:

foreach (var error in result.Errors)
{
ModelState.AddModelError(error.PropertyName, error.ErrorMessage);
}

8.1 用过滤器避免重复代码

每个 Action 都写三行校验代码显然不合适,抽成一个 Action 过滤器:

public sealed class FluentValidationFilter(IServiceProvider services) : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(
ActionExecutingContext context,
ActionExecutionDelegate next)
{
foreach (var argument in context.ActionArguments.Values)
{
if (argument is null)
{
continue;
}

var validatorType = typeof(IValidator<>).MakeGenericType(argument.GetType());
if (services.GetService(validatorType) is not IValidator validator)
{
continue;
}

var result = await validator.ValidateAsync(
new ValidationContext<object>(argument),
context.HttpContext.RequestAborted);

if (!result.IsValid)
{
foreach (var error in result.Errors)
{
context.ModelState.AddModelError(error.PropertyName, error.ErrorMessage);
}
}
}

if (!context.ModelState.IsValid)
{
context.Result = new BadRequestObjectResult(
new ValidationProblemDetails(context.ModelState));
return;
}

await next();
}
}

注册为全局过滤器:

builder.Services.AddControllers(options =>
{
options.Filters.Add<FluentValidationFilter>();
});

这样控制器里就不再出现校验代码,同时保留了显式调用的可控性——没有对应校验器的参数会被跳过,行为不会"意外生效"。


9. 错误消息

9.1 消息模板与占位符

RuleFor(x => x.TaskCode)
.NotEmpty().WithMessage("{PropertyName}不能为空。")
.Length(4, 32)
.WithMessage("{PropertyName}长度应在 {MinLength} 到 {MaxLength} 之间,当前为 {TotalLength}。");

常用占位符:{PropertyName} 字段名、{PropertyValue} 当前值、{ComparisonValue} 比较目标;长度类规则还有 {MinLength}{MaxLength}{TotalLength}

字段名默认取属性名(TaskCode),用 WithName 换成中文:

RuleFor(x => x.TaskCode)
.WithName("任务编号")
.NotEmpty().WithMessage("{PropertyName}不能为空。");

也可以全局把驼峰属性名转成带空格的形式,或接管命名逻辑:

ValidatorOptions.Global.DisplayNameResolver =
(type, member, expression) => member?.Name;

9.2 错误码

WithErrorCode 可以给错误附加一个机器可读的编码,便于前端按码处理或做国际化:

RuleFor(x => x.TaskCode)
.NotEmpty().WithErrorCode("TASK_CODE_REQUIRED");

它会出现在 ValidationFailure.ErrorCode 上,转换成响应结构时可以一并带出。

9.3 严重级别

不想让某条规则导致请求失败时,把它降级为警告:

RuleFor(x => x.Remark)
.MaximumLength(200)
.WithSeverity(Severity.Warning);

Severity 不影响 IsValid 之外的逻辑——它仍然会让 IsValidfalse,只是在 ValidationFailure.Severity 上做了标记,需要自己在转换响应时决定是否忽略。


10. 与 DataAnnotations 的选型

维度DataAnnotationsFluentValidation
规则位置DTO 属性上独立的校验器类
跨字段规则IValidatableObject,写法笨拙一等公民,表达自然
条件规则不支持When / Unless / Otherwise
依赖注入需在特性里 GetRequiredService构造函数注入
异步规则不支持MustAsync
OpenAPI 文档自动生成约束不会自动生成
单元测试需借助 Validator 静态类校验器是普通类,直接构造后断言
额外依赖需引包

实践中的建议:

  • 简单的 CRUD 接口,字段约束就是必填、长度、范围,用 DataAnnotations,顺带把约束反映到 Swagger 上;
  • 一个模型出现三条以上跨字段或条件规则,或规则需要查库时,整体切到 FluentValidation;
  • 不要在同一个模型上混用两套。混用时两套规则的执行时机不同(DataAnnotations 在模型绑定后自动执行,FluentValidation 在过滤器里执行),错误消息容易重复,排查也困难。选定一套,在一个模块内保持一致。