Automatisiere so viel wie geht

Vert.x im Produktionsbetrieb: Ein Erfahrungsbericht

Björn Stahl

© iStockphoto.com/bubaone

In den letzten zwei Jahren haben wir umfangreiche Erfahrungen mit Vert.x, Docker, Jetty und vielen Managed Services von Amazon Web Services beim Bau einer reaktiven Microservices-Plattform gesammelt. Wir haben gelernt, dass messen besser ist als schätzen und Performance relativ ist. Außerdem skalieren entkoppelte System gut, sind aber nicht trivial zu implementieren. Automatisierte Infrastrukturtests sind eine Investition, die sich mittelfristig auszahlt und „embrace failure“.

Es war von Anfang an unser Ziel, die Architektur unseres neuen Kernsystems „Cloud-native“ als verteiltes, leichtgewichtiges System zu entwickeln. Dies sollte jedoch in einer Form geschehen, bei der wir uns nicht gänzlich einer bestimmten Plattform verschreiben. Einen vollständig anbieteragnostischen Ansatz umzusetzen, ist selten sinnvoll, wir wollten aber zumindest in der Lage sein, das Projekt jederzeit sowohl in der Cloud als auch in unserem eigenen Rechenzentrum laufen zu lassen. Dies hat zudem den Vorteil, dass wir Testsysteme in unserer eigenen Umgebung aufsetzen und das Verhalten der Applikationen beobachten können.

Darüber hinaus sind Testergebnisse so deutlich schneller sichtbar, da durch das gewählte Infrastrukturpattern „Immutable Infrastructure“ das Deployment in der Cloud länger dauert als im eigenen Rechenzentrum: „Immutable Infrastructure“ bedeutet, dass Infrastruktur nicht im laufenden Betrieb aktualisiert wird. Jede neue Applikationsversion und jedes Deployment erzeugt neue Infrastrukur in Form von EC2-Instanzen. Die alte Applikationsversion bleibt noch eine gewisse Zeit bestehen. Sie kann beispielsweise aus dem Load Balancer entfernt werden, aber immer noch weiterlaufen. Erst nachdem alle Tests zufriedenstellend abgeschlossen worden sind, wird diese gelöscht. Dieser Ansatz ist radikal anders als traditionelle Server, auf denen beispielsweise Patches für das Betriebssystem eines laufenden Servers eingespielt werden. Falls jetzt ein Server ausfällt, lässt sich dieser sehr einfach durch einen neuen ersetzen. Bill Baker von Microsoft hat diesen Ansatz folgendermaßen beschrieben: „Managing your servers like cattle – you number them, and when they get sick and you have to shoot them in the head, the herd can keep moving.“ Der einzelne Server ist nicht mehr so wichtig. Was zählt, ist eine ausreichende Gesamtzahl, um die Requests beantworten zu können.

So viel wie möglich automatisieren

Was uns das Leben signifikant vereinfacht hat, war der hohe Grad an Automatisierung, den wir implementiert haben. Von Anfang an wurde so viel wie möglich automatisiert und sukzessiv verbessert. Ziel war es, die Infrastruktur vollständig über API-Aufrufe aufzusetzen, um die Fehlerrate zu minimieren. Dies ermöglichte das frühe Entdecken von Fehlern und auch Unstimmigkeiten in der Zielarchitektur. Darüber hinaus lässt sich die Infrastruktur durch den hohen Automatisierungsgrad auch leicht in anderen Regionen der Welt und unter anderen Accounts (für das Testsystem) aufsetzen.

Bei zanox haben wir bereits seit einigen Jahren viel Erfahrung in Bezug auf die Automatisierung von Deployments und seit Kurzem auch mit dem Betrieb von Docker gesammelt. Das Bereitstellen von Servern, auf denen nur Docker-Container ausgerollt werden, hat den Vorteil, dass die Entwickler deutlich mehr Freiheitsgrade bei der Wahl der Werkzeuge und Frameworks haben und nicht mehr so stark von IT-Ops abhängig sind. Jeder Container ist autonom und kann auf jeder Umgebung (Preproduction und Production) deployt werden – im Idealfall ohne Modifikation. Im zanox-Rechenzentrum wird Docker über ein Chef-Cookbook installiert, bei AWS hingegen über ein EC2-User-Data-Skript, das beim Bootstrapping der EC2-Instanz die notwendigen Pakete installiert und den Service startet.

Unabhängigkeit ist nicht einfach

Die Architektur unseres Produkts basiert auf den Austausch von Messages zwischen den einzelnen Microservices. Ein fundamentaler Bestandteil unserer Gesamtarchitektur ist das verteilte Messagingsystem Apache Kafka. AWS bietet dafür einen ähnlichen Managed Service namens Amazon Kinesis Streams an, der sich sehr gut für die Verarbeitung von Echtzeitdaten wie Clickstreams eignet. Gleiches gilt für Redis: Auch hier bietet AWS einen Managed Service namens Amazon ElastiCache an, der neben Redis auch Memcache als In-Memory-Cache bietet.

Damit die erwähnte Unabhängigkeit erreicht werden kann, mussten wir uns etwas einfallen lassen. Das erste, deutlich einfachere Problem war die Unterscheidung zwischen ElastiCache und Redis in unserem Rechenzentrum und in AWS. Beide Implementierungen werden über einen URL mit Port angebunden und kommunizieren über das Protokoll von Redis. Aus diesem Grund ließ sich hier einfach mit unterschiedlichen Properties-Dateien arbeiten. Jede unserer Stages hat einen eigenen eindeutigen Namen, der beim Deployment abgefragt wird. Diesen Stage-Parameter haben wir in den Docker-Containern als Unix-Umgebungsvariable der Applikation übergeben. Dadurch konnten wir das erste Problem sehr elegant umschiffen.

Das zweite Problem, die Einbindung von Kafka/Kinesis, war etwas schwieriger zu lösen. Glücklicherweise bietet Vert.x 2 die Möglichkeit, dynamisch Module zu laden. Wir haben im initialen Bootstrapping der Applikation einen Mechanismus verankert, der im Starter Verticle, das alle anderen Verticles lädt, abhängig von der Umgebung das entsprechende Modul lädt. Die Module laden eigenständig die Properties, die für die jeweilige Umgebung gebraucht werden.

Die Probleme der Unabhängigkeit waren somit gelöst. Einzig die Nachricht, dass Vert.x 3 keine Modulunterstützung mehr anbietet, hat anfänglich zu etwas Kopfschmerzen geführt. Doch auch für dieses Problem existiert eine Lösung: Die Logik für das Senden nach Apache Kafka oder Amazon Kinesis wurde in entsprechende Verticles ausgelagert, die im Starter Verticle deployt werden. Die interessante Problemstellung bestand darin zu unterscheiden, in welcher Umgebung wir uns befinden: im eigenen Rechenzentrum oder in AWS. Natürlich wäre es möglich, beim Deployment des Docker-Containers eine entsprechende Umgebungsvariable zu setzen, aber an dieser Stelle hilft Instance Metadata weiter: Wenn der URL http://169.254.169.254/latest/meta-data/ erreichbar ist wissen wir, dass wir uns in AWS befinden.

@Override
public void start() throws Exception {

  LOGGER.info("Main verticle has started, let's deploy some others...");

  AmazonUtil amazonUtil = AmazonUtil.getInstance();

  String messageVerticle = "";
  LOGGER.info("Are we running in AWS? " + amazonUtil.isEnvironmentAWS());
  if (amazonUtil.isEnvironmentAWS()) {
    // We register Kinesis verticle to event bus
    messageVerticle = KinesisVerticle.class.getCanonicalName();
  }
  else {
    // We register Kafka verticle to event bus
    messageVerticle = KafkaVerticle.class.getCanonicalName();
  }

  this.deployVerticle(messageVerticle, true);
}

private void deployVerticle(String verticle, boolean isWorker) {
  LOGGER.info("Deploying " + verticle);
  DeploymentOptions options = new DeploymentOptions().setWorker(false);

  if (isWorker)
    options = new DeploymentOptions().setWorker(true);

  vertx.deployVerticle(verticle, options, res -> {
    if (res.succeeded()) {

      String deploymentID = res.result();

      LOGGER.info("Verticle deployed ok, deploymentID = " + deploymentID);

    } else {
      LOGGER.error(res.cause());
    }
  });
}

In der Methode deployVerticle wird der Rückgabewert des Deployments des jeweiligen Verticles ausgewertet und bei Fehlern im Deployment-Prozess ausgegeben.

Metriken über Verticles

Wie schon angedeutet, haben wir unsere Architektur Cloud-native konzipiert. Was in diesem Fall bedeutet, dass vorhandene Services von AWS genutzt und Cloud-Patterns verwendet werden. Für das Monitoring der Infrastruktur verwenden wir Amazon CloudWatch. CloudWatch speichert Metriken der Basisinfrastruktur für vierzehn Tage, was in den meisten Fällen ausreichen sollte. Die eigentliche Macht von CloudWatch besteht darin, dass auf Basis der Metriken Alarme definiert werden können, die wiederum die Basis für die Skalierung der bestehenden Infrastruktur sind.

Abb. 1: Zusammenspiel von CloudWatch-Metriken und AutoScaling

Abb. 1: Zusammenspiel von CloudWatch-Metriken und AutoScaling

Das Schaubild in Abbildung 1 verdeutlicht die prinzipielle Funktionsweise des AutoScalings und welche wichtige Rolle CloudWatch dabei spielt. Das Elastic Load Balancing sendet Anfragen an die EC2-Instanzen. Von diesen Instanzen werden Metriken an CloudWatch gesendet, wobei CloudWatch bereits eine Basismenge an Metriken selbst misst. Auf Basis dieser Metriken können Alarme definiert werden, falls beispielsweise ein bestimmter Schwellwert für CPU- oder Netzwerkauslastung erreicht wird. Dieser Alarm wird dann an die AutoScaling Group, in der sich die EC2-Instanzen befinden, weitergeleitet und eine entsprechende Skalierungsaktion durchgeführt. Diese Funktionalität von CloudWatch und der AutoScaling Group beschränkt sich nicht nur auf die Basismetriken, das Gleiche kann auch mit eigenen Metriken erreicht werden. Initial haben wir die Dropwizard-Metrics-Bibliothek in Kombination mit BlackLocus CloudWatch Metrics für die Ermittlung der Metriken und das Senden nach CloudWatch verwendet.

Leider haben wir bei Lasttests aber ein sonderbares Verhalten feststellen müssen: Nach ca. 4 000 Requests hat die Anwendung nicht mehr reagiert, die Healthchecks des ELBs schlugen fehl und die EC2-Instanzen wurden durch den ELB neu gestartet. Nach etwas Fehlersuche hat sich herausgestellt, dass die Kombination aus BlackLocus und dem AWS SDK blockierend ist, was bei Vert.x (und auch Jetty) problematisch ist. Aus diesem Grund haben wir die Metrikermittlung in ein dediziertes Verticle ausgelagert. Dieses Verticle liest alle 30 Sekunden die CPU- und Speicherauslastung für den Java-Prozess aus und schickt diese Daten mithilfe des AWS Java SDKs an CloudWatch weiter (Listing 2).

static final long MB = 1024 * 1024;
private AmazonCloudWatchAsyncClient cloudWatchAsyncClient;
private OperatingSystemMXBean osBean;

@Override
public void start() {
  Runtime runtime = Runtime.getRuntime();
  cloudWatchAsyncClient = createCloudWatchClient();
  osBean = ManagementFactory.getPlatformMXBean(
    OperatingSystemMXBean.class);
  String instanceId = ServerProperties.getInstance().getInstanceId();

  vertx.setPeriodic(30000, id -> {

    Date metricsDate = new Date();
    Long usedMemory = ((runtime.totalMemory() - runtime.freeMemory()) / MB);

    // List of metrics to send to CloudWatch
    List metricDatumList = new ArrayList<>();

    // Metrics for the JVM CPU load
    MetricDatum cpuMetricDatum = new MetricDatum();

    // What % CPU load this current JVM is taking, from 0.0-1.0
    double cpuLoad = osBean.getProcessCpuLoad() * 100.0;
    cpuMetricDatum.setMetricName("JVM CPU load - " + instanceId);
    cpuMetricDatum.setUnit(StandardUnit.Percent);
    cpuMetricDatum.setValue(cpuLoad);
    cpuMetricDatum.setTimestamp(metricsDate);

    // Metrics for the JVM Memory Usage
    MetricDatum jvmMetricDatum = new MetricDatum();
    jvmMetricDatum.setMetricName("JVM used memory - " + instanceId);
    jvmMetricDatum.setUnit(StandardUnit.Megabytes);

    // Converts the Objects to Double-values for CloudWatch
    jvmMetricDatum.setValue(usedMemory.doubleValue());
    jvmMetricDatum.setTimestamp(metricsDate);

    PutMetricDataRequest metricDataRequest = new PutMetricDataRequest();
    metricDataRequest.setNamespace("viewtracker");

    metricDatumList.add(jvmMetricDatum);
    metricDatumList.add(cpuMetricDatum);
    metricDataRequest.setMetricData(metricDatumList);
    Future future = cloudWatchAsyncClient.putMetricDataAsync(metricDataRequest);

    try {
        future.get();
    } catch (Exception exc) {
        vertx.eventBus().send(VerticleNames.LOGGER_ERROR, "Can't write to CloudWatch: " + exc.getMessage());
    }
  });
}

Die Qual der Wahl

Die Wahl des richtigen EC2-Instanztyps ist bei Weitem nicht so einfach wie ursprünglich angenommen. Denn AWS bietet eine breite Auswahl von Instanztypen für alle möglichen Anwendungsfälle. Nach umfangreichen Tests im eigenen Datacenter und einigen Kalkulationen sind wir zunächst zu dem Schluss gekommen, dass eine t2.medium-Instanz völlig ausreichen sollte. Die T2-Instanzen sind für eine geringe Basislast mit einigen Peaks sehr gut geeignet; im Prinzip also das Nutzungsszenario, das wir im Auge hatten. Interessant an dem T2-Typ ist, dass CPU Credits für die Peaks im Basisbetrieb gesammelt und die Credits dann in Peak-Zeiten aufgebraucht werden können. Die t2.medium-Instanz hat zwei Hyperthreads und 4 GB RAM, das nach unseren Berechnungen völlig ausgereicht hätte. Bei Lasttests haben wir aber festgestellt, dass irgendwann die Antwortzeiten der Anwendung länger werden, obwohl die CPU-Last relativ niedrig bleibt und nur wenig ansteigt. Ein Test mit einem Instanztyp mit mehr Hyperthreads (m4.xlarge: 4 Cores, 16 GB RAM) hat ein deutlich besseres Verhalten unter Last gezeigt. Die Erklärung dafür ist auch relativ einleuchtend.

Unsere Applikation arbeitet mit vielen Verticles, die für einen Request nacheinander abgearbeitet werden. Einige Verticles brauchen für die Verarbeitung länger als andere. Dies haben wir bei der Anzahl der jeweilig deployten Verticles bereits berücksichtigt, jedoch hat die parallele Bearbeitung Grenzen. Genau hier kommt die Anzahl der zur Verfügung stehenden Hyperthreads ins Spiel. Je größer die Applikation und die Anzahl der deployten Verticles sind, desto mehr profitiert die Anwendung von einer größeren Anzahl an Hyperthreads. Dies ist einer der wichtigsten Punkte, die wir mit dem Cloud-Deployment gelernt haben. Allein die Tatsache, dass der neue Instanztyp m4.xlarge zwei Hyperthreads mehr zur Verfügung hat, gab der Applikation den entscheidenden Performanceschub, um eine hohe Anzahl von Anfragen verarbeiten zu können. Dieser Instanztyp hat zusätzlich noch eine bessere Netzwerkanbindung und einen höheren Durchsatz bei dem EBS-Volumen. Das ist aber für uns irrelevant, da alles im Speicher gehalten wird.

Ein weiterer Vorteil der Lasttests ist, dass wir wissen, wie viele Requests die Applikation verarbeiten kann, ohne zu kippen. Anhand der umfangreichen CloudWatch-Metriken konnten wir ebenfalls sehen, ob zum Beispiel die Prozessorauslastung oder der Speicher im Normbereich sind. Auf Basis dieser Erkenntnisse waren wir dann auch in der Lage, wirkungsvolle Skalierungsregeln zu erstellen.

Was passiert hier eigentlich?

Die Infrastruktur in AWS ist so ausgelegt, dass sie automatisch auf Fehler und Lastspitzen reagiert. Jederzeit die Kontrolle über dieses System zu behalten, bedeutet jederzeit so viele Informationen wie möglich zu haben. Sämtliche Informationen aus den Logfiles und die Metriken aus CloudWatch werden kontinuierlich in unseren ELK-Stack übertragen. Hierbei gibt es jedoch ein paar Dinge zu bedenken. Die Nachvollziehbarkeit im Fehlerfall ist schwierig, wenn Server vollautomatisch erstellt und auch wieder gelöscht werden. Einen Request durch ein verteiltes System zu verfolgen, ist ebenfalls nicht einfach. Gewöhnlich gibt man den Requests IDs, die in den Logeinträgen auftauchen und durch das System mitgetragen werden. Wir möchten aber zusätzlich wissen, wann der Request erstellt wurde und auf welcher Maschine. Am einfachsten geht dies mithilfe einer UUID, die mit dem Beginn eines Requests erzeugt wird. UUIDs gibt es in fünf verschiedenen Versionen, die im RFC 4122 definiert sind.

UUID

UUID steht für Universally Unique Identifier und wird hexadezimal in fünf Gruppen durch Bindestriche getrennt dargestellt, z. B. 1EEAF698-857F-40A2-B968-DFCDAFD986B7:

1 Zeitstempelbasierte UUID (ursprüngliche Variante)
2 DCE-Security-Version
3 Namensbasiert, MD5-gehasht
4 Zufällige oder pseudozufällige UUID
5 Namensbasiert, SHA1-gehasht

UUID Version 1 beinhaltet sowohl eine Node ID als auch den Timestamp, und ist somit perfekt für unseren Einsatzzweck geeignet. Als Identifikation für den Node war ursprünglich die MAC-Adresse des jeweiligen Computers gedacht. Dies ist für unsere Zwecke jedoch nicht sonderlich praktikabel. Daher musste eine bessere Möglichkeit gefunden werden. Als Lösung bot sich die Instance ID aus den AWS-Metadaten an. Jede erzeugte EC2-Instanz hat ein eindeutiges Set an Metadaten, zu dem die Instance ID gehört.

Das JDK unterstützt leider nicht die direkte Erzeugung einer Version 1 UUID, mithilfe der fromString(String uuid)-Methode kann aber aus jeder Zeichenkettenrepräsentation ein UUID-Object erzeugt werden. Der verwendete String muss jedoch zuvor korrekt erzeugt werden, sonst ist das damit erzeugte Objekt wertlos, d. h. es kann kein Timestamp oder Node ID ermittelt werden.

Microservices mit Jetty
Vert.x haben wir ausschließlich für den produzierenden, hochgradig asynchronen Teil der Applikation bzw. der Architektur genutzt. Darüber hinaus war es natürlich notwendig, einen konsumierenden Teil zu implementieren, der Daten aus dem Kinesis Stream liest und in das Datacenter von zanox in den Apache-Kafka-Cluster schreibt. Basis für diese Applikation ist die Servlet Engine und der HTTP-Sever Jetty . Dieser wird genutzt, um Healthchecks, Heartbeats und ein REST-API bereitzustellen. Darüber hinaus wurde für ein Artefakt die Kinesis Client Library (KCL) für das Konsumieren von Daten aus Kinesis genutzt. Einen Kinesis-Konsumenten zu entwickeln, der auch stabil im produktiven Betrieb eingesetzt werden kann, ist nicht trivial, da auf viele Exceptions entsprechend reagiert werden muss. Die notwendigen Schritte sind hier skizziert, ein Beispiel ist in folgendem GitHub-Repository zu finden. Aus diesem Grund haben wir uns AWS Lambda genauer angeschaut. Leider gab es zwei Punkte, die die Benutzung von Lambda verhindert haben:

  • AWS Lambda konnte bis vor Kurzem nicht hinter einem VPC zugreifen. Das war in unserem Fall notwendig, um Daten über ein VPN zwischen AWS und zanox austauschen zu können.
  • AWS Lambda hat keinen SharedContext, in dem die Verbindung zu Apache Kafka abgelegt werden kann.

Der Kinesis-Consumer implementiert das Interface IRecordProcessor und überschreibt die Methode processRecords. Dieser Methode wird neben einer Liste von Einträgen auch ein Pointer für die Checkpoint-Funktionalität mitgegeben. Falls eine bestimmte Zeit vergangen ist, wird automatisch ein Checkpoint in Amazon DynamoDB geschrieben. Die Methode processRecordsWithRetries iteriert über die Liste von Einträgen und versucht im Fehlerfall mehrfach den jeweiligen Eintrag nochmals zu verarbeiten. In der Methode processSingleRecord werden die Daten aus dem Eintrag gelesen und über den Apache-Kafka-Client in den Kafka-Cluster geschrieben.

@Override
public void processRecords(List records, IRecordProcessorCheckpointer iRecordProcessorCheckpointer) {
  try {
    logger.info('Start processing records');

    processRecordsWithRetries(records);

    if (System.currentTimeMillis() > nextCheckpointTimeInMillis) {
      checkpoint(iRecordProcessorCheckpointer);
      nextCheckpointTimeInMillis = System.currentTimeMillis() + CHECKPOINT_INTERVAL_MILLIS;
    }
  } catch (InvalidProtocolBufferException exc) {
    logger.error(exc);
  }
}

private void processRecordsWithRetries(List records) throws InvalidProtocolBufferException {

  for (Record record : records) {
    boolean processedSuccessfully = false;
    for (int i = 0; i < NUM_RETRIES; i++) {
      try {
  
          processSingleRecord(record);

          processedSuccessfully = true;
          break;
      } catch (Throwable t) {
          logger.warn("Caught throwable while processing record " + record, t);
      }

      try {
          Thread.sleep(BACKOFF_TIME_IN_MILLIS);
      } catch (InterruptedException e) {
          logger.debug("Interrupted sleep", e);
      }
    }

    if (!processedSuccessfully) {
      logger.error("Couldn't process record " + record + ". Skipping the record.");
    }
  }
}

private void processSingleRecord(Record record) {

  ByteBuffer data = record.getData();

KeyedMessage<String, byte[]> keyedMessage = new KeyedMessage<>(ServerProperties.getInstance().getKafkaTopic(),
      ServerProperties.getInstance().getKafkaPartition(), data.array());
  producer.send(keyedMessage);
}

Ein zweites Artefakt auf Basis von Jetty dient der Aktualisierung eines Redis-Clusters, den wir für die Ablage der Daten und den schnellen Zugriff nutzen. Dieser Redis-Cluster enthält für den Use Case optimierte Datenstrukturen, um die Zugriffe so schnell wie möglich zu machen. Über Jetty werden REST-Schnittstellen bereitgestellt, um Daten aus einer Datenbank in Redis nachzuladen, falls diese nicht mehr gültig sind. Auch an dieser Stelle haben wir ein Problem mit der BlackLocus-Bibliothek festgestellt, die die Servlet-Threads blockiert hat. Genauso wie bei der anderen Applikation haben auch an dieser Stelle die ELB-Healthchecks fehlgeschlagen, was dazu geführt hat, dass die EC2-Instanzen terminiert und neue gestartet wurden.

Redis: Cache is King

Ein weiteres Problem war, dass wir die Daten in Redis in der ersten Phase nicht durch einen Push-Mechanismus von unserem Backend direkt zu AWS aktualisieren konnten. Alternativ konnten wir auch nicht alle Keys ständig aktualisieren, da die Datenmenge auf der einen Seite viel zu groß war und auf der anderen Seite die Belastung der Datenbanken hierdurch die anderen Systeme massiv beeinträchtigt hätte.

Eine Analyse des bestehenden Systems zeigte uns einen Kompromiss auf. Wenn wir eine erhöhte Latenz für einige wenige Abfragen in Kauf nehmen, reduziert sich die Anzahl der zu aktualisierenden Schlüssel massiv. Hierfür haben wir nun einen Counter implementiert, der automatisch erhöht wird, wenn der jeweilige Key benutzt wird. Der Counter wird in regelmäßigen Abständen ausgewertet. Wenn ein vorher festgelegter Grenzwert erreicht wurde, wird der Key regelmäßig aktualisiert. Die nur selten verwendeten Keys müssen im Gegenzug aus unserem Backend geholt werden und stehen dann erst nach einer größeren Latenz in Redis zur Verfügung.

Ein paar Besonderheiten beim Testing

Eine Applikation wie die, die wir hier vorgestellt haben, muss natürlich auch sorgfältig getestet werden. Unit Tests funktionieren bei Vert.x wie in jedem anderen Java-Projekt auch und müssen nicht extra angepasst werden. Eine Ausnahme bildet das Testen der handle()-Methode in den Verticles. Hier gibt es keinen Rückgabewert, und es müsste erst ein Object erzeugt werden, welches das Message-Interface implementiert. Für diesen Fall haben sich bei uns zwei Best Practices bewährt:

  • So viel Logik wie möglich aus der handle()-Methode in separate Methoden auslagern, die sich dann leicht mit Unit Tests testen lassen.
  • Für das komplette Testing der handle()-Methode direkt einen Integrationstest schreiben.

Bei Integrationstests spielt Vert.x seine Stärke als asynchrones Framework aus: Diese können für jedes Verticle separat geschrieben werden. Es muss nicht mehr die gesamte Applikation dafür deployt oder Teile aufwendig gemockt werden. Bei den Integrationstests wird immer ein Verticle direkt deployt. Hierbei muss vor dem Start der eigentlichen Tests darauf geachtet werden, dass das Verticle vollständig deployt ist, d. h. dass alle Startprozesse abgeschlossen sind.

Der Integrationtest ist im Kern ein Verticle, das von TestVerticle abgeleitet ist. Eine Startmethode könnte in etwa so wie in Listing 4 aussehen.

@Override
public void start() {
  initialize();
  eventBus = vertx.eventBus();
  container.deployVerticle(MyVerticle.class.getName(), asyncDeployResult -> {
    assertTrue(asyncDeployResult.succeeded());
    assertNotNull("DeploymentID should not be null!", asyncDeployResult.result());
    startTests();
  });
}

In der eigentlichen Testmethode registrieren wir einen Handler für das nächste Verticle, das über ein Event angesprochen werden soll. Dieser Handler überprüft dann anhand der übergebenen Daten, ob das Ergebnis wie erwartet aussieht. Alle Asserts müssen also an dieser Stelle stattfinden.

  @Test
public void firstIntegrationTest() {
  LOGGER.info("TestCase 1...");

  eventBus.registerHandler("NextVerticleName", new Handler<Message>() {
    @Override
    public void handle(Message receivedEvent) {
      JsonObject jsonData = receivedEvent.body();
      assertNotNull(jsonData);
      assertEquals(expectedJson, jsonData);
      testComplete();
    }
  });

  eventBus.send(MyVerticle.class.getSimpleName(), startJson1);
  LOGGER.info("Sent first event... ");
}

Am häufigsten wird hier übersehen, dass zwar zuerst der Handler registriert wird, jedoch direkt danach die eventBus.send()-Methode ganz am Ende ausgeführt wird. Erst wenn diese gefeuert hat, wird die handle()-Methode angesprungen. Der Test ist auch erst abgeschlossen, wenn die testComplete()-Methode aufgerufen wurde. Diese muss auch aufgerufen werden. Ansonsten bleibt der Test einfach stecken und wird nie beendet. Es sollte also sichergestellt werden, dass in jedem Falle testComplete() aufgerufen wird oder ein Time-out zuschlägt. Das kann sonst beispielsweise in einem CI-Buildjob zu unangenehmen Überraschungen führen.

Mit diesen Integrationstests ließen sich die meisten Verticles perfekt testen; bis auf das initiale HttpVerticle, das die entsprechenden HTTP Requests entgegennimmt und beantwortet. Das Hauptaugenmerk des Tests lag für uns in der handle()-Methode. Diese wird allerdings erst ganz am Ende der Verarbeitungskette aufgerufen. Wir wollten jedoch genau diesen Part testen, ohne den Rest der Applikation zu deployen oder den HTTP Request zu mocken, es sollte ein richtiger HTTP Request erzeugt werden. Hierfür bedienten wir uns eines kleinen Tricks: Wir schrieben ein normales Verticle, das ein REST-Interface anbietet, über das dann die Tests gestartet werden.

public class HttpVerticleForTest extends Verticle {

  public void start() {
    RouteMatcher routeMatcher = new RouteMatcher();

    routeMatcher.get("/test/1", httpServerRequest -> {
      HttpServerResponse response = httpServerRequest.response();
      JsonObject json1 = new JsonObject();
      json1.putString("request-uuid", UUID.randomUUID().toString());
      HttpServerResponseProcessor.sendResponse(json1, response, vertx.eventBus());
    });

    vertx.createHttpServer().requestHandler(routeMatcher).listen(7777);
  }
}

In unserem Fall übernimmt eine eigene Klasse die Erstellung und Versendung der HTTP-Response (HttpResponseProcessor). Genau diese Klasse und in diesem speziellen Fall die sendResponse()-Methode sollen hier getestet werden. Der RouteMatcher kann natürlich um beliebig viele Szenarien erweitert werden und bietet dann ein schönes REST-Interface an, mit dem man einzelne Tests gezielt aufrufen kann. Dieses HttpVerticleForTest muss nun im Integrationstest ebenfalls deployt werden.

public class HttpServerResponseVerticle_ITCase extends TestVerticle {

  @Override
  public void start() {
    initialize();
    container.deployVerticle(HttpVerticleForTest.class.getName(), asyncDeployResult -> {
      assertTrue(asyncDeployResult.succeeded());
      assertNotNull("DeploymentID should not be null!", asyncDeployResult.result());
      vertx.setTimer(500, aLong -> startTests());
    });
  }

  /** Tests if an HTTP 200 is created.   */
  @Test
  public void first() throws Exception {
    URL url = new URL( "http://localhost:7777/test/1" );

    try {
      HttpURLConnection conn = (HttpURLConnection) url.openConnection();
      conn.setConnectTimeout(5000);
      conn.getContent();
      conn.disconnect();
      assertEquals(HttpURLConnection.HTTP_OK, conn.getResponseCode());
    } catch (SocketTimeoutException stex) {
      VertxAssert.fail("The request has been timed out!");
    } catch (java.net.UnknownHostException uhex) {
      VertxAssert.fail("The host is unknown! " + uhex.toString());
    } catch (Exception ex) {
      VertxAssert.fail("Something serious happened here! " + ex.toString());
    }
    testComplete();
  }
}

Der eigentliche Test ruft jetzt nur noch einen URL auf und überprüft, ob das erwartete Ergebnis zurückgesendet wird. Jede andere Antwort, Fehler oder auch Time-out führt zum Fehlschlagen des Tests.

Unterschiede zwischen Vert.x 2 und Vert.x 3

Während der Entwicklungsphase unserer Applikation wurde bereits eine neue Version von Vert.x veröffentlicht, die wir natürlich in Augenschein genommen haben. Der Versionssprung zwischen Version 2 und 3 ist signifikant: Es sind neue Klassen und neue APIs hinzugekommen. Darüber hinaus wurde die Paketstruktur des Projekts deutlich simplifiziert. Das Maven-Artefakt ist deutlich besser geworden: Die pom.xml-Datei ist kompakter, und für das Erzeugen eines Fat JARs wird das Maven-Shade-Plug-in genutzt. Ab der Version 3 wird eine main-Methode unterstützt, der komplizierte Weg über die Referenzierung in der mod.json-Datei ist glücklicherweise Geschichte. Eine simple „Hello World“-Applikation für Vert.x 3 mit Java 8 zeigt Listing 8.

public class HelloWorld {

  public static void main(String[] args) {
    // Create an HTTP server which simply returns "Hello World!" to each request. 
    Vertx.vertx().createHttpServer().requestHandler(req -> req.response().end("Hello World!")).listen(8080);
  }
}

Diese kleine Applikation lauscht auf den Port 8080, startet einen HTTP-Server und gibt auf Anfragen ein „Hello World!“ zurück. Eine weitere tiefgreifende Änderung in Vert.x 3 ist das Fehlen der Module. Mit einem Modul können Applikationen oder Funktionalitäten paketiert, deployt und gestartet werden. Beispielsweise können Module die Abstraktion von Infrastrukturkomponenten wie Messaging-Systemen implementieren, wie wir bereits zeigten. Diese lauschen dann auf dem internen Eventbus und verarbeiten die entsprechenden Nachrichten. In Vert.x 3 existieren diese Module nicht mehr. Die Funktionalität kann aber nachgebildet werden, indem Verticles in einer JAR-Datei paketiert und über den Classpath eingebunden werden. Die großen Änderungen von Vert.x 3 verhinderten bisher einen Umstieg, da dies einiges an Aufwand bedeutet. Wir werden dies jedoch so bald wie möglich nachholen und dann von unseren Erfahrungen berichten.

Verwandte Themen:

Geschrieben von
Björn Stahl
Björn Stahl
Björn Stahl ist Senior Developer bei der ZANOX AG und arbeitet dort im Bereich Java EE. Darüber hinaus beschäftigt er sich mit Build-Infrastrukturen, insbesondere mit Maven und Nexus und interessiert sich für das Performancetuning von Java-Anwendungen.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: