
Dieser Artikel beschreibt eine einfache Möglichkeit, die Regeln für die Überprüfung von Benutzereingaben für eine Client-Server-Anwendung zu vereinheitlichen. Am Beispiel eines einfachen Projekts werde ich zeigen, wie dies mit Asp net core und Vue js möglich ist.
Bei der Entwicklung von Webanwendungen stehen wir in der Regel vor der doppelten Validierung von Benutzereingaben. Einerseits müssen Benutzereingaben auf dem Client validiert werden, um redundante Anforderungen an den Server zu reduzieren und die Validierung selbst für den Benutzer zu beschleunigen. Wenn man von Validierung spricht, kann der Server nicht akzeptieren, dass die Client-Validierung tatsächlich funktioniert hat, bevor die Anforderung gesendet wurde, weil Der Benutzer kann den Validierungscode deaktivieren oder ändern. Oder stellen Sie sogar manuell eine Anfrage über die Client-API.
Daher hat die klassische Client-Server-Interaktion zwei Knoten mit häufig identischen Validierungsregeln für Benutzereingaben. Dieser Artikel als Ganzes wurde in mehr als einem Artikel behandelt. Eine einfache Lösung wird hier am Beispiel des ASP.Net Core API-Servers und des Vue js-Clients beschrieben.
Zunächst werden wir festlegen, dass wir ausschließlich die Anforderungen des Benutzers (Teams) und nicht der Entität validieren. Aus Sicht der klassischen 3-Layer-Architektur erfolgt unsere Validierung in der Präsentationsschicht.
Serverseite
In Visual Studio 2019 werde ich mithilfe der ASP.NET Core-Webanwendungsvorlage ein Projekt für eine Serveranwendung mit einem API-Typ erstellen. ASP out of the box verfügt über einen recht guten und erweiterbaren Validierungsmechanismus - die Modellvalidierung, nach der die Anforderungseigenschaften des Modells mit bestimmten Validierungsattributen gekennzeichnet sind.
Betrachten Sie dies anhand eines einfachen Controllers als Beispiel:
[Route("[controller]")] [ApiController] public class AccountController : ControllerBase { [HttpPost(nameof(Registration))] public ActionResult Registration([FromBody]RegistrationRequest request) { return Ok($"{request.Name}, !"); } }
Die Anfrage zur Registrierung eines neuen Benutzers sieht folgendermaßen aus:
Registrierungsanfrage 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; } }
Es verwendet vordefinierte Validierungsattribute aus dem System.ComponentModel.DataAnnotations-Namespace. Erforderliche Eigenschaften sind mit dem Attribut Erforderlich gekennzeichnet. Wenn Sie also leeren JSON ("{}") senden, gibt unsere API Folgendes zurück:
{ ... "errors": { "Age": [ " " ], "Name": [ " " ], "Email": [ " . " ], "Password": [ " " ] } }
Das erste Problem, das in dieser Phase auftreten kann, ist die Lokalisierung von Fehlerbeschreibungen. Übrigens, ASP verfügt über integrierte Lokalisierungstools, die wir später betrachten werden.
Um die Länge der Zeichenfolgendaten zu überprüfen, können Sie die Attribute mit sprechenden Namen verwenden: StringLength, MaxLength und MinLength. Gleichzeitig können durch Formatieren von Zeichenfolgen (geschweifte Klammern) Attributparameter in eine Nachricht integriert werden. Beispielsweise fügen wir für den Benutzernamen die minimale und maximale Länge in die Nachricht ein und für das Kennwort "Anzeigename", das im gleichnamigen Attribut angegeben ist. Das Range-Attribut ist für die Überprüfung des Werts verantwortlich, der im angegebenen Bereich liegen soll.
Senden wir eine Anfrage mit einem unannehmbar kurzen Benutzernamen und Passwort:
{ "Name": "a", "Password" : "123" }
In der Antwort vom Server finden Sie neue Validierungsfehlermeldungen:
"Name": [ " 2 50 " ], "Password": [ " 6 " ]
Das Problem, das vorerst möglicherweise nicht offensichtlich ist, besteht darin, dass die Grenzwerte für die Länge des Namens und des Kennworts auch in der Clientanwendung vorhanden sein müssen. Eine Situation, in der dieselben Daten manuell an zwei oder mehr Stellen festgelegt werden, ist ein potenzieller Nährboden für Fehler, eines der Anzeichen für ein schlechtes Design. Lass es uns reparieren.
Wir speichern alles, was der Client benötigt, in Ressourcendateien. Nachrichten in Controllers.AccountController.ru.resx und kulturell unabhängige Daten in einer gemeinsam genutzten Ressource: Controllers.AccountController.resx. Ich halte mich an dieses Format:
{PropertyName}DisplayName {PropertyName}{RuleName} {PropertyName}{RuleName}Message
Somit erhalten wir das folgende Bild Bitte beachten Sie dies, um die E-Mail-Adresse zu bestätigen mail wird als regulärer Ausdruck verwendet. Für die Kulturvalidierung wird eine benutzerdefinierte Regel verwendet - "Werte" (Werteliste). Sie müssen auch das Kennwortbestätigungsfeld überprüfen, das wir später auf der Benutzeroberfläche sehen werden.
Um auf Ressourcendateien für eine bestimmte Kultur zuzugreifen, fügen wir der Startup.ConfigureServices-Methode Lokalisierungsunterstützung hinzu und geben den Pfad zu den Ressourcendateien an:
services.AddLocalization(options => options.ResourcesPath = "Resources");
Und auch in der Startup.Configure-Methode, die die Kultur anhand des Headers der Benutzeranforderung bestimmt:
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() } });
Damit wir innerhalb des Controllers Zugriff auf die Lokalisierung haben, implementieren wir eine Abhängigkeit vom Typ IStringLocalizer im Konstruktor und ändern den Rückgabeausdruck der Registrierungsaktion:
return Ok(string.Format(_localizer["RegisteredMessage"], request.Name));
Die ResxValidatior-Klasse ist für die Überprüfung der Regeln verantwortlich, die die erstellten Ressourcen verwenden. Es enthält eine reservierte Liste von Schlüsselwörtern, voreingestellten Rollen und eine Methode zu deren Überprüfung.
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; } }
Erstellen Sie ein benutzerdefiniertes Validierungsattribut, das unser Validator aufruft. Hier besteht die Standardlogik darin, den Wert der verifizierten Eigenschaft des Modells, seinen Namen und den Validator aufzurufen.
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); } }
Schließlich können Sie alle Attribute in der Anforderung durch unser universelles ersetzen und den Namen der Ressource angeben:
[Resx(sectionName: "Controllers.AccountController")]
Wir testen die Funktionalität, indem wir dieselbe Anfrage senden:
{ "Name": "a", "Password" : "123" }
Fügen Sie zur Lokalisierung Controllers.AccountController.en.resx mit Nachrichten in Englisch sowie einer Kopfzeile mit Informationen zur Kultur hinzu: Accept-Language: en-US.
Bitte beachten Sie, dass wir jetzt die Einstellungen für eine bestimmte Kultur überschreiben können. In der Datei * .en.resx habe ich die Mindestkennwortlänge von 8 angegeben und eine entsprechende Nachricht erhalten:
"Password": [ "Password must be at least 8 characters" ]
Um während der Client-Validierung identische Nachrichten anzuzeigen, müssen Sie die gesamte Liste der Nachrichten für den Client-Teil exportieren. Der Einfachheit halber erstellen wir einen separaten Controller, der alles bietet, was Sie für eine Clientanwendung im i18n-Format benötigen.
Lokaler Controller [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); } }
Abhängig von der Accept-Language wird Folgendes zurückgegeben:
Accept-Language: en-US { "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!" } }
Accept-Language: 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}, !" } }
Als letztes müssen noch Ursprungsübergreifende Anforderungen für den Client zugelassen werden. Fügen Sie dazu in Startup.ConfigureServices Folgendes hinzu:
services.AddCors();
und in Startup.Configure hinzufügen:
app.UseCors(x => x .AllowAnyOrigin() .AllowAnyMethod() .AllowAnyHeader());
Client-Teil
Ich bevorzuge es, alle Teile der Anwendung in einer IDE zu sehen, daher erstelle ich ein Clientanwendungsprojekt mithilfe der Visual Studio-Vorlage: Basic Vue.js Web Application. Wenn Sie es nicht haben / VS mit solchen Vorlagen nicht verstopfen möchten, verwenden Sie vue cli. Damit werde ich zuvor eine Reihe von Paketen installieren: vuetify, axios, vue-i18n.
Wir fordern die konvertierten Ressourcendateien vom LocaleController an, wobei die Kultur im Header Accept-Language angegeben wird, und platzieren die Antwort in den Dateien en.json und ru.json im Verzeichnis locales.
Als nächstes benötigen wir einen Dienst, um die Antwort mit Fehlern vom Server zu analysieren.
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 }
Für den in Vutify.js integrierten Vee-Validate-Validator müssen Sie ein Array von Validierungsfunktionen mit dem Attribut rules angeben. Wir fordern sie vom Service für die Arbeit mit Ressourcen an.
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 }
Es macht keinen Sinn, diesen Service vollständig als zu beschreiben Die ResxValidator-Klasse wird teilweise dupliziert. Ich stelle fest, dass zur Überprüfung der Vergleichsregel, bei der der Wert der aktuellen Eigenschaft mit einer anderen verglichen werden muss, das Optionsobjekt übergeben wird, in dem der Delegat verglichen wird, wodurch der Wert zum Vergleich zurückgegeben wird.
Ein typisches Formularfeld sieht also folgendermaßen aus:
<v-text-field :label="displayFor('Name')" :rules="rulesFor('Name')" v-model="model.Name" type="text" prepend-icon="mdi-account"></v-text-field>
Für das Label wird die Wrapper-Funktion in locale.service aufgerufen, die den vollständigen Namen der Ressource sowie den Namen der Eigenschaft übergibt, für die Sie den Anzeigenamen erhalten möchten. Ähnliches gilt für Regeln. Das V-Modell gibt ein Modell zum Speichern der eingegebenen Daten an.
Übergeben Sie für die Kennwortbestätigungseigenschaft die Werte des Kennworts selbst im Optionsobjekt:
:rules="rulesFor('PasswordConfirm', { compared:() => model.Password })"
v-select für die Sprachauswahl verfügt über eine vordefinierte Liste von Elementen (dies dient der Vereinfachung des Beispiels) und onChange ist ein Handler. Weil Wenn wir SPA ausführen, möchten wir die Lokalisierung ändern, wenn der Benutzer eine Sprache auswählt. Daher wird in der onChange-Auswahl die ausgewählte Sprache überprüft, und wenn sie sich geändert hat, ändert sich das Gebietsschema der Benutzeroberfläche:
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(); } }
Das ist alles, das Repository mit einer funktionierenden Anwendung ist hier .
Zusammenfassend möchte ich darauf hinweisen, dass es großartig wäre, die Ressourcen selbst in einem einzigen JSON-Format zu speichern, das wir vom LocaleController anfordern, um sie von den Besonderheiten des ASP-Frameworks zu lösen. In ResxValidatior könnte man Unterstützung für Erweiterbarkeit und benutzerdefinierte Regeln hinzufügen sowie Code hervorheben, der mit locale.service identisch ist, und ihn im JS-Stil neu schreiben, um die Unterstützung zu vereinfachen. In diesem Beispiel konzentriere ich mich jedoch auf die Einfachheit.