zurück zum Artikel

Continuous Integration mit Java und JavaScript, Teil 1: Die Kunst der Versionierung

Architektur/Methoden
Continuous Integration mit Java und JavaScript

Continuous Integration ist ein seltsamer Begriff. Das vorangestellte Wort "Continuous" betont die Integration. In der Praxis bedeutet es jedoch eher "Integrating continuously", denn die Integrationsprobleme halten Projekte auf, nicht die Frequenz.

Ein unabhängiges Softwareprojekt integrieren zu wollen, ist eine Sysiphusarbeit. Sobald die Softwareentwicklung arbeitsteilig ist, muss entschieden werden, welche Module in welchen Versionen zusammen eine Einheit bilden. Benutzen Tester- oder Endanwender eine derartige Multi-Modul-Software, ist es unabdingbar zu wissen, aus welchen Teilen die Software besteht und welchen Stand (Version) das jeweils verwendete Modul hat. In der Zusammenarbeit zwischen Testern und Entwicklern sollte durch eine Benennung des Standes einer Multi-Modul-Software das vom Tester gemeldete Problem eindeutig auf einen Stand im Versionskontrollsystem abbildbar sein.

Das Grundproblem heißt Reproduzierbarkeit

Speziell bei mehreren Versionen und kundenspezifischen Auslieferungen darf der Überblick dabei nicht verloren gehen. Konzepte wie Continuous Integration (CI) helfen bei der Identifizierung inkompatibler Versionsstände. Hierbei wird bei einer kontinuierlichen Integration aller Module ein internes Release eines Systems erzeugt – so zumindest die ursprünglichste Definition von Grady Booch [1]. Durch das kontinuierliche Bauen der Software werden ebenso regelmäßige Modultests (Unit-Tests) aller Softwarebausteine durchgeführt. Beim Erstellen einer Multi-Modulsoftware existiert mindestens ein Integrationsprojekt, das die bisher erstellten Artefakte einbindet. Tests über solche oder nachgelagerte Projekte in Bezug auf die gleichzeitige Funktionsweise unabhängiger Module werden als Integrationstest bezeichnet. Sie dienen dazu, Bugs, die durch die Integration der Module entstehen, frühzeitig zu erkennen.

Continuous Integration

Teil 1: Die Kunst der Versionierung

Teil 2: Praktische Umsetzung [1]

Wenn beispielsweise die Schnittstelle eines Lieferanten geändert wird, ohne die abhängigen Module zu benachrichtigen, sind solche Bugs vorprogrammiert: Die Modultests des Lieferanten weisen keinen Fehler auf. Ein davon abhängiges Modul ist dagegen zu der neuen Version des Lieferanten inkompatibel und wird noch mit der Vorgängerversion entwickelt. Solche Fehler erst bei der Auslieferung zu entdecken, bedeutet fast zwangsläufig eine verzögerte Auslieferung.

Durch das kontinuierliche Bauen der aktuellen Versionen lassen sich solche Probleme rechtzeitig entdecken. Die Tester können die Fehlerbeschreibung jedoch nicht dokumentieren, da das Wissen über die verwendeten Stände bei der simplen Form des Bauens mit der jeweils aktuellen Version verloren ging. Die Reproduzierbarkeit der Builds ist eine Grundvoraussetzung für den effizienten Einsatz von Techniken wie Continuous Delivery.

Die Herausforderungen sind vielfältig

Organisationsstrukturen haben zusätzlich einen großen Einfluss auf die Art des Bauens von Multi-Modulsoftware. Deren genauere Untersuchung würde den Rahmen dieses Artikels sprengen, aber die Effizienz des Bauprozesses hängt durchaus von der Kenntnis über die unternehmensspezifischen Gegebenheiten ab. Zu den wichtigen Faktoren gehören unter anderem die Anzahl der Testsysteme, die Art der Abschottung eines Produktionssystems, der Zugriff auf externe Netze, die Anzahl der Teams und die Fähigkeit der Zusammenarbeit untereinander. Ein Überblick über die verfügbaren CI-Techniken unterstützt die Auswahl einer passenden Vorgehensweise in der eigenen Organisation.

Im Laufe der Jahre haben sich in verschiedenen CI-Projekten des Autors immer wieder die folgenden Bereiche als "neuralgisch" herauskristallisiert: Build-System, Repository-Struktur, Dependency-Mechanismen, Projekt-Organisation und letztlich auch das Zielsystem beziehungsweise die Varianten des Endprodukts. Die einzelnen Bereiche werden zuerst unabhängig von einer spezifischen Technik betrachtet und in einem Folgeartikel an einem Beispielprojekt näher untersucht.

Das Build-System

Die Basis der Baustelle ist das Build-System

Ein Build-System, auch Build-Tool genannt, ist ein Programm zum automatisierten Erzeugen von Software. Ein System, wie Apache Ant [2], Gnu Make [3], Apache Maven [4] oder npm [5] baut Software, in dem es für das jeweilige Zielsystem eine Reihe von Aktionen durchführt. Das sind typischerweise simple Aufrufe externer Programme in einer definierten Reihenfolge. Dabei verfolgen die Tools unterschiedliche Ansätze. Ant und Make arbeiten beispielsweise Tasks ab, die in Abhängigkeit zueinander stehen. Für die Quellen und die erzeugten Artefakte existiert in den beiden Werkzeugen keine Vorlage (Modell), sodass immer eine Abfolge zum Erstellen des Artefakts angegeben werden muss. Im Gegensatz dazu definiert das Build-Tool Maven ein Projektmodell. Das bedeutet, das Bauen wird in erster Linie konfiguriert. Für das Anwendungsbeispiel im zweiten Teil kam ein Zwitter zum Einsatz: Das Build-Tool Gradle ist sowohl Task-basiert, kennt jedoch je nach verwendeten Plug-in auch Projektmodelle und vereint damit die Vorteiler zweier Welten: Flexibilität und die Vermeidung von Redundanz.

Ein weiteres Merkmal eines Build-Systems ist der Grad der Abhängigkeit zum Betriebssystem und die damit verbundene Versionsstabilität. Das bedeutet, wie stabil ein Build-Tool sich selbst in einer geforderten Version installiert. Gradle-Anwender können dazu die geforderte Versionsnummer angeben,worauf sich das Werkzeug selbst in der angegebenen Version aus einem Repository lädt und sich um die Installation kümmert. Die Versionsstabilität des Build-Tools ist ein wichtiger Baustein für das Erstellen reproduzierbarer Builds.

Im Vorfeld sollte für die Auswahl des Build-Tools genügend Zeit eingeplant werden. Technisch spricht übrigens nichts dagegen, Java-Quellcode mit Java-fremden Tools wie npm oder Gnu Make zu bauen. Meist ist ein Build-Tool auf ein Zielsystem hin optimiert. Somit entscheiden Details hinsichtlich benötigter Plug-ins, Dependency-Mechanismen oder der Unterstützung eines bestimmten Repositories.

Kontrolle über die Versionen behalten

Beim Anlegen eines Build-Systems müssen die Administratoren entscheiden, wo die erstellten Artefakte für nachfolgende Schritte abgelegt und auf welche Art benötigte Artefakte wie Bibliotheken eingebunden werden. Die einfachste Variante ist das Speichern der Artefakte auf ein (Netz-)Laufwerk und/oder das Einchecken der benötigen Bibliotheken in das Versionskontrollsystem (CVS, SVN, Git). Der Nachteil an dieser Vorgehensweise ist, dass sich bestehende Arbeiten im Internet nicht wieder verwenden lassen und die Aktualisierung der benutzten Bibliotheken aufwendig ist.

Eine Alternative besteht in der Nutzung eines Repository-Systems für den Zugriff oder die Ablage von Artefakten. Ein solches Werkzeug verwaltet zusätzlich Metadaten zu den Artefakten wie Abhängigkeiten, Versionssummer, Vorgängerversion, Autor und Lizenz. Anhand der Meta-Daten kann ein Unternehmen sicherstellen, dass beispielsweise eine bestimmte Lizenzart aus- oder eingeschlossen wird. Da ein Repository-System den Zugriff zwischenspeichert, wird nebenbei stets eine redundante Kopie archiviert, die bei einem Ausfalls eines entfernten Systems die Reproduzierbarkeit des Builds sicherstellt. Die Kopie hilft zudem für die Fälle, bei denen sich der Lieferant entscheidet, eine benötigte Version aus dem Internet wieder zu entfernen. Idealerweise unterstützt das Build-System ein hausinternes Repository, um Ausfälle von externen Diensten, wie die von GitHub im April 2015 [6], zu überbrücken.

Voneinander abhängig

Die dritte Problemkategorie beim Erstellen reproduzierbarer Builds stellt die Verwaltung der Abhängigkeiten dar. Das kann sich auf eine einzelne Version oder einen Versionswertebereich beziehen. Manche Systeme unterstützen orthogonale Eigenschaften wie Betriebssystem oder CPU-Architektur. So ist npm in der Lage, Abhängigkeiten anhand des Betriebssystems zu konfigurieren. Im Scala-Umfeld sind Pakete anhand des verwendeten Scala-Compilers zu unterscheiden, und das Portage System unterstützt durch sogenannte USE Flags allgemeine orthogonale Notwendigkeiten wie Videounterstützung, IPv6 oder unterstützte Sprachen. Letztlich hat sich beim Bauen von Software die grobe Unterteilung der Dependencies in die Kategorien compile, test und runtime als ausreichend erwiesen. Sowohl Maven Repositories als auch npm kennen diese Ordnung. Letztlich sollte das Team prüfen, wie ein Build-System mit den Artefakten eines anderen umgehen kann.

Projektstruktur und Zielsystem

Grundsätzlich lassen sich Build-Projekte in zwei Strukturen unterscheiden: der integrierte Build und die Build-Kette. Letztere bedeutet die Organisation der Module in kleineren Einheiten und den Build-Prozess in der Reihenfolge nach Abhängigkeiten. Das hat den Vorteil, dass die Teams unabhängig voneinander arbeiten. Sie müssen lediglich die Übergabe der Artefakte und die Verwaltung der Versionssnummern definieren. Letzteres ist der große Schwachpunkt dieses Ansatzes, da ein langwieriger Prozess zur Bestimmung nötig ist oder das Unternehmen die Propagierung der Versionsnummer für die Build-Kette implementieren muss. Zudem sind die integrierte Entwicklung und das gleichzeitige Arbeiten an zwei Teilprojekten nicht möglich.

Bei einer integrierten Projektstruktur werden alle notwendigen Teilprojekte als Ganzes gebaut. Diese Vorgehensweise hat allerdings den wesentlichen Nachteil, dass die Länge der Bauzeit enorm ansteigen kann. Der Autor kann von Bauzeiten in der Größenordnung von mehrerer Stunden berichten, die durch umfangreiche Integrationstests hervorgerufen wurden. Zwar ist es möglich, die Integrationstests abzutrennen, aber das Ergebnis wäre wieder eine Build-Kette. Eine Mischform wäre denkbar, sofern das Team plant, an welcher Stelle die langwierigen Integrationstests stattfinden – vorzugsweise am Ende der Kette.

Das Zielsystem ist ein weiteres Problemfeld, das hier nur am Rande angeschnitten wird. Da im Beispiel das Zielsystem ein WAR-Archiv ist, ist die Bedingung der Unabhängigkeit vom Betriebssystem erfüllt. Beim Design eines CI-Workflows mit mehreren Zielsystemen – etwa CPU-Architekturen – spielt eben diese Unabhängigkeit eine wichtige Rolle. Ist sie nicht gegeben, könnte man mit Virtualisierungs-Tools wie Docker, Vagrant oder Uni-Kernels den Build-Prozess unabhängig vom Betriebssystem gestalten. Diese Tools sind wiederum eher in der Peripherie von Continuous Delivery verortet, die aber nicht Thema dieses Artikels sind.

Die Bedeutung der Versionsnummern

In den beschriebenen Problemzonen schwingt immer ein gemeinsamer Begriff mit: die Versionsnummer. Sie ist in erster Linie zur Unterscheidung von Modulen oder Varianten notwendig. Die einzige Bedingung für die Reproduzierbarkeit von Builds ist die Eindeutigkeit dieser Nummern. Üblicherweise ist dafür der Build-Server verantwortlich, beispielsweise indem der Build-Prozess ein Versions-Tag nach dem Bauen und vor der Veröffentlichung in das Versions-Kontrollsystem schreibt oder abbricht, wenn es bereits vergeben wurde.

Bei Build-Ketten ergeben sich jedoch weitere Schwierigkeiten: Um einen reproduzierbaren Build zu gewährleisten, muss sichergestellt werden, dass die konkrete Auswahl der Abhängigkeiten deterministisch ist. Das ist nur möglich, wenn eine Versionsordnung definiert ist. Zeitstempel als Version zu nutzen, etwa mit Mavens SNAPSHOT-Notation, ist keine praxistaugliche Ordnung, da sie im Build und in den erzeugten Artefakten nicht unterscheidbar sind. Dadurch kommt es in langen Build-Ketten zu merkwürdigen Phänomenen: Das erste Projekt wird erfolgreich gebaut, aber ein paar Schritte später bricht die Kette ab, weil eine neuere, jedoch inkompatible SNAPSHOT-Version verfügbar ist. Das ist ärgerlich, aber im Sinne von Continuous Integration so gewollt.

Im Gegensatz dazu kann es vorkommen, dass eine fehlerhafte Änderung nicht erkannt wird. Ein Beispiel mit einer Build-Kette bestehend aus den drei Modulen A, B und C soll das Problem verdeutlichen: Modul C benötigt Modul B, das wiederum Modul A integriert. Modul C ist das Ende der Build-Kette. Beim Bauen von Modul C sind die Änderungen des Moduls A im Modul B nicht verfügbar, wenn der Bau einer neuen SNAPSHOT-Version von Modul A nach der Ablage von Modul B erfolgt. Die Änderungen von Modul A zeigen sich in Modul C erst nach dem erneuten Build der Module B und C, obwohl sie selbst unverändert sind. Baut das System nur die Projekte mit einer erkennbaren Änderung, so sind diese Integrationsprobleme erst bei der Vergabe konkreter Versionen sichtbar, also erst zum Release-Zeitpunkt.

Wie erwähnt unterstützen manche Build-Systeme und Repositories Wertebereiche von Versionsnummern. In dem Fall können Abhängigkeiten in Form von Bedingungen formuliert werden, etwa 'größer als Version "1.0", aber kleiner als Version "2.1.1-rc5"'. Ist die Version "2.1.1-rc5" größer oder kleiner als "2.1.1-hotfix1"? Offensichtlich muss hierfür eine Versionsordnung bei allen Beteiligten klar definiert sein. Je nach System bieten sich hierfür verschiedene Modelle an, etwa Semantic Versioning [7] oder die .NET-Versionskonvention [8]. Meistens ist in den Versionsschemata ein einfacher Zähler vorhanden, dessen Änderung lediglich eine "kompatible" Änderung andeutet. Es liegt auf der Hand, den Counter beim Bauen automatisch zu erhöhen. So wäre es ein Leichtes, die Abhängigkeit so zu formulieren, dass beispielsweise immer der höchste Versionszähler verwendet wird – in Bezug zu einem abgestimmten fixen Versionsteil. Beispiel: In diesem Quartal hat Modul A die Version 5.1 und Modul B die Version 3.3. Demnach könnte Modul A die Abhängigkeit wie folgt definieren:

Modul A abhängig von B: >=3.3.0 & <3.4.0

Der Build ist reproduzierbar, solange im Integrationsmodul die Versionsnummern immer eindeutig selektiert werden können. Oft ist das nicht möglich – etwa bei einem Versionskonflikt oder wenn der Build des Integrations-Moduls immer zum gleichen Ergebnis kommen soll. Daher kann es ratsam sein, die vom Dependency-System errechneten Versionsnummern, also die effektiv genutzten, im Integrationsmodul zu fixieren, und das möglichst automatisch.

Dabei können Versionskonflikte auftreten, wenn eine Abhängigkeit zu einer Komponente in Konflikt zu einer gemeinsamen Abhängigkeit steht. Manche Dependency-Systeme versuchen, diese Versionskonflikte zu lösen. Vermutlich ist das Ermitteln der korrekten Versionsnummern nicht effizient möglich [9]. Zudem kommt es vor, dass das System immer eine bestimmte Version eines Moduls selektiert, obwohl neuere verfügbar wären. Folgendes Beispiel soll das Problem anhand der Module A, B und C verdeutlichen:

Modul A abhängig von:

B: >=3.3.0 & <3.4.0

C: >=1.1.0 & <2.0.0

Es kommt zu einem Versionskonflikt, wenn Modul B nun ebenfalls von Modul C abhängig ist, beispielsweise mit:

Modul B abhängig von:

C:>= 0.0.0 <1.0.0

In diesem Fall erkennt das Dependency-Management des Build-Systems einen Versionskonflikt. Eine Versionsnummer kann durch eine solche Einschränkung einer anderen Abhängigkeit ungewollt fixiert werden. Im obigen Beispiel wäre die effektiv selektierte Version von Modul C immer kleiner als "1.5", auch für den Fall, dass eine neuere Version von Modul C verfügbar wäre, wenn im Modul B die Abhängigkeit zu C wie folgt eingeschränkt ist:

Modul B abhängig von:

C:>=0.0.0 & < 1.5.0

Für Build-Ketten bedeutet das sprichwörtlich die Wahl zwischen Pest und Cholera: Fixierte Versionsnummern erfordern das Verteilen gepflegter Listen. Ansonsten führen potenzielle Versionskonflikte zum Abbruch von Build-Ketten beim Release-Zeitpunkt (SNAPSHOT). Die Alternative einer programmatischen Propagierung der Versionsnummer ist aufwendig zu implementieren, da sie eine automatische Änderung im Versionskontrollsystem durch die Fixierung der Version zur Folge haben kann. Hier sind Build-Systeme, die den manuellen Eingriff ermöglichen, klar im Vorteil.

Die Entscheidung über das Propagieren von Versionsnummern in einer Build-Kette hängt in erster Linie davon ab, ob die Teams sich auf ein gemeinsames Versionsschema einigen können (oder ihnen eine Einigung aufgezwungen wird). Erst bei der Existenz eines gemeinsamen Schemas lohnt sich die Überlegung, ob ein Automatismus hinsichtlich der Version einen Vorteil hat.

Zwischenfazit

Reproduzierbare Builds sind eine Grundvoraussetzung für manuelle Prüfungen wie Abnahme-, und Integrationstests. Bei unternehmensinternen Projekten ist zudem die Pflege interner Repositories oft notwendig. Wenn Build-Ketten im Einsatz sind, spielt die Art der Versionierung der Module und die korrekte Weitergabe der Version eine wichtige Rolle bei der Reproduzierbarkeit. Ein Versionskonzept, wie das oft zitierte "Semantic Versioning [10]", könnte hilfreich sein, sofern zwischen allen Beteiligten ein Konsens darüber herrscht.

Überhaupt ist die Organisations- oder Projektstruktur im Unternehmen der entscheidende Faktor für reproduzierbare Builds. Kein Build-System der Welt kann das Chaos eines abhängigen Projekts beseitigen. Es liegt also auf der Hand, einen CI-Workflow bezüglich der gegebenen Organisations- oder Projektstruktur auszurichten. Schließlich ändern sich diese, so agil sie auch sein mögen, immer langsamer als der Build-Prozess. (rme [11])

Dragan Zuvic
arbeitet als Berater und Entwickler bei thecodecampus.de [12] in Java EE-, Scala- & AngularJS-Projekten.

Literatur

  1. Grady Booch; Objektorientierte Analyse und Design; Addison-Wesley; 1994

URL dieses Artikels:
http://www.heise.de/-2853319

Links in diesem Artikel:
[1] https://www.heise.de/developer/artikel/Continuous-Integration-mit-Java-und-Javascript-Teil-2-Praktische-Umsetzung-2922729.html
[2] http://ant.apache.org/
[3] http://www.gnu.org/software/make/
[4] http://maven.apache.org/
[5] http://maven.apache.org/
[6] https://www.heise.de/meldung/Github-wehrt-sich-gegen-gross-angelegte-DDoS-Attacke-2587869.html
[7] http://semver.org/
[8] https://msdn.microsoft.com/en-us/library/ms973869.aspx#managevers_topic1
[9] https://people.debian.org/~dburrows/model.pdf
[10] http://semver.org/
[11] mailto:rme@ct.de
[12] http://www.thecodecampus.de/