Web API 参数验证:原生验证 与 FluentValidation 对比

原生验证方式

一般来说,我们对参数做校验时都会采用原生 数据注解+Validator 的方式来进行校验,例如下面的UserDTO

csharp 复制代码
internal record UserDTO
{
    public int Id { get; init; }
​
    [Required(ErrorMessage = "用户名不能为空")]
    public string UserName { get; init; } = null!;
​
    [Required(ErrorMessage = "邮箱地址不能为空")]
    [EmailAddress(ErrorMessage = "邮箱地址格式错误")]
    public string? Email { get; init; }
​
    [Required(ErrorMessage = "描述1不能为空")]
    [StringLength(120, MinimumLength = 1, ErrorMessage = "描述1长度必须在1~120个字符之间")]
    public string Description1 { get; init; } = null!;
​
    public string? Description2 { get; init; }
}

使用时可通过 Validator.TryValidateObject 手动触发验证:

ini 复制代码
var user = new UserDTO { /* ... */ };
var context = new ValidationContext(user);
var results = new List<ValidationResult>();
bool isValid = Validator.TryValidateObject(user, context, results, validateAllProperties: true);

这种方式对于单字段的简单规则 (如非空、长度、格式)非常高效。然而,一旦涉及跨字段的条件验证,原生机制就显得力不从心。

例如,现在新增一条业务规则:

只有当 Description1 有值时,Description2 才允许有值;若 Description1 为空,则 Description2 必须为空。

这时就不太好办了,只能让 DTO 实现 IValidatableObject 接口,在类内部编写验证逻辑

csharp 复制代码
internal record UserDTO : IValidatableObject //继承 IValidatableObject 接口
{
    // ... 属性定义 ...
    
    //验证
    public IEnumerable<ValidationResult> Validate(ValidationContext context)
    {
        if (string.IsNullOrEmpty(Description1) && !string.IsNullOrEmpty(Description2))
        {
            yield return new ValidationResult(
                "不能在 Description1 为空的情况下填写 Description2",
                new[] { nameof(Description1), nameof(Description2) });
        }
    }
    
}

虽然这是 .NET 官方支持的做法,但在实际项目中会带来一些问题:

  • 验证逻辑与数据模型耦合,违反 单一职责原则(SRP)关注点分离(SoC)
  • 条件判断需手动编写 if 语句,规则复杂时代码难以维护;
  • 验证逻辑难以独立测试或复用。

社区很多大佬也发现了这些问题,也针对这些痛点开发出了很多实用的验证框架,其中 FluentValidation 因其流畅的 API、强大的表达能力,成为广泛采用的解决方案

FluentValidation验证

与 .NET 原生的 数据注解+Validator 不同,FluentValidation 将验证逻辑从模型中分离出来,更适合构建高内聚、低耦合 的应用程序,尤其是在 DDD(领域驱动设计)和 整洁架构(Clean Architecture) 中被广泛采用。以下是它的一些核心特点

特性 说明
流畅 API 使用链式语法定义规则,代码直观易读
验证与模型分离 验证逻辑写在独立的验证器类中,不污染 DTO/实体
支持复杂条件验证 如"字段 A 为空时,字段 B 必填"等跨字段规则
内置丰富验证规则 非空、长度、正则、邮箱、范围、集合、异步验证等
高度可扩展 支持自定义验证器、条件、消息模板、本地化等

使用说明

在使用FluentValidation 时,我们需要对每个业务逻辑或验证对象写出一个对应的继承了AbstractValidator<T>的验证类,并在构造方法中实现验证逻辑,以下还拿前面提到的UserDTO举例

csharp 复制代码
//移除验证逻辑,只负责作为贫血模型来传输数据
internal record UserDTO
{
    public int Id { get; init; }
​
    public string UserName { get; init; } = null!;
    
    public string? Email { get; init; }
​
    public string Description1 { get; init; } = null!;
​
    public string? Description2 { get; init; }
}
scss 复制代码
public class UserDTOValidator : AbstractValidator<UserDTO>
{
    public UserDTOValidator()
    {
        //非空验证
        RuleFor(x => x.UserName)
            .NotNull().NotEmpty().WithMessage("用户名不能为空");
        
        //正则验证 仅作为扩展示例,原模型中不包含此字段
        RuleFor(x => x.PhoneNumber)
            .Matches(@"^1[3-9]\d{9}$").WithMessage("手机号格式错误");
        
        //邮箱验证
        RuleFor(x => x.Email)
            .NotEmpty().WithMessage("邮箱不能为空")
            .EmailAddress().WithMessage("邮箱格式不正确");
        
       //实现较为复杂验证逻辑的方式1
        RuleFor(x => x.Description1)
            .NotEmpty().When(x => !string.IsNullOrWhiteSpace(x.Description2)).WithMessage("不能在 Description1 为空的情况下填写 Description2");
        
        //如果不想使用以上语法的话,可以使用Custom/CustomAsync的方式来进行自定义设置,但是阅读起来比较费劲
        RuleFor(x => x.Description1).Custom((x, context) =>
        {
            var model = context.InstanceToValidate;
            if (!string.IsNullOrWhiteSpace(model.Description2) && string.IsNullOrWhiteSpace(model.Description1))
            {
                context.AddFailure("不能在 Description1 为空的情况下填写 Description2");
            }
        });
    }
}

除此之外,还有预定义的很多验证方法,例如GreaterThanOrEqualTo验证数值最小值,LessThanOrEqualTo验证数值最大值,对于自定义类使用Must来验证类内部的属性等,在结尾会有常用方法与说明

使用方式

定义好UserDTOValidator验证器后,我们可以通过多种方式在应用中执行验证逻辑。常见的方式包括以下几种

1. 手动调用验证

这是最基础且简单通用的方式,不依赖框架

csharp 复制代码
public void Test(UserDTO user)
{
    var validator = new UserDTOValidator();
    ValidationResult result = validator.Validate(request);
    // 或异步验证
    var result = await validator.ValidateAsync(request);
    if (!result.IsValid)
    {
        foreach (var failure in result.Errors)
        {
            //做对应错误信息处理
            Console.WriteLine($"{failure.PropertyName}: {failure.ErrorMessage}");
        }
    }
}

2. 通过依赖注入实现验证

手动验证

首先是在Program.cs中注册对应的服务

scss 复制代码
//一次性注册全部相关服务的两种方式
//第一种
builder.Services.AddValidatorsFromAssemblyContaining<Program>();//其中 Program 是验证类所在的程序集中的类
//第二种
builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly);

然后在对应Controller中构造出对应服务

csharp 复制代码
[ApiController]
[Route("test")]
public class TestController : ControllerBase
{
    private readonly IValidator<UserDTO> _validator;
​
    public TestController(IValidator<UserDTO> validator)
    {
        _validator = validator; 
    }
​
    [HttpPost]
    [Route("test")]
    public async Task<ActionResult<List<string>>> Test([FromBody] UserDTO request)
    {
        List<string> errors = new();
        var result = await _validator.ValidateAsync(request);
        if(!result.IsValid)
        {
            result.Errors.ForEach(x => errors.Add($"{x.PropertyName}:{x.ErrorMessage}"));
            return BadRequest(errors);
        }
        return Ok(result);
    }
}
自动验证(不推荐 / 已弃用集成方式)

官方不再推荐将 FluentValidation 集成到 ASP.NET Core 的内置模型验证管道中,而是建议显式调用验证器。所以对应的实现这里就不举例说明了

以下是官方不推荐的原文以及翻译

  • The ASP.NET validation pipeline is not asynchronous : If your validator contains asynchronous rules then your validator will not be able to run. You will receive an exception at runtime if you attempt to use an asynchronous validator with auto-validation. ASP.NET 验证管道不支持异步操作:如果你的验证器包含异步规则,则无法通过该管道执行。若在启用自动验证的情况下使用异步验证器,运行时将抛出异常
  • It is MVC-only : This approach for auto-validation only works with MVC Controllers and Razor Pages. It does not work with the more modern parts of ASP.NET such as Minimal APIs or Blazor. 仅限 MVC 使用:此自动验证方式仅适用于 MVC 控制器和 Razor Pages,无法用于 ASP.NET Core 中更现代的编程模型,例如 Minimal API 或 Blazor
  • It is harder to debug : The 'magic' nature of auto-validation makes it hard to debug/troubleshoot if something goes wrong as so much is done behind the scenes. 调试难度较高:由于自动验证在幕后隐式执行大量逻辑,其"黑盒"特性使得在出现问题时难以定位和排查错误

因此,在新项目中,推荐采用依赖注入 + 显式调用 ValidateAsync 的方式,既能支持异步规则,又兼容 Minimal API、Blazor 等架构,同时也便于单元测试和错误定制

3. 单元测试

由于验证器是独立类,我们可以轻松为其编写单元测试,无需构造复杂的模型或依赖 Web 上下文

ini 复制代码
    [TestMethod]
    public void MyTestMethod()
    {
        // Arrange
        var validator = new UserDTOValidator();
​
        // Act
        var result = validator.TestValidate(new UserDTO 
        { 
            Description1 = "", 
            Description2 = "test" 
        });
​
        // Assert
        result.ShouldHaveValidationErrorFor(x => x.Description1);
    }

4. 进阶方案:搭配MediatR 中使用

在 CQRS 模式中,通常在 Command/QueryHandler 前会自动进行验证。 注:MediatR会在后续其它文档中讲述

写一个Behavior

csharp 复制代码
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;
​
    public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }
​
    public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
    {
        var context = new ValidationContext<TRequest>(request);
        var failures = _validators
            .Select(v => v.Validate(context))
            .SelectMany(result => result.Errors)
            .Where(failure => failure != null)
            .ToList();
​
        if (failures.Any())
            throw new ValidationException(failures);
​
        return await next();
    }
}

并且注册对应服务

builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));

通过以上几种方式,我们可以灵活地将 FluentValidation 集成到各类 .NET 应用中------无论是简单的控制器手动调用,还是结合 CQRS 模式的自动管道验证。选择哪种方式,取决于项目的架构复杂度和对可维护性的要求。


附录

原生验证与FluentValidation优劣势对比

对比维度 原生验证(Data Annotations) FluentValidation
定义方式 在 DTO/Model 类上直接使用特性([Required], [EmailAddress] 通过独立的验证类(继承 AbstractValidator<T>)集中编写验证逻辑
代码耦合度 高:验证逻辑与模型强耦合,违反"关注点分离"原则 低:验证逻辑与模型完全解耦,模型保持"贫血"和纯净
可读性与维护性 简单场景下很清晰,复杂场景难以维护 验证规则集中、结构清晰,支持链式语法,易于阅读和维护
条件验证支持 需要自定义 ValidationAttribute 原生支持 .When(), .Unless() 等条件判断
异步验证支持 不支持异步 支持 ValidateAsync()CustomAsync()
跨属性/复杂业务验证 困难:需编写自定义验证特性,复用性差 简单:可在 RuleForCustom 中直接访问整个对象实例
测试友好性 较差:验证逻辑分散在属性上,难以单元测试 优秀:验证器是独立类,可轻松进行单元测试
自动集成 MVC 模型验证 开箱即用 旧版支持自动集成,新版不推荐
学习成本 低:内置、简单直观 中:需学习 Fluent API 和验证器注册机制
适用场景 简单表单、快速原型、小型项目 中大型项目、复杂业务规则、需要高可维护性和扩展性的系统

FluentValidation常用验证方法

验证方法 说明
NotNull() 验证值不能为 null
NotEmpty() 验证字符串、集合等不能为 null 或空(对字符串调用时等价于 !string.IsNullOrEmpty)
Empty() 验证值必须为 null 或空(字符串、集合等)
Equal(T expected) / Equal(Func<T, TProperty> expression) 验证值必须等于指定值或另一属性的值
NotEqual(T unexpected) / NotEqual(Func<T, TProperty> expression) 验证值不能等于指定值或另一属性的值
Length(int min, int max) 验证字符串长度必须在指定范围内(包含边界)
MinimumLength(int min) 验证字符串最小长度
MaximumLength(int max) 验证字符串最大长度
Matches(string regex) / Matches(Regex regex) 验证字符串是否匹配正则表达式
EmailAddress() 验证字符串是否为有效电子邮件格式
CreditCard() 验证字符串是否为有效的信用卡号(使用 Luhn 算法)
GreaterThan(T value) / GreaterThan(Expression<Func<T, TProperty>> expression) 验证值必须大于指定值或另一属性
GreaterThanOrEqualTo(T value) / GreaterThanOrEqualTo(Expression<Func<T, TProperty>> expression) 验证值必须大于或等于指定值或另一属性
LessThan(T value) / LessThan(Expression<Func<T, TProperty>> expression) 验证值必须小于指定值或另一属性
LessThanOrEqualTo(T value) / LessThanOrEqualTo(Expression<Func<T, TProperty>> expression) 验证值必须小于或等于指定值或另一属性
InclusiveBetween(T from, T to) 验证值必须在 [from, to] 范围内(包含边界)
ExclusiveBetween(T from, T to) 验证值必须在 (from, to) 范围内(不包含边界)
Must(Func<TProperty, bool> predicate) 自定义同步验证逻辑(返回 true 表示有效)
MustAsync(Func<TProperty, CancellationToken, Task> predicate) 自定义异步验证逻辑
Custom(Action<TProperty, ValidationContext> action) 完全自定义验证行为,可添加任意错误信息和属性名
CustomAsync(Func<TProperty, ValidationContext, CancellationToken, Task> action) 异步版本的 Custom
ScalePrecision(int scale, int precision) 验证 decimal 类型的小数位数(scale)和总位数(precision)
Cascade(CascadeMode mode) 设置当前规则的级联模式(Continue/Stop)
Predicate(Func<T, bool> predicate) 条件性跳过整个 RuleFor(较少用,通常用 When)
IsEnumName(bool caseSensitive = false) 验证字符串是否为枚举的有效名称
Enum() 验证值是否为有效枚举值(适用于可空或非可空枚举)
ChildRules(Action configure) 对复杂对象属性进行嵌套验证(需配合 SetValidator 或直接内联)
SetValidator(IValidator validator) 显式指定子属性的验证器实例
ForEach(Action<IRuleBuilderInitial<TElement, TElement>> ruleCallback) 对集合中的每个元素应用验证规则

注:

  • 所有方法均可链式调用,并支持 .WithMessage().WithErrorCode() 等自定义错误信息
  • 部分方法(如 NotEmpty)对不同类型的属性(string、IEnumerable、Guid 等)有不同语义

使用框架

  • FluentValidation 12.1.0
  • FluentValidation.DependencyInjectionExtensions 12.1.0

参考链接

  • 官方文档:https://docs.fluentvalidation.net
  • GitHub:https://github.com/FluentValidation/FluentValidation
  • 博客园-唐青枫:[C#.NET FluentValidation 全面解析:优雅实现对象验证 - 我是唐青枫 - 博客园](https://www.cnblogs.com/TangQF/articles/19154031)

版权声明:本文为个人原创,首发于微信公众号、掘金平台、博客园,作者均为「白气急」。转载请标明出处并附带链接。

相关推荐
2501_916766541 小时前
【Springboot】主配置文件
java·spring boot·后端
星释1 小时前
Rust 练习册 21:Hello World 与入门基础
开发语言·后端·rust
绝无仅有1 小时前
大厂面试题MySQL解析:MVCC、Redolog、Undolog与Binlog的区别
后端·面试·架构
绝无仅有1 小时前
MySQL面试题解析:MySQL读写分离与主从同步
后端·面试·架构
MegatronKing1 小时前
一个有意思的问题引起了我的反思
前端·后端·测试
JohnYan2 小时前
Bun技术评估 - 30 SSE支持
javascript·后端·bun
程序猿_极客2 小时前
【2025最新】 Java 入门到实战:数组 + 抽象类 + 接口 + 异常(含案例 + 语法全解析+巩固练习题)
java·开发语言·后端·java基础·java入门到实战
v***43172 小时前
spring.profiles.active和spring.profiles.include的使用及区别说明
java·后端·spring
IT_陈寒2 小时前
Vue3性能优化实战:5个被低估的Composition API技巧让你的应用快30%
前端·人工智能·后端