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

利用资源文件管理验证错误信息

上一篇文章的最后我们提到了利用ResourceManager对验证的默认错误信息进行管理,今天就来详细阐述一下具体的实现方式。

资源(Resource)并非什么新奇的东西,这在早期的.Net版本中已经得到了普遍使用。

  1. 首先,我们在项目中新建一个资源文件

    新建资源.png

    我们将这个资源文件命名为DataAnnotations.resx

  2. 双击打开文件后,在其中增加一些错误信息模板


    错误信息.png

    可以看到,以验证类的全名作为资源的名称,值中填写错误信息模板。

  3. 回顾一下上一篇文章的某段核心代码,没错,是ValidationMetadataLocalizationProvider中的CreateValidationMetadata方法。

public void CreateValidationMetadata(ValidationMetadataProviderContext context)
{
    ......
    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);
        }
    }
}

在这个方法里的foreach循环中,我们获取每一个验证的特性类,根据其类型作为名称,通过ResourceManager进行检索,从而得到对应的错误消息模板。

  1. 当然,ResourceManager我们需要在Starup.cs中配置的时候就传递进来。
var resourceManager = new ResourceManager("MvcDemo.Web.Resources.DataAnnotations", Assembly.GetExecutingAssembly());
services.AddControllersWithViews(op => op.ModelMetadataDetailsProviders.Add(new ValidationMetadataLocalizationProvider(resourceManager)));
  1. 完成
    由此,我们为所有的验证特性类都配置了默认的错误信息,而无需在每一个类中重复性的编写,这节省了我们在项目开发过程中的很大工作量。
    同时,我们并没有失去为某个校验信息进行指定特有的错误信息的能力,依然可以通过赋值ErrorMessage达到目的。
    现在,我们在上一章中的ViewModel变成了如下模样:
    public class ProjectViewModel
    {
        public int? Id { get; set; }
        [Display(Name = "项目名称")]
        [Required, MaxLength(50)]
        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)]
        public int? WorkDays { get; set; }
        [Display(Name = "联系人")]
        public string ContactPhone { get; set; }
        public string Remarks { get; set; }
    }

是否已看起来不是那么的凌乱?
但其中还是有一些碍眼的地方,比如在每个属性都出现的DisplayAttribute
对同一个实体来说,可能会存在多个ViewModel,我并不想到处出现Display(Name = "项目名称")这样的标记。
另外,一个属性上过多的Attribute实在影响代码阅读的流畅性。
带着这个问题,我们开始文章的第二部分。

利用资源文件管理

通过查阅Microsoft.AspNetCore.Mvc的源码可知,在模型验证不通过,生成错误信息的时候,ValidationAttributeAdapter是通过ModelMetadata.GetDisplayName()方法得到DisplayAttribute设置的Name值。
而查找DefaultModelMetadata的源码,可以看到它又是从DisplayMetadata获取到的。

public override string DisplayName
{
    get
    {
        if (DisplayMetadata.DisplayName == null)
        {
            return null;
        }

        return DisplayMetadata.DisplayName();
    }
}

DisplayMetadata是通过IDisplayMetadataProvider.CreateDisplayMetadata产生的。

public DisplayMetadata DisplayMetadata
{
    get
    {
        if (_details.DisplayMetadata == null)
        {
            var context = new DisplayMetadataProviderContext(Identity, _details.ModelAttributes);
            _detailsProvider.CreateDisplayMetadata(context);
            _details.DisplayMetadata = context.DisplayMetadata;
        }

        return _details.DisplayMetadata;
    }
}

这就又给了我们可乘之机。

1.首先,我们也编写一个实现IDisplayMetadataProvider的对象。

public class DisplayAttributeLocalizationProvider : IDisplayMetadataProvider
    {
        private ResourceManager _resourceManager;
        public DisplayAttributeLocalizationProvider(ResourceManager resourceManager)
        {
            _resourceManager = resourceManager;
        }

        public void CreateDisplayMetadata(DisplayMetadataProviderContext context)
        {
        }
    }

在这个对象中我们同样接收一个ResourceManager对象,可以让我们查询定义的资源对象。

  1. 随后我们要开始编写具体的实现接口。
    这跟之前为验证特性没有设定ErrorMessage属性,我们给他默认赋值一个是一样的思路:
    如果某个对象的属性没有定义DisplayAttribute,那我们就为它自动赋值一个DisplayName的默认值!
    Name值从哪里来?自然是从资源文件中获取!
    具体实现接口的代码如下:
public void CreateDisplayMetadata(DisplayMetadataProviderContext context)
{
    var attributes = context.Attributes;
    var displayAttribute = attributes.OfType().FirstOrDefault();
    if (displayAttribute != null)
    {
        return;
    }
    context.DisplayMetadata.DisplayName = () => _resourceManager.GetString(context.Key.Name);
}

初步的实现非常简单,先不论好坏,我们继续往下走。

  1. 在资源文件中定义属性的DisplayName
    打开DataAnnotations.resx,我们添加一些字段进行测试。

    资源.png

    我们添加了三行,分别以属性名为资源名称,值是该属性的DisplayName

  2. 删除ViewModel中所有的DisplayAttribute

public class ProjectViewModel
    {
        public int? Id { get; set; }
        [Required, MaxLength(50)]
        public string ProjectName { get; set; }
        [DataType(DataType.Date)]
        [Required]
        public DateTime? BeginDate { get; set; }
        [DataType(DataType.Date)]
        [Required]
        public DateTime? EndDate { get; set; }
        [Required]
        public int? LocationId { get; set; }
        [Required]
        [DataType(DataType.Currency)]
        [RegularExpression(@"\d{1,5}(.\d{1,4})?", ErrorMessage = "{0}不得超过10亿,最多输入4位小数")]
        public decimal? ContractPrice { get; set; }
        [Required]
        [Range(0, 1000)]
        public int? WorkDays { get; set; }
        public string ContactPhone { get; set; }
        public string Remarks { get; set; }
    }

现在的ViewModel变得更加的清爽了!

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

在原来的验证默认值处理的Provider下面,我们又增加了一个Provider。

现在看运行效果。

页面.png

可以看到,我们添加的三行资源对象里,ProjectName和ContractPrice因为匹配上了,所以呈现出资源文件里定义的名称。
而其他的字段因为删除了DisplayAttribute,导致显示名称变回属性名。

这样一来,不管在哪个ViewModel中定义了ProjectName这个属性,我们都可以准确地得到它的显示名称,而不用重复性地定义。

  1. 改进模型。
    之后在调试过程中,我们发现了几个小问题:
    1)我们只需要对ViewModel进行配置DisplayName的操作,其他的类不需要、也最好别去动它。但目前的做法是会影响到所有的类。
    2)有时候我们对于相同的属性名,需要显示不同的名称,需要有一种自定义的能力。

对于问题1,我们自定义了一个AutoDisplayAttribute类,用于标记。

    [AttributeUsage(AttributeTargets.Class | AttributeTargets.Property | AttributeTargets.Field)]
    public class AutoDisplayAttribute : Attribute
    {
    }

没错,这个特性类的实现是空的。
我们给我们需要的ViewModel进行标记,让我们自己的IDisplayMetadataProvider能够识别出来即可。

[AutoDisplay]
public class ProjectViewModel
{
......
}

第二个问题,处理方式有很多,比如可以直接通过DisplayAttribute进行定义,这样就不会被自动程序所赋值。
当然,我们根据自身业务所需要的场景提供了一种根据“类名.属性名”进行精确匹配的实现。

最后,改进后的接口的代码如下:

public void CreateDisplayMetadata(DisplayMetadataProviderContext context)
{
    var attributes = context.Attributes;
    var displayAttribute = attributes.OfType().FirstOrDefault();
    if (displayAttribute != null)
    {
        return;
    }

    //只有标记了AutoDisplay的类或属性才能启动自动读取
    var hasAutoDisplay = context.Key.ModelType.GetCustomAttributes(typeof(AutoDisplayAttribute), true).Length > 0 ||
        context.Key.ContainerType?.GetCustomAttributes(typeof(AutoDisplayAttribute), true).Length > 0;
    if (!hasAutoDisplay)
    {
        return;
    }

    //优先读取模式:ClassA.PropertyB
    if (context.Key.ContainerType != null)
    {
        var name = context.Key.ContainerType.Name + "." + context.Key.Name;

        if (_resourceManager.GetString(name) != null)
        {
            context.DisplayMetadata.DisplayName = () => _resourceManager.GetString(name);
            return;
        }
    }

    context.DisplayMetadata.DisplayName = () => _resourceManager.GetString(context.Key.Name);

}
  1. 效果测试
    我们添加了一条资源项


    资源文件

    运行查看效果


    页面

    如预期显示。

总结

这一章我们提供了一种通过资源文件来定义验证错误信息模板、DisplayName的方法。
后续我将会继续介绍模型验证的内容,下一章也许会讲解自动化验证的处理流程,敬请期待。

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

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