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

xiaoxiaotank發表於2021-12-08

注:本文隸屬於《理解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資料結構,你可以按照自己的方式來。

相關文章