Microservices mit Kotlin, Vert.x und OpenAPI, Teil 1

Dank neuer Konzepte und Techniken lassen sich mit wenig Boilerplate-Code und in kurzer Zeit schlanke und auch ressourcenschonende Microservices implementieren. Ein zweiteiliger Praxisartikel mit Vert.x, OpenAPI und Kotlin.

Architektur/Methoden  –  5 Kommentare
Microservices mit Kotlin, Vert.x und OpenAPI, Teil 1

Wenn in den vergangenen Jahren ein Architekturmuster als Königsweg oder Wunderwaffe bezeichnet wurde, um komplexe und gleichzeitig beherrschbare Anwendungssoftware zu entwickeln, dann war es in den meisten Fällen der Microservice. Das nicht zuletzt deswegen, weil viele Meinungen darüber kursieren, was genau ein Microservice ist, was er tun darf und was nicht. Dabei sind einige Ansichten weiter verbreitet als andere. Zu den verbreiteteren zählen:

  • Sie sind individuell ausrollbar.
  • Ein Microservice bildet eine klar abgegrenzte Geschäftsfunktion ab.
  • Ein Microservice ist keine Schicht im Sinne der Drei-Schichten-Architektur. Vielmehr ist er ein in sich abgeschlossenes Stück Software, das im Rahmen seiner Geschäftsfunktion für alle Aspekte von der Datenhaltung bis zur angebotenen Schnittstelle selbst verantwortlich ist.
  • Ein Entwicklerteam ist für den Microservice in Gänze verantwortlich.
  • Ein Microservice sollte eine REST-Schnittstelle anbieten.
  • Ein Microservice ist in seinem Umfang an Code klein und überschaubar.

In der Unix-Welt gibt es eine Philosophie zur Softwareentwicklung, die lautet: "Mache nur eine Sache und mache sie gut!" Der Microservice-Gedanke entspricht dieser Philosophie. Man möchte kleine, gut beherrschbare Softwarekomponenten mit klar definierten Schnittstellen haben, um daraus größere und komplexere Systeme zu bauen. Dadurch verringert sich die Komplexität des IT-Systems im Vergleich zu monolithischen Systemen üblicherweise nicht. Das Gegenteil ist meist der Fall: Microservice-Architekturen sind in der Regel komplex.

Das ist der Preis, mit dem man sich jedoch eine Reihe anderer Vorteile erkauft. Microservices lassen sich einzeln und unabhängig voneinander weiterentwickeln und skalieren. Da sie sich nach außen nur über eine definierte Schnittstelle abgrenzen, kann ihre Implementierung, sogar die zugrunde liegende Technik bei Bedarf erneuert oder ganz ausgetauscht werden, ohne dass diese Änderungen zwangsläufig bei den anderen Komponenten nachzuziehen sind.

Microservices mögen nicht für jeden Anwendungsfall der Königsweg oder die Wunderwaffe sein. Allerdings stehen ihr Nutzen und ihre Sinnhaftigkeit bei vielen Aufgaben außer Frage.

Das Problem zur Lösung

Der Artikel besteht aus zwei Teilen. Er beginnt mit der Vorstellung unseres Anwendungsfalles und der für die Implementierung ausgewählten Technologien. Des Weiteren wird die zu implementierende REST-Schnittstelle im Detail beschrieben. Im zweiten Teil wird die eigentliche Implementierung des Microservice erläutert.

Wir beginnen also mit einem typischen Anwendungsfall eines Remote-Proxys. Ein IT-System soll Daten nutzen, die ein externer, nicht unter der eigenen Kontrolle stehender Service liefert. Der Remote-Proxy soll systemfremde Daten sowie das Verhalten des externen Service so anpassen, dass die eigenen Dienste damit einfach interagieren können.

  • Fremde Daten sollen in das heimische Domänenmodell konvertiert werden.
  • Die Authentifizierung gegen den externen Service erledigt der Remote-Proxy, ohne dass sich die Dienste darum kümmern müssen.
  • Der Remote-Proxy kann einen Cache unterhalten, um Anfragen schneller zu bedienen. Dadurch lassen sich technische Störungen durch langsame Antworten des fremden Service vermindern.
  • Bei einem Ausfall des fremden Service kann der Proxy Anfragen aus seinem Cache bedienen oder Anfragen mit einem vordefinierten Fehlercode beantworten. Darauf können heimische Services entsprechend reagieren. Der Proxy kann also im Sinne des CircuitBreaker-Patterns agieren. Somit lassen sich Auswirkungen externer Störungen auf das IT-System vermindern.

Weiterhin sollen Microservices eine möglichst einfache Schnittstelle für ihre Konsumenten bereitstellen. Hier hat sich als Quasistandard das REST-Paradigma etabliert. Schnittstellen lassen sich damit programmiersprachenunabhängig beschreiben und realisieren.

Der Artikel zeigt nun, wie Entwickler mit diesen Anforderungen einen Remote-Proxy für eine externe REST-API bauen können. Als externe API dient in diesem Fall die frei verfügbare Astronomy Picture of the Day API der amerikanischen Weltraumagentur NASA. Es handelt sich dabei um eine REST-Schnittstelle, über die sich die Texte und Bild-URLs der Webseite "Astronomy Picture of the Day" anhand des Veröffentlichungsdatums abrufen lassen. An die NASA-API ist pro Bild eine Anfrage zu stellen, als Anfrageparameter wird ein Datums-String angegeben. Der Proxy soll es also ermöglichen, dass sich Anfragen für Daten über Astronomiebilder an ihn stellen lassen. Er liefert die gewünschten Bilddaten dann an den Konsumenten zurück.

Der Proxy wird die Anfragesemantik so verändern, dass er selbst ein Mapping vom Datum eines Bildes auf eine numerische ID vorhalten wird, über die sich der Proxy dann abfragen lässt. Zusätzlich soll es möglich sein, die Bilder mit einer Bewertung zu versehen. In einer eigenen Datenbank soll der Proxy eine numerische Bewertung zwischen 1 und 10 Punkten für jedes Bild speichern können. Über einen weiteren REST-Endpunkt sollen Anwender die aktuelle Bewertung für jedes Bild abrufen können.

Proxys implementieren keine oder nur wenig eigene Geschäftslogik. Sie konvertieren Daten und fangen externe Störungen möglichst so ab, dass das IT-System dadurch nicht beeinträchtigt wird. Da es Ziel ist, zum einen eigene numerische IDs für jedes Bild zu vergeben und dazu noch Bewertungen zu speichern, geht das Beispiel über das klassische Proxy-Pattern hinaus. Bei der Anwendung ist also von einem Microservice die Rede, der unter anderem das Remote-Proxy-Pattern implementiert.

Abbildung 1 zeigt in einem Sequenzdiagramm, wie die einzelnen Komponenten des Gesamtsystems miteinander interagieren sollen. In der grünen Box sieht man die beiden Komponenten des Microservice. Der Webserver nimmt von einem Servicenutzer einen HTTP-GET-Request entgegen. Um den Request bedienen zu können, werden Daten der externen NASA-API benötigt. Um an diese Daten zu kommen, wird eine asynchrone Nachricht an den Remote-Proxy gesendet, der daraufhin einen GET-Request an die NASA-API schickt. Sobald er von dort die Antwort mit den gewünschten Bilddaten erhält, sendet der Remote-Proxy diese Daten wieder über den Eventbus an den Webserver zurück. Letzterer ist damit nun in der Lage, die Anfrage des Servicenutzers zu bedienen.

Sequenzdiagramm zum Zusammenspiel des Microservice mit Anwender und externer API (Abb. 1)

Auswahl der Techniken

Mittlerweile existieren zahlreiche ausgereifte Werkzeuge und Frameworks, mit denen sich Microservices schnell und ohne viel Boilerplate-Code entwickeln lassen. Dieser Artikel zeigt, wie sich so etwas umsetzen lässt. Dabei werden folgende Techniken zum Einsatz kommen.

Schnittstellenbeschreibung mit OpenAPI 3

Um die vom Microservice angebotene Schnittstelle zu beschreiben, wird die OpenAPI-Spezifikation genutzt. Hierbei handelt es sich um eine auf YAML basierende Notation, mit der sich frei von Technik Schnittstellen und Datenobjekte beschreiben lassen. Die Beschreibungen lassen sich dann verwenden, um daraus Quellcode für eine Reihe von Programmiersprachen zu generieren. Der Microservice bietet also gleich die Möglichkeit, Quellcode für konsumierende Applikationen zu erzeugen. Es existieren Codegeneratoren für beispielsweise Java, C/C++, C#, Kotlin, PHP, Python, JavaScript und TypeScript. Im Beispiel wird es sogar möglich sein, die OpenAPI-Spezifikation einzusetzen, um damit einen Webserver zu konfigurieren.

Implementierung mit Vert.x und Kotlin

Kotlin ist eine Programmiersprache, die in Bytecode für die Java Virtual Maschine (JVM) übersetzt werden kann. Sie kann auf Java-Bibliotheken zugreifen, und mit Kotlin erstellter Code lässt sich aus Java heraus verwenden. Das hohe Maß an Interoperabilität mit Java macht es einfach, Kotlin in Java-Projekte zu integrieren. Kotlin-Code ist für Java-Entwickler in der Regel einfach zu verstehen. Die Syntax ist der von Java ähnlich, jedoch an vielen Stellen deutlich "schlanker".

Mit Vert.x steht ein ereignisorientiertes Entwicklungsframework zur Verfügung. Es unterstützt Nebenläufigkeit und setzt auf das Paradigma der reaktiven Programmierung. Das Framework hat zum Ziel, während der Ausführung möglichst wenige Kernel-Threads in Anspruch zu nehmen. Dadurch lässt sich ein hohes Maß an Nebenläufigkeit bei gleichzeitig geringer Inanspruchnahme von Betriebssystemressourcen erreichen.

Anwendungslogik implementiert man in Vert.x mit sogenannten Verticles. Diese Ausführungseinheiten arbeiten ereignisgetrieben und kommunizieren asynchron miteinander über den durch Vert.x bereitgestellten Eventbus. Das Framework ermöglicht es, mit wenigen zusätzlichen Codezeilen einen Webserver zu implementieren, der neben dem Uraltprotokoll HTTP/1.1 auch HTTP/2 anbietet.

Spezifikation der Schnittstelle

Es wird folgende REST-Schnittstelle implementiert. Parameter mit variablen Werten sind in geschweiften Klammern angegeben:

  • GET /apod/{apodId}: Unter Angabe einer numerischen ID lassen sich hier Daten zu einem Astronomiebild abfragen. Beispiel: http://www.unser-service.io/apod/42 ruft das Bild mit der ID 42 ab.
  • GET /apod: Lässt man die numerische ID weg, wird ein Array mit Informationen zu allen bekannten Bildern zurückgegeben. Beispiel: http://www.unser-service.io/apod/ ruft alle bekannten Bilder ab.
  • POST /apod: Mit einem POST-Request gegen diesen Endpunkt lässt sich ein neuer Eintrag eines Bildes zum Proxy hinzufügen. Zum Anlegen einer Ressource ist bei der Anfrage lediglich das Datum des gewünschten Bildes anzugeben. Der Proxy wird eine neue Ressource mit diesem Bild anlegen und dabei eine numerische ID vergeben, über die sich dann künftig die Bilddaten abrufen lassen. Die Adresse der neuen Ressource ist in der Antwort auf diesen Request enthalten:
curl -X POST "https://www.unser-service.io/apod" \
-H "accept: application/json" \
-H "X-API-KEY: OUR_SECRET_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"dateString\":\"2019-01-01\"}"

Das Beispiel legt eine neue Bildressource an, die auf das "Astronomy Picture of the Day" vom 1. Januar 2019 verweist.

GET /apod/{apodId}/rating: Für jedes Bild gibt es die Möglichkeit, eine Bewertung von 1 bis 10 zu vergeben. Aus allen abgegebenen Werten wird ein Durchschnittswert berechnet, den man über diesen Endpunkt per GET-Request anfordert. Beispiel: http://www.unser-service.io/apod/42/rating ruft das Rating für das Bild mit der ID 42 ab.

PUT /apod/{apodId}/rating: Eine eigene Wertung für ein Bild lässt sich per PUT-Request an den Endpunkt schicken:

curl -X PUT "https://localhost:8443/apod/42/rating" \
-H "accept: application/json" \
-H "Content-Type: application/json" \
-d "{\"rating\":7}"

Das Beispiel gibt ein Rating mit dem Wert 7 für das Bild mit der ID 42 ab.

Auszug aus der OpenAPI-Spezifikation

In Listing 1 sieht man die OpenAPI-Spezifikation für die Endpunkte POST / apod und GET apod[i] im YAML-Format:

Listing 1: OpenAPI3-Spezifikation des Microservice

1	openapi: '3.0.0'
2 info:
3 version: 1.1.0
4 title: apodrating
5 description: "The apod rating app lets you rate NASA's Astronomy Picture of the Day and view ratings for them."
6 paths:
7 /apod:
8 post:
9 summary: create an apod resource with the specified date.
10 operationId: postApod
11 requestBody:
12 required: true
13 description: The rating you want to set
14 content:
15 application/json:
16 schema:
17 $ref: '#/components/schemas/ApodRequest'
18 responses:
19 '201':
20 description: Apod entry created
21 headers:
22 location:
23 schema:
24 type: string
25 description: The location of the created resource.
26 '409':
27 description: Apod entry already exists
28 content:
29 application/json:
30 schema:
31 $ref: '#/components/schemas/Error'
32 '500':
33 description: Apod entry could not be created
34 content:
35 application/json:
36 schema:
37 $ref: '#/components/schemas/Error'
38 get:
39 summary: get all apods
40 operationId: getApods
41 responses:
42 '200':
43 description: the picture's title and hd uri
44 content:
45 application/json:
46 schema:
47 type: array
48 items:
49 $ref: '#/components/schemas/Apod'
50 default:
51 description: unexpected error
52 content:
53 application/json:
54 schema:
55 $ref: '#/components/schemas/Error'
56 components:
57 schemas:
58 Apod:
59 description: the apod data object
60 required:
61 - id
62 - title
63 - imageUriHd
64 properties:
65 id:
66 description: The unique id of this apod
67 readOnly: true
68 type: string
69 dateString:
70 description: The date string of this apod
71 readOnly: true
72 type: string
73 title:
74 description: The title of this apod
75 readOnly: true
76 type: string
77 imageUriHd:
78 description: The uri of this image in hd resolution
79 readOnly: true
80 type: string
81 ApodRequest:
82 description: a create apod request payload
83 required:
84 - dateString
85 properties:
86 dateString:
87 description: The date string of this apod
88 type: string
89- readOnly: true

Beide Endpunkte werden unterhalb des Pfades [i]/apod angelegt (Zeile 9). In Zeile 11 sieht man eine kurze textuelle Beschreibung des POST-Endpunkts. Von Bedeutung ist jedoch zunächst der Eintrag in Zeile 12, die operationID. Dabei handelt es sich um eine Kennung des speziellen Endpunkts, auf den man sich im Programmcode beziehen kann, um Anwendungslogik an diesen Endpunkt zu binden.

Ab Zeile 13 wird beschrieben, wie der Request beschaffen sein muss, den man von der Clientseite erwartet. In diesem Fall liegen die Daten im JSON-Format vor. Genauer gesagt handelt es sich um ein JSON-Objekt vom Typ ApodRequest, das man weiter unten in der Spezifikation selbst definiert (Zeile 83). Ab Zeile 20 wird beschrieben, welche Antworten der Endpunkt zurückgeben kann.

Der Endpunkt antwortet mit dem HTTP-Statuscode 201 (CREATED), sofern der Microservice den Request erfolgreich ausführen konnte. In der Antwort findet sich weiterhin ein HTTP-Header mit dem Namen location, der den Pfad zu der gerade erstellten Ressource enthält. Weiterhin kann der Endpunkt mit dem Fehlercode 409 (CONFLICT) antworten, sofern die zu erstellende Ressource bereits existiert. Bei Fehlern, deren Ursache auf der Serverseite liegt, wird der Endpunkt mit dem Code 500 (INTERNAL SERVER ERROR) antworten.

Ab Zeile 40 wird der GET-Endpunkt für den Pfad /apod beschrieben. Hier wird die operationID mit dem Namen "getApods" vergeben (Zeile 42). Der Endpunkt wird mit einem Array von JSON-Objekten vom Typ Apod antworten (s. Zeile 60). Lässt sich eine Anfrage erfolgreich beantworten, wird der Statuscode 200 (OK) zusammen mit dem Array zurückgegeben. Das trifft auch auf den Fall zu, dass der Service keinerlei Datensätze findet. Dann antwortet er mit dem Statuscode 200 und einem leeren Array.

Mit dieser Schnittstellenbeschreibung endet der erste Teil dieses Artikels. Es wurde gezeigt, wie aus dem vorgestellten Anwendungsfall eine Spezifikation für eine REST-Schnittstelle abgeleitet werden kann. Als Notation wurde die OpenAPI Spezifikation verwendet. Diese kann direkt bei der Implementierung des Microservice weiterverwendet werden, was im zweiten Teil des Artikels erläutert wird. (ane)

Jan Weinschenker
arbeitet bei der Holisticon AG in Hamburg als Berater im Geschäftsfeld Architektur. Er beschäftigt sich mit dem Design, der Entwicklung und der Verbesserung von verteilten Unternehmens- und Webanwendungen. Außerdem ist er Mitorganisator des Web-Performance-Meetups Hamburg.