Kapitel 4. Dienstleistungen

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

In diesem Kapitel lernst du, wie du Dienste nutzen kannst, eine wichtige Funktion, um modulare Codebases zu erstellen.Nachdem wir die Grundlagen des Bereitstellens und Konsumierens von Diensten kennengelernt haben, wenden wir sie auf EasyText an und machen es so erweiterbar.

Fabrik-Muster

Im vorigen Kapitel hast du gesehen, dass wir mit Kapselung allein nicht sehr weit kommen, wenn wir wirklich entkoppelte Module erstellen wollen.Wenn wir noch schreiben

MyInterface i = new MyImpl();

Jedes Mal, wenn wir eine Implementierungsklasse verwenden müssen, bedeutet das, dass die Implementierungsklasse exportiert werden muss. Folglich bleibt eine starke Kopplung zwischen dem Consumer und dem Provider der Implementierung bestehen: Der Consumer benötigt das Provider-Modul direkt, um seine exportierte Implementierungsklasse zu verwenden. Änderungen an der Implementierung wirken sich direkt auf alle Consumer aus. Wie du gleich sehen wirst, sind Services eine hervorragende Lösung für dieses Problem. Bevor wir uns jedoch mit Services beschäftigen, wollen wir sehen, ob wir dieses Problem mit einem bestehenden Muster lösen können, indem wir auf unserem bisherigen Wissen über das Modulsystem aufbauen.

Das Factory Pattern ist ein bekanntes Entwurfsmuster, das genau das Problem zu lösen scheint, mit dem wir es zu tun haben. Sein Ziel ist es, einen Konsumenten von Objekten von der Instanziierung bestimmter Klassen zu entkoppeln. Seit der ersten Beschreibung des Factory Pattern in dem kultigen Buch Gang of Four Design Patterns von Gamma et al. (Addison-Wesley) sind viele Variationen dieses Musters entstanden. Versuchen wir, eine einfache Variation dieses Musters zu implementieren und zu sehen, wie weit wir mit der Entkopplung von Modulen kommen.

Zur Veranschaulichung des Beispiels verwenden wir wieder die Anwendung EasyText, indem wir eine Fabrik für Analyzer Instanzen implementieren. Eine Implementierung für einen bestimmten Algorithmusnamen zu erhalten, ist recht einfach, wie in Beispiel 4-1 gezeigt.

Beispiel 4-1. Eine Fabrikklasse für Analyzer-Instanzen (➥ chapter4/easytext-factory)
public class AnalyzerFactory {

   public static List<String> getSupportedAnalyses() {
     return List.of(FleschKincaid.NAME, Coleman.NAME);
   }

   public static Analyzer getAnalyzer(String name) {
      switch (name) {
         case FleschKincaid.NAME: return new FleschKincaid();
         case Coleman.NAME: return new Coleman();
         default: throw new IllegalArgumentException("No such analyzer!");
      }
   }

}

Du kannst eine Liste der unterstützten Algorithmen von der Factory abrufen und eine Analyzer Instanz für einen Algorithmusnamen anfordern. Die Aufrufer von AnalyzerFactory wissen jetzt nicht mehr, welche Implementierungsklassen den Analyzern zugrunde liegen.

Aber wo platzieren wir diese Fabrik? Zum einen braucht die Fabrik selbst noch Zugriff auf mehrere Analysemodule mit ihren Implementierungsklassen. Andernfalls wäre die Instanziierung der verschiedenen Implementierungsklassen in getAnalyzer nicht möglich. Wir könnten die Fabrik im API-Modul unterbringen, aber dann hätte das API-Modul eine Kompilierzeitabhängigkeit von allen Implementierungsmodulen, was unbefriedigend ist. Eine API sollte nicht eng an ihre Implementierungen gekoppelt sein.

Lass uns die Fabrik erst einmal in ein eigenes Modul packen, wie in Abbildung 4-1 gezeigt.

Factory
Abbildung 4-1. Das Fabrikmodul entkoppelt die Frontends von den Modulen zur Implementierung der Analyse. Es gibt keine "requires"-Beziehung zwischen den Frontend-Modulen und den Analyse-Implementierungsmodulen.

Jetzt kennen die Frontend-Module nur noch die API und die Fabrik:

module easytext.cli {
   requires easytext.analysis.api;
   requires easytext.analysis.factory;
}

Eine Instanz von Analyzer zu bekommen, ist trivial:

Analyzer analyzer = AnalyzerFactory.getAnalyzer("Flesch-Kincaid");

Haben wir mit diesem Fabrikansatz etwas gewonnen, abgesehen von der erhöhten Komplexität?

Auf der einen Seite wissen die Frontend-Module nichts von den Analysemodulen und den Implementierungsklassen. Es gibt keine direkte requires Beziehung mehr zwischen den Konsumenten und den Anbietern von Analysen. Die Frontend-Module können unabhängig von den Analyse-Implementierungsmodulen kompiliert werden. Wenn die Fabrik zusätzliche Analysen anbietet, werden die Frontends diese gerne und ohne Änderungen nutzen. (Erinnere dich daran, dass sie mit AnalyzerFactory::getSupportedAnalyses Algorithmusnamen entdecken können, um Instanzen anzufordern.)

Auf der anderen Seite gibt es auf der Ebene der Fabrikmodule und darunter immer noch die gleichen Probleme mit der engen Kopplung. Jedes Mal, wenn ein neues Analysemodul hinzukommt, muss die Fabrik eine Abhängigkeit davon bekommen und die Implementierung von getAnalyzer erweitern. Und die Analysemodule müssen immer noch ihre Implementierungsklassen exportieren, damit die Fabrik sie verwenden kann. Sie könnten dies durch einen qualifizierten Export (wie in "Qualifizierte Exporte" beschrieben ) in Richtung des Fabrikmoduls tun, um den Umfang der Exposition zu begrenzen. Aber das setzt voraus, dass die Analysemodule das Fabrikmodul kennen, was eine weitere Form der unerwünschten Kopplung ist.

Das Factory-Pattern bietet also nur eine Teillösung. Wir stoßen auf eine grundsätzliche Einschränkung dessen, was man mit Modulen über requires und exports Relationen machen kann. Die Programmierung auf Schnittstellen ist schön und gut, aber wir müssen die Kapselung opfern, um Instanzen zu erstellen. Zum Glück gibt es eine Lösung im Java-Modulsystem. Im nächsten Abschnitt werden wir untersuchen, wie Dienste einen Ausweg aus dieser schwierigen Situation bieten.

Dienste für die Implementierung Verstecken

Wir haben versucht, die Implementierungsklassen mit dem Factory-Pattern zu verstecken, was uns nur teilweise gelungen ist.Das Hauptproblem ist, dass die Factory immer noch alle verfügbaren Implementierungen zur Kompilierzeit kennen muss und die Implementierungsklassen exportiert werden müssen. Eine Lösung, die dem traditionellen Scannen des Klassenpfads ähnelt, um Implementierungen zu entdecken, wird dieses Problem nicht lösen, da dies immer noch die Lesbarkeit aller Implementierungsklassen in allen Modulen voraussetzen würde. Es wäre immer noch nicht möglich, die Anwendung mit einer anderen Implementierung (neue Algorithmen im Fall von EasyText) zu erweitern, ohne den Code zu ändern und neu zu kompilieren. Das klingt überhaupt nicht nach nahtloser Erweiterbarkeit!

Die Entkopplung kann durch den Services-Mechanismus im Java-Modulsystem erheblich verbessert werden. Mit Services können wir wirklich nur öffentliche Schnittstellen gemeinsam nutzen und den Implementierungscode in Paketen, die nicht exportiert werden, stark kapseln. Vergiss nicht, dass die Verwendung von Services im Modulsystem im Gegensatz zu starker Kapselung (mit expliziten Exporten) und expliziten Abhängigkeiten (mit Requests) völlig optional ist. Du musst Services nicht verwenden, aber sie bieten eine überzeugende Möglichkeit, Module zu entkoppeln.

Dienste werden sowohl in Moduldeskriptoren als auch im Code mit Hilfe der ServiceLoader API ausgedrückt.In diesem Sinne ist die Verwendung von Diensten ein Eingriff: Du musst deine Anwendung so entwerfen, dass sie sie nutzen kann. Wie in "Dependency Injection" erläutert, gibt es neben der Verwendung von Diensten auch andere Möglichkeiten, die Umkehrung der Kontrolle zu erreichen.Im weiteren Verlauf dieses Kapitels wirst du lernen, wie Dienste eine bessere Entkopplung und Erweiterbarkeit ermöglichen.

Wir werden die EasyText-Anwendung so umgestalten, dass sie Dienste nutzen kann. Unser Ziel ist es, dass mehrere Module eine Analyse-Implementierung bereitstellen. Die Frontend-Module können diese Analyse-Implementierungen nutzen, ohne die Anbieter-Module zur Kompilierzeit zu kennen.

Dienste anbieten

Die Implementierung von Diensten in einem anderen Modul, ohne Implementierungsklassen zu exportieren, ist ohne besondere Unterstützung durch das Modulsystem nicht möglich.Das Java-Modulsystem ermöglicht eine deklarative Beschreibung der bereitstellenden und konsumierenden Dienste in module-info.java.

Im EasyText-Code haben wir bereits die Schnittstelle Analyzer definiert, die unser Dienstschnittstellentyp sein wird. Die Schnittstelle wird vom Modul easytext.analysis.api exportiert, das ein reines API-Modul ist.

package javamodularity.easytext.analysis.api;

import java.util.List;

public interface Analyzer {

   String getName();

   double analyze(List<List<String>> text);

}

Normalerweise ist der Servicetyp eine Schnittstelle, wie in diesem Fall. Er kann aber auch eine abstrakte oder sogar konkrete Klasse sein; es gibt keine technische Einschränkung. Außerdem ist der Typ Analyzer dafür gedacht, von den Servicekonsumenten direkt genutzt zu werden. Es ist auch möglich, einen Servicetyp bereitzustellen, der wie eine Fabrik oder ein Proxy funktioniert. Wenn z. B. die Instanzierung von Analyzer teuer wäre oder zusätzliche Schritte oder Argumente für die Initialisierung erforderlich sind, könnte der Diensttyp eher dem AnalyzerFactory ähneln. Bei diesem Ansatz hat der Verbraucher mehr Kontrolle über die Instanzierung.

Jetzt wollen wir unsere erste neue Analysator-Implementierung, den Coleman-Liau-Algorithmus (der vom Modul easytext.algorithm.coleman bereitgestellt wird), zu einem Dienstanbieter umgestalten.Dazu ist nur eine Änderung in module-info.java erforderlich, wie in Beispiel 4-2 gezeigt.

Beispiel 4-2. Moduldeskriptor, der einen Analyzer-Dienst bereitstellt (➥ chapter4/easytext-services)
module easytext.analysis.coleman {

   requires easytext.analysis.api;

   provides javamodularity.easytext.analysis.api.Analyzer
       with javamodularity.easytext.analysis.coleman.ColemanAnalyzer;

}

Die provides with-Syntax erklärt, dass dieser Baustein eine Implementierung der Schnittstelle Analyzer mit der ColemanAnalyzer als Implementierungsklasse bereitstellt.Sowohl der Diensttyp (nach provides) als auch die Implementierungsklasse (nach with) müssen voll qualifizierte Typnamen sein. Am wichtigsten ist, dass das Paket, das die Implementierungsklasse ColemanAnalyzer enthält, nicht von diesem Anbieterbaustein exportiert wird.

Dieses Konstrukt funktioniert nur, wenn das Modul, das provides deklariert, sowohl auf den Diensttyp als auch auf die Implementierungsklasse zugreifen kann. Normalerweise bedeutet dies, dass eine Schnittstelle, in diesem Beispiel Analyzer, entweder Teil des Moduls ist oder von einem anderen benötigten Modul exportiert wird. Die Implementierungsklasse ist normalerweise Teil des Anbietermoduls, in einem gekapselten (nicht exportierten) Paket.

Wenn du in der provides Klausel nicht existierende oder unzugängliche Typen verwendest, lässt sich der Moduldeskriptor nicht kompilieren und es wird ein Compilerfehler erzeugt. Die Implementierungsklasse, die im with Teil der Deklaration verwendet wird, wird normalerweise nicht exportiert. Schließlich ist der Sinn von Services, Implementierungsdetails zu verbergen.

Es sind keine Änderungen am Code des Diensttyps oder der Implementierungsklasse erforderlich, um ihn als Dienst bereitzustellen. Außer dieser module-info.java-Deklaration muss nichts weiter getan werden. Dienstimplementierungen sind einfache Java-Klassen. Es müssen keine speziellen Anmerkungen verwendet oder APIs implementiert werden.

Dienste ermöglichen es einem Modul, anderen Modulen Implementierungen zur Verfügung zu stellen, ohne die konkrete Implementierungsklasse zu exportieren. Das Modulsystem hat besondere Rechte, um auf das Anbieter-Modul zuzugreifen und die nicht exportierte Implementierungsklasse im Namen des Verbrauchers zu instanziieren. Das bedeutet, dass die Verbraucher des Dienstes Instanzen dieser Implementierungsklasse nutzen können, ohne direkt darauf zugreifen zu können. Außerdem weiß der Verbraucher eines Dienstes nicht, welches Modul eine Implementierung bereitgestellt hat, und muss dies auch nicht. Da der einzige gemeinsame Typ zwischen Anbieter und Verbraucher der Diensttyp ist (meistens eine Schnittstelle), gibt es eine echte Entkopplung.

Nachdem wir nun unseren ersten Dienst bereitgestellt haben, können wir diesen Vorgang für die anderen Analyzer Implementierungen wiederholen und haben damit die Hälfte geschafft. Beachte noch einmal, dass diese Module, die Dienste bereitstellen, keine Pakete exportieren. Ein Modul ohne Exporte zu haben, mag auf den ersten Blick etwas kontraintuitiv erscheinen. Nichtsdestotrotz tragen diese Analyse-Implementierungsmodule zur Laufzeit nützliche Funktionen über den Dienstemechanismus bei und kapseln ihre Implementierungsdetails zur Kompilierzeit.

Die andere Hälfte des Refactorings besteht darin, die Dienste zu nutzen. Wir überarbeiten das CLI-Modul so, dass es die Dienste von Analyzer nutzt.

Dienstleistungen konsumieren

Die Bereitstellung von Diensten ist sinnvoll, wenn andere Module sie nutzen können.Die Nutzung eines Dienstes im Java-Modulsystem erfordert zwei Schritte.Der erste Schritt ist das Hinzufügen einer uses Klausel zu module-info.java im CLI-Modul:

module easytext.cli {
   requires easytext.analysis.api;

   uses javamodularity.easytext.analysis.api.Analyzer;
}

Die uses Klausel weist die ServiceLoader an, die du gleich sehen wirst, dass dieses Modul Implementierungen von Analyzer verwenden möchte. Die ServiceLoader stellt dann Analyzer Instanzen für das Modul zur Verfügung.

Die uses Klausel verlangt nicht, dass eine Analyzer Implementierung zur Kompilierzeit verfügbar ist. Schließlich könnte eine Dienstimplementierung von einem Modul bereitgestellt werden, das wir zur Kompilierzeit nicht im Modulpfad haben. Dienste bieten genau deshalb Erweiterbarkeit, weil Anbieter und Verbraucher erst zur Laufzeit gebunden werden. Die Kompilierung schlägt nicht fehl, wenn keine Dienstanbieter gefunden werden. Der Diensttyp (Analyzer) hingegen muss zur Kompilierzeit zugänglich sein - daher die requires easytext.analysis.api Klausel im Moduldeskriptor.

Eine uses Klausel garantiert auch nicht, dass es während der Laufzeit Provider gibt. Die Anwendung wird ohne Provider erfolgreich starten. Das bedeutet, dass zur Laufzeit null oder mehr Provider verfügbar sein können, und unser Code muss damit umgehen.

Nachdem das Modul erklärt hat, dass es die Implementierungen von Analyzer nutzen will, können wir nun damit beginnen, Code zu schreiben, der den Dienst nutzt. Die Nutzung von Diensten erfolgt über die API ServiceLoader. Die API ServiceLoader selbst gibt es bereits seit Java 6. Obwohl sie im JDK weit verbreitet ist, kennen oder nutzen nur wenige Java-Entwickler ServiceLoader."ServiceLoader vor Java 9" liefert weitere historische Hintergründe.

Die ServiceLoader API wird im Java-Modulsystem für die Arbeit mit Modulen verwendet und ist ein wichtiges Programmierkonstrukt für die Arbeit mit dem Java-Modulsystem.Schauen wir uns ein Beispiel an; siehe Beispiel 4-3.

Beispiel 4-3. Main.java
Iterable<Analyzer> analyzers = ServiceLoader.load(Analyzer.class); 1

for (Analyzer analyzer: analyzers) { 2
   System.out.println(analyzer.getName() + ": " + analyzer.analyze(sentences));
}
1

Initialisiere eine ServiceLoader für Dienste des Typs Analyzer.

2

Iteriere über die Instanzen und rufe die Methode analyze auf.

Die Methode ServiceLoader::load gibt eine Instanz von ServiceLoader zurück, die praktischerweise auch Iterable implementiert. Wenn du wie im Beispiel darüber iterierst, werden Instanzen für alle Providertypen erstellt, die für die angeforderte Analyzer Beachte, dass wir hier nur die tatsächlichen Instanzen erhalten, ohne zusätzliche Informationen darüber, welche Module sie bereitgestellt haben.

Nachdem wir die Dienste durchlaufen haben, können wir sie wie jedes andere Java-Objekt verwenden. Eigentlich sind sie ganz normale Java-Objekte, nur dass sie von ServiceLoader für uns instanziiert werden. Da es sich um normale Java-Instanzen handelt, gibt es beim Aufruf eines Dienstes keinen Overhead. Der Aufruf einer Methode eines Dienstes ist ein direkter Methodenaufruf; es gibt keine Proxys oder andere Umwege, die die Leistung verringern.

Mit diesen Änderungen haben wir unseren EasyText Code von einer teilweise entkoppelten Fabrikstruktur zu einem vollständig modularen und erweiterbaren Aufbau umgestaltet, wie in Abbildung 4-2 dargestellt.

Structure of Easytext using ServiceLoader for extensibilitys
Abbildung 4-2. Struktur von EasyText mit ServiceLoader für die Erweiterbarkeit

Der Code ist vollständig entkoppelt, da das CLI-Modul nichts über Module wissen muss, die Analyzer implementieren. Die Anwendung ist leicht erweiterbar, da wir eine neue Analyzer Implementierung hinzufügen können, indem wir einfach ein neues Provider-Modul zum Modulpfad hinzufügen. Alle Dienste, die von diesen zusätzlichen Modulen bereitgestellt werden, werden automatisch über die ServiceLoader Service Discovery aufgenommen. Es sind keine Änderungen am Code oder eine Neukompilierung erforderlich. Das Beste daran ist wohl, dass der Code sauber ist. Die Programmierung mit Diensten ist genauso einfach wie das Schreiben von einfachem Java-Code (weil es eben einfacher Java-Code ist), aber die Auswirkungen auf Architektur und Design sind sehr positiv.

Du hast gesehen, dass Dienste einen einfachen Weg zur Entkopplung bieten. Betrachte Dienste als Eckpfeiler der modularen Entwicklung. Obwohl starke Mechanismen zur Definition von Modulgrenzen der erste Schritt zum modularen Design sind, sind Dienste erforderlich, um streng entkoppelte Module zu erstellen und zu verwenden.

Lebenszyklus der Dienstleistung

Wenn ServiceLoader für die Erstellung von Instanzen der bereitgestellten Dienste verantwortlich ist, ist es wichtig zu wissen, wie das genau funktioniert.In Beispiel 4-3 hat die Iteration dazu geführt, dass die Analyzer Implementierungsklassen instanziiert wurden.ServiceLoader arbeitet faul, d.h. der Aufruf von ServiceLoader::load instanziiert nicht sofort alle bekannten Provider-Implementierungsklassen.

Jedes Mal, wenn du ServiceLoader::load aufrufst, wird ein neues ServiceLoader instanziiert. Ein solches neues ServiceLoader instanziiert wiederum Anbieterklassen, wenn sie angefordert werden. Wenn du Dienste von einer bestehenden ServiceLoader Instanz anforderst, werden zwischengespeicherte Instanzen von Anbieterklassen zurückgegeben.

Dies wird durch den folgenden Code veranschaulicht:

ServiceLoader<Analyzer> first = ServiceLoader.load(Analyzer.class);
System.out.println("Using the first analyzers");
for (Analyzer analyzer: first) { 1
  System.out.println(analyzer.hashCode());
}

Iterable<Analyzer> second = ServiceLoader.load(Analyzer.class);
System.out.println("Using the second analyzers");
for (Analyzer analyzer: second) { 2
  System.out.println(analyzer.hashCode());
}

System.out.println("Using the first analyzers again, hashCode is the same");
for (Analyzer analyzer: first) { 3
  System.out.println(analyzer.hashCode());
}

first.reload(); 4
System.out.println("Reloading the first analyzers, hashCode is different");
for (Analyzer analyzer: first) {
  System.out.println(analyzer.hashCode());
}
1

Indem du über first iterierst, instanziiert ServiceLoader die Implementierungen von Analyzer.

2

Ein neues ServiceLoader, second, instanziiert seine eigenen, neuen Analyzer Implementierungen. Sie liefert andere Instanzen als first.

3

Die ursprünglich instanziierten Dienste werden bei einer erneuten Iteration von first zurückgegeben, da sie von der ersten ServiceLoader Instanz zwischengespeichert werden.

4

Nach reload liefert das Original first ServiceLoader neue Instanzen.

Dieser Code gibt etwa Folgendes aus (die tatsächlichen HashCodes können natürlich variieren):

Using the first analyzers
1379435698
Using the second analyzers
876563773
Using the first analyzers again, hashCode is the same
1379435698
Reloading the first analyzers, hashCode is different
87765719

Da jeder Aufruf von ServiceLoader::load zu neuen Dienstinstanzen führt, haben verschiedene Module, die denselben Dienst nutzen, alle ihre eigene Instanz.Daran solltest du dich erinnern, wenn du mit Diensten arbeitest, die einen Zustand enthalten. Der Zustand wird, wenn keine anderen Vorkehrungen getroffen werden, nicht zwischen verschiedenen ServiceLoaders für denselben Diensttyp geteilt. Es gibt keine Singleton-Dienstinstanz, anders als in den üblichen Dependency-Injection-Frameworks.

Methoden des Dienstanbieters

Dienstinstanzen können auf zwei Arten erstellt werden.Entweder muss die Dienstimplementierungsklasse einen öffentlichen No-arg-Konstruktor haben, oder es kann eine statische Provider-Methode verwendet werden. Es ist nicht immer wünschenswert, dass eine Dienstimplementierungsklasse einen öffentlichen No-arg-Konstruktor hat. In Fällen, in denen mehr Informationen an den Konstruktor übergeben werden müssen, ist eine statische Provider-Methode die bessere Option. Oder du möchtest eine bestehende Klasse ohne No-Arg-Konstruktor als Dienst bereitstellen.

Eine Provider-Methode ist eine public static no-arg-Methode namens provider, bei der der Rückgabetyp der Diensttyp ist.Sie muss eine Dienstinstanz des richtigen Typs (oder eines Subtyps) zurückgeben. Wie der Dienst in dieser Methode instanziiert wird, liegt ganz im Ermessen der provider Implementierung. Möglicherweise wird ein Singleton zwischengespeichert und zurückgegeben, oder es wird einfach bei jedem Aufruf eine neue Dienstinstanz instanziiert.

Bei der Verwendung des Provider-Methoden-Ansatzes bezieht sich die Klausel provides .. with auf die Klasse, die die Provider-Methode nach with enthält.Dies kann sehr wohl die Klasse der Dienstimplementierung selbst sein, aber auch eine andere Klasse. Eine Klasse, die nach with erscheint, muss entweder eine Provider-Methode oder einen öffentlichen No-Arg-Konstruktor haben. Wenn es keine statische Provider-Methode gibt, wird davon ausgegangen, dass die Klasse die Dienstimplementierung selbst ist und einen öffentlichen No-Arg-Konstruktor haben muss. Der Compiler beschwert sich, wenn dies nicht der Fall ist.

Schauen wir uns ein Beispiel für eine Provider-Methode an(Beispiel 4-4). Wir verwenden dafür eine andere Analyzer -Implementierung, nur um die Verwendung einer Provider-Methode zu verdeutlichen.

Beispiel 4-4. ExampleProviderMethod.java (➥ chapter4/providers/provider.method.example)
package javamodularity.providers.method;

import java.util.List;
import javamodularity.easytext.analysis.api.Analyzer;

public class ExampleProviderMethod implements Analyzer {

  private String name;

  ExampleProviderMethod(String name) {
    this.name = name;
  }

  @Override
  public String getName() {
    return name;
  }

  @Override
  public double analyze(List<List<String>> sentences) {
     return 0;
  }

  public static ExampleProviderMethod provider() {
    return new ExampleProviderMethod("Analyzer created by static method");
  }
}

Die Analyzer -Implementierung ist ziemlich nutzlos, aber sie zeigt die Verwendung einer provider -Methode. Die module-info.java für dieses Beispiel wäre genau so, wie wir sie bisher gesehen haben; das Java-Modulsystem findet den richtigen Weg, um die Klasse zu instanziieren. In diesem Beispiel ist die provider -Methode Teil der Implementierungsklasse. Alternativ können wir die provider -Methode in einer anderen Klasse unterbringen, die dann als Fabrik für die Dienstimplementierung dient. Beispiel 4-5 zeigt diesen Ansatz.

Beispiel 4-5. ExampleProviderFactory.java (➥ chapter4/providers/provider.factory.example)
package javamodularity.providers.factory;

public class ExampleProviderFactory {
  public static ExampleProvider provider() {
    return new ExampleProvider("Analyzer created by factory");
  }
}

Jetzt müssen wir module-info.java ändern, um diese Änderung zu berücksichtigen. provides .. with muss jetzt auf die Klasse zeigen, die die statische Provider-Methode enthält, wie in Beispiel 4-6 gezeigt.

Beispiel 4-6. module-info.java (➥ chapter4/providers/provider.factory.example)
module provider.factory.example {
    requires easytext.analysis.api;

    provides javamodularity.easytext.analysis.api.Analyzer
        with javamodularity.providers.factory.ExampleProviderFactory;
}
Tipp

ServiceLoader kann einen Dienst nur instanziieren, wenn die Anbieterklasse öffentlich ist. Nur die Anbieterklasse selbst muss öffentlich sein; unser zweites Beispiel zeigt, dass die Implementierung paketprivat sein kann, solange die Anbieterklasse öffentlich ist.

Beachte, dass in allen Fällen der exponierte Diensttyp Analyzer unverändert bleibt. Aus Sicht des Konsumenten macht es keinen Unterschied, wie der Dienst instanziiert wird. Eine statische Provider-Methode bietet mehr Flexibilität auf der Provider-Seite. In vielen Fällen reicht ein öffentlicher No-Arg-Konstruktor der Dienstimplementierungsklasse aus.

Dienste im Modulsystem bieten keinen Mechanismus zum Herunterfahren oder zur Abmeldung von Diensten.Der Tod eines Dienstes erfolgt implizit durch die Speicherbereinigung. Die Speicherbereinigung verhält sich bei Dienstinstanzen genauso wie bei allen anderen Objekten in Java. Sobald es keine festen Verweise auf das Objekt mehr gibt, kann es in den Müll geworfen werden.

Factory Pattern Revisited

Verbrauchermodule können Dienste über die ServiceLoader API beziehen. Du kannst ein nützliches Muster verwenden, um die Verwendung dieser API in Verbrauchern zu vermeiden, wenn du das möchtest. Stattdessen kannst du Verbrauchern eine API anbieten, die dem Factory-Beispiel am Anfang dieses Kapitels ähnelt. Sie basiert auf der Möglichkeit, ab Java 8 statische Methoden in Schnittstellen zu haben.

Der Diensttyp selbst wird um eine statische Methode (Factory-Methode) erweitert, die den ServiceLoader Lookup durchführt, wie in Beispiel 4-7 gezeigt.

Beispiel 4-7. Eine Factory-Methode für die Service-Schnittstelle bereitstellen (➥ chapter4/easytext-services-factory)
public interface Analyzer {

   String getName();

   double analyze(List<List<String>> text);

   static Iterable<Analyzer> getAnalyzers() {
     return ServiceLoader.load(Analyzer.class); 1
   }

}
1

Die Suche erfolgt jetzt innerhalb des Diensttyps selbst.

Da die Suche nach ServiceLoader in Analyzer im API-Modul durchgeführt wird, muss der Moduldeskriptor die Einschränkung uses ausdrücken:

module easytext.analysis.api {
   exports javamodularity.easytext.analysis.api;

   uses javamodularity.easytext.analysis.api.Analyzer;
}

Jetzt exportiert das API-Modul sowohl die Schnittstelle als auch die Implementierungen der Schnittstelle. Analyzer Verbrauchermodule, die die Implementierungen von Analyzer erhalten möchten, müssen nicht mehr ServiceLoader verwenden (obwohl sie das natürlich immer noch können). Stattdessen muss ein Verbrauchermodul nur das API-Modul anfordern und Analyzer::getAnalyzers aufrufen. Aus der Sicht des Verbrauchers besteht keine Notwendigkeit mehr für eine uses -Beschränkung oder die ServiceLoader -API.

Durch diesen Mechanismus kannst du die Leistung von Diensten unauffällig nutzen. Die Nutzer einer API müssen nichts von Diensten oder ServiceLoader wissen, erhalten aber trotzdem die Vorteile der Entkopplung und Erweiterbarkeit.

Standard-Dienstimplementierungen

Bisher sind wir von der Annahme ausgegangen, dass es ein API-Modul gibt und mehrere verschiedene Anbieter-Module, die diese API implementieren.Das ist nicht unvernünftig, aber bei weitem nicht die einzige Möglichkeit, die Dinge einzurichten. Es ist durchaus möglich, eine Implementierung in dasselbe Modul zu packen, das den Diensttyp exportiert. Wenn ein Diensttyp eine offensichtliche Standardimplementierung hat, warum sollte man sie nicht direkt aus demselben Modul anbieten?

Du siehst dieses Muster häufig in der Art und Weise, wie das JDK selbst Dienste verwendet. Obwohl es möglich ist, eigene Implementierungen für javax.sound.sampled.spi.AudioFileWriter oder javax.print.PrintServiceLookup bereitzustellen, sind die Standardimplementierungen, die vom Modul java.desktop bereitgestellt werden, meistens ausreichend. Diese Diensttypen werden von java.desktop exportiert und gleichzeitig werden Standardimplementierungen bereitgestellt.

java.desktop selbst hat sogar uses Einschränkungen für diese Diensttypen. Das zeigt, wie ein Modul gleichzeitig die Rolle des API-Besitzers, des Dienstanbieters und des Verbrauchers spielen kann.

Die Bündelung einer Standardimplementierung des Dienstes mit dem Diensttyp garantiert, dass mindestens eine Implementierung immer verfügbar ist. In diesem Fall ist kein defensiver Code auf Seiten des Verbrauchers erforderlich. Einige Dienstabhängigkeiten sollen optional sein. Eine Standardimplementierung im selben Modul wie der Diensttyp schließt dieses Szenario aus. In diesem Fall ist ein separates API-Modul erforderlich. In "Implementierung von optionalen Abhängigkeiten mit Diensten" wird dieses Muster genauer untersucht.

Auswahl der Service-Implementierung

Wenn es mehrere Anbieter gibt, willst du nicht unbedingt alle nutzen. Manchmal willst du eine Implementierung nach bestimmten Merkmalen filtern und auswählen.

Hinweis

Es ist immer der Verbraucher, der auf der Grundlage der Eigenschaften der Anbieter entscheidet, welcher Dienst verwendet wird. Da die Anbieter einander nicht kennen sollten, gibt es keine Möglichkeit, eine bestimmte Implementierung aus der Sicht eines Anbieters zu bevorzugen. Was würde passieren, wenn sich zum Beispiel zwei Anbieter als Standard- oder beste Implementierung bezeichnen? Die Logik zur Auswahl des/der richtigen Dienstes/Dienste ist anwendungsabhängig und gehört zum Verbraucher.

Du hast gesehen, dass die ServiceLoader API selbst ziemlich begrenzt ist. Bis jetzt haben wir nur über alle existierenden Service-Implementierungen iteriert. Was ist, wenn wir mehrere Anbieter haben, aber nur an der "besten" Implementierung interessiert sind? Das Java-Modulsystem kann unmöglich wissen, was die beste Implementierung für deine Bedürfnisse ist. Jede Domäne hat in dieser Hinsicht ihre eigenen Anforderungen. Daher liegt es an dir, deinen Servicetyp mit Methoden auszustatten, um die Fähigkeiten eines Service zu ermitteln und Entscheidungen auf der Grundlage dieser Methoden zu treffen. Das muss nicht kompliziert sein und läuft in der Regel darauf hinaus, selbstbeschreibende Methoden zu einer Service-Schnittstelle hinzuzufügen.

Die Schnittstelle des Dienstes Analyzer bietet zum Beispiel eine Methode getName.ServiceLoader kennt diese Methode nicht und kümmert sich auch nicht darum, aber wir können sie in den Verbrauchermodulen verwenden, um eine Implementierung zu identifizieren. Neben der Auswahl eines Algorithmus über den Namen kannst du auch verschiedene Merkmale beschreiben, zum Beispiel mit den Methoden getAccuracy oder getCost. Auf diese Weise kann ein Verbraucher des Dienstes Analyzer eine gut informierte Wahl zwischen Implementierungen treffen. Es ist keine explizite Unterstützung durch die API von ServiceLoader notwendig: Es läuft alles darauf hinaus, selbstbeschreibende Schnittstellen zu entwerfen.

Servicetyp-Prüfung und Lazy Instantiation

In manchen Szenarien reicht der zuvor beschriebene Mechanismus immer noch nicht aus.Was ist, wenn es keine Methode auf der Dienstschnittstelle gibt, um die richtige Implementierung zu unterscheiden? Oder die Instanziierung der Dienste ist teuer? Wir würden die Kosten für die Initialisierung aller Dienstimplementierungen aufbringen, nur um die richtige zu finden, indem wir eine ServiceLoader Iteration verwenden. In den meisten Szenarien ist das kein Problem, aber für problematische Fälle gibt es eine Lösung.

Mit Java 9 wurde ServiceLoader erweitert, um die Überprüfung des Typs der Dienstimplementierung vor der Instanzierung zu unterstützen.Neben der Iteration über alle bereitgestellten Instanzen, wie wir es bisher getan haben, ist es auch möglich, einen Strom von ServiceLoader.Provider Beschreibungen zu überprüfen. Die Klasse ServiceLoader.Provider ermöglicht es, einen Dienstanbieter zu überprüfen, bevor eine Instanz angefordert wird. Die Methode stream auf ServiceLoader gibt einen Strom von ServiceLoader.Provider Objekten zurück, die überprüft werden können.

Schauen wir uns wieder ein Beispiel an, das auf EasyText basiert.

Zunächst führen wir in Beispiel 4-8 eine eigene Annotation ein, die zur Auswahl der richtigen Service-Implementierung verwendet werden kann. Eine solche Annotation kann Teil des API-Moduls sein, das von Anbietern und Nachfragern gemeinsam genutzt wird. Die Beispiel-Annotation beschreibt, ob eine Analyzer schnell ist.

Beispiel 4-8. Definiere eine Annotation, um die Dienstimplementierungsklasse zu annotieren (➥ chapter4/easytext-filtering)
package javamodularity.easytext.analysis.api;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

@Retention(RetentionPolicy.RUNTIME)
public @interface Fast {

  public boolean value() default true;

}

Wir können diese Annotation nun verwenden, um Metadaten zu einer Dienstimplementierung hinzuzufügen. Hier fügen wir sie zu einem Beispiel Analyzer hinzu:

@Fast
public class ReallyFastAnalyzer implements Analyzer {
  // Implementation of the analyzer
}

Jetzt brauchen wir nur noch einen Code, um die Analyzers zu filtern:

public class Main {
  public static void main(String args[]) {
    ServiceLoader<Analyzer> analyzers =
      ServiceLoader.load(Analyzer.class);

    analyzers.stream()
      .filter(provider -> isFast(provider.type()))
      .map(ServiceLoader.Provider::get)
      .forEach(analyzer -> System.out.println(analyzer.getName()));
  }

  private static boolean isFast(Class<?> clazz) {
    return clazz.isAnnotationPresent(Fast.class)
      && clazz.getAnnotation(Fast.class).value() == true;

  }
}

Über die Methode type auf Provider erhalten wir Zugriff auf die java.lang.Class Repräsentation der Dienstimplementierung, die wir zur Filterung an die Methode isFast übergeben.

Die Methode isFast prüft das Vorhandensein unserer Annotation @Fast und prüft den Wert explizit für true (das ist der Standardwert).Analyzer Implementierungen, die nicht als schnell annotiert sind, werden ignoriert, aber Dienste, die mit @Fast oder @Fast(true) annotiert sind, werden instanziiert und aufgerufen. Wenn du die filter aus der Stream-Pipeline entfernst, werden alle Analyzers wahllos aufgerufen.

Die Beispiele in diesem Kapitel zeigen, dass die ServiceLoader API zwar grundlegend ist, der Dienstemechanismus aber sehr leistungsfähig ist. Dienste sind ein wichtiges Konstrukt im Java-Modulsystem, wenn es um die Modularisierung von Code geht.

Hinweis

Die Verwendung von Services als Mittel zur Verbesserung der Entkopplung ist nicht neu. OSGi bietet zum Beispiel auch ein servicebasiertes Programmiermodell. Um wirklich modularen Code in OSGi zu erstellen, musst du Services verwenden. Wir bauen also auf einem bewährten Konzept auf.

Modulauflösung mit Service Binding

Erinnerst du dich daran, dass du in "Modulauflösung und der Modulpfad" gelernt hast, dass Module auf der Grundlage der requires Klauseln in den Moduldeskriptoren aufgelöst werden?Indem du rekursiv allen requires Relationen ausgehend von einem Root-Modul folgst, wird die Menge der aufgelösten Module aus den Modulen auf dem Modulpfad gebildet. Während dieses Prozesses werden fehlende Module erkannt, was den Vorteil einer zuverlässigen Konfiguration mit sich bringt.Die Anwendung wird nicht gestartet, wenn ein erforderliches Modul fehlt.

Die Klauseln provides und uses fügen dem Auflösungsprozess eine weitere Dimension hinzu. Während die Klauseln requires die Beziehungen zwischen den Modulen zur Kompilierzeit festlegen, erfolgt die Bindung der Dienste zur Laufzeit. Da sowohl die Anbieter- als auch die Nachfragemodule ihre Absichten in den Moduldeskriptoren deklarieren, können diese Informationen auch während des Modulauflösungsprozesses genutzt werden.

Theoretisch kann eine Anwendung starten, ohne dass einer ihrer Dienste zur Laufzeit gebunden ist. Der Aufruf von ServiceLoader::load führt zu keiner Instanz.Das ist kaum sinnvoll, deshalb sucht das Modulsystem beim Start zusätzlich zu den benötigten Modulen auch die Module der Dienstanbieter auf dem Modulpfad.

Wenn ein Modul mit einer uses Klausel aufgelöst wird, sucht das Modulsystem alle Anbietermodule für den gegebenen Diensttyp auf dem Modulpfad und fügt sie dem Auflösungsprozess hinzu. Diese Anbietermodule und ihre Abhängigkeiten werden Teil des Modulgraphen zur Laufzeit.

Die Auswirkungen dieser Erweiterung der Modulauflösung werden anhand eines Beispiels deutlicher. In Abbildung 4-3 sehen wir uns unser EasyText-Beispiel noch einmal aus der Perspektive der Modulauflösung an.

Service binding influences module resolution.
Abbildung 4-3. Dienstbindung beeinflusst Modulauflösung

Wir gehen davon aus, dass es fünf Module auf dem Modulpfad gibt: cli (das Stammmodul), api, kincaid, coleman und ein imaginäres Modul syllablecounter. Die Modulauflösung beginnt mit cli. Es hat eine "requires"-Beziehung zu api, also wird dieses Modul der Menge der aufgelösten Module hinzugefügt. So weit, nichts Neues.

cli hat jedoch auch eine uses Klausel für Analyzer. Es gibt zwei Provider-Module auf dem Modulpfad, die Implementierungen dieser Schnittstelle bereitstellen. Daher werden die Provider-Module kincaid und coleman zur Menge der aufgelösten Module hinzugefügt. Die Modulauflösung endet für cli, da es keine weiteren requires oder uses Klauseln hat.

Bei kincaid gibt es nichts mehr zu den aufgelösten Modulen hinzuzufügen. Das api Modul, das es benötigt, wurde bereits aufgelöst. Bei coleman sind die Dinge interessanter. Die Dienstbindung hat dazu geführt, dass coleman aufgelöst wurde. In diesem Beispiel benötigt das coleman Modul ein weiteres Modul: syllablecounter Daher wird syllablecounter ebenfalls aufgelöst und beide Module werden dem Laufzeitmoduldiagramm hinzugefügt.

Hätte syllablecounter selbst requires (oder sogar uses!) Klauseln, würden diese ebenfalls der Modulauflösung unterliegen. Umgekehrt schlägt die Auflösung fehl, wenn syllablecounter nicht im Modulpfad gefunden wird, und die Anwendung startet nicht. Obwohl cli, das Verbrauchermodul, kein statisches Wissen über das Anbietermodul coleman hat, wird es dennoch mit all seinen Abhängigkeiten über die Dienstbindung aufgelöst.

Ein Verbraucher kann nicht angeben, dass er mindestens eine Implementierung benötigt. Wenn keine Module des Dienstanbieters gefunden werden, startet die Anwendung genauso. Code, der ServiceLoader verwendet, muss diese Möglichkeit berücksichtigen. Du hast bereits gesehen, dass viele JDK-Diensttypen Standardimplementierungen haben. Wenn es eine Standardimplementierung in dem Modul gibt, das den Diensttyp bereitstellt, ist garantiert, dass immer mindestens eine Dienstimplementierung verfügbar ist.

Im Beispiel ist die Modulauflösung auch dann erfolgreich, wenn coleman nicht im Modulpfad liegt. Zur Laufzeit findet der Aufruf von ServiceLoader::load in diesem Fall nur die Implementierung von kincaid. Wenn jedoch coleman im Modulpfad liegt, syllablecounter aber nicht, wird die Anwendung nicht gestartet, weil die Modulauflösung fehlschlägt. Dieses Problem stillschweigend zu ignorieren, wäre für das Modulsystem möglich, widerspricht aber dem Mantra einer zuverlässigen Konfiguration auf der Grundlage von Moduldeskriptoren.

Dienstleistungen und Verlinkung

In "Module verlinken" hast du gelernt, wie du mit jlink eigene Laufzeit-Images erstellen kannst. Wir können auch ein Image für die EasyText-Implementierung mit Diensten erstellen. Basierend auf dem, was du im vorherigen Kapitel gelernt hast, können wir den folgenden jlink-Befehl verwenden:

$ jlink --module-path mods/:$JAVA_HOME/jmods --add-modules easytext.cli \
        --output image

jlink erstellt ein Verzeichnis image, das ein Verzeichnis bin enthält. Mit dem folgenden Befehl können wir die im Image enthaltenen Module überprüfen:

$ image/bin/java --list-modules

java.base@9
easytext.analysis.api
easytext.cli

Die Module api und cli sind wie erwartet Teil des Bildes, aber was ist mit den beiden Analyseanbieter-Modulen? Wenn wir die Anwendung so starten, startet sie korrekt, weil die Serviceanbieter optional sind. Aber ohne Analyseanbieter ist sie ziemlich nutzlos.

jlink führt die Modulauflösung ausgehend vom Root-Modul easytext.cli durch. Alle aufgelösten Module werden in das resultierende Abbild aufgenommen. Der Auflösungsprozess unterscheidet sich jedoch von der Auflösung, die das Modulsystem beim Start vornimmt, die wir im vorherigen Abschnitt besprochen haben. Während der Modulauflösung führt jlink keine Dienstbindung durch. Das bedeutet, dass Dienstanbieter nicht automatisch auf der Grundlage von uses Klauseln in das Abbild aufgenommen werden.

Obwohl dies für Benutzer, die sich dessen nicht bewusst sind, sicherlich zu unerwarteten Ergebnissen führen wird, ist dies eine bewusste Entscheidung. Dienste werden oft für die Erweiterbarkeit verwendet. Die EasyText-Anwendung ist ein gutes Beispiel dafür; neue Arten von Algorithmen können hinzugefügt werden, indem neue Service-Provider-Module zum Modulpfad hinzugefügt werden. Dienste, die auf diese Weise genutzt werden, sind nicht unbedingt erforderlich, um die Anwendung auszuführen. Welche Dienstanbieter du kombinieren möchtest, ist eine anwendungsabhängige Angelegenheit. Zur Build-Zeit besteht keine Abhängigkeit von den Dienstanbietern, und zur Link-Zeit ist es wirklich Sache des Erstellers des gewünschten Bildes, zu entscheiden, welche Dienstanbieter verfügbar sein sollen.

Hinweis

Ein handfesterer Grund, in jlink keine automatische Dienstbindung vorzunehmen, ist, dass java.base eine enorme Anzahl von uses Klauseln hat. Die Anbieter für all diese Diensttypen befinden sich in verschiedenen anderen Plattformmodulen. Die standardmäßige Bindung all dieser Dienste würde zu einer viel größeren Mindestgröße des Images führen. Ohne automatische Dienstbindung in jlink kannst du ein Image erstellen, das nur java.base und Anwendungsmodule enthält, wie in unserem Beispiel. Im Allgemeinen kann die automatische Dienstbindung zu unerwartet großen Modulgraphen führen.

Versuchen wir, ein Laufzeit-Image für die EasyText-Anwendung zu erstellen, das so konfiguriert ist, dass es von der Kommandozeile aus ausgeführt werden kann. Um Analyzer einzubeziehen, verwenden wir das Argument --add-modules beim Ausführen von jlink für jedes Provider-Modul, das wir hinzufügen wollen:

$ jlink --module-path mods/:$JAVA_HOME/jmods \
        --add-modules easytext.cli           \
        --add-modules easytext.analysis.coleman \
        --add-modules easytext.analysis.kincaid \
        --output image
$ image/bin/java --list-modules

java.base@9
easytext.analysis.api
easytext.analysis.coleman
easytext.analysis.kincaid
easytext.cli

Das sieht besser aus, aber wir werden beim Starten der Anwendung immer noch ein Problem finden:

$ image/bin/java -m easytext.cli input.txt

Die Anwendung wird mit einer Ausnahme beendet: java.lang.IllegalStateException: SyllableCounter not found. Das Modul kincaid verwendet einen anderen Dienst des Typs SyllableCounter. Dies ist ein Fall, in dem ein Dienstanbieter einen anderen Dienst verwendet, um seine Funktionalität zu implementieren. Wir wissen bereits, dass jlink Dienstanbieter nicht automatisch einbindet, also wurde auch das Modul, das das Beispiel SyllableCounter enthält, nicht eingebunden. Wir verwenden --add-modules noch einmal, um schließlich ein voll funktionsfähiges Bild zu erhalten:

$ jlink --module-path mods/:$JAVA_HOME/jmods \
        --add-modules easytext.cli           \
        --add-modules easytext.analysis.coleman \
        --add-modules easytext.analysis.kincaid \
        --add-modules easytext.analysis.naivesyllablecounter \
        --output image

Die Tatsache, dass jlink Service Provider nicht standardmäßig einbindet, erfordert etwas zusätzliche Arbeit bei der Verknüpfung, vor allem, wenn Dienste andere Dienste transitiv nutzen. Im Gegenzug bietet es viel Flexibilität bei der Feinabstimmung des Inhalts eines Runtime-Images. Verschiedene Images können verschiedene Arten von Nutzern bedienen, indem einfach die eingebundenen Service Provider neu konfiguriert werden. In "Die richtigen Service Provider-Module finden" werden wir sehen, dass jlink zusätzliche Optionen bietet, um relevante Service Provider-Module zu finden und zu verknüpfen.

In den vorangegangenen Kapiteln wurden die Grundlagen des Java-Modulsystems behandelt. Bei der Modularität geht es viel um Design und Architektur, und hier wird es wirklich interessant. Im nächsten Kapitel werden wir uns mit Mustern beschäftigen, die die Wartbarkeit, Flexibilität und Wiederverwendbarkeit von Systemen verbessern, die mit Modulen aufgebaut wurden.

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.