Penyatuan aturan validasi dengan contoh Asp core + VueJS


Artikel ini menjelaskan cara sederhana untuk menyatukan aturan validasi input pengguna untuk aplikasi client-server. Menggunakan proyek sederhana sebagai contoh, saya akan menunjukkan bagaimana hal ini dapat dilakukan dengan menggunakan Asp net core dan Vue js.


Saat mengembangkan aplikasi web, sebagai aturan, kami menghadapi tugas validasi ganda input pengguna. Di satu sisi, input pengguna harus divalidasi pada klien untuk mengurangi permintaan berlebihan ke server dan mempercepat validasi itu sendiri untuk pengguna. Di sisi lain, berbicara tentang validasi, server tidak dapat menerima "dengan keyakinan" bahwa validasi klien benar-benar berfungsi sebelum mengirim permintaan, karena pengguna dapat menonaktifkan atau memodifikasi kode validasi. Atau bahkan membuat permintaan dari API klien secara manual.


Dengan demikian, interaksi client-server klasik memiliki 2 node, dengan aturan validasi input pengguna sering identik. Artikel ini secara keseluruhan telah dibahas dalam lebih dari satu artikel, solusi ringan akan dijelaskan di sini menggunakan server ASP.Net Core API dan klien Vue js sebagai contoh.


Untuk memulainya, kami akan menentukan bahwa kami akan memvalidasi secara eksklusif permintaan pengguna (tim), bukan entitas, dan, dari sudut pandang arsitektur 3-layer klasik, validasi kami ada di Presentation Layer.


Sisi server


Sementara di Visual Studio 2019 saya akan membuat proyek untuk aplikasi server, menggunakan templat Aplikasi Web Inti ASP.NET, dengan jenis API. ASP out of the box memiliki mekanisme validasi yang cukup baik dan dapat diperpanjang - validasi model, yang menurutnya, properti permintaan model ditandai dengan atribut validasi tertentu.


Pertimbangkan ini dengan pengontrol sederhana sebagai contoh:


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

Permintaan untuk mendaftarkan pengguna baru akan terlihat seperti ini:


Permintaan Pendaftaran
  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; } } 

Ini menggunakan atribut validasi yang telah ditentukan sebelumnya dari namespace System.ComponentModel.DataAnnotations. Properti yang diperlukan ditandai dengan atribut yang Diperlukan. Dengan demikian, saat mengirim JSON kosong ("{}"), API kami akan kembali:


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

Masalah pertama yang dapat ditemui pada tahap ini adalah lokalisasi deskripsi kesalahan. Omong-omong, ASP memiliki alat pelokalan bawaan, kami akan mempertimbangkannya nanti.


Untuk memeriksa panjang data string, Anda dapat menggunakan atribut dengan nama yang berbicara: StringLength, MaxLength dan MinLength. Pada saat yang sama, dengan memformat string (kurung kurawal), parameter atribut dapat diintegrasikan ke dalam pesan. Misalnya, untuk nama pengguna, kami menyisipkan panjang minimum dan maksimum dalam pesan, dan untuk kata sandi "nama tampilan" yang ditentukan dalam atribut dengan nama yang sama. Atribut Range bertanggung jawab untuk memeriksa nilai yang harus dalam rentang yang ditentukan.
Mari kirim permintaan dengan nama pengguna dan kata sandi singkat yang tidak dapat diterima:


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

Dalam respons dari server, Anda dapat menemukan pesan kesalahan validasi baru:


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

Masalahnya, yang mungkin, untuk saat ini, tidak jelas, adalah bahwa nilai batas untuk panjang nama dan kata sandi juga harus ada dalam aplikasi klien. Situasi di mana data yang sama ditetapkan secara manual di dua atau di tempat yang jelek merupakan tempat berkembang biaknya bug, salah satu tanda desain yang buruk. Mari kita perbaiki.


Kami akan menyimpan semua yang dibutuhkan klien dalam file sumber daya. Pesan di Controllers.AccountController.ru.resx, dan data yang bebas secara budaya di sumber bersama: Controllers.AccountController.resx. Saya mematuhi format ini:


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

Dengan demikian, kita mendapatkan gambar berikut


Harap perhatikan bahwa untuk memvalidasi alamat email mail digunakan ekspresi reguler. Dan untuk validasi budaya, aturan khusus digunakan - "Nilai" (daftar nilai). Anda juga perlu memeriksa bidang konfirmasi kata sandi, yang akan kita lihat nanti, di UI.


Untuk mengakses file sumber daya untuk budaya tertentu, kami menambahkan dukungan pelokalan dalam metode Startup.ConfigureServices, yang menunjukkan jalur ke file sumber daya:


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

Dan juga dalam metode Startup.Configure, menentukan budaya dengan header permintaan pengguna:


  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() } }); 

Sekarang, sehingga di dalam controller, kami memiliki akses ke pelokalan, kami akan mengimplementasikan dependensi tipe IStringLocalizer di konstruktor, dan mengubah ekspresi balik dari tindakan Registrasi:

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

Kelas ResxValidatior akan bertanggung jawab untuk memeriksa aturan, yang akan menggunakan sumber daya yang dibuat. Ini berisi daftar kata kunci yang dipesan, gulungan yang telah ditetapkan, dan metode untuk memeriksanya.


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; } } 

Buat atribut validasi khusus yang akan dipanggil oleh validator kami. Di sini, logika standar adalah untuk mendapatkan nilai properti terverifikasi dari model, namanya dan memanggil validator.


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); } } 

Terakhir, Anda dapat mengganti semua atribut dalam permintaan dengan atribut universal kami, yang menunjukkan nama sumber daya:


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

Kami akan menguji fungsionalitas dengan mengirimkan permintaan yang sama:


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

Untuk pelokalan, tambahkan Controllers.AccountController.en.resx dengan pesan dalam bahasa Inggris, serta tajuk, dengan informasi tentang budaya: Accept-Language: en-US.


Harap perhatikan bahwa kami sekarang dapat mengganti pengaturan untuk budaya tertentu. Dalam file * .en.resx, saya menentukan panjang kata sandi minimum 8, dan saya menerima pesan yang sesuai:


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

Untuk menampilkan pesan yang identik selama validasi klien, Anda harus entah bagaimana mengekspor seluruh daftar pesan untuk bagian klien. Untuk kesederhanaan, kami akan membuat pengontrol terpisah yang akan memberikan semua yang Anda butuhkan untuk aplikasi klien dalam format i18n.


Kontroler lokal
  [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); } } 

Bergantung pada Bahasa Terima, itu akan kembali:


Bahasa Terima: id-AS
 { "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!" } } 

Bahasa Terima: 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},  !" } } 

Hal terakhir yang harus dilakukan adalah mengizinkan permintaan lintas-asal untuk klien. Untuk melakukan ini, di Startup.ConfigureServices tambahkan:


  services.AddCors(); 

dan di Startup.Configure tambahkan:


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

Bagian klien


Saya lebih suka melihat semua bagian aplikasi dalam satu IDE, jadi saya akan membuat proyek aplikasi klien menggunakan templat Visual Studio: Aplikasi Web Basic Vue.js. Jika Anda tidak memilikinya / Anda tidak ingin menyumbat VS dengan template seperti itu, gunakan vue cli, dengan itu saya akan menginstal sejumlah paket sebelumnya: vuetify, axios, vue-i18n.


Kami meminta file sumber daya yang dikonversi dari LocaleController, yang menunjukkan budaya di header Bahasa Terima, dan menempatkan respons di file en.json dan ru.json di direktori locales.


Selanjutnya, kita membutuhkan layanan untuk mem-parsing respons dengan kesalahan dari server.


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 } 

Validator validasi yang dibangun di dalam Vutify.js mengharuskan Anda untuk menentukan array fungsi validasi dengan atribut aturan, kami akan meminta mereka dari layanan untuk bekerja dengan sumber daya.


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 } 

Tidak masuk akal untuk menggambarkan layanan ini sepenuhnya, sebagai sebagian menduplikasi kelas ResxValidator. Saya perhatikan bahwa untuk memeriksa aturan Bandingkan, di mana perlu untuk membandingkan nilai properti saat ini dengan yang lain, objek opsi dilewati, di mana delegasi dibandingkan, yang mengembalikan nilai untuk perbandingan.


Dengan demikian, bidang formulir khas akan terlihat seperti ini:


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

Untuk label, fungsi wrapper disebut pada locale.service, yang meneruskan nama lengkap sumber daya, serta nama properti tempat Anda ingin mendapatkan nama tampilan. Demikian pula untuk aturan. Model-v menentukan model untuk menyimpan data yang dimasukkan.
Untuk properti konfirmasi kata sandi, berikan nilai kata sandi itu sendiri di objek opsi:


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

v-pilih untuk pemilihan bahasa memiliki daftar elemen yang telah ditentukan (ini dilakukan untuk kesederhanaan misalnya) dan onChange adalah penangan. Karena kami melakukan SPA, kami ingin mengubah pelokalan ketika pengguna memilih bahasa, oleh karena itu, pada pilihan onChange, bahasa yang dipilih diperiksa, dan jika telah berubah, lokal antarmuka berubah:


  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(); } } 

Itu saja, repositori dengan aplikasi yang berfungsi ada di sini .


Ringkasnya, saya ingin mencatat bahwa akan lebih baik untuk awalnya menyimpan sumber daya dalam format JSON tunggal, yang kami minta dari LocaleController, untuk melepaskannya dari spesifikasi kerangka kerja ASP. Dalam ResxValidatior, orang dapat menambahkan dukungan untuk ekstensibilitas dan aturan khusus khususnya, serta menyoroti kode yang identik dengan locale.service dan menulis ulang dalam gaya JS untuk menyederhanakan dukungan. Namun, dalam contoh ini, saya fokus pada kesederhanaan.

Source: https://habr.com/ru/post/id473776/


All Articles