通过Asp core + VueJS的示例统一验证规则


本文介绍了一种统一客户端-服务器应用程序的用户输入验证规则的简单方法。 以一个简单的项目为例,我将展示如何使用Asp net core和Vue js做到这一点。


通常,在开发Web应用程序时,我们面临着双重验证用户输入的任务。 一方面,必须在客户端上验证用户输入,以减少对服务器的冗余请求,并加快用户的验证速度。 另一方面,谈到验证,服务器无法“信任”接受客户端验证在发送请求之前实际起作用的理由,因为 用户可以禁用或修改验证码。 甚至手动从客户端API发出请求。


因此,经典的客户端-服务器交互具有2个节点,通常具有相同的用户输入验证规则。 整个文章已在多篇文章中进行了讨论;这里将以ASP.Net Core API服务器和Vue js客户端为例来描述轻量级解决方案。


首先,我们将确定只验证用户(团队)的请求,而不验证实体的请求,并且从经典3层体系结构的角度来看,我们的验证位于表示层中。


服务器端


在Visual Studio 2019中,我将使用ASP.NET Core Web应用程序模板和API类型为服务器应用程序创建项目。 现成的ASP具有相当好的和可扩展的验证机制-模型验证,根据该模型,模型的请求属性将标记有特定的验证属性。


考虑一个简单的控制器作为示例:


[Route("[controller]")] [ApiController] public class AccountController : ControllerBase { [HttpPost(nameof(Registration))] public ActionResult Registration([FromBody]RegistrationRequest request) { return Ok($"{request.Name},  !"); } } 

注册新用户的请求将如下所示:


注册请求
  public class RegistrationRequest { [StringLength(maximumLength: 50, MinimumLength = 2, ErrorMessage = "     2  50 ")] [Required(ErrorMessage = " ")] public string Name { get; set; } [Required(ErrorMessage = "  . ")] [EmailAddress(ErrorMessage = "  . ")] public string Email { get; set; } [Required(ErrorMessage = " ")] [MaxLength(100, ErrorMessage = "{0}    {1} ")] [MinLength(6, ErrorMessage ="{0}    {1} ")] [DisplayName("")] public string Password { get; set; } [Required(ErrorMessage = " ")] [Range(18,150, ErrorMessage = "      18  150")] public string Age { get; set; } [DisplayName("")] public string Culture { get; set; } } 

它使用System.ComponentModel.DataAnnotations命名空间中的预定义验证属性。 必需的属性用Required属性标记。 因此,当发送空的JSON(“ {}”)时,我们的API将返回:


 { ... "errors": { "Age": [ " " ], "Name": [ " " ], "Email": [ "  . " ], "Password": [ " " ] } } 

在此阶段可能遇到的第一个问题是错误描述的本地化。 顺便说一句,ASP具有内置的本地化工具,我们将在以后讨论。


要检查字符串数据的长度,可以使用具有语音名称的属性:StringLength,MaxLength和MinLength。 同时,通过格式化字符串(大括号),可以将属性参数集成到消​​息中。 例如,对于用户名,我们在消息中插入最小和最大长度,并在同名属性中指定密码“显示名称”。 Range属性负责检查应在指定范围内的值。
让我们发送一个用户名和密码短的请求:


  { "Name": "a", "Password" : "123" } 

在服务器的响应中,您可以找到新的验证错误消息:


  "Name": [ "     2  50 " ], "Password": [ "    6 " ] 

目前可能不明显的问题是,客户端应用程序中还必须存在名称和密码长度的边界值。 在两个或两个以上的地方手动设置相同数据的情况可能会滋生错误,这是设计不佳的标志之一。 让我们修复它。


我们将把客户需要的一切存储在资源文件中。 Controllers.AccountController.ru.resx中的消息以及共享资源中的文化独立数据:Controllers.AccountController.resx。 我坚持这种格式:


 {PropertyName}DisplayName {PropertyName}{RuleName} {PropertyName}{RuleName}Message 

因此,我们得到以下图片


请注意,要验证电子邮件地址 mail用于正则表达式。 对于文化验证,使用自定义规则-“值”(值列表)。 您还需要检查UI上的密码确认字段,稍后我们会看到。


要访问特定区域性的资源文件,我们在Startup.ConfigureServices方法中添加了本地化支持,以指示资源文件的路径:


 services.AddLocalization(options => options.ResourcesPath = "Resources"); 

同样在Startup.Configure方法中,通过用户请求的标头确定区域性:


  app.UseRequestLocalization(new RequestLocalizationOptions { DefaultRequestCulture = new RequestCulture("ru-RU"), SupportedCultures = new[] { new CultureInfo("en-US"), new CultureInfo("ru-RU") }, RequestCultureProviders = new List<IRequestCultureProvider> { new AcceptLanguageHeaderRequestCultureProvider() } }); 

现在,在控制器内部,我们可以访问本地化,我们将在构造函数中实现IStringLocalizer类型的依赖关系,并修改Registration操作的返回表达式:

  return Ok(string.Format(_localizer["RegisteredMessage"], request.Name)); 

ResxValidatior类将负责检查规则,这些规则将使用创建的资源。 它包含关键字的保留列表,预设卷以及检查它们的方法。


确认函
 public class ResxValidator { public const char ValuesSeparator = ','; public const char RangeSeparator = '-'; public enum Keywords { DisplayName, Message, Required, Pattern, Length, MinLength, MaxLength, Range, MinValue, MaxValue, Values, Compare } private readonly Dictionary<Keywords, Func<string, string, bool>> _rules = new Dictionary<Keywords, Func<string, string, bool>>() { [Keywords.Required] = (v, arg) => !string.IsNullOrEmpty(v), [Keywords.Pattern] = (v, arg) => !string.IsNullOrWhiteSpace(v) && Regex.IsMatch(v, arg), [Keywords.Range] = (v, arg) => !string.IsNullOrWhiteSpace(v) && long.TryParse(v, out var vLong) && long.TryParse(arg.Split(RangeSeparator)[0].Trim(), out var vMin) && long.TryParse(arg.Split(RangeSeparator)[1].Trim(), out var vMax) && vLong >= vMin && vLong <= vMax, [Keywords.Length] = (v, arg) => !string.IsNullOrWhiteSpace(v) && long.TryParse(arg.Split(RangeSeparator)[0].Trim(), out var vMin) && long.TryParse(arg.Split(RangeSeparator)[1].Trim(), out var vMax) && v.Length >= vMin && v.Length <= vMax, [Keywords.MinLength] = (v, arg) => !string.IsNullOrWhiteSpace(v) && v.Length >= int.Parse(arg), [Keywords.MaxLength] = (v, arg) => !string.IsNullOrWhiteSpace(v) && v.Length <= int.Parse(arg), [Keywords.Values] = (v, arg) => !string.IsNullOrWhiteSpace(v) && arg.Split(ValuesSeparator).Select(x => x.Trim()).Contains(v), [Keywords.MinValue] = (v, arg) => !string.IsNullOrEmpty(v) && long.TryParse(v, out var vLong) && long.TryParse(arg, out var argLong) && vLong >= argLong, [Keywords.MaxValue] = (v, arg) => !string.IsNullOrEmpty(v) && long.TryParse(v, out var vLong) && long.TryParse(arg, out var argLong) && vLong <= argLong }; private readonly IStringLocalizer _localizer; public ResxValidator(IStringLocalizer localizer) { _localizer = localizer; } public bool IsValid(string memberName, string value, out string message) { var rules = _rules.Select(x => new { Name = x.Key, Check = x.Value, String = _localizer.GetString(memberName + x.Key) }).Where(x => x.String != null && !x.String.ResourceNotFound); foreach (var rule in rules) { if (!rule.Check(value, rule.String?.Value)) { var messageResourceKey = $"{memberName}{rule.Name}{Keywords.Message}"; var messageResource = _localizer[messageResourceKey]; var displayNameResourceKey = $"{memberName}{Keywords.DisplayName}"; var displayNameResource = _localizer[displayNameResourceKey] ?? displayNameResourceKey; message = messageResource != null && !messageResource.ResourceNotFound ? string.Format(messageResource.Value, displayNameResource, rule.String?.Value) : messageResourceKey; return false; } } message = null; return true; } } 

创建我们的验证者将调用的自定义验证属性。 在这里,标准逻辑是获取模型的已验证属性的值,其名称并调用验证程序。


ResxAttribute
 public sealed class ResxAttribute : ValidationAttribute { private readonly string _baseName; private string _resourceName; public ResxAttribute(string sectionName, string resourceName = null) { _baseName = sectionName; _resourceName = resourceName; } protected override ValidationResult IsValid(object value, ValidationContext validationContext) { if (_resourceName == null) _resourceName = validationContext.MemberName; var factory = validationContext .GetService(typeof(IStringLocalizerFactory)) as IStringLocalizerFactory; var localizer = factory?.Create(_baseName, System.Reflection.Assembly.GetExecutingAssembly().GetName().Name); ErrorMessage = ErrorMessageString; var currentValue = value as string; var validator = new ResxValidator(localizer); return validator.IsValid(_resourceName, currentValue, out var message) ? ValidationResult.Success : new ValidationResult(message); } } 

最后,您可以将请求中的所有属性替换为通用属性,指明资源的名称:


  [Resx(sectionName: "Controllers.AccountController")] 

我们将通过发送相同的请求来测试功能:


  { "Name": "a", "Password" : "123" } 

对于本地化,添加带有英语消息以及标头的Controllers.AccountController.en.resx,以及标有以下区域性的信息:接受语言:zh-CN。


请注意,我们现在可以覆盖特定区域性的设置。 在* .en.resx文件中,我指定了最小密码长度8,并且收到了相应的消息:


  "Password": [ "Password must be at least 8 characters" ] 

要在客户端验证期间显示相同的消息,您必须以某种方式导出客户端部分的整个消息列表。 为简单起见,我们将制作一个单独的控制器,以i18n格式提供客户端应用程序所需的一切。


区域控制器
  [Route("[controller]")] [ApiController] public class LocaleController : ControllerBase { private readonly IStringLocalizerFactory _factory; private readonly string _assumbly; private readonly string _location; public LocaleController(IStringLocalizerFactory factory) { _factory = factory; _assumbly = System.Reflection.Assembly.GetExecutingAssembly().GetName().Name; _location = Path.Combine(Directory.GetCurrentDirectory(), "Resources"); } [HttpGet("Config")] public IActionResult GetConfig(string culture) { if (!string.IsNullOrEmpty(culture)) { CultureInfo.CurrentCulture = new CultureInfo(culture); CultureInfo.CurrentUICulture = new CultureInfo(culture); } var resources = Directory.GetFiles(_location, "*.resx", SearchOption.AllDirectories) .Select(x => x.Replace(_location + Path.DirectorySeparatorChar, string.Empty)) .Select(x => x.Substring(0, x.IndexOf('.'))) .Distinct(); var config = new Dictionary<string, Dictionary<string, string>>(); foreach (var resource in resources.Select(x => x.Replace('\\', '.'))) { var section = _factory.Create(resource, _assumbly) .GetAllStrings() .OrderBy(x => x.Name) .ToDictionary(x => x.Name, x => x.Value); config.Add(resource.Replace('.', '-'), section); } var result = JsonConvert.SerializeObject(config, Formatting.Indented); return Ok(result); } } 

根据接受语言,它将返回:


接受语言:美国
 { "Controllers-AccountController": { "AgeDisplayName": "Age", "AgeRange": "18 - 150", "AgeRangeMessage": "{0} must be {1}", "EmailDisplayName": "Email", "EmailPattern": "^[-\\w.]+@([A-z0-9][-A-z0-9]+\\.)+[Az]{2,4}$", "EmailPatternMessage": "Incorrect email", "EmailRequired": "", "EmailRequiredMessage": "Email required", "LanguageDisplayName": "Language", "LanguageValues": "ru-RU, en-US", "LanguageValuesMessage": "Incorrect language. Possible: {1}", "NameDisplayName": "Name", "NameLength": "2 - 50", "NameLengthMessage": "Name length must be {1} characters", "PasswordConfirmCompare": "Password", "PasswordConfirmCompareMessage": "Passwords must be the same", "PasswordConfirmDisplayName": "Password confirmation", "PasswordDisplayName": "Password", "PasswordMaxLength": "100", "PasswordMaxLengthMessage": "{0} can't be greater than {1} characters", "PasswordMinLength": "8", "PasswordMinLengthMessage": "{0} must be at least {1} characters", "PasswordRequired": "", "PasswordRequiredMessage": "Password required", "RegisteredMessage": "{0}, you've been registered!" } } 

接受语言:en-RU
 { "Controllers-AccountController": { "AgeDisplayName": "", "AgeRange": "18 - 150", "AgeRangeMessage": "{0}     {1}", "EmailDisplayName": " . ", "EmailPattern": "^[-\\w.]+@([A-z0-9][-A-z0-9]+\\.)+[Az]{2,4}$", "EmailPatternMessage": "  . ", "EmailRequired": "", "EmailRequiredMessage": "  . ", "LanguageDisplayName": "", "LanguageValues": "ru-RU, en-US", "LanguageValuesMessage": " .  : {1}", "NameDisplayName": "", "NameLength": "2 - 50", "NameLengthMessage": "    {1} ", "PasswordConfirmCompare": "Password", "PasswordConfirmCompareMessage": "  ", "PasswordConfirmDisplayName": " ", "PasswordDisplayName": "", "PasswordMaxLength": "100", "PasswordMaxLengthMessage": "{0}    {1} ", "PasswordMinLength": "6", "PasswordMinLengthMessage": "{0}    {1} ", "PasswordRequired": "", "PasswordRequiredMessage": " ", "RegisteredMessage": "{0},  !" } } 

剩下要做的最后一件事就是允许客户端的跨域请求。 为此,请在Startup.ConfigureServices中添加:


  services.AddCors(); 

并在Startup.Configure中添加:


  app.UseCors(x => x .AllowAnyOrigin() .AllowAnyMethod() .AllowAnyHeader()); 

客户部分


我更喜欢在一个IDE中查看应用程序的所有部分,因此我将使用Visual Studio模板(基本Vue.js Web应用程序)创建一个客户端应用程序项目。 如果您没有/不想使用此类模板堵塞VS,请使用vue cli,我将在此之前安装许多软件包:vuetify,axios,vue-i18n。


我们从LocaleController请求转换后的资源文件,在Accept-Language标头中指示区域性,然后将响应放置在locales目录中的en.json和ru.json文件中。


接下来,我们需要一个服务来解析来自服务器的错误响应。


errorHandler.service.js
 const ErrorHandlerService = { parseResponseError (error) { if (error.toString().includes('Network Error')) { return 'ServerUnavailable' } if (error.response) { let statusText = error.response.statusText; if (error.response.data && error.response.data.errors) { let message = ''; for (let property in error.response.data.errors) { error.response.data.errors[property].forEach(function (entry) { if (entry) { message += entry + '\n' } }) } return message } else if (error.response.data && error.response.data.message) { return error.response.data.message } else if (statusText) { return statusText } } } }; export { ErrorHandlerService } 

Vutify.js内置的vee-validate验证器要求您使用rules属性指定一个验证函数数组,我们将从服务中请求它们以使用资源。


locale.service.js
 import i18n from '../i18n'; const LocaleService = { getRules(resource, options) { let rules = []; options = this.prepareOptions(options); this.addRequireRule(rules, resource, options); this.addPatternRule(rules, resource, options); this.addRangeRule(rules, resource, options); this.addLengthRule(rules, resource, options); this.addMaxLengthRule(rules, resource, options); this.addMinLengthRule(rules, resource, options); this.addValuesRule(rules, resource, options); this.addCompareRule(rules, resource, options); return rules; }, prepareOptions(options){ let getter = v => v; let compared = () => null; if (!options){ options = { getter: getter, compared: compared }; } if (!options.getter) options.getter = getter; if (!options.compared) options.compared = compared; return options; }, addRequireRule(rules, resource, options){ let settings = this.getRuleSettings(resource, 'Required'); if(settings){ rules.push(v => !!options.getter(v) || settings.message); } }, addPatternRule(rules, resource, options){ let settings = this.getRuleSettings(resource, 'Pattern'); if(settings){ rules.push(v => !!options.getter(v) || settings.message); rules.push(v => new RegExp(settings.value).test(options.getter(v)) || settings.message); } }, addRangeRule(rules, resource, options){ let settings = this.getRuleSettings(resource, 'Range'); if(settings){ let values = settings.value.split('-'); rules.push(v => !!options.getter(v) || settings.message); rules.push(v => parseInt(options.getter(v)) >= values[0] && parseInt(options.getter(v)) <= values[1] || settings.message); } }, addLengthRule(rules, resource, options){ let settings = this.getRuleSettings(resource, 'Length'); if(settings){ let values = settings.value.split('-'); rules.push(v => !!options.getter(v) || settings.message); rules.push(v => options.getter(v).length >= values[0] && options.getter(v).length <= values[1] || settings.message); } }, addMaxLengthRule(rules, resource, options){ let settings = this.getRuleSettings(resource, 'MaxLength'); if(settings){ rules.push(v => !!options.getter(v) || settings.message); rules.push(v => options.getter(v).length <= settings.value || settings.message); } }, addMinLengthRule(rules, resource, options){ let settings = this.getRuleSettings(resource, 'MinLength'); if(settings){ rules.push(v => !!options.getter(v) || settings.message); rules.push(v => options.getter(v).length >= settings.value || settings.message); } }, addValuesRule(rules, resource, options){ let settings = this.getRuleSettings(resource, 'Values'); if(settings) { let values = settings.value.split(','); rules.push(v => !!options.getter(v) || settings.message); rules.push(v => !!values.find(x => x.trim() === options.getter(v)) || settings.message); } }, addCompareRule(rules, resource, options){ let settings = this.getRuleSettings(resource, 'Compare'); if(settings) { rules.push(() => { return settings.value === '' || !!settings.value || settings.message }); rules.push(v => { return options.getter(v) === options.compared() || settings.message; }); } }, getRuleSettings(resource, rule){ let value = this.getRuleValue(resource, rule); let message = this.getRuleMessage(resource, rule, value); return value === '' || value ? { value: value, message: message } : null; }, getRuleValue(resource, rule){ let key =`${resource}${rule}`; return this.getI18nValue(key); }, getDisplayName(resource){ let key =`${resource}DisplayName`; return this.getI18nValue(key); }, getRuleMessage(resource, rule, value){ let key =`${resource}${rule}Message`; return i18n.t(key, [this.getDisplayName(resource), value]); }, getI18nValue(key){ let value = i18n.t(key); return value !== key ? value : null; } }; export { LocaleService } 

完全描述此服务没有任何意义,因为 它部分复制ResxValidator类。 我注意到,要检查“比较”规则,即必须将当前属性的值与另一个属性进行比较,则传递了options对象,在其中比较了委托,该委托返回了要比较的值。


因此,典型的表单字段如下所示:


 <v-text-field :label="displayFor('Name')" :rules="rulesFor('Name')" v-model="model.Name" type="text" prepend-icon="mdi-account"></v-text-field> 

对于标签,在locale.service上调用包装函数,该函数传递资源的全名以及要获取其显示名称的属性的名称。 规则也是如此。 v模型指定用于存储输入数据的模型。
对于密码确认属性,在options对象中传递密码本身的值:


 :rules="rulesFor('PasswordConfirm', { compared:() => model.Password })" 

用于语言选择的v-select具有预定义的元素列表(此操作是为了简化示例),并且onChange是处理程序。 因为 我们做SPA,我们想在用户选择一种语言时更改本地化,因此,在onChange select中,选中了所选择的语言,如果语言已更改,则界面语言会更改:


  onCultureChange (value) { let culture = this.cultures.find(x => x.value === value); this.model.Culture = culture.value; if (culture.locale !== this.$i18n.locale) { this.$i18n.locale = culture.locale; this.$refs.form.resetValidation(); } } 

就是这样, 这里有一个运行正常的应用程序的存储库。


总结一下,我想指出 ,将资源最初存储为我们从LocaleController要求的单一JSON格式以便将其与ASP框架的细节脱钩是非常好的。 在ResxValidatior中,可以特别添加对可扩展性和自定义规则的支持,并突出显示与locale.service相同的代码,并以JS样式重写以简化支持。 但是,在此示例中,我着重于简单性。

Source: https://habr.com/ru/post/zh-CN473776/


All Articles