最佳实践系列:ASP.NET Core 3.0中的验证机制——给错误信息加上默认值(1)

.Net自带的模型验证机制

利用System.ComponentModel.DataAnnoutations下的一系列ValidationAttribute派生类,我们可以轻松地对某个属性进行标记,从而验证其数据的有效性。
这个功能从很早的.Net框架就已经有了,这并没什么问题。
我们可以如下面的方式进行使用:

  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位小数")]
        public decimal? ContractPrice { get; set; }
        [Display(Name = "工期(日历天)")]
        [Required(ErrorMessage = "{0}不可以为空")]
        [Range(0, 1000, ErrorMessage = "{0}不得超过1000天,最少1天")]
        public int? WorkDays { get; set; }
        [Display(Name = "联系人")]
        public string ContactPhone { get; set; }
        public string Remarks { get; set; }
    }
  1. 新建一个叫做Edit的Razor View,利用AspNetCore的TagHelper功能,我们可以很简单地编写前端代码。
    以下是这个页面的全部代码
@model ProjectViewModel
@{
    ViewData["Title"] = "Edit";
}

Edit

取消
  1. 运行效果如下
    页面.png

    可以看到我们没有编写任何的前端代码,通过内置的验证机制,点击保存的时候就对每一个字段自动进行了验证。
    客户端验证的工作原理是项目自动引入的jquery.validate.jsjquery.validate.unobtrusive.js
    如果客户端禁用了js,在服务端也可以得到一致的校验结果。

繁重的ViewModel?

问题来了。
不觉得我们定义的ViewModel有点过于繁重了吗?
我需要在每一个验证属性上写ErrorMessage,即使很多错误提示是重复的!

我希望尽量减少重复编码的工作,给这些校验设置一个默认值。
我们先删除掉所有的ErrorMessage属性,运行后发现,其确实有一个默认值,只是这个默认值是英文的。

页面.png

很好,接下来的问题就是如何去设置这样的一个默认值。

解决方案一:重载ValidationAttributeAdapterProvider

翻看Microsoft.AspNetCore.Mvc的源码,我们可以发现模型验证的错误文本是通过适配器产生的,而适配器则通过Provider进行提供。

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的源码,可以看到错误文本的产生方法

public abstract class ValidationAttributeAdapter : 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());
        }
    }

接下来,我们可以进行魔改操作

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类,继承于原来默认的Provider。
在我们自己的Provider中,只干一件事,那就是在未指定ErrorMessage属性的时候,给它一个默认值。
最后,调用原来Provider的方法继续执行。
当然,为了使这个类能够运行起来,需要在Startup.cs中进行注册。

services.AddSingleton();

接下来我们看一下效果,去除掉所有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位小数")]
        public decimal? ContractPrice { get; set; }
        [Display(Name = "工期(日历天)")]
        [Required]
        [Range(0, 1000, ErrorMessage = "{0}不得超过1000天,最少1天")]
        public int? WorkDays { get; set; }
        [Display(Name = "联系人")]
        public string ContactPhone { get; set; }
        public string Remarks { get; set; }
    }

运行效果也如预期的一样


页面.png

解决方案二:提供自定义的ModelMetadataDetailsProvider

很幸运在AspNetCore内部IValidationAttributeAdapterProvider是通过依赖注入机制进行获取的,以至于我们可以通过这种方式介入框架内部原来的运行机制。
但实际上开发团队并没有提供这样一个自定义的入口来为我们这些应用开发者进行处理。
因此通过这种小技巧虽然达到了我们想要的效果,但可能会随着SDK的一个版本更新,导致原来的方法就无法正常运行。
于是,我接下来查询了许多资料,寻找到了另一个解决方案。
一位微软MVP的博文给了我启发: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);
                }
            }
        }
    }

在这个类里,我们为一些非空的值类型创建一个RequiredAttribute,因为这相当于是一种隐式的必填类型。
第二步,我们遍历所有的验证属性集合,如果其未指定ErrorMessage,则为其指定一个默认值。
注意,这里我使用ResourceManager进行文本获取,关于这一块在后续的博文中介绍。
目前只要知道我们通过这种方式给一个验证属性指定了ErrorMessage默认值。

  1. Startup.cs中进行配置
services.AddControllersWithViews(op =>
            {
                op.ModelMetadataDetailsProviders.Add(new ValidationMetadataLocalizationProvider(resourceManager));
            });

可以看出对于这种方式,开发团队是有提供入口进行配置的。
之后的运行效果一致。

总结

本文提供了两种提供验证属性默认值的方式,两者之间并没有特别明显的优劣势差异,不过我个人倾向于使用后一种。
以上的例子是针对RequiredAttribute,而实际上我们可以针对所有的ValidationAttribute都为其设定ErrorMessage默认值,原理是一样的。在具体实现方法上采用资源文件的方式显得更好一些,这方面将在下一个博文中介绍。

注:文中的代码环境为VS2019 Preview、.NetCore3.0 Preview 7

你可能感兴趣的:(最佳实践系列:ASP.NET Core 3.0中的验证机制——给错误信息加上默认值(1))