天天看點

了解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

時,會發生類型轉換錯誤,此時,會将該模型狀态标記為無效。

綁定格式

int

string

、模型類等綁定格式大家已經很熟悉了,我就不再贅述了。這次,隻給大家介紹一些比較特殊的綁定格式。

集合

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

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

      錯誤
    • 可以通過

      AddModelError

      TryAddModelError

      方法添加錯誤
    • 另外,若是直接修改

      ModelStateEntry

      ,那錯誤數量不會受該屬性限制
  • ValidationState

    :模型驗證狀态
    • Unvalidated

      :未驗證。當模型尚未進行驗證或任意一個

      ModelStateEntry

      驗證狀态為

      Unvalidated

      時,該值為未驗證。
    • Invalid

      :無效。當模型已驗證完畢(即沒有

      ModelStateEntry

      Unvalidated

      )并且任意一個

      ModelStateEntry

      Invalid

      ,該值為無效。
    • Valid

      :有效。當模型已驗證完畢,且所有

      ModelStateEntry

      驗證狀态僅包含

      Valid

      Skipped

      時,該值為有效。
    • 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表達式來建構強類型的驗證規則。

  • 檢視github
  • 檢視官網

接下來,跟我一起感受

FluentValidation

的魅力吧!

為了更好的展示,我們先豐富一下

CreateUserDto

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

    public int Age { get; set; }
}
           

安裝

今天,我們要安裝兩個包,分别是

FluentValidation

FluentValidation.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

,因為開發時很容易就在不經意間,在單例的驗證器中依賴了

Transient

Scoped

的服務,這會導緻生命周期提升。

另外,如果你想将

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

,該類包含了

FirstName

LastName

兩個屬性,并為其建立了一個驗證器。很顯然,我們希望在驗證

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

自定義驗證

官方提供的驗證器已經可以覆寫大多數的場景,但是總有一些場景是和我們的業務息息相關的,是以,自定義驗證就不可或缺了,官方為我們提供了

Must

Custom

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

這就很容易了解了,當驗證發現

Name

null

時,就會提示消息“姓名 不能為 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

,他預設會作用于

NotNull

Equal(false)

Equal(true)

上面,隻有當

Age >= 18

時,才會執行這些規則,然而,

NotNull

Equal(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.CascadeMode

Stop

時(下面會介紹),若多個驗證器中有驗證失敗的規則,那麼這些驗證器都會傳回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。

注意:僅當

AutomaticValidationEnabled

true

時,才會生效。

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

(預設)、

Warning

Info

即使你講嚴重級别設定為了

Warning

或者

Info

ValidationResult.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資料結構,你可以按照自己的方式來。

繼續閱讀