Artefaktbasierte Build-Systeme

Auf dieser Seite werden artefakte Build-Systeme und die Philosophie hinter ihrer Entstehung behandelt. Bazel ist ein Artefakt-basiertes Build-System. Aufgabenbasierte Build-Systeme stellen zwar einen guten Schritt über Build-Skripts dar, bieten jedoch zu viel Unterstützung für die einzelnen Entwickler, da sie ihre eigenen Aufgaben definieren können.

Artefaktbasierte Build-Systeme haben eine kleine Anzahl von Aufgaben, die vom System definiert werden und die Entwickler in begrenztem Umfang konfigurieren können. Techniker legen weiterhin fest, was erstellt werden soll, aber das Build-System bestimmt, wie es erstellt wird. Wie bei aufgabenbasierten Build-Systemen haben auch Artefaktbasierte Build-Systeme wie Bazel Build-Dateien. Der Inhalt dieser Build-Dateien ist jedoch sehr unterschiedlich. Build-Dateien sind in Bazel keine deklarative Gruppe von Befehlen in einer Turing-Complete-Skriptsprache, die das Erstellen einer Ausgabe beschreibt, sondern sind ein deklaratives Manifest, das eine Reihe von zu erstellenden Artefakten, deren Abhängigkeiten und eine begrenzte Anzahl von Optionen, die sich auf ihre Erstellung auswirken. Wenn Entwickler bazel in der Befehlszeile ausführen, geben sie eine Reihe von zu erstellenden Zielen an (was), und Boutique ist für die Konfiguration, Ausführung und Planung Kompilierungsschritte (wie). Da das Build-System jetzt die volle Kontrolle darüber hat, welche Tools wann ausgeführt werden, kann es viel stärkere Garantien liefern, die es wesentlich effizienter machen, aber trotzdem die Richtigkeit garantieren.

Funktionale Funktion

Es ist ganz einfach, eine Analogie zwischen artefakten Build-Systemen und funktionaler Programmierung zu finden. In traditionellen imperativen Programmiersprachen (z. B. Java, C und Python) werden Listen mit Anweisungen angegeben, die nacheinander ausgeführt werden sollen. Dies entspricht der Art und Weise, wie Programmierer mit programmbasierten Build-Systemen eine Reihe von Schritten definieren können.{ 101}auszuführen. Funktionale Programmiersprachen (wie Haskell und ML) sind hingegen eher wie eine Reihe von mathematischen Gleichungen strukturiert. In funktionellen Sprachen beschreibt der Programmierer eine auszuführende Berechnung, lässt jedoch den Zeitpunkt und die genaue Ausführung dieser Berechnung dem Compiler überlassen.

Dies entspricht der Idee, ein Manifest in einem Artefakt-basierten Build-System zu deklarieren und dem System zu überlassen, den Build auszuführen. Viele Probleme lassen sich mit der funktionalen Programmierung nicht ohne Weiteres beschreiben, aber von solchen, von denen sie vor allem profitieren, ist die Sprache in der Lage, solche Programme problemlos parallel zu parallelisieren und starke Garantien für ihre Richtigkeit zu geben, die in einer imperativen Sprache unmöglich ist. Am einfachsten lassen sich Probleme mit funktioneller Programmierung ausdrücken. Bei einer solchen Programmierung wird einfach ein Datenelement mithilfe einer Reihe von Regeln oder Funktionen in ein anderes übertragen. Genau das ist ein Build-System: Das gesamte System ist praktisch eine mathematische Funktion, die Quelldateien (und Tools wie den Compiler) als Eingaben verwendet und Binärdateien als Ausgaben erzeugt. Daher ist es nicht überraschend, dass es gut funktioniert, um ein Build-System basierend auf den Grundsätzen der funktionalen Programmierung aufzubauen.

Artefaktbasierte Build-Systeme verstehen

Blaze, das Build-System von Google, war das erste Artefakt-basierte Build-System. Bazel ist die Open-Source-Version von Blaze.

So sieht eine Build-Datei (normalerweise BUILD) in Bazel aus:

java_binary(
    name = "MyBinary",
    srcs = ["MyBinary.java"],
    deps = [
        ":mylib",
    ],
)
java_library(
    name = "mylib",
    srcs = ["MyLibrary.java", "MyHelper.java"],
    visibility = ["//java/com/example/myproduct:__subpackages__"],
    deps = [
        "//java/com/example/common",
        "//java/com/example/myproduct/otherlib",
    ],
)

In Bazel definieren BUILD-Dateien Ziele – die beiden Zieltypen sind java_binary und java_library. Jedes Ziel entspricht einem Artefakt, das vom System erstellt werden kann. Binäre Ziele erzeugen Binärdateien, die direkt ausgeführt werden können, und Bibliotheksziele erzeugen Bibliotheken, die von Binärdateien oder anderen Bibliotheken verwendet werden können. Jedes Ziel hat:

  • name: wie das Ziel in der Befehlszeile und von anderen Zielen referenziert wird
  • srcs: die zu kompilierenden Quelldateien, um das Artefakt für das Ziel zu erstellen
  • deps: andere Ziele, die vor diesem Ziel erstellt und damit verknüpft werden müssen

Abhängigkeiten können entweder innerhalb desselben Pakets (z. B. MyBinary-Abhängigkeit von :mylib) oder in einem anderen Paket in derselben Quellhierarchie (z. B. Abhängigkeit von mylib) liegen am //java/com/example/common.

Wie bei aufgabenbasierten Build-Systemen führen Sie Builds mit dem Bazel-Befehlszeilentool aus. Führen Sie bazel build :MyBinary aus, um das Ziel MyBinary zu erstellen. Nachdem Sie diesen Befehl zum ersten Mal in einem bereinigten Repository eingegeben haben, Bazel:

  1. Parst jede BUILD-Datei im Arbeitsbereich, um ein Diagramm mit Abhängigkeiten zwischen Artefakten zu erstellen.
  2. Die Grafik bestimmt die transitiven Abhängigkeiten von MyBinary. Das heißt, jedes Ziel, das von MyBinary abhängt, und jedes Ziel, von dem diese Ziele abhängen, sind rekursiv.
  3. Erstellt jede dieser Abhängigkeiten in der angegebenen Reihenfolge. Zunächst erstellt Bazel jedes Ziel, das keine anderen Abhängigkeiten hat, und erfasst, welche Abhängigkeiten weiterhin für jedes Ziel erstellt werden müssen. Sobald alle Abhängigkeiten eines Ziels erstellt wurden, erstellt Bazel dieses Ziel. Dieser Vorgang wird so lange fortgesetzt, bis alle transitiven Abhängigkeiten von MyBinary erstellt wurden.
  4. Erstellt MyBinary, um eine endgültige ausführbare Binärdatei zu erstellen, die in allen Abhängigkeiten verknüpft ist, die in Schritt 3 erstellt wurden.

Im Wesentlichen scheint es nicht so zu sein, dass sich das Verhalten hier bei der Verwendung eines aufgabenbasierten Build-Systems deutlich verändert. Tatsächlich ist das Endergebnis dieselbe Binärdatei und beim Erstellen dieses Verfahrens werden eine Reihe von Schritten analysiert, um Abhängigkeiten zu finden, und dann diese Schritte der Reihe nach ausgeführt. Es gibt jedoch kritische Unterschiede. Der erste Eintrag erscheint in Schritt 3: Da Bazel weiß, dass jedes Ziel nur eine Java-Bibliothek erzeugt, weiß es nur, dass er nur den Java-Compiler und kein beliebiges benutzerdefiniertes Skript ausführen muss. angezeigt wird, damit diese Schritte sicher parallel ausgeführt werden können. Dies kann die Leistung wesentlich steigern, wenn Sie Ziele auf einer Mehrkernmaschine nacheinander erstellen. Dies ist nur möglich, weil der artbasierte Ansatz das Build-System für die eigene Ausführung überlässt. , um die Parallelität zu verbessern.

Die Vorteile gehen jedoch über die Parallelität hinaus. Als Nächstes zeigt uns dieser Ansatz, dass der Entwickler bazel build :MyBinary ein zweites Mal eingibt, ohne Änderungen vorzunehmen: Bazel wird in weniger als einer Sekunde mit einer Meldung beendet, dass das Ziel aktuell ist. aus. Dies ist auf das zuvor beschriebene funktionale Programmierparadigm zurückzuführen. Bayel weiß, dass jedes Ziel nur das Ergebnis eines Java-Compilers ist und dass die Ausgabe nur aus dem Java-Compiler stammt. solange sie nicht geändert wurden, kann die Ausgabe wiederverwendet werden. Diese Analyse findet auf allen Ebenen statt. Wenn sich MyBinary.java ändert, kann Bazel MyBinary neu erstellen, aber mylib wiederverwenden. Wenn sich eine Quelldatei für //java/com/example/common ändert, kann Bazel diese Bibliothek mylib und MyBinary neu erstellen, aber //java/com/example/myproduct/otherlib wiederverwenden. Da Bazel die Attribute der Tools kennt, die in jedem Schritt ausgeführt werden, kann er immer nur den minimalen Satz von Artefakten neu erstellen. Dabei wird sichergestellt, dass keine veralteten Builds generiert werden.

Der Umriss des Build-Prozesses in Bezug auf Artefakte statt auf Aufgaben ist zwar subtil, aber sehr leistungsstark. Da die Flexibilität des Programmierers reduziert wird, kann das Build-System mehr darüber erfahren, was bei jedem Schritt des Builds ausgeführt wird. Mit diesem Wissen kann der Build sehr viel effizienter gestaltet werden, indem die Build-Prozesse parallelisiert und ihre Ausgaben wiederverwendet werden. Doch das ist nur der erste Schritt. Diese Bausteine der Parallelverarbeitung und Wiederverwendung bilden die Grundlage für ein verteiltes und hoch skalierbares Build-System.

Weitere Tricks für {9/}

Artefaktbasierte Build-Systeme lösen die Probleme mit Parallelität und Wiederverwendung im Wesentlichen, die in aufgabenbasierten Build-Systemen enthalten sind. Es gibt jedoch immer noch einige Probleme, die uns bereits gemeldet wurden. Bazel bietet intelligente Möglichkeiten, diese Probleme zu lösen. Bevor Sie fortfahren, sollten wir sie besprechen.

Tools als Abhängigkeiten

Ein Problem, das wir früher hatte, war, dass Builds von den auf unserem Computer installierten Tools abhängig waren. Die Reproduktion von Builds in verschiedenen Systemen könnte aufgrund unterschiedlicher Toolversionen oder Speicherorte schwierig sein. Das Problem wird noch schwieriger, wenn in Ihrem Projekt Sprachen verwendet werden, für die je nach Plattform, auf der sie erstellt oder kompiliert werden (z. B. Windows oder Linux), unterschiedliche Tools erforderlich sind. Plattformen benötigen etwas andere Tools, um dieselbe Arbeit auszuführen.

Bazel löst den ersten Teil dieses Problems, da Tools für jedes Ziel als Abhängigkeiten behandelt werden. Jeder java_library im Arbeitsbereich hängt implizit von einem Java-Compiler ab, der standardmäßig einen bekannten Compiler hat. Wenn Bazel einen java_library erstellt, wird geprüft, ob der angegebene Compiler an einem bekannten Speicherort verfügbar ist. Wie bei jeder anderen Abhängigkeit wird bei jeder Änderung des Java-Compilers jedes Artefakt, das davon abhängt, neu erstellt.

Bazel löst den zweiten Teil des Problems, die Plattformunabhängigkeit, durch das Festlegen von Build-Konfigurationen. Statt Ziele direkt von ihren Tools abhängig zu machen, hängen sie von den verschiedenen Konfigurationsarten ab:

  • Hostkonfiguration: Build-Tools, die während des Build ausgeführt werden
  • Zielkonfiguration: Erstellen der Binärdatei, die Sie zuletzt angefordert haben

Build-System erweitern

Im Lieferumfang von Bazel sind für viele gängige Programmiersprachen Ziele enthalten, Entwickler möchten jedoch immer mehr ausführen. Ein Vorteil von aufgabenbasierten Systemen ist ihre Flexibilität, die jede Art von Build-Prozess unterstützt. wäre es besser, dies in einem artefaktenbasierten Build-System zu verzichten. Glücklicherweise ermöglicht Bazel die Erweiterung der unterstützten Zieltypen durch Hinzufügen benutzerdefinierter Regeln.

Zum Definieren einer Regel in Bazel deklariert der Autor der Regeln die von der Regel benötigten Eingaben (in Form von Attributen, die in der Datei BUILD übergeben werden) und den von der Regel generierten Ausgaben. Der Autor definiert auch die Aktionen, die von dieser Regel generiert werden. Jede Aktion deklariert ihre Ein- und Ausgaben, führt eine bestimmte ausführbare Datei aus oder schreibt einen bestimmten String in eine Datei und kann über ihre Ein- und Ausgaben mit anderen Aktionen verbunden werden. Aktionen sind also die kleinste zusammensetzbare Einheit im Build-System. Eine Aktion kann beliebige Aktionen ausführen, solange sie nur die deklarierten Ein- und Ausgaben verwendet und Bazel die Planung übernimmt. und die Ergebnisse im Cache zu speichern.

Das System ist nicht tauglich, da es keine Möglichkeit gibt, einen Aktionsentwickler daran zu hindern, einen nicht deterministischen Prozess als Teil seiner Aktion einzuführen. In der Praxis tritt dies jedoch nicht sehr häufig auf. Wenn Missbrauchsmöglichkeiten bis hin zur Aktionsebene zur Verfügung stehen, sinkt die Wahrscheinlichkeit erheblich, dass Fehler auftreten. Regeln, die viele gängige Sprachen und Tools unterstützen, sind online verfügbar und für die meisten Projekte werden keine eigenen Regeln definiert. Selbst für diejenigen, die dies tun, müssen Regeldefinitionen nur an einer zentralen Stelle im Repository definiert werden. Das bedeutet, dass die meisten Entwickler diese Regeln verwenden können, ohne sich um die Implementierung kümmern zu müssen.

Umgebung isolieren

Aktionen scheinen auf dieselben Probleme wie Aufgaben in anderen Systemen zu kommen. Ist es nicht möglich, Aktionen zu schreiben, die sowohl in dieselbe Datei schreiben als auch miteinander in Konflikt stehen? Tatsächlich macht Bazel diese Konflikte durch Sandboxing unmöglich. Auf unterstützten Systemen wird jede Aktion über eine Dateisystem-Sandbox von allen anderen Aktionen isoliert. Tatsächlich kann jede Aktion nur eine eingeschränkte Ansicht des Dateisystems sehen, die die von ihr deklarierten Eingaben und alle von ihr erzeugten Ausgaben enthält. Dies wird durch Systeme wie LXC unter Linux erzwungen, der Technologie hinter Docker. Dies bedeutet, dass es unmöglich ist, dass Aktionen miteinander in Konflikt stehen, da sie keine Dateien lesen können, die nicht deklariert sind. Außerdem werden alle Dateien, die sie zwar schreiben, aber nicht deklarieren, bei der Aktion verworfen. Abschluss. Bazel nutzt auch Sandboxes, um die Kommunikation über das Netzwerk einzuschränken.

Externe Abhängigkeiten deterministisch machen

Ein weiteres Problem besteht weiterhin: Build-Systeme müssen häufig Abhängigkeiten (Tools oder Bibliotheken) aus externen Quellen herunterladen, anstatt sie direkt zu erstellen. Im Beispiel ist dies über die Abhängigkeit @com_google_common_guava_guava//jar zu sehen, die eine JAR-Datei von Maven herunterlädt.

Je nach Datei ist das Risiko riskant. Diese Dateien können sich jederzeit ändern. Dadurch muss das Build-System möglicherweise ständig prüfen, ob sie aktuell sind. Wenn sich eine Remote-Datei ohne entsprechende Änderung im Quellcode des Arbeitsbereichs ändert, kann dies auch zu nicht reproduzierbaren Builds führen. Ein Build kann an einem Tag fehlschlagen und am nächsten aufgrund eines nicht erkennbaren Grunds fehlschlagen. Abhängigkeitsänderung Eine externe Abhängigkeit kann außerdem ein großes Sicherheitsrisiko darstellen, wenn sie einem Dritten gehört: Wenn ein Angreifer diesen Drittanbieterserver infiltrieren kann, kann er die Abhängigkeitsdatei durch etwas von {101{/1} ersetzen. }ihr eigenes Design und gibt ihnen volle Kontrolle über ihre Build-Umgebung und ihre Ausgabe.

Das grundlegende Problem besteht darin, dass das Build-System diese Dateien erkennen soll, ohne dass sie in die Versionsverwaltung eingecheckt werden müssen. Die Aktualisierung einer Abhängigkeit sollte bewusst erfolgen. Diese Entscheidung sollte jedoch einmal an einem zentralen Ort erfolgen, anstatt von einzelnen Entwicklern oder automatisch vom System verwaltet zu werden. Dies liegt daran, dass auch bei einem "Live at Head"-Modell weiterhin Builds deterministisch sein sollen. Dies bedeutet, dass Sie bei einem Commit der letzten Woche Ihre Abhängigkeiten so sehen sollten, wie sie waren. als sie sind.

Bazel und einige andere Build-Systeme lösen dieses Problem, da sie eine geschäftsweite Manifestdatei benötigen, die einen kryptografischen Hash für jede externe Abhängigkeit im Arbeitsbereich enthält. Der Hash ist eine prägnante Methode, um die Datei eindeutig darzustellen, ohne die gesamte Datei in die Versionsverwaltung einzuchecken. Wenn von einem Arbeitsbereich aus auf eine neue externe Abhängigkeit verwiesen wird, wird der Manifest-Datei entweder manuell oder automatisch der Hash dieser Abhängigkeit hinzugefügt. Wenn Bazel einen Build ausführt, wird der tatsächliche Hash-Wert der im Cache gespeicherten Abhängigkeit mit dem im Manifest definierten erwarteten Hash verglichen. Die Datei wird nur dann noch einmal heruntergeladen, wenn der Hash-Wert abweicht.

Wenn der Artefakt, den wir herunterladen, einen anderen Hash hat als der im Manifest angegebene, schlägt der Build fehl, sofern der Hash im Manifest nicht aktualisiert wird. Dies kann automatisch erfolgen. Diese Änderung muss jedoch genehmigt und in der Versionsverwaltung eingecheckt werden, bevor der Build die neue Abhängigkeit akzeptiert. Dies bedeutet, dass immer ein Datensatz erfasst wird, wenn eine Abhängigkeit aktualisiert wurde, und eine externe Abhängigkeit kann sich nur mit einer entsprechenden Änderung in der Arbeitsbereichsquelle ändern. Es bedeutet auch, dass beim Auschecken einer älteren Version des Quellcodes garantiert der Build die gleichen Abhängigkeiten verwendet, die zum Zeitpunkt des Eincheckens dieser Version verwendet wurden. Andernfalls tritt beim Build ein Fehler auf. wenn diese Abhängigkeiten nicht mehr verfügbar sind.

Natürlich können Probleme weiterhin auftreten, wenn ein Remoteserver nicht mehr verfügbar ist oder beschädigte Daten bereitstellt. Dies kann dazu führen, dass alle Ihre Builds fehlschlagen, wenn Sie keine weitere Kopie dieser Abhängigkeit verfügbar haben. Zur Vermeidung dieses Problems sollten Sie für alle nicht trivialen Projekte alle Abhängigkeiten auf vertrauenswürdigen bzw. vertrauenswürdigen Servern oder Diensten spiegeln. Andernfalls sind Sie immer für die Verfügbarkeit Ihres Build-Systems einem Dritten ausgesetzt, auch wenn die eingecheckten Hashes eine Sicherheit garantieren.