Modularity Patterns mit dem Java-Modulsystem Jigsaw [Teil 2]
Dies ist der zweite Teil des Artikels über "Modularity Patterns mit dem Java-Modulsystem Jigsaw". Im ersten Teil werden Komponenten beschrieben, die Ziele von Jigsaw erläutert und folgende Modularity Patterns beschrieben:
- Manage Relationships
- Acyclic Relationships
- Published Interface
Modularity Pattern "Separate Abstractions"
Während das letzte beschriebene Pattern "Published Interface" vorgibt, den Implementierungsteil vom Schnittstellenteil in einer Komponente in verschiedene Packages zu trennen, geht das Pattern "Separate Abstractions" noch einen Schritt weiter: Auch hier wird Implementierung von Schnittstelle getrennt, aber in verschiedene Komponenten: Eine API-Komponente enthält nur die Schnittstelle, eine oder mehrere andere Komponenten eine dazu passende Implementierung.
Vorteile
Mit diesem Pattern kann eine Implementierung vollständig ausgetauscht werden bzw. sehr spät, zur Laufzeit, ausgewählt werden. Das Pattern kann (mit Jigsaw) auf verschiedene Arten umgesetzt werden. Wir beschreiben nachstehend drei Varianten. Alle Varianten bieten sogar die Möglichkeit, bei jedem Laufzeit-Aufruf jedes Mal eine andere Implementierung auszuwählen.
Variante 1: Statische Factory wählt Implementierung aus
Wenn mehrere Implementierungskomponenten (im Beispiel: blau, rot und grün) zur Auswahl stehen, wird noch eine Factory-Komponente benötigt, die Schnittstelle und Implementierung zusammenbringt und an einen Client auf Anfrage zurückgibt (s. Abb. 6a). Diese Abbildung 6a ist aber noch nicht vollständig: In einem solchen Zusammenspiel muss die Factory auch alle Implementierungen kennen, denn sie muss in der Lage sein, deren Klassen zu instanziieren. Abb. 6b vervollständigt das Bild: Die Factory hat nun auch requires-Beziehungen zu allen drei Implementierungen (blaue Pfeile). Umgekehrt geben diese ihre Inhalte gerichtet an die Factory frei (rote Pfeile).
Variante 2: Dependency-Injection per Reflection
In der Variante 2 ersetzen wir die Factory durch eine generische Komponente, die per Reflection auf die Implementierungen zugreift. Dazu könnte zum Beispiel das Spring-Framework zum Einsatz kommen. Da Spring unsere fachlichen Implementierungskomponenten natürlich nicht kennt, muss der Zugriff per Reflection erfolgen.
Abb. 7 zeigt diese Umsetzung. Die Packages der drei Implementierungen müssen für Reflectionzugriffe geöffnet werden (orangene Pfeile). Jigsaw "schenkt" bei Reflection die requires-Beziehung. Diese muss also nicht modelliert werden
Variante 3: Dynamische Bindung per uses-provides
Die Variante 3 zur Umsetzung des Patterns nutzt ein natives Jigsaw-Mittel: Per uses/provides können Module zur Startzeit der JVM dynamisch gebunden werden. Am einfachsten lässt sich dies am Beispiel eines JDBC-Treibers erklären: Dessen Auswahl wird in Anwendungen i.d.R. erst zur Startzeit festgelegt.
Das Listing 4 zeigt, wie das Jigsaw-Module java.sql (oben) die Schnittstelle java.sql.Driver über uses definiert, die ein Treiber implementieren muss: Das Module com.mysql.jdbc (unten) bietet mit provides eine solche Implementierung an:
Listing 4: uses-provides für dynamische Bindung
module java.sql { ... // Definiert Schnittstelle und exportiert sie uses java.sql.Driver; exports java.sql; }
module com.mysql.jdbc { requires java.sql; ... // Implementiert Schnittstelle provides java.sql.Driver with com.mysql.jdbc.Driver; }
Der java.util.ServiceLoader stellt zur Startzeit der JVM mögliche Implementierungen für jede der mittels uses definierten Schnittstelle zur Verfügung. Er bietet einen Iterator mit allen vorhandenen Implementierungen an, die Auswahl der Implementierung muss die Anwendung selbst vornehmen. In unserem JDBC-Treiberbeispiel wird man wohl erwarten, dass genau ein Treiber per provides vorhanden ist (kein Treiber oder mehrere Treiber sind nicht akzeptabel).
Modularity Pattern "Module Facade"
Das Pattern "Module Facade" zeigt ein Zusammenspiel von Komponenten und führt eine Fassade ein. Auf OO-Klassenebene wird das durch die GOF-Patterns "Facade", "Decorator" bzw. "Adapter" gelöst. In diesem Pattern fungiert eine Komponente als zentrale Fassade, wenn Komponenten miteinander interagieren. Ein Client benötigt nur die Abhängigkeit zu der Fassaden-Komponente.
Vorteile
Eine solche Fassade kann Details dahinterliegender Komponenten kapseln bzw. deren Schnittstellen, die sich auf verschiedene Komponenten verteilen, übersichtlicher zusammenfassen, um die Nutzung zu vereinfachen oder die Lesbarkeit zu erhöhen. Außerdem kann in der Fassaden-Komponente weitere zusätzliche Funktionalität untergebracht werden (wie Vor- oder Nachbearbeitung oder Querschnittsfunktionalität wie Logging). Auch kann in der Fassade eine Aufrufreihenfolge im Sinne eines bestimmten Workflows erzwungen werden.
Umsetzung mit Jigsaw
In Jigsaw kann man mit den bisher beschriebenen Mitteln eigene Fassaden-Module schreiben, die andere Module bzw. deren Schnittstellen kapseln. In Abb. 8 ist modfacade die Fassade auf die Module modx und mody. modapp hat nur eine Abhängigkeit auf modfacade.
Doch Vorsicht: Wenn modfacade Typen, die z.B. in mody definiert sind, in seiner eigenen Schnittstelle an modapp weiterreicht, so benötigt modapp eigentlich selbst eine reads-Abhängigkeit auf mody, um diese Typen benutzen zu können. Das ist unschön, da modapp eigentlich nicht unbedingt von der Existenz von mody wissen soll.
Jigsaw bietet bei der Modellierung von Abhängigkeiten daher die Möglichkeit, diese transitiv weiterzugeben. Mit dem Schlüsselwort requires transitive (s. Listing 5) kann modfacade eine reads-Abhängigkeit an modapp weitergeben. Der grüne Pfeil in Abb. 8 deutet diese transitiv weitergegebene reads-Beziehung an. modapp erhält somit automatisch eine (grün darstellte) reads-Abhängigkeit auf mody, ohne selber diese Abhängigkeit in seiner module-info definieren zu müssen.
Listing 5: module-info.java von modfacade
module modfacade { requires modx; requires transitive mody; }
Per requires transitive ist es jedoch nicht möglich, dahinterliegende Komponenten bzw. Schnittstellen wegzukapseln – im Gegenteil werden ja alle Typen automatisch durchgereicht.
Mehrfach transitiv
Mit requires transitive wird eine Abhängigkeit eins-transitiv weitergegeben. Die Kette lässt sich auch mehrfach-transitiv fortsetzen. Abb. 9 stellt diese Weitergabe über zwei Ebenen dar, hier über ein zweites Fassaden-Module modfacade2. Solange die transitive Kette nicht abbricht, werden Abhängigkeiten transparent immer weitergereicht.
Design von Aggregator-Modulen
Mit requires transitive lassen sich nützliche Aggregator-Module erstellen, die selbst gar keine Inhalte (keine Klassen, keine Packages) enthalten. Stattdessen bündeln sie andere Module an einer zentralen Stelle. Ein Beispiel zeigt unser Grundlagenartikel [1] anhand der JDK-Profile.
Modularity Pattern "Test Module"
Das Pattern "Test Module" besagt, dass eigene Komponenten für den Softwaretest benutzt werden sollen. Das Pattern sagt also, dass zu jeder Komponente eine korrespondierende Test-Komponente erstellt werden soll. Produktivcode und Testcode sollen immer getrennt werden. Ein willkommener Nebeneffekt einer Test-Komponente ist, dass damit die Nutzung der Schnittstelle des Testlings dokumentiert wird.
Blackbox-Test
Blackbox-Tests kann man einfach in einem separaten Jigsaw-Module ablegen. Solche Tests benötigen per Definition nur Zugriff auf die exportierte, öffentliche Schnittstelle des Testlings. Für einen Blackbox-Test passt also eine separate Test-Komponente perfekt. Sie testet die zu testende Komponente (den "Testling") nur über deren öffentliche Schnittstelle. Die Test-Komponente soll selbst möglichst nicht von weiteren Komponenten abhängen. Dazu müssen i.d.R. Mocks für all die Komponenten erstellt werden, von denen der Testling abhängt. Dadurch kann man Komponenten unabhängig voneinander testen und Fehler leichter lokalisieren. Allerdings kann die Erstellung der Mocks entsprechend aufwändig sein.
Das Test-Module definiert also eine reads-Beziehung auf den Testling und hat damit ausreichenden Zugriff (s. Abb. 10).
Whitebox-Test
Schwieriger ist die Umsetzung von Whitebox-Tests mit Jigsaw: Wenn Whitebox-Testcode Zugriff auf das (nicht-exportierte) Implementierungsgeheimnis benötigt, so muss ihm dieser Zugriff ermöglicht werden. Dazu sind verschiedene Varianten denkbar (s. auch der entsprechende Abschnitt in [1]).
Leider bietet Jigsaw keine Scopes für die Modellierung von Abhängigkeiten an. Eine Definition eines Scopes "Test" (wie aus Maven bekannt) ist also leider nicht möglich.
Modularity Pattern "External Configuration"
Das Pattern "External Configuration" sieht eine Trennung von Komponenten-Code von dessen Konfiguration vor.
Vorteile
Die Konfiguration einer Komponente soll getrennt von der Komponente abgelegt sein und gepflegt werden. Damit wird es möglich, den gleichen Code in verschiedenen Umgebungen laufen zu lassen und die umgebungsabhängigen Teile (möglichst spät) anzupassen. Je später die Konfiguration erfolgt, desto flexibler ist die Nutzung. Andererseits wird damit Nachvollziehbarkeit und Fehlerverfolgung erschwert.
Umsetzung mit Jigsaw
Wie beschrieben gruppiert ein Jigsaw-Module eine Menge von Packages und ihre Ressourcen-Dateien, also z. B. Property-Dateien, XML- oder YAML-Konfigurationen etc.
Die Konfiguration kann auf verschiedene Arten erfolgen:
Variante 1 "Konfiguration bei Build/Paketierung": Man kann im Build-Prozess die Konfigurationsanteile mit in das Modular-JAR-File paketieren. Für verschiedene Zielumgebungen (z. B. Testumgebung, Abnahmeumgebung, Produktivumgebung) muss der Build also Varianten dieser JAR-Dateien erzeugen und entsprechend nachvollziehbar ablegen. Hierfür bietet Jigsaw keine besonderen Wege oder Hilfsmittel. Eine solche Konfiguration erfolgt durch die üblichen Build-Mittel von Maven oder Gradle.
Variante 2 "Konfiguration beim Deployment": Zur Installationszeit werden die Konfigurationsanteile (manuell oder durch ein Installationsskript) festgelegt. Ein Jigsaw-Module greift dann zur Laufzeit auf die Konfigurationseinstellungen zu. Auch hierfür bietet Jigsaw keine Unterstützung.
Variante 3 "Konfiguration zur Laufzeit": Alternativ kann man Konfigurationsanteile für eine Komponente auch in eine spezielle zweite Konfigurationskomponente auslagern und diese mit-deployen. Dessen Inhalte müssen (nur) für die erste Komponente zugreifbar sein, die Ressourcen müssen also von außen zugreifbar sein. Denn Jigsaw schützt nicht nur Klassentypen, sondern auch den Zugriff auf Ressourcen:
- Allerdings sind Ressourcen-Dateien nur dann geschützt, wenn sich deren Pfad auf einen gültigen Package-Namen abbilden lässt.
- Ressourcen im "unnamed Package" sind folglich nicht geschützt.
- Auch Dateien mit ungültigem Package-Namen, z. B. für META-INF/MANIFEST.MF, schützt Jigsaw nicht.
- Alle anderen Ressourcendateien eines Modules sind aus anderen Modulen per se nicht zugreifbar. Ein Zugriff muss explizit mit opens (wohlgemerkt nicht exports) erlaubt werden.
Abb. 11 zeigt das Zusammenspiel einer Komponente modapp mit ihrer Konfigurationskomponente modapp.config.
Modularity Patterns "Levelize Modules", "Physical Layers" & "Levelized Build"
Die Patterns "Levelize Modules", "Physical Layers" und "Levelized Build" beschreiben, wie Komponenten zu "höherwertigen" Strukturen angeordnet werden können. Damit werden Schichtungen definiert, die auf höherer Ebene von Subsystemen eine Anwendung strukturieren.
Im Entwurf einer Anwendung ist es sinnvoll, Komponenten in technische Schichten sowie in fachliche Säulen einzusortieren, wie in Abb. 12 dargestellt.
Mit einer solchen Strukturierung schränkt man per Konvention Abhängigkeitsbeziehungen bzw. Aufruf ein: Komponenten einer Schicht dürfen nur Komponenten in der darunterliegenden Schicht oder in der gleichen Schicht aufrufen. Das ist durch die Pfeile in der Abbildung dargestellt.
Umsetzung mit Jigsaw
Jigsaw bietet für solche architektonischen Strukturen keine explizite Unterstützung. Es ist also Aufgabe im Software-Entwurf, mit dem erstellten (azyklischen) Jigsaw-Abhängigkeitsgraph die Schichten und Säulen korrekt abzubilden und die Jigsaw-Module in die Schichten und Säulen einzuordnen.
Wie erwähnt ermöglicht Jigsaw keine Hierarchie- oder Gruppenbildung von Modulen. Insbesondere sind also Module-Namen "flach" modelliert (anders als bei Maven gibt es keine Entsprechung einer Group-ID). Eine Module-Zugehörigkeit zu technischen Schichten oder fachlichen Säulen muss man also über Namenskonventionen abbilden (zum Beispiel über das altbekannte Reverse-DNS-Namensschema).
Ein weiterführendes Konzept sind Jigsaw-Layer, jedoch bieten sie nur eine Gruppierung zur Laufzeit, sind also für eine architektonische Modellierung eher ungeeignet (für weitere Details siehe auch example_layer-hierarchy in unserer Jigsaw-Beispielsammlung auf Github [2]).
Fazit
Mit Jigsaw kann man elementare Modularity Patterns gut umsetzen. Für manche Patterns bringt Jigsaw eine direkte, native Umsetzung mit (z. B. dynamische Bindung per uses/provides). Andere Patterns muss man zwar "zu Fuss" implementieren, die Umsetzung ist aber einfach möglich (z. B. Trennung von Schnittstelle und Implementierung in verschiedene Packages).
Leider hat Jigsaw wie beschrieben aber auch einige strukturelle Defizite. Versionierung ist nicht möglich, eine Anordnung von Modulen in Gruppen oder Hierarchien wird nicht unterstützt, ein Scoping von Beziehungen ist nicht definierbar. Hier hätten wir uns die Umsetzung weiterreichender Konzepte gewünscht.
- Informatik Aktuell – Dr. R. Grammes, M. Lehmann, Dr. K. Schaal: Java 9 bringt das neue Modulsystem Jigsaw
- Jigsaw-Beispielsammlung auf Github