Nel corso della mia vita da sviluppatore ho realizzato mantenuto ed esteso centinaia e centinaia di applicazioni in ambito .NET. Ogni applicazione e ogni contesto sono per me un nuovo mondo e nuova sfida ma non per questo è necessario affrontarli senza nessun asso nella manica 🙂
Così come un esploratore mette nel suo zaino i suoi strumenti preferiti, anche lo sviluppatore può sfruttare delle librerie già consolidate per affrontare i problemi più comuni e trasversali, come ad esempio le validazioni.
A prescindere dall’applicazione che stiamo per scrivere ci imbatteremo inevitabilmente in un mare di validazioni, controlli numerici, validazioni di stringhe e chi più ne ha ne metta. In questo caso l’asso nella manica si chiama Fluent Validation (qui la documentazione), uno strumento di cui non potrete più fare a meno una volta utilizzato
Nell’articolo di oggi vedremo come utilizzare questa libreria e come ci possa essere utile nei nostri progetti quotidiani.
Cos’è e a cosa serve
FluentValidation è una libreria .NET OpenSource che permette di definire regole di validazione per le tue classi in modo elegante e leggibile, utilizzando un’interfaccia fluent (da qui il nome). Invece di utilizzare gli attributi di validazione tradizionali come [Required]
o [StringLength]
, FluentValidation ti permette di scrivere le regole di validazione con una sintassi simile a Linq.
L’installazione è molto semplice, vi basterà installare il pacchetto nuget a seconda del vostro progetto:
Install-Package FluentValidation
//------------------------------
Install-Package FluentValidation.AspNetCore
I Validatori
La libreria ruota attorno ai cosiddetti validatori, delle classi dove andremo a centralizzare tutte le logiche di validazione delle nostre classi.
Ipotizziamo di avere una classe che rappresenti un prodotto:
public class Product
{
public string Code { get; }
public string Name { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
public int Quantity { get; set; }
}
Vogliamo aggiungere alcune classiche regole di validazione. Creiamo una classe chiamata ProductValidator
, che implementa l’interfaccia AbstractValidator<T>
, dove T
, in questo caso, è il nostro Prodotto.
public class ProductValidator: AbstractValidator<Product>
{
public ProductValidator()
{
}
}
La libreria FluentValidation ci espone già di default tutta una serie di elementi di verifica già sviluppati, nulla toglie ovviamente, che potremo andare a sviluppare un metodo tutto nostro.
A prescindere dalla natura del controllo li andremo ad inserire nel costruttore, in questo caso ProductValidator. Verifichiamo che la property Name non sia vuota:
public class ProductValidator: AbstractValidator<Product>
{
public ProductValidator()
{
RuleFor(product => product.Name).NotEmpty();
}
}
Per eseguire le regole di validazione è molto semplice, ci basterà istanziare (o farci iniettare tramite dependency injection) il nostro validatore e richiamare la funzione Validate()
.
FoodProductValidator fValidator = new FoodProductValidator();
ValidationResult fResult = fValidator.Validate(foodProduct);
I controlli possono essere aggregati, uno dopo l’altro. Aggiungiamo al nostro esempio il controllo che siano stati inseriti almeno 2 caratteri (e che non superi i 250 caratteri):
public class ProductValidator: AbstractValidator<Product>
{
public ProductValidator()
{
RuleFor(product => product.Name)
.NotEmpty()
.Length(2, 250);
}
}
Possiamo anche andare ad aggiungere un messaggio di validazione specifico nel caso in cui le regole di validazione non passino.
public class ProductValidator: AbstractValidator<Product>
{
public ProductValidator()
{
RuleFor(product => product.Name)
.NotEmpty()
.Length(2, 250)
.WithMessage("Name is required and length must be between 2 and 250 chars");
}
}
Questo approccio potrebbe essere molto comodo nel caso in cui la nostra applicazione fosse multilingua, nella funzione .WithMessage
potremmo passare una stringa recuperata da un dizionario di lingua.
Possiamo anche suddividere il messaggio in funzione del controllo: se vuoto allora ritorno un messaggio, se non passa la validazione della lunghezza invece ne mostriamo un altro.
public class ProductValidator: AbstractValidator<Product>
{
public ProductValidator()
{
RuleFor(product => product.Name)
.NotEmpty()
.WithMessage("Name is required");
.Length(2, 250)
.WithMessage("Length must be between 2 and 250 chars");
}
}
Proviamo ora ad implementare un controllo sulla property Price, verificando che sia maggiore di 0.
public class ProductValidator: AbstractValidator<Product>
{
public ProductValidator()
{
RuleFor(product => product.Name)
.NotEmpty()
.WithMessage("Name is required");
.Length(2, 250)
.WithMessage("Length must be between 2 and 250 chars");
RuleFor(product => product.Price)
.GreaterThan(0)
.WithMessage("Invalid price");
}
}
Aggiungiamo infine altre validazioni per le property restanti:
public class ProductValidator: AbstractValidator<Product>
{
public ProductValidator()
{
RuleFor(product => product.Name)
.NotEmpty()
.WithMessage("Name is required");
.Length(5, 250)
.WithMessage("Length must be between 5 and 250 chars");
RuleFor(product => product.Price)
.GreaterThan(0)
.WithMessage("Invalid price");
RuleFor(product => product.Description)
.NotNull()
.NotEmpty()
.WithMessage("Description is required");
RuleFor(product => product.Code)
.NotEmpty()
.WithMessage("Invalid product code");
}
}
Ereditarietà
Le regole di validazione che abbiamo scritto possono anche essere ereditate. Creiamo una nuova classe chiamata FoodProduct
, che erediti la classe Product
.
public class FoodProduct : Product
{
public List<ProductTypeGrouped> QuantityByExpiratioDate { get; set; }
public new int Quantity { get; private set; }
public bool IsFreshProduct { get; set; }
}
public class ProductTypeGrouped(string description, DateTime expiryDate, int quantity)
{
public string TypeDescription { get; set; } = description;
public DateTime ExpiryDate { get; set; } = expiryDate;
public int Quantity { get; set; } = quantity;
}
Abbiamo aggiunto 2 nuove property ed eseguito l’override su Quantity
, in modo tale da gestire le quantità in funzione della scadenza (nel dizionario QuantityByExpiratioDate
).
Per ereditare i controlli di validazioni scritti in ProductValidator ci basterà utilizzare la sintassi RuleFor(p => p).SetValidator(new ProductValidator());
all’inizio della nostra funzione di validazione.
public class FoodProductValidator : AbstractValidator<FoodProduct>
{
public FoodProductValidator()
{
RuleFor(p => p).SetValidator(new ProductValidator());
}
}
In questa situazione vorremmo però eseguire l’override di alcuni controlli, come per esempio i controlli sulla property Name
: niente di più semplice, basterà solamente riscrivere la regola come fatto prima:
public class FoodProductValidator : AbstractValidator<FoodProduct>
{
RuleFor(p => p).SetValidator(new ProductValidator());
RuleFor(x => x.Name)
.Length(10, 50) //--> Previous lenght: between 5 and 250
.WithMessage("Invalid name for this food");
}
Come vedete scrivere dei controlli di validazione è estremamente semplice, soprattutto per chi è già abituato a scrivere sfruttando una sintassi Linq.
Validatori custom
I controlli di default vanno benissimo per tutte le casistiche standard che ogni giorno affrontiamo, ogni progetto porta però delle complessità che necessitano di un controllo specifico, caso per caso. In questi casi possiamo andare a scrivere delle funzioni di validazione custom, sfruttando .Must()
.
Nell’esempio sono andato ad implementare un controllo che verificasse l’inizio del codice in funzione della property .IsFreshProduct
public class FoodProductValidator : AbstractValidator<FoodProduct>
{
public FoodProductValidator()
{
RuleFor(p => p).SetValidator(new ProductValidator());
RuleFor(x => x.Name)
.Length(3, 250)
.WithMessage("Invalid name for this food");
//Code FR_XXX if fresh food
//Code FP_XXX if not fresh food
RuleFor(x => x.Code)
.Must((item, list) =>
{
if (item.IsFreshProduct)
{
return item.Code.StartsWith("FR_");
}
else
{
return item.Code.StartsWith("FP_");
}
})
//Regular expression to match code format "XX_000"
.Matches("[A-Z]{2}_\\d{3}");
}
}
In realtà esiste una maniera più “elegante” per andare a scrivere un controllo custom, ossia quello di andare a creare un metodo riutilizzabile. Anche in questo caso è molto simile agli extension methods di Linq. Proviamo quindi a spostare la logica di validazione in metodo statico.
public static class ProductCustomValidator
{
public static IRuleBuilderOptions<T, TElement> CheckFoodProductCode<T, TElement>(this IRuleBuilder<T, TElement> ruleBuilder, string regexCodeFormat) where TElement : FoodProduct
{
//validation here
}
}
Il metodo CheckFoodProductCode
vive all’interno della classe statica ProductCustomValidator
, dove andremo a centralizzare tutti i controlli custom che dovremo implementare sulla nostra classe.
Spostiamo ora la logica in questo metodo:
public static class ProductCustomValidator
{
public static IRuleBuilderOptions<T, TElement> CheckIfProductCodeHasRightFormat<T, TElement>(this IRuleBuilder<T, TElement> ruleBuilder, string regexCodeFormat) where TElement : FoodProduct
{
Regex r = new Regex(regexCodeFormat);
return ruleBuilder
.NotNull()
.NotEmpty()
//Check format with regular expression (passed as param)
.Must((p) =>
{
return r.IsMatch(p.Code);
})
//Check initial letters
.Must((list) =>
{
if (list.IsFreshProduct)
{
return list.Code.StartsWith("FR_");
}
else
{
return list.Code.StartsWith("FP_");
}
})
.WithMessage("Invalid product code!");
}
}
La nostra classe di validazione diventerà quindi così:
public class FoodProductValidator : AbstractValidator<FoodProduct>
{
public FoodProductValidator()
{
RuleFor(p => p).SetValidator(new ProductValidator());
RuleFor(x => x.Name)
.Length(3, 250)
.WithMessage("Invalid name for this food");
RuleFor(p => p).CheckIfProductCodeHasRightFormat("[A-Z]{2}_\\d{3}");
}
}
I vantaggi sono gli stessi che ho discusso nel mio articolo sugli extension methods di Linq: centralizziamo la logica di controllo in un punto unico – riutilizzabile – e la leggibilità del codice aumenta molto.
Validazioni su liste
Spesso i nostri DTO hanno delle property che in realtà sono liste, fortunatamente FluentValidation ci permette di lavorare anche con questo tipo di dato! Vediamo un esempio su come applicare una validazione sulla property QuantityByExpiratioDate
.
Aggiungiamo un controllo per verificare che non ci sia nessun prodotto scaduto
public class FoodProductValidator : AbstractValidator<FoodProduct>
{
public FoodProductValidator()
{
RuleFor(p => p).SetValidator(new ProductValidator());
RuleFor(x => x.Name)
.Length(3, 250)
.WithMessage("Invalid name for this food");
RuleFor(p => p).CheckIfProductCodeHasRightFormat("[A-Z]{2}_\\d{3}");
RuleForEach(productWithExpiry => productWithExpiry.QuantityByExpiratioDate)
//Error if expired
.Must(pg => pg.ExpiryDate >= DateTime.Now)
.WithMessage((product, pg) => $"Product #{product.Code} '{pg.TypeDescription}' is expired!");
}
}
Tutta la logica che scriveremo all’interno di RuleForEach verrà applicato per ogni elemento nella lista. Proseguiamo il nostro viaggio, e aggiungiamo un altro tassello: le validazioni asincrone.
Validazioni asincrone
Spesso mi è capitato di dover validare alcuni dati in funzione di alcune chiamate API: verificare la validità di un documento sfruttando delle API o recuperare l’informazione di validità di un utente tramite un metodo asincrono
Tutte queste casistiche prevedono di effettuare delle chiamate asincrone: anche un questo caso la libreria ci mette a disposizione diversi metodi asincroni che possiamo richiamare ( es. MustAsync
o WhenAsync
).
Proseguiamo con le validazioni della nostra classe FoodProduct
, per questo esempio andremo a richiamare un’ipotetica API che verifica la disponibilità in magazzino del prodotto. La logica andremo sempre a scriverla nella nostra classe di validazione FoodProductValidator, con la differenza che dovremo passargli come parametro il client http.
Attenzione
In una reale implementazione non avrei passato direttamente il parametro HttpClient
, avrei probabilmente iniettato un’interfaccia sfruttando il meccanismo di Dependency Injection oppure avrei utilizzato un pattern HttpClientFactory per gestire al meglio il pool di connessioni dell’applicazione
public class FoodProductValidator : AbstractValidator<FoodProduct>
{
public FoodProductValidator(HttpClient client)
{
HttpClient _httpClient = client;
RuleFor(p => p).SetValidator(new ProductValidator());
RuleFor(x => x.Name)
.Length(3, 250)
.WithMessage("Invalid name for this food");
RuleFor(p => p).CheckIfProductCodeHasRightFormat("[A-Z]{2}_\\d{3}");
RuleForEach(productWithExpiry => productWithExpiry.QuantityByExpiratioDate)
//Error if expired
.Must(pg => pg.ExpiryDate >= DateTime.Now)
.WithSeverity(Severity.Error)
.WithMessage((product, pg) => $"Product #{product.Code} '{pg.TypeDescription}' is expired!");
RuleFor(p => p.Code).MustAsync(async (productCode, cancellation) =>
{
bool isVailable = false;
var response = await _httpClient.GetAsync($"/api/v1/products/{productCode}/is-available");
//[...] check response
return !isVailable;
}).WithMessage("Product not available");
}
}
ATTENZIONE dal momento che abbiamo utilizzato una regola asincrona nella nostra classe di validazione dovremo SEMPRE utilizzare la funzione di ValidateAsync(), se non lo faremo verrà generata un’exception.
FoodProductValidator fValidator = new FoodProductValidator(client);
ValidationResult fResult = await fValidator.ValidateAsync(foodProduct);
Conclusioni
FluentValidation si dimostra uno strumento estremamente versatile e potente per la gestione delle validazioni in ambiente .NET. La libreria offre numerosi vantaggi:
- Una sintassi fluent intuitiva e familiare per gli sviluppatori che già conoscono LINQ
- La possibilità di ereditare le regole di validazione
- Il supporto per validazioni personalizzate attraverso metodi custom
- La capacità di gestire validazioni su liste e collezioni
- Il supporto per operazioni asincrone, permettendo validazioni che richiedono chiamate API o operazioni di I/O
Grazie alla sua flessibilità e alla facilità d’uso, FluentValidation si rivela un “asso nella manica” indispensabile per qualsiasi sviluppatore .NET che voglia implementare validazioni robuste e mantenibili nei propri progetti. La centralizzazione delle logiche di validazione in classi dedicate, insieme alla possibilità di riutilizzare e comporre le regole, permette di scrivere codice più pulito, testabile e facile da mantenere.