Votre propre convertisseur JSON ou un peu plus sur ExpressionTrees



La sérialisation et la désérialisation sont des opérations typiques que le développeur moderne considère comme triviales. Nous communiquons avec des bases de données, générons des requêtes HTTP, recevons des données via l'API REST et souvent, nous ne pensons même pas à son fonctionnement. Aujourd'hui, je suggère d'écrire mon sérialiseur et désérialiseur pour JSON pour savoir ce qui est sous le capot.

Clause de non-responsabilité


Comme la dernière fois , je le remarquerai: nous écrirons un sérialiseur primitif, pourrait-on dire, un vélo. Si vous avez besoin d'une solution clé en main, utilisez Json.NET . Ces gars ont sorti un produit merveilleux qui est hautement personnalisable, peut faire beaucoup et résout déjà les problèmes qui surviennent lorsque vous travaillez avec JSON. Utiliser votre propre solution est vraiment cool, mais seulement si vous avez besoin de performances maximales, d'une personnalisation spéciale ou si vous aimez les vélos comme je les aime.

Domaine


Le service de conversion de JSON en une représentation d'objet comprend au moins deux sous-systèmes. Le désérialiseur est un sous-système qui transforme un JSON (texte) valide en une représentation d'objet à l'intérieur de notre programme. La désérialisation implique la tokenisation, c'est-à-dire l'analyse du JSON en éléments logiques. Serializer est un sous-système qui effectue la tâche inverse: transforme la représentation objet des données en JSON.

Le consommateur voit le plus souvent l'interface suivante. Je l'ai délibérément simplifié pour mettre en évidence les principales méthodes les plus utilisées.

public interface IJsonConverter { T Deserialize<T>(string json); string Serialize(object source); } 

«Sous le capot», la désérialisation comprend la tokenisation (analyse d'un texte JSON) et la construction de primitives qui facilitent la création d'une représentation d'objet plus tard. À des fins de formation, nous ignorerons la construction de primitives intermédiaires (par exemple, JObject, JProperty de Json.NET) et nous écrirons immédiatement des données sur l'objet. C'est un inconvénient, car cela réduit les options de personnalisation, mais il est impossible de créer une bibliothèque entière dans le cadre d'un article.

Tokenisation


Permettez-moi de vous rappeler que le processus de tokenisation ou d'analyse lexicale est une analyse du texte dans le but d'obtenir une représentation différente et plus rigoureuse des données qu'il contient. En règle générale, cette représentation est appelée jetons ou jetons. Pour analyser JSON, nous devons mettre en évidence les propriétés, leurs valeurs, les symboles du début et de la fin des structures, c'est-à-dire les jetons qui peuvent être représentés comme JsonToken dans le code.

JsonToken est une structure qui contient une valeur (texte), ainsi qu'un type de jeton. JSON est une notation stricte, donc tous les types de jetons peuvent être réduits à l' énumération suivante . Bien sûr, il serait bien d'ajouter au jeton ses coordonnées dans les données entrantes (ligne et colonne), mais le débogage dépasse le cadre de l'implémentation, ce qui signifie que JsonToken ne contient pas ces données.

Ainsi, la façon la plus simple d'analyser du texte en jetons est de lire chaque caractère séquentiellement et de le comparer avec des modèles. Nous devons comprendre ce que signifie tel ou tel symbole. Il est possible que le mot-clé (vrai, faux, nul) commence par ce caractère, il est possible que ce soit le début de la ligne (guillemet), ou peut-être que ce caractère lui-même est un jeton ([,], {,}). L'idée générale ressemble à ceci:

 var tokens = new List<JsonToken>(); for (int i = 0; i < json.Length; i++) { char ch = json[i]; switch (ch) { case '[': tokens.Add(new JsonToken(JsonTokenType.ArrayStart)); break; case ']': tokens.Add(new JsonToken(JsonTokenType.ArrayEnd)); break; case '"': string stringValue = ReadString(); tokens.Add(new JsonToken(JsonTokenType.String, stringValue); break; ... } } 

En regardant le code, il semble que vous puissiez lire et faire immédiatement quelque chose avec les données lues. Ils n'ont pas besoin d'être stockés, ils doivent être immédiatement envoyés au consommateur. Ainsi, un certain IEnumerator supplie, qui analysera le texte en morceaux. Tout d'abord, cela réduira l'allocation, car nous n'avons pas besoin de stocker les résultats intermédiaires (un tableau de jetons). Deuxièmement, nous augmenterons la vitesse de travail - oui, dans notre exemple, l'entrée est une chaîne, mais dans une situation réelle, elle sera remplacée par Stream (à partir d'un fichier ou d'un réseau), que nous lisons séquentiellement.

J'ai préparé le code JsonTokenizer , qui peut être trouvé ici . L'idée est la même: le tokenizer va séquentiellement le long de la ligne, essayant de déterminer à quoi le symbole ou sa séquence fait référence. S'il s'avérait être compris, nous créons un jeton et transférons le contrôle au consommateur. Si ce n'est pas encore clair, lisez la suite.

Préparation à la désérialisation des objets


Le plus souvent, une demande de conversion de données à partir de JSON est un appel à la méthode générique Deserialize , où TOut est le type de données avec lequel les jetons JSON doivent être mappés. Où Type est : il est temps d'appliquer Reflection et ExpressionTrees . Les bases de l'utilisation d'ExpressionTrees, ainsi que les raisons pour lesquelles les expressions compilées sont meilleures que la réflexion «nue», j'ai décrit dans un article précédent comment créer votre AutoMapper . Si vous ne savez rien sur Expression.Labmda.Compile () - je vous recommande de le lire. Il me semble que l'exemple du mappeur s'est avéré assez compréhensible.

Ainsi, le plan de création d'un désérialiseur d'objet est basé sur la connaissance que nous pouvons obtenir des types de propriété du type TOut à tout moment, c'est-à-dire la collection PropertyInfo . Dans le même temps, les types de propriétés sont limités par la notation JSON: nombres, chaînes, tableaux et objets. Même si nous n'oublions pas null, ce n'est pas tant que cela puisse paraître à première vue. Et si pour chaque type primitif nous sommes obligés de créer un désérialiseur séparé, alors pour les tableaux et les objets, nous pouvons créer des classes génériques. Si vous réfléchissez un peu, tous les sérialiseurs-désérialiseurs (ou convertisseurs ) peuvent être réduits à l'interface suivante:

 public interface IJsonConverter<T> { T Deserialize(JsonTokenizer tokenizer); void Serialize(T value, StringBuilder builder); } 

Le code d'un convertisseur fortement typé de types primitifs est aussi simple que possible: nous extrayons le JsonToken actuel du tokenizer et le transformons en valeur en analysant. Par exemple, float.Parse (currentToken.Value). Jetez un œil à BoolConverter ou FloatConverter - rien de compliqué. Ensuite, si vous avez besoin d'un désérialiseur pour bool? ou flottant?, il peut également être ajouté.

Désérialisation des baies


Le code de classe générique pour convertir un tableau à partir de JSON est également relativement simple. Il est paramétré par le type d'élément que l'on peut extraire Type.GetElementType () . Déterminer qu'un type est un tableau est également simple: Type.IsArray . La désérialisation des tableaux revient à dire tokenizer.MoveNext () jusqu'à ce qu'un jeton de type ArrayEnd soit atteint. La désérialisation des éléments du tableau est la désérialisation du type d'élément du tableau. Par conséquent, lors de la création d'un ArrayConverter, le désérialiseur d'élément lui est transmis.

Parfois, il y a des difficultés avec l'instanciation d'implémentations génériques, je vais donc vous dire immédiatement comment le faire. La réflexion vous permet de créer des types génériques en temps réel, ce qui signifie que nous pouvons utiliser le type créé comme argument pour Activator.CreateInstance. Profitez-en:

 Type elementType = arrayType.GetElementType(); Type converterType = typeof(ArrayConverter<>).MakeGenericType(elementType); var converterInstance = Activator.CreateInstance(converterType, object[] args); 

Finissant les préparatifs pour la création d'un désérialiseur d'objets, vous pouvez mettre tout le code d'infrastructure associé à la création et au stockage des désérialiseurs dans la façade de JConverter . Il sera responsable de toutes les opérations de sérialisation et de désérialisation JSON et est disponible pour les consommateurs en tant que service.

Désérialisation d'objets


Permettez-moi de vous rappeler que vous pouvez obtenir toutes les propriétés de type T comme ceci: typeof (T) .GetProperties (). Pour chaque propriété, vous pouvez extraire PropertyInfo.PropertyType , ce qui nous donnera la possibilité de créer un IJsonConverter typé pour la sérialisation et la désérialisation des données d'un type particulier. Si le type de la propriété est un tableau, nous instancions ArrayConverter ou en trouvons un approprié parmi ceux existants. Si le type de propriété est un type primitif, des désérialiseurs (convertisseurs) sont déjà créés pour eux dans le constructeur JConverter.

Le code résultant peut être affiché dans la classe générique ObjectConverter . Un activateur est créé dans son constructeur, les propriétés sont extraites d'un dictionnaire spécialement préparé, et pour chacun d'eux une méthode de désérialisation est créée - Action <TObject, JsonTokenizer>. Il est nécessaire, d'une part, afin d'associer immédiatement le IJsonConverter à la propriété souhaitée, et d'autre part, afin d'éviter la boxe lors de l'extraction et de l'écriture de types primitifs. Chaque méthode de désérialisation sait quelle propriété de l'objet sortant sera enregistrée, le désérialiseur de la valeur est strictement typé et renvoie la valeur exactement sous la forme dans laquelle elle est nécessaire.

La liaison d'un IJsonConverter à une propriété est la suivante:

 Type converterType = propertyValueConverter.GetType(); ConstantExpression Expression.Constant(propertyValueConverter, converterType); MethodInfo deserializeMethod = converterType.GetMethod("Deserialize"); var value = Expression.Call(converter, deserializeMethod, tokenizer); 

La constante Expression.Constant est créée directement dans l'expression, qui stocke une référence à l'instance de désérialiseur pour la valeur de la propriété. Ce n'est pas exactement la constante que nous écrivons en «C # normal», car elle peut stocker un type de référence. Ensuite, la méthode Deserialize est récupérée à partir du type de désérialiseur, qui renvoie la valeur du type souhaité, puis elle est appelée - Expression.Call . Ainsi, nous obtenons une méthode qui sait exactement où et quoi écrire. Il reste à le mettre dans le dictionnaire et à l'appeler lorsqu'un token du type Property avec le nom souhaité «vient» du tokenizer. Un autre avantage est que tout fonctionne très rapidement.

À quelle vitesse


Les vélos, comme cela a été noté au tout début, il est logique d'écrire dans plusieurs cas: s'il s'agit d'une tentative de comprendre le fonctionnement de la technologie, ou si vous devez obtenir des résultats spéciaux. Par exemple, la vitesse. Vous pouvez vous assurer que le désérialiseur désérialise vraiment avec les tests préparés (j'utilise AutoFixture pour obtenir les données de test). Au fait, vous avez probablement remarqué que j'ai également écrit la sérialisation d'objets. Mais comme l'article s'est avéré assez volumineux, je ne le décrirai pas, mais je donnerai simplement des repères. Oui, tout comme avec l'article précédent, j'ai écrit des benchmarks en utilisant la bibliothèque BenchmarkDotNet .

Bien sûr, j'ai comparé la vitesse de désérialisation avec Newtonsoft (Json.NET), comme la solution la plus courante et recommandée pour travailler avec JSON. De plus, sur leur site Web, il est écrit: 50% plus rapide que DataContractJsonSerializer et 250% plus rapide que JavaScriptSerializer. Bref, je voulais savoir combien mon code allait perdre. Les résultats m'ont surpris: notez que l'allocation des données est presque trois fois inférieure et que le taux de désérialisation est environ deux fois plus rapide.
La méthodeMoyenneErreurStddevRatioAlloué
Newtonsoft75,39 ms0,3027 ms0,2364 ms1,0035,47 Mo
Velo31,78 ms0,1135 ms0,1062 ms0,4212,36 Mo

La comparaison de la vitesse et de l'allocation pendant la sérialisation des données a donné des résultats encore plus intéressants. Il s'avère que le sérialiseur de vélo a alloué près de cinq fois moins et a fonctionné presque trois fois plus rapidement. Si la vitesse me dérangeait vraiment (vraiment), ce serait un succès évident.
La méthodeMoyenneErreurStddevRatioAlloué
Newtonsoft54,83 ms0,5582 ms0,5222 ms1,0025,44 Mo
Velo20,66 ms0,0484 ms0,0429 ms0,385,93 Mo

Oui, lors de la mesure de la vitesse, je n'ai pas utilisé les conseils pour augmenter la productivité qui sont publiés sur le site Web Json.NET. J'ai pris des mesures hors de la boîte, c'est-à-dire selon le scénario le plus couramment utilisé: JsonConvert.DeserializeObject. Il existe peut-être d’autres moyens d’améliorer les performances, mais je ne les connais pas.

Conclusions


Malgré la vitesse relativement élevée de sérialisation et de désérialisation, je ne recommanderais pas d'abandonner Json.NET au profit de ma propre solution. Le gain de vitesse est calculé en millisecondes et se «noie» facilement dans les retards du réseau, du disque ou du code, qui est hiérarchiquement situé au-dessus de l'endroit où la sérialisation est appliquée. Prendre en charge de telles solutions propriétaires est un enfer, où seuls les développeurs qui connaissent bien le sujet peuvent être autorisés.

La portée de ces vélos est des applications entièrement conçues en vue de hautes performances ou des projets pour animaux de compagnie où vous comprenez comment telle ou telle technologie fonctionne. J'espère que je vous ai aidé un peu dans tout cela.

Source: https://habr.com/ru/post/fr464525/


All Articles