最佳實踐系列:ASP.NET Core 3.0中的驗證機(jī)制——給錯誤信息加上默認(rèn)值(1)

.Net自帶的模型驗證機(jī)制

利用System.ComponentModel.DataAnnoutations下的一系列ValidationAttribute派生類,我們可以輕松地對某個屬性進(jìn)行標(biāo)記,從而驗證其數(shù)據(jù)的有效性。
這個功能從很早的.Net框架就已經(jīng)有了,這并沒什么問題。
我們可以如下面的方式進(jìn)行使用:

  1. 首先定義一個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; }
    }
  1. 新建一個叫做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>
  1. 運(yùn)行效果如下
    頁面.png

    可以看到我們沒有編寫任何的前端代碼,通過內(nèi)置的驗證機(jī)制,點(diǎn)擊保存的時候就對每一個字段自動進(jìn)行了驗證。
    客戶端驗證的工作原理是項目自動引入的jquery.validate.jsjquery.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)值是英文的。

頁面.png

很好,接下來的問題就是如何去設(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ù)期的一樣


頁面.png

解決方案二:提供自定義的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

  1. 首先,我們新建一個繼承自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)值。

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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容