In einem System, dessen Services über Events zusammenarbeiten, sind die Events die öffentliche Seite eines Service. Ein Event-Contract schreibt auf, was ein Service veröffentlicht, wie seine Events heißen und welche Daten sie tragen, bevor der erste Producer oder Consumer gebaut wird. Diese Seite zeigt einen Contract in AsyncAPI an einem Fahrradverleih und erklärt, was in ein Event gehört und was nicht.
In einem System, dessen Services über Events zusammenarbeiten, sind die Events das öffentliche Interface eines Service. Was ein Service veröffentlicht, wie seine Events heißen und welche Daten sie tragen, darauf verlassen sich alle, die sie konsumieren. Ein Event-Contract schreibt das auf, bevor der erste Producer oder Consumer gebaut wird. AsyncAPI ist dafür das Format: Was OpenAPI für REST-Endpoints ist, ist AsyncAPI für Events.
Dieses Modul zeigt einen Contract an einem kleinen Beispiel und erklärt, was in ein Event gehört und was nicht.
Kund:innen leihen an einer Station ein Fahrrad aus und geben es an einer beliebigen Station zurück. Zwei Services sind beteiligt:
contracts/rental-service.asyncapi.yaml.contracts/billing-service.asyncapi.yaml.Jeder Service hat seinen eigenen Contract. Er beschreibt den Service aus dessen eigener Sicht: was er sendet und was er empfängt.
Ein Contract in AsyncAPI 3 hat vier Teile.
info und servers: Welcher Service ist das, in welcher Version, und über welchen Broker laufen
seine Events. Hier ist es ein lokal gestartetes Kafka.channels: Wohin Events fließen. Bei Kafka ist die address eines Channels der Name des Topics.
Der Rental-Service schreibt alle Events einer Ausleihe in ein Topic rentals.operations: Was der Service mit einem Channel tut, send oder receive. Der Rental-Service hat
zwei send-Operations, der Billing-Service eine receive-Operation auf demselben Channel.components: Die Messages selbst, mit ihren Headers, ihrem Payload und ihrem Kafka-Key, und die
Schemas, aus denen sie bestehen.Der Kern ist die Message. So sieht der Payload von RentalEnded aus:
RentalEndedPayload:
type: object
required:
- rentalId
- bikeId
- customerId
- endStationId
- startedAt
- endedAt
properties:
rentalId:
type: string
format: uuid
bikeId:
type: string
customerId:
type: string
format: uuid
endStationId:
type: string
startedAt:
type: string
format: date-time
endedAt:
type: string
format: date-time
Ob ein Contract taugt, entscheidet sich weniger an der Syntax als an drei Fragen.
Meldet das Event eine Tatsache, oder ist es ein verstecktes Kommando? Ein Event beschreibt etwas,
das schon passiert ist, und heißt deshalb in der Vergangenheitsform: RentalEnded, nicht EndRental.
Ein naheliegender Fehler wäre ein Event ChargeCustomer, das der Rental-Service an die Abrechnung
schickt. Das ist ein Befehl, der nur die Form eines Events hat: Der Rental-Service müsste wissen, dass
es eine Abrechnung gibt und was sie tun soll, und die beiden Services wären so eng gekoppelt wie bei
einem synchronen Aufruf. Mit RentalEnded meldet der Rental-Service nur, was passiert ist. Was daraus
folgt, entscheidet jeder Consumer selbst.
Trägt es die Daten, die die Consumer brauchen, und nicht mehr? Die Abrechnung braucht, wer
gefahren ist und wie lange. Deshalb trägt RentalEnded die customerId und beide Zeitpunkte. Der
Preis steht bewusst nicht darin: Den Tarif kennt die Abrechnung, und wenn er sich ändert, soll der
Rental-Service davon nichts merken. Auch Name und Adresse der Kund:in fehlen. Jedes Feld im Payload
ist ein Versprechen, das der Producer nicht mehr zurücknehmen kann, ohne seine Consumer zu fragen.
Fehlt ein Consumer? Ein Contract wird gegen alle geprüft, die das Event brauchen. Würde später eine
Wartung dazukommen, die zählt, wie oft ein Fahrrad gefahren ist, könnte sie RentalEnded konsumieren,
ohne dass der Rental-Service etwas ändern muss. Das ist der eigentliche Gewinn von Events: Neue
Consumer kosten den Producer nichts, solange das Event schon trägt, was sie brauchen.
Der Billing-Service definiert RentalEnded nicht noch einmal. Sein Contract verweist auf die Message
im Contract des Rental-Service:
messages:
RentalEnded:
$ref: './rental-service.asyncapi.yaml#/components/messages/RentalEnded'
So gibt es für jedes Event genau eine Definition, und sie liegt beim Service, dem das Event gehört. Legt man die Contracts aller Services nebeneinander, entsteht daraus der Event-Katalog des Systems.
Ein Contract ist erst dann eine Grundlage, wenn er formal gültig ist. Zwei Wege:
Auf der Kommandozeile, mit Node.js:
npx @asyncapi/cli validate contracts/rental-service.asyncapi.yaml
Die Contracts in diesem Modul sind mit der AsyncAPI CLI geprüft und gültig.
Beide Events einer Ausleihe liegen im selben Topic rentals, mit der rentalId als Kafka-Key. Kafka
garantiert eine Reihenfolge nur innerhalb einer Partition, und alle Events mit demselben Key landen in
derselben Partition. So sieht jeder Consumer RentalStarted vor RentalEnded derselben Ausleihe. Mit
einem eigenen Topic pro Event-Typ ginge diese Garantie verloren: Ein Consumer könnte das Ende einer
Ausleihe sehen, bevor er ihren Beginn kennt.
Der Preis dafür: Ein Consumer, der nur RentalEnded braucht, liest RentalStarted mit und ignoriert
es. Das ist meist billig. Die Faustregel: Was für denselben Gegenstand in Reihenfolge ankommen muss,
gehört in ein Topic, mit dessen Id als Key.
Die fachlichen Daten stehen im Payload, die technischen in den Headers. Welche Headers eine Message
trägt, legt hier die Kafka-Hilfsbibliothek des Microservice Dungeon fest, und der Contract beschreibt,
was sie tatsächlich sendet: unter anderem eine event_id, eindeutig pro Event, sowie event_type und
event_type_version, an denen ein Consumer erkennt, welche Message er vor sich hat. Kafka kann ein Event
mehr als einmal zustellen. An der event_id erkennt ein Consumer, dass er es schon verarbeitet hat.
Wann etwas in der Domäne passiert ist, gehört in den Payload (startedAt, endedAt). Der Header
created_at sagt nur, wann das Event geschrieben wurde. Wie die Bibliothek die Headers benutzt, zeigt das
Modul service-development-3-with-infrastructure.