明志唯新

验证规则构建神器 FluentValidation

发表于

上一篇文章《MediatR在.NET应用中的实践》中,我们在讲 MediatR 的管线内容时,提到过可以在管线中增加 Command/Query 的验证。今天我来带领大家了解一个 .NET 技术领域中很流行的强类型验证规则构建库:FluentValidation

FluentValidation 简介

这么多年的开发工作中,我一直很喜欢 Fluent 编程风格,所以对 Fluent 开头或风格上比较 Fluent 的各种类库工具也都蛮喜欢。比如 .NET 领域的:FluentAssertionsFluentMigratorFluentFTPFluentSchedulerFluentEmail 以及 Flurl 等等。以后我会另起几篇文章介绍一下他们。

FluentValidation 是一个面向 .NET 应用的强类型验证规则构建库,且使用 Apache-2.0 协议开源在 https://github.com/FluentValidation/FluentValidation 。官方网站是: https://fluentvalidation.net

官网直接在首屏以源代码方式来展现他:直观、简洁、很 Fluent的显著特:

public class CustomerValidator : AbstractValidator<Customer> {
  public CustomerValidator() {
    RuleFor(x => x.Surname).NotEmpty();
    RuleFor(x => x.Forename).NotEmpty().WithMessage("Please specify a first name");
    RuleFor(x => x.Discount).NotEqual(0).When(x => x.HasDiscount);
    RuleFor(x => x.Address).Length(20, 250);
    RuleFor(x => x.Postcode).Must(BeAValidPostcode).WithMessage("Please specify a valid postcode");
  }

  private bool BeAValidPostcode(string postcode) {
    // custom postcode validating logic goes here
  }
}

简单理解一下其中的几个关键要素:

  1. 这是一个针对Customer类型对象的验证规则
  2. 规则验证器必须继承自 AbstractValidator<T>,其中的T就是你所希望在这个验证器中验证的实际数据类型;
  3. 规则验证器通过构造函数直接进行规则设定;
  4. 通常针对一个属性的验证规则我们直接以RuleFor(x => x.*** )作为代码开头进行流畅的规则验证;
  5. 内置规则方法已经非常丰富:NotEmptyNotEqualLength等,也可以使用 Must 进行自定义设置;
  6. 可以用When设定规则验证的前提;
  7. 默认常见内置规则,都有统一内置的验证不通过的消息;可通过WithMessage设置独立的验证不通过的消息;

FluentValidation 的相关包

FluentValidation 的验证规则设置能力非常强大,下图中是目前所有内置的规则验证:

用之前,我们通常需要引用的几个包:

  • FluentValidation 核心包,必须的
  • FluentValidation.DependencyInjectionExtensions 当你需要在依赖注入的场景下用的时候,这是必须的
  • FluentValidation.AspNetCore 当你需要在ASP.NET Core相关业务场景用的时候,最好也引用一下这个.

注意 FluentValidation.AspNetCore 中以及包含了对 FluentValidationFluentValidation.DependencyInjectionExtensions 的依赖。

ASP.NET Core 中启用 FluentValidation 的

默认情况下的 ASP.NETASP.NET Core,Controler 中 Action 的参数会被自动绑定和验证通过 DataAnnotation 相关的 Attribute 约定的验证规则,但是 DataAnnotations 应对简单的验证还行,如果需要分不同场景或者有前提条件等的时候,他就明显力不从心了。FluentValidation 则可以针对我们各种需求进行验证,所以我建议大家在实际项目中多考虑使用之。

入门使用

public void ConfigureServices(IServiceCollection services) 
{
    // 或者是 services.AddControllers(setup =>
    services.AddMvc(setup => 
    {
      //...mvc setup...
    }).AddFluentValidation();
}

通过上面的代码启用 FluentValidation 后,MVC 将使用 FluentValidation 来验证 Controller 上 Action 中绑定的 Model 对象。

注意: 作为 .NET 6 一部分的 Minimal API 不支持自动验证

此时你可能会问,他怎么知道用哪个验证规则啊?嗯,上面这种简单启用时,验证规则也需要通过显性的代码进行验证规则注入:

services.AddTransient<IValidator<Customer>, CustomerValidator>();

可以想象,如果你有很多的 Model 类型和对应的验证规则设置,这样一个一个的注册,会心态崩溃,最终放弃的。

自动注册

我们可以根据需要通过下面两种方式来进行自动化的注册:

// 扫描并注册 Startup 类型所在程序集中的 Validator 验证器
services.AddValidatorsFromAssemblyContaining(typeof(Startup));
// 扫描并注册指定名称程序集中的所有 Validator 验证器
services.AddValidatorsFromAssembly(Assembly.Load("SomeAssembly"));

嗯,很好,这样我就可以随意增加新的 Validator ,而不必担心忘记注册了。

进阶设置

默认情况下,在执行 FluentValidation 之后,任何其他验证器提供程序也将有机会执行,这也就意味着您可以将 FluentValidationDataAnnotations 属性(或其他 ModelValidatorProvider)混合使用。

但我们可能并不想混乱的开启那么多验证,造成对同一个 Model 有多套验证,一旦发现不符合业务预期,要到处找验证是怎么回事儿。所以推荐大家只使用其一,比如在启用 FluentValidation 时禁用 DataAnnotations:

AddFluentValidation(fv => {
 // 禁用 MVC 默认的 DataAnnotations 验证
 fv.DisableDataAnnotationsValidation = true;
});

这样,我们的 ASP.NET Core 就会忽略默认的 DataAnnotations 验证。

隐式子属性验证

如果你详细阅读过 FluentValidation 的官方文档,你会了解到它带有子属性验证的场景。也就是一个 Model 的属性类型,是另外一个设置过验证规则的类。我们想让子属性也在父对象被验证时同时被验证,还懒得在验证规则中明文通过SetValidator设置子属性验证,怎么办?

services.AddMvc().AddFluentValidation(fv => 
{
// 递归检查所有子属性的验证规则
 fv.ImplicitlyValidateChildProperties = true;
});

虽然这样可以让你偷懒,但是我不建议这样做,因为验证器不只是 MVC 中需要的。我们在验证规则中应该明文设置子属性验证规则,这样也可针对不同的场景和业务要求让规则显性

对于数据集合,默认情况下,您必须创建特定的集合验证器或启用隐式子属性验证来验证属于集合类型的模型。例如,定义一个继承自 AbstractValidator<List<Customer>> 的验证器。启用隐式子属性验证(见上文)后,您不必显式创建集合验证器类,因为集合中的每个 customer 元素都将被自动验证。但 Customer 对象上的任何子属性也将自动验证!如果你不希望这样,可以选择仅对根集合元素启用隐式验证:

services.AddMvc().AddFluentValidation(fv => 
{
 fv.ImplicitlyValidateRootCollectionElements = true;
});

再次声明,我不建议在 MVC 中启用隐式的子属性验证,这给你的实际业务会带来不确定性和不必要的性能损害。

规则集 RuleSet

规则集允许您将验证规则组合在一起,这些规则可以作为一个组一起执行:

 public class PersonValidator : AbstractValidator<Person> 
 {
  public PersonValidator() 
  {
     RuleSet("Names", () => 
     {
        RuleFor(x => x.Surname).NotNull();
        RuleFor(x => x.Forename).NotNull();
     });

     RuleFor(x => x.Id).NotEqual(0);
  }
}

这样在手动验证等场景下,可以通过代码指定仅验证规则集中的规则,而忽律其他规则:

var validator = new PersonValidator();
var person = new Person();
var result = validator.Validate(person, options => options.IncludeRuleSets("Names"));

在 MediatR 的管线中对 Request 进行自动验证

正如开头我们说的,MediatR 可以通过管线对 Request 进行验证,这里我们也使用 FluentValidation 作为 MediatR 的默认验证。

首先定义验证管线:

public class RequestValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public RequestValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next)
    {
        var failures = _validators
            .Select(v => v.Validate(request))
            .SelectMany(result => result.Errors)
            .Where(f => f != null)
            .ToList();

        if (failures.Count != 0)
        {
            throw new ValidationException(failures);
        }

        return next();
    }
}

然后注册管线:

services.AddTransient(typeof(IPipelineBehavior<,>), typeof(RequestValidationBehavior<,>));

注意 建议验证管线加在其他自定义管线之前,使得每次通过 Mediator Send 一个 Request 时,都会优先执行验证,验证不通过就没后面管线什么事儿了。

最后根据需要在你自己的 ASP.NET Core 自定义异常处理管线中增加针对 ValidationException 的统一处理,下面是我针对 WebAPI 的样例代码参考:

public class CustomExceptionHandlerMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<CustomExceptionHandlerMiddleware> _logger;

    public CustomExceptionHandlerMiddleware(RequestDelegate next, ILogger<CustomExceptionHandlerMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task Invoke(HttpContext context)
    {
        try
        {
            await _next(context);
        }
        catch (Exception ex)
        {
            await HandleExceptionAsync(context, ex);
        }
    }

    private Task HandleExceptionAsync(HttpContext context, Exception exception)
    {
        var code = HttpStatusCode.InternalServerError;

        var result = string.Empty;

        switch (exception)
        {
            case ValidationException validationException:
                code = HttpStatusCode.BadRequest;
                result = JsonConvert.SerializeObject(new { code, message = validationException.Failures.First().Value.FirstOrDefault() ?? validationException.Message, details = validationException.Failures });
                break;
            // case 其他需要统一处理的异常
        }

        context.Response.ContentType = "application/json";
        context.Response.StatusCode = (int)code;

        if (string.IsNullOrEmpty(result))
        {
            _logger.LogError("发生服务器端异常,{@exception}", exception);
            result = JsonConvert.SerializeObject(new { code, message = exception.Message });
        }

        return context.Response.WriteAsync(result);
    }
}

结束语

FluentValidation 远比我在文中介绍的要强大的多,小小的一篇公众号不可能把它完全讲的面面俱到,建议你通过本文了解一些特性后,去读一下官方文档。如果还从未用过,建议你写几个 demo 尝试一下,实践出真知