Modulare Anwendungen mit Java: Tutorial mit Beispielen
Seit Java 9 hat ein Modulsystem auf der Plattform Einzug gehalten. Der Artikel geht der Frage nach, warum Modularisierung essentiell wichtig ist für die Beherrschung der zunehmenden Komplexität von Software-Systemen und damit auch für den Bau von wartbaren und langlebigen Architekturen. Darüber hinaus wird gezeigt, wie auf Basis des Java-Modulsystems Anwendungen entworfen und gebaut werden können und was das Ganze mit Microservice-Architekturen zu tun hat.
Beispiele für Module finden sich überall. In der Automobilindustrie teilen sich die verschiedenen Autotypen eines Herstellers eine Reihe von Modulen, die - unterschiedlich kombiniert und um weitere Module ergänzt - zu entsprechenden Automodellen zusammengesetzt werden. Modularisierung im noch größeren Maßstab findet sich am Beispiel der internationalen Raumstation ISS. Das größte, künstliche, im Erdorbit befindliche Objekt ist als gemeinsames Projekt von fünf Raumfahrt-Agenturen und zehn Ländern entstanden. Die Raumstation besteht aus 34 Modulen, die in den nächsten Jahren um weitere ergänzt werden. Die einzelnen Module sind in unterschiedlichen Ländern von unterschiedlichen Teams gebaut worden, um diese nach erfolgreichen Tests auf dem Boden schließlich in der Erdumlaufbahn zusammenzusetzen bzw. an die Station anzudocken. Ein Blick in all diese Module zeigt weitere Modularisierungsansätze, indem verschiedene, autark funktionierende technische Komponenten zu neuen Funktionseinheiten zusammengesetzt wurden. In der Industrie ist Modularisierung längst ein tragfähiges Strategiekonzept, um Komplexität zu reduzieren, aber auch aus unternehmerischer Sicht, um sich veränderten Markt- und Wettbewerbsbedingungen anpassen zu können, indem Module schnell ausgetauscht, angepasst und verbessert werden können.
Im Fall der Raumstation wurde das Innenleben jedes Moduls unabhängig von den anderen Modulen gebaut und ist auf die gewünschte Funktionalität hin testbar. Beachtet werden musste beim Bau der Module, an welche anderen Module es gekoppelt wird, ob Funktionen anderer Module genutzt werden sollen, welche Funktionalitäten anderen Modulen zur Verfügung zu stellen sind und wie die Verbindungsstellen zwischen den Modulen aussehen müssen. Übertragen auf die Softwareentwicklung ergeben sich daraus drei wesentliche Aspekte:
- starke Kapselung,
- wohldefinierte Schnittstellen und
- explizite Abhängigkeiten.
"Starke Kapselung" bedeutet, dass der Zugriff auf die Implementierungen des Moduls nur über Schnittstellen erfolgt und die eigentlichen Implementierungsdetails nach außen hin verborgen bleiben. Bei Letzterem wird auch vom "Geheimnisprinzip" gesprochen. Schnittstellen müssen wohldefiniert sein, damit andere Module wissen, wie sie zugreifen können und welche Funktionalitäten das Modul überhaupt zur Verfügung stellt. Die expliziten Abhängigkeiten beschreiben, von welchen Modulen ein anderes Modul abhängt. Ohne diese explizite Definition wäre ein Wissen darüber, wie die Module untereinander abhängen und kommunizieren, nur schwer nachvollziehbar. Aber was macht die Modularisierung jetzt so besonders? Warum sollte Modularisierung angewendet werden?
Modularisierung ist ein machtvolles Instrument
Eines der wichtigsten Prinzipien der Softwaretechnik, wenn nicht sogar das wichtigste, ist die Modularisierung. Wie im Beispiel der internationalen Raumstation zu sehen war, half die Aufteilung der Raumstation in einzelne Module, die Komplexität erheblich zu verringern. Dadurch konnten die Module getrennt voneinander entwickelt und zudem auch getrennt voneinander betrachtet und verstanden werden. Genau das ist es, was die Wartbarkeit und damit letztlich auch die Langlebigkeit von Systemen erheblich erhöht. Die Reduzierung von Komplexität schafft eine direkte Verbesserung von Lesbarkeit und Verständlichkeit. Schnittstellen-Definitionen vereinfachen darüber hinaus die Erweiterbarkeit eines Systems und die Rekombination von Modulen.
Modularisierung unterstützt zudem direkt die agile Softwareentwicklung, können doch leicht verschiedene Teams für unterschiedliche Module verantwortlich sein. Des weiteren lassen sich Module ähnlich schneiden, wie es der Microservice-Ansatz fordert. Wie später noch zu sehen ist, können dadurch neue und vorteilhafte Ansätze bei der Softwareentwicklung entstehen.
Modularisierung ist im Grunde nichts neues und sie findet sich in allen modernen Softwareprojekten wieder, sei es in Form von Klassen mit ihren Schnittstellen oder bei Komponenten. Was aber häufig sträflich vernachlässigt wird, ist die Modularisierung auf einer höheren Ebene, und genau das ist später dann eine der Ursachen für Wartungs- und Weiterentwicklungs-Problemen.
In vielen Unternehmen wird der Ansatz verfolgt, ein Softwareprojekt in unterschiedliche Teilprojekte zu untergliedern, die dort entstandenen monolithischen Codestrukturen zu einzelnen JARs zu kompilieren, um diese zuletzt zu einer Anwendung zusammenzusetzen. Je nach Projektanforderung ist dagegen zunächst nichts einzuwenden. Hinter der Kombination verschiedener JARs ist oft der Wille zu einem Modularisierungsansatz oberhalb der Klassen zu erkennen. Vergessen wird dabei aber häufig, dass normale JAR-Artefakte nichts weiter als .zip-Archive sind, die mit Modulen wenig zu tun haben. JARs haben keine Schnittstellen, kapseln nicht ihren Inhalt und es gibt auch keine Information darüber, ob und von welchen anderen JARs sie abhängen. Mit der nötigen Disziplin kann auf diesem Wege durchaus modularisiert werden, aber die Modulgrenzen sind nur als theoretische Grenzen definiert, können bis Java 8 allerdings nicht mit technischen Mitteln durchgesetzt werden. Dies führt in der Praxis leider oft nach kurzer Zeit dazu, dass die Modularisierung vernachlässigt wird, Abhängigkeiten wuchern und per Reflection auf Klassen und Methoden Zugriff genommen wird, was vom Entwickler ursprünglich so nicht vorgesehen war. Die Berücksichtigung einer sauberen und im weiteren Entwicklungsprozess nicht aufgeweichten Modularisierung wird sich – bezogen auf die Lebensdauer eines Systems – aber immer als machtvolles Instrument zur Erstellung wartbarer Systeme herausstellen. Mit Java 9 wurde ein Modulsystem eingeführt, welches eine direkte Unterstützung für das Bauen modularer Anwendungen bietet und auch in weiteren Entwicklungszyklen der Software die Modularisierung garantieren kann.
Das Java-Modulsystem
Mit der Einführung des Java-Modulsystems liegt das JDK ebenfalls modularisiert vor, was die Erstellung eigener Java-Laufzeitumgebungen erlaubt, die dann nur jene Plattform-Module enthalten, die von der ausgelieferten Java-Anwendung wirklich benötigt werden. Zudem erlaubt das Modulsystem die Erstellung eigener Java-Module. Bis einschließlich Java 8 bestand eine Anwendung aus Klassen, Daten und Ressourcen, die auf verschiedene Pakete aufgeteilt wurden. Ein Modul beinhaltet diese Pakete und liegt hierarchisch gesehen somit oberhalb dieser, wie es Abb. 1 verdeutlicht.
Ein Java-Modul besteht aus seinem Modulnamen, einer Schnittstelle und den Paketen mit den eigentlichen Implementierungen beziehungsweise Daten und Ressourcen. Dabei wird über die Modul-Schnittstelle deklariert, was an Paketen nach außen zur Verfügung steht und was für Abhängigkeiten zu anderen Modulen existieren. Abb. 2 zeigt den grundsätzlichen Aufbau eines Java-Moduls.
Die Modul-Schnittstelle deklariert drei Dinge:
- exports: die Deklaration der nach außen zur Verfügung gestellten Pakete
- requires: Angabe darüber, auf welche anderen Module zugegriffen wird
- Uses/Provides: Auflistung der Dienste, die ein Modul zur Laufzeit zur Verfügung stellt oder selber benötigt
- opens: Öffnet die angegebenen Pakete für Reflection-Zugriffe zur Laufzeit
Die Modul-Schnittstelle wird in Form einer Datei, dem Modul-Deskriptor, angelegt und oberhalb der Paketstruktur unter dem Namen module-info.java abgelegt. Die Struktur des Inhalts sieht wie folgt aus:
module <Modulname> { requires <benötigtes Modul>; exports <exportiertes Paket>; }
Zur eindeutigen Benennung von Modulen wird die Vorgehensweise nach der "Reverse domain name notation" empfohlen, so dass ein einfaches Modul folgenden Moduldeskriptor haben könnte:
module de.firma.modmain { }
Abb. 3 zeigt die sich ergebende Projektstruktur. Darin ist das Projektverzeichnis ModulDemo mit dem Source-Verzeichnis src angelegt. Innerhalb dieses Verzeichnisses liegt das eigentliche Modul de.firma.modmain und darunter der Modul-Deskriptor sowie die Pakete. Dies ist bereits ausreichend, um ein funktionsfähiges Java-Modul zu erstellen.
Um eine Modulabhängigkeit zu deklarieren, wird zu dem obigen Modul ein weiteres mit dem Namen de.firma.moda erstellt. Abb. 4 zeigt die gewünschten Abhängigkeiten. Das Modul de.firma.modmain soll auf das Modul de.firma.moda lesend zugreifen und das Modul de.firma.moda soll das Paket de.firma.moda nach außen freigeben und damit zugreifbar machen.
Die sich ergebenen Moduldeskriptoren sehen wie folgt aus:
module de.firma.modmain { requires de.firma.moda; } module de.firma.moda { exports de.firma.moda; }
Nur auf das, was über die Schnittstelle explizit nach außen freigegeben wird, kann zugegriffen werden, wobei auch ein Zugriff per Reflection verwehrt bleibt. Um diese Art des Zugriffs dennoch zuzulassen, kann das Schlüsselwort opens verwendet oder direkt das gesamte Modul für Reflection-Zugriffe freigegeben werden. Dafür gibt es neben anderen Modularten die sogenannten "Open Modules". Die verschiedenen Modularten und die Möglichkeit der Formulierung transitiver Abhängigkeiten soll an dieser Stelle jedoch nicht weiter ausgeführt werden.
Goodbye Klassenpfad – Willkommen Modulpfad
Mit den Java-Modulen wird der Modulpfad eingeführt, der neben dem Klassenpfad existiert und diesen auf lange Sicht ablösen soll. Konnte bisher beim Starten einer Anwendung alles im Klassenpfad liegende geladen werden, verhält es sich beim Modulpfad anders. Beim Starten einer modularisierten Anwendung wird der Name des initialen Moduls angeben, also des Moduls, welches die main()-Methode enthält. Die Java-Plattform lädt dieses Modul und schaut im Moduldeskriptor nach, welche abhängigen Module benötigt werden und lädt diese ebenfalls. Diese zusätzlichen Module werden wiederum auf Abhängigkeiten untersucht usw. bis sich schließlich der komplette Abhängigkeitsgraph, der sogenannte Modulgraph, ergibt. Die Kenntnis vom Modulpfad ist wichtig für das Verständnis eines weiteren Mechanismus, dem der Services.
Services
Seit Java SE 6 existiert in der Spezifikation ein Service-Provider-Mechanismus, der für das Java-Modulsystem entsprechend erweitert wurde und eine zusätzliche Entkopplung zwischen Modulen ermöglicht. Abb. 5 zeigt das grundsätzliche Konzept des Service-Provider-Mechanismus.
Der Moduldeskriptor erlaubt die Definition von Service-Provider und Service-Consumer. Module, die als Consumer fungieren, können dann zur Laufzeit das Service-Provider-Modul selber wählen, ohne dass zuvor eine explizite Abhängigkeit zwischen diesen Modulen deklariert wurde. Zur Laufzeit werden die Provider-Module an zentraler Stelle registriert, was automatisch durch die Plattform erfolgt. Wenn dann ein Consumer-Modul Zugriff auf einen bestimmten Provider-Module-Type haben möchte, welches durch ein Service-Provider-Interface definiert ist, wird ein LookUp auf der Registrierungsstelle ausgeführt und eine passende Implementierung des Provider-Module-Types zurückgeliefert.
Von diesem Mechanismus wird im folgenden Beispiel Gebrauch gemacht.
Entwurf einer modularen Anwendung
Im folgenden wird die in Abb. 6 dargestellte Anwendung entworfen. Dabei handelt es sich um ein Tool zur Verwaltung von Sessions und Speakern für eine Konferenz. Ausgehend von dem klassischen Ansatz einer 3-Schichten-Architektur, wo die oberste Schicht die GUI enthält, die mittlere Schicht die eigentliche Logik und die unterste für die Datenhaltung zuständig ist, wäre die Abbildung dieser Schichten auf Module naheliegend. Diese resultierenden drei Module würden bei einer komplexen Anwendung das Behältnis für Untermodule darstellen. Bei dieser Art von Modulen wird auch von Aggregatormodulen gesprochen. Bei großen Anwendungen ist hier die Aufteilung des Systems innerhalb der Aggergatormodule in die einzelnen Untermodule und deren Zusammenspiel untereinander die große Kunst. Neben einer ganzen Reihe von zu beachtenden Dingen ist es wichtig, wie groß z. B. die Schnittstellen sind, wie hoch die Anzahl der Abhängigkeiten von Modulen zu anderen Modulen ist und wieviele Module insgesamt existieren oder wiederum in Untermodule aufgeteilt sind. Bei genügend komplexen Systemen ist dies keine triviale Aufgabe und es muss darauf geachtet werden, letztlich nicht in einer schwer zu beherrschenden Modul-Hölle zu landen.
Die Microservice-Philosophie verfolgend kann aber auch ein anderer Weg eingeschlagen werden. Wieder ausgehend von den drei Schichten lässt sich eine Aufteilung in die einzelnen Fachdomänen gegenüberstellen und auf Basis dessen Module entwerfen. Im diesem Beispiel wären die beiden Fachdomänen die Verwaltung der Speaker und die Verwaltung der Sessions. Das heißt, dass die Module nicht mehr den Schichten folgend horizontal, sondern den Fachdomänen folgend vertikal geschnitten werden. Abb. 7 zeigt den für das Beispiel verfolgte Ansatz.
Das GUI wurde in ein separates Modul ausgelagert und ist somit für die Darstellung der Inhalte beider Fachdomänen verantwortlich. Im Gegensatz zu diesem horizontal geschnittenen Modul sind die weiteren den Fachdomänen zugeordneten Teile auf den Schichten in ein jeweils vertikal geschnittenes Modul aufgeteilt. Zudem findet hier der Service-Provider-Mechanismus seine Anwendung, indem das initiale GUI-Modul als Consumer handelt und die beiden anderen Module die Provider repräsentieren. Damit ist eine maximale Entkopplung zwischen dem GUI-Modul und den anderen beiden Modul erfolgt, die untereinander ohnehin keine Anhängigkeit voneinander haben. Diese drei Module ließen sich sehr komfortabel auf drei verschiedene Teams zur Entwicklung aufteilen. Wie im Abschnitt über die Services bereits angesprochen, werden die verschiedenen Provider über ein gemeinsames Interface spezifiziert. Dieses Interface wird im Java-Modulsystem ebenfalls in ein Modul ausgelagert, so dass sich insgesamt die in Abb. 8 dargestellten vier Module ergeben.
Die entsprechenden Moduldeskriptoren sehen wie folgt aus:
module de.firma.api { exports de.firma.api; } module de.firma.gui { requires de.firma.api; uses de.firma.api.ConferenceService; } module de.firma.sessions { requires de.firma.api; provides de.firma.api.ConferenceService with de.firma.sessions.SessionService; } module de.firma.speaker { requires de.firma.api; provides de.firma.api.ConferenceService with de.firma.speaker.SessionService; }
Das GUI-Modul deklariert in seinem Moduldeskriptor mit dem Schlüsselwort uses, was für einen Provider-Typ es benötigt. Die Provider-Module geben mit provides an, für welchen Provider-Typ sie eine Implementierung liefern, die wiederum hinter dem Schlüsselwort with angegeben wird.
Das Interface wird im API-Modul angelegt und sieht wie folgt aus:
package de.firma.api; public interface ConferenceService<T> { public Collection<T> getAll(); public Optional<?> get(String id); public void update(T item); public Optional<?> persist(T item); public void remove(T item); }
Damit das GUI-Modul die gelieferten Provider zur Laufzeit auch auseinanderhalten kann, wurde der Weg über Annotations gewählt. Dazu wurden die beiden Annotations @Sessions und @Speaker erzeugt, die die jeweilige Provider-Klasse markieren. Nachfolgend wird die Implementierung des SessionService gezeigt:
package de.firma.sessions.service; @Sessions public class SessionService implements ConferenceService<Session> { protected SessionDAO sessionDAO; public SessionService() { this.sessionDAO = new SessionDAO(); } public Collection<Session> getAll() { return sessionDAO.getEntities(); } … }
Das GUI-Modul nutzt zum Auffinden der Provider den mit Java 6 eingeführten ServiceLocator, wie im nachfolgenden Code zu sehen ist.
package de.firma.gui; import java.util.ServiceLoader; public class ServiceFactory { private ServiceLoader<ConferenceService> services = ServiceLoader.load( ConferenceService.class); private ConferenceService<?> getServiceByAnnotation(Class annotation) throws ClassNotFoundException { Optional<ConferenceService> service = services.stream().filter(provider -> provider.type().isAnnotationPresent(annotation)).map(ServiceLoader.Provider::get).findFirst(); services.reload(); if (service.isPresent()) { return service.get(); } else { throw new ClassNotFoundException(annotation.getName() + "Service not found."); } } }
Der Methode getServiceByAnnotation wird die Annotation des gewünschten Providers übergeben und innerhalb der Methode wird mit ServiceLoader.load(ConferenceService.class) ein Iterator über alle Implementierungen des ConferenceService-Interfaces geholt. Es wird ein Stream erzeugt und dieser bezüglich der Annotation gefiltert, um schließlich den mit der Annotation markierten Provider zu erhalten und zurückzuliefern.
Es ist wichtig, die Funktionsweise des ServiceLoader genau zu verstehen, da dieser "lazily" arbeitet. Das bedeutet, das ServiceLoader::load noch keine Instanzen der Provider erzeugt. Zunächst erzeugt jeder Aufruf von ServiceLocator::load eine neue Instanz des ServiceLoaders. Das bedeutet, wenn mehr als eine ServiceLoader-Instanz existiert, dann wird jeder Locator auch andere Provider-Instanzen erzeugen und zurückliefern. Erst bei der Anforderung eines Providers wird dieser von dem ServiceLoader instanziiert oder falls früher schon geschehen, die Instanz aus einem Cache zurückgeliefert. Dieser Umstand sollte bedacht werden, wenn ein Provider einen Status enthalten soll, aber über verschiedene ServiceLoader auf die Provider zugegriffen wird. Es gibt keine Singleton-Instanz des Providers und somit würde eine Statusinformation auch nicht geteilt.
In dem obigen Beispiel wird ServiceLoader::reload aufgerufen, was den ServiceLoader dazu veranlasst, seinen Provider-Cache zu löschen, damit alle Provider neu geladen werden. Dies ist dem Umstand geschuldet, dass ein Stream nur einmal konsumiert werden kann. Um das Beispiel möglichst einfach zu halten, ist dieser Ansatz gewählt worden, aber natürlich gibt es auch andere Möglichkeiten.
Der komplette Code des Beispiels kann auf GitHub [1] eingesehen werden. Weiterführende Informationen zur Modularisierung und zu den vielen hier nicht behandelten Möglichkeiten des Java-Modulsystems finden sich im Buch "Modularisierung mit Java 9" [2].
Fazit
Java-Module sind ein sehr guter Weg, um saubere, modulare Architekturen zu erhalten und Modulgrenzen auch technisch durchzusetzen. Die nötige Modularisierungs-Bereitschaft vorausgesetzt, ist das Modularisierungsprinzip eines der machtvollsten Instrumente für den Bau wartbarer und langlebiger Architekturen. Denn es gibt nicht die "richtige" Architektur, es kann lediglich versucht werden, eine Architektur zu erschaffen, die Weiterentwicklung und Wartung unterstützt und dafür ist Modularisierung unersetzlich und steckt auch als Kernidee hinter Modularisierungsansätzen wie Microservices und Containern.
- Beispiel auf GitHub
- Guido Oelmann, 2017: Modularisierung mit Java 9: Grundlagen und Techniken für langlebige Softwarearchitekturen