Egy alkalmazás fejlesztésekor óhatatlan, hogy a felhasználótól közvetlenül vagy közvetett formában adatokat olvassunk. Ezen adatokat ellenőrizni kell használat előtt, hogy megfelelnek-e a feltételezéseinknek és megfelelő formában vannak-e. Ezt a folyamatot nevezzük validációnak. Ez minél később történik meg, annál drágább. De miért is? Tételezzük fel, hogy egy webshopot készítünk, ami a szállításhoz kér egy irányítószámot. Ha ezt csak a rendelés végén ellenőrizzük, vagy még rosszabb módon közvetlen a kiszállításhoz használt címke nyomtatása előtt, akkor a felhasználóval újra fel kell venni a kapcsolatot, manuálisan be kell kérni az adatokat. Ez egy rossz felhasználói élményt eredményez, aminek az következménye lehet az lesz, hogy törli inkább a megrendelését, de az biztos, hogy az e-mail üzenet kiküldése és az adatok újra bekérése lassítja a folyamatot, ami értékes időkiesést okoz.
Fontos megjegyezni, hogy a validáció nem helyettesíti a hibakezelést. Egymás kiegészítői. Ha jó a validációnk, akkor igaz, hogy ideális esetben sosem fognak lefutni, de mi van akkor, ha változik a logika vagy a validációs szabályok? Ebben az esetben a hibakezelő rutinjaink meg fogják fogni a hibát és tudni fogjuk, hogy valahol elfelejtettük módosítani a kódunkat.
Továbbá fontos kiemelni, hogy ha egy szerver-kliens alkalmazást fejlesztünk, akkor a validációnak a szerver oldalon mindenképpen meg kell történnie, mivel a klienst nem mi kontrolláljuk, ezért az „hazudhat” az adatok állapotáról és küldhet amúgy nem helyes adatokat. Felhasználói élmény szempontjából azonban célszerű a kliensen is validálni, de ez sosem helyettesítheti a szerver oldali validációt. Illetve minden olyan adatot ellenőriznünk kell, amit a felhasználó kontrollálhat.
Például, ha egy konfigurációs fájlt használunk, amit szerkeszthet a felhasználó, akkor annak az értékeit is érdemes ellenőrizni.
A validáció a szituációtól függően többféleképpen történhet. Például ha egy XML vagy JSON adatot fogadunk, akkor az alap validációt XSD vagy JSON Schema segítségével is meg tudjuk oldani. Ha pedig tényleges felhasználói bemenet (pl. konzolról bekért számok) validációjáról van szó, akkor ez történhet egy if-else vezérlési szerkezettel is. Szerencsére azonban .NET esetén nem kell újra feltalálnunk a kereket, mivel az biztosít számunkra osztályokat ennek a feladatnak az elvégzésére.
A .NET megoldásának alapötlete az, hogy készítünk egy modell osztályt, ami leírja a felhasználói bemenetet, amit validálni akarunk. Ennek az osztálynak a tulajdonságait pedig annotáljuk attribútumokkal, amik a megkötéseket, szabályokat fejezik ki.
A validációs szabályok magában az attribútumokban kerülnek leimplementálásra és egy validátor osztály futtatja ezeket le és szedi össze a hibaüzeneteket, amit alkalomadtán meg tudunk jeleníteni a felhasználói felületen.
Validációs logika bonyolítása
A validáció szituációtól függően lehet egyszerű, de akár komplex logika is. Például egy e-mail cím ellenőrzésénél történhet ez „egyszerű” reguláris kifejezéssel, ami megmondja egy szövegről, hogy megfelel-e egy adott formátumnak. De történhet komplexebb módon is. Például a uservagyok@tesztelek.com megfelel a formai követelményeknek, de a tesztelek.com nem biztos, hogy egy létező domain, vagy ha létezik is, csak akkor képes e-mail küldésre és fogadásra, ha van egy MX rekord1 konfigurálva. Ezen ellenőrzések elvégzése csupán csak azt biztosítja, hogy a tesztelek.com létezik és képes e-mail üzenetek küldésére és fogadására, de nem garantálja azt, hogy a uservagyok felhasználó ténylegesen egy létező felhasználó. A lényeg azonban ebből szerintem az, hogy a validáció az üzleti követelményektől függően lehet egyszerű, de akár komplex feladat is. Illetve nem utolsó sorban időt vesz igénybe és ez egy blokkoló folyamat. Vagyis addig, amíg validálunk, további bevitelt nem engedélyezünk. A hálózat lassúságának következtében ez a folyamat a fenti példában eltarthat akár 30 másodpercig is, ami szintén nem egy jó felhasználói élményt eredményez.
Éppen ezért érdemes meghúzni a határt, hogy csak a minimálisan szükséges szabályokat implementáljuk le és minden esetben gondoljuk át, hogy kell-e az adott validáció? PEsetünkben az e-mail példánál maradva, ha azt szeretnénk kivédeni, hogy a user nehogy véletlenül rossz címet adjon meg, akkor egy olcsóbb validáció lehet, hogy kétszer kérjük be a mail címét és a mezők formátum ellenőrzése mellett azt is figyeljük, hogy a két mező egyezik-e. Ilyen módon a véletlen elgépeléseket kivédhetjük.
Single responsibility
Felmerülhet a kérdés, hogy a validáció nem sérti-e a Single responsibility-t? Például ha azt mondjuk, hogy csinálunk egy IValidatable interfészt, amit implementálnia kell minden modell osztálynak, akkor lényegében a modell két felelősséggel rendelkezik. Egyrészt feladata az adatok modellezése és másrészt azok helyességének, integritásának biztosítása. Ez szorosan vett definíció szerint sérti a single responsibility elvet, de ennek a megközelítésnek is van előnye. Egyértelmű előny ezen megoldás mellett, hogy egy helyen van az igazság forrása és egy helyen kell csak hozzányúlni a kódhoz, ha módosítani kell. Hátrány természetesen az, hogy ha a logika bonyolult vagy sok adat van az osztályban, akkor a validáció igencsak nagyra tud hízni. Éppen ezért jó választ arra, hogy melyik megközelítést érdemes alkalmazni, szituációfüggő.
A .NET megoldása az attribútumokkal a szó szoros értelmében követi az egy felelősség elvét, mert minden validációs osztály csak egy validációért felel, illetve minden modell csak az adatokért. A kettő között a kapcsolatot pedig az annotáció biztosítja.
Tágabb értelmezésben azonban az attribútumok a tulajdonságok részei, hiszen szabályokat fogalmaznak meg rájuk nézve. Lényeg az, hogy validáció esetén nem biztos, hogy minden helyen érvényesíteni kell az egy felelősség elvet, de ez nem feltétlen baj, mert biztonsági szempontból lehet többet ártanánk azzal, hogy ha szétszórva szerepelne a kódban ez a logika.
.NET implementáció
A .NET esetén a validációs attribútumok a ValidationAttribute osztályból kell, hogy származzanak. Ez a System.ComponentModel.DataAnnotations névtérben található meg. Ez a névtér számos előre definiált osztályt tartalmaz a validációhoz, nem kell minden nekünk egymagunknak leimplementálni.
A fontosabb validációs osztályok:
-
RequiredAttribute
Azt jelzi, hogy az adott tulajdonság kitöltése kötelező, nem opcionális.
-
AllowedValuesAttribute
Az attribútum konstruktorában meghatározott értékek közül kell kikerülnie a tulajdonság értékének.
-
DeniedValuesAttribute
Az attribútum konstruktorában meghatározott értékek egyike sem lehet a tulajdonság értéke.
-
Base64StringAttribute
A
stringtípusú tulajdonságnak Base64 kódolásúnak kell lennie. -
LengthAttribute
Egy
stringvagy tömb típusú tulajdonság elvárt hosszát határozza meg. -
MaxLengthAttribute
Egy
stringvagy tömb típusú tulajdonság maximális hosszát határozza meg. -
MinLengthAttribute
Egy
stringvagy tömb típusú tulajdonság minimális hosszát határozza meg. -
RangeAttribute
Egy numerikus érték esetén az elfogadott értéktartományt határozza meg.
-
RegularExpressionAttribute
Egy
stringtípusú tulajdonság esetén a szövegnek illeszkednie kell az attribútumban megadott reguláris kifejezésre. -
EmailAddressAttribute
Egy
stringtípusú tulajdonságnak e-mail címnek kell lennie. -
CreditCardAttribute
Egy
stringtípusú tulajdonságnak hitelkártya számnak kell lennie. -
PhoneAttribute
Egy
stringtípusú tulajdonságnak telefonszámnak kell lennie.
Nézzünk egy példát a validációra és saját attribútumok létrehozására:
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Reflection;
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class NotNullOrEmptyAttribute : ValidationAttribute
{
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
if (value is not string str)
throw new InvalidOperationException($"{nameof(NotNullOrEmptyAttribute)} is valid on string properties");
if (string.IsNullOrWhiteSpace(str))
return new ValidationResult("The field cannot be null or empty.", new string[] { validationContext.MemberName });
return ValidationResult.Success;
}
}
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public class MustMatchAttribute : ValidationAttribute
{
public string OtherPropertyName { get; }
public MustMatchAttribute(string otherPropertyName)
{
OtherPropertyName = otherPropertyName;
}
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
validationContext.Items.TryGetValue(OtherPropertyName, out var otherValue);
if (value is not string sringValue)
throw new InvalidOperationException($"{nameof(MustMatchAttribute)} is valid on string properties");
if (otherValue is not string otherPropertyValue)
throw new InvalidOperationException($"{nameof(MustMatchAttribute)} requires the other property to be a string");
if (sringValue != otherPropertyValue)
{
return new ValidationResult( $"The field must match the {OtherPropertyName} field.", new string[] { validationContext.MemberName });
}
return ValidationResult.Success;
}
}
public class User
{
[NotNullOrEmpty]
public string Name { get; set; }
[Range(14, 120, ErrorMessage = "Age must be between 0 and 120.")]
public int Age { get; set; }
[EmailAddress]
[MustMatch(nameof(EmailConfirm))]
public string Email { get; set; }
[EmailAddress]
[MustMatch(nameof(Email))]
public string EmailConfirm { get; set; }
}
internal class Program
{
private static bool TryValidate<T>(T @object, out List<ValidationResult> results) where T : notnull
{
ValidationContext context = new ValidationContext(@object,
serviceProvider: null,
items: GetProperties(@object));
results = new List<ValidationResult>();
return Validator.TryValidateObject(instance: @object,
validationContext: context,
validationResults: results,
validateAllProperties: true);
}
private static IDictionary<object, object> GetProperties<T>(T @object)
{
Dictionary<object, object> propertyNamesAndValues = new();
var properties = typeof(T).GetProperties(BindingFlags.Instance | BindingFlags.Public);
foreach (var property in properties)
{
var value = property.GetValue(@object);
if (value != null)
{
propertyNamesAndValues.Add(property.Name, value);
}
}
return propertyNamesAndValues;
}
private static void Main(string[] args)
{
var invalidUser = new User
{
Age = 11,
Email = "foo@bar.com",
EmailConfirm = "foo@baz.bom",
Name = "",
};
if (!TryValidate(invalidUser, out List<ValidationResult> issues))
{
foreach (var issue in issues)
{
Console.WriteLine($"{string.Join(',', issue.MemberNames)}: {issue.ErrorMessage}");
}
}
else
{
Console.WriteLine("User is valid.");
}
}
}
A kódban a User osztály írja le a validálni kívánt adatainkat. Jelen esetben egy felhasználó alap adatait. Azt szeretnénk elérni, hogy a felhasználó neve ne legyen üres, az életkora 14 és 120 közötti kell, hogy legyen és az E-mail címét jól adta meg.
Saját attribútumot a ValidationAttribute osztályból örököltetéssel tudunk létrehozni. Ennek van egy IsValid metódusa, amit felülírva a logikánkat le tudjuk implementálni. A metódus első paramétere maga a tulajdonság értéke, a második pedig egy ValidationContext típusú objektum. Ezen keresztül a teljes objektumot, amit validálunk, el tudjuk érni, illetve ha van dependency injection keretrendszerünk bekötve az alkalmazásunkba, akkor szolgáltatásokat is el tudunk érni rajta keresztül. Ha hiba történik, akkor egy ValidationResult típusban vissza kell adnunk a hibát, amihez a tulajdonság nevét is illik csatolni a második paraméterének kitöltésével. Ha a tulajdonság validálása során nem történt hiba, akkor a metódusnak a ValidationResult.Success értékkel kell visszatérnie.
Az olyan validációk, amelyek több tulajdonság együttesére függenek rá, nehezen kivitelezhetőek és nincs igazán szép megoldás ezzel a módszerrel. Mivel a második vagy harmadik függőség értékét a ValidationContext-ből az Items tulajdonságon keresztül tudjuk lekérni, ami Dictionary<object, object> típusú. Ezt láthatjuk a MustMatchAttribute implementációjában.
A tényleges validációt a Validator osztály TryValidateObject statikus metódusával tudjuk megtenni. Ennek az első paramétere az objektum, amit validálni akarunk, a második paramétere egy ValidationContext, amit nekünk kell létrehozni, a harmadik paramétere egy lista, amibe az eredményeket teszi és végezetül az utolsó bool típusú paramétere azt jelzi, hogy az objektum minden tulajdonságát validálni szeretnénk-e. Ha ez hamis értékű, akkor csak azon tulajdonságok kerülnek ellenőrzésre, amelyek meg lettek jelölve a [Required] attribútummal.
-
A Mail Exchanger record egy speciális típusú DNS (Domain Name System) rekord, amely azt határozza meg, hogy mely levelezőszerver(ek) felelősek egy adott domain nevében érkező e-mailek fogadásáért.↩