Propre mappeur ou un peu sur ExpressionTrees

image

Aujourd'hui, nous allons parler de la façon d'écrire votre AutoMapper . Oui, je voudrais vraiment vous en parler, mais je ne peux pas. Le fait est que ces solutions sont très importantes, ont des antécédents d'essais et d'erreurs et ont également parcouru un long chemin vers l'application. Je ne peux que comprendre comment cela fonctionne, donner un point de départ à ceux qui voudraient comprendre le mécanisme de travail des «cartographes». Vous pourriez même dire que nous allons écrire notre vélo.

Clause de non-responsabilité


Je vous rappelle encore une fois: nous allons écrire un mappeur primitif. Si vous décidez soudainement de le modifier et de l'utiliser dans la prod - ne le faites pas. Prenez une solution toute faite qui connaît la pile de problèmes dans ce domaine et sait déjà comment les résoudre. Il existe plusieurs raisons plus ou moins importantes d'écrire et d'utiliser votre mappeur de vélo:

  • Besoin d'une personnalisation spéciale.
  • Vous avez besoin de performances maximales dans vos conditions et vous êtes prêt à remplir les cônes.
  • Vous voulez comprendre le fonctionnement du mappeur.
  • Vous aimez juste faire du vélo.

Comment appelle-t-on le «mappeur»?


Il s'agit du sous-système chargé de prendre un objet et de le convertir (copier ses valeurs) en un autre. Une tâche typique consiste à convertir un DTO en objet de couche métier. Le mappeur le plus primitif «parcourt» les propriétés de la source de données et les compare avec les propriétés du type de données qui seront sorties. Après la correspondance, les valeurs sont extraites de la source et écrites dans l'objet, qui sera le résultat de la conversion. Quelque part en cours de route, il sera très probablement encore nécessaire de créer ce «résultat».

Pour le consommateur, le mappeur est un service qui fournit l'interface suivante:

public interface IMapper<out TOut> { TOut Map(object source); } 

J'insiste: c'est l'interface la plus primitive qui, de mon point de vue, est commode pour l'explication. En réalité, nous aurons probablement affaire à un mappeur plus spécifique (IMapper <TIn, TOut>) ou à une façade plus générale (IMapper), qui sélectionnera elle-même un mappeur spécifique pour les types spécifiés d'objets d'entrée-sortie.

Implémentation naïve


Remarque: même l'implémentation naïve de mapper nécessite des connaissances de base de Reflection et ExpressionTrees . Si vous n'avez pas suivi les liens ou entendu parler de ces technologies - faites-le, lisez-le. Je promets que le monde ne sera plus jamais le même.

Cependant, nous écrivons votre propre mappeur. Pour commencer, obtenons toutes les propriétés ( PropertyInfo ) du type de données qui seront sorties (ci-après je l'appellerai TOut ). C'est assez simple: nous connaissons le type, puisque nous écrivons l'implémentation d'une classe générique paramétrée avec le type TOut. Ensuite, en utilisant une instance de la classe Type, nous obtenons toutes ses propriétés.

 Type outType = typeof(TOut); PropertyInfo[] outProperties = outType.GetProperties(); 

Lors de l'obtention des propriétés, j'omet les fonctionnalités. Par exemple, certains d'entre eux peuvent être sans fonction de définition, certains peuvent être marqués comme ignorés par l'attribut, certains peuvent avoir un accès spécial. Nous envisageons l'option la plus simple.

Nous allons plus loin. Ce serait bien de pouvoir créer une instance de type TOut, c'est-à-dire l'objet même dans lequel on "mappe" l'objet entrant. En C #, il existe plusieurs façons de procéder. Par exemple, nous pouvons le faire: System.Activator.CreateInstance (). Ou même juste un nouveau TOut (), mais pour cela, vous devez créer une restriction pour TOut, ce que vous ne voudriez pas faire dans l'interface généralisée. Cependant, nous savons tous les deux quelque chose sur ExpressionTrees, ce qui signifie que nous pouvons le faire comme ceci:

 ConstructorInfo outConstructor = outType.GetConstructor(Array.Empty<Type>()); Func<TOut> activator = outConstructor == null ? throw new Exception($"Default constructor for {outType.Name} not found") : Expression.Lambda<Func<TOut>>(Expression.New(outConstructor)).Compile(); 

Pourquoi Parce que nous savons qu'une instance de la classe Type peut donner des informations sur les constructeurs dont elle dispose - cela est très pratique lorsque nous décidons de développer notre mappeur afin de transmettre toutes les données au constructeur. De plus, nous en avons appris un peu plus sur ExpressionTrees, à savoir qu'ils permettent à la plaque de créer et de compiler du code, qui peut ensuite être réutilisé. Dans ce cas, c'est une fonction qui ressemble en fait à () => new TOut ().

Vous devez maintenant écrire la méthode du mappeur principal, qui copiera les valeurs. Nous allons suivre le chemin le plus simple: parcourir les propriétés de l'objet qui nous est parvenu à l'entrée et rechercher les propriétés du même nom parmi les propriétés de l'objet sortant. Si trouvé - copie, sinon - continuez.

 TOut outInstance = _activator(); PropertyInfo[] sourceProperties = source.GetType().GetProperties(); for (var i = 0; i < sourceProperties.Length; i++) { PropertyInfo sourceProperty = sourceProperties[i]; string propertyName = sourceProperty.Name; if (_outProperties.TryGetValue(propertyName, out PropertyInfo outProperty)) { object sourceValue = sourceProperty.GetValue(source); outProperty.SetValue(outInstance, sourceValue); } } return outInstance; 

Ainsi, nous avons entièrement formé la classe BasicMapper . Vous pouvez vous familiariser avec ses tests ici . Veuillez noter que la source peut être soit un objet d'un type particulier, soit un objet anonyme.

Performance et boxe


La réflexion est grande, mais lente. De plus, son utilisation fréquente augmente le trafic mémoire, ce qui signifie qu'il charge le GC, ce qui signifie qu'il ralentit encore plus l'application. Par exemple, nous venons d'utiliser les méthodes PropertyInfo.SetValue et PropertyInfo.GetValue . La méthode GetValue renvoie un objet dans lequel une certaine valeur est encapsulée (boxe). Cela signifie que nous avons reçu une allocation à partir de zéro.

Les mappeurs sont généralement situés là où vous devez transformer un objet en un autre ... Non, pas un, mais de nombreux objets. Par exemple, lorsque nous prenons quelque chose dans la base de données. Dans cet endroit, je voudrais voir des performances normales et ne pas perdre de mémoire sur une opération élémentaire.

Que pouvons-nous faire? ExpressionTrees nous aidera à nouveau. Le fait est que .NET vous permet de créer et de compiler du code "à la volée": nous le décrivons dans la représentation de l'objet, disons quoi et où nous allons l'utiliser ... et le compilons. Presque pas de magie.

Mappeur compilé


En fait, tout est relativement simple: nous avons déjà fait de nouvelles avec Expression.New (ConstructorInfo). Vous avez probablement remarqué que la méthode statique New s'appelle exactement de la même manière que l'opérateur. Le fait est que presque toute la syntaxe C # se reflète sous la forme de méthodes statiques de la classe Expression. Si quelque chose manque, cela signifie que vous recherchez le soi-disant "Sucre syntaxique."

Voici quelques opérations que nous utiliserons dans notre mappeur:

  • Déclaration de variable - Expression.Variable (Type, chaîne). L'argument Type indique quel type de variable sera créé, et chaîne est le nom de la variable.
  • Affectation - Expression.Assign (Expression, Expression). Le premier argument est ce que nous attribuons, et le deuxième argument est ce que nous attribuons.
  • L'accès à la propriété d'un objet est Expression.Property (Expression, PropertyInfo). Expression est le propriétaire de la propriété et PropertyInfo est la représentation objet de la propriété obtenue via Reflection.

Grâce à ces connaissances, nous pouvons créer des variables, accéder aux propriétés des objets et attribuer des valeurs aux propriétés des objets. Très probablement, nous comprenons également que ExpressionTree doit être compilé en un délégué de la forme Func <objet, TOut> . Le plan est le suivant: nous obtenons une variable qui contient les données d'entrée, créons une instance de type TOut et créons des expressions qui affectent une propriété à une autre.

Malheureusement, le code n'est pas très compact, donc je vous suggère de jeter un œil à l'implémentation de CompiledMapper tout de suite. Je n'ai apporté ici que des points clés.

Tout d'abord, nous créons une représentation objet du paramètre de notre fonction. Puisqu'il prend un objet en entrée, l'objet sera un paramètre.

 var parameter = Expression.Parameter(typeof(object), "source"); 

Ensuite, nous créons deux variables et une liste d'expressions dans lesquelles nous ajouterons séquentiellement des expressions d'affectation. L'ordre est important, car c'est ainsi que les commandes seront exécutées lorsque nous appellerons la méthode compilée. Par exemple, nous ne pouvons pas affecter une valeur à une variable qui n'a pas encore été déclarée.

De plus, de la même manière que dans le cas d'une implémentation naïve, nous parcourons la liste des propriétés de type et essayons de les faire correspondre par leur nom. Cependant, au lieu d'affecter immédiatement des valeurs, nous créons des expressions pour extraire des valeurs et attribuer des valeurs pour chaque propriété associée.

 Expression sourceValue = Expression.Property(sourceInstance, sourceProperty); Expression outValue = Expression.Property(outInstance, outProperty); expressions.Add(Expression.Assign(outValue, sourceValue)); 

Un point important: après avoir créé toutes les opérations d'affectation, nous devons retourner le résultat de la fonction. Pour ce faire, la dernière expression de la liste doit être Expression, contenant l'instance de la classe que nous avons créée. J'ai laissé un commentaire à côté de cette ligne. Pourquoi le comportement correspondant au mot clé return dans ExpressionTree ressemble-t-il à cela? Je crains que ce soit un problème distinct. Maintenant, je pense que c'est facile à retenir.

Eh bien, à la toute fin, nous devons compiler toutes les expressions que nous avons construites. Qu'est-ce qui nous intéresse ici? La variable body contient le "corps" de la fonction. Les «fonctions normales» ont un corps, non? Eh bien, que nous enfermons entre accolades. Ainsi, Expression.Block est exactement cela. Étant donné que les accolades sont également une portée, nous devons passer les variables qui y seront utilisées - dans notre cas sourceInstance et outInstance.

 var body = Expression.Block(new[] {sourceInstance, outInstance}, expressions); return Expression.Lambda<Func<object, TOut>>(body, parameter).Compile(); 

En sortie, on obtient Func <objet, TOut>, c'est-à-dire une fonction qui peut convertir des données d'un objet à un autre. Pourquoi de telles difficultés, demandez-vous? Je vous rappelle que, premièrement, nous voulions éviter la boxe lors de la copie des valeurs ValueType, et deuxièmement, nous voulions abandonner les méthodes PropertyInfo.GetValue et PropertyInfo.SetValue, car elles sont quelque peu lentes.

Pourquoi pas la boxe? Parce que le ExpressionTree compilé est un véritable IL, et pour l'exécution, il ressemble (presque) à votre code. Pourquoi le «mappeur compilé» est-il plus rapide? Encore une fois: parce que c'est tout simplement IL. Soit dit en passant, nous pouvons facilement confirmer la vitesse en utilisant la bibliothèque BenchmarkDotNet , et le benchmark lui-même peut être consulté ici .
La méthodeMoyenneErreurStddevRatioAlloué
AutoMapper1291,6 nous3.3173 us3.1030 us1,00312,5 KB
Velo_BasicMapper11 987,0 us33.8389 us28.2570 us9.283437,5 KB
Velo_CompiledMapper341,3 nous2.8230 us2.6407 us0,26312,5 KB

Dans la colonne Ratio, «CompiledMapper» (CompiledMapper) a montré un très bon résultat, même par rapport à AutoMapper (c'est la ligne de base, c'est-à-dire 1). Cependant, ne nous réjouissons pas: AutoMapper a des capacités nettement supérieures par rapport à notre vélo. Avec cette plaque, je voulais juste montrer que ExpressionTrees est beaucoup plus rapide que «l'approche de réflexion classique».

Résumé


J'espère avoir pu montrer que l'écriture de votre mappeur est assez simple. Reflection et ExpressionTrees sont des outils très puissants que les développeurs utilisent pour résoudre de nombreuses tâches différentes. Injection de dépendances, sérialisation / désérialisation, référentiels CRUD, création de requêtes SQL, utilisation d'autres langages comme scripts pour les applications .NET - tout cela se fait à l'aide de Reflection, Reflection.Emit et ExpressionTrees.

Et le mappeur? Mapper est un excellent exemple où vous pouvez apprendre tout cela.

PS: Si vous vouliez plus d'ExpressionTrees, je vous suggère de lire comment faire votre convertisseur JSON en utilisant cette technologie.

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


All Articles