Sérialisation binaire .Net sans référence à l'assembly avec le type source ou comment négocier avec BinaryFormatter

Dans cet article, je partagerai l'expérience de la sérialisation de type binaire entre assemblys, sans référence les uns aux autres. Il s'est avéré qu'il existe des cas réels et «légitimes» où vous devez désérialiser des données sans avoir de lien vers l'assembly où elles sont déclarées. Dans l'article, je parlerai du scénario dans lequel il a été nécessaire, je décrirai la méthode de solution, et je parlerai également des erreurs intermédiaires faites dans le processus de recherche

Présentation Énoncé du problème


Nous coopérons avec une grande entreprise travaillant dans le domaine de la géologie. Historiquement, la société a écrit des logiciels très différents pour travailler avec des données provenant de différents types d'équipements + analyse de données + prévisions. Hélas, tous ces logiciels sont loin d'être toujours «amicaux» entre eux, et le plus souvent du tout amicaux. Afin de consolider en quelque sorte les informations, un portail web est en cours de création, où différents programmes téléchargent leurs données sous forme de xml. Et le portail essaie de créer une vue plus-moins-complète. Une nuance importante: comme les développeurs du portail ne sont pas forts dans les domaines de chaque application, chaque équipe a fourni un module analyseur / convertisseur de données de son xml aux structures de données du portail.

Je travaille dans une équipe développant l'une des applications et nous avons assez facilement écrit un mécanisme d'exportation pour notre partie des données. Mais ici, l'analyste commercial a décidé que le portail central avait besoin de l'un des rapports que notre programme était en train de créer. C'est là que le premier problème est apparu: le rapport est reconstruit à chaque fois et les résultats ne sont enregistrés nulle part.
"Alors enregistrez-le!" Le lecteur y réfléchira probablement. Je le pensais aussi, mais j'ai été sérieusement déçu de l'exigence que le rapport soit déjà construit pour les données téléchargées. Rien à faire - vous devez transférer la logique.

Étape 0. Refactorisation. Rien de grave


Il a été décidé de séparer la logique de construction du rapport (en fait, il s'agit d'une étiquette à 4 colonnes, mais la logique est un wagon et un grand chariot) dans une classe distincte, et d'inclure le fichier avec cette classe par référence dans l'assemblage de l'analyseur. Par cela, nous:

  1. Évitez la copie directe
  2. Protection contre les écarts de version

Séparer la logique en une classe distincte n'est pas une tâche difficile. Mais alors tout n'était pas si rose: l'algorithme était basé sur des objets métier, dont le transfert ne correspondait pas à notre concept. J'ai dû réécrire les méthodes pour qu'elles n'acceptent que des types simples et qu'elles fonctionnent. Ce n'était pas toujours simple et par endroits, il fallait des solutions, dont la beauté restait en cause, mais dans l'ensemble, une solution fiable a été obtenue sans béquilles évidentes.

Il y avait un détail qui, comme vous le savez, sert souvent de refuge confortable au diable: nous avons hérité d'une approche étrange des générations précédentes de développeurs, selon laquelle certaines des données requises pour créer un rapport sont stockées dans la base de données en tant qu'objets .Net sérialisés en binaire ( questions "pourquoi?", "kaaak?", etc. hélas, resteront sans réponse en raison du manque de destinataires). Et dans la saisie des calculs, nous devons bien sûr les désérialiser.

Ces types, dont il était impossible de se débarrasser, nous avons également inclus "par référence", d'autant plus qu'ils n'étaient pas assez compliqués.

Étape 1. Désérialisation. Rappelez-vous le nom complet du type


Après avoir effectué les manipulations ci-dessus et effectué un test, j'ai reçu de manière inattendue une erreur d'exécution qui
[A] Namespace.TypeA ne peut pas être converti en [B] Namespace.TypeA. Le type A provient de 'Assembley.Application, Version = 1.0.0.0, Culture = neutral, PublicKeyToken = null' dans le contexte 'Default' à l'emplacement '...'. Le type B provient de 'Assmbley.Portal, Version = 1.0.0.0, Culture = neutral, PublicKeyToken = null' dans le contexte 'Default' à l'emplacement ''.
Les tout premiers liens Google m'ont dit que le fait est que BinaryFormatter écrit non seulement des données, mais également des informations de type dans le flux de sortie, ce qui est logique. Et étant donné que le nom complet du type contient l'assembly dans lequel il est déclaré, l'image de ce que j'ai essayé de désérialiser un type est complètement différente du point de vue de .Net

Après m'être gratté la tête, j'ai, en l'occurrence, pris une décision évidente mais, hélas, vicieuse, de remplacer un type spécifique de TypeA pendant la désérialisation dynamique . Tout fonctionnait. Les résultats du rapport ont convergé de haut en bas, les tests sur le serveur de build ont réussi. Avec un sentiment d'accomplissement, nous envoyons la tâche aux testeurs.

Étape 2. Le principal. Sérialisation entre les assemblages


Le calcul est venu rapidement sous la forme de bogues enregistrés par les testeurs, qui ont déclaré que l'analyseur côté portail était tombé à l'exception qu'il ne pouvait pas charger l'assembly Assembley.Application (assemblage de notre application). Première pensée - je n'ai pas nettoyé les références. Mais - non, tout va bien, personne ne se réfère. J'essaye de l'exécuter à nouveau dans le bac à sable - tout fonctionne. Je commence à soupçonner une erreur de construction, mais ici une idée me vient à l’esprit qui ne me plaît pas: je change le chemin de sortie de l’analyseur dans un dossier séparé, et non dans le répertoire bin partagé de l’application. Et le tour est joué - je reçois l'exception décrite. L'analyse de Stectrace confirme de vagues suppositions - la désérialisation est en baisse.

La prise de conscience a été rapide et douloureuse: le remplacement d'un type spécifique par dynamique n'a rien changé, BinaryFormatter a toujours créé un type à partir d'un assembly externe, uniquement lorsque l'assembly avec le type était à proximité, que le runtime l'a chargé naturellement et lorsque l'assembly a disparu - nous obtenons une erreur.

Il y avait une raison d'être triste. Mais googler a donné de l'espoir sous la forme de la classe SerializationBinder . Il s'est avéré que cela vous permet de déterminer le type de désérialisation de nos données. Pour ce faire, créez un héritier et définissez-y la méthode suivante.

public abstract Type BindToType(String assemblyName, String typeName); 

dans lequel vous pouvez retourner n'importe quel type pour des conditions données.
La classe BinaryFormatter a une propriété Binder où vous pouvez injecter votre implémentation.

Il semblerait qu'il n'y ait pas de problème. Mais encore une fois, les détails restent (voir ci-dessus).

Tout d'abord, vous devez traiter les demandes pour tous les types (et standard également).
Une option d'implémentation intéressante a été trouvée sur Internet ici , mais ils essaient d'utiliser le classeur par défaut de BinaryFormatter, sous la forme d'une construction

 var defaultBinder = new BinaryFormatter().Binder 

Mais en fait, la propriété Binder est nulle par défaut. Une analyse du code source a montré qu'à l'intérieur du BinaryFormatter, si Binder est vérifié, si c'est le cas, ses méthodes sont appelées, sinon, une logique interne est utilisée, ce qui se résume finalement à

  var assembly = Assembly.Load(assemblyName); return FormatterServices.GetTypeFromAssembly(assembly, typeName); 

Sans plus tarder, j'ai répété la même logique en moi.

Voici ce qui s'est passé dans la première implémentation

 public class MyBinder : SerializationBinder { public override Type BindToType(string assemblyName, string typeName) { if (assemblyName.Contains("<ObligatoryPartOfNamespace>") ) { var bindToType = Type.GetType(typeName); return bindToType; } else { var bindToType = LoadTypeFromAssembly(assemblyName, typeName); return bindToType; } } private Type LoadTypeFromAssembly(string assemblyName, string typeName) { if (string.IsNullOrEmpty(assemblyName) || string.IsNullOrEmpty(typeName)) return null; var assembly = Assembly.Load(assemblyName); return FormatterServices.GetTypeFromAssembly(assembly, typeName); } } 

C'est-à-dire on vérifie si l'espace de noms appartient au projet - on retourne le type du domaine courant, si le type de système - on charge depuis l'assembly correspondant

Cela semble logique. Nous commençons les tests: notre type vient - nous remplaçons, il est créé. Hourra! La chaîne vient - nous suivons la branche avec le chargement de l'assemblage. Ça marche! Champagne virtuel ouvert ...

Mais ici ... Un dictionnaire est livré avec des éléments de types d'utilisateurs: puisqu'il s'agit d'un type de système, alors ... évidemment, nous essayons de le charger à partir de l'assemblage, mais puisque les éléments qu'il contient sont nos types, d'ailleurs, avec une qualification complète (assemblage, version, clé ), puis nous retombons. (il devrait y avoir un sourire triste).

De toute évidence, vous devez modifier le nom d'entrée du type, en remplaçant les liens vers l'assemblage souhaité. J'espérais vraiment que pour le nom du type, il y avait un analogue de la classe AssemblyName , mais je n'ai rien trouvé de similaire. Écrire un analyseur universel avec remplacement n'est pas une tâche facile. Après une série d'expériences, je suis parvenu à la solution suivante: dans le constructeur statique, je soustrais les types à remplacer, puis je cherche leurs noms dans la ligne avec le nom du type créé, et quand je le trouve, je remplace le nom de l'assembly

  /// <summary> /// The types that may be changed to local /// </summary> protected static IEnumerable<Type> _changedTypes; static MyBinder() { var executingAssembly = Assembly.GetCallingAssembly(); var name = executingAssembly.GetName().Name; _changedTypes = executingAssembly.GetTypes().Where(t => t.Namespace != null && !t.Namespace.Contains(name) && !t.Name.StartsWith("<")); //!t.Namespace.Contains(name) - .     ,         // "<'      -     } private static string CorrectTypeName(string name) { foreach (var changedType in _changedTypes) { var ind = name.IndexOf(changedType.FullName); if (ind != -1) { var endIndex = name.IndexOf("PublicKeyToken", ind) ; if (endIndex != -1) { endIndex += +"PublicKeyToken".Length + 1; while (char.IsLetterOrDigit(name[endIndex++])) { } var sb = new StringBuilder(); sb.Append(name.Substring(0, ind)); sb.Append(changedType.AssemblyQualifiedName); sb.Append(name.Substring(endIndex-1)); name = sb.ToString(); } } } return name; } /// <summary> /// look up the type locally if the assembly-name is "NA" /// </summary> /// <param name="assemblyName"></param> /// <param name="typeName"></param> /// <returns></returns> public override Type BindToType(string assemblyName, string typeName) { typeName = CorrectTypeName(typeName); if (assemblyName.Contains("<ObligatoryPartOfNamespace>") || assemblyName.Equals("NA")) { var bindToType = Type.GetType(typeName); return bindToType; } else { var bindToType = LoadTypeFromAssembly(assemblyName, typeName); return bindToType; } } 

Comme vous pouvez le voir, je suis parti du fait que PublicKeyToken est le dernier dans la description du type. Ce n'est peut-être pas fiable à 100%, mais dans mes tests, je n'ai pas trouvé de cas où ce n'est pas le cas.

Ainsi, une ligne du formulaire
"System.Collections.Generic.Dictionary`2 [[SomeNamespace.CustomType, Assembley.Application, Version = 1.0.0.0, Culture = neutral, PublicKeyToken = null], [System.Byte [], mscorlib, Version = 4.0.0.0, Culture = neutre, PublicKeyToken = b77a5c561934e089]] »

se transforme en
"System.Collections.Generic.Dictionary`2 [[SomeNamespace.CustomType, Assembley.Portal, Version = 1.0.0.0, Culture = neutral, PublicKeyToken = null], [System.Byte [], mscorlib, Version = 4.0.0.0, Culture = neutre, PublicKeyToken = b77a5c561934e089]] »

Maintenant, tout fonctionnait enfin "comme une horloge". Il y avait des subtilités techniques mineures: si vous vous en souvenez, les fichiers que nous avons inclus étaient inclus dans le lien de l'application principale. Mais dans l'application principale, toutes ces danses ne sont pas nécessaires. Par conséquent, un mécanisme de compilation conditionnelle du formulaire

 BinaryFormatter binForm = new BinaryFormatter(); #if EXTERNAL_LIB binForm.Binder = new MyBinder(); #endif 

En conséquence, dans l'assemblage de portail, nous définissons la macro EXTERNAL_LIB, mais dans l'application principale - non

"Digression non lyrique"


En fait, dans le processus de codage, afin de vérifier rapidement la solution, j'ai fait une erreur de calcul, qui m'a probablement coûté un certain nombre de cellules nerveuses: pour commencer, je viens de coder en dur la substitution de type pour Dicitionary. En conséquence, après la désérialisation, il s'est avéré être un dictionnaire vide, qui s'est également «écrasé» lors de la tentative d'exécution de certaines opérations avec lui. Je commençais déjà à penser que vous ne pouviez pas tromper BinaryFormatter , et j'ai commencé des expériences désespérées avec une tentative d'écrire l'héritier du Dictionnaire. Heureusement, je me suis arrêté presque à temps et suis retourné à l'écriture d'un mécanisme de substitution universel et, le mettant en œuvre, j'ai réalisé que pour créer un dictionnaire, il ne suffit pas de redéfinir son type: vous devez toujours prendre soin des types de KeyValuePair <TKey, TValue>, Comparer, qui sont également demandés à Liant


Ce sont les aventures de sérialisation binaire. Je serais reconnaissant pour la rétroaction.

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


All Articles