Giocare con i generatori di sorgenti System.Text.Json

Giocare con i generatori di sorgenti System.Text.Json

Nel mio lavoro quotidiano, sto acquisendo familiarità con i dettagli dell'utilizzo di System.Text.Json. Per chi non avesse familiarità con questa libreria, è stata rilasciata insieme a .NET Core 3.0 come libreria di serializzazione JSON integrata.

Al suo rilascio, System.Text.Json era piuttosto semplice nel suo set di funzionalità, progettato principalmente per gli scenari ASP.NET Core per gestire la formattazione di input e output da e verso JSON. La libreria è stata progettata per essere performante e ridurre le allocazioni per scenari comuni. La migrazione a System.Text.Json ha aiutato ASP.NET Core a continuare a migliorare le prestazioni del framework.

Da quella versione originale, il team continua ad espandere le funzionalità di System.Text.Json, supportando scenari utente più complessi. Nella prossima major release del client Elasticsearch .NET, il mio obiettivo è passare interamente a System.Text.Json per la serializzazione.

Oggi, v7.x utilizza una variante interiorizzata e modificata di Utf8Json, una precedente libreria JSON ad alte prestazioni che purtroppo non viene più mantenuta. Utf8Json è stato inizialmente scelto per ottimizzare le applicazioni effettuando un numero elevato di chiamate a Elasticsearch, evitando il maggior sovraccarico possibile.

Il passaggio a System.Text.Json nella prossima versione ha il vantaggio di continuare a ottenere (de)serializzazione ad alte prestazioni e bassa allocazione dei nostri oggetti di richiesta e risposta fortemente tipizzati. Poiché è relativamente nuovo, sfrutta ancora di più le ultime API ad alte prestazioni all'interno di .NET. Inoltre, significa che passiamo a una libreria supportata da Microsoft e ben mantenuta, che viene fornita "nella confezione" per la maggior parte dei consumatori che utilizzano .NET Core e quindi non richiede dipendenze aggiuntive.

Questo ci porta all'argomento del post di oggi, in cui esplorerò brevemente una nuova funzionalità incentrata sulle prestazioni in arrivo nella prossima versione di System.Text.Json (incluso in .NET 6), generatori di sorgenti. Non perderò tempo a spiegare la motivazione di questa funzione qui. Ti consiglio invece di leggere il post sul blog di Layomi, “Prova il nuovo generatore di sorgenti System.Text.Json“, spiegandolo nel dettaglio. In breve, il team ha sfruttato le capacità del generatore di sorgenti nel compilatore C# 9 per ottimizzare parte dei costi di runtime della (de)serializzazione.

I generatori di sorgenti offrono una tecnologia estremamente interessante come parte del compilatore Roslyn, consentendo alle librerie di eseguire analisi del codice in fase di compilazione ed emettere codice aggiuntivo nella destinazione della compilazione. Ci sono già alcuni esempi di dove questo può essere utilizzato nel post del blog originale che introduce la funzione.

Il team di System.Text.Json ha sfruttato questa nuova capacità per ridurre il costo di runtime della (de)serializzazione. Uno dei compiti di una libreria JSON è che deve mappare il JSON in ingresso sugli oggetti. Durante la deserializzazione, deve individuare le proprietà corrette per cui impostare i valori. Parte di questo si ottiene attraverso la riflessione, un insieme di API che ci consentono di ispezionare e lavorare con le informazioni sul tipo.

La riflessione è potente, ma ha un costo in termini di prestazioni e può essere relativamente lenta. La nuova funzionalità in System.Text.Json 6.x consente agli sviluppatori di abilitare i generatori di sorgenti che eseguono questo lavoro in anticipo durante la compilazione. È davvero molto brillante in quanto rimuove la maggior parte del costo di runtime della serializzazione da e verso oggetti fortemente tipizzati.

Questo post non sarà il mio solito stile di immersione profonda. Tuttavia, dal momento che ho sperimentato la nuova funzionalità, ho pensato che sarebbe stato utile condividere uno scenario reale per sfruttare i generatori di sorgenti System.Text.Json per aumentare le prestazioni.

Lo scenario

Uno degli scenari comuni che i consumatori del client Elasticsearch devono completare è l'indicizzazione dei documenti in Elasticsearch. L'API index accetta una semplice richiesta che include il JSON che rappresenta i dati da indicizzare. Il tipo IndexRequest, quindi, include una singola proprietà Document di un tipo TDocument generico.

A differenza di molti altri tipi di richiesta definiti nella libreria, quando si invia la richiesta al server, non si vuole serializzare il tipo di richiesta stesso (IndexRequest), ma solo l'oggetto TDocument. Non entrerò nel codice esistente per questo qui in quanto confonderà le acque e non è così rilevante per il punto principale di questo post. Invece, lascia che ti spieghi brevemente come questo viene implementato in forma di prototipo in questo momento, che comunque non è così dissimile dall'attuale base di codice.

public interface IProxyRequest
{
	void WriteJson(Utf8JsonWriter writer);
}

public class IndexRequest<TDocument> : IProxyRequest
{
	public TDocument? Document { get; set; }

		public void WriteJson(Utf8JsonWriter writer)
	{
		if (Document is null) return;

		using var aps = new ArrayPoolStream();
		JsonSerializer.Serialize(aps, Document);
		writer.WriteRawValue(aps.GetBytes());
	}
}

Il tipo IndexRequest implementa l'interfaccia IProxyRequest. Questa interfaccia definisce un singolo metodo che accetta un Utf8JsonWriter. Il writer Utf8Json è un tipo di serializzazione di basso livello in System.Text.Json per la scrittura diretta di token e valori JSON. Il concetto fondamentale è che questo metodo delega la serializzazione di un tipo, al tipo stesso, dandogli il controllo completo su ciò che è effettivamente serializzato.

Per ora, questo codice usa la serializzazione System.Text.Json direttamente per serializzare la proprietà Document. Ricorda, questo è il tipo fornito dal consumatore che rappresenta i dati indicizzati.

L'implementazione finale includerà il passaggio di JsonSerializerOptions e l'implementazione di ITransportSerializer registrata nella configurazione del client. Dobbiamo farlo perché consente ai consumatori della libreria di fornire un'implementazione di ITransportSerializer. Se fornita, questa implementazione viene usata durante la serializzazione dei propri tipi, mentre i tipi client usano ancora System.Text.Json. È fondamentale in quanto non vogliamo costringere i consumatori a rendere i loro tipi compatibili con System.Text.Json per utilizzare il client. Se preferiscono, possono configurare il client con un'implementazione basata su JSON.Net.

Il codice sopra serializza il documento e, grazie a una nuova API aggiunta a Utf8JsonWriter, può scrivere il codice JSON grezzo nello scrittore usando WriteRawValue.

Il metodo WriteJson verrà invocato da un JsonConverter personalizzato e tutto ciò a cui abbiamo accesso è Utf8JsonWriter. Non mostrerò quel convertitore qui perché è leggermente fuori tema. Infine, le istanze JsonConverter e JsonConverterFactory personalizzate possono essere utilizzate per eseguire personalizzazioni avanzate durante la (de)serializzazione dei tipi. Nel mio esempio, se il tipo implementa IProxyRequest viene utilizzato un convertitore personalizzato che chiama il metodo WriteJson.

Questo (finalmente) mi porta a un caso d'uso di esempio per la funzionalità del generatore di sorgenti da System.Text.Json. Cosa succede se il consumatore desidera aumentare le prestazioni sfruttando i contesti di serializzazione del generatore di sorgenti quando il documento viene serializzato?

Nel prototipo ho aggiunto una proprietà Action a IndexRequest. Un consumatore può impostare questa proprietà e fornire la propria personalizzazione della serializzazione per il proprio documento. Lo sviluppatore può scrivere direttamente nello scrittore Utf8Json ma anche sfruttare la funzione del generatore di sorgenti, se preferisce.

public class IndexRequest<TDocument> : IProxyRequest
{
	public TDocument? Document { get; set; }

	public Action<Utf8JsonWriter, TDocument>? WriteCustomJson { get; set; }

	public void WriteJson(Utf8JsonWriter writer)
	{
		if (Document is null) return;

		if (WriteCustomJson is not null)
		{
			WriteCustomJson(writer, Document);
			return;
		}

		using var aps = new ArrayPoolStream();
		JsonSerializer.Serialize(aps, Document);
		writer.WriteRawValue(aps.GetBytes());
	}
}

Questo sarebbe un caso d'uso avanzato e necessario solo per i consumatori con requisiti di prestazioni particolarmente elevati. Quando viene fornita un'azione, il metodo WriteJson la usa per eseguire la serializzazione.

Per vederlo in azione, immagina che il consumatore stia indicizzando i dati sui libri. Per il test, ho utilizzato un semplice tipo POCO per definire i campi di dati che voglio indicizzare.

public class Book
{
	public string Title { get; set; }
	public string SubTitle { get; set; }
	public DateTime PublishDate { get; set; }
	public string ISBN { get; set; }
	public string Description { get; set; }
	public Category Category { get; set; }
	public List<Author> Authors { get; set; }
	public Publisher Publisher { get; set; }
}

public enum Category
{
	ComputerScience
}

public class Author
{
	public string? FirstName { get; set; }
	public string? LastName { get; set; }
}

public class Publisher
{
	public string Name { get; set; }
	public string HeadOfficeCountry { get; set; }
}

Mentre questi si serializzerebbero bene senza ulteriore lavoro, abilitiamo la generazione di sorgenti. Questo crea metadati che possono essere usati durante la serializzazione invece di riflettere sul tipo in fase di esecuzione. È semplice come aggiungere questa definizione al codice di consumo.

[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(Book))]
internal partial class BookContext : JsonSerializerContext
{
}

Dobbiamo includere una classe parziale derivante da JsonSerializerContext e aggiungere l'attributo JsonSerializable che lo contrassegna per l'inclusione nella generazione del codice sorgente.

La funzionalità del generatore di sorgenti viene eseguita in fase di compilazione per completare il codice BookContext. Come mostrato sopra, possiamo anche fornire opzioni che controllano la serializzazione del tipo aggiungendo l'attributo JsonSourceGenerationOptions. JsonSerializerContext contiene la logica che crea JsonTypeInfo, spostando il costo di riflessione sul tempo di compilazione. Ciò comporta l'inclusione nella compilazione di diversi file generati.

Durante l'indicizzazione, il codice del consumatore può quindi assomigliare a questo.

var request = new IndexRequest<Book>()
{
	WriteCustomJson = (writer, document) =>
	{
		BookContext.Default.Book.Serialize!(writer, document);
		writer.Flush();
	},
	Book = = new Book
	{
		Title = "This is a book",
		SubTitle = "It's really good, buy it!",
		PublishDate = new DateTime(2020, 01, 01),
		Category = Category.ComputerScience,
		Description = "This contains everything you ever want to know about everything!",
		ISBN = "123456789",
		Publisher = new Publisher
		{
			Name = "Cool Books Ltd",
			HeadOfficeCountry = "United Kingdom"
		},
		Authors = new List<Author>
		{
			new Author{ FirstName = "Steve", LastName = "Gordon" },
			new Author{ FirstName = "Michael", LastName = "Gordon" },
			new Author{ FirstName = "Rhiannon", LastName = "Gordon" }
		}
	}
};

La parte importante è all'interno dell'azione WriteCustomJson, definita qui usando la sintassi lambda. Utilizza l'istanza predefinita del BookContext generato dall'origine, serializzandolo direttamente nel writer Utf8Json.

È abbastanza semplice introdurre questa funzionalità, ma quale vantaggio offre? Per fare un confronto, ho creato un rapido benchmark che serializza 100 istanze di IndexRequest. Ciò simula parte del costo dell'invio di 100 chiamate API all'API di indice del server. I risultati per il mio test case sono stati i seguenti.

|                  Method | Mean [us] | Ratio |   Gen 0 | Allocated [B] |
|------------------------ |----------:|------:|--------:|--------------:|
|        SerialiseRequest |  396.4 us |  1.00 | 27.3438 |     115,200 B |
| SerialiseUsingSourceGen |  132.3 us |  0.33 | 14.6484 |      61,600 B |

Nel mio prototipo, l'utilizzo del generatore di sorgenti System.Text.Json rende la serializzazione del runtime 3 volte più veloce e, in questo caso, alloca quasi la metà del caso alternativo. Naturalmente, l'impatto dipenderà dalla complessità del tipo da (de)serializzato, ma questo è comunque un esperimento entusiasmante. Sembra promettente fornire un meccanismo per consentire ai consumatori di ottimizzare il proprio codice con generatori di sorgenti, in particolare per scenari di acquisizione o recupero di volumi.

Cercherò il vantaggio dell'utilizzo della funzionalità del generatore di sorgenti per i tipi di richiesta e risposta all'interno del client. Sono ragionevolmente fiducioso che fornirà un buon aumento delle prestazioni che possiamo sfruttare per rendere più veloce la serializzazione per i nostri consumatori. Poiché questa è una delle attività principali di un cliente come il nostro, potrebbe essere un vero vantaggio che i consumatori ottengono solo aggiornando. Insieme ad altre ottimizzazioni, dovrebbe fare il passaggio a System.Text.Json poiché la serializzazione predefinita vale la pena.