Seit vier Jahren entwickelt Celonis eine eigene Query Engine zur Beantwortung von Process-Mining-Abfragen. Die Anforderungen an eine Entwicklungs- und Testinfrastruktur haben sich seit den Anfängen als kleines C++-Projekt auf mehreren Plattformen deutlich verändert. Entwicklungs- und Testlösungen sind in C++ nicht so etabliert wie in anderen Programmiersprachen wie Java; darum haben wir es nach und nach mit verschiedenen Strategien probiert: Im Folgenden präsentieren wir Ihnen unsere Erfahrungen gruppiert anhand von fünf Kategorien: Entwicklungssystem, Abhängigkeitsmanagement, Entwicklung, Tests und Bereitstellung.
CMake ist heute de facto das Standardentwicklungssystem für plattformunabhängige C++-Projekte. Da es als Meta-Entwicklungssystem fungiert, lassen sich plattformspezifische Build-Dateien erzeugen. Zwar werden neuere Entwicklungssysteme immer beliebter, doch sind mehr Entwickler mit CMake vertraut, ermöglichen mehr Abhängigkeiten eine nahtlose Integration und ist die Software deutlich reifer geworden.
Anfänglich verwendeten wir CMake für UNIX-Builds (Linux & macOS), für Windows hingegen nutzten wir direkt die Visual-Studio-Projektdateien. Dafür gab es mehrere Gründe:
Erstens musste die Verzeichnisstruktur für Source- und Header-Dateien manuell eingerichtet werden, da Visual Studio diese Struktur nicht selbst erkennen konnte. Das Ergebnis war eine Liste von Source- und Header-Dateien in der IDE. Das Verfahren wurde erst mit CMake 3.8 vereinfacht.
Zweitens ist das Importieren vorhandener VS-Projektdateien kein leichtes Unterfangen. Viele der Einstellungen ließen sich nicht einfach in CMake-Anweisungen übersetzen. Ein Beispiel dafür ist eine Multi-Core-Kompilierung, bei der nicht ganz klar ist, welchen Effekt die Einstellungen in der Benutzeroberfläche von Visual Studio tatsächlich haben und wie sie sich in Visual-Studio-Dateien einrichten bzw. später via MSBuild aufrufen lassen. Eine sorgfältige Portierung aller Compiler- und Abhängigkeitseinstellungen schien die Mühe nicht wert.
Als größere Änderungen an der Abhängigkeits- und Projektstruktur sowieso zahlreiche Anpassungen an den verschiedenen Entwicklungssystemen erforderlich machten, führten wir die beiden Entwicklungssystemansätze endlich zusammen und verwendeten nur noch CMake.
Zu Beginn des Projekts wurden Abhängigkeiten zusammen mit dem Code in das Repository eingecheckt. Das ist eine einfache Methode, mit der Entwickler die Möglichkeit erhalten, den Code mit den erforderlichen Abhängigkeiten auszuchecken, ohne sich Gedanken über Details machen zu müssen. Für Windows gab es sogar vordefinierte, eingecheckte Binärdateien. Die Erkenntnis, dass damit zahlreiche Nachteile verbunden sind, kam relativ schnell. Das Repository wurde größer, Zusammenführungskonflikte wurden komplizierter, und die Verknüpfung von Code und Abhängigkeiten war ein aktuelles Risiko.
Um diese Herausforderungen pragmatisch zu lösen, wechselten wir zum Klonen von Abhängigkeiten und vordefinierten Binärdateien via Shell-Skript. Dabei verloren wir die automatische Synchronizität der eingecheckten Variante, da für Abhängigkeitsversionen an einem bestimmten Commit-Zeitpunkt nicht mehr strikt garantiert werden konnte, dass beide Versionen identisch sind.
Das Shell-Skript hätte eine Abhängigkeit zu einem spezifischen Commit klonen müssen. Das Referenzieren von Verzweigungen oder Tags war deswegen riskant, weil sich diese später ändern konnten, was eine Reproduktion älterer Builds erschweren würde. Abhängigkeitsmanagement ähnelte also stark Git-Submodulen. In beiden Fällen würde der Entwickler einen weiteren Befehl ausführen müssen (oder zumindest einen Befehl zum Klonen anpassen); bei Git-Submodulen wurde die Versionsverwaltung für Abhängigkeiten jedoch strikt von Git übernommen.
Darum entschieden wir uns schnell für den Einsatz von Git-Submodulen. Beim Hinzufügen einer Abhängigkeit als Git-Submodul würde diese am spezifischen Commit hinzugefügt, auf den HEAD zu dem Zeitpunkt verwiesen hat, selbst wenn HEAD eine symbolische Referenz war. So sind Commits des Codes mit Commits der Abhängigkeit verbunden und sollten in der Regel die Möglichkeit bieten, ältere Versionen der Software zu reproduzieren.
Beispiel:
$ git clone https://github.com/boostorg/boost.git
$ cd boost
$ git symbolic-ref HEAD
refs/heads/master
$ git show-ref refs/heads/master
3d189428991b0434aa1f2236d18dac1584e6ab84 refs/heads/master
$ cd ../test-repo
$ git submodule add https://github.com/boostorg/boost.git
Wenn diese Änderungen nun als Commit übernommen werden und eine andere Person den Code zu einem späteren Punkt auscheckt, wird Folgendes angezeigt:
$ cd test-repo
$ git submodule update —init
$ cd boost
$ git symbolic-ref HEAD
fatal: ref HEAD is not a symbolic ref
$ git show-ref HEAD
3d189428991b0434aa1f2236d18dac1584e6ab84 refs/remotes/origin/HEAD
Wir sehen also, dass HEAD die Informationen über eine symbolische Referenz verloren hat und sich nur an 3d189428991b0434aa1f2236d18dac1584e6ab84 erinnert.
Zwar ist das theoretisch eine tolle Garantie, doch wurde das Verfahren von Entwicklern nur selten genutzt. Das Aktualisieren von Abhängigkeiten war ein Prozess, der mehrere Schritte und Commits in verschiedenen Repositories beinhaltete. Intelligentere Git-Befehle schienen entweder nicht besonders gut dokumentiert oder oftmals eine Funktion späterer Git-Versionen zu sein.
Außerdem erwiesen sich Git-Submodule damals als nicht besonders zuverlässig und beliebt, da durch CI sowie den Mangel an Tools und Support Probleme entstanden. Ein Wechseln von Verzweigungen sorgte bei Git-Submodulen ebenfalls für Schwierigkeiten, sodass die Gesamterfahrung für Entwickler eher schlecht war. Zudem kam es zu Engpässen bei der Aktualisierung von Abhängigkeiten, da dies in der Regel nur von wenigen Personen vorgenommen wurde. Darüber hinaus wurde die Integration mit den Entwicklungssystemen, die eine andere Bereitstellung erforderten, nicht angegangen.
Angesichts dieser Erfahrungen schien es eine gute Idee zu sein, sich um ein Abhängigkeitsmanagement zu bemühen, das die Verwaltung von Versionen, Artefakten und Erstellung zusammenführen würde. Aus diesem Grund stiegen wir auf Conan um. Conan ist ein Python-basierter C++-Paket-Manager, der alle drei Anforderungen erfüllt. Jedes Paket wird von einer Python-Datei beschrieben, die sämtliche Metadaten und Befehle zum Erstellen, Testen und Bereitstellen enthält. Vordefinierte Pakete lassen sich in einem Artefakt-Repository so bereitstellen, dass Entwickler und die kontinuierliche Integration keine konstante Erstellung vornehmen müssen. Außerdem verfügt Conan über eine gut ausgereifte CMake-Integration, die das Importieren von Paketen als CMake-Zielen besonders leicht macht.
Am Anfang eines Softwareprojekts ist die Entwicklung noch kein Problem. Wenn das Team auf wenige Personen begrenzt ist und es keine richtigen Freigabeprozesse gibt, wird die Software meist von den Entwicklern selbst entwickelt. So haben wir auch angefangen. Freizugebende Builds wurden von einem der Entwickler entwickelt. Deswegen kam es schnell zu Engpässen. Angesichts der steigenden Zahl von Entwicklern und Tag für Tag mehr übermittelter Aufgaben führten Änderungen oftmals zu Regressionen, die erst sehr spät erkannt wurden.
Darum gewann eine kontinuierliche Entwicklung relevanter Verzweigungen an Bedeutung. Da wir für Repositories Produkte von Atlassian (Bitbucket Server) sowie Ticket Management (Jira) verwendeten, war die logische Wahl für einen Entwicklungsserver das entsprechende Atlassian-Produkt Bamboo. Das bietet den Vorteil, dass Builds mit Verzweigungen und Tickets mit Builds verknüpft sind (wenn Verzweigungen richtig benannt werden).
Angesichts einer wachsenden Heterogenität der erforderlichen Entwicklungsumgebungen für immer mehr Projekte, die mit dem Bamboo-Server entwickelt werden sollten, wurde rasch der Einsatz von Docker-basierten Builds nötig. Darum begannen wir, den von Bamboo seit Version 6.4 bereitgestellten Docker Runner intensiv zu nutzen. Während anfänglich nur die Hauptentwicklungsverzweigung sowie mehrere Release-Verzweigungen entwickelt wurden, weiten wir den Umfang der entwickelten Verzweigungen nun immer weiter aus. Je nach Projekt kann es sich dabei um Verzweigungen, die über offene Pull-Anfragen verfügen, bzw. um sämtliche Verzweigungen handeln, die im Push-Verfahren in das Remote-Repository übermittelt werden.
Das Entwickeln von Verzweigungen ist nur dann ein effektives Mittel gegen Regressionen, wenn es ein umfassendes Testprotokoll zum Testen der einzelnen Builds gibt. Andernfalls wäre lediglich eine Suche nach Kompilierungsfehlern möglich. Da die Query Engine ein Datenbankprodukt ist, besteht die offensichtliche Testmethode aus dem Laden einiger Daten und dem anschließenden Ausführen verschiedener Abfragen für diese Daten. Auf diese Weise begannen wir mit dem Testen; diese Methode macht noch heute den größten Teil der inzwischen über 2.300 Tests aus.
Doch nicht alle Abschnitte lassen sich mit Abfragen effizient erreichen. Für die Routinen im Datenmanagement, interne Puffer oder Komprimierung wurden zusätzliche Verfahren benötigt, die gründlich getestet werden mussten. Darum entschieden wir uns für den Einsatz von Catch (und später Catch2) als C++-Test-Framework – besonders mit Blick auf diese internen, aber extrem wichtigen Bestandteile unserer Software. Außerdem begannen wir damit, Abdeckungsdaten von llcm-cov zu nutzen, um jenen Abschnitt des Codes zu ermitteln, den wir mit unserem Test ausführen. Dadurch erhalten wir Hinweise darauf, wo wir Tests noch ausbauen müssen.
Darüber hinaus werden jede Nacht Tests unter Verwendung von Säuberern ausgeführt. So können wir Probleme, die durch nicht definiertes Verhalten bzw. spezifische Adress- oder Threading-Bedingungen entstehen, verhindern.
Zu Beginn des Projekts war die Query Engine eine einzelne ausführbare Datei, deren Integration und Nutzung von der Software übernommen werden musste, in die sie eingebettet war.
Bei Linux konnte jeder Entwickler einfach die ausführbare Datei lokal erstellen und dann die Query Engine verwenden. Bei Windows und macOS war das nicht möglich, da es vor der Einrichtung eines Entwicklungsservers keine Option für Entwickler gab, Entwicklungsumgebungen für diese Plattformen einzurichten.
Darum entschieden wir uns für ein Einchecken der Binärdateien in das Repository. Das hieß, dass Entwickler, die die Query Engine einer der beiden Plattformen in einem anderen Projekt verwenden wollten, nur noch den neuesten Code auschecken mussten, so eine aktuelle Version davon eingecheckt wurde. Dies führte rasch dazu, dass reihenweise Binärdateien in das Repository committet wurden. Dadurch dauerten Klon- und andere Vorgänge im Repository deutlich länger. Da die Query Engine in einem Java-Projekt die meiste Zeit Verwendung findet, entschieden wir uns nun für die Erstellung eines entsprechenden Maven-Pakets. So werden die plattformspezifischen ausführbaren C++-Dateien separat verpackt.
Unsere empfohlenen und aktuellen Best Practices für die Einrichtung eines C++-Entwicklungssystems sehen wie folgt aus:
Sie sollten in fast jedem Fall CMake verwenden.
Sie sollten wahrscheinlich Conan verwenden.
Ab einer Teamgröße von drei Personen sollten Sie wahrscheinlich über einige automatische Builds verfügen.
Fehlgeschlagene Tests sollten den Build stoppen.
Grundsätzlich versuchen wir, uns an zwei Dinge zu erinnern, wenn wir im Zusammenhang mit dem Entwicklungssystem Änderungen vornehmen: Iterative Änderungen und Entwicklererfahrung.
Wir wollen Änderungen ausschließlich iterativ vornehmen, damit die Builds stets so stabil wie möglich sind. Das wird umso wichtiger, je größer das Team und je höher die Komplexität der Software ist.
Außerdem sollte die Entwicklererfahrung der Hauptfaktor bei den Entscheidungsprozessen für Änderungen am Entwicklungssystem sein. Unsere Erfahrung mit Git-Submodulen hat gezeigt, dass die Einführung von Technologie, die keine gute Benutzererfahrung bietet, Folgen für die individuelle Produktverantwortung von Entwicklern sowie die Agilität des ganzen Teams im Ganzen hat.