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

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

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

  1. 首先定義一個(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; }
    }
  1. 新建一個(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>
  1. 運(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.jsjquery.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)值是英文的。

頁面.png

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


頁面.png

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

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

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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,578評(píng)論 6 544
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,701評(píng)論 3 429
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,691評(píng)論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)。 經(jīng)常有香客問我,道長(zhǎng),這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,974評(píng)論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 72,694評(píng)論 6 413
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 56,026評(píng)論 1 329
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,015評(píng)論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 43,193評(píng)論 0 290
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,719評(píng)論 1 336
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 41,442評(píng)論 3 360
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 43,668評(píng)論 1 374
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,151評(píng)論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,846評(píng)論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,255評(píng)論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,592評(píng)論 1 295
  • 我被黑心中介騙來泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 52,394評(píng)論 3 400
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 48,635評(píng)論 2 380

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