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.
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
)
;
for
(
Analyzer
analyzer:
analyzers
)
{
System
.
out
.
println
(
analyzer
.
getName
(
)
+
": "
+
analyzer
.
analyze
(
sentences
)
)
;
}
Initialisiere eine
ServiceLoader
für Dienste des TypsAnalyzer
.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.
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
)
{
System
.
out
.
println
(
analyzer
.
hashCode
(
)
)
;
}
Iterable
<
Analyzer
>
second
=
ServiceLoader
.
load
(
Analyzer
.
class
)
;
System
.
out
.
println
(
"Using the second analyzers"
)
;
for
(
Analyzer
analyzer:
second
)
{
System
.
out
.
println
(
analyzer
.
hashCode
(
)
)
;
}
System
.
out
.
println
(
"Using the first analyzers again, hashCode is the same"
)
;
for
(
Analyzer
analyzer:
first
)
{
System
.
out
.
println
(
analyzer
.
hashCode
(
)
)
;
}
first
.
reload
(
)
;
System
.
out
.
println
(
"Reloading the first analyzers, hashCode is different"
)
;
for
(
Analyzer
analyzer:
first
)
{
System
.
out
.
println
(
analyzer
.
hashCode
(
)
)
;
}
Indem du über
first
iterierst, instanziiertServiceLoader
die Implementierungen vonAnalyzer
.Ein neues
ServiceLoader
,second
, instanziiert seine eigenen, neuenAnalyzer
Implementierungen. Sie liefert andere Instanzen alsfirst
.Die ursprünglich instanziierten Dienste werden bei einer erneuten Iteration von
first
zurückgegeben, da sie von der erstenServiceLoader
Instanz zwischengespeichert werden.Nach
reload
liefert das Originalfirst
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 ServiceLoader
s 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
)
;
}
}
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 Analyzer
s 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 Analyzer
s 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.
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.
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.