ASP.NET Core MVC 中的模型驗證
資料模型的驗證被視為是資料合法性的第一步,要求滿足型別、長度、校驗等規則,有了MVC的模型校驗能夠省卻很多前後端程式碼,為程式碼的簡潔性也做出了不少貢獻。
原文地址:https://docs.microsoft.com/zh-cn/aspnet/core/mvc/models/validation?view=aspnetcore-2.1
作者:Rachel Appel
模型驗證簡介
在將資料儲存到資料庫之前,應用必須先驗證資料。 必須檢查資料是否存在潛在的安全威脅,確保資料已設定適當的型別和大小格式,並且必須符合相關規則。 實施驗證的過程可能有些單調乏味,但卻必不可少。 在 MVC 中,驗證發生在客戶端和伺服器上。
幸運的是,.NET 已將驗證抽象化為驗證屬性。 這些屬性包含驗證程式碼,從而減少了所需編寫的程式碼量。
在 ASP.NET Core 2.2 及更高版本中,如果能夠確定給定模型關係圖不需要進行驗證,ASP.NET Core 執行時便會簡化(跳過)驗證。 驗證無法或沒有關聯任何驗證程式的模型時,跳過驗證可能會顯著提升效能。 已跳過的驗證包括諸如基元集合(byte[]
、string[]
、Dictionary<string, string>
等)之類的物件,或沒有任何驗證程式的複雜物件關係圖。
驗證屬性
驗證屬性用於配置模型驗證,因此,在概念上類似於資料庫表中欄位上的驗證。 它包括諸如分配資料型別或必填欄位之類的約束。 其他型別的驗證包括將向資料應用模式以強制實施業務規則,比如信用卡、電話號碼或電子郵件地址。 驗證屬性更易使用,並使這些要求的實施變得更簡單。
驗證特性在屬性級別指定:
C#
[Required]
public string MyProperty { get; set; }
下面是一個應用的已批註 Movie
模型,該應用用於儲存電影和電視節目的相關資訊。 大多數屬性都是必需屬性,多個字串屬性具有長度要求。 此外,還有一個針對·Price
屬性設定的從 0 到 $999.99 的數值範圍限制,以及一個自定義驗證特性。
C#
public class Movie { public int Id { get; set; } [Required] [StringLength(100)] public string Title { get; set; } [ClassicMovie(1960)] [DataType(DataType.Date)] public DateTime ReleaseDate { get; set; } [Required] [StringLength(1000)] public string Description { get; set; } [Range(0, 999.99)] public decimal Price { get; set; } [Required] public Genre Genre { get; set; } public bool Preorder { get; set; } }
通過讀取整個模型即可顯示有關此應用的資料的規則,從而使程式碼維護變得更輕鬆。 下面是幾個常用的內建驗證屬性:
-
[CreditCard]
:驗證屬性是否具有信用卡格式。 -
[Compare]
:驗證某個模型中的兩個屬性是否匹配。 -
[EmailAddress]
:驗證屬性是否具有電子郵件格式。 -
[Phone]
:驗證屬性是否具有電話格式。 -
[Range]
:驗證屬性值是否落在給定範圍內。 -
[RegularExpression]
:驗證資料是否與指定的正則表示式匹配。 -
[Required]
:將屬性設定為必需屬性。 -
[StringLength]
:驗證字串屬性是否最多具有給定的最大長度。 -
[Url]
:驗證屬性是否具有 URL 格式。
MVC 支援從 ValidationAttribute
派生的所有用於驗證的屬性。 在 System.ComponentModel.DataAnnotations 名稱空間中可找到許多有用的驗證屬性。
在某些情況下,內建屬性可能無法提供所需的功能。 這時,就可以通過從 ValidationAttribute
派生或將模型更改為實現 IValidatableObject
,來建立自定義驗證屬性。
必需屬性的使用說明
從本質上來說,需要不可以為 null 的值型別(如 decimal
、int
、float
和 DateTime
),但不需要 Required
特性。 應用不會對標記為 Required
的不可為 null 的型別執行任何伺服器端驗證檢查。
對於不可為 null 的型別,MVC 模型繫結(與驗證和驗證屬性無關)會拒絕包含缺失值或空白的表單域提交。 如果目標屬性上缺少 BindRequired
特性,模型繫結會忽略不可為 null 的型別的缺失資料,導致傳入表單資料中缺少表單域。
BindRequired 特性(另請參閱 ASP.NET Core 中的模型繫結)可用於確保表單資料完整。 當應用於某個屬性時,模型繫結系統要求該屬性具有值。 當應用於某個型別時,模型繫結系統要求該型別的所有屬性都具有值。
使用 Nullable<T> 型別(例如,decimal?
或 System.Nullable<decimal>
)並將其標記為 Required
時,將執行伺服器端驗證檢查,就像該屬性是標準的可以為 null 的型別(例如,string
)一樣。
客戶端驗證要求與標記為 Required
的模型屬性對應的表單域以及未標記為 Required
的不可為 null 的型別屬性具有值。 Required
可用於控制客戶端驗證錯誤訊息。
模型狀態
模型狀態表示已提交的 HTML 表單值中的驗證錯誤。
MVC 將繼續驗證欄位,直至達到錯誤數上限(預設為 200 個)。 可以使用 Startup.ConfigureServices
中的以下程式碼配置該數字:
C#
services.AddMvc(options => options.MaxModelValidationErrors = 50);
處理模型狀態錯誤
在執行控制器操作之前進行模型驗證。 該操作負責檢查 ModelState.IsValid
並做出相應響應。 在許多情況下,正確的反應是返回錯誤響應,理想狀況下會詳細說明模型驗證失敗的原因。
如果在使用 [ApiController]
屬性的 web API 控制器中,ModelState.IsValid
的計算結果為 false
,將返回包含問題詳細資訊的自動 HTTP 400 響應。 有關詳細資訊,請參閱自動 HTTP 400 響應。
某些應用會選擇遵循標準約定來處理模型驗證錯誤,在這種情況下,可以在篩選器中實現此類策略。 應測試操作在有效模型狀態和無效模型狀態下的行為方式。
手動驗證
完成模型繫結和驗證後,可能需要重複其中的某些步驟。 例如,使用者可能在應輸入整數的欄位中輸入了文字,或者你可能需要計算模型的某個屬性的值。
你可能需要手動執行驗證。 為此,請呼叫 TryValidateModel
方法,如下所示:
C#
TryValidateModel(movie);
自定義驗證
驗證屬性適用於大多數驗證需求。 但是,某些驗證規則特定於你的業務。 你的規則可能不是常見的資料驗證技術,比如確保欄位是必填欄位或符合一系列值。 在這些情況下,自定義驗證屬性是一種不錯的解決方案。 在 MVC 中建立你自己的自定義驗證屬性很簡單。 只需從 ValidationAttribute
繼承並重寫 IsValid
方法。 IsValid
方法採用兩個引數,第一個是名為 value 的物件,第二個是名為 validationContext 的 ValidationContext
物件。 Value 引用自定義驗證程式要驗證的欄位中的實際值。
在下面的示例中,一項業務規則規定,使用者不能將 1960 年以後發行的電影的流派設定為 Classic。 [ClassicMovie]
屬性會先檢查流派,如果是經典流派,則檢視發行日期是否晚於 1960 年。 如果晚於 1960 年,則驗證失敗。 此屬性採用一個表示年份的整數引數,可用於驗證資料。 可以在該屬性的建構函式中捕獲該引數的值,如下所示:
C#
public class ClassicMovieAttribute : ValidationAttribute, IClientModelValidator
{
private int _year;
public ClassicMovieAttribute(int year)
{
_year = year;
}
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
Movie movie = (Movie)validationContext.ObjectInstance;
if (movie.Genre == Genre.Classic && movie.ReleaseDate.Year > _year)
{
return new ValidationResult(GetErrorMessage());
}
return ValidationResult.Success;
}
上面的 movie
變量表示一個 Movie
物件,其中包含要驗證的表單提交中的資料。 在此例中,驗證程式碼會根據規則檢查 ClassicMovieAttribute
類的 IsValid
方法中的日期和流派。 驗證成功時,IsValid
返回 ValidationResult.Success
程式碼。 驗證失敗時,返回 ValidationResult
和錯誤訊息:
C#
private string GetErrorMessage()
{
return $"Classic movies must have a release year earlier than {_year}.";
}
當用戶修改 Genre
欄位並提交表單時,ClassicMovieAttribute
的 IsValid
方法將驗證該電影是否為經典電影。 將 ClassicMovieAttribute
像所有內建特性一樣應用於屬性(如 ReleaseDate
)以確保執行驗證,如前面的程式碼示例所示。 由於此示例僅適用於 Movie
型別,因此建議使用 IValidatableObject
,如下一段中所示。
也可以通過實現 IValidatableObject
介面上的 Validate
方法,將這段程式碼直接放入模型中。 如果自定義驗證特性可用於驗證各個屬性,則可使用 IValidatableObject
來實現類級別的驗證,如下所示。
C#
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (Genre == Genre.Classic && ReleaseDate.Year > _classicYear)
{
yield return new ValidationResult(
$"Classic movies must have a release year earlier than {_classicYear}.",
new[] { "ReleaseDate" });
}
}
客戶端驗證
客戶端驗證極大地方便了使用者。 它節省了時間,讓使用者不必浪費時間等待伺服器往返。 從商業角度而言,即使每次只有幾分之一秒,但如果每天有幾百次,也會耗費大量的時間和成本,帶來很多不必要的煩惱。 簡單直接的驗證能夠提高使用者的工作效率和投入產出比。
你必須有一個包含適當的 JavaScript 指令碼引用的檢視,才能讓客戶端驗證正常工作,如下所示。
CSHTML
<script src="https://ajax.aspnetcdn.com/ajax/jQuery/jquery-2.2.0.min.js"></script>
CSHTML
<script src="https://ajax.aspnetcdn.com/ajax/jquery.validate/1.16.0/jquery.validate.min.js"></script>
<script src="https://ajax.aspnetcdn.com/ajax/jquery.validation.unobtrusive/3.2.6/jquery.validate.unobtrusive.min.js"></script>
jQuery 非介入式驗證指令碼是一個基於熱門 jQuery Validate 外掛的自定義 Microsoft 前端庫。 如果沒有 jQuery 非介入式驗證,則必須在兩個位置編碼相同的驗證邏輯:一次是在模型屬性上的伺服器端驗證特性中,一次是在客戶端指令碼中(jQuery Validate 的 validate()
方法示例展示了這種情況可能的複雜程度)。 MVC 的標記幫助程式和 HTML 幫助程式則能夠使用模型屬性中的驗證特性和型別元資料,呈現需要驗證的表單元素中的 HTML 5 data- 特性。 MVC 為內建屬性和自定義屬性生成 data-
屬性。 然後,jQuery 非介入式驗證分析 data-
屬性並將邏輯傳遞給 jQuery Validate,從而將伺服器端驗證邏輯有效地“複製”到客戶端。 可以使用相關標記幫助程式在客戶端上顯示驗證錯誤,如下所示:
CSHTML
<div class="form-group">
<label asp-for="ReleaseDate" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="ReleaseDate" class="form-control" />
<span asp-validation-for="ReleaseDate" class="text-danger"></span>
</div>
</div>
上面的標記幫助程式將呈現以下 HTML。 請注意,HTML 輸出中的 data-
特性與 ReleaseDate
屬性的驗證特性相對應。 下面的 data-val-required
屬性包含在使用者未填寫發行日期欄位時將顯示的錯誤訊息。 jQuery 非介入式驗證將此值傳遞給 jQuery Validate required()
方法,該方法隨後在隨附的 <span> 元素中顯示該訊息。
HTML
<form action="/Movies/Create" method="post">
<div class="form-horizontal">
<h4>Movie</h4>
<div class="text-danger"></div>
<div class="form-group">
<label class="col-md-2 control-label" for="ReleaseDate">ReleaseDate</label>
<div class="col-md-10">
<input class="form-control" type="datetime"
data-val="true" data-val-required="The ReleaseDate field is required."
id="ReleaseDate" name="ReleaseDate" value="" />
<span class="text-danger field-validation-valid"
data-valmsg-for="ReleaseDate" data-valmsg-replace="true"></span>
</div>
</div>
</div>
</form>
客戶端驗證將阻止提交,直到表單變為有效為止。 “提交”按鈕執行 JavaScript:要麼提交表單要麼顯示錯誤訊息。
MVC 基於屬性的 .NET 資料型別確定型別特性值(有可能使用 [DataType]
特性進行重寫)。 [DataType]
基本特性不執行真正的伺服器端驗證。 瀏覽器選擇自己的錯誤訊息,並根據需要顯示這些錯誤,但 jQuery 非介入式驗證包可以重寫訊息,並使它們與其他訊息的顯示保持一致。 當用戶應用 [DataType]
子類(比如 [EmailAddress]
)時,最常發生這種情況。
向動態表單新增驗證
由於 jQuery 非介入式驗證會在第一次載入頁面時將驗證邏輯和引數傳遞到 jQuery Validate,因此,動態生成的表單不會自動展示驗證。 你必須指示 jQuery 非介入式驗證在建立動態表單後立即對其進行分析。 例如,下面的程式碼展示如何對通過 AJAX 新增的表單設定客戶端驗證。
JavaScript
$.get({
url: "https://url/that/returns/a/form",
dataType: "html",
error: function(jqXHR, textStatus, errorThrown) {
alert(textStatus + ": Couldn't add form. " + errorThrown);
},
success: function(newFormHTML) {
var container = document.getElementById("form-container");
container.insertAdjacentHTML("beforeend", newFormHTML);
var forms = container.getElementsByTagName("form");
var newForm = forms[forms.length - 1];
$.validator.unobtrusive.parse(newForm);
}
})
$.validator.unobtrusive.parse()
方法採用 jQuery 選擇器作為它的一個引數。 此方法指示 jQuery 非介入式驗證分析該選擇器內表單的 data-
屬性。 這些屬性的值隨後傳遞到 jQuery Validate 外掛中,以便表單展示所需的客戶端驗證規則。
向動態控制元件新增驗證
也可以在動態生成各個控制元件(比如 <input/>
和 <select/>
)時,更新表單上的驗證規則。 不能將用於這些元素的選擇器直接傳遞到 parse()
方法,因為周圍表單已進行分析並且不會更新。 應當先刪除現有的驗證資料,然後重新分析整個表單,如下所示:
JavaScript
$.get({
url: "https://url/that/returns/a/control",
dataType: "html",
error: function(jqXHR, textStatus, errorThrown) {
alert(textStatus + ": Couldn't add control. " + errorThrown);
},
success: function(newInputHTML) {
var form = document.getElementById("my-form");
form.insertAdjacentHTML("beforeend", newInputHTML);
$(form).removeData("validator") // Added by jQuery Validate
.removeData("unobtrusiveValidation"); // Added by jQuery Unobtrusive Validation
$.validator.unobtrusive.parse(form);
}
})
IClientModelValidator
可為自定義屬性建立客戶端邏輯,建立 jQuery 驗證的介面卡的非介入式驗證將在驗證過程中,在客戶端上自動為你執行此邏輯。 第一步是通過實現 IClientModelValidator
介面來控制要新增哪些 data- 屬性,如下所示:
C#
public void AddValidation(ClientModelValidationContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
MergeAttribute(context.Attributes, "data-val", "true");
MergeAttribute(context.Attributes, "data-val-classicmovie", GetErrorMessage());
var year = _year.ToString(CultureInfo.InvariantCulture);
MergeAttribute(context.Attributes, "data-val-classicmovie-year", year);
}
實現此介面的屬性可以將 HTML 屬性新增到生成的欄位。 檢查 ReleaseDate
元素的輸出時,將顯示與上一示例類似的 HTML,唯一不同的是,此示例包含一個已在 IClientModelValidator
的 AddValidation
方法中定義的 data-val-classicmovie
屬性。
HTML
<input class="form-control" type="datetime"
data-val="true"
data-val-classicmovie="Classic movies must have a release year earlier than 1960."
data-val-classicmovie-year="1960"
data-val-required="The ReleaseDate field is required."
id="ReleaseDate" name="ReleaseDate" value="" />
非介入式驗證使用 data-
屬性中的資料來顯示錯誤訊息。 不過,除非將規則或訊息新增到 jQuery 的 validator
物件,否則 jQuery 並不知道它們的存在。 如以下示例所示,將一個自定義 classicmovie
客戶端驗證方法新增到 validator
物件。 有關 unobtrusive.adapters.add
方法的說明,請參閱 ASP.NET MVC 中的非介入式客戶端驗證。
JavaScript
$.validator.addMethod('classicmovie',
function (value, element, params) {
// Get element value. Classic genre has value '0'.
var genre = $(params[0]).val(),
year = params[1],
date = new Date(value);
if (genre && genre.length > 0 && genre[0] === '0') {
// Since this is a classic movie, invalid if release date is after given year.
return date.getFullYear() <= year;
}
return true;
});
$.validator.unobtrusive.adapters.add('classicmovie',
['year'],
function (options) {
var element = $(options.form).find('select#Genre')[0];
options.rules['classicmovie'] = [element, parseInt(options.params['year'])];
options.messages['classicmovie'] = options.message;
});
classicmovie
方法使用前面的程式碼對電影發行日期執行客戶端驗證。 如果該方法返回 false
,則顯示錯誤訊息。
遠端驗證
遠端驗證是一項非常不錯的功能,可在需要根據伺服器上的資料驗證客戶端上的資料時使用。 例如,應用可能需要驗證某個電子郵件或使用者名稱是否已被使用,並且它必須為此查詢大量資料。 為驗證一個或幾個欄位而下載大量資料會佔用過多資源。 它還有可能暴露敏感資訊。 一種替代方法是發出往返請求來驗證欄位。
可以分兩步實現遠端驗證。 首先,必須使用 [Remote]
屬性為模型新增批註。 [Remote]
屬性採用多個過載,可用於將客戶端 JavaScript 定向到要呼叫的相應程式碼。 下面的示例指向 Users
控制器的 VerifyEmail
操作方法。
C#
[Remote(action: "VerifyEmail", controller: "Users")]
public string Email { get; set; }
第二步是按照 [Remote]
屬性中的定義,將驗證程式碼放入相應的操作方法。 根據 jQuery Validate remote 方法文件,伺服器響應必須是符合以下條件的 JSON 字串:
- 對於有效元素,為
"true"
。 - 對於無效元素,為
"false"
、undefined
或null
,使用預設錯誤訊息。
如果伺服器響應是一個字串(例如,"That name is already taken, try peter123 instead"
),則該字串顯示為一條自定義錯誤訊息來替代預設字串。
VerifyEmail
方法的定義遵循這些規則,如下所示。 如果電子郵件已被佔用,它會返回驗證錯誤訊息;如果電子郵件可用,則返回 true
,並將結果包裝在 JsonResult
物件中。 然後,客戶端可以使用返回的值,繼續進行下一步操作或根據需要顯示錯誤。
C#
[AcceptVerbs("Get", "Post")]
public IActionResult VerifyEmail(string email)
{
if (!_userRepository.VerifyEmail(email))
{
return Json($"Email {email} is already in use.");
}
return Json(true);
}
現在,當用戶輸入電子郵件時,檢視中的 JavaScript 會發出遠端呼叫,以瞭解該電子郵件是否已被佔用,如果是,則顯示錯誤訊息。 如果不是,使用者就可以像往常一樣提交表單。
[Remote]
特性的 AdditionalFields
屬性可用於根據伺服器上的資料驗證欄位組合。 例如,如果上面的 User
模型具有兩個附加屬性,名為 FirstName
和 LastName
,你可能想要驗證該名稱對尚未被現有使用者佔用。 按以下程式碼所示定義新屬性:
C#
[Remote(action: "VerifyName", controller: "Users", AdditionalFields = nameof(LastName))]
public string FirstName { get; set; }
[Remote(action: "VerifyName", controller: "Users", AdditionalFields = nameof(FirstName))]
public string LastName { get; set; }
AdditionalFields
可能已顯式設定為字串 "FirstName"
和 "LastName"
,但使用 nameof
這樣的操作符可簡化稍後的重構過程。 然後,用於執行驗證的操作方法必須採用兩個引數,一個用於 FirstName
的值,一個用於 LastName
的值。
C#
[AcceptVerbs("Get", "Post")]
public IActionResult VerifyName(string firstName, string lastName)
{
if (!_userRepository.VerifyName(firstName, lastName))
{
return Json(data: $"A user named {firstName} {lastName} already exists.");
}
return Json(data: true);
}
現在,當用戶輸入名和姓時,JavaScript 會:
- 發出遠端呼叫,以瞭解該名稱對是否已被佔用。
- 如果被佔用,則顯示一條錯誤訊息。
- 如果未被佔用,則使用者可以提交表單。
如果需要使用 [Remote]
特性驗證兩個或更多附加欄位,可將其以逗號分隔的列表形式列出。 例如,若要向模型中新增 MiddleName
屬性,可按以下程式碼所示設定 [Remote]
特性:
C#
[Remote(action: "VerifyName", controller: "Users", AdditionalFields = nameof(FirstName) + "," + nameof(LastName))]
public string MiddleName { get; set; }
AdditionalFields
與所有屬性引數一樣,必須是常量表達式。 因此,不能使用內插字串或呼叫 string.Join()
來初始化 AdditionalFields
。 對於新增到 [Remote]
特性的每個附加欄位,都必須向相應的控制器操作方法另外新增一個引數。