Wie wir die Portierung von Produkten von C # nach C ++ automatisiert haben

Hallo Habr. In diesem Beitrag werde ich darüber sprechen, wie wir es geschafft haben, eine monatliche Veröffentlichung von Bibliotheken für die C ++ - Sprache zu organisieren, deren Quellcode in C # entwickelt wurde. Hier geht es nicht um verwaltetes C ++ oder gar um das Erstellen einer Brücke zwischen nicht verwaltetem C ++ und der CLR-Umgebung. Es geht um die Automatisierung der C ++ - Codegenerierung, bei der die API und die Funktionalität des ursprünglichen C # -Codes wiederholt werden.

Wir haben die notwendige Infrastruktur für die Übersetzung von Code zwischen Sprachen und die Emulation der Funktionen der .Net-Bibliothek selbst geschrieben und damit ein Problem gelöst, das normalerweise als akademisch angesehen wird. Auf diese Weise konnten wir auch monatliche Versionen von Pre-Donets-Produkten für die C ++ - Sprache veröffentlichen und den Code für jede Version aus der entsprechenden Version des C # -Codes abrufen. Gleichzeitig werden die Tests, die den ursprünglichen Code abdeckten, mit portiert, sodass Sie die Leistung der resultierenden Lösung zusammen mit speziell geschriebenen Tests in C ++ steuern können.

In diesem Artikel werde ich kurz die Geschichte unseres Projekts und die darin verwendeten Technologien beschreiben. Ich werde die Fragen der wirtschaftlichen Rechtfertigung nur am Rande ansprechen, da die technische Seite für mich viel interessanter ist. In den folgenden Artikeln der Reihe möchte ich mich mit Themen wie Codegenerierung und Speicherverwaltung sowie einigen anderen befassen, wenn die Community relevante Fragen hat.

Hintergrund


Zunächst befasste sich unser Unternehmen mit der Freigabe von Bibliotheken für die .Net-Plattform. Diese Bibliotheken bieten hauptsächlich APIs für die Arbeit mit einigen Dateiformaten (Dokumente, Tabellen, Folien, Grafiken) und Protokollen (E-Mail), die eine bestimmte Marktlücke für solche Lösungen einnehmen. Die gesamte Entwicklung wurde in C # durchgeführt.

Ende der 2000er Jahre beschloss das Unternehmen, in einen neuen Markt einzutreten und ähnliche Produkte für Java herauszubringen. Die Entwicklung von Grund auf würde offensichtlich eine Investition von Ressourcen erfordern, die mit der anfänglichen Entwicklung aller betroffenen Produkte vergleichbar ist. Die Option, den Donnet-Code in eine Ebene zu packen, die Aufrufe und Daten von Java nach .Net und umgekehrt übersetzt, wurde aus bestimmten Gründen ebenfalls abgelehnt. Stattdessen wurde die Frage gestellt, ob es in irgendeiner Weise möglich ist, vorhandenen Code vollständig auf die neue Plattform zu migrieren. Dies war umso relevanter, als es sich nicht um eine einmalige Aktion handelte, sondern um eine monatliche Veröffentlichung neuer Versionen jedes Produkts, die zwischen zwei Sprachen synchronisiert waren.

Es wurde beschlossen, die Entscheidung in zwei Teile zu teilen. Der erste - der sogenannte Porter - würde die C # -Quellcodesyntax in Java konvertieren und gleichzeitig .NET-Typen und -Methoden durch ihre Gegenstücke aus Java-Bibliotheken ersetzen. Die zweite - die Bibliothek - würde die Arbeit jener Teile der .Net-Bibliothek emulieren, für die es schwierig oder unmöglich ist, eine direkte Korrespondenz mit Java herzustellen, und dafür verfügbare Komponenten von Drittanbietern anziehen.

Für die grundsätzliche Durchführbarkeit eines solchen Plans sprach Folgendes:

  1. Ideologisch sind die Sprachen C # und Java ziemlich ähnlich - zumindest hinsichtlich der Typstruktur und Organisation der Arbeit mit dem Speicher;
  2. Es ging darum, Bibliotheken zu portieren, die GUI musste nicht portiert werden.
  3. , , - , System.Net System.Drawing;
  4. , .Net ( Framework, Standard Xamarin), .

Ich werde nicht auf Details eingehen, da sie einen separaten Artikel verdienen (und keinen). Ich kann nur sagen, dass es vom Beginn der Entwicklung bis zur Veröffentlichung des ersten Java-Produkts ungefähr zwei Jahre gedauert hat, und seitdem ist die Veröffentlichung von Java-Produkten eine regelmäßige Praxis des Unternehmens geworden. Während der Entwicklung des Projekts hat sich der Portier von einem einfachen Dienstprogramm, das Text nach festgelegten Regeln konvertiert, zu einem komplexen Codegenerator entwickelt, der mit der AST-Darstellung des Quellcodes arbeitet. Die Bibliothek ist auch mit Code überwachsen.

Der Erfolg der Java-Richtung bestimmte den Wunsch des Unternehmens, für sich selbst weiter in neue Märkte zu expandieren, und 2013 wurde die Frage nach der Veröffentlichung von Produkten für die C ++ - Sprache in einem ähnlichen Szenario aufgeworfen.

Formulierung des Problems


Um die Veröffentlichung positiver Produktversionen sicherzustellen, musste ein Framework erstellt werden, mit dem Sie C ++ - Code aus beliebigem C # -Code abrufen, kompilieren, überprüfen und dem Client übergeben können. Es ging um Bibliotheken mit Volumina von mehreren hunderttausend bis zu mehreren Millionen Zeilen (ohne Abhängigkeiten).

Gleichzeitig wurden die Erfahrungen mit dem Java-Porter berücksichtigt: Als es sich zunächst nur um ein einfaches Tool zum Konvertieren von Syntax handelte, entstand natürlich die Praxis, den portierten Code manuell zu finalisieren. Kurzfristig, konzentriert auf die schnelle Veröffentlichung von Produkten, war dies relevant, da dies den Entwicklungsprozess beschleunigen konnte. Langfristig erhöhte dies jedoch die Kosten für die Vorbereitung jeder Version für die Veröffentlichung erheblich, da jeder Übersetzungsfehler jedes Mal korrigiert werden musste.

Diese Komplexität war natürlich beherrschbar - zumindest indem nur Patches in den resultierenden Java-Code übertragen wurden, die als Differenz zwischen der Ausgabe des Portiers für die nächsten beiden Revisionen des C # -Codes berechnet werden. Dieser Ansatz ermöglichte es, jede portierte Leitung nur einmal zu korrigieren und in Zukunft den bereits entwickelten Code zu verwenden, bei dem keine Änderungen vorgenommen wurden. Bei der Entwicklung eines positiven Portiers bestand das Ziel jedoch darin, die Phase der Korrektur des portierten Codes zu beseitigen und stattdessen das Framework selbst zu reparieren. Somit würde jeder willkürlich seltene Übersetzungsfehler einmal korrigiert - im Porter-Code, und dieser Fix würde für alle zukünftigen Versionen aller portierten Produkte gelten.

Zusätzlich zum Porter selbst musste eine Bibliothek in C ++ entwickelt werden, mit der die folgenden Probleme gelöst werden konnten:

  1. Emulation der .Net-Umgebung in dem Maße, in dem der portierte Code funktionieren muss;
  2. Anpassung des portierten C # -Codes an die Realitäten von C ++ (Typstruktur, Speicherverwaltung, anderer Servicecode);
  3. Glätten der Unterschiede zwischen "neu geschriebenem C #" und C ++ selbst, um Programmierern, die nicht mit .NET-Paradigmen vertraut sind, die Verwendung von portiertem Code zu erleichtern.

Aus offensichtlichen Gründen wurde kein Versuch unternommen, .Net-Typen direkt Typen aus der Standardbibliothek zuzuordnen. Stattdessen wurde beschlossen, immer Typen aus seiner Bibliothek als Ersatz für die Donnet-Typen zu verwenden.

Viele Leser werden sofort fragen, warum sie keine vorhandenen Implementierungen wie Mono verwendet haben . Dafür gab es Gründe.

  1. Durch das Anziehen einer solchen fertigen Bibliothek wäre es möglich, nur die erste Anforderung zu erfüllen, nicht jedoch die zweite und nicht die dritte.
  2. Mono C# , , , .
  3. (API, , , C++, ) , .
  4. , .Net, . , , .

Theoretisch könnte eine solche Bibliothek vollständig über einen Port in C ++ übersetzt werden. Dies würde jedoch zu Beginn der Entwicklung einen voll funktionsfähigen Porter erfordern, da ohne eine Systembibliothek das Debuggen von portiertem Code grundsätzlich nicht möglich ist. Darüber hinaus wäre die Frage der Optimierung des übersetzten Codes der Systembibliothek noch akuter als beim Code portierter Produkte, da Aufrufe der Systembibliothek tendenziell zu einem Engpass werden.

Aus diesem Grund wurde beschlossen, die Bibliothek als eine Reihe von Adaptern zu entwickeln, die den Zugriff auf Funktionen ermöglichen, die bereits in Bibliotheken von Drittanbietern implementiert sind, jedoch über eine .NET-ähnliche API (ähnlich wie Java). Dies würde die Arbeit reduzieren und fertige, bereits optimierte C ++ - Komponenten verwenden.

Eine wichtige Voraussetzung für das Framework war, dass der portierte Code als Teil von Benutzeranwendungen funktionieren muss (soweit es Bibliotheken betrifft). Dies bedeutete, dass das Speicherverwaltungsmodell C ++ - Programmierern klar gemacht werden sollte, da wir nicht erzwingen können, dass beliebiger Clientcode in einer Garbage Collection-Umgebung ausgeführt wird. Die Verwendung von intelligenten Zeigern wurde als Kompromissmodell gewählt. Wie wir es geschafft haben, einen solchen Übergang sicherzustellen (insbesondere um das Problem der Zirkelverweise zu lösen), werde ich in einem separaten Artikel erörtern.

Eine weitere Anforderung war die Fähigkeit, Bibliotheken nicht nur zu portieren, sondern auch zu testen. Das Unternehmen verfügt über eine hohe Kultur der Testabdeckung seiner Produkte, und die Möglichkeit, in C ++ dieselben Tests auszuführen, die für den ursprünglichen Code geschrieben wurden, würde die Suche nach Problemen nach der Übersetzung erheblich vereinfachen.

Die verbleibenden Anforderungen (Startformat, Testabdeckung, Technologie usw.) betrafen hauptsächlich die Arbeitsmethoden für das Projekt und das Projekt. Ich werde nicht auf sie eingehen.

Geschichte


Bevor ich fortfahre, muss ich noch ein paar Worte zur Struktur des Unternehmens sagen. Das Unternehmen arbeitet remote, alle Teams sind verteilt. Die Entwicklung eines bestimmten Produkts liegt normalerweise in der Verantwortung eines Teams, das (fast immer) und (hauptsächlich) geografisch geordnet ist.

Die aktive Arbeit an dem Projekt begann im Herbst 2013. Aufgrund der verteilten Struktur des Unternehmens und auch aufgrund einiger Zweifel am Erfolg der Entwicklung wurden sofort drei Versionen des Frameworks eingeführt: Zwei davon dienten jeweils einem Produkt, das dritte drei auf einmal. Es wurde angenommen, dass dies dann die Entwicklung weniger effektiver Lösungen stoppen und gegebenenfalls Ressourcen neu zuweisen würde.

In Zukunft nahmen vier weitere Teams an der Arbeit am „gemeinsamen“ Framework teil, von denen zwei später ihre Entscheidung überdachten und sich weigerten, Produkte für C ++ zu veröffentlichen. Anfang 2017 wurde beschlossen, die Entwicklung einer der „individuellen“ Lösungen einzustellen und das entsprechende Team auf die Arbeit mit einem „gemeinsamen“ Rahmen zu übertragen. Die gestoppte Entwicklung setzte die Verwendung des Boehm GC als Mittel zur Speicherverwaltung voraus und enthielt eine viel umfangreichere Implementierung einiger Teile der Systembibliothek, die dann auf die „allgemeine“ Lösung übertragen wurde.

So kamen zwei Entwicklungen ins Ziel - nämlich die Veröffentlichung portierter Produkte - eine „Einzelperson“ und eine „Kollektiv“. Die ersten Releases basierend auf unserem („gemeinsamen“) Framework fanden im Februar 2018 statt. Anschließend wurden die Releases aller sechs Teams, die diese Lösung verwenden, monatlich veröffentlicht, und das Framework selbst wurde als separates Produkt des Unternehmens veröffentlicht. Es wurde sogar die Frage aufgeworfen, es Open Source zu machen, aber diese Diskussion hat sich noch nicht entwickelt.

Das Team, das weiterhin unabhängig an einem ähnlichen Framework arbeitete, veröffentlichte 2018 auch seine erste C ++ - Version.

Die ersten Veröffentlichungen enthielten verkürzte Versionen der Originalprodukte, wodurch die Ausstrahlung unwichtiger Teile so weit wie möglich verzögert werden konnte. In nachfolgenden Versionen wurde eine teilweise Erweiterung der Funktionalität vorgenommen (und erfolgt).

Organisation der Arbeit am Projekt


Die Organisation der gemeinsamen Arbeit an dem Projekt durch mehrere Teams konnte erhebliche Änderungen erfahren. Zunächst wurde beschlossen, dass ein großes „zentrales“ Team für die Entwicklung, Unterstützung und Korrektur des Frameworks verantwortlich ist, während kleine „Produkt“ -Teams, die an der Veröffentlichung der Endprodukte in C ++ beteiligt sind, hauptsächlich für den Versuch verantwortlich sind, ihre zu portieren Code und Feedback (Informationen zu Portierungs-, Kompilierungs- und Ausführungsfehlern). Ein solches Schema erwies sich jedoch als unproduktiv, da das zentrale Team mit Anfragen aller „Produkt“ -Teams überlastet war und sie erst fortfahren konnten, wenn die aufgetretenen Probleme behoben waren.

Aus Gründen, die weitgehend unabhängig vom Stand dieser Entwicklung sind, wurde beschlossen, das „zentrale“ Team aufzulösen und Mitarbeiter in „Produktteams“ zu überführen, die nun dafür verantwortlich sind, den Rahmen an ihre Bedürfnisse anzupassen. In diesem Fall würde jedes Team selbst entscheiden, ob es seine gemeinsamen Grundlagen nutzt oder einen eigenen Projektgabel generiert. Eine solche Erklärung der Frage war für das Java-Framework relevant, dessen Code zu diesem Zeitpunkt stabil war. Es war jedoch eine Konsolidierung der Bemühungen erforderlich, um die C ++ - Bibliothek so schnell wie möglich zu füllen, damit die Teams weiterhin zusammenarbeiten konnten.

Diese Form der Arbeit hatte auch ihre Nachteile, so dass in Zukunft eine weitere Reform durchgeführt wurde. Das „zentrale“ Team wurde zwar in kleinerer Zusammensetzung, aber mit unterschiedlichen Funktionen wiederhergestellt: Jetzt war es nicht mehr für die eigentliche Entwicklung des Projekts verantwortlich, sondern für die Organisation der gemeinsamen Arbeit daran. Dies beinhaltete Unterstützung für die CI-Umgebung, Organisation von Merge Request-Praktiken, regelmäßige Besprechungen mit Entwicklungsteilnehmern, Unterstützung der Dokumentation, Abdeckung von Tests, Unterstützung bei Architekturlösungen und Fehlerbehebung usw. Darüber hinaus übernahm das Team die Arbeit zur Beseitigung technischer Schulden und anderer ressourcenintensiver Bereiche. In diesem Modus wird die Entwicklung bis heute fortgesetzt.

So wurde das Projekt von mehreren (ungefähr fünf) Entwicklern initiiert und umfasste in den besten Zeiten ungefähr zwanzig Personen. Etwa zehn bis fünfzehn Personen, die für die Entwicklung und Unterstützung des Frameworks und die Veröffentlichung von sechs portierten Produkten verantwortlich sind, können in den letzten Jahren als stabiler Wert angesehen werden.

Der Autor dieser Zeilen trat Mitte 2016 in das Unternehmen ein und begann in einem der Teams zu arbeiten, die ihren Code mit einer „gemeinsamen“ Lösung sendeten. Im Winter desselben Jahres, als beschlossen wurde, das „zentrale“ Team neu zu bilden, wechselte ich in die Position ihrer Teamleiterin. Daher habe ich heute mehr als dreieinhalb Jahre Erfahrung im Projekt.

Die Autonomie der Teams, die für die Veröffentlichung portierter Produkte verantwortlich sind, hat dazu geführt, dass es sich in einigen Fällen für Entwickler als einfacher herausstellte, den Portier durch Betriebsmodi zu ergänzen, als Kompromisse bei seinem standardmäßigen Verhalten einzugehen. Dies erklärt mehr als erwartet die Anzahl der verfügbaren Optionen bei der Konfiguration des Porters.

Technologien


Es ist Zeit, über die im Projekt verwendeten Technologien zu sprechen. Porter ist eine in C # geschriebene Konsolenanwendung, da es in dieser Form einfacher ist, Skripte einzubetten, die Aufgaben wie "Port-Compile-Run-Tests" ausführen. Darüber hinaus gibt es eine GUI-Komponente, mit der Sie dieselben Ziele erreichen können, indem Sie auf die Schaltflächen klicken.

Die alte NRefactory- Bibliothek ist für das Parsen von Code und das Auflösen der Semantik verantwortlich . Leider war Roslyn zum Zeitpunkt des Projektbeginns noch nicht verfügbar, obwohl eine Migration darauf natürlich geplant ist.

Porter verwendet AST- Holzstegeum Informationen zu sammeln und C ++ - Ausgabecode zu generieren. Wenn C ++ - Code generiert wird, wird die AST-Darstellung nicht erstellt und der gesamte Code wird als einfacher Text gespeichert.

In vielen Fällen benötigt der Portier zusätzliche Informationen zur Feinabstimmung. Diese Informationen werden ihm in Form von Optionen und Attributen übermittelt. Die Optionen gelten sofort für das gesamte Projekt und ermöglichen es Ihnen, beispielsweise die Namen von Exportmakroelementen von Klassen oder C # -Vorprozessordefinitionen festzulegen, die bei der Codeanalyse verwendet werden. Attribute werden an Typen und Entitäten angehängt und bestimmen die für sie spezifische Verarbeitung (z. B. die Notwendigkeit, Schlüsselwörter "const" oder "veränderlich" für Klassenmitglieder zu generieren oder sie von der Portierung auszuschließen).

C # -Klassen und -Strukturen werden in C ++ - Klassen übersetzt, ihre Mitglieder und ausführbarer Code werden in die nächsten Entsprechungen übersetzt. Generische Typen und Methoden werden C ++ - Vorlagen zugeordnet. C # -Links werden in intelligente Zeiger (stark oder schwach) übersetzt, die in der Bibliothek definiert sind. Weitere Einzelheiten zu den Grundsätzen des Portiers werden in einem separaten Artikel erörtert.

Daher wird die ursprüngliche C # -Baugruppe in ein C ++ - Projekt konvertiert, das anstelle von .NET-Bibliotheken von unserer gemeinsam genutzten Bibliothek abhängt. Dies ist in der folgenden Abbildung dargestellt:



cmake wird zum Erstellen der Bibliothek und der portierten Projekte verwendet. Derzeit werden die Compiler VS 2017 und 2019 (Windows), GCC und Clang (Linux) unterstützt.

Wie oben erwähnt, sind die meisten unserer .Net-Implementierungen dünne Schichten von Bibliotheken von Drittanbietern, die den Großteil der Arbeit erledigen. Es enthält:

  • Skia - für die Arbeit mit Grafiken;
  • Botan - zur Unterstützung von Verschlüsselungsfunktionen;
  • Intensivstation - für die Arbeit mit Strings, Codierungen und Kulturen;
  • Libxml2 - für die Arbeit mit XML;
  • PCRE2 - zum Arbeiten mit regulären Ausdrücken;
  • zlib - um Komprimierungsfunktionen zu implementieren;
  • Boost - für verschiedene Zwecke;
  • mehrere andere Bibliotheken.

Sowohl der Portier als auch die Bibliothek werden in zahlreichen Tests behandelt. Bibliothekstests verwenden das gtest-Framework. Porter-Tests sind hauptsächlich in NUnit / xUnit geschrieben und in mehrere Kategorien unterteilt, die Folgendes bestätigen:

  • Die Porter-Ausgabe dieser Eingabedateien entspricht dem Ziel.
  • Die Ausgabe der portierten Programme nach dem Kompilieren und Starten stimmt mit dem Ziel überein.
  • NUnit-Tests aus Eingabeprojekten werden erfolgreich in gtest-Tests in portierten Projekten konvertiert und bestanden.
  • Die API für portierte Projekte funktioniert erfolgreich in C ++.
  • Die Auswirkungen einzelner Optionen und Attribute auf den Übersetzungsprozess sind wie erwartet.

Wir verwenden GitLab , um den Quellcode zu speichern . Jenkins wurde als CI-Umgebung ausgewählt . Portierte Produkte sind als Nuget-Pakete und als Download-Archive erhältlich.

Probleme


Während der Arbeit an dem Projekt hatten wir viele Probleme. Einige von ihnen wurden erwartet, während andere bereits im Prozess erschienen. Wir listen kurz die wichtigsten auf.

  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. Schwierigkeiten beim Schutz des geistigen Eigentums.
    Wenn C # -Code durch Boxed-Lösungen ziemlich leicht verschleiert werden kann, müssen Sie in C ++ zusätzliche Anstrengungen unternehmen, da viele Klassenmitglieder nicht ohne Konsequenzen aus Header-Dateien gelöscht werden können. Das Übersetzen generischer Klassen und Methoden in Vorlagen führt auch zu Schwachstellen, indem Algorithmen verfügbar gemacht werden.

Trotzdem ist das Projekt aus technischer Sicht sehr interessant. Wenn Sie daran arbeiten, können Sie viel lernen und viel lernen. Dazu trägt auch der akademische Charakter der Aufgabe bei.

Zusammenfassung


Im Rahmen des Projekts konnten wir ein System implementieren, das ein interessantes akademisches Problem aufgrund seiner direkten praktischen Anwendung löst. Wir haben eine monatliche Ausgabe der Firmenbibliotheken in einer Sprache organisiert, für die sie ursprünglich nicht vorgesehen waren. Es stellte sich heraus, dass die meisten Probleme vollständig lösbar sind und die daraus resultierende Lösung zuverlässig und praktisch ist.

In Kürze sollen zwei weitere Artikel veröffentlicht werden. Einer von ihnen beschreibt anhand von Beispielen ausführlich, wie ein Portier funktioniert und wie C # -Konstrukte in C ++ angezeigt werden. In einer anderen Rede werden wir darüber sprechen, wie wir es geschafft haben, die Kompatibilität von Speichermodellen in zwei Sprachen sicherzustellen.

Ich werde versuchen, die Fragen in den Kommentaren zu beantworten. Wenn die Leser Interesse an anderen Aspekten unserer Entwicklung zeigen und die Antworten über die Korrespondenz in den Kommentaren hinausgehen, werden wir die Möglichkeit in Betracht ziehen, neue Artikel zu veröffentlichen.

All Articles