Required!? Dove stiamo andando non c’è bisogno di… Required!

Quando realizziamo una Web API con ASP.NET Core, è prassi decorare le proprietà obbligatorie dei nostri modelli con l’attributo `Required` anche se, in certi casi, questo ci costringe ad accettare dei compromessi nella modellazione. Vedremo quindi una tecnica alternativa: un approccio che sfrutta la nullabilità dei tipi e il deserializzatore JSON per fare a meno di tale attributo. Questo ci porterà anche a valutare lo stato di maturità di `System.Text.Json`.

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.

Il client ha dimenticato di valorizzare il DateTimeOffset che rappresenta la sua destinazione temporale? Fa niente! Mandiamolo al primo gennaio dell’anno 1!

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?

Per fornire un’esperienza di utilizzo migliore a chi si integra con la nostra applicazione, la Web API dovrebbe essere progettata per segnalare puntualmente sia la mancanza di valori obbligatori e sia la presenza di proprietà dal nome sconosciuto.

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.

Aspetta un minuto, mi stai dicendo che hai definito una proprietà obbligatoria… nullabile?

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?

L’obbligatorietà di una proprietà può essere espressa semplicemente scegliendo un tipo non nullabile e definendola come parametro del costruttore.

Questo può essere fatto non solo per i value type come DateTimeOffsetint 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 ?.

ObbligatorioFacoltativo
intint?
boolbool?
string[]string[]?
FluxCapacitorParamsFluxCapacitorParams?

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.

Abbiamo progettato il modello in totale libertà, senza essere minimamente influenzati dalle particolarità dell’infrastruttura sottostante.

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.

Le migliori performance di System.Text.Json sono controbilanciate da carenze nelle funzionalità offerte.

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.

Non esistono soluzioni universalmente valide: dovremmo sempre essere pronti a valutare quale sia la scelta più opportuna per lo specifico progetto che stiamo realizzando.

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.

Il nostro modello resta completamente agnostico del modo in cui verrà deserializzato.

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.

Questa forma di far cortese non è solo un modo fine a sé stesso di migliorare la vita a chi usa il nostro software. È una strategia che riduce efficacemente il numero di richieste di assistenza.

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.

System.Text.Json esporrà nuove API, tra cui quelle per definire i Contract Resolver e richiedere la presenza dei valori per le proprietà obbligatorie.

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.