理解ASP.NET Core - 模型綁定&驗證(Model Binding and Validation)


注:本文隸屬於《理解ASP.NET Core》系列文章,請查看置頂博客或點擊此處查看全文目錄

模型綁定

什么是模型綁定?簡單說就是將HTTP請求參數綁定到程序方法入參上,該變量可以是簡單類型,也可以是復雜類。

綁定源

所謂綁定源,是指用於模型綁定的值來源。

先舉個例子:

[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容器。

建議大家在編寫接口時,盡量顯式指明綁定源。

在綁定的時候,可能會遇到以下兩種情況:

情況一:模型屬性在綁定源中不存在

什么是模型屬性在綁定源中不存在?給大家舉個例子:

[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、模型類等綁定格式大家已經很熟悉了,我就不再贅述了。這次,只給大家介紹一些比較特殊的綁定格式。

集合

假設存在以下接口,接口參數是一個數組:

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的長度是有限制的,若傳遞的集合過長,超過了長度限制,就會有截斷的風險。所以,建議將該集合放到一個模型類里面,該模型類作為接口參數。

字典

假設存在以下接口,接口參數是一個字典:

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方法:

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,傳入以下參數:

{
    "age":"abc"
}

會得到如下響應:

模型狀態無效: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的核心類結構:

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方法重新驗證:

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

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

    public int Age { get; set; }
}

安裝

今天,我們要安裝兩個包,分別是FluentValidationFluentValidation.AspNetCore(后者依賴前者):

  • FluentValidation:是整個驗證庫的核心
  • FluentValidation.AspNetCore:用於與ASP.NET Core集成

選擇你喜歡的安裝方式:

  • 方式1:通過NuGet安裝:
Install-Package FluentValidation

Install-Package FluentValidation.AspNetCore
  • 方式2:通過CLI安裝
dotnet add package FluentValidation

dotnet add package FluentValidation.AspNetCore

創建 CreateUserDto 的驗證器

為了配置CreateUserDto各個屬性的驗證規則,我們需要為它創建一個驗證器(validator),該驗證器繼承自抽象類AbstractValidator<T>T就是你要驗證的類型,這里就是CreateUserDto

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方法:

[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對象時,會得到以下響應:

模型狀態無效:Name' 不能為空。
'Age' 必須大於 '0'。

雖然我們已經基本實現了驗證功能,但是不免有人會吐槽:驗證代碼也太多了吧,而且還要手動 new 一個指定類型的驗證器對象,太麻煩了,我還是喜歡用ModelState

下面就滿足你的要求。

與ASP.NET Core集成

首先,通過AddFluentValidation擴展方法注冊相關服務,並注冊驗證器CreateUserDtoValidator

注冊驗證器的方式有兩種:

  • 一種是手動注冊,如services.AddTransient<IValidator<CreateUserDto>, CreateUserDtoValidator>();
  • 另一種是通過指定程序集,程序集內的所有(public、非抽象、繼承自AbstractValidator<T>)驗證器將會被自動注冊

我們使用第二種方式:

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

注意:AddFluentValidation必須在AddMvc之后注冊,因為其需要使用Mvc的服務。

通過RegisterValidatorsFromAssemblyContaining<T>方法,可以自動查找指定類型所屬的程序集。

該方法可以指定一個filter,可以對要注冊的驗證器進行篩選。

需要注意的是,這些驗證器默認注冊的生命周期是Scoped,你也可以修改成其他的:

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

不過,不建議將其注冊為Singleton,因為開發時很容易就在不經意間,在單例的驗證器中依賴了TransientScoped的服務,這會導致生命周期提升。

另外,如果你想將internal的驗證器也自動注冊到DI容器中,可以通過指定參數includeInternalTypes來實現:

fv.RegisterValidatorsFromAssemblyContaining<CreateUserDtoValidator>(includeInternalTypes: true)

好了,現在將Post方法改回我們熟悉的樣子:

[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

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來實現:

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

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來實現:

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合並到一個驗證器中。

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

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上場了,通過它指定多個實現類的驗證器,當進行模型驗證時,可以自動根據模型類型,選擇對應的驗證器:

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

Must使用起來最簡單,看例子:

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實例,並且可以針對同一個驗證規則創建多個錯誤消息。

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來實現。

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來實現:

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來添加反向條件達成時的驗證規則。

其他驗證配置

一起來看以下其他常用的配置項。

請注意,以下部分配置項,可以在每個驗證器內進行配置覆蓋。

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

當接口入參為集合類型時,如:

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

可以忽略當前文化,強制設置指定文化,如強制設置為美國:

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

驗證屬性展示名稱的解析器。通過該配置,可以自定義驗證屬性展示名稱,如加前綴“xiaoxiaotank_”:

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

    return null;
};

錯誤消息類似如下:

'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方法返回該類型。類似如下:

{
    "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的類型上。

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吧)?以下給出一個簡單的示例:

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數據結構,你可以按照自己的方式來。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM