Validační atributy, umožňující ověřit, že zadávané hodnoty odpovídají požadovaným omezením, jsou velmi užitečná věc používaná napříč .NETem. V tomto článku se podíváme na jejich pokročilejší tvorbu a také na to, jak v nich využívat dependency injection.
Jednoduchý validační atribut
Tvorba jednoduchého validačního atributu je, inu, jednoduchá. Podívejte se třeba na atribut z knihovny Altairis Validation Toolkit který ověřuje platnost IČO. Kompletní zdroják najdete na GitHubu, ale v zásadě stačí pár kroků:
Za prvé, vytvořte novou třídu, jejíž název bude končit slovem Attribute
, zde např. IcoAttribute
. Při použití se pak přípona Attribute
nepoužívá, takže atribut bude použit jako [Ico]
.
Za druhé, Odekorujte třídu atributem AttributeUsage
takto:
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
To není absolutně nezbytné, ale nedává smysl používat náš atribut jinde než na vlastnosti nebo ho používat vícekrát, takže omezením použití zabráníme nesmyslné aplikaci.
Za třetí, poděďte třídu od ValidationAttribute
.
Za čtvrté, označte třídu jako sealed
, což znemožní z ní dále dědit. Není to nezbytné, ale je to doporučené, z důvodů bezpečnostních a výkonnostních.
Za páté, vytvořte obvyklé konstruktory s výchozí chybovou hláškou a možností její změny.
No a konečně, přepište jednoduchý overload metody IsValid
, který bere jako argument validovanou hodnotu a vrací true
nebo false
podle toho, zda je hodnota validní nebo ne. Zdrojový kód jednoduchého atributu IcoAttribute je následující:
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class IcoAttribute : ValidationAttribute {
public IcoAttribute()
: this("The field {0} must be valid IČO (identification number of person).") { }
public IcoAttribute(Func<string> errorMessageAccessor)
: base(errorMessageAccessor) { }
public IcoAttribute(string errorMessage)
: base(errorMessage) { }
public override bool IsValid(object value) {
// Empty values are valid - use RequiredAttribute instead
var s = value as string;
if (string.IsNullOrWhiteSpace(s)) return true;
// IČO is 8 digits, pad with zeroes when not
if (!Regex.IsMatch(s, "^[0-9]{1,8}$")) return false;
s = s.PadLeft(8, '0');
// Calculate sum of digits
var sum = 0;
for (var i = 0; i < 7; i++) {
sum += int.Parse(s[i].ToString()) * (8 - i);
}
// Verify checksum number
var chs = 11 - (sum % 11);
return chs.ToString().EndsWith(s.Substring(7), StringComparison.Ordinal);
}
}
Složitější atribut s použitím ValidationContext
Tento jednoduchý přístup ale v řadě případů nestačí. Někdy potřebujeme znát víc, než jednu hodnotu. K tomuto účelu slouží třída ValidationContext
a jiný overload metody IsValid
. Pokud ho chcete využít, musíte v první řadě zařídit, aby vlastnost RequiresValidationContext
vracela true
:
public override bool RequiresValidationContext => true;
Poté přepište jiný overload metody IsValid, který kromě validované hodnoty dostane i ValidationContext
a vrací nikoliv boolean, ale instanci třídy ValidationResult
. Pomocí třídy ValidationContext
se můžete dostat i k jiným vlastnostem validované entity a podobně.
Podívejte se na zdroják GreaterThanAttribute
na GitHubu. Tento atribut porovnává hodnotu s hodnotou jiné vlastnosti.
Použití DI
Nebývá úplně časté používat ve validačních atributech dependency injection, ale možné to je. Typický příklad najdete v nejnovější verzi atributu pro validaci čísla bankovního účtu, který jsem popisoval nedávno. V jeho první verzi jsem použil řešení, na které jsem nebyl moc hrdý, totiž seznam kódů bank je natvrdo zadaný do zdrojáku. Druhá verze je lepší.
Vytvořil jsem jednoduchý interface IBankCodeValidator
, který definuje jedinou metodu Validate
, která na vstupu dostane string
(kód banky) a vrací true
nebo false
, podle toho zda jde o validní kód banky nebo ne:
public interface IBankCodeValidator {
bool Validate(string code);
}
Jednoduchou implementaci pak představuje třída StaticBankCodeValidator
, která má hardcoded seznam validních kódů:
public class StaticBankCodeValidator : IBankCodeValidator {
// Bank codes avaliable from https://www.cnb.cz/cs/platebni-styk/.galleries/ucty_kody_bank/download/kody_bank_CR.csv
// Valid as of 2020-07-01
private static readonly string[] BankCodes = {
"0100","0300","0600","0710","0800","2010","2020","2030","2060","2070",
"2100","2200","2220","2240","2250","2260","2275","2600","2700","3030",
"3050","3060","3500","4000","4300","5500","5800","6000","6100","6200",
"6210","6300","6700","6800","7910","7940","7950","7960","7970","7980",
"7990","8030","8040","8060","8090","8150","8190","8198","8199","8200",
"8215","8220","8225","8230","8240","8250","8255","8260","8265","8270",
"8272","8280","8283","8291","8292","8293","8294","8296" };
public bool Validate(string code) => BankCodes.Contains(code);
}
Součástí knihovny Altairis Validation Toolkit jsou tři implementace:
StaticBankCodeValidator
je popsán výše. Nemá žádné externí závislosti, ale v případě změny seznamu kódů bude nezbytné, abych jej změnil, vydal novou verzi a uživatelé knihovnu aktualizovali. Není to moc elegantní řešení, ale pokládám jej za použitelné, protože seznam kódů jako takových se nemění příliš často.EmptyBankCodeValidator
prostě na jakýkoliv kód vrátítrue
a validaci kódu banky neprovádí vůbec. To může mít smysl při prácí s historickými daty, protože zatímco algoritmus výpočtu kontrolní číslice předčíslí a vlastního čísla účtu se nemění, kód banky může ze seznamu smizet, pokud nějaká banka zanikne nebo se sloučí s jinou, jako například když se eBanka sloučila s RaifeeisenBank a její kód2400
byl vyřazen a místo toho se používá5500
.OnlineBankCodeValidator
si jednou za den stáhne z webu ČNB seznam kódů bank a používá ho. Z hlediska změn je to nejlepší řešení, ale vyžaduje aby měl server přístup na Internet a je nutné řešit co v případě nedostupnosti seznamu. Moje řešení je, že pokud se do pěti sekund nepodaří seznam stáhnout, prohlásí se kód banky vždy za validní.
Použití je pak snadné, stačí zaregistrovat patřičnou implementaci do IoC/DI kontajneru. To můžete pro ten výchozí v .NET Core udělat jedním řádkem v metodě ConfigureServices
třídy Startup
:
services.AddSingleton<IBankCodeValidator>(new OnlineBankCodeValidator());
Jenomže jak zařídit, aby validační atribut dokázal patřičnou službu získat? Obvykle se v .NET Core používá constructor dependency, kdy se požadované služby předávají jako argumenty konstruktoru. Jenomže tento postup nelze v případě atributů použít.
Nicméně ValidationContext
nabízí též metodu GetService
, která umožní získat instanci potřebné služby. Toho využívám ve třídě CzechBankAccountAttribute
.
Nejprve si definuji privátní field bankCodeValidator
a přidám mu výchozí hodnotu:
private IBankCodeValidator bankCodeValidator = new StaticBankCodeValidator();
Ve složitějším overloadu metody IsValid
se pokusím získat službu registrovanou v DI a zavolám jednodušší overload téže metody, který provede vlastní validaci:
protected override ValidationResult IsValid(object value, ValidationContext validationContext) {
this.bankCodeValidator = (IBankCodeValidator)validationContext.GetService(typeof(IBankCodeValidator)) ?? this.bankCodeValidator;
return this.IsValid(value)
? ValidationResult.Success
: new ValidationResult(this.FormatErrorMessage(validationContext.MemberName), new string[] { validationContext.MemberName });
}
Používám tam poněkud zvláštní konstrukci proměnná = Metoda() ?? proměnná
. Jejím účelem je zajistit, aby se proměnná aktualizovala jenom v případě, že metoda vrátí něco jiného než null
, např. pokud v kontajneru není nic zaregistrováno. Je to jednodušší ekvivalent následujícího zápisu:
var valService = (IBankCodeValidator)validationContext.GetService(typeof(IBankCodeValidator));
if(valService != null) this.bankCodeValidator = valService;
Acknowledgements: Inspirací pro tento článek mi byl post Injecting services into ValidationAttributes in ASP.NET Core od Andrewa Locka.