Aufgabenbasierte Build-Systeme

Auf dieser Seite werden aufgabenbasierte Build-Systeme und ihre Funktionsweise beschrieben sowie einige von den Problemen, die bei aufgabenbasierten Systemen auftreten können. Nach Shell-Skripts sind aufgabenbasierte Build-Systeme die nächste logische Weiterentwicklung des Builds.

Aufgabenbasierte Build-Systeme verstehen

In einem aufgabenbasierten Build-System ist die grundlegende Arbeitseinheit die Aufgabe. Jede Aufgabe ist ein Skript, das eine beliebige Logik ausführen kann, und Aufgaben geben andere Aufgaben als Abhängigkeiten an, die davor ausgeführt werden müssen. Die meisten der gängigsten Build-Systeme, z. B. Ant, Maven, Gradle, Grunt und Rake, sind aufgabenbasiert. Anstelle von Shell-Skripts benötigen die meisten modernen Build-Systeme Build-Dateien, mit denen beschrieben wird, wie der Build ausgeführt wird.

Nehmen Sie dieses Beispiel aus dem Ant-Handbuch an:

<project name="MyProject" default="dist" basedir=".">
   <description>
     simple example build file
   </description>
   <!-- set global properties for this build -->
   <property name="src" location="src"/>
   <property name="build" location="build"/>
   <property name="dist" location="dist"/>

   <target name="init">
     <!-- Create the time stamp -->
     <tstamp/>
     <!-- Create the build directory structure used by compile -->
     <mkdir dir="${build}"/>
   </target>
   <target name="compile" depends="init"
       description="compile the source">
     <!-- Compile the Java code from ${src} into ${build} -->
     <javac srcdir="${src}" destdir="${build}"/>
   </target>
   <target name="dist" depends="compile"
       description="generate the distribution">
     <!-- Create the distribution directory -->
     <mkdir dir="${dist}/lib"/>
     <!-- Put everything in ${build} into the MyProject-${DSTAMP}.jar file -->
     <jar jarfile="${dist}/lib/MyProject-${DSTAMP}.jar" basedir="${build}"/>
   </target>
   <target name="clean"
       description="clean up">
     <!-- Delete the ${build} and ${dist} directory trees -->
     <delete dir="${build}"/>
     <delete dir="${dist}"/>
   </target>
</project>

Die Build-Datei ist in XML geschrieben und definiert einige einfache Metadaten zum Build zusammen mit einer Liste von Aufgaben (die <target>-Tags in der XML-Datei). Ant verwendet das Wort target für eine Aufgabe und das Wort task für Befehle. Bei jeder Aufgabe wird eine Liste möglicher, von Ant definierter Befehle ausgeführt, die hier das Erstellen und Löschen von Verzeichnissen, die Ausführung von javac und das Erstellen einer JAR-Datei umfassen. Diese Gruppe von Befehlen kann mit vom Nutzer bereitgestellten Plug-ins erweitert werden, um alle Arten von Logik abzudecken. In jeder Aufgabe können auch die Aufgaben definiert werden, von denen sie abhängen. Diese Abhängigkeiten bilden einen azyklischen Graphen, wie in Abbildung 1 dargestellt.

Acrylgrafik, die Abhängigkeiten zeigt

Abbildung 1. Ein azyklisches Diagramm, das Abhängigkeiten zeigt

Nutzer führen Builds aus, indem sie Aufgaben im Befehlszeilentool von Ant bereitstellen. Wenn ein Nutzer beispielsweise ant dist eingibt, führt Ant folgende Schritte aus:

  1. Lädt eine Datei namens build.xml im aktuellen Verzeichnis und parst sie, um die in Abbildung 1 gezeigte Grafikstruktur zu erstellen.
  2. Sucht nach der Aufgabe mit dem Namen dist, die in der Befehlszeile angegeben wurde, und stellt fest, dass sie eine Abhängigkeit von der Aufgabe mit dem Namen compile hat.
  3. Es wird nach der Aufgabe mit dem Namen compile gesucht und festgestellt, dass sie eine Abhängigkeit von der Aufgabe mit dem Namen init hat.
  4. Sucht nach der Aufgabe mit dem Namen init und stellt fest, dass sie keine Abhängigkeiten hat.
  5. Führt die in der Aufgabe init definierten Befehle aus.
  6. Führt die in der Aufgabe compile definierten Befehle aus, sofern alle Abhängigkeiten dieser Aufgabe ausgeführt wurden.
  7. Führt die in der Aufgabe dist definierten Befehle aus, sofern alle Abhängigkeiten dieser Aufgabe ausgeführt wurden.

Am Ende entspricht der von Ant beim Ausführen der Aufgabe dist ausgeführte Code dem folgenden Shell-Skript:

./createTimestamp.sh
mkdir build/
javac src/* -d build/
mkdir -p dist/lib/
jar cf dist/lib/MyProject-$(date --iso-8601).jar build/*

Wenn die Syntax entfernt wird, sind die Build-Datei und das Build-Skript nicht zu unterschiedlich. Dabei haben wir aber schon viel erreicht. Wir können neue Buildfiles in anderen Verzeichnissen erstellen und diese verknüpfen. Neue Aufgaben, die von vorhandenen Aufgaben abhängen, können wir auf einfache und komplexe Weise hinzufügen. Wir brauchen nur den Namen einer einzelnen Aufgabe an das ant-Befehlszeilentool zu übergeben und bestimmt alles, was ausgeführt werden muss.

Thet ist eine alte Software, die 2000 auf den Markt kam. Andere Tools wie Maven und Gradle haben in den letzten Jahren Ant verbessert und wurden im Wesentlichen durch Funktionen wie die automatische Verwaltung externer Abhängigkeiten und eine sauberere Syntax ohne XML ersetzt. Diese neueren Systeme bleiben jedoch unverändert: Entwickler können Build-Skripts als prinzipiell und modular als Aufgaben schreiben und Tools zur Ausführung dieser Aufgaben und zum Verwalten von Abhängigkeiten bereitstellen.

Die dunkle Seite aufgabenbasierter Build-Systeme

Da Entwickler mit diesen Tools im Wesentlichen jedes Skript als Aufgabe definieren können, sind sie äußerst leistungsfähig, da Sie damit fast alles tun können, was Sie sich mit ihnen vorstellen können. Diese Leistung hat jedoch auch Nachteile und aufgabenbasierte Build-Systeme werden manchmal komplizierter, wenn die Build-Skripts komplexer werden. Das Problem bei diesen Systemen besteht darin, dass sie zu viel Energie für Entwickler und zu wenig Strom für das System verursachen. Da das System nicht weiß, was die Skripts tun, leidet die Leistung, da dies Build-Schritte planen und ausführen kann. Das System kann auch nicht überprüfen, ob die einzelnen Skripts ordnungsgemäß funktionieren. Skripte werden daher in der Regel immer komplexer und werden letztendlich zu einem anderen Problem, das behoben werden muss.

Schwierigkeiten beim Parallelisieren von Build-Schritten

Moderne Entwicklungs-Workstations sind sehr leistungsstark und haben mehrere Kerne, die mehrere Build-Schritte parallel ausführen können. Aufgabenbasierte Systeme können jedoch oft nicht parallel ausgeführt werden, obwohl das für möglich scheint. Angenommen, Aufgabe A hängt von den Aufgaben B und C ab. Sind die Aufgaben B und C unabhängig voneinander? Ist es sicher, sie gleichzeitig auszuführen, damit das System schneller auf Aufgabe A zugreifen kann? Vielleicht, wenn sie nicht auf dieselben Ressourcen zugreifen. Aber vielleicht auch nicht – möglicherweise verwenden beide dieselbe Datei, um ihren Status zu verfolgen und ihre gleichzeitige Ausführung zu verursachen. Im Allgemeinen gibt es keine Möglichkeit, das System zu erkennen. Daher muss es entweder zu Konflikten führen, was zu seltenen, aber schwer zu behebenden Build-Problemen führen kann, oder es muss den gesamten für die Ausführung in einem einzigen Thread in einem einzigen Prozess. Dies kann eine enorme Verschwendung von leistungsstarken Entwicklermaschinen sein. Damit wird die Möglichkeit ausgeschlossen, den Build auf mehrere Maschinen zu verteilen.

Schwierigkeiten beim Ausführen inkrementeller Builds

Ein gutes Build-System ermöglicht es Entwicklern, zuverlässige inkrementelle Builds auszuführen, sodass eine kleine Änderung nicht erfordert, dass die gesamte Codebasis von Grund auf neu erstellt wird. Dies ist besonders wichtig, wenn das Build-System langsam ist und aus den oben genannten Gründen keine Build-Schritte parallelisieren kann. Leider haben auch aufgabenbasierte Build-Systeme hier Schwierigkeiten. Da Aufgaben alles tun können, lässt sich im Allgemeinen nicht prüfen, ob sie bereits erledigt sind. Bei vielen Aufgaben wird einfach eine Reihe von Quelldateien verwendet und ein Compiler ausgeführt, um eine Reihe von Binärdateien zu erstellen. Daher müssen sie nicht noch einmal ausgeführt werden, wenn die zugrunde liegenden Quelldateien nicht geändert wurden. Ohne zusätzliche Informationen kann das System dies jedoch nicht mit Sicherheit sagen. Vielleicht lädt die Aufgabe eine Datei herunter, die geändert worden sein könnte, oder schreibt einen Zeitstempel, der bei jeder Ausführung unterschiedlich sein könnte. Das System muss in der Regel bei jedem Build jede Aufgabe noch einmal ausführen, um die Richtigkeit zu gewährleisten. Einige Build-Systeme versuchen, inkrementelle Builds zu aktivieren. Dabei können Entwickler die Bedingungen angeben, unter denen eine Aufgabe noch einmal ausgeführt werden muss. Dies ist zwar manchmal machbar, aber oft schwieriger. In Sprachen wie C++, bei denen Dateien direkt in andere Dateien aufgenommen werden können, ist es beispielsweise nicht möglich, den gesamten Satz von Dateien zu ermitteln, der auf Änderungen überwacht werden muss, ohne die Eingabequellen zu parsen. Entwickler nehmen oft Verknüpfungen auf. Diese Tastenkombinationen können zu seltenen und frustrierenden Problemen führen, bei denen ein Aufgabenergebnis wiederverwendet wird, obwohl dies nicht sollte. Wenn dies häufig der Fall ist, gewöhnen sich Entwickler vor dieser Zeit daran, vor jedem Build einen sauberen Zustand zu erhalten, wodurch es überhaupt nicht möglich ist, einen inkrementellen Build zu erstellen. Wenn eine Aufgabe noch einmal ausgeführt werden muss, ist dies erstaunlich einfach und ein Job kann von Maschinen besser als von Menschen ausgeführt werden.

Schwierigkeiten bei der Verwaltung und Fehlerbehebung von Skripts

Außerdem sind die von aufgabenbasierten Build-Systemen aufgewendeten Build-Skripts häufig nur schwer zu bearbeiten. Build-Skripts sind zwar oft weniger Kontrolle, aber Build-Skripts sind genau wie das System aufgebaut und können leicht ausgeblendet werden. Hier sind einige Beispiele für Fehler, die sehr häufig beim Arbeiten mit einem aufgabenbasierten Build-System auftreten:

  • Aufgabe B hängt davon ab, dass Aufgabe B eine bestimmte Datei ausgegeben hat. Der Inhaber von Aufgabe B erkennt nicht, dass andere Aufgaben darauf beruhen, und ändert sie so, dass die Ausgabe an einem anderen Speicherort erstellt wird. Dies kann erst erkannt werden, wenn jemand versucht, Aufgabe A auszuführen, und feststellt, dass dies fehlschlägt.
  • Aufgabe B hängt von Aufgabe B ab, die von Aufgabe C abhängt, die eine bestimmte Datei als Ausgabe generiert, die von Aufgabe A benötigt wird. Der Inhaber von Aufgabe B entscheidet, dass er nicht mehr von der Aufgabe C abhängig sein muss. Dies führt dazu, dass die Aufgabe A fehlschlägt, obwohl die Aufgabe B überhaupt nicht an der Aufgabe C interessiert ist.
  • Der Entwickler einer neuen Aufgabe trifft versehentlich eine Annahme über den Computer, auf dem die Aufgabe ausgeführt wird, z. B. den Standort eines Tools oder den Wert bestimmter Umgebungsvariablen. Die Aufgabe funktioniert auf ihrem Computer, schlägt jedoch fehl, wenn ein anderer Entwickler sie versucht.
  • Eine Aufgabe enthält eine nicht deterministische Komponente, z. B. das Herunterladen einer Datei aus dem Internet oder das Hinzufügen eines Zeitstempels zu einem Build. Jetzt erhalten Nutzer jedes Mal unterschiedliche Ergebnisse, wenn sie den Build ausführen. Das bedeutet, dass Entwickler nicht immer in der Lage sind, Fehler oder Ausfälle, die in einem automatisierten Build-System auftreten, zu reproduzieren und zu beheben.
  • Aufgaben mit mehreren Abhängigkeiten können Race-Bedingungen erstellen. Wenn Aufgabe A sowohl von Aufgabe B als auch von Aufgabe C abhängt und Aufgabe B und C dieselbe Datei ändern, erhält Aufgabe A ein anderes Ergebnis, je nachdem, welche Aufgabe B und C zuerst abgeschlossen wurden.

Es gibt keinen allgemeinen Weg, diese Probleme hinsichtlich Leistung, Richtigkeit oder Verwaltbarkeit innerhalb des hier beschriebenen aufgabenbasierten Frameworks zu lösen. Solange Entwickler beliebigen Code schreiben können, der während des Builds ausgeführt wird, kann das System nicht genügend Informationen haben, um Builds immer schnell und korrekt ausführen zu können. Um das Problem zu lösen, müssen wir Entwicklern die Leistung entziehen, die sie wieder in die Hand des Systems legen, aber die Rolle des Systems nicht als ausgeführte, sondern als Artefakte neu gestalten möchten.

Dieser Ansatz führte zur Erstellung artefaktbasierter Build-Systeme wie Blaze und Bazel.