MicroProfile unter der Lupe, Teil 1: Config API

Neuigkeiten von der Insel  –  4 Kommentare

Das Eclipse-MicroProfile-Projekt ist angetreten, eine portable Lösung für Microservices im Enterprise-Java-Umfeld zu liefern. Neue APIs sollen die Lücke zwischen dem Java-EE-Standard und den Praxisanforderungen von Microservices-Architekturen schließen.

Microservices und Java EE (a.k.a. EE4J)? Das passt nicht wirklich! So zumindest die landläufige Meinung, wenn es darum geht, Microservice-Anwendungen auf Grundlage des Java-Enterprise-Standard umzusetzen. Dabei bietet Java EE mit JAX-RS, CDI, JPA und JSON-P alles, was es braucht, um RESTful-Microservices zu implementieren. Und auch asynchrone, auf Messages oder Events basierende Services lassen sich dank JMS, WebSockets und Server-Sent Events (seit Java EE 8) mit wenigen Zeilen Code realisieren. Wo also liegt das Problem?

Microservice-Anwendungen zeichnen sich in der Regel dadurch aus, dass eine Vielzahl von Services, die jeweils in eigenen Prozessen laufen, miteinander interagieren und so ein großes Ganzes ergeben. Nicht selten werden diese Services zusätzlich in Containern verpackt und in der Cloud deployt. Unterm Strich haben wir es also mit einem hoch dynamischen, stark verteilten System zu tun.

Die eigentliche Herausforderung liegt somit weniger in der Umsetzung der (fachlichen) Logik eines einzelnen Service als vielmehr in der Realisierung des reibungslosen Zusammenspiels der Gesamtheit aller Services. Und genau hier liegt das Problem beziehungsweise die Schwachstelle von Java EE. Denn Java EE ist darauf ausgerichtet, dass die einzelnen Artefakte – in diesem Fall also die Services – innerhalb eines Application Server deployt werden, damit dieser übergreifende Dienste (Cross-Cutting Concerns) wie Configuration, Monitoring, Logging, Security et cetera übernehmen kann. Fällt der Server weg oder gibt es mehrere autarke Server-Instanzen, von denen jede einen eigenen Microservice verwaltet, fehlt die koordinierende Schaltzentrale.

Genau dieses Manko hat auch eine Reihe von Application-Server-Anbietern erkannt und 2016 die Initiative MicroProfile.io ins Leben gerufen. Die inzwischen in der Eclipse Foundation beheimatete Initiative ist angetreten, die Lücke zwischen dem Java-Enterprise-Standard und den Praxisanforderungen Microservices-basierter Architekturen zu schließen. Laut eigener Aussagen möchte man das bestehende Momentum der Java-EE-Community als Hebel nutzen und organisch um den Bedarf der Microservices-Community ergänzen. Der Plan scheint aufzugehen. In nur wenigen Monaten ist es gelungen, eine Reihe sinnvoller Microservices-relevanter APIs mit bestehenden Java-EE-7/8-APIs zu kombinieren und diese in regelmäßigen MicroProfile-Releases zu veröffentlichen. Egal ob Health Check, Metrics, Fault Tolerance, JWT Propagation, Configuration, Tracing oder Open API, MicroProfile scheint die richtigen Antworten – sprich APIs – im Gepäck zu haben.

Dieser Blogbeitrag ist der erste Teil einer kleinen Serie, die sich mit den Microservice-spezifischen APIs des MicroProfiles beschäftigt. Also den APIs, die nicht Bestandteil der Java-EE-Spezifikation sind. Den Anfang macht die Config API, die erst vor wenigen Tagen in der Version 1.2 veröffentlicht wurde.

Die Idee, Anwendungslogik und Konfiguration voneinander zu trennen, ist nicht wirklich neu. Mithilfe einer ausgelagerten Konfiguration lässt sich die Anwendung beziehungsweise der Service von außen auf die jeweilige Laufzeitumgebung anpassen. Für die lokale Testumgebung nutzt man zum Beispiel im Test-Quellcode vorgegebene Einstellungen, während die Laufzeitumgebung des Containers in einer produktiven Umgebung innerhalb eines Containers in der Cloud die notwendigen Werte für die Konfiguration setzt.

Was einfach klingt, kann in der Praxis zu unerwarteten Herausforderungen führen. So stammen die einzelnen Werte für die Konfiguration einer Anwendung beziehungsweise eines Service in der Regel aus mehreren verschiedenen Quellen, wie System Properties, Umgebungsvariablen, Dateien, Datenbanken oder proprietären Konfigurationsquellen. Auch das Format, in dem die Werte abgelegt sind, ist oftmals nicht einheitlich und entspricht normalerweise nicht dem Objektformat, in dem die Konfigurationswerte innerhalb der Anwendung beziehungsweise des Service verwendet werden sollen. Während es bei statischen Konfigurationswerten ausreicht, diese einmalig beim Start zu laden, sind dynamische Werte permanent auf ihre Aktualität zu prüfen und bei Bedarf neu einzulesen. Und natürlich sollte eine Anwendung auch dann noch sinnvoll funktionieren, wenn eine Konfiguration einmal nicht gefunden werden kann.

All diese Aspekte und noch etliche mehr berücksichtigt die MicroProfile Config API (im Folgenden Config API). Sie bietet einen vereinheitlichen Zugriff auf verschiedenste Konfigurationsquellen an. Die einzelnen Quellen lassen sich dabei mit Prioritäten versehen, sodass ein gezieltes Überschreiben von Konfigurationen möglich wird. Von Haus aus kennt die Config API drei verschiedene Quellen:

  • System Properties via System.getProperties( ) (ordinal = 400 )
  • Umgebungsvariablen via System.getenv( ) (ordinal = 300)
  • lokale Konfigurationsdatei via META-INF/microproifile-config.properties (ordinal = 100)

Standardwerte für die Konfiguration können so in einer Datei mit dem Namen microproifile-config.properties im Verzeichnis META-INF abgelegt und bei Bedarf in den jeweiligen Deployment-Umgebungen überschrieben werden. Natürlich ist es auch möglich, eigene Quellen einzubinden und mit einer individuellen Priorität zu belegen. Zum Beispiel ließe sich ein zentraler Key-Value-Store als weitere Konfigurationsquelle verwenden.

Die Config API bietet zwei unterschiedliche Wege zum Zugriff auf die im Hintergrund verwalteten Konfigurationen. Zum einen kann der Zugriff programmatisch erfolgen, zum anderen via CDI Injection.

Der programmatische Zugriff erfolgt mithilfe einer Instanz der Config-Klasse, die sich im einfachsten Fall via ConfigProvider erzeugen lässt:

// get access to the Config instance via ConfigProvider 
Config config = ConfigProvider.getConfig();

// access config properties via Config instance
String someStringValue = config.getValue("SOME_STRING_KEY", String.class);
Boolean someBooleanValue = config.getValue("SOME_BOOL_KEY", Boolean.class);

Möchte man beim Erstellung der Config-Instanz mehr Einfluss nehmen, kann anstelle des ConfigProvider auf einen ConfigBuilder zurückgegriffen werden. Der Builder erlaubt die eine oder andere individuelle Anpassung. Der oben gezeigte Code würde mit einem ConfigBuilder wie folgt aussehen:

// get access to the Config instance via ConfigBuilder
ConfigBuilder builder = ConfigProviderResolver.getBuilder();
builder.addDefaultSources();
Config config = builder.build();

// access config properties via Config instance
String someStringValue = config.getValue("SOME_STRING_KEY", String.class);
Boolean someBooleanValue = config.getValue("SOME_BOOL_KEY", Boolean.class);

Anmerkung: Bei der Verwendung des ConfigProvider wird die beim Aufruf der getConfig()-Methode zurückgelieferte Config-Instanz aus Gründen der Effizienz automatisch gecacht. Das ist bei der Verwendung des ConfigBuilder beziehungsweise des ConfigProviderResolver nicht der Fall.

Deutlich einfacher als der ebene gezeigte programmatische Zugriff ist der via CDI Injection:

@Inject @ConfigProperty("SOME_STRING_KEY")
String someStringValue;

@Inject @ConfigProperty("SOME_BOOL_KEY")
String someBooleanValue;

Was aber, wenn sich der gesuchte Schlüssel in keiner der Konfigurationsquellen finden lässt? Im obigen Beispiel würde das automatisch zu einer Exception führen. Im ersten Beispiel, dem programmatischen Zugriff, wäre es eine NoSuchElementExeption zur Laufzeit. Im zweiten Beispiel dagegen, im Falle der CDI Injection, würde eine DeploymentException während des Start-ups geworfen werden.

Soll eine Konfiguration lediglich optional sein, um so zum Beispiel nur in speziellen Umgebungen Standardwerte zu überschreiben, in anderen dagegen nicht, ist dies ebenfalls für beide gezeigten Zugriffsmechanismen möglich. Im Falle des programmatischen Zugriffs sieht das wie folgt aus:

// get access to the Config instance 
Config config = ConfigProvider.getConfig();

// access optional string property
String someStringValue = config.getOptionalValue("SOME_KEY", String.class)
.orElse("someDefaultValue");

Bei CDI Injection dagegen reicht die einfache Angabe eines Default-Werts vom richtigen Typ:

// inject optional property
@Inject @ConfigProperty("SOME_KEY", defaultValue="someDefaultValue")
String someValue;

Auch wenn Microservice-basierte Anwendungen so entworfen sein sollten, dass sie einen Restart einzelner Services problemlos überleben, macht es sicherlich keinen Sinn, einen Service jedes Mal neu zu starten, nur weil sich ein Wert einer Konfiguration geändert hat. Aus diesem Grund sieht die Config API einen Mechanismus vor, Konfigurationswerte dynamisch in dem Moment der Verwendung zu laden.

Das macht natürlich wenig Sinn, wenn die Konfigurationsquelle eine gemeinsam mit dem WAR deployte Properties-Datei ist. Anders dagegen sieht es schon aus, wenn als Quelle eine Datenbank oder ein Konfigurationsserver angebunden wird.

Um in dem Moment der Nutzung einer Property tatsächlich immer ihren aktuellen Wert und nicht den Wert zum Zeitpunkt der Injection zu erhalten, muss anstelle der ConfigProperty ein Provider injiziert werden. Mithilfe des Providers und dessen get()-Methode kann anschließend eine Just-in-Time-Konfiguration erfolgen.

// inject property provider
@Inject @ConfigProperty("SOME_KEY")
Provider<String> someValueProvider;
...
// get property value "just-in-time" via provider
String someValue = someValueProvider.get();

Der Begriff "Just-in-Time" ist in diesem Fall allerdings eher relativ zu sehen und bedeutet lediglich, dass der gerade in der Konfigurationsquelle hinterlegte Wert herangezogen wird. Wie die Quelle wiederum die Werte aktualisiert und wann auf ihr ein Refresh durchgeführt wird, ist nicht spezifiziert und somit dem Autor der ConfigSource-Klasse überlassen. Die Implementierung von OpenLiberty zum Beispiel erlaubt die Angabe einer Refresh-Rate via System-Property microprofile.config.refresh.rate für die Konfigurationsquellen. Der Standardwert liegt bei 500 (ms), sodass bei Bedarf alle halbe Sekunde ein Refresh erfolgt.

Die Config API unterstützt, wie im ersten Beispiel gezeigt, nicht nur Properties vom Typ String. Dank sogenannter Converter lassen sich die in den Konfigurationsquellen hinterlegten String-Werte in beliebige Java-Typen überführen.

Für eine Reihe von Java-Typen existieren bereits sogenannte Build-in Converter. Neben den bisher gezeigten Typen String und Boolean werden unter anderem auch Integer, Long, Double, Float sowie Duration, LocalTime, LocalDate, LocalDateTime, Instant und URL "out of the box" unterstützt. Und auch die Verwendung von Arrays ist seit Version 1.2 möglich:

// injection list property via array converter 
@Inject @ConfigProperty("SOME_LIST_KEY")
private List<String> someListValues;

...

// get access to the Config instance via ConfigProvider
Config config = ConfigProvider.getConfig();
// access config properties via Config instance
private String[] someArrayValues =
config.getValue("SOME_ARRAY_KEY", String[].class);

Was aber, wenn nicht unterstützte oder eigene Datentypen via Config API gesetzt werden sollen? In diesem Fall lässt sich ein spezifischer Custom-Converter, der das Converter<T>-Interface implementiert, nutzen. Das folgende Beispiel zeigt einen einfachen Converter für eine Klasse namens Email:

@Priority(200)
public class EmailConverter implements Converter<Email> {

@Override
public Email convert(String email) throws IllegalArgumentException {
return new Email(email);
}
}

Damit der Converter zur Laufzeit automatisch herangezogen werden kann, muss er zusätzlich registriert werden. Dies kann entweder via Java ServiceLocator – durch Angabe des voll qualifizierten Klassennamens des Converters in einer entsprechenden Datei META-INF/services/org.eclipse.microprofile.config.spi.Converter – geschehen oder alternativ programmatisch mithilfe des ConfigBuilder:

// get access to the Config instance via ConfigBuilder
ConfigBuilder builder = ConfigProviderResolver.getBuilder();
builder.addDefaultSources();

// add custom converter for Email class
Converter<Email> emailConverter = new EmailConverter();
builder.withConverters(emailConverter);
Config config = builder.build();

// read email property into email object
Optional<Email> emailOpt = config.getOptionalValue("ADMIN_EMAIL",
Email.class);
...

Wie das Listing des EmailConverter zeigt, lassen sich auch Converter mit einer Priorität versehen. Sind also mehrere Converter für denselben Typ registriert, dann überschreibt der Converter mit der höheren Priorität diejenigen mit geringerer Priorität. Der Standardwert ist 100.

Alles in allem macht die Config API einen runden Eindruck, was aber auch nicht wirklich verwundert, wenn man einmal einen genaueren Blick auf die Ursprünge der API wirft:

Wer ein wenig Lust bekommen hat, die API einmal auszuprobieren, kann das mit folgenden Implementierungen tun:

Für die Zukunft sind bereits weitere Features geplant. Ein Hauptaugenmerk wird darauf liegen, dynamische Konfigurationsänderungen besser zu unterstützen. Man darf gespannt sein.