Παίζοντας με System.Text.Json Source Generators – Steve Gordon

Παίζοντας με System.Text.Json Source Generators – Steve Gordon

Januar 25, 2023 0 Von admin

Στην καθημερινή μου εργασία, εξοικειώνομαι αρκετά με τις λεπτομέρειες της χρήσης του System.Text.Json. Για όσους δεν είναι εξοικειωμένοι με αυτή τη βιβλιοθήκη, ήταν κυκλοφόρησε μαζί με το .NET Core 3.0 ως βιβλιοθήκη σειριοποίησης JSON in-the-box.

Κατά την κυκλοφορία του, το System.Text.Json ήταν αρκετά βασικό στο σύνολο δυνατοτήτων του, σχεδιασμένο κυρίως για σενάρια ASP.NET Core για τη διαχείριση της μορφοποίησης εισόδου και εξόδου από και προς το JSON. Η βιβλιοθήκη σχεδιάστηκε για να είναι αποτελεσματική και να μειώνει τις κατανομές για κοινά σενάρια. Η μετεγκατάσταση στο System.Text.Json βοήθησε το ASP.NET Core να συνεχίσει να βελτιώνει την απόδοση του πλαισίου.

Από εκείνη την αρχική έκδοση, η ομάδα συνεχίζει να επεκτείνει τη λειτουργικότητα του System.Text.Json, υποστηρίζοντας πιο περίπλοκα σενάρια χρηστών. Στο επόμενη σημαντική έκδοση του πελάτη Elasticsearch .NETστόχος μου είναι να μεταβώ πλήρως στο System.Text.Json για σειριοποίηση.

Σήμερα, το v7.x χρησιμοποιεί μια εσωτερικοποιημένη και τροποποιημένη παραλλαγή του Utf8Json, μιας προηγούμενης βιβλιοθήκης JSON υψηλής απόδοσης που δυστυχώς δεν διατηρείται πλέον. Το Utf8Json επιλέχθηκε αρχικά για τη βελτιστοποίηση των εφαρμογών που πραγματοποιούν μεγάλο αριθμό κλήσεων στο Elasticsearch, αποφεύγοντας όσο το δυνατόν περισσότερα έξοδα.

Η μετάβαση στο System.Text.Json στην επόμενη έκδοση έχει το πλεονέκτημα της συνεχούς λήψης υψηλής απόδοσης, χαμηλής κατανομής (απο)σειριοποίησης των αντικειμένων αιτημάτων και απόκρισης με έντονη πληκτρολόγηση. Δεδομένου ότι είναι σχετικά νέο, αξιοποιεί ακόμη περισσότερα από τα πιο πρόσφατα API υψηλής απόδοσης στο .NET. Επιπλέον, σημαίνει ότι μεταβαίνουμε σε μια βιβλιοθήκη που υποστηρίζεται και καλά συντηρημένη από τη Microsoft, η οποία αποστέλλεται „in the box“ για τους περισσότερους καταναλωτές που χρησιμοποιούν .NET Core και επομένως δεν απαιτεί πρόσθετες εξαρτήσεις.

Αυτό μας φέρνει στο θέμα της σημερινής ανάρτησης, όπου θα εξερευνήσω εν συντομία μια νέα λειτουργία εστιασμένη στην απόδοση που θα έρχεται στην επόμενη έκδοση του System.Text.Json (περιλαμβάνεται στο .NET 6), γεννήτριες πηγών. Δεν θα αφιερώσω χρόνο για να εξηγήσω το κίνητρο για αυτό το χαρακτηριστικό εδώ. Αντ ‚αυτού, σας συνιστώ να διαβάσετε την ανάρτηση ιστολογίου του Layomi, „Δοκιμάστε τη νέα δημιουργία πηγής System.Text.Json», εξηγώντας το αναλυτικά. Εν ολίγοις, η ομάδα έχει αξιοποιήσει τις δυνατότητες δημιουργίας πηγής στον μεταγλωττιστή C# 9 για να βελτιστοποιήσει κάποιο από το κόστος χρόνου εκτέλεσης της (απο)σειριοποίησης.

Οι γεννήτριες πηγών προσφέρουν κάποια εξαιρετικά ενδιαφέρουσα τεχνολογία ως μέρος του μεταγλωττιστή Roslyn, επιτρέποντας στις βιβλιοθήκες να εκτελούν ανάλυση κώδικα μεταγλώττισης και να εκπέμπουν πρόσθετο κώδικα στον στόχο μεταγλώττισης. Υπάρχουν ήδη μερικά παραδείγματα όπου μπορεί να χρησιμοποιηθεί στην αρχική ανάρτηση ιστολογίου παρουσιάζοντας το χαρακτηριστικό.

Η ομάδα System.Text.Json έχει αξιοποιήσει αυτή τη νέα δυνατότητα για να μειώσει το κόστος χρόνου εκτέλεσης της (απο)σειριοποίησης. Μία από τις εργασίες μιας βιβλιοθήκης JSON είναι ότι πρέπει να αντιστοιχίσει το εισερχόμενο JSON σε αντικείμενα. Κατά την αποσειριοποίηση, πρέπει να εντοπίσει τις σωστές ιδιότητες για να ορίσει τιμές. Κάποια από αυτά επιτυγχάνονται μέσω του προβληματισμού, ενός συνόλου API που μας επιτρέπουν να επιθεωρήσουμε και να εργαστούμε με πληροφορίες τύπου.

Η αντανάκλαση είναι ισχυρή, αλλά έχει κόστος απόδοσης και μπορεί να είναι σχετικά αργή. Η νέα δυνατότητα στο System.Text.Json 6.x επιτρέπει στους προγραμματιστές να ενεργοποιήσουν τις γεννήτριες πηγών που εκτελούν αυτήν την εργασία εκ των προτέρων κατά τη μεταγλώττιση. Είναι πραγματικά πολύ εξαιρετικό καθώς αυτό αφαιρεί το μεγαλύτερο μέρος του κόστους εκτέλεσης της σειριοποίησης προς και από αντικείμενα με έντονη πληκτρολόγηση.

Αυτή η ανάρτηση δεν θα είναι το συνηθισμένο μου στυλ βαθιάς κατάδυσης. Ωστόσο, καθώς έχω πειραματιστεί με τη νέα δυνατότητα, σκέφτηκα ότι θα ήταν χρήσιμο να μοιραστώ ένα πραγματικό σενάριο για τη μόχλευση των γεννητριών πηγών System.Text.Json για κέρδη απόδοσης.

Το Σενάριο

Ένα από τα κοινά σενάρια που πρέπει να συμπληρώσουν οι καταναλωτές του πελάτη Elasticsearch είναι η ευρετηρίαση εγγράφων στο Elasticsearch. Το API ευρετηρίου δέχεται ένα απλό αίτημα που περιλαμβάνει το JSON που αντιπροσωπεύει τα δεδομένα που πρόκειται να ευρετηριαστούν. Ο τύπος IndexRequest, επομένως, περιλαμβάνει μια μεμονωμένη ιδιότητα Document ενός γενικού τύπου TDocument.

Σε αντίθεση με πολλούς άλλους τύπους αιτημάτων που ορίζονται στη βιβλιοθήκη, κατά την αποστολή της αίτησης στον διακομιστή, δεν θέλουμε να σειριοποιήσουμε τον ίδιο τον τύπο αιτήματος (IndexRequest), μόνο το αντικείμενο TDocument. Δεν θα μπω στον υπάρχοντα κώδικα για αυτό εδώ, καθώς θα θολώσει τα νερά, και δεν είναι τόσο σχετικό με το κύριο σημείο αυτής της ανάρτησης. Αντ ‚αυτού, επιτρέψτε μου να εξηγήσω εν συντομία πώς αυτό υλοποιείται σε πρωτότυπη μορφή αυτή τη στιγμή, η οποία ούτως ή άλλως δεν είναι τόσο διαφορετική με την τρέχουσα βάση κώδικα.

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());
	}
}

Έχουμε τον τύπο IndexRequest να υλοποιεί τη διεπαφή IProxyRequest. Αυτή η διεπαφή ορίζει μια μεμονωμένη μέθοδο που χρησιμοποιεί ένα Utf8JsonWriter. Το πρόγραμμα εγγραφής Utf8Json είναι ένας τύπος σειριοποίησης χαμηλού επιπέδου στο System.Text.Json για απευθείας εγγραφή διακριτικών και τιμών JSON. Η κρίσιμη ιδέα είναι ότι αυτή η μέθοδος αναθέτει τη σειριοποίηση ενός τύπου, στον ίδιο τον τύπο, δίνοντάς του τον πλήρη έλεγχο σε αυτό που πραγματικά σειριοποιείται.

Προς το παρόν, αυτός ο κώδικας χρησιμοποιεί τη σειριοποίηση System.Text.Json απευθείας για τη σειριοποίηση της ιδιότητας Document. Θυμηθείτε, αυτός είναι ο τύπος που παρέχεται από τον καταναλωτή που αντιπροσωπεύει τα δεδομένα που ευρετηριάζονται.

Η τελική υλοποίηση θα περιλαμβάνει τη μετάβαση στο JsonSerializerOptions και την υλοποίηση του ITransportSerializer που έχει καταχωρηθεί στη διαμόρφωση του πελάτη. Πρέπει να το κάνουμε αυτό γιατί επιτρέπει στους καταναλωτές της βιβλιοθήκης να παρέχουν μια υλοποίηση του ITransportSerializer. Εάν παρέχεται, αυτή η υλοποίηση χρησιμοποιείται κατά τη σειριοποίηση των δικών τους τύπων, ενώ οι τύποι πελάτη εξακολουθούν να χρησιμοποιούν System.Text.Json. Είναι ζωτικής σημασίας καθώς δεν θέλουμε να αναγκάσουμε τους καταναλωτές να κάνουν τους τύπους τους συμβατούς με το System.Text.Json να χρησιμοποιούν τον πελάτη. Θα μπορούσαν να διαμορφώσουν τον υπολογιστή-πελάτη με μια υλοποίηση που βασίζεται στο JSON.Net, αν προτιμούν.

Ο παραπάνω κώδικας σειριοποιεί το έγγραφο και, χάρη σε ένα νέο API που προστέθηκε στο Utf8JsonWriter, μπορεί να γράψει το ακατέργαστο JSON στον συγγραφέα χρησιμοποιώντας το WriteRawValue.

Η μέθοδος WriteJson θα κληθεί από έναν προσαρμοσμένο JsonConverter και το μόνο στο οποίο έχουμε πρόσβαση είναι το Utf8JsonWriter. Δεν θα δείξω αυτόν τον μετατροπέα εδώ, καθώς είναι λίγο εκτός θέματος. Τελικά, οι προσαρμοσμένες παρουσίες JsonConverters και JsonConverterFactory μπορούν να χρησιμοποιηθούν για την εκτέλεση προηγμένης προσαρμογής κατά την (απ)σειριοποίηση τύπων. Στο παράδειγμά μου, εάν ο τύπος υλοποιεί το IProxyRequest χρησιμοποιείται ένας προσαρμοσμένος μετατροπέας που καλεί στη μέθοδο WriteJson.

Αυτό (επιτέλους) με φέρνει σε ένα παράδειγμα περίπτωσης χρήσης για τη λειτουργικότητα της γεννήτριας πηγής από το System.Text.Json. Τι θα συμβεί αν ο καταναλωτής θέλει να ενισχύσει την απόδοση αξιοποιώντας τα περιβάλλοντα σειριοποίησης της γεννήτριας πηγής όταν το έγγραφό του είναι σειριοποιημένο;

Στο πρωτότυπο, πρόσθεσα μια ιδιότητα Action στο IndexRequest. Ένας καταναλωτής μπορεί να ορίσει αυτήν την ιδιότητα και να παρέχει τη δική του προσαρμογή σειριοποίησης για το έγγραφό του. Ο προγραμματιστής μπορεί να γράψει απευθείας στο πρόγραμμα εγγραφής Utf8Json, αλλά και να αξιοποιήσει τη δυνατότητα δημιουργίας πηγής, αν προτιμά.

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());
	}
}

Αυτή θα ήταν μια προηγμένη περίπτωση χρήσης και απαραίτητη μόνο για καταναλωτές με απαιτήσεις ιδιαίτερα υψηλών επιδόσεων. Όταν παρέχεται μια ενέργεια, η μέθοδος WriteJson τη χρησιμοποιεί για να εκτελέσει τη σειριοποίηση.

Για να το δείτε στην πράξη, φανταστείτε ότι ο καταναλωτής ευρετηριάζει δεδομένα σχετικά με βιβλία. Για τη δοκιμή, χρησιμοποίησα απλούς τύπους POCO για να ορίσω τα πεδία δεδομένων που θέλω να καταχωρήσω στο ευρετήριο.

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; }
}

Αν και αυτά θα δημιουργηθούν σε σειρά χωρίς περαιτέρω εργασία, ας ενεργοποιήσουμε τη δημιουργία πηγών. Αυτό δημιουργεί μεταδεδομένα που μπορούν να χρησιμοποιηθούν κατά τη σειριοποίηση αντί να αντικατοπτρίζονται στον τύπο κατά το χρόνο εκτέλεσης. Είναι τόσο απλό όσο η προσθήκη αυτού του ορισμού στον καταναλωτή κώδικα.

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

Πρέπει να συμπεριλάβουμε μια μερική κλάση που προέρχεται από το JsonSerializerContext και να προσθέσουμε το χαρακτηριστικό JsonSerializable σε αυτό που το επισημαίνει για συμπερίληψη στη δημιουργία πηγής.

Η δυνατότητα δημιουργίας προέλευσης εκτελείται τη στιγμή της μεταγλώττισης για να ολοκληρωθεί ο κώδικας BookContext. Όπως φαίνεται παραπάνω, μπορούμε ακόμη και να παρέχουμε επιλογές που ελέγχουν τη σειριοποίηση του τύπου προσθέτοντας το χαρακτηριστικό JsonSourceGenerationOptions. Το JsonSerializerContext περιέχει λογική που δημιουργεί το JsonTypeInfo, μετατοπίζοντας το κόστος ανάκλασης στο χρόνο μεταγλώττισης. Αυτό έχει ως αποτέλεσμα πολλά αρχεία που δημιουργούνται να περιλαμβάνονται στη συλλογή.

Κατά τη δημιουργία ευρετηρίου, ο κωδικός καταναλωτή μπορεί να μοιάζει κάπως έτσι.

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" }
		}
	}
};

Το σημαντικό μέρος βρίσκεται μέσα στην ενέργεια WriteCustomJson, που ορίζεται εδώ χρησιμοποιώντας τη σύνταξη λάμδα. Χρησιμοποιεί την προεπιλεγμένη παρουσία του BookContext που δημιουργήθηκε από την πηγή, σειριοποιώντας το απευθείας στο πρόγραμμα εγγραφής Utf8Json.

Είναι πολύ απλό να εισαχθεί αυτή η δυνατότητα, αλλά τι όφελος παρέχει; Για να συγκρίνω, κατέρριψα ένα γρήγορο σημείο αναφοράς που σειριοποιεί 100 περιπτώσεις του IndexRequest. Αυτό προσομοιώνει μέρος του κόστους αποστολής 100 κλήσεων API στο API ευρετηρίου του διακομιστή. Τα αποτελέσματα για τη δοκιμαστική μου περίπτωση ήταν τα εξής.

|                  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 |

Στο πρωτότυπο μου, η χρήση της γεννήτριας πηγής System.Text.Json κάνει τη σειριοποίηση χρόνου εκτέλεσης 3 φορές πιο γρήγορη και, σε αυτήν την περίπτωση, εκχωρεί σχεδόν το μισό από την εναλλακτική θήκη. Φυσικά, ο αντίκτυπος θα εξαρτηθεί από την πολυπλοκότητα του τύπου που θα (απο)σειριοποιηθεί, αλλά αυτό εξακολουθεί να είναι ένα συναρπαστικό πείραμα. Φαίνεται πολλά υποσχόμενο να παρέχει στους καταναλωτές έναν μηχανισμό για τη βελτιστοποίηση του κώδικά τους με γεννήτριες πηγών, ειδικά για σενάρια απορρόφησης όγκου ή ανάκτησης.

Θα ερευνήσω το όφελος από τη χρήση της λειτουργικότητας δημιουργίας πηγής για τους τύπους αιτημάτων και απαντήσεων εντός του πελάτη. Είμαι εύλογα βέβαιος ότι θα προσφέρει μια καλή ώθηση απόδοσης που μπορούμε να αξιοποιήσουμε για να κάνουμε τη σειριοποίηση πιο γρήγορη για τους καταναλωτές μας. Δεδομένου ότι αυτή είναι μια από τις βασικές δραστηριότητες ενός πελάτη όπως ο δικός μας, θα μπορούσε να είναι ένα πραγματικό όφελος που θα λάβουν οι καταναλωτές μόνο με την αναβάθμιση. Μαζί με άλλες βελτιστοποιήσεις, θα πρέπει να κάνει τη μετάβαση στο System.Text.Json ως προεπιλεγμένη σειριοποίηση που αξίζει τον κόπο.