Su GitHub trovi un progetto dimostrativo in C#.
Il tuo modello! Bisogna fare qualcosa per il tuo modello!
Quando decoriamo una proprietà con l’attributo Required
, stiamo indicando che non potrà assumere valori vuoti o nulli. Prendiamo come esempio il seguente DTO che permette al client di… configurare il suo viaggio nel tempo! Egli saprà di dover obbligatoriamente fornire un valore per la Destination
.
public class TimeTravelRequest
{
[Required]
DateTimeOffset Destination { get; set; }
}
L’obbligatorietà è ben evidente anche nella documentazione Swagger grazie alla presenza di un asterisco rosso di fianco al nome della proprietà
Tuttavia, all’atto pratico, il client potrà inviare un payload JSON in cui tale valore non è presente. La mancanza del dato verrà compensata da un valore di default.
Psst… Riuscirà poi a tornare senza asfalto né rotaie per raggiungere le 88 miglia orarie?
In molte situazioni questo è un problema perché tende a nascondere la presenza di bug: il client avrà scelto di omettere tale proprietà o si sarà trattato di una sua dimenticanza? Non avrà mica sbagliato a digitare il nome?
Vediamo come poter attuare entrambe le cose.
Ci… dobbiamo… riuscire
La soluzione più facile ma anche più problematica consiste nel rendere la proprietà nullabile, ovvero aggiungere il simbolo ?
come suffisso del tipo.
public class TimeTravelRequest
{
[Required]
DateTimeOffset? Destination { get; set; }
}
Infatti, se eseguiamo di nuovo la richiesta, ora il client verrà informato del problema perché il valore di default per DateTimeOffset?
è null
, che viene espressamente invalidato dalla presenza dell’attributo Required
.
Per quanto immediata che sia, questa soluzione però porta con sé un effetto indesiderato.
Pur di “assecondare” questo funzionamento, abbiamo accettato uno scomodo compromesso che tornerà a confondendoci le idee in altri punti dell’applicazione, soprattutto perché la presenza dell’attributo Required
non si evince dall’intellisense.
I noi stessi del futuro penseranno: “Uhm, perché accetta valori null? Non era mica obbligatoria questa proprietà?”
Accidenti, ho rotto il modello
Accettare (molti) compromessi come questo spesso vuol dire rallentare lo sviluppo e rendere più probabile l’introduzione di bug. Quale altra tecnica potremmo usare?
Questo può essere fatto non solo per i value type come DateTimeOffset
, int
e bool
ma anche per qualsiasi reference type come gli Array
, le List
, i Dictionary
o qualsiasi altra classe definita da noi. Abbiamo il potere di decidere cosa sia facoltativo o obbligatorio semplicemente usando (o non usando) il suffisso ?
.
Obbligatorio | Facoltativo |
---|---|
int | int? |
bool | bool? |
string[] | string[]? |
FluxCapacitorParams | FluxCapacitorParams? |
Da C# 8, infatti, possiamo abilitare una funzionalità chiamata Nullable Reference Types aggiungendo quanto segue nel file di progetto .csproj
. Questa è anche l’impostazione predefinita che Microsoft ha scelto di usare nei nuovi template di progetto per .NET 6.
<PropertyGroup>
<Nullable>enable</Nullable>
</PropertyGroup>
Inoltre, usando i record introdotti con C# 9, possiamo usare una sintassi molto succinta per tenere separate le proprietà obbligatorie (che saranno anche parametri del costruttore) da quelle facoltative. Con questo accorgimento non avremo più bisogno di ricorrere all’attributo Required
.
public record TimeTravelConfiguration(
// Proprietà obbligatorie: sono definite come parametri del costruttore
DateTimeOffset Destination)
{
// Proprietà facoltative
public FluxCapacitorParams? OverrideDefaultParams { get; init; }
}
ASP.NET Core è perfettamente in grado di lavorare con i record e con le proprietà init-only usate nell’esempio. Così, avendo costruito un modello immutabile, rappresentiamo più fedelmente la volontà – che non può essere cambiata – del client per la richiesta corrente.
Questo rende possibile liberare il modello: il deserializzatore!
Ora dobbiamo concentrarci sulla parte infrastrutturale di ASP.NET Core, che deve essere configurata affinché sia il più trasparente possibile e assecondi le nostre scelte di progettazione.
Il nostro primo obiettivo è imporre al client di fornire tutti i valori obbligatori. Se non lo facesse, la nostra Web API gli restituirebbe un errore 400 Bad Request
.
Questo è un compito che possiamo affidare al deserializzatore JSON. Essendo ASP.NET Core un framework estremamente estendibile, possiamo scegliere liberamente il deserializzatore da usare. Tipicamente la scelta cade su una delle due soluzioni principali:
Newtonsoft.Json
, il prodotto più maturo e “storico” che ha accompagnato ASP.NET Web API sin dal .NET Framework;System.Text.Json
, il sostituto più recente, introdotto da Microsoft con ASP.NET Core 3.0 e sviluppato con particolare attenzione alle performance.
Nella documentazione si trova una tabella comparativa che mostra come System.Text.Json
sia privo di alcune funzionalità, a cui a volte sopperisce con laboriosi workaround.
A causa di queste limitazioni non ci è possibile, ad oggi, usare System.Text.Json
per mettere in atto la soluzione proposta da questo articolo,
Vediamo invece cosa può offrirci Newtonsoft.Json
.
La nostra unica possibilità di riparare il presente è… nel passato
Ricorrere a Newtonsoft.Json
in nuove applicazioni è ancora possibile e possiamo sceglierlo quando, in casi come questo, ci interessa beneficiare delle sue maggiori funzionalità pur accettando un calo prestazionale che andremo a quantificare.
Iniziamo aggiungendo il riferimento al pacchetto NuGet del serializzatore e al pacchetto per l’integrazione con ASP.NET Core.
dotnet add package Newtonsoft.Json
dotnet add package Microsoft.AspNetCore.Mvc.NewtonsoftJson
Registriamo i servizi in corrispondenza della chiamata a AddControllers
chi si trova nel metodo ConfigureServices
della classe Startup
. Se stiamo usando il nuovo Minimal Hosting Model introdotto con .NET 6, troveremo la stessa chiamata nella classe Program
.
services
.AddControllers()
.AddNewtonsoftJson(options =>
{
// Imponiamo al client di fornire tutti i valori obbligatori usando un Contract Resolver
options.SerializerSettings.ContractResolver = new RequirePropertiesContractResolver();
// Impediamogli anche di fornire proprietà dal nome sconosciuto
options.SerializerSettings.MissingMemberHandling = MissingMemberHandling.Error;
});
L’oggetto RequirePropertiesContractResolver
ci permette appunto di intervenire nella logica di deserializzazione. Ad esempio, agendo sul JsonObjectContract
che rappresenta il nostro modello, possiamo richiedere la presenza dei valori per le sue proprietà obbligatorie. Mettiamolo in pratica nel seguente esempio (la versione completa è su GitHub).
public class RequirePropertiesContractResolver : DefaultContractResolver
{
protected override JsonObjectContract CreateObjectContract(Type objectType)
{
// Otteniamo un JsonObjectContract che rappresenta il modello
JsonObjectContract contract = base.CreateObjectContract(objectType);
// E poi modifichiamolo in modo che i valori per le sue proprietà debbano essere forniti
SetAllPropertiesAsRequired(objectType, contract);
return contract;
}
private void SetAllPropertiesAsRequired(Type objectType, JsonObjectContract contract)
{
// Iteriamo attraverso tutte le proprietà del modello
foreach (var property in contract.Properties)
{
// Per ciascuna, indichiamo che la sua presenza nel payload è richiesta
property.Required = GetRequiredForProperty(property);
}
}
private Required GetRequiredForProperty(JsonProperty jsonProperty)
{
// Otteniamo un riferimento alla proprietà tramite reflection
PropertyInfo propertyInfo = jsonProperty.DeclaringType.GetProperty(jsonProperty.UnderlyingName, BindingFlags.Public | BindingFlags.Instance);
// Se è nullabile, consentiamo al client di passare un valore null ma non potrà ometterla
// Se non è nullabile, allora non potrà passare valori null né ometterla
return IsNullable(propertyInfo) ? Required.AllowNull : Required.Always;
}
private bool IsNullable(PropertyInfo propertyInfo)
{
// Determiniamo se la proprietà è nullabile o no
NullabilityInfoContext nullabilityContext = new();
NullabilityInfo info = nullabilityContext.Create(propertyInfo);
return info.ReadState == NullabilityState.Nullable;
}
}
La API NullabilityInfoContext
, una novità di .NET 6, ci permette di conoscere a runtime se una proprietà sia nullabile o meno. Infatti, questa informazione non è ottenibile osservando solo il tipo della proprietà.
Realizzare un Contract Resolver come quello mostrato nell’esempio ci permette di centralizzare la logica di deserializzazione.
Se ti ci metti con impegno, raggiungi qualsiasi risultato!
Dato che tutti i componenti sono al loro posto, il client otterrà un errore sia che ometta valori obbligatori, sia che fornisca proprietà inesistenti. Obiettivo raggiunto!
La nostra Web API adesso sta fornendo un valido aiuto a coloro che si integrano con la nostra applicazione perché renderà evidenti, in maniera precoce, gli eventuali errori nel payload.
Siamo sul pesante (?)
Davvero? Verifichiamolo! Ricordiamoci di misurare il costo prestazionale delle scelte architetturali che prendiamo, soprattutto se coinvolgono la reflection come in questo caso.
Usando BenchmarkDotNet e la classe WebApplicationFactory possiamo misurare le prestazioni grezze dei deserializzatori senza coinvolgere logica applicativa.
Osservando il grafico, possiamo trarre alcune conclusioni:
System.Text.Json
offre performance indubbiamente migliori, frutto del lavoro di ottimizzazione di Microsoft;Newtonsoft.Json
è comunque un buon prodotto, in grado di sostenere un volume di migliaia di richieste al secondo (in base all’hardware e ai core a disposizione);- Abilitando i controlli sulle proprietà, otteniamo comunque prestazioni accettabili per gran parte dei casi d’uso.
Per ottenere questo risultato, il progetto dimostrativo attua una strategia di caching dei JsonObjectContract
. Dopo la loro iniziale creazione, infatti, questi oggetti possono essere riutilizzati tali e quali a ogni successiva richiesta HTTP.
Il prossimo novembre ti rimanderò indietro… nel futuro!
Come è tipico per ogni rilascio, anche .NET 7 porterà con sé numerose novità quando verrà ufficialmente rilasciato a novembre 2022.
L’elenco di funzionalità pianificate per System.Text.Json in .NET 7 dimostra come Microsoft voglia, a poco a poco, colmare il divario funzionale con Newtonsoft.Json
.
Perciò, possiamo aspettarci che in futuro la tecnica mostrata in questo articolo possa essere messa in pratica usando System.Text.Json
con performance migliori, date le ottimizzazioni certosine fatte da Microsoft di rilascio in rilascio.
Che ne pensi, ti piace questa soluzione? Guarda come è stata implementata nel progetto dimostrativo su GitHub.