2

理解ASP.NET Core - 模型绑定&验证(Model Binding and Validation)

 2 years ago
source link: https://www.cnblogs.com/xiaoxiaotank/p/15657240.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

模型绑定&验证(Model Binding and Validation)

注:本文隶属于《理解ASP.NET Core》系列文章,请查看置顶博客或点击此处查看全文目录

什么是模型绑定?简单说就是将HTTP请求参数绑定到程序方法入参上,该变量可以是简单类型,也可以是复杂类。

所谓绑定源,是指用于模型绑定的值来源。

先举个例子:

csharp
[Route("api/[controller]")]
public class UserController : ControllerBase
{
    [Route("{id}")]
    public string Get([FromRoute] string id)
    {
        return id;
    }
}

就拿上面的例子来说,Get方法的参数id,被[FromRoute]标注,表示其绑定源是路由。当然,绑定源不仅仅只有这一种:

  • [FromQuery]:从Url的查询字符串中获取值。查询字符串就是Url中问号(?)后面拼接的参数
  • [FromRoute]:从路由数据中获取值。例如上例中的{id}
  • [FromForm]:从表单中获取值。
  • [FromBody]:从请求正文中获取值。
  • [FromHeader]:从请求标头中获取值。
  • [FromServices]:从DI容器中获取服务。相比其他源,它特殊在值不是来源于HTTP请求,而是DI容器。

建议大家在编写接口时,尽量显式指明绑定源。

在绑定的时候,可能会遇到以下两种情况:

情况一:模型属性在绑定源中不存在

什么是模型属性在绑定源中不存在?给大家举个例子:

csharp
[HttpPost]
public string Post1([FromForm] CreateUserDto input)
{
    return JsonSerializer.Serialize(input);
}

[HttpPost]
public string Post2([FromRoute]int[] numbers)
{
    return JsonSerializer.Serialize(numbers);
}

Post2方法的模型属性numbers要求从路由中寻找值,但是很明显我们的路由中并未提供,这种情况就是模型属性在绑定源中不存在。

默认的,若模型属性在绑定源中不存在,且不加任何验证条件时,不会将其标记为模型状态错误,而是会将该属性设置为null或默认值:

  • 可以为Null的简单类型设置为null
  • 不可为Null的值类型设置为default
  • 如果是复杂类型,则通过默认构造函数创建该实例。如例子中的Post1,如果我们没有通过表单传值,你会发现会得到一个使用CreateUserDto默认构造函数创建的实例。
  • 数组则设置为Array.Empty<T>(),不过byte[]数组设置为null。如例子中的Post2,你会得到一个空数组。

情况二:绑定源无法转换为模型中的目标类型

比如,当尝试将绑定源中的字符串abc转换为模型中的值类型int时,会发生类型转换错误,此时,会将该模型状态标记为无效。

intstring、模型类等绑定格式大家已经很熟悉了,我就不再赘述了。这次,只给大家介绍一些比较特殊的绑定格式。

假设存在以下接口,接口参数是一个数组:

csharp
public string[] Post([FromQuery] string[] ids)

public string[] Post([FromForm] string[] ids)

参数为:[1,2]

为了将参数绑定到数组ids上,你可以通过表单或查询字符串传入,可以采用以下格式之一:

  • ids=1&ids=2
  • ids[0]=1&ids[1]=2
  • [0]=1&[1]=2
  • ids[a]=1&ids[b]=2&ids.index=a&ids.index=b
  • [a]=1&[b]=2&index=a&index=b

此外,表单还可以支持一种格式:ids[]=1&ids[]=2

如果通过查询字符串传递请求参数,你就要注意,由于浏览器对于Url的长度是有限制的,若传递的集合过长,超过了长度限制,就会有截断的风险。所以,建议将该集合放到一个模型类里面,该模型类作为接口参数。

假设存在以下接口,接口参数是一个字典:

csharp
public Dictionary<int, string> Post([FromQuery] Dictionary<int, string> idNames)

参数为:{ [1] = "j", [2] = "k" }

为了将参数绑定到字典idNames上,你可以通过表单或查询字符串传入,可以采用以下格式之一:

  • idNames[1]=j&idNames[2]=k,注意:方括号中的数字是字典的key
  • [1]=j&[2]=k
  • idNames[0].key=1&idNames[0].value=j&idNames[1].key=2&idNames[1].value=k,注意:方括号中的数字是索引,不是字典的key
  • [0].key=1&[0].value=j&[1].key=2&[1].value=k

同样,请注意Url长度限制问题。

聊完了模型绑定,那接下来就是要验证绑定的模型是否有效。

假设UserController中存在一个Post方法:

csharp
public class UserController : ControllerBase
{
    [HttpPost]
    public string Post([FromBody] CreateUserDto input)
    {
        // 模型状态无效,返回错误消息
        if (!ModelState.IsValid)
        {
            return "模型状态无效:"
                + string.Join(Environment.NewLine,
                    ModelState.Values.SelectMany(v => v.Errors.Select(e => e.ErrorMessage)));
        }

        return JsonSerializer.Serialize(input);
    }
}

public class CreateUserDto
{
    public int Age { get; set; }
}

现在,我们请求Post,传入以下参数:

json
{
    "age":"abc"
}

会得到如下响应:

pgsql
模型状态无效:The JSON value could not be converted to System.Int32. Path: $.age | LineNumber: 1 | BytePositionInLine: 15.

我们得到了模型状态无效的错误消息,这是因为字符串“abc”无法转换为int类型。

你也看到了,我们通过ModelState.IsValid来检查模型状态是否有效。

另外,对于Web Api应用,由于标记了[ApiController]特性,其会自动执行ModelState.IsValid检察,详细说明查看Web Api中的模型验证

ModelStateDictionary

ModelState的类型为ModelStateDictionary,也就是一个字典,Key就是无效节点的标识,Value就是无效节点详情。

我们一起看一下ModelStateDictionary的核心类结构:

csharp
public class ModelStateDictionary : IReadOnlyDictionary<string, ModelStateEntry>
{
    public static readonly int DefaultMaxAllowedErrors = 200;
    
    public ModelStateDictionary()
        : this(DefaultMaxAllowedErrors) { }
    
    public ModelStateDictionary(int maxAllowedErrors) { ... }
    
    public ModelStateDictionary(ModelStateDictionary dictionary)
            : this(dictionary?.MaxAllowedErrors ?? DefaultMaxAllowedErrors) { ... }
    
    public ModelStateEntry Root { get; }
    
    // 允许的模型状态最大错误数量,默认是 200
    public int MaxAllowedErrors { get; set; }

    // 指示模型状态错误数量是否达到最大值
    public bool HasReachedMaxErrors { get; }

    // 通过`AddModelError`或`TryAddModelError`方法添加的错误数量
    public int ErrorCount { get; }

    // 无效节点的数量
    public int Count { get; }

    public KeyEnumerable Keys { get; }

    IEnumerable<string> IReadOnlyDictionary<string, ModelStateEntry>.Keys => Keys;

    public ValueEnumerable Values { get; }

    IEnumerable<ModelStateEntry> IReadOnlyDictionary<string, ModelStateEntry>.Values => Values;

    // 枚举,模型验证状态,有 Unvalidated、Invalid、Valid、Skipped 共4种
    public ModelValidationState ValidationState { get; }

    // 指示模型状态是否有效,当验证状态为 Valid 和 Skipped 有效
    public bool IsValid { get; }

    public ModelStateEntry this[string key] { get; }
}
  • MaxAllowedErrors:允许的模型状态错误数量,默认是 200。
    • 当错误数量达到MaxAllowedErrors - 1 时,若还要添加错误,则该错误不会被添加,而是添加一个 TooManyModelErrorsException错误
    • 可以通过AddModelErrorTryAddModelError方法添加错误
    • 另外,若是直接修改ModelStateEntry,那错误数量不会受该属性限制
  • ValidationState:模型验证状态
    • Unvalidated:未验证。当模型尚未进行验证或任意一个ModelStateEntry验证状态为Unvalidated时,该值为未验证。
    • Invalid:无效。当模型已验证完毕(即没有ModelStateEntry验证状态为Unvalidated)并且任意一个ModelStateEntry验证状态为Invalid,该值为无效。
    • Valid:有效。当模型已验证完毕,且所有ModelStateEntry验证状态仅包含ValidSkipped时,该值为有效。
    • Skipped:跳过。整个模型跳过验证时,该值为跳过。

默认情况下,模型验证是自动进行的。不过有时,需要为模型进行一番自定义操作后,重新进行模型验证。可以先通过ModelStateDictionary.ClearValidationState方法清除验证状态,然后调用ControllerBase.TryValidateModel方法重新验证:

csharp
public class CreateUserDto
{
    [Required]
    public string FirstName { get; set; }

    [Required]
    public string LastName { get; set; }
}

[HttpPost]
public string Post([FromBody] CreateUserDto input)
{
    if (input.FirstName is null)
    {
        input.FirstName = "first";
    }
    if (input.LastName is null)
    {
        input.LastName = "last";
    }

    // 先清除验证状态
    ModelState.ClearValidationState(string.Empty);

    // 重新进行验证
    if (!TryValidateModel(input, string.Empty))
    {
        return "模型状态无效:"
            + string.Join(Environment.NewLine,
                ModelState.Values.SelectMany(v => v.Errors.Select(e => e.ErrorMessage)));
    }

    return JsonSerializer.Serialize(input);
}

针对一些常用的验证:如判断是否为null、字符串格式是否为邮箱等,为了减少大家的工作量,减少代码冗余,可以通过特性的方式在模型的属性上进行标注。

微软为我们内置了一部分验证特性,位于System.ComponentModel.DataAnnotations命名空间下(只列举一部分):

  • [Required]:验证属性是否为null。该特性作用在可为null的数据类型上才有效
    • 作用于字符串类型时,允许使用AllowEmptyStrings属性指示是否允许空字符串,默认false
  • [StringLength]:验证字符串属性的长度是否在指定范围内
  • [Range]:验证数值属性是否在指定范围内
  • [Url]:验证属性的格式是否为URL
  • [Phone]:验证属性的格式是否为电话号码
  • [EmailAddress]:验证属性的格式是否为邮箱地址
  • [Compare]:验证当前属性和指定的属性是否匹配
  • [RegularExpression]:验证属性是否和正则表达式匹配

大家一定或多或少都接触过这些特性。不过,我并不打算详细介绍这些特性的使用,因为这些特性的局限性较高,不够灵活。

那有没有更好用的呢?当然有,接下来就给大家介绍一款验证库——FluentValidation

FluentValidation

FluentValidation是一款免费开源的模型验证库,通过它,你可以使用Fluent接口和Lambda表达式来构建强类型的验证规则。

接下来,跟我一起感受FluentValidation的魅力吧!

为了更好的展示,我们先丰富一下CreateUserDto

csharp
public class CreateUserDto
{
    public string Name { get; set; }

    public int Age { get; set; }
}

今天,我们要安装两个包,分别是FluentValidationFluentValidation.AspNetCore(后者依赖前者):

  • FluentValidation:是整个验证库的核心
  • FluentValidation.AspNetCore:用于与ASP.NET Core集成

选择你喜欢的安装方式:

  • 方式1:通过NuGet安装:
ada
Install-Package FluentValidation

Install-Package FluentValidation.AspNetCore
  • 方式2:通过CLI安装
ada
dotnet add package FluentValidation

dotnet add package FluentValidation.AspNetCore

创建 CreateUserDto 的验证器

为了配置CreateUserDto各个属性的验证规则,我们需要为它创建一个验证器(validator),该验证器继承自抽象类AbstractValidator<T>T就是你要验证的类型,这里就是CreateUserDto

csharp
public class CreateUserDtoValidator : AbstractValidator<CreateUserDto>
{
    public CreateUserDtoValidator()
    {
        RuleFor(x => x.Name).NotEmpty();
        RuleFor(x => x.Age).GreaterThan(0);
    }
}

验证器很简单,只有一个构造函数,所有的验证规则,都将写入到该构造函数中。

通过RuleFor并传入Lambda表达式为指定属性设定验证规则,然后,就可以以Fluent的方式添加验证规则。这里我添加了两个验证规则:Name 不能为空、Age 必须大于 0

现在,改写一下Post方法:

csharp
[HttpPost]
public string Post([FromBody] CreateUserDto input)
{
    var validator = new CreateUserDtoValidator();
    var result = validator.Validate(input);

    if (!result.IsValid)
    {
        return $"模型状态无效:{result}";
    }

    return JsonSerializer.Serialize(input);
}

通过ValidationResult.ToString方法,可以将所有错误消息组合为一条错误消息,默认分隔符是换行(Environment.NewLine),但是你也可以传入自定义分隔符。

当我们传入一个空的json对象时,会得到以下响应:

csharp
模型状态无效:Name' 不能为空。
'Age' 必须大于 '0'。

虽然我们已经基本实现了验证功能,但是不免有人会吐槽:验证代码也太多了吧,而且还要手动 new 一个指定类型的验证器对象,太麻烦了,我还是喜欢用ModelState

下面就满足你的要求。

与ASP.NET Core集成

首先,通过AddFluentValidation扩展方法注册相关服务,并注册验证器CreateUserDtoValidator

注册验证器的方式有两种:

  • 一种是手动注册,如services.AddTransient<IValidator<CreateUserDto>, CreateUserDtoValidator>();
  • 另一种是通过指定程序集,程序集内的所有(public、非抽象、继承自AbstractValidator<T>)验证器将会被自动注册

我们使用第二种方式:

csharp
public void ConfigureServices(IServiceCollection services)
{
    services.AddControllersWithViews()
        .AddFluentValidation(fv => 
            fv.RegisterValidatorsFromAssemblyContaining<CreateUserDtoValidator>());
}

注意:AddFluentValidation必须在AddMvc之后注册,因为其需要使用Mvc的服务。

通过RegisterValidatorsFromAssemblyContaining<T>方法,可以自动查找指定类型所属的程序集。

该方法可以指定一个filter,可以对要注册的验证器进行筛选。

需要注意的是,这些验证器默认注册的生命周期是Scoped,你也可以修改成其他的:

csharp
fv.RegisterValidatorsFromAssemblyContaining<CreateUserDtoValidator>(lifetime: ServiceLifetime.Transient)

不过,不建议将其注册为Singleton,因为开发时很容易就在不经意间,在单例的验证器中依赖了TransientScoped的服务,这会导致生命周期提升。

另外,如果你想将internal的验证器也自动注册到DI容器中,可以通过指定参数includeInternalTypes来实现:

csharp
fv.RegisterValidatorsFromAssemblyContaining<CreateUserDtoValidator>(includeInternalTypes: true)

好了,现在将Post方法改回我们熟悉的样子:

csharp
[HttpPost]
public string Post([FromBody] CreateUserDto input)
{
    if (!ModelState.IsValid)
    {
        return "模型状态无效:"
            + string.Join(Environment.NewLine,
                ModelState.Values.SelectMany(v => v.Errors.Select(e => e.ErrorMessage)));
    }

    return JsonSerializer.Serialize(input);
}

再次传入一个空的json对象时,就可以得到错误响应啦!

现在,在ASP.NET Core中使用FluentValidation已经初见成效了。不过,我们还有一些细节问题需要解决,如复杂属性验证、集合验证、组合验证等。

复杂属性验证

首先,改造一下CreateUserDto

csharp
public class CreateUserDto
{
    public CreateUserNameDto Name { get; set; }

    public int Age { get; set; }        
}

public class CreateUserNameDto
{
    public string FirstName { get; set; }

    public string LastName { get; set; }
}

public class CreateUserNameDtoValidator : AbstractValidator<CreateUserNameDto>
{
    public CreateUserNameDtoValidator()
    {
        RuleFor(x => x.FirstName).NotEmpty();
        RuleFor(x => x.LastName).NotEmpty();
    }
}

现在,我们的Name重新封装为了一个类CreateUserNameDto,该类包含了FirstNameLastName两个属性,并为其创建了一个验证器。很显然,我们希望在验证CreateUserDtoValidator中,可以使用CreateUserNameDtoValidator来验证Name。这可以通过SetValidator来实现:

csharp
public class CreateUserDtoValidator : AbstractValidator<CreateUserDto>
{
    public CreateUserDtoValidator()
    {
        RuleFor(x => x.Name).SetValidator(new CreateUserNameDtoValidator());
        RuleFor(x => x.Age).GreaterThan(0);
    }
}

需要说明的是,如果Name is null(如果是集合,则若为null或空集合),那么不会执行CreateUserNameDtoValidator。如果要验证Name is not null,请使用NotNull()NotEmpty()

首先,改造一下CreateUserDto

csharp
public class CreateUserDto
{
    public int Age { get; set; }

    public List<string> Hobbies { get; set; }      

    public List<CreateUserNameDto> Names { get; set; }
}

可以看到,新增了两个集合:简单集合Hobbies和复杂集合Names。如果仅使用RuleFor设定验证规则,那么其验证的是集合整体,而不是集合中的每个项。

为了验证集合中的每个项,需要使用RuleForEach或在RuleFor后跟ForEach来实现:

csharp
public class CreateUserDtoValidator : AbstractValidator<CreateUserDto>
{
    public CreateUserDtoValidator()
    {
        RuleFor(x => x.Age).GreaterThan(0);

        // Hobbies 集合不能为空
        RuleFor(x => x.Hobbies).NotEmpty();
        // Hobbies 集合中的每一项不能为空
        RuleForEach(x => x.Hobbies).NotEmpty();

        RuleFor(x => x.Names).NotEmpty();
        RuleForEach(x => x.Names).NotEmpty().SetValidator(new CreateUserNameDtoValidator());
    }
}

验证规则组合

有时,一个类的验证规则,可能会有很多很多,这时,如果都放在一个验证器中,就会显得代码又多又乱。那该怎么办呢?

我们可以为这个类创建多个验证器,将所有验证规则分配到这些验证器中,最后再通过Include合并到一个验证器中。

csharp
public class CreateUserDtoNameValidator : AbstractValidator<CreateUserDto>
{
    public CreateUserDtoNameValidator()
    {
        RuleFor(x => x.Name).NotEmpty();
    }
}

public class CreateUserDtoAgeValidator : AbstractValidator<CreateUserDto>
{
    public CreateUserDtoAgeValidator()
    {
        RuleFor(x => x.Age).GreaterThan(0);
    }
}

public class CreateUserDtoValidator : AbstractValidator<CreateUserDto>
{
    public CreateUserDtoValidator()
    {
        Include(new CreateUserDtoNameValidator());
        Include(new CreateUserDtoAgeValidator());
    }
}

虽然模型绑定不支持反序列化接口类型,但是它在其他场景中还是有用途的。

首先,改造一下CreateUserDto

csharp
public class CreateUserDto
{
    public int Age { get; set; }

    public IPet Pet { get; set; }
}

public interface IPet 
{
    string Name { get; set; }
}

public class DogPet : IPet
{
    public string Name { get; set; }

    public int Age { get; set; }
}

public class CatPet : IPet
{
    public string Name { get; set; }
}

public class DogPetValidator : AbstractValidator<DogPet>
{
    public DogPetValidator()
    {
        RuleFor(x => x.Name).NotEmpty();
        RuleFor(x => x.Age).GreaterThan(0);
    }
}

public class CatPetValidator : AbstractValidator<CatPet>
{
    public CatPetValidator()
    {
        RuleFor(x => x.Name).NotEmpty();
    }
}

这次,我们新增了一个属性,它是接口类型,也就是说它的实现类是不固定的。这种情况下,我们该如何为其指定验证器呢?

这时候就轮到SetInheritanceValidator上场了,通过它指定多个实现类的验证器,当进行模型验证时,可以自动根据模型类型,选择对应的验证器:

csharp
public class CreateUserDtoValidator : AbstractValidator<CreateUserDto>
{
    public CreateUserDtoValidator()
    {
        RuleFor(x => x.Age).GreaterThan(0);

        RuleFor(x => x.Pet).NotEmpty().SetInheritanceValidator(v =>
        {
            v.Add(new DogPetValidator());
            v.Add(new CatPetValidator());
        });
    }
}

自定义验证

官方提供的验证器已经可以覆盖大多数的场景,但是总有一些场景是和我们的业务息息相关的,因此,自定义验证就不可或缺了,官方为我们提供了MustCustom

Must使用起来最简单,看例子:

csharp
public class CreateUserDto
{
    public List<string> Hobbies { get; set; }
}

public class CreateUserDtoValidator : AbstractValidator<CreateUserDto>
{
    public CreateUserDtoValidator()
    {
        RuleFor(x => x.Hobbies).NotEmpty()
            .Must((x, hobbies, context) =>
            {
                var duplicateHobby = hobbies.GroupBy(h => h).FirstOrDefault(g => g.Count() > 1)?.Key;
                if(duplicateHobby is not null)
                {
                    // 添加自定义占位符
                    context.MessageFormatter.AppendArgument("DuplicateHobby", duplicateHobby);
                    return false;
                }

                return true;
            }).WithMessage("爱好不能重复,重复项:{DuplicateHobby}");
    }
}

在该示例中,我们使用自定义验证来验证Hobbies列表中是否存在重复项,并将重复项写入错误消息。

Must的重载中,可以最多接收三个入参,分别是验证属性所在的对象实例、验证属性和验证上下文。另外,还通过验证上下文的MessageFormatter添加了自定义的占位符。

Custom

如果Must无法满足需求,可以考虑使用Custom。相比Must,它可以手动创建ValidationFailure实例,并且可以针对同一个验证规则创建多个错误消息。

csharp
public class CreateUserDtoValidator : AbstractValidator<CreateUserDto>
{
    public CreateUserDtoValidator()
    {
        RuleFor(x => x.Hobbies).NotEmpty()
            .Custom((hobbies, context) =>
            {
                var duplicateHobby = hobbies.GroupBy(h => h).FirstOrDefault(g => g.Count() > 1)?.Key;
                if (duplicateHobby is not null)
                {
                    // 当验证失败时,会同时输出这两条消息
                    context.AddFailure($"爱好不能重复,重复项:{duplicateHobby}");
                    context.AddFailure($"再说一次,爱好不能重复");
                }
            });
    }
}

当存在重复项时,会同时输出两条错误消息(即使设置了CascadeMode.Stop,这就是所期望的)。

现在,模型验证方式你已经全部掌握了。现在的你,是否想要验证消息重写、属性重命名、条件验证等功能呢?

验证消息重写和属性重命名

默认的验证消息可以满足一部分需求,但是无法满足所有需求,所以,重写验证消息,是不可或缺的一项功能,这可以通过WithMessage来实现。

csharp
public class CreateUserDtoValidator : AbstractValidator<CreateUserDto>
{
    public CreateUserDtoValidator()
    {
        RuleFor(x => x.Name)
            .NotNull().WithMessage("{PropertyName} 不能为 null")
            .WithName("姓名");

        RuleFor(x => x.Age)
            .GreaterThan(0).WithMessage(x => $"姓名为“{x.Name}”的年龄“{x.Age}”不正确");
    }
}

WithMessage内,除了自定义验证消息外,还有一个占位符{PropertyName},它可以将属性名Name填充进去。如果你想展示姓名而不是Name,可以通过WithName来更改属性的展示名称。

WithName仅用于重写属性用于展示的名称,如果想要将属性本身重命名,可以使用OverridePropertyName

这就很容易理解了,当验证发现Namenull时,就会提示消息“姓名 不能为 null”。

另外,WithMessage还可以接收Lambda表达式,允许你自由的使用模型的其他属性。

有时,只有当满足特定条件时,才验证某个属性,这可以通过When来实现:

csharp
public class CreateUserDto
{
    public string Name { get; set; }

    public int Age { get; set; }

    public bool? HasGirlfriend { get; set; }

    public bool HardWorking { get; set; }

    public bool Healthy { get; set; }
}

public class CreateUserDtoValidator : AbstractValidator<CreateUserDto>
{
    public CreateUserDtoValidator()
    {
        RuleFor(x => x.HasGirlfriend)
            .NotNull()
            .Equal(false).When(x => x.Age < 18, ApplyConditionTo.CurrentValidator)
            .Equal(true).When(x => x.Age >= 18, ApplyConditionTo.CurrentValidator);

        When(x => x.HasGirlfriend == true, () =>
        {
            RuleFor(x => x.HardWorking).Equal(true);
            RuleFor(x => x.Healthy).Equal(true);
        }).Otherwise(() =>
        {
            RuleFor(x => x.Healthy).Equal(true);
        });
    }
}

When有两种使用方式:

1.第一种是在规则后紧跟When设定条件,那么只有当满足该条件时,才会执行前面的验证规则。

需要注意的是,默认情况下,When会作用于它之前的所有规则上。例如,对于条件x.Age >= 18,他默认会作用于NotNullEqual(false)Equal(true)上面,只有当Age >= 18时,才会执行这些规则,然而,NotNullEqual(false)又受限于条件x.Age < 18

如果我们想要让When仅仅作用于紧跟它之前的那一条验证规则上,可以通过指定ApplyConditionTo.CurrentValidator来达到目的。例如示例中的x.Age < 18仅会作用于Equal(false),而x.Age >= 18仅会作用于Equal(true)

可见,第一种比较适合用于对某一条验证规则设定条件。

2.第二种则是直接使用When来指定达到某个条件时要执行的验证规则。相比第一种,它的好处是更加适合针对多条验证规则添加同一条件,还可以结合Otherwise来添加反向条件达成时的验证规则。

其他验证配置

一起来看以下其他常用的配置项。

请注意,以下部分配置项,可以在每个验证器内进行配置覆盖。

csharp
public class FluentValidationMvcConfiguration
{
    public bool ImplicitlyValidateChildProperties { get; set; }
    
    public bool LocalizationEnabled { get; set; }
    
    public bool AutomaticValidationEnabled { get; set; }
    
    public bool DisableDataAnnotationsValidation { get; set; }
    
    public IValidatorFactory ValidatorFactory { get; set; }
    
    public Type ValidatorFactoryType { get; set; }

    public bool ImplicitlyValidateRootCollectionElements { get; set; }

    public ValidatorConfiguration ValidatorOptions { get; }
}

public class ValidatorConfiguration
{
    public CascadeMode CascadeMode { get; set; }

    public Severity Severity { get; set; }

    public string PropertyChainSeparator { get; set; }

    public ILanguageManager LanguageManager { get; set; }

    public ValidatorSelectorOptions ValidatorSelectors { get; }

    public Func<MessageFormatter> MessageFormatterFactory { get; set; }

    public Func<Type, MemberInfo, LambdaExpression, string> PropertyNameResolver { get; set; }

    public Func<Type, MemberInfo, LambdaExpression, string> DisplayNameResolver { get; set; }

    public bool DisableAccessorCache { get; set; }

    public Func<IPropertyValidator, string> ErrorCodeResolver { get; set; }
}
ImplicitlyValidateChildProperties

默认 false。当设置为 true 时,你就可以不用通过SetValidator为复杂属性设置验证器了,它会自动寻找。注意,当其设置为 true 时,如果你又使用了SetValidator,会导致验证两次。

不过,当设置为 true 时,可能会行为不一致,比如当设置ValidatorOptions.CascadeModeStop时(下面会介绍),若多个验证器中有验证失败的规则,那么这些验证器都会返回1条验证失败消息。这并不是Bug,可以参考此Issue了解原因。

LocalizationEnabled

默认 true。当设置为 true 时,会启用本地化支持,提示的错误消息文本与当前文化(CultureInfo.CurrentUICulture) 有关。

AutomaticValidationEnabled

默认 true。当设置为 true 时,ASP.NET在模型绑定时会尝试使用FluentValidation进行模型验证。如果设置为 false,则不会自动使用FluentValidation进行模型验证。

写这篇文章时,用的 FluentValidation 版本是10.3.5,当时有一个bug,可能你在用的过程中也会很疑惑,我已经提了Issue。现在作者已经修复了,将在新版本中发布。

DisableDataAnnotationsValidation

默认 false。默认情况下,FluentValidation 执行完时,还会执行 DataAnnotations。通过将其设置为 true,来禁用 DataAnnotations。

注意:仅当AutomaticValidationEnabledtrue时,才会生效。

ImplicitlyValidateRootCollectionElements

当接口入参为集合类型时,如:

csharp
public string Post([FromBody] List<CreateUserDto> input)

若要验证该集合,则需要实现继承自AbstractValidator<List<CreateUserDto>>的验证器,或者指定ImplicitlyValidateChildProperties = true

如果,你想仅仅验证CreateUserDto的属性,而不验证其子属性CreateUserNameDto的属性,则必须设置ImplicitlyValidateChildProperties = false,并设置ImplicitlyValidateRootCollectionElements = true(当ImplicitlyValidateChildProperties = true时,会忽略该配置)。

ValidatorOptions.CascadeMode

指定验证失败时的级联模式,共两种(外加一个已过时的):

  • Continue:默认的。即使验证失败了,也会执行全部验证规则。
  • Stop:当一个验证器中出现验证失败时,立即停止当前验证器的继续执行。如果在当前验证器中通过SetValidator为复杂属性设置另一个验证器,那么会将其视为一个验证器。不过,如果设置ImplicitlyValidateChildProperties = true,那么这将会被视为不同的验证器。
  • [Obsolete]StopOnFirstFailure:官方建议,如果可以使用Stop,就不要使用该模式。注意该模式和Stop模式行为并非完全一致,具体要不要用,自己决定。点击此处查看他俩的区别。
ValidatorOptions.Severity

设置验证错误的严重级别,可以配置的项有Error(默认)、WarningInfo

即使你讲严重级别设置为了Warning或者InfoValidationResult.IsValid仍是false。不同的是,ValidationResult.Errors中的严重级别是Warning或者Info

ValidatorOptions.LanguageManager

可以忽略当前文化,强制设置指定文化,如强制设置为美国:

csharp
ValidatorOptions.LanguageManager.Culture = new CultureInfo("en-US");
ValidatorOptions.DisplayNameResolver

验证属性展示名称的解析器。通过该配置,可以自定义验证属性展示名称,如加前缀“xiaoxiaotank_”:

csharp
ValidatorOptions.DisplayNameResolver = (type, member, expression) =>
{
    if (member is not null)
    {
        return "xiaoxiaotank_" + member.Name;
    }

    return null;
};

错误消息类似如下:

1c
'xiaoxiaotank_FirstName' 不能为Null。

上面我们已经接触了{PropertyName}占位符,除了它之外,还有很多。下面就介绍一些:

  • {PropertyName}:正在验证的属性的名称
  • {PropertyValue}:正在验证的属性的值
  • {ComparisonValue}:比较验证器中要比较的值
  • {MinLength}:字符串最小长度
  • {MaxLength}:字符串最大长度
  • {TotalLength}:字符串长度
  • {RegularExpression}:正则表达式验证器的正则表达式
  • {From}:范围验证器的范围下限
  • {To}:范围验证器的范围上限
  • {ExpectedPrecision}:decimal精度验证器的数字总位数
  • {ExpectedScale}:decimal精度验证器的小数位数
  • {Digits}:decimal精度验证器正在验证的数字实际整数位数
  • {ActualScale}:decimal精度验证器正在验证的数字实际小数位数

这些占位符,只能运用在特定的验证器中。更多占位符的详细介绍,请查看官方文档Built-in Validators

Web Api中的模型验证

对于Web Api应用,由于标记了[ApiController]特性,其会自动执行ModelState.IsValid进行检查,若发现模型状态无效,会返回包含错误信息的指定格式的HTTP 400响应。

该格式默认类型为ValidationProblemDetails,在Action中可以通过调用ValidationProblem方法返回该类型。类似如下:

json
{
    "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
    "title": "One or more validation errors occurred.",
    "status": 400,
    "traceId": "00-16fd10e48fa5d545ae2e5f3fee05dc84-d23c49c9a5e35d49-00",
    "errors": {
        "Hobbies[0].LastName": [
            "'xiaoxiaotank_LastName' 不能为Null。",
            "'xiaoxiaotank_LastName' 不能为空。"
        ],
        "Hobbies[0].FirstName": [
            "'xiaoxiaotank_FirstName' 不能为Null。",
            "'xiaoxiaotank_FirstName' 不能为空。"
        ]
    }
}

其实现的根本原理是使用了ModelStateInvalidFilter过滤器,该过滤器会附加在所有被标注了ApiControllerAttribute的类型上。

csharp
public class ModelStateInvalidFilter : IActionFilter, IOrderedFilter
{
    internal const int FilterOrder = -2000;

    private readonly ApiBehaviorOptions _apiBehaviorOptions;
    private readonly ILogger _logger;

    public ModelStateInvalidFilter(ApiBehaviorOptions apiBehaviorOptions, ILogger logger)
    {
        // ...
    }

    // 默认 -2000
    public int Order => FilterOrder;

    public bool IsReusable => true;

    public void OnActionExecuted(ActionExecutedContext context) { }

    public void OnActionExecuting(ActionExecutingContext context)
    {
        if (context.Result == null && !context.ModelState.IsValid)
        {
            _logger.ModelStateInvalidFilterExecuting();
            context.Result = _apiBehaviorOptions.InvalidModelStateResponseFactory(context);
        }
    }
}

internal class ApiBehaviorOptionsSetup : IConfigureOptions<ApiBehaviorOptions>
{
    private ProblemDetailsFactory _problemDetailsFactory;

    public void Configure(ApiBehaviorOptions options)
    {
        // 看这里
        options.InvalidModelStateResponseFactory = context =>
        {
            // ProblemDetailsFactory 中依赖 ApiBehaviorOptionsSetup,所以这里未使用构造函数注入,以避免DI循环
            _problemDetailsFactory ??= context.HttpContext.RequestServices.GetRequiredService<ProblemDetailsFactory>();
            return ProblemDetailsInvalidModelStateResponse(_problemDetailsFactory, context);
        };

        ConfigureClientErrorMapping(options);
    }

    internal static IActionResult ProblemDetailsInvalidModelStateResponse(ProblemDetailsFactory problemDetailsFactory, ActionContext context)
    {
        var problemDetails = problemDetailsFactory.CreateValidationProblemDetails(context.HttpContext, context.ModelState);
        ObjectResult result;
        if (problemDetails.Status == 400)
        {
            // 兼容 2.x
            result = new BadRequestObjectResult(problemDetails);
        }
        else
        {
            result = new ObjectResult(problemDetails)
            {
                StatusCode = problemDetails.Status,
            };
        }
        result.ContentTypes.Add("application/problem+json");
        result.ContentTypes.Add("application/problem+xml");

        return result;
    }

    internal static void ConfigureClientErrorMapping(ApiBehaviorOptions options)
    {
        options.ClientErrorMapping[400] = new ClientErrorData
        {
            Link = "https://tools.ietf.org/html/rfc7231#section-6.5.1",
            Title = Resources.ApiConventions_Title_400,
        };

        // ...还有很多,省略了
    }
}

全局模型验证

Web Api中有全局的自动模型验证,那Web中你是否也想整一个呢(你该不会想总在方法内写ModelState.IsValid吧)?以下给出一个简单的示例:

csharp
public class ModelStateValidationFilterAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        if (!context.ModelState.IsValid)
        {
            if (context.HttpContext.Request.AcceptJson())
            {
                var errorMsg = string.Join(Environment.NewLine, context.ModelState.Values.SelectMany(v => v.Errors.Select(e => e.ErrorMessage)));
                context.Result = new BadRequestObjectResult(AjaxResponse.Failed(errorMsg));
            }
            else
            {
                context.Result = new ViewResult();
            }
        }
    }
}

public static class HttpRequestExtensions
{
    public static bool AcceptJson(this HttpRequest request)
    {
        if (request == null) throw new ArgumentNullException(nameof(request));

        var regex = new Regex(@"^(\*|application)/(\*|json)$");

        return request.Headers[HeaderNames.Accept].ToString()
            .Split(',')
            .Any(type => regex.IsMatch(type));
    }
}

AjaxResponse.Failed(errorMsg)只是自定义的json数据结构,你可以按照自己的方式来。

__EOF__


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK