Kapitel 1. Modularität ist wichtig
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
Hast du dich jemals verwirrt am Kopf gekratzt und dich gefragt: "Warum ist dieser Code hier? Wie hängt er mit dem Rest dieser gigantischen Codebasis zusammen? Wo soll ich überhaupt anfangen?" Oder sind deine Augen glasig geworden, nachdem du die vielen Java-Archive (JARs), die mit deinem Anwendungscode gebündelt sind, durchgesehen hast? Das haben wir auch.
Die Kunst, große Codebasen zu strukturieren, wird unterschätzt. Das ist weder ein neues Problem, noch ist es spezifisch für Java. Allerdings ist Java eine der Mainstream-Sprachen, in der ständig sehr große Anwendungen entwickelt werden - und die oft viele Bibliotheken aus dem Java-Ökosystem nutzen. Unter diesen Umständen können die Systeme über unsere Fähigkeit hinauswachsen, sie zu verstehen und effizient zu entwickeln. Mangelnde Struktur wird auf lange Sicht teuer bezahlt, wie die Erfahrung zeigt.
Modularität ist eine der Techniken, die du einsetzen kannst, um diese Komplexität zu bewältigen und zu reduzieren.Java 9 führt ein neues Modulsystem ein, das die Modularisierung einfacher und zugänglicher macht. Es baut auf den Abstraktionen auf, die Java bereits für die modulare Entwicklung hat. In gewisser Weise fördert es die bewährten Methoden für die Entwicklung von Java in großem Maßstab als Teil der Sprache Java.
Das Java-Modulsystem wird einen tiefgreifenden Einfluss auf die Java-Entwicklung haben. Es stellt einen grundlegenden Wandel hin zur Modularität als Bürger erster Klasse für die gesamte Java-Plattform dar. Die Modularisierung wird von Grund auf angegangen, mit Änderungen an der Sprache, der Java Virtual Machine (JVM) und den Standardbibliotheken. Das ist zwar eine gewaltige Anstrengung, aber nicht so auffällig wie z.B. die Einführung von Streams und Lambdas in Java 8. Es gibt noch einen weiteren grundlegenden Unterschied zwischen einem Feature wie Lambdas und dem Java-Modulsystem. Ein Modulsystem befasst sich mit der groß angelegten Struktur ganzer Anwendungen. Die Umwandlung einer inneren Klasse in ein Lambda ist eine relativ kleine und lokal begrenzte Änderung innerhalb einer einzelnen Klasse. Die Modularisierung einer Anwendung wirkt sich auf Design, Kompilierung, Paketierung, Bereitstellung usw. aus. Es ist also viel mehr als nur ein weiteres Sprachfeature.
Bei jeder neuen Java-Version ist die Versuchung groß, sofort loszulegen und die neuen Funktionen zu nutzen. Um das Beste aus dem Modulsystem herauszuholen, sollten wir zunächst einen Schritt zurücktreten und uns darauf konzentrieren, was Modularität ist. Und, was noch wichtiger ist, warum wir uns dafür interessieren sollten.
Was ist Modularität?
Bislang haben wir das Ziel der Modularität (die Beherrschung und Verringerung der Komplexität) angesprochen, aber nicht, was Modularität bedeutet.Im Kern ist Modularisierung die Zerlegung eines Systems in in sich geschlossene, aber miteinander verbundene Module.Module sind identifizierbare Artefakte, die Code enthalten, mit Metadaten, die das Modul und seine Beziehung zu anderen Modulen beschreiben.Idealerweise sind diese Artefakte von der Kompilierzeit bis zur Laufzeit erkennbar. Eine Anwendung besteht dann aus mehreren Modulen, die zusammenarbeiten.
Module fassen also zusammenhängenden Code zusammen, aber das ist noch nicht alles: Module müssen sich an drei Grundregeln halten :
- Starke Verkapselung
-
Ein Modul muss in der Lage sein, einen Teil seines Codes vor anderen Modulen zu verbergen. Auf diese Weise wird eine klare Grenze zwischen öffentlich nutzbarem Code und Code, der als internes Implementierungsdetail gilt, gezogen. So wird eine versehentliche oder unerwünschte Kopplung zwischen Modulen verhindert: Was gekapselt wurde, kann nicht verwendet werden. Folglich kann gekapselter Code frei geändert werden, ohne dass sich dies auf die Nutzer des Moduls auswirkt.
- Gut definierte Schnittstellen
-
Kapselung ist gut, aber wenn Module zusammenarbeiten sollen, kann nicht alles gekapselt werden. Code, der nicht gekapselt ist, ist per Definition Teil der öffentlichen API eines Moduls. Da andere Module diesen öffentlichen Code nutzen können, muss er mit großer Sorgfalt verwaltet werden. Eine Änderung in nicht gekapseltem Code kann andere Module, die von ihm abhängen, zerstören. Deshalb sollten Module gut definierte und stabile Schnittstellen zu anderen Modulen bereitstellen.
- Explizite Abhängigkeiten
-
Module sind oft auf andere Module angewiesen, um ihre Aufgaben zu erfüllen.Solche Abhängigkeiten müssen Teil der Moduldefinition sein, damit Module in sich geschlossen sind. Explizite Abhängigkeiten führen zu einem Modulgraphen: Knoten stellen Module dar, und Kanten repräsentieren Abhängigkeiten zwischen Modulen.Ein Modulgraph ist wichtig, um eine Anwendung zu verstehen und sie mit allen notwendigen Modulen zu betreiben. Er bildet die Grundlage für eine zuverlässige Konfiguration von Modulen.
Flexibilität, Verständlichkeit und Wiederverwendbarkeit kommen bei Modulen zusammen. Module können flexibel zu verschiedenen Konfigurationen zusammengestellt werden, wobei die expliziten Abhängigkeiten genutzt werden, um sicherzustellen, dass alles zusammen funktioniert. Die Kapselung stellt sicher, dass du die Implementierungsdetails nicht kennen musst und dich nicht versehentlich auf sie verlässt. Um ein Modul zu verwenden, reicht es aus, seine öffentliche API zu kennen. Außerdem kann ein Modul, das gut definierte Schnittstellen offenlegt und seine Implementierungsdetails kapselt, leicht gegen alternative Implementierungen ausgetauscht werden, die der gleichen API entsprechen.
Modulare Anwendungen haben viele Vorteile. Erfahrene Entwicklerinnen und Entwickler wissen nur zu gut, was passiert, wenn Codebasen nicht modular aufgebaut sind. Unangenehme Begriffe wie Spaghetti-Architektur, chaotischer Monolith oder großer Schlammball beschreiben nicht einmal ansatzweise den damit verbundenen Schmerz. Modularität ist jedoch kein Allheilmittel, sondern ein architektonisches Prinzip, das bei richtiger Anwendung diese Probleme in hohem Maße verhindern kann.
Abgesehen davon ist die Definition von Modularität in diesem Abschnitt bewusst abstrakt gehalten. Sie lässt dich vielleicht an komponentenbasierte Entwicklung (die im letzten Jahrhundert der letzte Schrei war), serviceorientierte Architekturen oder den aktuellen Hype um Microservices denken. Tatsächlich versuchen diese Paradigmen, ähnliche Probleme auf verschiedenen Abstraktionsebenen zu lösen.
Was bräuchte man, um Module in Java zu realisieren? Es ist lehrreich, sich einen Moment Zeit zu nehmen und darüber nachzudenken, inwieweit die Grundprinzipien der Modularität in Java, wie du es kennst, bereits vorhanden sind (und wo sie noch fehlen).
Dann kannst du mit dem nächsten Abschnitt fortfahren.
Vor Java 9
Java wird für die Entwicklung von Anwendungen aller Art und Größe eingesetzt. Anwendungen mit Millionen von Codezeilen sind da keine Ausnahme. Offensichtlich hat Java etwas richtig gemacht, wenn es um die Entwicklung großer Systeme geht - und das schon vor der Einführung von Java 9. Sehen wir uns die drei Grundprinzipien der Modularität noch einmal im Hinblick auf Java vor der Einführung des Java 9 Modulsystems an.
Die Kapselung von Typen kann durch eine Kombination aus Paketen und Zugriffsmodifikatoren (wie private
, protected
oder public
) erreicht werden.Indem du eine Klasse zum Beispiel zu protected
machst, kannst du verhindern, dass andere Klassen auf sie zugreifen, es sei denn, sie befinden sich im selben Paket.Das wirft eine interessante Frage auf: Was ist, wenn du auf diese Klasse aus einem anderen Paket in deiner Komponente zugreifen willst, aber trotzdem verhindern willst, dass andere sie benutzen?
Dafür gibt es keine gute Möglichkeit. Du kannst die Klasse natürlich öffentlich machen.Aber öffentlich bedeutet auch öffentlich für jeden anderen Typ im System, also keine Kapselung. Du kannst darauf hinweisen, dass es nicht klug ist, eine solche Klasse zu verwenden, indem du sie in ein .impl
oder .internal
Paket packst. Aber wer schaut sich das schon an? Die Leute benutzen sie trotzdem, einfach weil sie es können. Es gibt keine Möglichkeit, ein solches Implementierungspaket zu verstecken.
Was gut definierte Schnittstellen angeht, hat Java seit seinen Anfängen gute Arbeit geleistet. Du hast es erraten, wir sprechen von Javas eigenem Schlüsselwort interface
. Eine öffentliche Schnittstelle offenzulegen, während die Implementierungsklasse hinter einer Factory oder durch Dependency Injection versteckt wird, ist eine bewährte Methode. Wie du in diesem Buch sehen wirst, spielen Schnittstellen eine zentrale Rolle in modularen Systemen.
Explizite Abhängigkeiten sind der Punkt, an dem die Dinge auseinanderfallen.Ja, Java verfügt über explizite import
Anweisungen. Leider sind diese Importe ausschließlich ein Konstrukt zur Kompilierzeit. Sobald du deinen Code in ein JAR verpackt hast, gibt es keine Hinweise darauf, welche anderen JARs die Typen enthalten, die dein JAR zum Ausführen benötigt. Dieses Problem ist sogar so schlimm, dass sich neben der Java-Sprache viele externe Tools entwickelt haben, um dieses Problem zu lösen. In der folgenden Seitenleiste findest du weitere Einzelheiten.
So wie es aussieht, bietet Java solide Konstrukte für die Entwicklung großer modularer Anwendungen. Es ist aber auch klar, dass es definitiv Raum für Verbesserungen gibt.
JARs als Module?
JAR-Dateien scheinen den Modulen aus der Zeit vor Java 9 am nächsten zu kommen. Sie haben einen Namen, gruppieren zusammenhängenden Code und können gut definierte öffentliche Schnittstellen bieten.Schauen wir uns ein Beispiel für eine typische Java-Anwendung an, die auf der JVM läuft, um den Begriff der JARs als Module zu verstehen; siehe Abbildung 1-1.
Es gibt ein Anwendungs-JAR namens MyApplication.jar, das benutzerdefinierten Anwendungscode enthält. Zwei Bibliotheken werden von der Anwendung verwendet: Google Guava und Hibernate Validator. Außerdem gibt es drei weitere JARs. Dabei handelt es sich um transitive Abhängigkeiten von Hibernate Validator, die möglicherweise von einem Build-Tool wie Maven für uns aufgelöst werden. MyApplication läuft auf einer Pre-Java 9-Laufzeitumgebung, die ihrerseits Java-Plattformklassen über mehrere gebündelte JARs zur Verfügung stellt. Die Pre-Java 9 Runtime kann eine Java Runtime Environment (JRE) oder ein Java Development Kit (JDK) sein, aber in beiden Fällen enthält sie rt.jar(runtime library), die die Klassen der Java Standardbibliothek enthält.
Wenn du dir Abbildung 1-1 genau ansiehst, kannst du erkennen, dass einige der JARs Klassen in kursiver Schrift aufführen. Diese Klassen sollten eigentlich interne Klassen der Bibliotheken sein. com.google.common.base.internal.Finalizer
wird zum Beispiel in Guava selbst verwendet, ist aber nicht Teil der offiziellen API. Es ist eine öffentliche Klasse, da andere Guava-Pakete Finalizer
verwenden. Leider bedeutet das auch, dass es für com.myapp.Main
kein Hindernis gibt, Klassen wie Finalizer
zu verwenden. Mit anderen Worten: Es gibt keine starke Kapselung.
Das Gleiche gilt für interne Klassen der Java-Plattform selbst. Pakete wie sun.misc
waren schon immer für den Anwendungscode zugänglich, auch wenn die Dokumentation eindringlich davor warnt, dass es sich um nicht unterstützte APIs handelt, die nicht verwendet werden sollten. Trotz dieser Warnung werden Utility-Klassen wie sun.misc.BASE64Encoder
ständig im Anwendungscode verwendet.
Technisch gesehen kann dieser Code bei jeder Aktualisierung der Java-Laufzeitumgebung kaputt gehen, da es sich um interne Implementierungsklassen handelt. Da Java großen Wert auf Abwärtskompatibilität legt, sind diese Klassen aufgrund der fehlenden Kapselung ohnehin als halböffentliche APIs zu betrachten. Das ist eine unglückliche Situation, die durch das Fehlen einer echten Kapselung entsteht.
Was ist mit expliziten Abhängigkeiten? Wie du bereits gelernt hast, gibt es keine Abhängigkeitsinformationen mehr, wenn du dir JARs genau ansiehst.Du führst MyApplication wie folgt aus:
java -classpath lib/guava-19.0.jar:\ lib/hibernate-validator-5.3.1.jar:\ lib/jboss-logging-3.3.0Final.jar:\ lib/classmate-1.3.1.jar:\ lib/validation-api-1.1.0.Final.jar \ -jar MyApplication.jar
Den richtigen Klassenpfad einzurichten, ist Sache des Nutzers und ohne explizite Abhängigkeitsinformationen nichts für schwache Nerven.
Klassenpfad Hölle
Der Klassenpfad wird von der Java-Laufzeit verwendet, um Klassen zu finden.In unserem Beispiel führen wir Main
aus, und alle Klassen, die direkt oder indirekt von dieser Klasse referenziert werden, müssen irgendwann geladen werden. Du kannst den Klassenpfad als eine Liste aller Klassen betrachten, die zur Laufzeit geladen werden können. Auch wenn hinter den Kulissen mehr dahinter steckt, reicht diese Ansicht aus, um die Probleme mit dem Klassenpfad zu verstehen.
Der resultierende Klassenpfad für MyApplication sieht wie folgt aus:
java.lang.Object java.lang.String ... sun.misc.BASE64Encoder sun.misc.Unsafe ... javax.crypto.Cypher javax.crypto.SecretKey ... com.myapp.Main ... com.google.common.base.Joiner ... com.google.common.base.internal.Joiner org.hibernate.validator.HibernateValidator org.hibernate.validator.constraints.NotEmpty ... org.hibernate.validator.internal.engine.ConfigurationImpl ... javax.validation.Configuration javax.validation.constraints.NotNull
Es gibt keine JARs oder logische Gruppierungen mehr. Alle Klassen werden in einer flachen Liste in der Reihenfolge aufgeführt, die durch das Argument -classpath
definiert ist. Wenn die JVM eine Klasse lädt, liest sie den Klassenpfad in sequenzieller Reihenfolge, um die richtige Klasse zu finden. Sobald die Klasse gefunden ist, endet die Suche und die Klasse wird geladen.
Was ist, wenn eine Klasse nicht im Klassenpfad gefunden wird? Dann bekommst du eine Laufzeit-Exception. Da Klassen nur langsam geladen werden, könnte diese ausgelöst werden, wenn ein unglücklicher Benutzer zum ersten Mal auf eine Schaltfläche in deiner Anwendung klickt. Die JVM kann die Vollständigkeit des Klassenpfads beim Start nicht effizient überprüfen. Es gibt keine Möglichkeit, im Voraus zu sagen, ob der Klassenpfad vollständig ist oder ob du ein weiteres JAR hinzufügen solltest. Das ist natürlich nicht gut.
Noch heimtückischere Probleme entstehen, wenn sich doppelte Klassen im Klassenpfad befinden. Nehmen wir an, du versuchst, die manuelle Einrichtung des Klassenpfads zu umgehen. Stattdessen lässt du Maven anhand der expliziten Abhängigkeitsinformationen in den POMs den richtigen Satz von JARs für den Klassenpfad zusammenstellen. Da Maven Abhängigkeiten transitiv auflöst, ist es nicht ungewöhnlich, dass zwei Versionen derselben Bibliothek (z.B. Guava 19 und Guava 18) in diesem Satz landen, ohne dass du etwas dafür kannst. Nun werden beide Bibliotheks-JARs in einer undefinierten Reihenfolge in den Klassenpfad eingefügt. Die erste Version der Bibliotheksklassen wird geladen. Andere Klassen erwarten jedoch möglicherweise eine Klasse aus der (möglicherweise inkompatiblen) anderen Version. Auch dies führt zu Laufzeitausnahmen. Generell gilt: Immer wenn der Klassenpfad zwei Klassen mit demselben (voll qualifizierten) Namen enthält, auch wenn sie nicht miteinander verwandt sind, "gewinnt" nur eine.
Jetzt wird klar, warum der Begriff Klassenpfad-Hölle (auch JAR-Hölle genannt) in der Java-Welt so berüchtigt ist.Einige Leute haben die Kunst perfektioniert, einen Klassenpfad durch Ausprobieren zu optimieren - eine ziemlich traurige Beschäftigung, wenn man darüber nachdenkt. Der fragile Klassenpfad ist nach wie vor eine der Hauptursachen für Probleme und Frustration. Wenn doch nur mehr Informationen über die Beziehungen zwischen JARs zur Laufzeit zur Verfügung stünden. Es ist, als ob sich ein Abhängigkeitsgraph im Klassenpfad versteckt und nur darauf wartet, herauszukommen und ausgenutzt zu werden. Auf geht's, Java 9 Module!
Java 9 Module
Inzwischen kennst du die aktuellen Stärken und Grenzen von Java, wenn es um Modularität geht.Mit Java 9 bekommen wir einen neuen Verbündeten auf der Suche nach gut strukturierten Anwendungen: das Java-Modulsystem. Bei der Entwicklung des Java Platform Module System zur Überwindung der aktuellen Einschränkungen wurden zwei Hauptziele definiert:
-
Modularisiere das JDK selbst.
-
Biete ein Modulsystem an, das du für deine Bewerbungen nutzen kannst.
Diese Ziele sind eng miteinander verbunden. Die Modularisierung des JDK erfolgt durch die Verwendung des gleichen Modulsystems, das wir als Anwendungsentwickler in Java 9 nutzen können.
Das Modulsystem führt ein natives Konzept von Modulen in die Java-Sprache und -Laufzeit ein. Module können Pakete entweder exportieren oder stark kapseln. Außerdem drücken sie Abhängigkeiten von anderen Modulen explizit aus. Wie du siehst, werden alle drei Grundsätze der Modularität vom Java-Modulsystem berücksichtigt.
Schauen wir uns noch einmal das Beispiel MyApplication in Abbildung 1-2 an, das jetzt auf dem Java 9 Modulsystem basiert.
Jedes JAR wird zu einem Modul, das explizite Verweise auf andere Module enthält. Die Tatsache, dass hibernate-validator
jboss-logging
, classmate
und validation-api
verwendet, ist Teil seines Moduldeskriptors.Ein Modul hat einen öffentlich zugänglichen Teil (oben) und einen gekapselten Teil (unten, gekennzeichnet durch das Vorhängeschloss).
Deshalb kann MyApplication die Klasse Finalizer
von Guava nicht mehr verwenden. Anhand dieses Diagramms sehen wir, dass MyApplication auch validation-api
verwendet, um einige seiner Klassen zu annotieren.Darüber hinaus hat MyApplication eine explizite Abhängigkeit von einem Modul im JDK namens java.sql
.
Abbildung 1-2 verrät uns viel mehr über die Anwendung als die in Abbildung 1-1 gezeigte Klassenpfadsituation. Alles, was man dort sagen könnte, ist, dass MyApplication Klassen aus rt.jar verwendet, wie alle Java-Anwendungen, und dass sie mit einer Reihe von JARs auf dem (möglicherweise falschen) Klassenpfad läuft.
Das ist nur die Anwendungsschicht. Die ganze Ebene besteht aus Modulen.Auf der JDK-Schicht gibt es ebenfalls Module(Abbildung 1-2 zeigt eine kleine Teilmenge). Wie die Module der Anwendungsschicht haben sie explizite Abhängigkeiten und legen einige Pakete offen, während sie andere verbergen.Das wichtigste Plattformmodul im modularen JDK ist java.base
.
Es stellt Pakete wie java.lang
und java.util
zur Verfügung, ohne die kein anderes Modul auskommt. Da du die Verwendung von Typen aus diesen Paketen nicht vermeiden kannst, benötigt jedes Modul implizit java.base
. Wenn die Anwendungsmodule andere Funktionen von Plattformmodulen benötigen als die von java.base
, müssen diese Abhängigkeiten ebenfalls explizit sein, wie im Fall der Abhängigkeit von MyApplication von java.sql
.
Endlich gibt es eine Möglichkeit, Abhängigkeiten zwischen einzelnen Teilen des Codes auf einer höheren Granularitätsebene in der Java-Sprache auszudrücken. Stell dir nun die Vorteile vor, die sich ergeben, wenn all diese Informationen zur Kompilier- und Laufzeit zur Verfügung stehen. Versehentliche Abhängigkeiten von Code aus anderen, nicht referenzierten Modulen können verhindert werden. Die Toolchain weiß, welche zusätzlichen Module für die Ausführung eines Moduls erforderlich sind, indem sie dessen (transitive) Abhängigkeiten untersucht, und anhand dieses Wissens können Optimierungen vorgenommen werden.
Starke Kapselung, wohldefinierte Schnittstellen und explizite Abhängigkeiten sind jetzt Teil der Java-Plattform.Kurz gesagt, das sind die wichtigsten Vorteile des Java Platform Module System:
- Zuverlässige Konfiguration
-
Das Modulsystem prüft, ob eine bestimmte Kombination von Modulen alle Abhängigkeiten erfüllt, bevor der Code kompiliert oder ausgeführt wird. Das führt zu weniger Fehlern während der Laufzeit.
- Starke Verkapselung
-
Module wählen explizit aus, was sie anderen Modulen zur Verfügung stellen. Unbeabsichtigte Abhängigkeiten von internen Implementierungsdetails werden verhindert.
- Skalierbare Entwicklung
-
Explizite Grenzen ermöglichen es Teams, parallel zu arbeiten und trotzdem wartbare Codebases zu erstellen. Nur explizit exportierte öffentliche Typen werden gemeinsam genutzt, wodurch Grenzen geschaffen werden, die vom Modulsystem automatisch durchgesetzt werden.
- Sicherheit
-
Eine starke Kapselung wird auf den tiefsten Schichten innerhalb der JVM durchgesetzt. Dies schränkt die Angriffsfläche der Java-Laufzeitumgebung ein. Ein reflektierender Zugriff auf sensible interne Klassen ist nicht mehr möglich.
- Optimierung
-
Da das Modulsystem weiß, welche Module zusammengehören, einschließlich der Plattformmodule, muss beim Start der JVM kein anderer Code berücksichtigt werden. Es eröffnet auch die Möglichkeit, eine minimale Konfiguration von Modulen für die Verteilung zu erstellen. Außerdem können auf eine solche Menge von Modulen programmweite Optimierungen angewendet werden. Vor den Modulen war dies viel schwieriger, da keine expliziten Abhängigkeitsinformationen verfügbar waren und eine Klasse jede andere Klasse aus dem Klassenpfad referenzieren konnte.
Im nächsten Kapitel untersuchen wir, wie Module definiert werden und welche Konzepte ihr Zusammenspiel bestimmen. Dazu schauen wir uns die Module im JDK selbst an. Es gibt viel mehr Plattformmodule als in Abbildung 1-2 dargestellt.
Die Erkundung des modularen JDK in Kapitel 2 ist eine gute Möglichkeit, die Konzepte des Modulsystems kennenzulernen und sich gleichzeitig mit den Modulen im JDK vertraut zu machen. Das sind schließlich die Module, die du in erster Linie in deinen modularen Java 9-Anwendungen verwenden wirst. Danach bist du bereit, in Kapitel 3 deine eigenen Module zu schreiben.
Get Java 9 Modularität now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.