DataAnnotations 实现数据模型的通用校验
参数校验的意义
在实际项目开发中,无论任何方式、任何规模的开发模式,项目中都离不开对接入数据模型参数的合法性校验,目前普片的开发模式基本是前后端分离,当用户在前端页面中输入一些表单数据时,点击提交按钮,触发请求目标服务器的一系列后续操作,在这中间的执行过程中(标准做法推荐)无论是前端代码部分,还是服务端代码部分都应该有针对用户输入数据的合法性校验,典型做法如下:
前端部分
:当用户在页面输入表单数据时,前端监听页面表单事件触发相应的数据合法性校验规则,当数据非法时,合理的提示用户数据错误,只有当所有表单数据都校验通过后,才继续提交数据给目标后端对应的接口;后端部分
:当前端数据合法校验通过后,向目标服务器提交表单数据时,服务端接收到相应的提交数据,在入口源头出就应该触发相关的合法性校验规则,当数据都校验通过后,继续执行后续的相关业务逻辑处理,反之则响应相关非法数据的提示信息;
特别说明:在实际的项目中,无论前端部分还是服务端部分,参数的校验都是很有必要性的。无效的参数,可能会导致应用程序的异常和一些不可预知的错误行为。
常用参数的校验
这里例举一些项目中比较常用的参数模型校验项,如下所示:
- Name:姓名校验,比如需要是纯汉字的姓名;
- Password:密码强度验证,比如要求用户输入必须包含大小写字母、数字和特殊符号的强密码;
- QQ号:QQ 号码验证,是否是有效合法的 QQ 号码;
- China Postal Code:中国邮政编码;
- IP Address:IPV4 或者 IPV6 地址验证;
- Phone:手机号码或者座机号码合法性验证;
- ID Card:身份证号码验证,比如:15 位和 18 位数身份证号码;
- Email Address:邮箱地址的合法性校验;
- String:字符串验证,比如字段是否不为 null、长度是否超限;
- URL:验证属性是否具有 URL 格式;
- Number:数值型参数校验,数值范围校验,比如非负数,非负整数,正整数等;
- File:文件路径及扩展名校验;
对于参数校验,常见的方式有正则匹配校验,通过对目标参数编写合法的正则表达式,实现对参数合法性的校验。
.NET 中内置 DataAnnotations 提供的特性校验
上面我们介绍了一些常用的参数验证项,接下来我们来了解下在 .NET
中内置提供的 DataAnnotations
数据注解,该类提供了一些常用的验证参数特性。
官方解释:
- 提供用于为
ASP.NET MVC
和ASP.NET
数据控件定义元数据的特性类。- 该类位于
System.ComponentModel.DataAnnotations
命名空间。
关于 DataAnnotations 中的特性介绍
让我们可以通过这些特性对 API
请求中的参数进行验证,常用的特性一般有:
- [ValidateNever]: 指示应从验证中排除属性或参数。
- [CreditCard]:验证属性是否具有信用卡格式。
- [Compare]:验证模型中的两个属性是否匹配。
- [EmailAddress]:验证属性是否具有电子邮件格式。
- [Phone]:验证属性是否具有电话号码格式。
- [Range]:验证属性值是否位于指定范围内。
- [RegularExpression]:验证属性值是否与指定的正则表达式匹配。
- [Required]:验证字段是否不为 null。
- [StringLength]:验证字符串属性值是否不超过指定的长度限制。
- [Url]:验证属性是否具有 URL 格式。
其中 RegularExpression
特性,基于正则表达式可以扩展实现很多常用的验证类型,下面的( 基于 DataAnnotations 的通用模型校验封装
)环节举例说明;
关于该类更多详细信息请查看,
https://learn.microsoft.com/zh-cn/dotnet/api/system.componentmodel.dataannotations?view=net-7.0
基于 DataAnnotations 的通用模型校验封装
此处主要是使用了 Validator.TryValidateObject()
方法:
Validator.TryValidateObject(object instance, ValidationContext validationContext, ICollection<ValidationResult>? validationResults, bool validateAllProperties);
Validator
类提供如下校验方法:
基于 DataAnnotations 的特性校验助手实现步骤
- 错误成员对象类
ErrorMember
namespace Jeff.Common.Validatetion;
/// <summary>
/// 错误成员对象
/// </summary>
public class ErrorMember
{
/// <summary>
/// 错误信息
/// </summary>
public string? ErrorMessage { get; set; }
/// <summary>
/// 错误成员名称
/// </summary>
public string? ErrorMemberName { get; set; }
}
- 验证结果类
ValidResult
namespace Jeff.Common.Validatetion;
/// <summary>
/// 验证结果类
/// </summary>
public class ValidResult
{
public ValidResult()
{
ErrorMembers = new List<ErrorMember>();
}
/// <summary>
/// 错误成员列表
/// </summary>
public List<ErrorMember> ErrorMembers { get; set; }
/// <summary>
/// 验证结果
/// </summary>
public bool IsVaild { get; set; }
}
- 定义操作正则表达式的公共类
RegexHelper
(基于RegularExpression
特性扩展)
using System;
using System.Net;
using System.Text.RegularExpressions;
namespace Jeff.Common.Validatetion;
/// <summary>
/// 操作正则表达式的公共类
/// Regex 用法参考:https://learn.microsoft.com/zh-cn/dotnet/api/system.text.regularexpressions.regex.-ctor?redirectedfrom=MSDN&view=net-7.0
/// </summary>
public class RegexHelper
{
#region 常用正则验证模式字符串
public enum ValidateType
{
Email, // 邮箱
TelePhoneNumber, // 固定电话(座机)
MobilePhoneNumber, // 移动电话
Age, // 年龄(1-120 之间有效)
Birthday, // 出生日期
Timespan, // 时间戳
IdentityCardNumber, // 身份证
IpV4, // IPv4 地址
IpV6, // IPV6 地址
Domain, // 域名
English, // 英文字母
Chinese, // 汉字
MacAddress, // MAC 地址
Url, // URL
}
private static readonly Dictionary<ValidateType, string> keyValuePairs = new Dictionary<ValidateType, string>
{
{ ValidateType.Email, _Email },
{ ValidateType.TelePhoneNumber,_TelephoneNumber },
{ ValidateType.MobilePhoneNumber,_MobilePhoneNumber },
{ ValidateType.Age,_Age },
{ ValidateType.Birthday,_Birthday },
{ ValidateType.Timespan,_Timespan },
{ ValidateType.IdentityCardNumber,_IdentityCardNumber },
{ ValidateType.IpV4,_IpV4 },
{ ValidateType.IpV6,_IpV6 },
{ ValidateType.Domain,_Domain },
{ ValidateType.English,_English },
{ ValidateType.Chinese,_Chinese },
{ ValidateType.MacAddress,_MacAddress },
{ ValidateType.Url,_Url },
};
public const string _Email = @"^(\w)+(\.\w)*@(\w)+((\.\w+)+)$"; // ^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$ , [A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,4}
public const string _TelephoneNumber = @"(d+-)?(d{4}-?d{7}|d{3}-?d{8}|^d{7,8})(-d+)?"; //座机号码(中国大陆)
public const string _MobilePhoneNumber = @"^(13[0-9]|14[01456879]|15[0-35-9]|16[2567]|17[0-8]|18[0-9]|19[0-35-9])\d{8}$"; //移动电话
public const string _Age = @"^(?:[1-9][0-9]?|1[01][0-9]|120)$"; // 年龄 1-120 之间有效
public const string _Birthday = @"^((?:19[2-9]\d{1})|(?:20(?:(?:0[0-9])|(?:1[0-8]))))((?:0?[1-9])|(?:1[0-2]))((?:0?[1-9])|(?:[1-2][0-9])|30|31)$";
public const string _Timespan = @"^15|16|17\d{8,11}$"; // 目前时间戳是15开头,以后16、17等开头,长度 10 位是秒级时间戳的正则,13 位时间戳是到毫秒级的。
public const string _IdentityCardNumber = @"^[1-9]\d{7}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}$|^[1-9]\d{5}[1-9]\d{3}((0\d)|(1[0-2]))(([0|1|2]\d)|3[0-1])\d{3}([0-9]|X)$";
public const string _IpV4 = @"^((2(5[0-5]|[0-4]\d))|[0-1]?\d{1,2})(\.((2(5[0-5]|[0-4]\d))|[0-1]?\d{1,2})){3}$";
public const string _IpV6 = @"^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$";
public const string _Domain = @"^[a-zA-Z0-9][-a-zA-Z0-9]{0,62}(\.[a-zA-Z0-9][-a-zA-Z0-9]{0,62})+\.?$";
public const string _English = @"^[A-Za-z]+$";
public const string _Chinese = @"^[\u4e00-\u9fa5]{0,}$";
public const string _MacAddress = @"^([0-9A-F]{2})(-[0-9A-F]{2}){5}$";
public const string _Url = @"^[a-zA-z]+://(\w+(-\w+)*)(\.(\w+(-\w+)*))*(\?\S*)?$";
#endregion
/// <summary>
/// 获取验证模式字符串
/// </summary>
/// <param name="validateType"></param>
/// <returns></returns>
public static (bool hasPattern, string pattern) GetValidatePattern(ValidateType validateType)
{
bool hasPattern = keyValuePairs.TryGetValue(validateType, out string? pattern);
return (hasPattern, pattern ?? string.Empty);
}
#region 验证输入字符串是否与模式字符串匹配
/// <summary>
/// 验证输入字符串是否与模式字符串匹配
/// </summary>
/// <param name="input">输入的字符串</param>
/// <param name="validateType">模式字符串类型</param>
/// <param name="matchTimeout">超时间隔</param>
/// <param name="options">筛选条件</param>
/// <returns></returns>
public static (bool isMatch, string info) IsMatch(string input, ValidateType validateType, TimeSpan matchTimeout, RegexOptions options = RegexOptions.None)
{
var (hasPattern, pattern) = GetValidatePattern(validateType);
if (hasPattern && !string.IsNullOrWhiteSpace(pattern))
{
bool isMatch = IsMatch(input, pattern, matchTimeout, options);
if (isMatch) return (true, "Format validation passed."); // 格式验证通过。
else return (false, "Format validation failed."); // 格式验证未通过。
}
return (false, "Unknown ValidatePattern."); // 未知验证模式
}
/// <summary>
/// 验证输入字符串是否与模式字符串匹配,匹配返回true
/// </summary>
/// <param name="input">输入字符串</param>
/// <param name="pattern">模式字符串</param>
/// <returns></returns>
public static bool IsMatch(string input, string pattern)
{
return IsMatch(input, pattern, TimeSpan.Zero, RegexOptions.IgnoreCase);
}
/// <summary>
/// 验证输入字符串是否与模式字符串匹配,匹配返回true
/// </summary>
/// <param name="input">输入的字符串</param>
/// <param name="pattern">模式字符串</param>
/// <param name="matchTimeout">超时间隔</param>
/// <param name="options">筛选条件</param>
/// <returns></returns>
public static bool IsMatch(string input, string pattern, TimeSpan matchTimeout, RegexOptions options = RegexOptions.None)
{
return Regex.IsMatch(input, pattern, options, matchTimeout);
}
#endregion
}
- 定义验证结果统一模型格式类
ResponseInfo
(此类通常也是通用的数据响应模型类)
namespace Jeff.Common.Model;
public sealed class ResponseInfo<T> where T : class
{
/*
Microsoft.AspNetCore.Http.StatusCodes
System.Net.HttpStatusCode
*/
/// <summary>
/// 响应代码(自定义)
/// </summary>
public int Code { get; set; }
/// <summary>
/// 接口状态
/// </summary>
public bool Success { get; set; }
#region 此处可以考虑多语言国际化设计(语言提示代号对照表)
/// <summary>
/// 语言对照码,参考:https://blog.csdn.net/shenenhua/article/details/79150053
/// </summary>
public string Lang { get; set; } = "zh-cn";
/// <summary>
/// 提示信息
/// </summary>
public string Message { get; set; } = string.Empty;
#endregion
/// <summary>
/// 数据体
/// </summary>
public T? Data { get; set; }
}
- 实现验证助手类
ValidatetionHelper
,配合System.ComponentModel.DataAnnotations
类使用
// 数据注解,https://learn.microsoft.com/zh-cn/dotnet/api/system.componentmodel.dataannotations?view=net-7.0
using System.ComponentModel.DataAnnotations;
using Jeff.Common.Model;
namespace Jeff.Common.Validatetion;
/// <summary>
/// 验证助手类
/// </summary>
public sealed class ValidatetionHelper
{
/// <summary>
/// DTO 模型校验
/// </summary>
/// <param name="value"></param>
/// <returns></returns>
public static ValidResult IsValid(object value)
{
var result = new ValidResult();
try
{
var validationContext = new ValidationContext(value);
var results = new List<ValidationResult>();
bool isValid = Validator.TryValidateObject(value, validationContext, results, true);
result.IsVaild = isValid;
if (!isValid)
{
foreach (ValidationResult? item in results)
{
result.ErrorMembers.Add(new ErrorMember()
{
ErrorMessage = item.ErrorMessage,
ErrorMemberName = item.MemberNames.FirstOrDefault()
});
}
}
}
catch (ValidationException ex)
{
result.IsVaild = false;
result.ErrorMembers = new List<ErrorMember>
{
new ErrorMember()
{
ErrorMessage = ex.Message,
ErrorMemberName = "Internal error"
}
};
}
return result;
}
/// <summary>
/// DTO 模型校验统一响应信息
/// </summary>
/// <typeparam name="T"></typeparam>
/// <param name="model"></param>
/// <returns></returns>
public static ResponseInfo<ValidResult> GetValidInfo<T>(T model) where T : class
{
var result = new ResponseInfo<ValidResult>();
var validResult = IsValid(model);
if (!validResult.IsVaild)
{
result.Code = 420;
result.Message = "DTO 模型参数值异常";
result.Success = false;
result.Data = validResult;
}
else
{
result.Code = 200;
result.Success = true;
result.Message = "DTO 模型参数值合法";
}
return result;
}
}
如何使用 DataAnnotations 封装的特性校验助手?
- 首先定义一个数据模型类(
DTO
),添加校验特性ValidationAttribute
using System.ComponentModel.DataAnnotations;
using Jeff.Common.Validatetion;
namespace Jeff.Comm.Test;
public class Person
{
[Display(Name = "姓名"), Required(ErrorMessage = "{0}必须填写")]
public string Name { get; set; }
[Display(Name = "邮箱")]
[Required(ErrorMessage = "{0}必须填写")]
[RegularExpression(RegexHelper._Email, ErrorMessage = "RegularExpression: {0}格式非法")]
[EmailAddress(ErrorMessage = "EmailAddress: {0}格式非法")]
public string Email { get; set; }
[Display(Name = "Age年龄")]
[Required(ErrorMessage = "{0}必须填写")]
[Range(1, 120, ErrorMessage = "超出范围")]
[RegularExpression(RegexHelper._Age, ErrorMessage = "{0}超出合理范围")]
public int Age { get; set; }
[Display(Name = "Birthday出生日期")]
[Required(ErrorMessage = "{0}必须填写")]
[RegularExpression(RegexHelper._Timespan, ErrorMessage = "{0}超出合理范围")]
public TimeSpan Birthday { get; set; }
[Display(Name = "Address住址")]
[Required(ErrorMessage = "{0}必须填写")]
[StringLength(200, MinimumLength = 10, ErrorMessage = "{0}输入长度不正确")]
public string Address { get; set; }
[Display(Name = "Mobile手机号码")]
[Required(ErrorMessage = "{0}必须填写")]
[RegularExpression(RegexHelper._MobilePhoneNumber, ErrorMessage = "{0}格式非法")]
public string Mobile { get; set; }
[Display(Name = "Salary薪水")]
[Required(ErrorMessage = "{0}必须填写")]
[Range(typeof(decimal), "1000.00", "3000.99")]
public decimal Salary { get; set; }
[Display(Name = "MyUrl连接")]
[Required(ErrorMessage = "{0}必须填写")]
[Url(ErrorMessage = "Url:{0}格式非法")]
[RegularExpression(RegexHelper._Url, ErrorMessage = "RegularExpression:{0}格式非法")]
public string MyUrl { get; set; }
}
- 控制台调用通用校验助手验证方法
ValidatetionHelper.IsValid()
或ValidatetionHelper.GetValidInfo()
// 通用模型数据验证测试
static void ValidatetionTest()
{
var p = new Person
{
Name = "",
Age = -10,
Email = "www.baidu.com",
MobilePhoneNumber = "12345",
Salary = 4000,
MyUrl = "aaa"
};
// 调用通用模型校验
var result = ValidatetionHelper.IsValid(p);
if (!result.IsVaild)
{
foreach (ErrorMember errorMember in result.ErrorMembers)
{
// 控制台打印字段验证信息
Console.WriteLine($"{errorMember.ErrorMemberName}:{errorMember.ErrorMessage}");
}
}
Console.WriteLine();
// 调用通用模型校验,返回统一数据格式
var validInfo = ValidatetionHelper.GetValidInfo(p);
var options = new JsonSerializerOptions
{
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, // 设置中文编码乱码
WriteIndented = false
};
string jsonStr = JsonSerializer.Serialize(validInfo, options);
Console.WriteLine($"校验结果返回统一数据格式:{jsonStr}");
}
在控制台Program.Main
方法中调用 ValidatetionTest()
方法:
internal class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
{
#region 数据注解(DataAnnotations)模型验证
ValidatetionTest();
#endregion
}
Console.ReadKey();
}
启动控制台,输出如下信息:
如何实现自定义的验证特性?
当我们碰到这些参数需要验证的时候,而上面内置类提供的特性不能满足需求时,此时我们可以实现自定义的验证特性来满足校验需求,按照微软给出的编码规则,我们只需继承 ValidationAttribute
类,并重写 IsValid()
方法即可。
自定义校验特性案例
比如实现一个密码强度的验证,实现步骤如下:
- 定义密码强度规则,只包含英文字母、数字和特殊字符的组合,并且组合长度至少 8 位数;
/// <summary>
/// 只包含英文字母、数字和特殊字符的组合
/// </summary>
/// <returns></returns>
public static bool IsCombinationOfEnglishNumberSymbol(string input, int? minLength = null, int? maxLength = null)
{
var pattern = @"(?=.*\d)(?=.*[a-zA-Z])(?=.*[^a-zA-Z\d]).";
if (minLength is null && maxLength is null)
pattern = $@"^{pattern}+$";
else if (minLength is not null && maxLength is null)
pattern = $@"^{pattern}{{{minLength},}}$";
else if (minLength is null && maxLength is not null)
pattern = $@"^{pattern}{{1,{maxLength}}}$";
else
pattern = $@"^{pattern}{{{minLength},{maxLength}}}$";
return Regex.IsMatch(input, pattern);
}
- 实现自定义特性
EnglishNumberSymbolCombinationAttribute
,继承自ValidationAttribute
;
using System.ComponentModel.DataAnnotations;
namespace Jeff.Common.Validatetion.CustomAttributes;
/// <summary>
/// 是否是英文字母、数字和特殊字符的组合
/// </summary>
public class EnglishNumberSymbolCombinationAttribute : ValidationAttribute
{
/// <summary>
/// 默认的错误提示信息
/// </summary>
private const string error = "无效的英文字母、数字和特殊字符的组合";
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
if (value is null) return new ValidationResult("参数值为 null");
//if (value is null)
//{
// throw new ArgumentNullException(nameof(attribute));
//}
// 验证参数逻辑 value 是需要验证的值,而 validationContext 中包含了验证相关的上下文信息,这里可自己封装一个验证格式的 FormatValidation 类
if (FormatValidation.IsCombinationOfEnglishNumberSymbol(value as string, 8))
//验证成功返回 success
return ValidationResult.Success;
//不成功 提示验证错误的信息
else return new ValidationResult(ErrorMessage ?? error);
}
}
以上就实现了一个自定义规则的 自定义验证特性
,使用方式很简单,可以把它附属在我们 请求的参数
上或者 DTO 里的属性
,也可以是 Action 上的形参
,如下所示:
public class CreateDTO
{
[Required]
public string StoreName { get; init; }
[Required]
// 附属在 DTO 里的属性
[EnglishNumberSymbolCombination(ErrorMessage = "UserId 必须是英文字母、数字和特殊符号的组合")]
public string UserId { get; init; }
}
...
// 附属在 Action 上的形参
[HttpGet]
public async ValueTask<ActionResult> Delete([EnglishNumberSymbolCombination]string userId, string storeName)
该自定义验证特性还可以结合 DataAnnotations
内置的 [Compare]
特性,可以实现账号注册的密码确认验证(输入密码和确认密码是否一致性
)。关于更多自定义参数校验特性,感兴趣的小伙伴可参照上面案例的实现思路,自行扩展实现哟。
总结
对于模型参数的校验,在实际项目系统中是非常有必要性的(通常在数据源头提供验证),利用 .NET
内置的 DataAnnotations
(数据注解)提供的特性校验,可以很方便的实现通用的模型校验助手,关于其他特性的用法,请自行参考微软官方文档,这里注意下RegularExpressionAttribute
(指定 ASP.NET
动态数据中的数据字段值必须与指定的正则表达式匹配),该特性可以方便的接入正则匹配验证,当遇到复杂的参数校验时,可以快速方便的扩展自定义校验特性,从此告别传统编码中各种 if(xxx != yyyy)
判断的验证,让整体代码编写更佳简练干净。