.Net自帶的模型驗證機(jī)制
利用System.ComponentModel.DataAnnoutations
下的一系列ValidationAttribute
派生類,我們可以輕松地對某個屬性進(jìn)行標(biāo)記,從而驗證其數(shù)據(jù)的有效性。
這個功能從很早的.Net框架就已經(jīng)有了,這并沒什么問題。
我們可以如下面的方式進(jìn)行使用:
- 首先定義一個ViewModel
public class ProjectViewModel
{
public int? Id { get; set; }
[Display(Name = "項目名稱")]
[Required(ErrorMessage = "{0}不可以為空"), MaxLength(50, ErrorMessage ="{0}長度不能超過{1}")]
public string ProjectName { get; set; }
[DataType(DataType.Date)]
[Display(Name = "開始時間")]
[Required(ErrorMessage = "{0}不可以為空")]
public DateTime? BeginDate { get; set; }
[DataType(DataType.Date)]
[Display(Name = "截止時間")]
[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; }
}
- 新建一個叫做Edit的Razor View,利用AspNetCore的TagHelper功能,我們可以很簡單地編寫前端代碼。
以下是這個頁面的全部代碼
@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)置的驗證機(jī)制,點(diǎn)擊保存的時候就對每一個字段自動進(jìn)行了驗證。
客戶端驗證的工作原理是項目自動引入的jquery.validate.js
及jquery.validate.unobtrusive.js
。
如果客戶端禁用了js,在服務(wù)端也可以得到一致的校驗結(jié)果。
繁重的ViewModel?
問題來了。
不覺得我們定義的ViewModel有點(diǎn)過于繁重了嗎?
我需要在每一個驗證屬性上寫ErrorMessage,即使很多錯誤提示是重復(fù)的!
我希望盡量減少重復(fù)編碼的工作,給這些校驗設(shè)置一個默認(rèn)值。
我們先刪除掉所有的ErrorMessage
屬性,運(yùn)行后發(fā)現(xiàn),其確實有一個默認(rèn)值,只是這個默認(rèn)值是英文的。
很好,接下來的問題就是如何去設(shè)置這樣的一個默認(rèn)值。
解決方案一:重載ValidationAttributeAdapterProvider
翻看Microsoft.AspNetCore.Mvc的源碼,我們可以發(fā)現(xiàn)模型驗證的錯誤文本是通過適配器產(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的源碼,可以看到錯誤文本的產(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);
}
}
以上定義了一個自己的Provider類,繼承于原來默認(rèn)的Provider。
在我們自己的Provider中,只干一件事,那就是在未指定ErrorMessage
屬性的時候,給它一個默認(rèn)值。
最后,調(diào)用原來Provider的方法繼續(xù)執(zhí)行。
當(dāng)然,為了使這個類能夠運(yùn)行起來,需要在Startup.cs
中進(jìn)行注冊。
services.AddSingleton<IValidationAttributeAdapterProvider, MyValidationAttributeAdapterProvider>();
接下來我們看一下效果,去除掉所有Required的ErrorMessage屬性,是不是看起來干凈很多?
public class ProjectViewModel
{
public int? Id { get; set; }
[Display(Name = "項目名稱")]
[Required, MaxLength(50, ErrorMessage ="{0}長度不能超過{1}")]
public string ProjectName { get; set; }
[DataType(DataType.Date)]
[Display(Name = "開始時間")]
[Required]
public DateTime? BeginDate { get; set; }
[DataType(DataType.Date)]
[Display(Name = "截止時間")]
[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ī)制。
但實際上開發(fā)團(tuán)隊并沒有提供這樣一個自定義的入口來為我們這些應(yīng)用開發(fā)者進(jìn)行處理。
因此通過這種小技巧雖然達(dá)到了我們想要的效果,但可能會隨著SDK的一個版本更新,導(dǎo)致原來的方法就無法正常運(yùn)行。
于是,我接下來查詢了許多資料,尋找到了另一個解決方案。
一位微軟MVP的博文給了我啟發(fā):Customization And Localization Of ASP.NET Core MVC Default Validation Error Messages
- 首先,我們新建一個繼承自
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) //是一個非空的值類型
{
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);
}
}
}
}
在這個類里,我們?yōu)橐恍┓强盏闹殿愋蛣?chuàng)建一個RequiredAttribute
,因為這相當(dāng)于是一種隱式的必填類型。
第二步,我們遍歷所有的驗證屬性集合,如果其未指定ErrorMessage
,則為其指定一個默認(rèn)值。
注意,這里我使用ResourceManager
進(jìn)行文本獲取,關(guān)于這一塊在后續(xù)的博文中介紹。
目前只要知道我們通過這種方式給一個驗證屬性指定了ErrorMessage
默認(rèn)值。
- 在
Startup.cs
中進(jìn)行配置
services.AddControllersWithViews(op =>
{
op.ModelMetadataDetailsProviders.Add(new ValidationMetadataLocalizationProvider(resourceManager));
});
可以看出對于這種方式,開發(fā)團(tuán)隊是有提供入口進(jìn)行配置的。
之后的運(yùn)行效果一致。
總結(jié)
本文提供了兩種提供驗證屬性默認(rèn)值的方式,兩者之間并沒有特別明顯的優(yōu)劣勢差異,不過我個人傾向于使用后一種。
以上的例子是針對RequiredAttribute
,而實際上我們可以針對所有的ValidationAttribute
都為其設(shè)定ErrorMessage
默認(rèn)值,原理是一樣的。在具體實現(xiàn)方法上采用資源文件的方式顯得更好一些,這方面將在下一個博文中介紹。
注:文中的代碼環(huán)境為VS2019 Preview、.NetCore3.0 Preview 7