Java Magazin   5.2019 - Java 12

Erhältlich ab:  April 2019

Autoren / Autorinnen: 
Stephan Schuster ,  
Sebastian Meyen ,  
Lars Röwekamp ,  
,  
Dominik Mohilo ,  
Michael Inden ,  
Tam Hanna ,  
Falk Sippach ,  
Dirk Dorsch ,  
Gernot StarkePeter Hruschka ,  
Konstantin Diener ,  
Oliver Zeigermann ,  
Mehrdad Jalali-Sohi ,  
René Preißel ,  
Thilo Frotscher ,  
Michael Schäfer ,  
Oliver B. Fischer ,  
Hans Kamutzki

In verteilten Systemen müssen Komponenten über externe Schnittstellen kommunizieren. Consumer-driven Contracts stellen einen speziellen Fall von Integrationstests dar. Sie ermöglichen es, bereits in der Entwicklung Schnittstellenverträge abzusichern, ohne dabei die beteiligten Services starten und End-to-end-Tests durchführen zu müssen. Für Spring-Entwickler stehen mit Pact JVM und Spring Cloud Contract gleich zwei Frameworks zur Verfügung, um solche Tests umzusetzen. Dieser Artikel soll bei der Entscheidung helfen, welches Framework man einsetzen möchte.

Das Ziel von Microservices ist, die Gesamtfunktionalität eines komplexen Systems in möglichst unabhängige Services aufzuteilen. Verglichen mit einem Monolithen möchte man dadurch Ziele wie ein schnelleres Deployment einzelner Services oder eine bessere und striktere Modularisierung des Systems erreichen. Da man es mit einem verteilten System zu tun hat, gehen diese Vorteile mit einer größeren technischen Komplexität einher. Einen Aspekt davon stellt die Kommunikation der Services untereinander über externe Schnittstellen wie REST APIs oder asynchrone Nachrichten dar.

Durch die externen Grenzen der Services untereinander sind Probleme beim Nutzen und Anbieten von Schnittstellen vorprogrammiert. So können z. B. neue Pflichtheader oder Parameter in einem REST Call dazukommen, welche die Schnittstelle brechen. Oft bemerken Konsumenten das erst spät, etwa bei End-to-end-Tests oder vielleicht sogar erst in der Produktion. Um die Entwicklung abzusichern, können Konsumenten das Verhalten einer Schnittstelle anhand von Mocks nachbilden und in Tests integrieren [1]. Dadurch wird sichergestellt, dass der Konsument (Consumer) den Dienst korrekt aufruft. Allerdings erfordert das, dass die Mocks aktuell gehalten werden. Die Tests müssen sich ebenfalls am Anbieter der Schnittstelle (Producer) und dessen Änderungen orientieren. Wird der Producer durch ein anderes Team bereitgestellt, kann dies das Aktuellhalten der Tests wegen geringerer direkter Kommunikation weiter erschweren.

Der Anspruch von Consumer-driven Contracts (CDC) ist, diese Lücke zwischen Consumer und Producer zu schließen, indem die Konsumenten dem Anbieter ihre Erwartungen in Form von Verträgen mitteilen. Dabei sind Verträge nicht im Sinne vollständiger Schnittstellenbeschreibungen (z. B. als WDSL) zu verstehen, sondern als minimale Anforderung eines Clients an einen API-Provider. Während der Anbieter eines API in der Regel Anforderungen mehrerer Clients befriedigen muss, sind diese nicht unbedingt daran interessiert, die komplette Schnittstelle zu kennen; oft reicht ein Ausschnitt eines JSON-Dokuments. Der Consumer-driven-Ansatz ist daher nur in Umfeldern interessant, in denen APIs parallel mit den Anforderungen der Konsumenten entwickelt werden.

Im Vorgehensmodell von CDC implementiert der Konsument zuerst gegen die zu definierende externe Schnittstelle und macht die daraus resultierende Anforderung dem Providerteam zugänglich. Der Konsument schreibt außerdem Tests, die die korrekte Verwendung des Vertrags durch den Consumer überprüfen. Clientseitig ist dieses Vorgehen identisch mit dem Testen gegen einen Mock-Server wie z. B. WireMock, der auf Aufrufe mit Stubs der externen Schnittstelle antwortet. Bei CDCs kommt nun aber hinzu, dass der Producer den Vertrag ebenfalls in Unit-Tests integriert. Diese vergleichen die Rückgabewerte der Schnittstelle mit der Erwartung des Vertrags. Erst wenn beide Tests erfolgreich sind, ist der Vertrag vollständig verifiziert. Weichen Werte auf der Providerseite ab oder hat sich die Verwendung der Schnittstelle auf Clientseite geändert, schlagen die Tests auf einer oder beiden Seiten fehl. Schnittstelle und ggf. Logik müssen angepasst werden. Somit hat man die Tests zweier unabhängiger Services miteinander integriert, ohne dass diese direkt miteinander kommunizieren müssen.

Contract-Tests erhöhen also die Sicherheit zur Entwicklungszeit, da Fehler bei der formalen Verwendung der Schnittstellen schnell sichtbar werden und dann behoben werden können. Eine komplette Integration mit anderen Services ist an dieser Stelle nicht nötig. Contract-Tests können allerdings keine End-to-end-Tests ersetzen. Sie können als Integrationstests im Sinne der fowlerschen Testpyramide gesehen werden, deren Grenze zum (gemockten) externen System hin verläuft. Sie berücksichtigen daher auch nicht, ob die Daten der Stubs den realen Daten eines Produktivsystems entsprechen. Hierfür sind natürlich nach wie vor z. B. End-to-end-REST-API-Tests, UI-Tests oder Acceptance-Tests erforderlich [2].

Pact JVM [3] und Spring Cloud Contract (SCC) [4] sind zwei Frameworks, die CDC in der Java-Welt unterstützen. Dieser Artikel stellt diese Frameworks kurz vor und zeigt, wie man mit ihnen Tests in einer Spring-Anwendung schreibt. Der Artikel möchte eine etwas umfassendere Darstellung bieten und behandelt neben REST-API-Contract-Tests (über die bereits eine Reihe von Artikeln und Blogeinträgen existiert) auch Messaging-Schnittstellen. Dabei beschränken sich – bedingt durch das Kundenprojekt des Autors – Darstellung und Bewertung der Alternativen auf die Anwendung des jeweiligen Frameworks in einer Spring-Microservices-Landschaft. Als Beispiel dienen drei Microservices eines fiktiven Onlinebuchshops (das Beispiel ist auf GitHub verfügbar [5]:

  • Ein Client-Service, der Anfragen von außen entgegennimmt und auf zwei interne Services weiterverteilt

  • Ein Catalog Service für das Abrufen von Büchern mittels REST-Schnittstelle

  • Ein Order Service für das Bestellen von Büchern über eine Messaging-Schnittstelle

Beispielanwendungsfall

Der Beispielclient steht exemplarisch für den Aufruf sowohl eines REST API als auch einer AMQP-Schnittstelle. Im REST-Fall wird eine einfache GET-Anfrage an den Catalog Service gestellt, um eine Liste von Büchern abzurufen; im Messaging-Fall wird ein BookOrderedEvent empfangen, nachdem ein Buch bestellt wurde. Diese über AMQP versendete Nachricht wird dabei von Spring bereits in ein BookOrderedEvent umgewandelt und dem RabbitListener übergeben. Listing 1 zeigt den REST-Client und den Message Listener der Beispielanwendung. Abbildung 1 stellt schematisch den Ablauf von Consumer-driven-Contracts-Tests, abstrahiert vom jeweiligen Framework, für die beiden Fälle des Anwendungsbeispiels dar.

Listing 1

@Service
public class BookRestUpstreamService  {
  private final RestTemplate restTemplate;
  public List<Book> getAllBooks() {
    String url = "http://localhost:8087/books";
    return Arrays.asList(restTemplate.exchange(url, HttpMethod.GET, createHeaders(), Book[].class).getBody());
  }
  ...
}
@Service
public class BookOrderedEventRabbitListener {
  private final BookService bookService;
  @RabbitListener(queues = "order-queue")
  public void receiveMessage(BookOrderedEvent event) {
    bookService.orderConfirmed(event);
  }
}
schuster_cdc_1.tif_fmt1.jpgAbb. 1: Schematische Darstellung von CDC anhand der Beispielanwendung

Pact

Ursprünglich in und für Ruby umgesetzt, existiert neben unterschiedlichen Implementierungen mit Pact JVM auch eine Variante, die auf der JVM nutzbar ist und einen JUnit Runner sowie Gradle- und Maven-Plug-ins zur Verfügung stellt. Darüber hinaus gibt es Spring-Erweiterungen, die eine Integration in Spring-Boot-Tests ermöglichen. Pact unterstützt CDC für REST-Schnittstellen ebenso wie für Messaging-Schnittstellen.

Ein Contract-Test wird in folgenden Schritten umgesetzt: Auf der Consumer-Seite wird in einem ersten Schritt der Vertrag per DSL definiert und in einem zweiten Testschritt verifiziert. In diesem Schritt wird ein Mock-Server gestartet, der auf Anfragen mit den vorher definierten Antworten reagiert. Ein Server kann hierbei ein Mock-HTTP-Server oder ein Mock-AMQP-Server sein. Während der Ausführung des Tests wird eine Pact-Datei generiert und in Form eines JSON-Dokuments auf das Dateisystem geschrieben. Ein Verifikationsschritt ist also immer erforderlich, da nur so sichergestellt wird, dass der definierte Vertrag auch tatsächlich den Anforderungen des Clients gerecht wird (sonst könnte der Vertrag beliebig sein). Die Pact-Datei kann nun dem Producer übergeben werden, der dann die tatsächliche Antwort mit der erwarteten vergleichen kann. Die folgenden Abschnitte beschreiben diese Schritte im Detail.

Pact-Consumer-Test

Das Schreiben des Tests erfolgt unter Verwendung von pact-jvm-consumer-junit-Maven- oder Gradle-Plug-ins. Listing 2 zeigt den vereinfachten Test für einen REST-API-Vertrag.

Listing 2

public class BookCatalogConsumerPactTest {
  @Rule
  public PactProviderRuleMk2 mockProvider =
    new PactProviderRuleMk2("book-catalog-service", "localhost", 8081, this);
 
  @Pact(consumer="books-client-catalog-rest-consumer")
  public RequestResponsePact pact(PactDslWithProvider builder) {
    DslPart payload = new PactDslJsonArray()
      .object()
      .integerType("id")
      .stringType("authorFirstName");
    return builder
      .given("get")
      .uponReceiving("A successful api GET call")
      .path("/books")
      .headers(expectedRequestHeaders())
      .method("GET")
      .willRespondWith()
      .status(200)
      .body(payload)
      .headers(responseHeaders())
      ...
      .toPact();
  }
  ...
  @Test
  @PactVerification
  public void verifyPact() {
    ResponseEntity<Book[]> response = new RestTemplate().exchange("http://localhost:8081/books", HttpMethod.GET, requestHeaders(), Book[].class);
 
    assertThat(response.getStatusCode().value(), equalTo(200));
    ...
 }
 ...
}

Pact JVM bietet neben der hier dargestellten annotations- und Rule-basierten Methode auch die Möglichkeit via Vererbung von PactConsumerTest mit ähnlichem Ergebnis, auf die hier allerdings nicht weiter eingegangen wird.

Bei der Durchführung des Tests wird ein Mock-HTTP-Server hochgefahren. Dieser beantwortet Anfragen mit den in Pact DSL definierten Objekten, die in der mit @ Pact annotierten Methode erstellt werden. Die Anfragen werden dann mit der mit @PactVerification annotierten Methode gestellt und können dort mit JUnit-Mitteln auf Korrektheit geprüft werden. Listing 3 zeigt Ausschnitte der mittels (erfolgreichem) Test generierten Pact-Datei.

Listing 3

{
  "provider":{"name":"book-catalog-service"},
  "consumer":{"name":"books-client-catalog-rest-consumer"},
  "interactions":[
    {"description":"A successful api GET call",
      "request":{"method":"GET","path":"/books",
      },
      "response":{"status":200,
        "body":[
          {"id":1}
        ],
        "matchingRules":{
          "body":{
            "$[0].id":{
              "matchers":[{"match":"integer"}]
            }
          }
        }
      },
      "providerStates":[{"name":"get"}]
    }
  ]
}

Die Pact-Datei definiert den erwarteten Response-Status und Response-Body. Die Felder des Bodys können über Matching Rules beschrieben werden, die z. B. den erwarteten Typ oder auch Muster mit Regular Expressions beschreiben. Ein REST-Vertrag kann dabei mehrere Interaktionen beinhalten, z. B. erwartete Antworten auf unterschiedliche Eingaben oder HTTP-Methoden. Die Zuordnung zur Testmethode erfolgt über den Namen des Providers (im Beispiel „book-catalog-service“, der optional auch in @PactVerifier angegeben werden kann). Optional können Zustände über das Element providerStates benannt werden, die den Zustand des Providers bei Aufruf der Methode angeben. Diese Zustände können später innerhalb des Providertests in eigenen Testmethoden hergestellt werden. Dadurch können auch Request-Response-Abfolgen simuliert werden, ohne dass eventuelle Zustände zwischen den Aufrufen gespeichert werden müssen.

Messaging Contracts werden analog erstellt. Listing 4 und Listing 5 zeigen Unit-Test und Pact-File für diesen Fall. Ein Messaging Contract kann ebenso wie ein API Contract mehrere Interaktionen enthalten. Komplexere Interaktionen können wieder über das State-Konzept abgebildet werden. Die Zuordnung zur Testmethode erfolgt hier allerdings nicht über den Producer-Namen, sondern über das description-Feld eines Messages-Eintrags. Auf diese Weise können analog mehrere zusammenhängende Nachrichten innerhalb eines Consumer-Tests definiert und verifiziert werden. Das unterscheidet sich vom REST-Fall, in dem alle Fälle innerhalb des Verifiers über mehrere HTTP Calls abgearbeitet werden können. Der Unterschied resultiert aus der MessageMockProviderRule, die das Simulieren des AMQP-Servers übernimmt. Diese Regel stellt keine unabhängige Umgebung zur Verfügung wie der Mock-HTTP-Server, sondern liefert lediglich die aus dem Pact generierte Nachricht als byte[] an die Testklasse zurück. Darüber hinaus besteht die – hier nicht dargestellte – Möglichkeit, über die fragments-Eigenschaft der @PactVerification-Annotation mehrere Nachrichten in eine Pact-Datei aufzunehmen.

Listing 4

public class BookOrderedEventConsumerPactTest {
  @Rule
  public MessagePactProviderRule mockProvider = new MessagePactProviderRule(this);
  private byte[] currentMessage;
  @Pact(provider = "book-order-service",consumer = "books-client-book-ordered-event-consumer")
  public MessagePact createPact(MessagePactBuilder builder) {
    PactDslJsonBody body = new PactDslJsonBody();
    body.stringType("customerId");
    return builder
      .given("orderCommandReceived")
      .metaData(“destination”: “order-exchange”)
      .expectsToReceive("A message sent via order-exchange")
      .withContent(body)
      .toPact();
  }
  @Test
  @PactVerification({"book-order-service"})
  public void verify() throws Exception {
    byte[] message = mockProvider.getMessage();
    Assert.assertNotNull(new String(currentMessage));
  }
}

Listing 5

{"consumer":{"name":"books-client-book-ordered-event-consumer"},
  "provider":{"name":"book-order-service"},
  "messages":[{
    "description":"A message sent via order-exchange",
    "contents":{"isbn":"978-3-86680-192-9","customerId":"string"},
    "providerStates":[{"name":"orderCommandReceived"}],
    "matchingRules":{
      "body":{
        "$.customerId":{"matchers":[{"match":"type"}],
          "combine":"AND"
        },
        "$.isbn":{
          "matchers":[{"match":"regex","regex":"[0-9]{3}..."}],
          "combine":"AND"
        }
      }
    }
  }
]}

Pact-Producer-Test

Auf der Providerseite kann der generierte Vertrag nun getestet werden. Das setzt voraus, dass ein Server läuft, der die reale Antwort erzeugt, die mit dem Vertrag verglichen werden soll. Für den REST-Fall stehen mehrere Möglichkeiten zur Verfügung. Benutzt man den PactRunner JUnit Runner, erfolgt dies über ein Build-Tool wie Gradle oder Maven, das dafür sorgen muss, dass der Service-Provider gestartet wird. Alternativ kann man mit dem Plug-in pact-jvm-provider-spring den Server über einen Spring-Boot-Test ohne weitere Build-Konfiguration starten. Diese Möglichkeit ist in Listing 6 dargestellt. Im Unit-Test selbst wird mit dem HttpTarget ein Client genutzt, der einen laufenden Server an einer angegebenen Adresse erwartet. Die @PactBroker-Annotation gibt an, von wo der Vertrag des Providers book-catalog-service heruntergeladen werden kann. Das ist hier ein PactBroker (s. u.), könnte aber z. B. auch einfach ein lokales Verzeichnis sein. Sobald das geschehen ist, generiert der PactRunner aus dem Vertrag Unit-Tests, ruft über das HttpTarget die Schnittstelle auf und verifiziert das Ergebnis. Als Entwickler muss man sich nur um die Konfiguration kümmern. Durch die @State-Annotation kann z. B. ein Zustand für diesen Testaufruf hergestellt werden, ähnlich wie in einem @Before eines üblichen JUnit-Tests. Pact ruft diese Methode vor der Verifikation auf. Sollen noch weitere Testschritte ausgeführt werden, kann das in derselben Methode erfolgen.

Listing 6

@RunWith(SpringRestPactRunner.class)
@SpringBootTest
@Provider("book-catalog-service")
@PactBroker(host="localhost", port="80", protocol = "http")
public class BookCatalogRestEndpointPactOnlyTest {
  @TestTarget
  public final Target target = new HttpTarget(8087);
 
  @Test
  @State("get")
  public void verifySuccessfulGet() {
    ...
  }
}

Bei einem Message-Contract ändert sich lediglich das Target zu einem AMQP Target. Als Nachteil erweist sich hier die fehlende Integration mit Spring. Zunächst müssen die beteiligten Services gemockt oder selbst instanziiert werden, da eine Ausführung als Spring-Boot-Test nicht möglich ist. Das AMQP Target ruft jede mit @PactVerifyProvider annotierte Methode auf und erwartet als Rückgabewert die JSON Payload, die von der Schnittstelle zurückgegeben wird. Auf diesen Wert wird dann analog zum REST-Fall die Verifizierung angewendet. Das bedeutet letztlich, dass der Producer nicht als Integrationstest ausgeführt werden kann, der z. B. auch die Zustellung der Nachricht an die korrekte Destination sicherstellt, sondern lediglich die Payload überprüfen kann. Ebenso eröffnet das auch die Möglichkeit, im Test selbst eine Antwort zu generieren, die nicht mit der tatsächlichen Ausgabe korrespondiert. Listing 7 zeigt einen Ausschnitt aus dem Unit-Test. Die Verbindung zur Pact-Datei wird über die @PactVerifyProvider-Annotation hergestellt, die dem description-Feld im Vertrag entsprechen muss. Optional kann hier wieder eine mit @State annotierte Methode hinzugefügt werden, die vor dem Test aufgerufen wird.

Listing 7

@RunWith(PactRunner.class)
@Provider("book-order-service")
@PactBroker(host="localhost", port="80", protocol = "http")
public class OrderEventPactOnlyTest {
  @TestTarget
  public final Target target = new AmqpTarget(Collections.singletonList("de.sidion.books.order.*"));
  ...
  @Test
  @PactVerifyProvider("A message sent via order-exchange")
  public String verifyMessageForOrder() throws Exception {
    ...
    when(repo.createOrder(any(), any(), any()))
      .thenReturn(BookOrder.of(bookId, isbn, customerId));
        ArgumentCaptor<BookOrderedEvent> capt = ArgumentCaptor.forClass(BookOrderedEvent.class);
    service.createBookOrder("1", "1", "978-3-86680-192-9");
    Mockito.verify(notificationService).publishBookOrderedEvent(capt.capture());
    BookOrderedEvent event = capt.getValue();
    return new ObjectMapper().writeValueAsString(event);
  }
}

Pact Broker

Um auf der Producer-Seite einen Pact verifizieren zu können, müssen die Dateien zur Verfügung gestellt werden. Im einfachsten Fall kopiert man einfach die Dateien in das Build-Verzeichnis, z. B. indem man die Dateien in das Repository des Providers mit eincheckt.

Mit dem Pact Broker stellt das Pact-Projekt einen eigenen Server für den Austausch von Verträgen bereit. Consumer veröffentlichen ihre Verträge auf dem Server, Producer können sich jeweils die neueste Version herunterladen. Darüber hinaus bietet der Pact Broker nützliche Funktionen für die Integration in das Deployment: Pact-Versionen, die Versionen der Consumer und Producer sowie die Ergebnisse der Verifizierung werden im Broker gespeichert. So können Vertragsverletzungen direkt sichtbar gemacht und festgestellt werden, welche Client- und Serverversionen kompatibel sind. Mittels API-Aufrufen können diese Features auch von automatisierten Delivery Pipelines genutzt werden (Abb. 2).

schuster_cdc_2.tif_fmt1.jpgAbb. 2: Pact Broker mit den Verträgen der Beispielanwendung

Spring Cloud Contract (SCC)

SCC entstand im Umfeld des Spring-Cloud-Projekts. Naturgemäß auf das Spring-Ökosystem ausgerichtet, bietet es aber die Möglichkeit, Verträge im YAML-Format zu spezifizieren und ein Spring Cloud Contract Docker Image zu nutzen. Das ermöglicht es SCC, auch Nicht-JVM-Sprachen zu nutzen [6]. Das ist keine wirklich polyglotte Lösung, die Implementierung basiert nach wie vor auf der JVM.

Ähnlich wie bei Pact erfolgt die Verifizierung auf Consumer-Seite in zwei Schritten: Zuerst wird der Vertrag definiert und dann im Rahmen eines Unit-Tests verifiziert. Anders als bei Pact JVM wird der Vertrag aber außerhalb des Unit-Tests in einer Groovy DSL oder YAML-Datei definiert. Der Consumer-Test enthält nur Code zur Validierung. Dabei startet der Stub Runner die Mock-Umgebung (WireMock für REST- und eine eigene AMQP-Mock-Implementierung für Messageverträge) und nutzt die Verträge, um entsprechende Antworten zu erzeugen. Auf der Producer-Seite vergleicht der Contract Verifier die Antwort mit den erwarteten Ergebnissen des Vertrags. Auch hier muss kein Test geschrieben, sondern lediglich die Testumgebung konfiguriert werden. Ermöglicht wird das durch Einbinden der entsprechenden Plug-ins, z. B. des spring-cloud-contract-gradle-plugin.

Der von SCC präferierte Workflow geht davon aus, dass Verträge vom Consumer erstellt, an das Producer-Team übermittelt (z. B. als Teil eines Pull Requests) und in dessen Repository gepflegt werden. Der Producer Build erstellt beim Publizieren der Artefakte ein zusätzliches JAR mit den Verträgen. Dafür müssen das Maven Publish Plugin und ein Maven Repository verwendet werden. Dieses Artefakt wird dann sowohl vom Client als auch vom Provider genutzt. Im REST-Fall enthält das JAR bereits WireMock Stubs, die aus den Verträgen generiert wurden. Im AMQP-Fall verwendet das eigens implementierte Mock-Framework die Vertragsdaten des Message Contracts.

Listing 8 und Listing 9 zeigen Verträge für REST und AMQP in der Groovy DSL. Ähnlich wie Pact REST Contracts definiert der Vertrag Response-Codes, Headerfelder und entsprechende Patterns für den Response-Body.

Listing 8

org.springframework.cloud.contract.spec.Contract.make {
  request {
    method 'GET'
    url '/books'
    headers {contentType('application/json')}
  }
  response {
    status 200
      body([["authorFirstName":$(regex("[a-zA-Z]*"))]])
      headers {
        contentType('application/json')
      }
  }
}

Listing 9

Contract.make {
  description("Example messaging contract")
  label 'book-order'
  input {
    triggeredBy('bookOrdered()')
  }
  outputMessage {
    sentTo 'order-exchange'
    body(["customerId": $(regex("[0-9]*"))])
    headers {
      messagingContentType(applicationJson())
    }
  }
}

Die Payload eines Message Contracts wird nach denselben Prinzipien überprüft. Darüber hinaus unterscheidet SCC drei Modi: das Triggern der Nachricht durch eine Triggernachricht; zeitgesteuert, z. B. durch einen Cronjob; oder über eine Methode, wie im Beispiel dargestellt.

SCC-Consumer-Test

Listing 10 zeigt den Consumer-Test für eine REST-Schnittstelle. Über die @AutoConfigureStubRunner-Annotation wird angegeben, wo und welche Verträge für den Stub Runner benutzt werden sollen. Der restliche Test kann wie ein normaler Integrationstest geschrieben werden, z. B. als Spring-Boot-Test. Wird nun die externe REST-Schnittstelle aufgerufen, antwortet der durch den Stub Runner gestartete WireMock-Server. Da dieser in einem Zufallsport initialisiert wird, kann über minPort und maxPort die Range eingeschränkt werden. Im Beispiel soll es genau Port 9999 sein, der vom REST-Client der Anwendung verwendet wird.

Listing 10

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureStubRunner(stubsMode = StubRunnerProperties.StubsMode.LOCAL,
minPort = 9999, maxPort = 9999,
ids = "de.sidion.books:books-catalog-service-scc-only-test")
public class BookCatalogConsumerSccStubrunnerTest {
  @Autowired 
  BookRestUpstreamService bookService; //Ruft das Catalog API auf (Listing 1)
  @Test
  public void verifyBookCatalogGetAllBooksContract() throws Exception {
    List<Book> books = bookService.getAllBooks();
    assertThat(books, Matchers.notNullValue());
    ...
  }
}

Für Messaging Contracts stellt SCC eine eigene Mock-Implementierung zur Verfügung. In dieser Implementierung werden RabbitMQ-Mock-Komponenten wie Templates und Listener-Container definiert und mittels ContractVerifierAmqpAutoConfiguration dem Spring Context übergeben. Im Consumer-Test (Listing 11) wird dann durch Aufruf von StubTrigger.trigger() eine Nachricht an den Mock-AMQP-Server geschickt. Auf diese Weise kann ein Integrationstest mit komplettem Spring Context gestartet werden, ohne von externer Kommunikationsinfrastruktur abhängig zu sein.

Listing 11

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureStubRunner(stubsMode = StubRunnerProperties.StubsMode.LOCAL, ids = "de.sidion.books:books-order-service-scc-only-test")
public class BookOrderedEventConsumerSccStubrunnerTest {
  @Autowired StubTrigger stubTrigger;
  @Autowired BookService service; //wird vom BookOrderedEventRabbitListener aufgerufen (Listing 1)
  @Test
  public void verifyBookOrderedEventContract() throws Exception {
    int counterBefore = service.getMessagesReceivedCounter();
    stubTrigger.trigger("book-order");
    int counterAfter = service.getMessagesReceivedCounter();
    assertThat(counterAfter, equalTo(counterBefore + 1));
  }
}

SCC-Producer-Test

Analog zur Vorgehensweise bei Pact besteht der Test auf Producer-Seite im Herunterladen des Vertrags und der Überprüfung der Erwartungen mit der tatsächlichen Ausgabe der Schnittstelle. Auch hier erfolgt die Überprüfung automatisch. Anders als bei Pact wird dabei jedoch Testcode generiert und im Build-Verzeichnis abgelegt. Als Entwickler muss man eine abstrakte Basistestklasse sch reiben und diese über das Build-Skript dem Framework bekannt machen, sowie den Testklassenpfad erweitern. Die generierten Tests erweitern dann die Basisklasse. Listing 12 zeigt den Ausschnitt aus dem Gradle-Build-File.

Listing 12

contracts {
  contractDependency {
    stringNotation = "${project.group}:book-catalog-service:+"
  }
  contractsPath = "*"
  baseClassForTests =
    "de.sidion.books.catalog.contracts.BookCatalogRestEndpointBaseSccOnlyTest"
}
sourceSets {
  contractTest {
    java {
    compileClasspath+=main.output+test.output
    runtimeClasspath+=main.output+test.output
    srcDir file('build/generated-test-sources')
    }
  }
}

In Listing 12 wird auch offensichtlich, wie SCC die Maven-Nomenklatur verwendet, um Verträge im Repository zu finden. Diese Nomenklatur muss auch bei anderen Mechanismen – Git und Pact-Broker werden ebenfalls unterstützt – beibehalten bleiben.

Für die Verifizierung eines REST-API-Vertrags setzt SCC auf MockMvc-Klassen auf. Daher muss ein entsprechender Mock konfiguriert werden. Listing 13 zeigt das unter Verwendung von RestAssured.

Listing 13

@RunWith(SpringRunner.class)
@ContextConfiguration(classes = BookCatalogApplication.class)
public abstract class BookCatalogRestEndpointBaseSccOnlyTest {
  @Autowired BookRestEndpoint endpoint;
  @Before
  public void setup() {
    RestAssuredMockMvc.standaloneSetup(endpoint);
  }
}

Listing 14 zeigt den Test für einen Messaging Contract. Bei Messaging Contracts muss die Basistestklasse dafür sorgen, dass der im Vertrag angegebene Trigger ausgelöst wird. SCC erwartet, dass die Methode, die im Vertrag unter triggeredBy (Listing 9) angegeben wurde, in der Testklasse existiert, und ruft diese zu Beginn der Verifikation auf. Im Beispiel heißt diese Triggermethode, über welche der Producer die zu verifizierende Nachricht verschickt, bookOrdered(). Hier ist zu beachten, dass der SCC Verifier über @AutoConfigureMessageVerifier eingebunden werden muss. Der generierte Test kann dann die an den Mock Exchange versendete Nachricht abfangen und verifizieren. Intern geschieht dies über die Verwendung von Mockito Spies in den Komponenten der MessageVerifier-Konfiguration.

Listing 14

@RunWith(SpringRunner.class)
@SpringBootTest
@AutoConfigureMessageVerifier
public abstract class OderEventSccOnlyTest {
  @Autowired BookOrderDomainService service;
  protected void bookOrdered() {
    service.createBookOrder("1", "1", "978-3-86680-192-9"); 
  }
}
 
//BookOrderDomainService:
public void createBookOrder(String customerId, String bookId, String isbn) {
  BookOrder order = orderRepository.createOrder(customerId, bookId, isbn);
  BookOrderedEvent  event = BookOrderedEvent.of(order); 
  rabbitTemplate.convertAndSend("order-exchange", "orders.books.#", event);
}

Listing 15 verdeutlicht beispielhaft, wie der generierte Test über die Klasse ContractVerifierMessaging das BookOrdered-Event abfängt und verifiziert.

Listing 15

public class ContractVerifierTest extends OderEventSccOnlyTest {
  @Inject ContractVerifierMessaging contractVerifierMessaging;
  @Inject ContractVerifierObjectMapper contractVerifierObjectMapper;
  @Test
  public void validate_bookMessagingContract() throws Exception {
    // when:
    bookOrdered();
    // then:
    ContractVerifierMessage response = contractVerifierMessaging.receive("order-exchange");
    assertThat(response).isNotNull();
    assertThat(response.getHeader("contentType")).isNotNull();
      … 
    // and:
     DocumentContext parsedJson = JsonPath.parse
      (contractVerifierObjectMapper.writeValueAsString(response.getPayload()));
      assertThatJson(parsedJson).field("['customerId']").matches("[0-9]*");
     … 
  }
}

Vergleich von Pact und Spring Cloud Contract

Obwohl beide Ansätze Consumer-driven Contracts implementieren, werden doch einige Unterschiede offensichtlich:

  • Pact ist in unterschiedlichen Implementierungen verfügbar. Dies ist ein Vorteil, hat aber auch eine Kehrseite: Die Integration mit Spring ist nicht komplett. Mit pact-jvm-provider-spring gibt es zwar ein Plug-in für Spring, allerdings noch nicht für Message Contracts.

  • Bei Pact erschwert insbesondere die nur rudimentäre Unterstützung von Messaging-Verträgen das Testen, da hier eigentlich nur reine Stub-Tests mit Mockito oder ähnlichen Mitteln geschrieben werden können. Man schreibt hier keinen Integrationstest, der das Verhalten der Messaging-Komponenten wie Queues und Listener mittestet, sondern verifiziert lediglich die Payload. Orientiert man sich an der anfangs erwähnten Testpyramide, wird die Teststrategie inkonsistent.

  • Bei SCC muss der Vertrag als Datei oder Skript außerhalb des JUnit-Tests gepflegt werden. Pact JVM hingegen bietet ein einfaches API, mit dem die Contract-Dateien programmatisch erzeugt werden können. In den Augen des Autors erleichtert dies das Schreiben und Pflegen des Vertrags, da Vertrag und Verifizierung Teil derselben Testklasse sind.

  • Pact bietet mit dem Pact Broker ein spezielles Repository für Verträge sowie ein dazugehöriges API. Das vereinfacht die Pflege der Verträge, die automatisch über Build Tasks an den Broker übermittelt werden. Darüber hinaus vereinfacht es die Integration in Deployment-Pipelines: Beim Veröffentlichen eines neuen Vertrags kann der Provider automatisch validiert werden. Neben der Testabsicherung beim Build kann außerdem beim Deployment sichergestellt werden, dass nur kompatible Producer- und Consumer-Versionen installiert werden (das Fehlschlagen von Producer-Tests verhindert nicht, dass der Consumer Build erfolgreich ist und ggf. automatisch deployt wird).

Kombination beider Frameworks im Kundenprojekt

Als Praxisbeispiel kann ein Kundenprojekt dienen. Dabei handelt es sich um eine E-Commerce-Plattform, die mit Spring Boot Microservices implementiert ist. Ein Großteil der internen Kommunikation ist asynchron mit RabbitMQ umgesetzt, einige Schnittstellen mit REST. Für CDC sind derzeit noch keine externen Clients vorgesehen, eine Anbindung soll aber möglich sein. Die Verwendung von SCC bietet sich also geradezu an, insbesondere aufgrund der guten Unterstützung von AMQP. Andererseits bietet ein Pact Broker neben der Infrastrukturunterstützung weitere Vorteile, wie Offenheit für weitere Nicht-JVM-Consumer. Daher wurde entschieden, die hier als Vorteile beschriebenen Features beider Frameworks zu nutzen, indem auf der Consumer-Seite Pact und auf der Provider-Seite SCC verwendet wird.

Ermöglicht wird dies über das Plug-in spring-cloud-contract-pact von Spring Cloud Contract, mit dem Pact-Verträge in SCC-Verträge und umgekehrt übersetzt werden können. Während die Struktur von Pact und SCC-Verträgen sehr ähnlich ist und die Übersetzung problemlos funktioniert, ergeben sich starke Abweichungen für Messaging Contracts. So kennt Pact zum Beispiel keine Möglichkeit, die Message Destination zu definieren. SCC Message Contracts erwarten darüber hinaus eine Triggermethode, für die es ebenfalls kein Konzept in Pact gibt. Diese wird vom providerStates-Feld des Message Pacts gemappt. Die Message Destination wird (ab der neuesten Releaseversion 2.1.0, die seit Januar 2019 verfügbar ist) in einem speziellen sentTo-Metadateneintrag in der Pact-Datei erwartet.

Ein weiterer Nachteil dieser Art von Verwendung von SCC auf Providerseite ist, dass SCC das Ergebnis der Verifikation nicht von allein an den Pact Broker schickt. Für das Publizieren des Producer-Testergebnisses kann eine JUnit Rule implementiert werden, die das Ergebnis über das Pact Broker API an den Broker schickt (ebenfalls Teil des GitHub-Beispiel-Repositorys [5]).

Für das Kundenprojekt wurde folgende Vorgehensweise gewählt:

  • Consumer Contracts werden mittels pact-jvm als JUnit-Test geschrieben und verifiziert.

  • Alle generierten Verträge werden bei der Ausführung als Jenkins-Job mit dem Plug-in pact gradle auf den Pact Broker publiziert.

  • Der WebHook des Pact Brokers ruft bei geänderten Verträgen den betroffenen Jenkins-Job-Provider auf. Dieser führt (unter anderem) die Verifizierung des Producers aus und publiziert das Ergebnis an den Broker.

  • Wenn der Vertrag auf Providerseite nicht validiert werden kann, schlägt der entsprechende Jenkins-Job fehl. Das erzwingt die Anpassung der Implementierung auf Producer-Seite.

Ein automatisiertes Deployment findet nur statt, wenn Consumer und Producer den aktuellsten Vertrag erfolgreich verifiziert haben. Hierfür muss neben den oben erwähnten Besonderheiten die Build-Tool-Konfiguration auf Producer-Seite angepasst werden (auf die Deployment Pipeline wird hier nicht eingegangen). Neben der Einbindung des Plug-ins muss im Contracts-Abschnitt nur der Pact Broker als Source für die Verträge konfiguriert werden, wie in Listing 16 dargestellt.

Listing 16

contracts {
  contractDependency {
    stringNotation = "${project.group}:book-order-service:+"
  }
  contractsPath = "*"
  contractsMode = "REMOTE"
  baseClassForTests = "de.sidion.books.order.contracts.OrderEventPactWithSccTest"
  contractRepository {
    repositoryUrl = "pact://http://localhost:80"
  }
}

Fazit

In diesem Artikel wurden die Funktionsweise und die Vorteile von SCC und Pact JVM demonstriert. Kurz zusammengefasst kann man den Vorteil von SCC in seiner Integration in das Spring-Ökosystem sehen, den Vorteil von Pact in seiner größeren Plattformunabhängigkeit und in Tools wie dem Pact Broker. Dennoch kann man mit etwas eigenem Aufwand beide Welten miteinander verbinden. Natürlich spielen die konkreten Anforderungen des Projekts eine Rolle. Möchte man z. B. in einem Spring-System keinen Pact Broker nutzen, ist es sinnvoller, nur SCC zu nutzen. Werden nur REST APIs verwendet, ist es praktisch kein Unterschied, welches Framework man wählt. Möchte man primär eine Lösung, um über RabbitMQ verschickte Nachrichten zu verifizieren, wäre SCC sicherlich auch hier das Mittel der Wahl. Kommen weitere Clients in Spiel, etwa JavaScript-Anwendungen, kann es sich lohnen, über eine Pact-Lösung oder ähnliche Kombinationen wie die hier vorgestellte nachzudenken.

Im Beispielprojekt auf GitHub [5] sind Implementierungen für jeden der hier dargestellten Fälle enthalten: Pact-Pact, SCC-SCC und Pact-SCC, jeweils für REST APIs und Message Contracts.

schuster_stephan_dr_sw.tif_fmt1.jpgDr. Stephan Schuster arbeitet als Architekt und Entwickler bei sidion und entwickelt seit über zehn Jahren Webanwendungen. Sein aktueller Schwerpunkt liegt auf Microservices-Architekturen.

Neue JDK-Releases waren einmal wahre Großereignisse: lange erwartet, nicht selten verspätet, und häufig mit einem umstrittenen Featureset. So dauerte es mal kürzer, mal länger, bis sich Kern-Java wieder einmal mehr in Richtung Gegenwart bewegte.

Nun ist das Drama vorüber, Javas Updates kommen berechenbar für alle im Sechsmonatsrhythmus. Oracles JDK-Team hat sich verpflichtet, in hoher Frequenz neue JDK-Releases zu servieren, auch mit dem Risiko, dass das eine oder andere Wunschfeature der Community dann einfach nicht reinkommt. Es kann dann sein, dass es im nächsten Release oder halt im übernächsten nachgereicht wird. Liefern ist im Zweifelsfall besser als Nichtliefern.

So entwickeln alle Beteiligten – das JDK-Entwicklungsteam wie auch die weltweite Community der JDK-Anwender – moderne Routinen für die schnelle Abfolge lauffähiger Java-Versionen. Das scheint angesichts tiefgreifender Änderungen der Art und Weise, wie wir teilweise schon heute, erst recht aber in Zukunft, Java-Anwendungen deployen (getrieben durch Docker, Kubernetes, Knative, AWS Lambda und neuerdings eventuell Quarkus), der richtige Weg zu sein.

Ob Sie nun selbst Ihre Produktivsysteme im halbjährlichen Rhythmus auf die jeweils aktuelle Java-Version aktualisieren oder sich vielmehr auf einen der Anbieter langlebiger, stabiler Java-Versionen verlassen – wir haben es mit einem Java zu tun, das sich derzeit deutlich erfrischt!

Denn noch ein weiteres Merkmal ist neu: Gab es bislang praktisch nur eine relevante Java Runtime, gibt es nun eine Auswahl an verschiedenen kostenpflichtigen und kostenlosen Java-Laufzeitumgebungen. Wir haben Ihnen zu diesem Thema eine kompakte Übersicht erstellt (S. 36). Lesen Sie zu Java 12 auch unsere Beiträge auf den Seiten 26 und 30 der vorliegenden Ausgabe des Java Magazins und verschaffen Sie sich einen Überblick über alles Neue mit unserer Infografik auf Seite 34.

Und da ich oben das Stichwort schon gegeben habe: Red Hat hat vor Kurzem mit seinem Open-Source-Framework Quarkus einen interessanten Ansatz für die Zukunft Javas vorgestellt. Als „Container First“-Ansatz soll Quarkus eine Start-up-Zeit im Millisekundenbereich vorweisen und so die bekannten Defizite im Cloud/Container/Microservices-Bereich eliminieren. Als eigener Stack, der auf der GraalVM sowie etlichen MicroProfile-APIs basiert, könnte das Projekt der Java-Welt mehr als nur einen spannenden Impuls geben …

Sie sehen: Die alte Java-Welt bleibt ganz schön in Bewegung. Gut für Java. Gut für uns.

meyen_sebastian_sw.tif_fmt1.jpgSebastian Meyen | Chefredakteur

Die HotSpot Java VM ist nun gut zwei Jahrzehnte alt. Seitdem hat sich die Welt der Softwareentwicklung stark verändert. Zeit also, HotSpot in Rente zu schicken? Seit Längerem arbeiten die Oracle Labs an einem neuen Compiler für die JVM, der polyglott und hochperformant sein soll.

Seit einigen Monaten stellt Oracle der Allgemeinheit die ersten Release Candidates der GraalVM, einer neuen virtuellen Maschine, zur Verfügung (zum Zeitpunkt der Niederschrift des Artikels ist RC10 aktuell). Wenn eine Software mit ihrem Namen Bezug auf den heiligen Gral nimmt, legt das die Messlatte gleich um einige Zentimeter höher. Erinnern wir uns: Der heilige Gral verspricht seinem Besitzer nicht weniger als Lebenskraft, Jugend, Nahrung in Hülle und Fülle sowie Glückseligkeit und damit alles in allem quasi die Unsterblichkeit. Entsprechend lang ist auch die Featureliste der GraalVM, die aus einer Reihe von Forschungsprojekten am Institut für Softwaresysteme der Linzer Johannes-Kepler-Universität in enger Zusammenarbeit mit den Oracle Labs hervorgegangen ist.

Nicht ohne Grund ist im Namen nur die Abkürzung VM enthalten und nicht JVM, denn auf einer anderen Architektur kann sie nicht nur Java-Bytecode ausführen, sondern ist eine echte polyglotte virtuelle Maschine, auf der prinzipiell jede Programmiersprache ausgeführt werden kann. Damit stellt die GraalVM etwas Neues dar. Denn auch wenn schon seit Langem der Begriff polyglott für die JVM in Bezug auf Sprachen wie Groovy, Kotlin oder auch JRuby verwendet wird, ist die JVM selbst nicht polyglott: Sie selbst kann nur Bytecode ausführen. Dass es Compiler gibt, die für eine bestimmte Sprache Bytecode für die JVM erzeugen können, hat nichts mit der JVM zu tun, sondern erlaubt es uns Entwicklern nur, mit anderen Sprachen für die JVM entwickeln zu können. Die GraalVM hingegen lässt solche Grenzen hinter sich und ist als universelle Virtual Machine ausgelegt. So unterstützt sie alle Sprachen, für die es einen Bytecode-erzeugenden Compiler gibt, sprich, wer mit Java, Scala, Clojure und Co. arbeitet, kann jetzt schon die GraalVM nutzen. Derzeit unterstützt sie Java bis Version 8; Java 11 und höher sollen später folgen. Neben diesen Sprachen für die JVM können auch Programme in der Statistiksprache R, Python, Ruby, JavaScript sowie LLVM-Bitcode durch die GraalVM ausgeführt werden.

Aber warum braucht es überhaupt eine neue virtuelle Maschine, wenn es doch schon andere gibt? Sicherlich gibt es nicht den einen Grund, und – wie bei einigen Projekten – auch nicht den großen Plan am Anfang. Wohl eher flossen Möglichkeiten und Bedürfnisse zum richtigen Zeitpunkt zusammen. In den vergangenen Jahren sind viele neue Sprachen auf der Bühne der Softwareentwicklung erschienen. Dabei geht es nicht um exotische Sprachen oder Projekte ohne praktische Relevanz für die tägliche Arbeit. Vielmehr geht es um Sprachen wie Scala oder Kotlin, die anderen Paradigmen folgen, oder Sprachen wie Go und Rust, die von Organisationen vorrangig für ihre eigenen Bedürfnisse entwickelt wurden. Die Motivation für viele JVM-basierte Sprachen war es, hinsichtlich der Sprachfeatures in einigen Bereichen besser als Java zu sein und Dinge zu vereinfachen. In anderen Bereichen stellte Java bis jetzt nie eine echte Alternative dar. Sei es, weil die Installation des Java Runtime Environments (JRE) zusammen mit der Anwendung oft zu aufwendig, sei es weil die Start-up-Zeit der Java Virtual Machine vor dem jeweiligen Anwendungshintergrund nicht praktikabel ist. Aber auch Trends in der Softwarearchitektur, wie zum Beispiel Serverless Computing oder schnell skalierende Cloudarchitekturen, bei denen es nicht auf maximalen Durchsatz, sondern auf schnelle Startzeiten ankommt, setzen Java unter Druck. Projekte wie Kotlin/Native bestätigen das ebenso wie der Erfolg von Go in der Systementwicklung.

Die Entwicklung von Java war bis Java 9 eher behäbig als schnell, und der Innovationsdruck ist, wie gerade gezeigt wurde, hoch. Java als weitverbreitete Sprache weiterzuentwickeln ist nicht einfach, denn Änderungen an der Sprache können auch Änderungen an anderen Stellen des JDKs, beispielsweise der JVM, erforderlich machen. Dabei hat HotSpot ein Problem: HotSpot ist in C++ geschrieben, ist komplex und hat eine inzwischen sehr alte Codebasis. Zwar hat sich auch C++ weiterentwickelt, doch eine bestehende Codebasis zu modernisieren, ist aufwendig. Zudem wird C++ immer weniger an den Universitäten gelehrt. Damit ist es schwieriger, auch Nachwuchs für die Arbeit an HotSpot zu finden.

Bezug und Installation

Oracle stellt zwei Varianten der GraalVM bereit: die kostenfrei einsetzbare Community Edition und die kostenpflichtige Enterprise Edition. Beide Varianten sind derzeit nur für macOS und Linux verfügbar. Erwartungsgemäß sind bestimmte Features der Enterprise Edition vorbehalten. So kann von mit der Enterprise Edition erzeugten Native Binarys ein Heapdump gezogen und auf DWARF-Debugging-Informationen zugegriffen werden. Zudem kann die Enterprise Edition bessere Optimierungen vornehmen als die Community Edition. Die Community Edition ist über die Projektwebseite erhältlich, die Enterprise Edition über das Oracle Technology Network. Beide Editionen können als .tar.gz-Datei heruntergeladen werden. Zur Installation ist es daher ausreichend, das jeweilige Archiv zu entpacken und die Variable JAVA_HOME auf das entpackte Verzeichnis zu setzen.

Rückblick

Mit Java 1.3 hielt im April 1999 die HotSpot JVM Einzug in die damals noch sehr junge Java-Welt. Java 1.0 war drei Jahre zuvor erschienen, kannte damals weder innere Klassen noch Reflection. Das Collections Framework kam erst in Java 1.2 hinzu. Diese Version war auch die erste Version mit einem Just-in-Time-Compiler (JIT) für Bytecode, der vorher nur interpretiert ausgeführt wurde. Das liegt jetzt gut zwanzig Jahre zurück und wahrscheinlich ist der eine oder andere Leser zu dieser Zeit noch gar nicht auf der Welt gewesen. Vorherrschend für die Anwendungsentwicklung waren zu dieser Zeit C und C++. Viele Geschäftsanwendungen wurden auch mit Borlands Delphi entwickelt, und das Internet wurde langsam von einer breiteren Gruppe wahrgenommen.

Die meisten Java-Programmierer der ersten Stunde waren der Dominanz von C und C++ entsprechend vorher C/C++-Programmierer, denen der Umstieg auf Java wegen der großen Ähnlichkeit der Sprachen leichtfiel. Aufgrund ihrer Verbreitung und der hohen Geschwindigkeit von C und C++ wurde auch die HotSpot JVM in C++ mit Assemblerteilen geschrieben. Der damals geschriebene Java-Code unterschied sich auch von Code, wie er heute geschrieben würde. Wo heute Streams zum Einsatz kämen, herrschten damals for-Schleifen, und wenn es performancekritisch wurde, galt es, die Erzeugung von neuen Objekten zu vermeiden oder sie zu cachen. Die Einführung der HotSpot JVM brachte hier durch die Just-in-Time-Compilation eine wesentliche Verbesserung. Vor allem die von HotSpot durchgeführten dynamischen Optimierungen wie beispielsweise Inlining, Dead Code Elimination oder Loop Unrolling trugen wesentlich zu einer besseren Performance bei. Java-Code mit aktuellen Features wie Autoboxing, Lambdas und Streams steigern auf der einen Seite sowohl die Eleganz als auch die Expressivität des Codes, sind aber auf der anderen Seite auch langsamer als klassische Konstrukte wie beispielsweise for-Schleifen. Grund hierfür ist, dass im Hintergrund oft viele kurzlebige Objekte erzeugt werden, die anschließend wieder weggeräumt werden müssen. Denn auch die Tatsache, dass die Garbage Collectors leistungsfähiger und die Erzeugung von Objekten immer billiger geworden sind, ändert an diesem Problem nichts.

Somit werden Techniken wichtiger, die stärker auf die Optimierung von aktuell erzeugtem Bytecode einzahlen oder am besten die Erzeugung von neuen Objekten im Heap gleich ganz vermeiden. Grundlage hierfür ist beispielsweise die sogenannte Escape-Analyse. Dabei werden die möglichen Ausführungspfade von einem Codeabschnitt daraufhin untersucht, ob erzeugte Objekte den aktuellen Scope verlassen können. Ein Beispiel hierfür ist das Hinzufügen eines neuen Objekts in eine als Parameter übergebene Map oder dessen Rückgabe als Return-Wert. In diesen Fällen kann das Objekt auch nach der Beendigung einer erzeugenden Methode weiter existieren und verwendet werden. Kann dies durch statische Codeanalyse ausgeschlossen werden, kann in der untersuchten Methode die Objekterzeugung eliminiert und statt mit Objekten mit Variablen gearbeitet werden. Im besten Fall würde es sich um Variablen für primitive Typen handeln, die nur auf dem Stack lägen. Doch selbst wenn es sich bei den Variablen um Referenzen auf Objekte im Heap handeln würde, käme eine solche Optimierung der Garbage Collection zugute, da die zu analysierenden Strukturen einfacher wären.

Die Architektur von GraalVM

Ehe wir uns den Interna der GraalVM zuwenden können, ist es wichtig, sich noch einmal mit dem Aufbau des Java Development Kits (JDK) und der JVM auseinanderzusetzen. Das JDK umfasst sowohl alle für die Entwicklung als auch die Ausführung von Java-Programmen benötigten Komponenten. Für die Entwicklung stellt das JDK Werkzeuge wie den Java-Compiler javac und andere Tools bereit. Für die Ausführung ist hingegen die Java Runtime Environment (JRE) zuständig, die entweder stand-alone oder als Teil des JDKs verfügbar ist. Herzstück der JRE ist die HotSpot JVM (Java). Die JVM (Abb. 1) ist eine Implementierung der Java Virtual Machine Specification und besteht selbst aus mehreren Komponenten. Das Class-Loader-Subsystem ist zuständig für das Laden, Verifizieren und Linken von Class-Dateien und die dann erfolgende Initialisierung von statischen Feldern sowie die Ausführung von statischen Codeblöcken. Die Runtime Data Areas enthalten vereinfacht gesagt alle für die Ausführung notwendigen Daten wie den Heap oder den Stack des aktuellen Th reads. Die Execution Engine stellt den Interpreter, den JIT-Compiler sowie den Garbage Collector bereit. Im Kontext der GraalVM ist der JIT-Compiler von besonderem Interesse. Java-Code wird anfänglich nur durch den Interpreter in der Execution Engine ausgeführt. Das bedeutet, dass der Java Bytecode ohne jegliche Optimierung eins zu eins in Maschinencode umgesetzt und ausgeführt wird. Sind während der Ausführung genügend Informationen vom Profiler gesammelt und ist eine Methode hinreichend oft ausgeführt worden, beginnt die Arbeit des JIT-Compilers. Aufgrund der gesammelten Profiling-Daten kann der JIT-Compiler entscheiden, wie die Methode optimiert werden kann, ehe sie in Maschinencode übersetzt wird. Die HotSpot JVM enthält zwei JIT-Compiler: C1, einen schnellen und nur leicht optimierenden Compiler, ursprünglich gedacht für Desktopanwendungen, und C2, einen aggressiv optimierenden Compiler für Serveranwendungen, bei denen es auf höchsten Durchsatz ankommt.

fischer_graalvm_1.tif_fmt1.jpgAbb. 1: Schematische Darstellung der JVM

Lange waren diese beiden Compiler der HotSpot JVM nicht austauschbar. Mit dem Java Enhancement Proposal 243 [1] wurde 2014 die Schaffung eines Java Virtual Machine Compiler Interface (JVMCI) vorgeschlagen, um andere, selbst in Java geschriebene Compiler nutzen zu können. Neben der reinen Modularisierung würde so sowohl die Leistungsfähigkeit von Java gezeigt als auch eine breitere Basis für Entwicklungsarbeiten an der JVM geschaffen.

Anders als der Name es vermuten lässt, ist das Herzstück der GraalVM keine eigene virtuelle Maschine, sondern vielmehr der selbst in Java geschriebene und hoch optimierende Graal-Compiler. Die GraalVM nutzt als Grundlage OpenJDK, in das es über das JVMCI den Graal-Compiler integriert, der dort C2 ersetzt. Der Name GraalVM ist wohl daher eher aus Marketinggründen gewählt worden, richtig wäre wohl eher „OpenJDK/Oracle JDK with Graal Compiler and additional Tooling“.

Damit ein Compiler unterschiedliche Sprachen unterstützen kann, muss dieser mit einer sprachunabhängigen Zwischenrepräsentation zwischen der Quellsprache und dem zu erzeugenden Maschinencode arbeiten. Im Fall von Graal wurde als Format für diese Zwischenpräsentation ein Graph [2] gewählt. Der wesentliche Vorteil eines Graphen ist, dass sich ähnliche Statements verschiedener Sprachen gleich darstellen lassen. foreach-Schleifen in Python und Java lassen sich im Graphen gleich darstellen, ebenso wie ein if-Statement in fast jeder Sprache. Durch diese sprachunabhängige Darstellung ist es auch möglich, mehrere Sprachen im gleichen Programm zu verwenden. Um sie aus Sicht des Compilers als ein Programm zu behandeln, ist es lediglich notwendig, aus ihnen einen gemeinsamen Graphen zu erzeugen. Auf diesem Graphen kann dann sprachunabhängig optimiert und Maschinencode erzeugt werden.

Der Graal-Compiler ist nur eine, wenn auch zentrale Komponente für die GraalVM. So stellt sie den LLVM Bitcode Interpreter Sulong bereit, wodurch es möglich ist, jede Sprache, für die es ein LLVM Frontend gibt, mit der GraalVM auszuführen. Die SubstrateVM erlaubt es, aus Java-Programmen via Ahead-of-Time-(AOT-)Compilation Native Binaries zu generieren. Das ebenfalls in Java geschriebene Truffle Framework, das die Grundlage für die Unterstützung anderer Sprachen ist, stellt ein API bereit, über das Interpreter für Programmiersprachen gebaut werden können, die anschließend mittels der GraalVM ausführbar sind. Durch die Ausführung durch die GraalVM profitieren die so unterstützten Sprachen auch von den Optimierungsmöglichkeiten des Graal-Compilers. Abbildung 2 zeigt das Zusammenspiel der einzelnen Komponenten.

fischer_graalvm_2.tif_fmt1.jpgAbb. 2: Aufbau der GraalVM

Performance im Vergleich

Für das Versprechen, unabhängig von der eigentlichen Quellsprache immer eine hohe Ausführungsgeschwindigkeit zu erreichen, sind die Optimierungsmöglichkeiten des Graal-Compilers ausschlaggebend. Um die so erreichbaren Performanceeigenschaften der GraalVM mit denen anderer JVMs zu vergleichen, dient das Programm Top Ten von Chris Seaton [3]. Es ermittelt aus einer ca. 144 MiB großen Textdatei die zehn häufigsten Wörter unter Nutzung von Javas Streaming-API und wurde für die Messung in einen JMH-Benchmark umgeschrieben (Listing 1).

Listing 1

public class TopTenBenchmark {
private Stream<String> fileLines(String path) {
  try { return Files.lines(Paths.get(path));
    } catch (IOException e) { throw new RuntimeException(e); }
  }
@BenchmarkMode(Mode.SampleTime)
@Benchmark
public void topten(Blackhole blackhole) {
  Arrays.stream(new String[]{"large.txt"}
    .flatMap(this::fileLines)
    .flatMap(line -> Arrays.stream(line.split("\\b")))
    .map(word -> word.replaceAll("[^a-zA-Z]", ""))
    .filter(word -> word.length() > 0)
    .map(String::toLowerCase)
    .collect(Collectors.groupingBy(Function.identity(), 
               Collectors.counting()))
    .entrySet().stream()
    .sorted((a, b) -> -a.getValue().compareTo(b.getValue()))
    .limit(10)
    .forEach(e -> blackhole.consume(format("%s = %d%n", e.getKey(), e.getValue())));
  }
}

Für die Ausführung des Benchmarks, dessen Ergebnisse in Tabelle 1 für die unterschiedlichen JDK-Versionen zusammengefasst wurden, kam ein MacBook Pro 2017 mit einem 2,5 GHz Intel Core i7 zum Einsatz.

JDK

Zeit in Sekunden

Oracle JDK 8u192

15,801

OpenJDK 8u192

15.250

GraalVM 1.0 CE RC 10

13.814

GraalVM 1.0 EE RC 10

9.867

OpenJDK 11 (Open 9)

26,268

OpenJDK 11

16,920

Oracle JDK 11

17,102

Tabelle 1: Ausführungszeit des Top-Ten-Benchmarks für unterschiedliche JDKs

Für jedes verglichene JDK wurde der Benchmark mit fünf Durchläufen zum Aufwärmen und fünf Messdurchläufen ausgeführt. Im Fall der GraalVM muss noch bedacht werden, dass die JVM hier selbst noch den in Java geschriebenen Graal-Compiler übersetzen muss. Für das gewählte Beispiel erreichen die beiden GraalVM-Editionen die beste Ausführungsperformance. Die Community Edition benötigt rund 14 Sekunden, die Enterprise Edition schlägt diesen Spitzenwert mit 10 Sekunden nochmals um 4 Sekunden. An letzter Stelle liegt OpenJDK 11 mit der Open9 JVM mit einem Abstand zum besten Wert von 18 Sekunden. Diese Werte können und sollen nicht verallgemeinert werden, zeigen aber doch, um welche Größenordnungen die einzelnen virtuellen Maschinen auseinander liegen können. Dass die Enterprise Edition der GraalVM deutlich schneller ist als die Community Edition, zeigt, dass Oracle hier bewusst eine Trennlinie zwischen dem zieht, was kostenfrei und was kostenpflichtig zu bekommen ist.

Native Binaries erzeugen

Der Wunsch, Java-Programme als richtiges Native Binary vom Betriebssystem ohne Umweg über die Java Virtual Machine ausführen zu können, existiert schon lange. Java-Programme könnten so leichter verteilt und installiert werden, da keine in einem zusätzlichen Schritt auszuführende JRE-Installation mehr notwendig ist. Auch müssten keine .jar-Dateien von benötigten Bibliotheken mehr verteilt und der entsprechende Klassenpfad für die Ausführung bestimmt werden.

Weiterhin hätte ein Native Binary den Vorteil, schneller zu starten, da es bereits vor der Ausführung in Maschinencodeform übersetzt worden ist (AOT-Compilation) und nicht erst zur Laufzeit noch durch den JIT-Compiler in solchen übersetzt werden muss. Damit ist das Performanceverhalten von Native Binaries konstant und vorhersagbar, denn der Code wird nicht mehr zur Laufzeit durch den JIT-Compiler im Zusammenspiel mit dem Profiler optimiert. Native Binaries brauchen auch wesentlich weniger Speicher, denn die Infrastruktur der JVM für den JIT-Compiler muss nicht mitlaufen.

An einer Verbesserung der Start-up-Zeit hat Oracle in verschiedenen JDK-Versionen durch Techniken wie CDS (Class Data Sharing), AppCDS (Application Class Data Sharing) und dem mit Java 9 inoffiziell eingeführten AOT-Compiler jaotc, der selbst eine frühere Version des Graal-Compilers nutzt, gearbeitet. Doch bei jeder dieser Techniken wurde auch weiterhin ein installiertes JRE benötigt, und auch wenn messbare Verbesserungen bei der Startgeschwindigkeit festgestellt werden konnten, waren diese nicht signifikant.

Für die Erzeugung von Native Binaries kommt mit der GraalVM das Programm native-image, mit dem einzelne .jar- oder .class-Dateien in Maschinencode übersetzt werden können. Die Herausforderung bei Java-Programmen, wie bei allen JDK-basierten Sprachen, liegt in der Möglichkeit, Klassen zur Laufzeit nachzuladen. Dieses mächtige Feature wird oft auch benutzt, um zur Laufzeit zu bestimmen, welche Frameworks genutzt werden können. So könnte ein Programm zur Laufzeit testen, welches Logging Framework im Klassenpfad liegt, und abhängig vom Ergebnis dieser Suche sein Logging konfigurieren. Das verbreitetste Beispiel für diese Art der Konfiguration zur Laufzeit dürfte das Spring Framework sein. Dynamisches Verhalten wie dieses stellt für die Ahead-of-Time-Compilation eine große Herausforderung dar.

native-image, das intern für die Übersetzung auch den Graal-Compiler verwendet, analysiert daher während der Übersetzung alle möglichen Pfade, die das Programm bei einem gegebenen Klassenpfad durchlaufen kann. Diese Analyse führt dazu, dass eine Übersetzung, verglichen mit javac oder dem LLVM-Compiler clang, wesentlich länger dauert. Dafür wird zuverlässig bestimmt, welche Klassen benötigt werden und welche nicht. Mittels native-image erzeugte Programme sind daher auch um Größenordnungen kleiner (meist nur ein paar Megabyte) als die Summe aus JDK/JRE und benötigten Bibliotheken. Normalerweise müssen Java-Programmierer sich nicht um Thread- und Speichermanagement kümmern, dies übernimmt die JVM für sie automatisch. Auch im Fall von Native Binaries bleibt das so. Hierfür sorgt die SubstrateVM, die selbst in Java geschrieben ist und von native-image in das erzeugte Binary mit hineinkompiliert wird.

Doch es gibt auch Besonderheiten bei der Erzeugung von Native Binaries zu beachten. Oft wird in Java die statische Initialisierung genutzt, also die Möglichkeit, statische Felder direkt beim Laden der Klasse durch eine direkte Zuweisung oder in einem statischen Block zu erstellen. native-image führt standardmäßig statische Initialisierungen während der Übersetzung aus, was dazu führt, dass die erzeugten Programme bei jeder Ausführung mit den immer exakt gleichen Werten laufen. So kann die Start-up-Zeit verbessert werden.

Werden während der statischen Initialisierung nur Listen mit Konstanten gefüllt, ist dies unkritisch. Werden jedoch laufzeitabhängige Werte wie ein Datum zugewiesen, wird das Programm immer genau mit diesem Datumswert arbeiten.

Listing 2

public class DaysUntilChristmas {
  private static LocalDateTime now = LocalDateTime.now();
 
  public static void main(String[] args) {
    LocalDateTime christmas = LocalDateTime.of(now.getYear(), 12, 1, 0, 0);
    Duration timeLeft = Duration.between(now, christmas);
    System.out.println("Weihnachten ist in " + timeLeft);
  }
}

Listing 3

> export JAVA_HOME=/opt/graal/graalvm-ce-1.0.0-rc10/Contents/Home/
> mkdir out
> $JAVA_HOME/bin/javac -d out src/main/java/net/sweblog/playground/graal/weihnachten/DaysUntilChristmas.java
> $JAVA_HOME/bin/native-image --no-server -H:Name=duc -cp out net.sweblog.playground.graal.weihnachten.DaysUntilChristmas
[duc:41559]      classlist:   1,426.77 ms
[duc:41559]         (cap):      855.38 ms
[duc:41559]         setup:   2,057.42 ms
[duc:41559]   (typeflow):   2,604.42 ms
[duc:41559]     (objects):      617.27 ms
[duc:41559]    (features):      109.90 ms
[duc:41559]      analysis:    3,389.01 ms
[duc:41559]      universe:      165.76 ms
[duc:41559]       (parse):      588.47 ms
[duc:41559]       (inline):   1,256.12 ms
[duc:41559]    (compile):   5,068.60 ms
[duc:41559]      compile:   7,150.38 ms
[duc:41559]        image:      401.07 ms
[duc:41559]          write:      154.97 ms
[duc:41559]        [total]:  14,988.09 ms
> ./duc && sleep 5 && ./duc
Weihnachten ist in PT7516H23M23.698S
Weihnachten ist in PT7516H23M23.698S

Listing 2 zeigt ein als Beispiel dienendes Programm, das die verbleibende Zeit bis Weihnachten berechnet. Listing 3 zeigt, wie dieses Programm übersetzt und zweimal ausgeführt wird. Dass das Feld now bei jeder Ausführung den gleichen Zeitstempel hat, ist daran ersichtlich, dass die verbleibende Zeit immer die gleiche ist.

Noch ein anderes Java-Feature stellt die Ahead-of-Time-Übersetzung vor Probleme: Reflection. native-image versucht hier so gut wie möglich, automatisch bei der Analyse des zu übersetzenden Bytecodes das Ergebnis des jeweils genutzten Reflection API zu bestimmen. Möglich ist das, wenn das Ergebnis konstant ist, beispielsweise wenn bei einem Aufruf von Class.forName(String className) im Rahmen der statischen Analyse ermittelt werden kann, dass der Wert von className immer derselbe, also konstant ist. In diesem Fall kann die Anweisung beispielsweise so umgeschrieben werden, als ob für die bestimmte Klasse direkt newInstance() aufgerufen werden würde. Jede Nutzung von Reflection, deren Ergebnis nicht exakt vorausbestimmt werden kann, ist ein Fehler.

Für jeden dieser einschränkenden Punkte steht aber auch eine Lösung. Kann oder soll die statische Initialisierung von Werten nicht vermieden werden, verfügt native-image über die Option --delay-class-initialization-to-runtime, mit der eine kommaseparierte Liste von Klassen angegeben werden kann, deren Initialisierung doch erst zur Laufzeit durchgeführt werden soll. Erlangt die GraalVM eine größere Verbreitung, dürfte wohl aber auch die statische Initialisierung von laufzeitabhängigen Feldern zukünftig als Bad Practice gelten.

Ebenso können alle für die korrekte Unterstützung von Reflection notwendigen Informationen entweder über Konfigurationsdateien oder programmatisch mittels des für die SubstrateVM und die GraalVM verfügbaren API spezifiziert werden. Mittels dieses API können auch fremde Anwendungen wie Netty oder Tomcat so angepasst werden, dass aus ihnen auch Native Binaries erzeugbar sind.

Für wen sind Native Binaries interessant? Für jeden, der auf den Write-once-run-anywhere-Anspruch von Java verzichten kann oder dem eine schnelle Start-up-Zeit wichtiger ist als ein maximaler Durchsatz. Diesen zu erreichen, ist nur mittels der Kombination aus Laufzeit-Profiling und JIT-Compiler möglich. Wie groß der Unterschied zwischen AOT und JIT sein kann, zeigt Listing 4. Hier ist die Ausführungszeit von Top Ten als normales Programm bei einer einzelnen Ausführung mittels der GraalVM und als Native Binary über die Befehlszeile zu sehen.

Listing 4

> /usr/bin/time $JAVA_HOME/bin/java -cp target/classes net.sweblog.playground.graal.TopTen 1>&-
       16.25 real        22.21 user         0.67 sys
> /usr/bin/time ./TopTen 1>&-
       36.25 real        35.99 user         0.18 sys

Als Native Binary läuft Top Ten mehr als zwanzig Sekunden länger als bei einer Ausführung durch die GraalVM. Dass die Werte für real und user auch nahe beieinander liegen, lässt vermuten, dass nicht mehrere Threads bei der Ausführung genutzt werden. Auf der anderen Seite braucht ein als Native Binary ausgeführtes Hello World nicht länger als sein C-Pendant. Damit eröffnen Native Binaries endlich die Möglichkeit, Java in der Systementwicklung einzusetzen – ein Bereich, für den Java vorher nie in Betracht gezogen wurde. Hier lag bei Java die effektive Laufzeit bisher nie in einem sinnvollen Verhältnis zur Start-up-Zeit der JVM.

Fazit

Ziel war es, eine allgemeine Einführung in die GraalVM für Java-Entwickler zu geben sowie ihre Performanceeigenschaften und die Möglichkeit zu zeigen, Native Binaries zu generieren. Andere Features wie die Ausführung von Python oder Ruby sowie polyglotte Entwicklung hätten den Rahmen dieses Artikels gesprengt und sind ein eigenes Thema.

Selbst wenn die GraalVM lediglich wie eine normale JVM genutzt wird, verweist sie allein bei diesem Einsatzszenario andere JVMs auf die hinteren Ränge. Zusammen mit der Möglichkeit, sie auf Grundlage des Truffle Frameworks als Plattform für Projekte mit den bereits mehrfach genannten Sprachen zu nutzen, selbst domainspezifische Sprachen mit vergleichsweise geringem Aufwand implementieren und polyglotte Anwendungen entwickeln zu können, stellt die GraalVM einen Durchbruch dar, dessen Auswirkung auf die Softwareentwicklung im Moment noch nicht abschätzbar ist. Oracle dürfte damit die Grundlage für die nächsten zwanzig Jahre Softwareentwicklung mit Java gelegt haben, die wahrscheinlich mehr zur Zukunft von Java beiträgt als manches Sprachfeature, über das gerne leidenschaftlich diskutiert wird. Bleibt zum Schluss nur der Wunsch nach einem baldigen Final Release und einer kontinuierlichen Weiterentwicklung.

fischer_oliver_sw.tif_fmt1.jpgOliver B. Fischer arbeitet bei der E-Post Development GmbH und organisiert die JUG Berlin-Brandenburg.