
توضح هذه المقالة طريقة بسيطة لتوحيد قواعد التحقق من صحة إدخال المستخدم لتطبيق خادم عميل. باستخدام مشروع بسيط كمثال ، سأوضح كيف يمكن القيام بذلك باستخدام Asp net core و Vue js.
عند تطوير تطبيقات الويب ، وكقاعدة عامة ، نواجه مهمة التحقق المزدوج من إدخال المستخدم. من ناحية ، يجب التحقق من صحة إدخال المستخدم على العميل لتقليل الطلبات الزائدة على الخادم وتسريع عملية التحقق من الصحة للمستخدم نفسه. من ناحية أخرى ، عند الحديث عن التحقق من الصحة ، لا يمكن للخادم قبول "بناءً على إيمان" أن التحقق من صحة العميل قد تم بالفعل قبل إرسال الطلب ، لأن يمكن للمستخدم تعطيل أو تعديل رمز التحقق من الصحة. أو حتى تقديم طلب من API العميل يدويا.
وبالتالي ، فإن التفاعل بين العميل والخادم الكلاسيكي له عقدتان ، مع قواعد التحقق من صحة إدخال المستخدم في كثير من الأحيان. تمت مناقشة هذه المقالة ككل في أكثر من مقالة واحدة ؛ سيتم وصف حل خفيف الوزن هنا باستخدام خادم API ASP.Net Core وعميل Vue js كمثال.
بادئ ذي بدء ، سوف نحدد أننا سنقوم بالتحقق من صحة طلبات المستخدم (الفريق) فقط ، وليس الكيان ، ومن وجهة نظر البنية الكلاسيكية ثلاثية الطبقات ، يتم التحقق من الصحة في طبقة العرض التقديمي.
جانب الخادم
أثناء إنشاء 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}, !"); } }
سيبدو طلب تسجيل مستخدم جديد كما يلي:
RegistrationRequest 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. يتم تمييز الخصائص المطلوبة بالسمة المطلوبة. وبالتالي ، عند إرسال JSON فارغة ("{}") ، ستعود واجهة برمجة التطبيقات لدينا:
{ ... "errors": { "Age": [ " " ], "Name": [ " " ], "Email": [ " . " ], "Password": [ " " ] } }
المشكلة الأولى التي يمكن مواجهتها في هذه المرحلة هي توطين أوصاف الخطأ. بالمناسبة ، يحتوي ASP على أدوات تعريب مضمنة ، وسوف ننظر في ذلك لاحقًا.
للتحقق من طول بيانات السلسلة ، يمكنك استخدام السمات ذات الأسماء الناطقة: StringLength و MaxLength و MinLength. في الوقت نفسه ، من خلال تنسيق سلاسل (الأقواس المتعرجة) ، يمكن دمج معلمات السمات في رسالة. على سبيل المثال ، بالنسبة لاسم المستخدم ، نقوم بإدراج الحد الأدنى والحد الأقصى للطول في الرسالة ، ولاسم كلمة المرور "اسم العرض" المحدد في السمة التي تحمل نفس الاسم. تعد سمة النطاق مسؤولة عن التحقق من القيمة التي يجب أن تكون في النطاق المحدد.
دعنا نرسل طلبًا باستخدام اسم مستخدم وكلمة مرور قصيرة بشكل غير مقبول:
{ "Name": "a", "Password" : "123" }
في استجابة الخادم ، يمكنك العثور على رسائل خطأ جديدة للتحقق من الصحة:
"Name": [ " 2 50 " ], "Password": [ " 6 " ]
المشكلة ، التي قد تكون غير واضحة في الوقت الحالي ، هي أن قيم الحدود لطول الاسم وكلمة المرور يجب أن تكون موجودة أيضًا في تطبيق العميل. يمثل الموقف الذي يتم فيه تعيين نفس البيانات يدويًا في مكانين أو أماكن جسدية أرضًا محتملة لتكاثر الأخطاء ، وهي إحدى علامات سوء التصميم. دعونا إصلاحه.
سنقوم بتخزين كل ما يحتاجه العميل في ملفات الموارد. الرسائل في Controllers.AccountController.ru.resx ، والبيانات المستقلة ثقافيًا في مورد مشترك: Controllers.AccountController.resx. أنا ألتزم بهذا التنسيق:
{PropertyName}DisplayName {PropertyName}{RuleName} {PropertyName}{RuleName}Message
وبالتالي ، نحصل على الصورة التالية يرجى ملاحظة أن للتحقق من صحة عنوان البريد الإلكتروني يستخدم البريد التعبير العادي. وللتحقق من صحة الثقافة ، يتم استخدام قاعدة مخصصة - "القيم" (قائمة القيم). ستحتاج أيضًا إلى التحقق من حقل تأكيد كلمة المرور ، والذي سنراه لاحقًا ، في واجهة المستخدم.
للوصول إلى ملفات الموارد لثقافة معينة ، نضيف دعم الترجمة في أسلوب 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 في المنشئ ، ونعدل تعبير الإرجاع لإجراء التسجيل:
return Ok(string.Format(_localizer["RegisteredMessage"], request.Name));
فئة ResxValidatior ستكون مسؤولة عن التحقق من القواعد ، والتي سوف تستخدم الموارد التي تم إنشاؤها. أنه يحتوي على قائمة محجوزة من الكلمات الأساسية ، ولفائف محددة مسبقًا ، وطريقة للتحقق منها.
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 مع رسائل باللغة الإنجليزية ، وكذلك رأس ، مع معلومات حول الثقافة: Accept-Language: en-US.
يرجى ملاحظة أنه يمكننا الآن تجاوز الإعدادات لثقافة معينة. في ملف * .en.resx ، حددت الحد الأدنى لطول كلمة المرور وهو 8 ، وتلقيت رسالة مناسبة:
"Password": [ "Password must be at least 8 characters" ]
لعرض رسائل متطابقة أثناء التحقق من صحة العميل ، يجب عليك بطريقة ما تصدير قائمة الرسائل بالكامل لجزء العميل. للبساطة ، سنقوم بإنشاء وحدة تحكم منفصلة توفر كل ما تحتاجه لتطبيق عميل بتنسيق i18n.
LocaleController [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: Basic Vue.js Web Application. إذا لم يكن لديك / لا تريد أن تسد VS بمثل هذه القوالب ، فاستخدم vue cli ، حيث سأقوم بتثبيت عدد من الحزم قبل: vuetify ، axios ، vue-i18n.
نطلب ملفات الموارد المحولة من LocaleController ، مع الإشارة إلى الثقافة في رأس Accept-Language ، ووضع الاستجابة في ملفات 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 تحديد مجموعة من وظائف التحقق من الصحة باستخدام سمة القواعد ، وسوف نطلب منهم من الخدمة العمل مع الموارد.
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. ألاحظ أنه للتحقق من قاعدة المقارنة ، حيث يكون من الضروري مقارنة قيمة الخاصية الحالية بآخر ، يتم تمرير كائن الخيارات الذي تتم مقارنة المفوض به ، والذي يُرجع قيمة المقارنة.
وبالتالي ، سيبدو حقل النموذج النموذجي كما يلي:
<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 نموذجًا لتخزين البيانات المدخلة.
بالنسبة إلى خاصية تأكيد كلمة المرور ، مرر قيم كلمة المرور نفسها في كائن الخيارات:
:rules="rulesFor('PasswordConfirm', { compared:() => model.Password })"
يحتوي v-select لاختيار اللغة على قائمة محددة مسبقًا من العناصر (يتم ذلك لبساطة المثال) و onChange هو معالج. لأن نحن نفعل SPA ، نريد تغيير الترجمة عندما يختار المستخدم لغة ما ، وبالتالي ، في اختيار onChange ، يتم تحديد اللغة المحددة ، وإذا تغيرت ، تتغير لغة الواجهة:
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(); } }
هذا كل شيء ، مستودع مع تطبيق العمل هنا .
باختصار ، أود أن أشير إلى أنه سيكون من الرائع تخزين الموارد بأنفسهم بتنسيق JSON واحد ، والذي نطلبه من LocaleController ، من أجل فك ارتباطه من تفاصيل إطار عمل ASP. في ResxValidatior ، يمكن للمرء أن يضيف دعمًا لقواعد القابلية للتوسعة والقواعد المخصصة على وجه الخصوص ، بالإضافة إلى إبراز الكود المطابق لـ locale.service وإعادة كتابته بأسلوب JS لتبسيط الدعم. ومع ذلك ، في هذا المثال ، أركز على البساطة.