Microservice-Entwicklung mit Java EE – eine Einführung in Eclipse MicroProfile

Für die Entwicklung von Microservices gilt Java EE in der Community häufig als zu schwergewichtig. Und das, obwohl mit CDI, JAX-RS, JSON-P und JPA eigentlich seit Jahren alle benötigten Werkzeuge für die Implementierung von RESTful-Webservices zur Verfügung stehen.

In Pocket speichern vorlesen Druckansicht 51 Kommentare lesen
Microserviceentwicklung mit Java EE – Eine Einführung in Eclipse MicroProfile
Lesezeit: 18 Min.
Von
  • Thorben Janssen
Inhaltsverzeichnis

Unter Java-Entwicklern war Java EE in der Vergangenheit häufig kein Thema, wenn es um Microservices ging. Denn Java EE hat, aus meiner Sicht meist zu Unrecht, den Ruf zu schwergewichtig und damit für Microservices ungeeignet zu sein. Die Herausforderung haben viele Anbieter von Applikationsservern bereits vor Jahren angenommen und ihre Server modularer und schlanker gemacht. Den Erfolg dieser Bemühungen zeigte Antonio Goncalves bereits 2016 in einem Blogartikel, in dem er unter anderen die Startzeiten und den Speicherbedarf verschiedener Java-EE-Applikationsserver ermittelte. In seinem Benchmark starten moderne Applikationsserver innerhalb von etwa zwei Sekunden und benötigen zur Laufzeit lediglich 30 bis 40 MByte RAM. Moderne Server lassen sich somit auch für kleinere, horizontal skalierte Anwendungen verwenden, wie sie in Microservice-Architekturen häufig zu finden sind.

Damit bietet Java EE die grundlegenden Werkzeuge für die Entwicklung einzelner Microservices. Um ein ganzes System skalierbarer Services zu betreiben, gilt es allerdings, zusätzliche Themen, wie Fault Tolerance, Monitoring und Metriken, sowie die Konfiguration von Services zu adressieren. Für alle diese Fragestellung gibt es außerhalb von Java EE bereits erprobte Frameworks, die sich in die Anwendung integrieren lassen.

Der Anspruch vieler Anbieter von Applikationsservern geht allerdings weiter. Sie möchten eine Plattform für die Entwicklung von Microservices bieten, die die Frameworks bereits mit ausgewählten Java-EE-Spezifikationen integriert. Mit dieser Zielsetzung entstand Eclipse MicroProfile, das aktuell in der Version 2.0 vorliegt.

Dabei verfolgt das Projekt einen ähnlichen Ansatz wie Java EE und stellt lediglich die Spezifikationen, APIs und TCKs bereit. Darauf aufbauend können unabhängige Entwicklungsteams ihre eigenen Implementierungen und Server bereitstellen. Darüber hinaus können sie zusätzliche Funktionen entwickeln, die zu einem späteren Zeitpunkt Bestandteil des MicroProfile oder einer darin enthaltenen Spezifikation werden können. Somit fungieren die Implementierungen der MicroProfile-Spezifikation sowohl als Produkt und Plattform für die Anwendungsentwicklung, als auch als Innovationsmotor für die Weiterentwicklung der Spezifikationen.

Die Auswahl an MicroProfile-Implementierung ist vielseitig und umfasst neben Communityprojekten wie Hammok auch Varianten bekannter Anbieter von Java-EE-Applikationsservern wie Red Hats Wildfly Swarm, Open Liberty und WebSphere Liberty von IBM, Tomitribes TomEE sowie Payara Micro.

Vergleichbar mit den bekannten Applikationsservern, die bestimmte Versionen des Java-EE-Standards implementieren und es dadurch ermöglichen, dieselbe Applikation auf Servern unterschiedlicher Hersteller zu betreiben, implementieren die zuvor genannten Projekte die MicroProfile-Spezifikation. Diese besteht aus verschiedenen Teilspezifikationen, die entweder Bestandteil von Java EE sind oder durch eine der Arbeitsgruppen des MicroProfile-Projekts entwickelt werden.

Eclipse MicroProfile 2.0 besteht aus zwölf miteinander integrierten Spezifikationen. Vier davon sind vielen Entwicklern bereits aus Java EE 8 bekannt: CDI 2.0, JSON-P 1.1, JAX-RS 2.1 und JSON-B 1.0. Somit basiert das zentrale Entwicklungsmodell von Eclipse MicroProfile auf erprobten Java-EE-Spezifikationen, mit denen viele Entwicklungsteams bereits vertraut sind. Das vereinfacht nicht nur den Umstieg, sondern ermöglicht es auch bereits bestehende Komponenten und Bibliotheken weiterzuverwenden. Andere Java-EE-Spezifikationen sind nicht Bestandteil von Eclipse MicroProfile 2.0, stehen je nach MicroProfile-Implementierung aber bereits zur Verfügung oder lassen sich bei Bedarf hinzufügen.

Die anderen acht Spezifikationen wurden im Rahmen des Eclipse-MicroProfile-Projekts erstellt und standardisieren häufig Konzepte, die bereits in populären Bibliotheken umgesetzt wurden. Sie behandeln typische Fragestellungen, die beim Betrieb eines verteilten, horizontal skalierten Microservice-Systems auftreten.

Die Konfiguration von Anwendungen hat Java EE bisher nicht behandelt. Daher stellt Eclipse MicroProfile eine eigene Spezifikation bereit, die eine Standardisierung verschiedener Konfigurationslösungen und somit deren implementierungsunabhängige Verwendung ermöglicht.

Die meisten Enterprise-Anwendungen verwenden Konfigurationsdaten, mit denen sie sich an die jeweilige Ausführungsumgebung anpassen lassen. Dazu zählen beispielsweise die Verbindungsdaten zu externen Systemen, umgebungsspezifische Pfadangaben und Timeouts.

Auf Basis der MicroProfile-Config-Spezifikation lassen sich diese Informationen aus standardisierten Quellen wie lokalen Systemeigenschaften und Dateien, aber auch aus eigenen Quellen wie einer Datenbank, laden und zu einer Anwendungskonfiguration zusammenführen.

Innerhalb der Anwendung besteht die Möglichkeit auf die Konfigurationsparameter programmatisch zuzugreifen oder einzelne Konfigurationsparameter zu injecten. Im folgenden Beispiel wird der Wert des Konfigurationsparameter my.config.someService.url injected. Falls dieser von keiner Konfigurationsquelle bereitgestellt wird, löst es während des Deployments der Anwendung eine DeploymentException aus.

@ApplicationScoped
public class InjectConfigurationParameter {

@Inject
@ConfigProperty(name="my.config.someService.url")
private String someUrl;


}

Die Überwachung eines Systems von Microservices ist deutlich komplexer als die Überwachung monolithischer Anwendungen. Anstelle weniger Instanzen einer einzigen Anwendung gilt es nun mehrere Instanzen von unterschiedlichen, verteilten Services zu überwachen. Health Checks stellen dabei die einfachste Möglichkeit der Überwachung dar. Sie liefern lediglich die Information, ob eine Serviceinstanz verfügbar ist. Dieser Mechanismus wird in vielen Cloud-Umgebungen eingesetzt, um ausgefallene Microservice-Instanzen automatisiert neu zu starten.

Die Health-Check-Spezifikation definiert dafür den /health-REST-Endpoint, den jede MicroProfile-Implementierung bereitstellen muss. Wenn das System verfügbar ist, antwortet dieser Endpoint mit dem HTTP Code 200 und dem Status UP. Zusätzlich lassen sich weitere Informationen über einzelne Komponenten des Service bereitstellen und komplexere Prüfungen implementieren.

{ 
"outcome" : "UP"
"checks": []
}

Werden detailliertere Informationen über den Zustand eines Microservice benötigt, kann der zusätzliche Metriken exportieren. Diese lassen sich anschließend von Monitoring-Anwendungen überwachen und darstellen. Die MicroProfile-Metrics-Spezifikation definiert dafür eine einheitliche API, über die alle MicroProfile-Implementierungen ihre Metriken bereitstellen.

Die Spezifikation unterteilt die Metriken in drei Kategorien:

  • • erforderliche Metriken,
  • • anbieterspezifische Metriken und
  • • anwendungsspezifische Metriken.

Die erforderlichen Metriken stehen unter /metrics/base bereit und liefern Statistiken über die JVM und das Betriebssystem wie die Größe des Heap, die Anzahl der aktuell verwendeten Threads oder die bisher für die Garbage Collection aufgewendete Zeit.

Alle Anbieter einer MicroProfile-Implementierung können die Metriken um eigene Statistiken erweitern, die dann unter /metrics/vendor bereitstehen.

Die Spezifikation definiert außerdem eine API, mit der Anwendungen eigene Metriken bereitstellen können, die sich unter /metrics/application abrufen lassen. Um eigene Metriken zu implementieren, muss lediglich ein Typ, eine Methode oder ein Konstruktor mit @Counted, [i]@Gauge, @Metered, @Timed oder @Metric annotiert werden. Die MicroProfile-Metrics-Implementierung zählt dann die Anzahl der Aufrufe, stellt den Rückgabewert einer Methode bereit, bestimmt die Frequenz der Aufrufe, misst die Ausführungszeit der Aufrufe oder registriert eine durch den Entwickler verwaltete Metrik.

Der folgende Codeausschnitt zeigt ein Beispiel für eine Metrik, die die Ausführungszeit der buildName-Methode misst.

@Timed(absolute = true,
name = "example.buildName",
displayName = "Time to build the name",
description = "Time of buildName in ns",
unit = MetricUnits.NANOSECONDS)
public String buildName() {
// do something
return getNameFromRegistry();
}

Auf diese Art lassen sich detaillierte Informationen über den Zustand einer Anwendung sammeln und für die Überwachung und Steuerung des Systems bereitstellen.

Fehlertoleranz ist in verteilten Systemen und somit bei der Erstellung von Microservices von besonderer Bedeutung. Während monolithische Systeme entweder vollständig verfügbar oder nicht verfügbar sind, können bei einer Microservice-Architektur einzelne Services ausfallen. Dadurch erhöht sich die Anzahl der Fehlerquellen und somit die Komplexität des Gesamtsystems. Es bietet bei einer lose gekoppelten, fehlertoleranten Implementierung der Services jedoch auch den Vorteil, dass sich Ausfälle in einem Teilbereich des Systems behandeln oder isolieren lassen und somit andere Teile des Systems verfügbar bleiben.

Es gibt bereits einige Bibliotheken, beispielsweise Hystrix, mit denen sich die technischen Anforderungen einer solchen Implementierung umsetzen lassen. Die Fault-Tolerance-Spezifikation definiert darauf aufbauend eine API, mit der sich bekannte Konzepte wie Timeouts, Retry-Mechanismen, Fallback-Verhalten, Bulkheads und CircuitBreaker verwenden lassen.

Das Ziel der Spezifikation ist es, Anwendungsentwicklern die technisch erforderlichen Komponenten für eine fehlertolerante Implementierung durch den Container bereitzustellen. Dafür definiert sie verschiedene InterceptorBindings, mit denen sich die zuvor genannten Konzepte auf einzelne Methoden oder CDI Beans anwenden lassen. Um die InterceptorBindings zu verwenden, ist lediglich eine CDI Bean oder eine Methode einer CDI Bean mit einer oder mehrerer der folgenden Annotationen zu annotieren: @Retry, @Timeout, @CircuitBreaker, @Bulkhead und @Fallback.

Im folgenden Codebeispiel dienen diese InterceptorBindings dazu ein Retry- und Fallback-Verhalten für die buildName-Methode zu definieren.

package org.example;



public class MyBean {
@Retry(maxRetries = 3)
@Fallback(fallbackMethod= "defaultName")
public String buildName() {
// do something
return getNameFromRegistry();
}
private String defaultName () {
return "aDefaultName";
}
}

Die @Retry-Annotation definiert, dass, falls ein Aufruf der buildName-Methode fehlschlägt, bis zu drei zusätzliche Aufrufversuche ausgeführt werden. Falls keiner dieser Aufrufe erfolgreich ist, wird die durch die @Fallback-Annotation definierte fallbackMethod ausgeführt. In diesem Beispiel ist das die defaultName-Method, die einen vordefinierten String zurückgibt. Die Parameter und der Rückgabewert der beiden Methoden müssen dabei identisch sein, um eine FaultToleranceDefinitionException zu vermeiden.

Wenn die fehlertolerante Verwendung einer Methode oder CDI Bean benötigt wird, muss diese meist auch mit Metriken überwacht und für verschiedene Ausführungsumgebungen konfiguriert werden. Daher integriert die MicroProfile-Fault-Tolerance-Spezifikation die bereits beschriebenen Config- und Metrics-Spezifikationen.

Dadurch lassen sich alle Parameter der zuvor gezeigten InterceptorBindings durch externe Konfigurationen überschreiben, die dem folgenden Namensschema entsprechen: <classname>/<methodname>/<annotation>/<parameter>. Im vorherigen Beispiel ließe sich also der maxRetries-Parameter der @Retry-Annotation mit dem Konfigurationsparameter org.example.MyBean/buildName/Retry/maxRetries an die Anforderungen der jeweiligen Ausführungsumgebung anpassen.

Des Weiteren werden automatisch Metriken für jede mit @Retry, @Timeout, @CircuitBreaker, @Bulkhead oder @Fallback annotierte Methode und CDI Bean erfasst. Damit lassen sich beispielsweise die Anzahl der erfolgreichen und fehlgeschlagenen Aufrufe, die Anzahl der ausgeführten Retries, die Menge der eingetretenen Timeouts und die Anzahl der vom Bulkhead abgelehnten Aufrufe messen. Somit stehen automatisch viele Informationen zur Verfügung, die Aufschluss über den aktuellen Zustand des Microservice-Systems liefern.

Eine weitere Herausforderung in verteilten Systemen ist die Authentifizierung zwischen verschiedenen Services. Das gilt vor allem in Microservice-Architekturen, in denen zustandslose Services über eine REST-API aufgerufen werden. Wenn sich in so einem System Services gegenseitig aufrufen, müssen diese den aktuellen Authentifizierungszustand vom aufrufenden an den aufgerufenen Service weitergeben.

Die MicroProfile-JWT-Authentication-Spezifikation verwendet dazu das OpenID-Connect-Protokoll auf Basis von JSON Web Tokens (JWT). Die JWT-Authentication-Implementierung extrahiert und verifiziert die Tokens automatisch. Der anschließend auf Basis des Tokens erzeugt Java EE Security Context lässt sich innerhalb von JAX-RS-Ressourcen verwenden. Des Weiteren kann mittels CDI Injection auf den Token und die damit verbundenen Claims zugegriffen werden.

Mehr Informationen über die genauen Bestandteile des Tokens und die verschiedenen Zugriffsmöglichkeiten hat Lars Röwekamp in seinem Artikel MicroProfile unter der Lupe, Teil 3: JWT Security beschrieben.

Die Open-API-Spezifikation definiert einen Standard zur Dokumentation von REST-APIs. Die MicroProfile-OpenAPI-Spezifikation setzt auf diesen Standard auf und spezifiziert eine Reihe von Annotationen, mit denen sich Open-API-Dokumente erstellen lassen. Dazu wurde ein Großteil der Annotation der häufig eingesetzten Swagger-Core-Bibliothek übernommen. Viele Entwickler sind daher bereits mit den Annotationen und Konzepten vertraut – was die Verwendung von MicroProfile OpenAPI erleichtert.

Der folgende Codeausschnitt zeigt ein Beispiel, in dem ein REST Endpoint mit Hilfe verschiedener OpenAPI-Annotationen dokumentiert wird. Die @Operation-Annotation definiert eine Beschreibung der GET-Methode und die beiden @APIResponse-Annotationen beschreiben die zu erwartenden Antworten für den Fall, dass eine Order mit der angegebenen ID gefunden wurde, und für den Fall, in dem keine Order mit einer übereinstimmenden ID vorhanden ist.

@GET
@Path("/{orderId}")
@Operation(summary = "Get an order by its ID")
@APIResponse(description = "The order",
content = @Content(mediaType = "application/json",
schema = @Schema(implementation = Order.class))),
@APIResponse(responseCode = "404", description = "Order not found")
public Response getOrderById(
@Parameter(description = "The ID of the order that needs to be loaded", required = true) @PathParam("orderId") String orderId)
{...}

Auf diese Weise lassen sich alle REST-Endpoints im Quellcode beschreiben. Darauf aufbauend wird dann eine Dokumentation generiert, die den Anforderungen der Open API Spezifikation entspricht.

/order/{orderId}:
get:
summary: Get an order by its ID
operationId: getOrderById
parameters:
- name: orderId
in: path
description: 'The ID of the order that needs to be loaded '
required: true
schema:
type: string
responses:
default:
description: The order
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
404:
description: Order not found

Die bisher besprochenen MicroProfile-Spezifikationen haben gezeigt, dass das Java-EE-Programmiermodell gezielt um Funktionen erweitert wird, die die Implementierung und den Betrieb verteilter Anwendungen erleichtern. Das gilt auch für die OpenTracing-Spezifikation.

Durch die Aufteilung des Systems auf mehrere, unabhängige Services wird es im Fehlerfall deutlich schwieriger den Verlauf eines einzelnen Aufrufs und die dabei aufgetretene Situation nachzuvollziehen. Um dieses Problem zu lösen, stehen inzwischen verschiedene Tracing-Anwendungen zur Verfügung, die das OpenTracing-Protokoll implementieren. Sie ermöglichen es, den Verlauf eines Aufrufs innerhalb eines Systems zu überwachen. Dazu ist es erforderlich, dass alle Services Informationen über jeden Aufruf auf Basis des OpenTracing-Protokolls bereitstellen. Innerhalb der Tracing-Anwendung lassen sich diese Informationen sammeln und für die Auswertung bereitstellen.

Die MicroProfile-OpenTracing-Spezifikation erleichtert die Integration eines Microservice in eine auf Basis des OpenTracing-Protokolls überwachte Umgebung. Dazu definiert die Spezifikation einen Modus, der keinerlei Anpassung des Quellcodes erfordert und automatisch die benötigten Tracing-Informationen über einen Serviceaufruf bereitstellt. Zusätzlich lässt sich eine Klasse oder Methode mit der @Traced-Annotation annotieren, um die beim Tracing verwendeten Informationen anzupassen.

Viele Aufrufe in einer Microservice-Architektur werden nicht von einem einzigen Service bearbeitet. Der Service, der durch einen Client aufgerufen wird, ruft häufig weitere Services auf, die Teilaufgaben implementieren. Um den Vorgang zu vereinfachen, kommt die MicroProfile-Rest-Client-Spezifikation zum Einsatz, die aufbauend auf den JAX-RS 2.0 Client APIs eine einfache Möglichkeit bietet, aus einem Microservice einen anderen REST-Endpoint aufzurufen. Dazu werden ein Clientinterface des aufzurufenden REST-Service und ein ResponseExceptionMapper zur Behandlung auftretender Fehler benötigt.

Der aufzurufende REST-Endpoint wird durch ein Clientinterface abgebildet. Die Methoden dieses Interfaces müssen dabei denen entsprechen, die auf dem entfernten REST-Endpoint aufgerufen werden sollen.

Um beispielsweise die getOrderById-Methode des bereits zuvor gezeigten Order Endpoints aufzurufen, benötigt man das nachfolgend gezeigte Client-Interface. Wenn mehrere Methoden auf dem REST-Endpoint aufgerufen werden sollen, lässt sich das Interface um diese erweitern.

@Path("/order")
@Consumes("application/json")
@Dependent
@RegisterRestClient
@RegisterProvider(OrderResponseExceptionMapper.class)
public interface OrderService {

@GET
@Path("/{orderId}")
public Response getOrderById(@PathParam("orderId") String orderId) throws UnknownOrderException;
}

Die @RegisterRestClient-Annotation ist in diesem Beispiel von besonderer Bedeutung. Sie registriert das Interface als REST-Client, der sich in andere CDI-Beans injecten lässt. Falls sich für die beim Aufruf verwendete orderId keine Order finden lässt, liefert der Endpoint den Response Code 404 zurück. Die Behandlung des Codes erfolgt durch den OrderResponseExceptionMapper, der mit Hilfe der @RegisterProvider-Annotation registriert und im nachfolgenden Abschnitt genauer erläutert wird.

Um die im Fehlerfall erhaltenen HTTP-Codes in der Anwendung behandeln zu können, werden sie durch einen ResponseExceptionMapper auf Java Exceptions abgebildet. In diesem Beispiel wird für den HTTP-Code 404 eine UnknownOrderException erzeugt.

    @Override
public boolean handles(int statusCode, MultivaluedMap<String, Object> headers) {
return statusCode == 404;
}

@Override
public OrderException toThrowable(Response response) {
switch(response.getStatus()) {
case 404: return new UnknownOrderException();
}
return null;
}

}

Sobald das Client-Interface und die Fehlerbehandlung implementiert sind, lässt sich der REST-Client mit Hilfe der @Inject- und @RestClient-Annotationen in eine CDI Bean injecten und anschließend verwenden.

@ApplicationScoped
public class OrderManager {

@Inject
@RestClient
private OrderService orderService;


}

In den bisherigen Codebeispielen ist die Base URL des OrderService nicht definiert. Das liegt daran, dass sie auf Basis der MicroProfile-Config-Spezifikation konfiguriert werden sollte. Dazu ist ein Parameter erforderlich, der dem Namensmuster <fullyQualifiedInterfaceName>/mp-rest/url entspricht. In diesem Beispiel ließe sich die Base URL also durch den Parameter org.example.OrderService/mp-rest/url konfigurieren.

Das aktuell in Version 2.0 vorliegende Eclipse-MicroProfile-Projekt definiert eine Reihe an Spezifikationen, die die Entwicklung von Microservices erleichtern sollen. Im Mittelpunkt stehen dabei die Java-EE-Spezifikationen CDI, JAX-RS, JSON-B und JSON-P. Sie definieren das Programmiermodell und sind vielen Anwendungsentwicklern bereits vertraut. Darauf aufbauend kommen acht zusätzliche Spezifikationen hinzu, die typische Problemstellungen verteilter Systeme ansprechen, für die Java EE bisher keine Lösungen bot. Dazu zählen die Anwendungskonfiguration, die Überwachung der Services mit Hilfe von Health Checks, Metriken und Tracing, die tokenbasierte Authentifizierung zwischen verschiedenen Microservices sowie der fehlertolerantere Aufruf von REST-Endpoints.

Insgesamt bietet das MicroProfile-Projekt damit eine gute Grundlage zur Entwicklung von Microservices auf Basis des Java-EE-Programmiermodells. Die nachfolgenden Artikel dieser Reihe werfen einen detaillierteren Blick auf die verschiedenen MicroProfile-spezifischen Spezifikationen und zeigen, wie sich typische Anwendungsfälle umsetzen lassen.

Thorben Janssen
arbeitet als freiberuflicher Consultant, Trainer und Autor des Buchs "Hibernate Tips – More than 70 solutions to common Hibernate problems". Er entwickelt seit mehr als 15 Jahren Anwendungen auf Basis von Java EE und ist Mitglied der JSR 365 (Contexts and Dependency Injection for JavaTM 2.0) Expert Group.

(map)