Modulare Java-Zukunft: Das Java Platform Module System erklärt
Nachdem die Java-Welt Jahre lang nicht unbedingt einen Schwerpunkt auf Modularisierung legte – OSGi einmal ausgenommen –, tut sich in Java 9 mit dem JPMS einiges, was sich auf das gesamte Ökosystem auswirken wird.
- Nicolai Parlog
Java 9 bringt eine Menge spannender Erweiterungen mit sich, aber keine hat im Vorfeld für so viel Diskussionen, Vorfreude und Aufruhr gesorgt wie das Modulsystem. Das Java Platform Module System (JPMS) ermöglicht es Entwicklern, Java-Anwendungen auf Artefaktebene zu modularisieren. Aber worum genau geht es dabei und welche Probleme hat Java diesbezüglich? Wie möchte das Modulsystem diese lösen und was bedeutet das für die Migration zu Java 9?
Die Philosophie des Modulsystem, aber auch die Features und Einschränkungen, die es mitbringt, lassen sich nur einordnen, wenn man zunächst den Hintergrund beleuchtet, vor dem dessen Entwicklung stattgefunden hat.
Wenn man ein System modularisiert, relativ unabhängig davon, auf welcher Ebene, gibt es drei grundlegende Eigenschaften, die Entwickler den einzelnen Modulen zuordnen können müssen:
- einen Namen, der sie innerhalb des Systems identifizer- und referenzierbar macht
- klar definierte Abhängigkeiten zu anderen Modulen
- eine ebenso klar definierte API und daraus folgend die Möglichkeit, Interna zu verbergen
Wer zum Beispiel an Java-Klassen denkt, sieht diese drei Eigenschaften gleich: Klassen haben systemweit – idealerweise sogar global – eindeutige Namen, anhand derer andere Klassen sie referenzieren können, und ihre API ist durch die Menge der sichtbaren Methoden und Felder definiert. Interna, insbesondere ihr Zustand, aber auch die Implementierung der API, ist verborgen und, abgesehen von Reflection oder gar Bytecode-Manipulation, nicht zugreifbar.
Schaut man sich statt Klassen allerdings Java Archives (JARs) an, wird offensichtlich, dass Java hier eine ähnliche Modularisierung nicht nennenswert unterstützt. JARs haben zwar Dateinamen, die aber für den Compiler oder die JVM weitgehend bedeutungslos sind. Abhängigkeiten lassen sich nicht ausdrücken, und es gibt keine Möglichkeit, Funktionen innerhalb eines JARs, aber nicht darüber hinaus erreichbar zu machen, ohne auf unangenehme Hacks zurückgreifen zu müssen.
(So können Entwickler zum Beispiel alle Klassen einer Bibliothek in nur einem Paket halten, um dann dort Paketsichtbarkeit zu verwenden. Das nimmt ihnen allerdings die Möglichkeit, den Code durch Pakete ordentlich zu strukturieren.)
Der Grund für die mangelnde Unterstützung von Modularisierung auf Artefaktebene ist der Umgang von Compiler und virtueller Maschine (JVM) mit JARs. Die sind, wie der Name andeutet, tatsächlich nur Behälter für Klassen. Zur Kompilier- oder Laufzeit wird der Bytecode der gesuchten Klassen aus den JARs geladen und unabhängig davon weiterverarbeitet – es existiert keine Laufzeitrepräsentation von JARs. Folgerichtig besteht keine Option, artefaktspezifische Informationen zu verarbeiten.
In der JAR-Hölle
Das mag zunächst nicht allzu bedeutend klingen. Entwickler und Tools ordnen Artefakten allerdings durchaus diese Eigenschaften – Namen, Abhängigkeiten und APIs – zu. Die so entstehende Diskrepanz zwischen der Vorstellung einer Systemarchitektur und ihrer Abbildung zur Kompilier- und Laufzeit ist nicht nur philosophisch unschön, sondern hat Konsequenzen.
So lassen sich die meisten Schwierigkeiten, die unter dem Namen JAR Hell zusammengefasst werden, darauf zurückführen. Darüber hinaus leiden Sicherheit und Wartbarkeit eines Projekts, insbesondere von Bibliotheken, wenn keine effektive Möglichkeit besteht, Implementierungsdetails zu schützen.
Als Beispiel für Sicherheitsprobleme kann Java selbst dienen: Ein erheblicher Teil der Sicherheitslücken, die Oracle nach dem Erwerb von Sun in Java 7 stopfen musste, stammten daher, dass sich sicherheitskritische Teile des Codes im Java Development Kit (JDK) von Schadcode erreichen und ausnutzen ließen. Wäre eine ordentliche Modularisierung möglich, die den Zugriff verhindert, wäre es dazu vermutlich nicht gekommen.
Ein Extrembeispiel dafür, wie die Wartbarkeit unter mangelnder Kapselung leiden kann, ist das JUnit-Projekt. Einer der Gründe dafür, dass JUnit 5 von Grund auf neu geschrieben wird und ein bestimmender Faktor in der neuen Architektur ist die enge Kopplung von Tools an Implementierungsdetails von Version 4. Hier haben IDE- und Build-Tool-Entwickler Reflection verwendet, um den Zustand von JUnit auszulesen und das in einem Maß, das es schwierig machte, das Projekt weiterzuentwickeln, ohne dabei die Funktionen der Tools zu beeinträchtigen.
Die mangelhafte Unterstützung für Modularisierung plagt das Java-Ökosystem schon lange, weshalb eine Menge Hilfsmittel zum Ausgleich entstanden sind. Zuallererst fallen hier Build-Tools ein, die explizit erlauben, zwei der drei Eigenschaften, nämlich Namen und Abhängigkeiten, nachzureichen. OSGi geht noch einen Schritt weiter und fügt die Möglichkeit zur Kapselung hinzu, indem es nur ausgewählte Pakete exportiert und die anderen unsichtbar sind.
Während Build-Tools nur einen Teil der Probleme lösen, sieht OSGi – vermutlich wegen seiner Komplexität – keine massenhafte Verwendung. Außerdem sind beide Ansätze keine Lösung für das JDK selbst. Um das anzugehen, wurde 2008 Project Jigsaw ins Leben gerufen, in dessen Rahmen das Oracle-Team zum einen die JDK-Code-Basis so umstrukturierte, dass überhaupt sinnvolle Module erkennbar sind, und zum anderen ein System implementierte, das es erlaubt, Module zu erzeugen.
Das Modulsystem
Das Java Platform Module System gibt Entwicklern die Möglichkeit, Artefakte in Module zu verwandeln. Das JDK wurde in knapp hundert davon zerlegt (einzusehen per java --list-modules), die in dem neuen und bisher unspezifizierten Dateiformat JMOD vorliegen und im Java-Installationsverzeichnis im Unterordner jmods zu finden sind.
Module außerhalb des JDK werden als sogenannte Modular JARs erzeugt. Dabei handelt es sich um normale JARs, die jedoch einen kleinen Zusatz haben: den Module Descriptor (dazu gleich mehr).
Der Compiler und die JVM verarbeiten Module getrennt von "normalen" JARs, und dementsprechend bekommen die Kommandos javac und java außer dem Class Path einen Module Path. Die Pfade funktionieren ähnlich, aber üblicherweise gehören JARs auf den Class Path und Modular JARs auf den Module Path.
Definieren von Modulen
Beim Module Descriptor handelt es sich um eine Datei module-info.class im Root-Verzeichnis des JAR. Kompiliert wird sie üblicherweise aus der sogenannten Module Declaration, einer Datei module-info.java im Root-Verzeichnis des Projekts.
Bei der Deklaration handelt es sich um ein gänzlich neues Java-Konstrukt. Es definiert alle Eigenschaften eines Moduls – dazu gehören in erster Linie sein Name, seine Abhängigkeiten und seine öffentliche API, die durch das Exportieren von Paketen festgelegt wird. Eine einfache Deklaration sieht folgendermaßen aus:
module org.codefx.foo {
requires org.codefx.bar;
exports org.codefx.foo.api;
}
Das Beispiel definiert ein Modul org.codefx.foo. Wie Paketnamen sollen die für Module möglichst global eindeutig sein, wozu sich ebenfalls die Benennungsstrategie des Umdrehens eines Domain-Namens anbietet.
Das Modul definiert eine Abhängigkeit von org.codefx.bar. Zusätzliche lassen sich durch weitere requires-Klauseln angeben. Außerdem exportiert das Modul das Paket org.codefx.foo.api. Dass dessen Name so anfängt wie der des Moduls, ist kein Zufall, denn beide verwenden die gleiche Benennungsstrategie – das ist aber keineswegs verpflichtend. Weitere Pakete können Entwickler durch mehr exports-Klauseln exportieren.
Zuverlässige Konfiguration, starke Kapselung und weitere Versprechen
Sowohl beim Kompilieren als auch beim Ausführen ist das Modulsystem mit von der Partie. Es wertet die Modulbeschreibungen aus und ist so in der Lage, problematische Situationen früh zu erkennen.
Zunächst werden die requires-Klauseln betrachtet. Wenn ein Modul foo die Klausel requires bar enthält, sagt man dazu umgangssprachlich "foo benötigt bar" oder "foo hängt von bar ab". Das Modulsystem bildet das durch das neue Konzept der Readability ab. Im Beispiel ist "bar lesbar durch foo" beziehungsweise "foo ließt bar".
Dazu muss bar allerdings tatsächlich auffindbar sein – sonst gibt es einen Kompilierfehler (wenn gerade der Compiler läuft) oder eine Exception (wenn die JVM das Programm startet). Das heißt, dass das Modulsystem sowohl zur Kompilier- als auch zur Startzeit sicherstellt, dass alle benötigten Abhängigkeiten verfügbar sind – das JPMS fasst das als "Reliable Configuration" zusammen. Das ermöglicht es viele Fehler, die sonst erst zur Laufzeit aufgefallen sind (zum Beispiel NoClassDefFoundError), beim Start der JVM zu finden.
Auf Readability aufbauend gibt es das Konzept der Accessibility, das regelt, welche Typen für welchen Code zugreifbar sind. Ein Typ Drink in einem Modul bar ist für Code in einem Modul foo zugänglich, wenn die folgenden drei Bedingungen erfüllt sind:
- Drink ist public.
- Das Paket, das Drink enthält, wird von bar exportiert.
- Das Modul foo liest bar.
Ohne weiteres Zutun gelten die ersten beiden Bedingungen auch für Reflection.
Zusammengefasst gesagt, public heißt nicht mehr "öffentlich für alle", sondern zunächst nur "öffentlich im gleichen Modul" – für jede Öffnung für andere Module müssen Entwickler eine bewusste Entscheidung treffen.
Das Modulsystem bezeichnet das als "Strong Encapsulation" (starke Kapselung), und es ist der Hauptgrund für viele der hitzigen Diskussionen um Java 9. Auf der einen Seite erlaubt es dem JDK und Bibliotheken, sicherheitskritischen oder nur für die interne Benutzung gedachten Code so zu kapseln, dass er sich nicht ohne weiteres verwenden lässt. Auf der anderen Seite sorgt es dafür, dass von vielen Bibliotheken und Frameworks verwendete Herangehensweisen nicht mehr so einfach oder vielleicht sogar überhaupt nicht mehr funktionieren. Beim Umstieg auf Java 9 wird das einige Arbeit verursachen – insbesondere für große Anwendungen.
Neben Reliable Configuration und Strong Encapsulation gibt das Modulsystem noch weitere Versprechen. Die starke Kapselung soll die Sicherheit des JDK verbessern beziehungsweise weniger aufwendig gestalten. Da sie dem JDK, aber auch Bibliotheken und Frameworks die Möglichkeit gibt zu verhindern, dass anderer Code Implementierungsdetails verwendet und so versehentlich davon abhängt, soll sich auch deren Wartbarkeit und damit letzten Endes die Entwicklungsgeschwindigkeit verbessern.
Ein weiterer interessanter Aspekt ist, dass dank des Modulsystems für jede Klasse über ihren Paketnamen klar ist, aus welchem Modul sie stammt, und sich somit das Laden von Klassen beschleunigen lässt. Das soll die Performance beim Starten der JVM verbessern, wo typischerweise viele Klassen geladen werden.
Migration nach Java 9
Einige der beschriebenen Änderungen führen unmittelbar zu der Frage, wie es um die Abwärtskompatibilität von Java 9 bestellt ist. Was ist etwa mit Code, der von internen APIs abhängt? Und sind von einem Tag auf den anderen alle JARs zu Modulen zu machen?
Prinzipiell ist Java 9 eine voll kompatible Weiterentwicklung, aber das ist nur dann so, wenn man sich an alle Regeln gehalten, das heißt ausschließlich auf standardisierte APIs und Details verlassen hat. Das wiederum ist bei vielen Anwendungen und insbesondere Bibliotheken und Frameworks nicht der Fall.
Im Folgenden sind die wahrscheinlichsten Probleme gelistet, die bei einer Migration auf Java 9 zu beachten sind:
- Verwendung JDK-interner Java-APIs, wofür alle Klassen in sun.*- oder com.sun.*-Paketen in Frage kommen
- Verwendung der der als "deprecated" markierten Methoden in LogManager und Pack200
- "Split Packages", das heißt die Definition eines Typs in einem Paket, das bereits in einem anderen Modul vorkommt
- Abschaffung von "Endorsed Standards Override Mechanism", "Extension Mechanism" und "Boot Class Path Override"
- neues Ordnerlayout der Runtime Images (das heißt JRE und JDK)
- neue Versions-Strings: zum Beispiel 9.1.4 statt 1.9.0_31 (das hat zwar nichts mit Modulen zu tun, kann aber dennoch zu Problemen führen)
Prinzipiell sind die meisten Punkte durch Änderungen im Code lösbar. Das funktioniert allerdings nur, wenn man die Kontrolle darüber hat. Bei Bibliotheken und Frameworks, die man nur verwendet, sind dementsprechend andere Lösungen gefragt. Zu dem Zweck wurden, sozusagen als Notausgänge, einige Command Line Flags eingeführt:
- Den Zugriff auf private APIs kann man mit --add-exports und --add-opens erlauben.
- Statt das im Detail freizugeben, kann die JVM auch mit --permit-illegal-access gestartet werden, was alle Zugriffsprüfungen deaktiviert.
- Das Flag --patch-module erlaubt es, zur Startzeit Klassen neuen Module zuzuordnen und somit einige Split-Package-Fälle zu umgehen.
- Mit --upgrade-module-path lassen sich JDK-Module gegen andere austauschen.
An anderer Stelle finden sich detailliertere Untersuchungen zur Kompatibilität von Java 9 und zum Konflikt zwischen Reflection und Strong Encapsulation.
Ein wichtiger Aspekt soll hier aber kurz Erwähnung finden: Eine Migration auf Java 9 lässt sich am besten vorbereiten, indem Projekte ihre Abhängigkeiten aktualisieren – nicht nur in puncto Bibliotheken und Frameworks, sondern auch in Sachen Werkzeuge wie Build Tools und IDEs. So lässt sich sicherstellen, dass schnell auf Java-9-kompatible Varianten gewechselt werden kann.
Die Inkompatibilitäten treten bereits bei der Ausführung auf Java 9 auf – unabhängig davon, ob die Anwendung selbst modularisiert wird. Sind sie gemeistert, steht eine Migration des Codes in das Modulsystem noch aus.
Modularisierung
Der wichtigste Hinweis zur Modularisierung der eigenen Anwendung ist, dass sie nicht verpflichtend ist. JARs bleiben ein gültiges Format, und der Class Path wird weiterhin voll unterstützt. Eine Anwendung, die auf Java 8 läuft und keine der oben besprochenen Inkompatibilitäten nutzt, funktioniert auf Java 9 genauso weiter.
Daraus folgt, dass das Modulsystem einen Mechanismus haben muss, wodurch seine Module (die des JDK) und JARs (die der Anwendung darüber) zusammenarbeiten können. Er steht tatsächlich zur Verfügung und erlaubt es dank zweier explizit dafür geschaffener Features, die Grenze zwischen Modulen und JARs flexibel zu verschieben. Dadurch können sowohl Entwickler von Anwendungen als auch von Bibliotheken und Frameworks ihre Artefakte unabhängig voneinander modularisieren.
Unnamed Module
Eines dieser Features ist das sogenannte Unnamed Module. Es wird automatisch vom Modulsystem erstellt und enthält alle Klassen aus JARs, die auf dem Class Path aufgelistet sind.
Alle Application-Klassen landen im gleichen Modul, wodurch zwischen ihnen keine Modulgrenzen bestehen und das Chaos des Class Path innerhalb des Unnamed Module erhalten bleibt – die Konzepte Readability und Accessibility spielen keine Rolle. Es funktioniert also alles wie in Java 8 und davor.
Was passiert aber, wenn aus ihm heraus auf ein anderes Modul, zum Beispiel eine Klasse aus dem JDK, zugegriffen wird? Wie erwähnt, lässt sich ohne Readability-Beziehung keine Accessibility herstellen, die JDK-Klasse wäre daher nicht verwendbar. Da das Unnamed Module automatisch erstellt wurde, lässt sich nicht ohne weiteres feststellen, wovon es abhängt, und folgerichtig liest es automatisch alle anderen Module. Ebenso lässt sich die API nicht automatisch ableiten, weswegen es alle Pakete exportiert.
Da auch modulare JARs letzten Endes nur JARs sind, können Entwickler sie ebenfalls auf den Class Path legen. Für sie gelten die gleichen Regeln wie für reguläre JARs. Das heißt, ihre Module Declaration wird ignoriert, und alle enthaltenen Klassen gehen im Unnamed Module auf. Das erlaubt es Entwicklern einer Bibliothek, sie in ein Modul zu wandeln, ohne dass die Nutzer deswegen gezwungen sind, es auch so einzusetzen.
Zusammen ermöglicht das eine Bottom-up-Migration, im Zuge der Bibliotheken, deren Abhängigkeiten alle zu Modulen gewandelt wurden (also zunächst jene, die nur vom JDK abhängen), selbst zu Modulen werden.
Automatic Modules und mehr
Möchte ein Projekt die Vorteile des Modulsystems genießen, ohne dass alle Abhängigkeiten modularisiert sind, sind letztere dennoch zu deklarieren. Da sie im Unnamed Module landen, müsste das etwa so aussehen:
module org.codefx.foo {
// Guava 20 is not modularized;
// it ends up on the class path
requires unnamed; // or something
}
Das funktioniert allerdings nicht – das Unnamed Modul ist nunmal namenlos und lässt sich nicht referenzieren. Das ist keine technische Schwäche, sondern eine absichtliche Hürde. Sie soll verhindern, dass angenehm modularisierte Artefakte vom Chaos des Class Path abhängen können. Stattdessen wurde die Möglichkeit entwickelt, eine Brücke vom Module zum Class Path zu schlagen.
Befindet sich ein reguläres JAR auf dem Module Path, erzeugt das Modulsystem daraus ein Automatic Module. Der Name des Moduls leitet sich vom Dateinamen ab (so wird etwa aus guava-20.0.jar das Modul guava), und da sich weder Abhängigkeiten noch API bestimmen lassen, wird auch hier jedes Modul gelesen und jedes Paket exportiert. Das ähnelt dem Unnamed Module, aber es gibt zwei entscheidende Unterschiede:
- Zu jedem JAR wird ein Automatic Module erstellt.
- Automatic Modules haben Namen, weswegen sich Abhängigkeiten von ihnen definieren lassen.
Damit kann die vorangegangene Module Declaration folgendermaßen aussehen:
module org.codefx.foo {
// Guava 20 is still not modularized;
// this time it ends up on the module path
requires guava;
}
Auf die Weise lassen sich Projekte, die von Guava abhängen, unabhängig davon modularisieren.
Dass die Namen automatischer Module vom Dateinamen abhängen und damit nicht stabil sind, kann allerdings zu Problemen führen, wenn Artefakte das gleiche Modul mit unterschiedlichen Namen referenzieren. Das JDK-Team rät deswegen davon ab, Module mit Abhängigkeiten auf automatische Module zu publizieren (z. B. auf Maven Central). Als Konsequenz wird die Modularisierung des gesamten Ökosystems einer Bottom-Up-Strategie folgen müssen und sicherlich einige Jahre dauern.
Weitere Features
Zum aktuellen Zeitpunkt ist es wichtig, über Kompatibilität, Migration und schrittweise Modularisierung zu reden. Da das eher negativ belegte Themen sind, verliert man schnell aus den Augen, was das Modulsystem noch zu bieten hat. Da sind insbesondere die detaillierteren Optionen zu nennen, um Abhängigkeiten (z. B. optionale) und APIs (z. B. nur für Reflection) zu definieren.
Darüber hinaus gibt es beispielsweise Services, mit denen sich Abhängigkeiten zwischen Modulen entkoppeln lassen. Ein weiteres spannendes Feature sind Layer, die dabei helfen, modularisierte Anwendungen isoliert oder hierarchisch organisiert zu starten, was insbesondere Container wie Application Server benötigen.
Mit einem in Module zerlegten JDK und klar definierten Abhängigkeiten ist es möglich, eine Laufzeitumgebung zusammenzustellen, die nur aus genau dem besteht, was die Anwendung benötigt. Das neue Tool jlink erlaubt ausgehend von einer Menge direkt verwendeter Module alle indirekt benötigten zu bestimmen und daraus eine Laufzeitumgebung zu erstellen. Damit sollen die Zeiten vorbei sein, in denen der Code für XML, SQL oder AWT/Swing/JavaFX immer auch auf einem Raspberry Pi oder in einem Docker Image vorhanden sind. Dank Compact Profiles war das zwar seit Java 8 nicht mehr nötig, aber jlink ist wesentlich flexibler.
Fazit
Das Modulsystem revolutioniert den Umgang von Java mit Artefakten. Statt einfacher Behälter können JARs in Zukunft Module mit eigener Identität, expliziten Abhängigkeiten und einer klar definierten API sein. Das System wertet die Informationen aus und stellt sicher, dass Anwendungen nur starten, wenn alle benötigten Module vorhanden sind (Reliable Configuration), und dass Module ihre Interna unzugänglich machen können (Strong Encapsulation). Darüber hinaus soll das JPMS Sicherheit, Wartbarkeit und Performance verbessern.
Dabei wird die Abwärtskompatibilität zwar theoretisch erhalten, praktisch kann aber dennoch einiges schiefgehen. Sind diese Schwierigkeiten gemeistert, ermöglicht das Modulsystem eine schrittweise Modularisierung von Anwendungen, Bibliotheken und Frameworks. Eine Migration zum Modulsystem wird neben dem Festhalten der Architektur in Code mit neuen Features wie Services und Layern belohnt.
Unabhängig von einzelnen Features könnte aber die positivste Folge sein, dass das Ökosystem als ganzes Modularisierung auf Artefaktebene ernster nimmt als zuvor. Insbesondere Build Tools und IDEs, aber auch Analysewerkzeuge werden nun auf einer weiteren Ebene Features und Feedback liefern. Viel mehr Entwickler werden sich mit Konzepten der Modularisierung großer Anwendungen auseinandersetzen, und mit aktiv zu pflegenden Moduldeklarationen ist die Zeit vorbei, in der gefühlte und tatsächliche Architektur einer Anwendung beliebig auseinanderlaufen können.
Dank besserem Verständnis für die Grundlagen wird auch der Schritt zu OSGi leichter fallen, und Projekte, die davon profitieren würden, es aber bisher aus Komplexitätsgründen gemieden haben, können es neu in Betracht ziehen, wenn sie dank des JPMS einen Teil des Wegs dorthin zurückgelegt haben. Nach einer holprigen Übergangsphase sollte das Java-Ökosystem in einigen Jahren deutlich gefestigter dastehen als heute.
Nicolai Parlog
ist selbständiger Softwareentwicker, Autor und Trainer. Er lebt in Karlsruhe, bloggt auf codefx.org und schreibt mit Manning "The Java 9 Module System".
(jul)