Comment nous avons automatisé le portage des produits de C # vers C ++

Bonjour, Habr. Dans cet article, je parlerai de la façon dont nous avons réussi à organiser une version mensuelle des bibliothèques pour le langage C ++, dont le code source est développé en C #. Il ne s'agit pas de C ++ managé, ni même de créer un pont entre C ++ non managé et l'environnement CLR - il s'agit d'automatiser la génération de code C ++ qui répète l'API et les fonctionnalités du code C # d'origine.

Nous avons écrit l'infrastructure nécessaire pour traduire le code entre les langues et émuler nous-mêmes les fonctions de la bibliothèque .Net, résolvant ainsi un problème généralement considéré comme académique. Cela nous a également permis de commencer à publier des versions mensuelles de produits pré-Donets pour le langage C ++, en obtenant le code de chaque version à partir de la version correspondante du code C #. Dans le même temps, les tests qui couvraient le code d'origine sont portés avec lui et vous permettent de contrôler les performances de la solution résultante avec des tests spécialement écrits en C ++.

Dans cet article, je décrirai brièvement l'histoire de notre projet et les technologies qui y sont utilisées. Je n'aborderai les questions de justification économique qu'en passant, car le côté technique m'est beaucoup plus intéressant. Dans les articles suivants de la série, je prévois de m'attarder sur des sujets tels que la génération de code et la gestion de la mémoire, ainsi que sur certains autres, si la communauté a des questions pertinentes.

Contexte


Initialement, notre entreprise était engagée dans la sortie de bibliothèques pour la plate-forme .Net. Ces bibliothèques fournissent principalement des API pour travailler avec certains formats de fichiers (documents, tableaux, diapositives, graphiques) et protocoles (e-mail), occupant une certaine niche sur le marché pour de telles solutions. Tout le développement a été réalisé en C #.

À la fin des années 2000, la société a décidé de pénétrer un nouveau marché pour elle-même, commençant à lancer des produits similaires pour Java. Un développement à partir de zéro nécessiterait évidemment un investissement de ressources comparable au développement initial de tous les produits concernés. L'option d'encapsuler le code Donnet dans une couche qui traduit les appels et les données de Java vers .Net et vice versa a également été rejetée pour certaines raisons. Au lieu de cela, la question a été posée de savoir s'il était possible de migrer complètement le code existant vers la nouvelle plateforme. Cela était d'autant plus pertinent qu'il ne s'agissait pas d'une promotion ponctuelle, mais d'une version mensuelle des nouvelles versions de chaque produit, synchronisées entre deux langues.

Il a été décidé de diviser la décision en deux parties. Le premier - le soi-disant Porter - convertirait la syntaxe du code source C # en Java, remplaçant simultanément les types et méthodes .Net par leurs homologues des bibliothèques Java. La seconde - la bibliothèque - imiterait le travail des parties de la bibliothèque .Net pour lesquelles il est difficile ou impossible d'établir une correspondance directe avec Java, attirant pour cela des composants tiers disponibles.

En faveur de la faisabilité principale d'un tel plan, les intervenants suivants ont pris la parole:

  1. Sur le plan idéologique, les langages C # et Java sont assez similaires - du moins, avec la structure des types et l'organisation du travail avec la mémoire;
  2. Il s'agissait de porter des bibliothèques, il n'était pas nécessaire de porter l'interface graphique;
  3. , , - , System.Net System.Drawing;
  4. , .Net ( Framework, Standard Xamarin), .

Je n'entrerai pas dans les détails, car ils méritent un article séparé (et non un). Je peux seulement dire qu'il a fallu environ deux ans entre le début du développement et la sortie du premier produit Java, et depuis lors, la sortie des produits Java est devenue une pratique courante de l'entreprise. Au cours du développement du projet, le porteur est passé d'un simple utilitaire qui convertit le texte selon les règles établies à un générateur de code complexe qui fonctionne avec la représentation AST du code source. La bibliothèque est également envahie de code.

Le succès de la direction Java a déterminé le désir de l'entreprise de se développer davantage sur de nouveaux marchés, et en 2013, la question a été soulevée de la sortie de produits pour le langage C ++ dans un scénario similaire.

Formulation du problème


Afin d'assurer la publication de versions positives des produits, il était nécessaire de créer un cadre qui vous permettrait d'obtenir du code C ++ à partir de code C # arbitraire, de le compiler, de le vérifier et de le donner au client. Il s'agissait de bibliothèques avec des volumes allant de plusieurs centaines de milliers à plusieurs millions de lignes (hors dépendances).

Dans le même temps, l'expérience avec le porteur Java a été prise en compte: au départ, alors qu'il ne s'agissait que d'un simple outil de conversion de syntaxes, la pratique de finaliser manuellement le code porté s'est naturellement imposée. À court terme, axé sur la publication rapide des produits, cela était pertinent, car cela permettait d'accélérer le processus de développement, mais à long terme, cela augmentait considérablement les coûts de préparation de chaque version pour la publication en raison de la nécessité de corriger chaque erreur de traduction à chaque fois qu'elle se produisait.

Bien sûr, cette complexité était gérable - du moins en ne transférant que des correctifs dans le code Java résultant, qui sont calculés comme la différence entre la sortie du porteur pour les deux prochaines révisions du code C #. Cette approche a permis de corriger chaque ligne portée une seule fois et d'utiliser à l'avenir le code déjà modifié où aucune modification n'a été apportée. Cependant, lors du développement d'un porteur positif, l'objectif était de se débarrasser de l'étape de correction du code porté, au lieu de fixer le cadre lui-même. Ainsi, chaque erreur de traduction arbitrairement rare serait corrigée une fois - dans le code de porteur, et ce correctif s'appliquerait à toutes les versions futures de tous les produits portés.

En plus du porteur lui-même, il était également nécessaire de développer une bibliothèque en C ++ qui résoudrait les problèmes suivants:

  1. Émulation de l'environnement .Net dans la mesure où il est nécessaire que le code porté fonctionne;
  2. Adapter le code C # porté aux réalités du C ++ (structure de type, gestion de la mémoire, autre code de service);
  3. Lisser les différences entre «C # réécrit» et C ++ proprement dit, pour permettre aux programmeurs peu familiers avec les paradigmes .Net d'utiliser du code porté.

Pour des raisons évidentes, aucune tentative n'a été faite pour mapper directement les types .Net aux types de la bibliothèque standard. Au lieu de cela, il a été décidé de toujours utiliser les types de sa bibliothèque en remplacement des types Donnet.

De nombreux lecteurs se demanderont immédiatement pourquoi ils n'ont pas utilisé les implémentations existantes comme Mono . Il y avait des raisons à cela.

  1. En attirant une bibliothèque aussi complète, il serait possible de satisfaire uniquement la première exigence, mais pas la deuxième et non la troisième.
  2. Mono C# , , , .
  3. (API, , , C++, ) , .
  4. , .Net, . , , .

Théoriquement, une telle bibliothèque pourrait être traduite en C ++ entièrement à l'aide d'un port, cependant, cela nécessiterait un porteur entièrement fonctionnel au tout début du développement, car sans bibliothèque système, le débogage de tout code porté est impossible en principe. De plus, la question de l'optimisation du code traduit de la bibliothèque système serait encore plus aiguë que pour le code des produits portés, car les appels à la bibliothèque système ont tendance à devenir un goulot d'étranglement.

En conséquence, il a été décidé de développer la bibliothèque en tant qu'ensemble d'adaptateurs permettant d'accéder à des fonctions déjà implémentées dans des bibliothèques tierces, mais via une API de type .Net (similaire à Java). Cela réduirait le travail et utiliserait des composants C ++ prêts à l'emploi, déjà optimisés.

Une exigence importante pour le cadre était que le code porté devait pouvoir fonctionner dans le cadre des applications utilisateur (en ce qui concerne les bibliothèques). Cela signifiait que le modèle de gestion de la mémoire aurait dû être précisé aux programmeurs C ++, car nous ne pouvons pas forcer le code client arbitraire à s'exécuter dans un environnement de récupération de place. L'utilisation de pointeurs intelligents a été choisie comme modèle de compromis. Sur la façon dont nous avons réussi à assurer une telle transition (en particulier, pour résoudre le problème des références circulaires), je discuterai dans un article séparé.

Une autre exigence était la possibilité de porter non seulement des bibliothèques, mais aussi des tests pour celles-ci. La société possède une culture élevée de couverture des tests de ses produits, et la possibilité d'exécuter en C ++ les mêmes tests qui ont été écrits pour le code d'origine simplifierait considérablement la recherche de problèmes après la traduction.

Les exigences restantes (format de lancement, couverture des tests, technologie, etc.) concernaient principalement les méthodes de travail avec le projet et sur le projet. Je ne m'attarderai pas sur eux.

Récit


Avant de continuer, je dois dire quelques mots sur la structure de l'entreprise. L'entreprise travaille à distance, toutes les équipes y sont réparties. Le développement d'un certain produit est généralement la responsabilité d'une équipe, unie par la langue (presque toujours) et la géographie (principalement).

Le travail actif sur le projet a commencé à l'automne 2013. En raison de la structure distribuée de l'entreprise, et également en raison de certains doutes quant au succès du développement, trois versions du cadre ont été lancées immédiatement: deux d'entre elles servaient un produit chacune, la troisième en couvrait trois à la fois. On a supposé que cela arrêterait alors le développement de solutions moins efficaces et réallouerait les ressources si nécessaire.

À l'avenir, quatre autres équipes se sont jointes aux travaux sur le cadre «commun», dont deux ont par la suite reconsidéré leur décision et refusé de publier des produits pour C ++. Début 2017, il a été décidé d'arrêter le développement d'une des solutions «individuelles» et de transférer l'équipe correspondante pour travailler avec un cadre «commun». Le développement arrêté supposait l'utilisation du Boehm GC comme moyen de gestion de la mémoire et contenait une implémentation beaucoup plus riche de certaines parties de la bibliothèque système, qui a ensuite été transférée vers la solution «générale».

Ainsi, deux développements sont arrivés à la ligne d'arrivée - c'est-à-dire à la sortie des produits portés - un «individuel» et un «collectif». Les premières versions basées sur notre framework («commun») ont eu lieu en février 2018. Par la suite, les versions des six équipes utilisant cette solution sont devenues mensuelles et le framework lui-même a été publié en tant que produit distinct de la société. Même la question a été posée de le rendre open-source, mais cette discussion n'est pas encore développée.

L'équipe, qui a continué à travailler de manière indépendante sur un cadre similaire, a également publié sa première version C ++ en 2018.

Les premières versions contenaient des versions tronquées des produits originaux, ce qui permettait de retarder autant que possible le travail de diffusion de pièces sans importance. Dans les versions ultérieures, une addition par partie de fonctionnalités s'est produite (et se produit).

Organisation des travaux sur le projet


L'organisation d'un travail commun sur le projet par plusieurs équipes a réussi à subir des changements importants. Initialement, il a été décidé qu'une grande équipe «centrale» serait responsable du développement, du support et de la fixation du cadre, tandis que les petites équipes «produit» impliquées dans la sortie des produits finaux en C ++ seraient principalement chargées d'essayer de porter leur code et fournir des commentaires (informations sur les erreurs de portage, de compilation et d'exécution). Un tel schéma s'est toutefois avéré improductif, car l'équipe centrale était surchargée de demandes de toutes les équipes «produits», et elles ne pouvaient pas avancer tant que les problèmes rencontrés n'étaient pas résolus.

Pour des raisons largement indépendantes de l'état de cette évolution particulière, il a été décidé de dissoudre l'équipe «centrale» et de transférer les personnes vers des équipes «produit», désormais chargées de fixer le cadre à leurs besoins. Dans ce cas, chaque équipe déciderait elle-même d'utiliser ses bases communes ou de générer sa propre fourchette du projet. Un tel énoncé de la question était pertinent pour le cadre Java, dont le code était stable à l'époque, mais la consolidation des efforts était nécessaire pour remplir la bibliothèque C ++ dès que possible, afin que les équipes continuent de travailler ensemble.

Cette forme de travail avait aussi ses inconvénients, donc à l'avenir une autre réforme a été réalisée. L'équipe «centrale» a été reconstituée, bien que dans une composition plus petite, mais avec des fonctions différentes: désormais elle n'était pas responsable du développement proprement dit du projet, mais de l'organisation d'un travail commun sur celui-ci. Cela comprenait la prise en charge de l'environnement CI, l'organisation des pratiques de demande de fusion, la tenue de réunions régulières avec les participants au développement, la documentation de support, la couverture des tests, l'aide aux solutions architecturales et au dépannage, etc. En outre, l'équipe a entrepris des travaux pour éliminer la dette technique et d'autres domaines à forte intensité de ressources. Dans ce mode, le développement se poursuit à ce jour.

Ainsi, le projet a été initié par les efforts de plusieurs (environ cinq) développeurs et comptait au mieux une vingtaine de personnes. Une dizaine à quinze personnes responsables du développement et du support du framework et de la sortie de six produits portés peuvent être considérées comme une valeur stable ces dernières années.

L'auteur de ces lignes a rejoint l'entreprise mi-2016, commençant à travailler dans l'une des équipes diffusant leur code à l'aide d'une solution «commune». Au cours de l'hiver de la même année, quand il a été décidé de recréer l'équipe «centrale», je suis passée au poste de chef d'équipe. Ainsi, mon expérience dans le projet aujourd'hui est de plus de trois ans et demi.

L'autonomie des équipes chargées de la sortie des produits portés a conduit au fait que dans certains cas, il s'est avéré plus facile pour les développeurs de compléter le porteur avec des modes de fonctionnement que de faire des compromis sur le comportement à adopter par défaut. Cela explique plus que vous ne le pensez, le nombre d'options disponibles lors de la configuration du portier.

Les technologies


Il est temps de parler des technologies utilisées dans le projet. Porter est une application console écrite en C #, car sous cette forme, il est plus facile d'incorporer dans des scripts qui effectuent des tâches telles que des "tests d'exécution de compilation de port". De plus, il existe un composant GUI qui vous permet d'atteindre les mêmes objectifs en cliquant sur les boutons.

L'ancienne bibliothèque NRefactory est chargée d'analyser le code et de résoudre la sémantique . Malheureusement, au moment du démarrage du projet, Roslyn n'était pas encore disponible, bien que la migration vers celui-ci, bien sûr, soit dans nos plans.

Porter utilise des passerelles en bois ASTpour collecter des informations et générer du code de sortie C ++. Lorsque du code C ++ est généré, la représentation AST n'est pas créée et tout le code est enregistré en texte brut.

Dans de nombreux cas, le porteur a besoin d'informations supplémentaires pour un réglage fin. Ces informations lui sont transmises sous forme d'options et d'attributs. Les options s'appliquent immédiatement à l'ensemble du projet et vous permettent de définir, par exemple, les noms des membres de macro d'exportation des classes ou des définitions de préprocesseur C # utilisés dans l'analyse de code. Les attributs sont accrochés aux types et aux entités et déterminent le traitement qui leur est spécifique (par exemple, la nécessité de générer des mots clés «const» ou «mutable» pour les membres de la classe ou de les exclure du portage).

Les classes et structures C # sont traduites en classes C ++, leurs membres et le code exécutable sont traduits dans les équivalents les plus proches. Les types et méthodes génériques sont mappés aux modèles C ++. Les liens C # sont traduits en pointeurs intelligents (forts ou faibles) définis dans la bibliothèque. Plus de détails sur les principes du porteur seront discutés dans un article séparé.

Ainsi, l'assembly C # d'origine est converti en projet C ++, qui au lieu des bibliothèques .Net dépend de notre bibliothèque partagée. Ceci est illustré dans le diagramme suivant:



cmake est utilisé pour construire la bibliothèque et les projets portés. Les compilateurs VS 2017 et 2019 (Windows), GCC et Clang (Linux) sont actuellement pris en charge.

Comme mentionné ci-dessus, la plupart de nos implémentations .Net sont de fines couches de bibliothèques tierces qui effectuent la majeure partie du travail. Il comprend:

  • Skia - pour travailler avec des graphiques;
  • Botan - pour prendre en charge les fonctions de cryptage;
  • ICU - pour travailler avec des chaînes, des encodages et des cultures;
  • Libxml2 - pour travailler avec XML;
  • PCRE2 - pour travailler avec des expressions régulières;
  • zlib - pour implémenter des fonctions de compression;
  • Boost - à des fins diverses;
  • plusieurs autres bibliothèques.

Le portier et la bibliothèque sont couverts par de nombreux tests. Les tests de bibliothèque utilisent le framework gtest. Les tests de Porter sont écrits principalement en NUnit / xUnit et sont divisés en plusieurs catégories, certifiant que:

  • la sortie du porteur sur ces fichiers d'entrée correspond à la cible;
  • la sortie des programmes portés après compilation et lancement coïncide avec la cible;
  • Les tests NUnit des projets d'entrée sont convertis avec succès en tests gtest dans les projets portés et réussissent;
  • L'API des projets portés fonctionne correctement en C ++;
  • l'impact des options et des attributs individuels sur le processus de traduction est comme prévu.

Nous utilisons GitLab pour stocker le code source . Jenkins a été choisi comme environnement CI . Les produits portés sont disponibles sous forme de packages Nuget et d'archives de téléchargement.

Problèmes


En travaillant sur le projet, nous avons dû faire face à beaucoup de problèmes. Certains étaient attendus, tandis que d'autres sont déjà apparus dans le processus. Nous énumérons brièvement les principaux.

  1. .Net C++.
    , C++ Object, RTTI. .Net STL.
  2. .
    , , . , C# , C++ — .
  3. .
    — . , . , .
  4. .
    C++ , , .
  5. C#.
    C# , C++. , :

    • , ;
    • , (, yeild);
    • , (, , , C#);
    • , C++ (, C# foreground-).
  6. .
    , .Net , .
  7. .
    - , , «» , . , , , , using, -. . , .
  8. .
    , , , , , / - .
  9. .
    . , . , , , .
  10. Difficultés de protection de la propriété intellectuelle.
    Si le code C # est assez facilement obscurci par des solutions encadrées, alors en C ++, vous devez faire des efforts supplémentaires, car de nombreux membres de classe ne peuvent pas être supprimés des fichiers d'en-tête sans conséquences. La traduction de classes et de méthodes génériques en modèles crée également des vulnérabilités en exposant des algorithmes.

Malgré cela, le projet est très intéressant d'un point de vue technique. Travailler dessus vous permet d'apprendre beaucoup et d'apprendre beaucoup. La nature académique de la tâche y contribue également.

Sommaire


Dans le cadre du projet, nous avons pu mettre en place un système qui résout un problème académique intéressant pour son application pratique directe. Nous avons organisé un numéro mensuel des bibliothèques d'entreprise dans une langue à laquelle elles n'étaient pas initialement destinées. Il s'est avéré que la plupart des problèmes sont entièrement résolubles et la solution qui en résulte est fiable et pratique.

Bientôt, il est prévu de publier deux autres articles. L'un d'eux décrira en détail, avec des exemples, comment fonctionne un porteur et comment les constructions C # sont affichées en C ++. Dans un autre discours, nous parlerons de la façon dont nous avons réussi à assurer la compatibilité des modèles de mémoire de deux langues.

Je vais essayer de répondre aux questions dans les commentaires. Si les lecteurs s'intéressent à d'autres aspects de notre développement et que les réponses commencent à dépasser la correspondance dans les commentaires, nous envisagerons la possibilité de publier de nouveaux articles.

All Articles