.Net自帶的模型驗(yàn)證機(jī)制
利用System.ComponentModel.DataAnnoutations
下的一系列ValidationAttribute
派生類,我們可以輕松地對(duì)某個(gè)屬性進(jìn)行標(biāo)記,從而驗(yàn)證其數(shù)據(jù)的有效性。
這個(gè)功能從很早的.Net框架就已經(jīng)有了,這并沒什么問題。
我們可以如下面的方式進(jìn)行使用:
- 首先定義一個(gè)ViewModel
public class ProjectViewModel
{
public int? Id { get; set; }
[Display(Name = "項(xiàng)目名稱")]
[Required(ErrorMessage = "{0}不可以為空"), MaxLength(50, ErrorMessage ="{0}長(zhǎng)度不能超過{1}")]
public string ProjectName { get; set; }
[DataType(DataType.Date)]
[Display(Name = "開始時(shí)間")]
[Required(ErrorMessage = "{0}不可以為空")]
public DateTime? BeginDate { get; set; }
[DataType(DataType.Date)]
[Display(Name = "截止時(shí)間")]
[Required(ErrorMessage = "{0}不可以為空")]
public DateTime? EndDate { get; set; }
[Display(Name = "所在地")]
[Required(ErrorMessage = "{0}不可以為空")]
public int? LocationId { get; set; }
[Required(ErrorMessage = "{0}不可以為空")]
[DataType(DataType.Currency)]
[Display(Name = "合同金額(萬元)")]
[RegularExpression(@"\d{1,5}(.\d{1,4})?", ErrorMessage = "{0}不得超過10億,最多輸入4位小數(shù)")]
public decimal? ContractPrice { get; set; }
[Display(Name = "工期(日歷天)")]
[Required(ErrorMessage = "{0}不可以為空")]
[Range(0, 1000, ErrorMessage = "{0}不得超過1000天,最少1天")]
public int? WorkDays { get; set; }
[Display(Name = "聯(lián)系人")]
public string ContactPhone { get; set; }
public string Remarks { get; set; }
}
- 新建一個(gè)叫做Edit的Razor View,利用AspNetCore的TagHelper功能,我們可以很簡(jiǎn)單地編寫前端代碼。
以下是這個(gè)頁面的全部代碼
@model ProjectViewModel
@{
ViewData["Title"] = "Edit";
}
<h1>Edit</h1>
<form asp-action="Edit">
<input type="hidden" asp-for="Id" />
<div class="form-group">
<label asp-for="ProjectName"></label>
<input asp-for="ProjectName" class="form-control" />
<span asp-validation-for="ProjectName" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="BeginDate"></label>
<input asp-for="BeginDate" class="form-control" />
<span asp-validation-for="BeginDate" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="EndDate"></label>
<input asp-for="EndDate" class="form-control" />
<span asp-validation-for="EndDate" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="LocationId"></label>
<select asp-for="LocationId" asp-items="@ViewBag.Regions" class="form-control"></select>
<span asp-validation-for="LocationId" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="ContractPrice"></label>
<input asp-for="ContractPrice" class="form-control" />
<span asp-validation-for="ContractPrice" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="WorkDays"></label>
<input asp-for="WorkDays" class="form-control" />
<span asp-validation-for="WorkDays" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="ContactPhone"></label>
<input asp-for="ContactPhone" class="form-control" />
<span asp-validation-for="ContactPhone" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Remarks"></label>
<textarea asp-for="Remarks" class="form-control"></textarea>
<span asp-validation-for="Remarks" class="text-danger"></span>
</div>
<a class="btn btn-secondary" asp-action="List">取消</a>
<button type="submit" class="btn btn-primary">保存</button>
</form>
- 運(yùn)行效果如下
頁面.png
可以看到我們沒有編寫任何的前端代碼,通過內(nèi)置的驗(yàn)證機(jī)制,點(diǎn)擊保存的時(shí)候就對(duì)每一個(gè)字段自動(dòng)進(jìn)行了驗(yàn)證。
客戶端驗(yàn)證的工作原理是項(xiàng)目自動(dòng)引入的jquery.validate.js
及jquery.validate.unobtrusive.js
。
如果客戶端禁用了js,在服務(wù)端也可以得到一致的校驗(yàn)結(jié)果。
繁重的ViewModel?
問題來了。
不覺得我們定義的ViewModel有點(diǎn)過于繁重了嗎?
我需要在每一個(gè)驗(yàn)證屬性上寫ErrorMessage,即使很多錯(cuò)誤提示是重復(fù)的!
我希望盡量減少重復(fù)編碼的工作,給這些校驗(yàn)設(shè)置一個(gè)默認(rèn)值。
我們先刪除掉所有的ErrorMessage
屬性,運(yùn)行后發(fā)現(xiàn),其確實(shí)有一個(gè)默認(rèn)值,只是這個(gè)默認(rèn)值是英文的。
很好,接下來的問題就是如何去設(shè)置這樣的一個(gè)默認(rèn)值。
解決方案一:重載ValidationAttributeAdapterProvider
翻看Microsoft.AspNetCore.Mvc的源碼,我們可以發(fā)現(xiàn)模型驗(yàn)證的錯(cuò)誤文本是通過適配器產(chǎn)生的,而適配器則通過Provider進(jìn)行提供。
public class ValidationAttributeAdapterProvider : IValidationAttributeAdapterProvider
{
public IAttributeAdapter GetAttributeAdapter(ValidationAttribute attribute, IStringLocalizer stringLocalizer)
{
if (attribute == null)
{
throw new ArgumentNullException(nameof(attribute));
}
IAttributeAdapter adapter;
var type = attribute.GetType();
.....
else if (type == typeof(RequiredAttribute))
{
adapter = new RequiredAttributeAdapter((RequiredAttribute)attribute, stringLocalizer);
}
.....
return adapter;
}
};
而再翻看ValidationAttributeAdapter的源碼,可以看到錯(cuò)誤文本的產(chǎn)生方法
public abstract class ValidationAttributeAdapter<TAttribute> : IClientModelValidator
where TAttribute : ValidationAttribute
{
protected virtual string GetErrorMessage(ModelMetadata modelMetadata, params object[] arguments)
{
if (modelMetadata == null)
{
throw new ArgumentNullException(nameof(modelMetadata));
}
if (_stringLocalizer != null &&
!string.IsNullOrEmpty(Attribute.ErrorMessage) &&
string.IsNullOrEmpty(Attribute.ErrorMessageResourceName) &&
Attribute.ErrorMessageResourceType == null)
{
return _stringLocalizer[Attribute.ErrorMessage, arguments];
}
return Attribute.FormatErrorMessage(modelMetadata.GetDisplayName());
}
}
接下來,我們可以進(jìn)行魔改操作
public class MyValidationAttributeAdapterProvider : ValidationAttributeAdapterProvider, IValidationAttributeAdapterProvider
{
public IAttributeAdapter GetAttributeAdapter(ValidationAttribute attribute, IStringLocalizer stringLocalizer)
{
if (attribute == null)
{
throw new ArgumentNullException(nameof(attribute));
}
var requiredAttribute = attribute as RequiredAttribute;
if (requiredAttribute != null)
{
if (requiredAttribute.ErrorMessage == null && requiredAttribute.ErrorMessageResourceName == null)
{
requiredAttribute.ErrorMessage = "{0}不可為空";
}
}
return base.GetAttributeAdapter(attribute, stringLocalizer);
}
}
以上定義了一個(gè)自己的Provider類,繼承于原來默認(rèn)的Provider。
在我們自己的Provider中,只干一件事,那就是在未指定ErrorMessage
屬性的時(shí)候,給它一個(gè)默認(rèn)值。
最后,調(diào)用原來Provider的方法繼續(xù)執(zhí)行。
當(dāng)然,為了使這個(gè)類能夠運(yùn)行起來,需要在Startup.cs
中進(jìn)行注冊(cè)。
services.AddSingleton<IValidationAttributeAdapterProvider, MyValidationAttributeAdapterProvider>();
接下來我們看一下效果,去除掉所有Required的ErrorMessage屬性,是不是看起來干凈很多?
public class ProjectViewModel
{
public int? Id { get; set; }
[Display(Name = "項(xiàng)目名稱")]
[Required, MaxLength(50, ErrorMessage ="{0}長(zhǎng)度不能超過{1}")]
public string ProjectName { get; set; }
[DataType(DataType.Date)]
[Display(Name = "開始時(shí)間")]
[Required]
public DateTime? BeginDate { get; set; }
[DataType(DataType.Date)]
[Display(Name = "截止時(shí)間")]
[Required]
public DateTime? EndDate { get; set; }
[Display(Name = "所在地")]
[Required]
public int? LocationId { get; set; }
[Required]
[DataType(DataType.Currency)]
[Display(Name = "合同金額(萬元)")]
[RegularExpression(@"\d{1,5}(.\d{1,4})?", ErrorMessage = "{0}不得超過10億,最多輸入4位小數(shù)")]
public decimal? ContractPrice { get; set; }
[Display(Name = "工期(日歷天)")]
[Required]
[Range(0, 1000, ErrorMessage = "{0}不得超過1000天,最少1天")]
public int? WorkDays { get; set; }
[Display(Name = "聯(lián)系人")]
public string ContactPhone { get; set; }
public string Remarks { get; set; }
}
運(yùn)行效果也如預(yù)期的一樣
解決方案二:提供自定義的ModelMetadataDetailsProvider
很幸運(yùn)在AspNetCore內(nèi)部IValidationAttributeAdapterProvider
是通過依賴注入機(jī)制進(jìn)行獲取的,以至于我們可以通過這種方式介入框架內(nèi)部原來的運(yùn)行機(jī)制。
但實(shí)際上開發(fā)團(tuán)隊(duì)并沒有提供這樣一個(gè)自定義的入口來為我們這些應(yīng)用開發(fā)者進(jìn)行處理。
因此通過這種小技巧雖然達(dá)到了我們想要的效果,但可能會(huì)隨著SDK的一個(gè)版本更新,導(dǎo)致原來的方法就無法正常運(yùn)行。
于是,我接下來查詢了許多資料,尋找到了另一個(gè)解決方案。
一位微軟MVP的博文給了我啟發(fā):Customization And Localization Of ASP.NET Core MVC Default Validation Error Messages
- 首先,我們新建一個(gè)繼承自
IValidationMetadataProvider
的類
public sealed class ValidationMetadataLocalizationProvider : IValidationMetadataProvider
{
private ResourceManager _resourceManager;
public ValidationMetadataLocalizationProvider(ResourceManager resourceManager)
{
_resourceManager = resourceManager;
}
public void CreateValidationMetadata(ValidationMetadataProviderContext context)
{
var typeInfo = context.Key.ModelType.GetTypeInfo();
if (typeInfo.IsValueType && Nullable.GetUnderlyingType(typeInfo) == null) //是一個(gè)非空的值類型
{
if (!context.ValidationMetadata.ValidatorMetadata.Any(m => m.GetType() == typeof(RequiredAttribute)))
{
context.ValidationMetadata.ValidatorMetadata.Add(new RequiredAttribute());
}
}
foreach (var attribute in context.ValidationMetadata.ValidatorMetadata)
{
ValidationAttribute tAttr = attribute as ValidationAttribute;
if (tAttr != null && tAttr.ErrorMessage == null
&& tAttr.ErrorMessageResourceName == null)
{
var name = tAttr.GetType().Name;
tAttr.ErrorMessage = _resourceManager.GetString(name);
}
}
}
}
在這個(gè)類里,我們?yōu)橐恍┓强盏闹殿愋蛣?chuàng)建一個(gè)RequiredAttribute
,因?yàn)檫@相當(dāng)于是一種隱式的必填類型。
第二步,我們遍歷所有的驗(yàn)證屬性集合,如果其未指定ErrorMessage
,則為其指定一個(gè)默認(rèn)值。
注意,這里我使用ResourceManager
進(jìn)行文本獲取,關(guān)于這一塊在后續(xù)的博文中介紹。
目前只要知道我們通過這種方式給一個(gè)驗(yàn)證屬性指定了ErrorMessage
默認(rèn)值。
- 在
Startup.cs
中進(jìn)行配置
services.AddControllersWithViews(op =>
{
op.ModelMetadataDetailsProviders.Add(new ValidationMetadataLocalizationProvider(resourceManager));
});
可以看出對(duì)于這種方式,開發(fā)團(tuán)隊(duì)是有提供入口進(jìn)行配置的。
之后的運(yùn)行效果一致。
總結(jié)
本文提供了兩種提供驗(yàn)證屬性默認(rèn)值的方式,兩者之間并沒有特別明顯的優(yōu)劣勢(shì)差異,不過我個(gè)人傾向于使用后一種。
以上的例子是針對(duì)RequiredAttribute
,而實(shí)際上我們可以針對(duì)所有的ValidationAttribute
都為其設(shè)定ErrorMessage
默認(rèn)值,原理是一樣的。在具體實(shí)現(xiàn)方法上采用資源文件的方式顯得更好一些,這方面將在下一個(gè)博文中介紹。
注:文中的代碼環(huán)境為VS2019 Preview、.NetCore3.0 Preview 7