zurück zum Artikel

Scala: Microservices mit dem Actor-Modell serialisieren

Christian Mang
Scala: Microservices mit dem Actor-Modell serialisieren

(Bild: Deutsche Bahn)

Sollen Microservices mit funktionaler Programmierung oder CQRS umgesetzt werden, bieten sich alternativ zu Spring Boot Scala und die Akka-HTTP-Library an.

Für inzwischen weit verbreitete Microservices-Architekturen nutzen Entwicklerinnen und Entwickler häufig Java und Spring Boot als bewährte, aktiv unterstützte und kontinuierlich weiterentwickelte Programmierbasis. Wer allerdings tiefer in die funktionale Programmierung einsteigen will, stößt mit Spring Boot und Java schnell an Grenzen. Auch Patterns wie CQRS (Command Query Responsibility Segregation [1]) erfordern andere Tools. Als Alternativen zu Java und Spring Boot bieten sich in solchen Fällen die Programmiersprache Scala und die Library Akka HTTP an.

Nach einer kurzen Einführung von einer höheren Abstraktionsebene zeigt der Autor anhand beispielhafter Modelle für nebenläufige Berechnungen sowie eines Überblicks zur Funktionsweise von Actors und sogenannter finite-state machines (Endlicher Automat bzw. Zustandsmaschine [2]), wie sich die Library Akka HTTP in Kombination mit Scala für Microservices einsetzen lässt – bis hin zum Cluster-Betrieb und Zusammenspiel mit Kubernetes. Ein Demoprojekt, das die im Folgenden dargestellten Beispiele lauffähig enthält, liegt auf GitHub parat [3].

Abstrakt betrachtet ist ein REST Endpoint eine Funktion, die einen HTTP-Request entgegennimmt und eine HTTP-Response zurückgibt. Welche Funktion innerhalb einer Anwendung den HTTP-Request erhält, ist durch Annotationen, durch ein Route-File oder eine DSL (Domain-specific Language) festgelegt.

f(HttpRequest) => HttpResponse

Spring Boot weist dabei jedem HTTP-Request einen Thread aus dem eigenen Thread-Pool zu. Die Threads isolieren die Aufrufe voneinander. Jeder Aufruf läuft, aus seiner Sicht gesehen, allein und sequenziell ab. Nebenläufigkeit lässt sich bei mehreren aktuell bearbeiteten HTTP-Requests durch die parallel ablaufenden Threads erreichen. Je mehr HTTP-Requests ein Microservice gleichzeitig bearbeiten muss, desto mehr kann er parallel verarbeiten. Schwierig wird es erst dann, wenn eine Art von Synchronisierung zwischen den Threads erforderlich ist. Entwicklerinnen und Entwickler, die beim Implementieren Performance-Bottlenecks vermeiden wollen, stehen – abhängig von den spezifischen Anforderungen – vor ernsten Herausforderungen.

Akka HTTP setzt auf Actors [4], um die HTTP-Requests abzuarbeiten. Actors verkörpern im Vergleich zum Threading-Modell von Spring ein vollkommen anderes Modell für nebenläufige Berechnungen, das Carl Hewitt, Peter Bishop und Richard Steiger erstmals 1973 beschrieben [5]. Heute existieren Implementierungen unter anderen in Erlang und Scala (mit einer Java API) [6].

Ein Actor besteht aus einer Thread-sicheren Message Queue und einer Actor-Funktion, die die Messages der Queue nacheinander bearbeitet. Die Interaktion innerhalb der Anwendung erfolgt ausschließlich über Messages, die in Message-Queues abgelegt werden.

Sobald ein Actor eine Message in seiner Queue vorfindet, ist er bereit zur Ausführung. Der Scheduler des Actor-Systems weist den Actor einem Thread zu. Dieser führt die Funktion mit der Message als Parameter aus. Die Rückgabe der Funktion kann wiederum auch nur über Messages erfolgen. Parallelität wird durch gleichzeitiges Ausführen mehrerer Actors erreicht. Ein einzelner HTTP-Request kann mehrere Actors gleichzeitig beschäftigen. Wobei ein einzelner Actor zu einer Zeit immer nur von einem Thread ausgeführt wird. Das heißt, auch bei zwei Messages in der Queue wird eine nach der anderen bearbeitet.

Die Unterscheidung der einzelnen Actors erfolgt anhand ihrer ActorRef – vereinfacht gesagt der Eingabeseite der Message-Queue. Erwartet ein Actor eine Antwort auf seine Message, so ist ein Bestandteil der abgesendeten Message stets seine eigene ActorRef. Damit weiß der aufgerufene Actor, wohin er die Antwort senden soll.

Aus der Anfangszeit der Microservices-Ära hat sich die provokante und viel diskutierte Forderung gehalten, dass Microservices nicht mehr als 100 Zeilen Code enthalten sollen. Da Actors meist mit weniger als 100 Zeilen Code auskommen, könnten sie als die "wahren Microservices" gelten.

Beispiel eines Actors:

sealed trait Command // 1

final case class GetUsers(replyTo: ActorRef[Users]) extends Command // 2
final case class CreateUser(user: User, replyTo: ActorRef[ActionPerformed]) extends Command

final case class Users(users: Seq[User]) // 3
final case class ActionPerformed(description: String)

def apply(): Behavior[Command] = registry(Set.empty)

private def registry(users: Set[User]): Behavior[Command] = // 4
Behaviors.receiveMessage {
case GetUsers(replyTo) =>
replyTo ! Users(users.toSeq) // 5
Behaviors.same // 6
case reateUser(user, replyTo) =>
replyTo ! ActionPerformed(s"User ${user.name} created.")
registry(users + user)
    case GetUser(name, replyTo) =>
replyTo ! GetUserResponse(users.find(_.name == name))
Behaviors.same
    case DeleteUser(name, replyTo) =>
replyTo ! ActionPerformed(s"User $name deleted.")
registry(users.filterNot(_.name == name))
}

Da der Actor unter Umständen mehr als eine Art von Messages verarbeiten kann, sind alle in einem Trait (Interface) zusammengefasst (1) . Abschnitt (2) beschreibt die Message, die der Actor empfängt. Da es eine reine Abfrage ist, taucht lediglich die Antwortadresse auf. Die Klasse, die der aufrufende Code als Antwort erhält, ist in Abschnitt (3) zu sehen. Die Funktion des Actors besteht aus einem Pattern Match, der die verarbeitbaren Messages trennt (4). In Abschnitt (5) wird die Antwort an den Absender zurückgegeben. Dass der Actor sein Verhalten nicht ändert, beschreibt Abschnitt (6) (siehe hierzu auch das Kapitel "Finite-state Machine").

Da auf einen HTTP-Request auch eine Antwort erfolgen muss, kommt das Ask-Pattern zum Einsatz. Wie im Beispiel oben gezeigt, wird dazu die ActorRef des Actors mitgegeben, der die Antwort erhalten soll. Das muss nicht zwingend der Actor sein, der die Anfrage gestellt hat. Auch Weiterleiten und Delegieren der Bearbeitung sind möglich. Allerdings sind lange Verkettungen von Weiterleitungen oft schwer zu warten, sodass die ActorRef meistens auf den Absender zeigt. Wichtig dabei ist, dass die Antwort nicht der Rückgabewert der Funktion ist, sondern auch mittels Message übermittelt wird.

Der Rückgabewert der Funktion ist typischerweise ein Behavior[Command]. Ein Aktor kann nach jeder Message sein Verhalten ändern, also die Funktion, die die Messages verarbeitet oder auch die Parameter der Funktion. Eine Finite-state Machine hat immer einen Zustand und interpretiert abhängig davon das nächste Command. Über das Behaviour[Command] lässt sich somit auch ein volatiler Zustand halten [7]. Dabei wird eine Liste von User-Objekten als innerer Zustand von einem Aufruf zum nächsten über das Behaviour übergeben. Die Liste existiert allerdings nur so lange, wie es den Actor gibt. Ein Neustart des Actors oder des gesamten Actor-Systems führt zum Datenverlust – ein Verhalten, das beispielsweise beim Zwischenspeichern von Daten (Cache) sinnvoll ist.

Um den Zustand eines Actors auch über einen Neustart hinweg zu erhalten, müssen Entwicklerinnen und Entwickler auf die Actor-Persistenz [8] zurückgreifen. Dieses optionale, zusätzliche Framework unterstützt beim Sichern des Zustands in einer Datenbank, das mit Hilfe von Event Sourcing erfolgt. Beim Event Sourcing werden nicht der aktuelle Zustand gespeichert, sondern alle Events, die den Zustand verändern. Dadurch lässt sich nicht nur der aktuelle Zustand, sondern auch jeder frühere wiederherstellen. Damit die Zeit für das Wiederherstellen nicht ins Unendliche wächst, empfehlen sich regelmäßige Snapshots. Der jeweils letzte dient dann als Grundlage, und anschließend müssen nur noch alle späteren Events erneut bearbeitet werden.

Beispiel eines persistenten Actors:

sealed trait Command // 1 
 
final case class GetUsers(replyTo: ActorRef[Users]) extends Command // 2 
final case class CreateUser(user: User, replyTo: ActorRef[ActionPerformed])
  extends Command 

final case class Users(users: Seq[User]) // 3 
final case class ActionPerformed(description: String) 

// actor events 
sealed trait Event extends CborSerializable // 4
final case class CreateUserEvent(user: User) extends Event 
final case class DeleteUserEvent(name: String) extends Event 
 
// actor state 
final case class State(users: Set[User]) // 5
 
def apply(entityId: String): Behavior[Command] = 
  EventSourcedBehavior.withEnforcedReplies[Command, Event, State](
    persistenceId = PersistenceId.of("UserRegitry", entityId), 
    emptyState = State(Set.empty), 
    commandHandler = commandHandler, 
    eventHandler = eventHandler) 
 
val commandHandler: (State, Command) => ReplyEffect[Event, State] = { // 6
    (state, command) => command match { 
  case GetUsers(replyTo) =>  
    Effect.none.thenReply(replyTo)(state => Users(state.users.toSeq)) 
  case CreateUser(user, replyTo) => // 7
    Effect.persist(CreateUserEvent(user)).thenReply(replyTo)(_ => 
      ActionPerformed(s"User ${user.name} created.")) // 8
  case GetUser(name, replyTo) => 
    Effect.none.thenReply(replyTo)(state => 
      GetUserResponse(state.users.find(_.name == name)))
  case DeleteUser(name, replyTo) => 
    Effect.persist(DeleteUserEvent(name)).thenReply(replyTo)(_ => 
      ActionPerformed(s"User $name deleted."))
  }
}
 
val eventHandler: (State, Event) => State = { (state, event) => // 9
  event match { 
    case CreateUserEvent(user) => State(state.users + user) // 10
    case DeleteUserEvent(name) => State(state.users.filterNot(_.name == name)) 
  } 
}

Die Unterschiede zum beispielhaften Actor weiter oben zeigen sich ab Abschnitt (4) in den serialisierbaren Events, die den inneren Zustand des Actors verändern. Der innere Zustand des Actors wird über die Klasse State gespeichert (5). Die Abschnitte (6) und (7) beschreiben die Funktion des Actors für Commands sowie das Pattern zum Erstellen eines Users. In Abschnitt (8) werden das Event zum Wiederherstellen des Actors gespeichert und die Antwort gesendet. Die Funktion des Actors zur Wiederherstellung des inneren Zustands zeigt sich in Abschnitt (9), das Ändern des inneren Zustands erfolgt in Abschnitt (10).

Beim Erstellen eines Actors mit Actor-Persistenz ist eine PersistenceID notwendig. Ist diese ID schon in der Datenbank vorhanden, wird der frühere Zustand des Actors wiederhergestellt (siehe dazu ein Beispiel des Autors, das die gesamte Liste aller User-Objekte als inneren Zustand verwendet [9]).

Ein weiteres optionales Framework ermöglicht den Betrieb im Cluster [10]. In der ActorRef wird jetzt zusätzlich die IP des Hosts des jeweiligen Actor-Systems gespeichert. Um das Zusammenfinden und den Status der einzelnen Knoten im Cluster kümmert sich das Framework ebenfalls. Darüber hinaus sind Cluster-Singletons und Distributed Publish Subscribe (Pub/Sub) möglich. Für den Betrieb im Cluster ist Actor-Persistenz auch eine Voraussetzung – außer für den seltenen Fall, dass ausschließlich zustandslose Actors vorliegen. Da die Events in einer Datenbank liegen, lässt sich ein persistierter Actor in jedem beliebigen Knoten des Clusters neu erstellen.

Cluster-Singletons [11] sind ein mächtiges Werkzeug. Mit ihnen lässt sich sicherstellen, dass nur ein einziger Actor eines bestimmten Typs im gesamten Cluster erstellt wird. Der Actor wird auf dem ältesten Knoten im Cluster erstellt. Mit diesem Pattern lassen sich Cluster-weit verteilte Actors steuern. Die verteilten Actors sind dann üblicherweise Node Coordinator, die wiederum für verschiedene Child Actors zuständig sind. Über die Baumstruktur aus Cluster-Singleton, Node Coordinator und Childs lassen sich Child Actors im Cluster verteilen und einfach wiederfinden. Auch ein Rebalancing nach dem Hinzufügen oder Entfernen von Knoten ist schnell implementiert:

Ein einzelner Actor kann so einen einzelnen Vorgang abbilden, etwa wie im Beispielcode das Anlegen und Bearbeiten eines Nutzers [12] oder ein Online-Warenkorb vom ersten Artikel bis zum Bezahlen an der Kasse. Über einen eindeutigen Identifier lässt sich der bearbeitende Actor in einem beliebig großen Cluster finden und ihm dann die Messages zuschicken.

Veranschaulichen lässt sich das am Beispiel eines Actors in einer Prozessstrecke beim Beantragen eines Onlinekredites. Bei einem Kredit hält ein Actor alle Daten des Vorgangs. Wird der Antrag eine gewisse Zeit nicht mehr weiterbearbeitet, kann man den Actor löschen, um Speicher freizugeben. Kommen später doch wieder neue Daten hinzu, lässt sich der Vorgang mit den gespeicherten Events einfach wiederherstellen – und der Actor ist erneut bereit, den Vorgang fortsetzen zu können. Auch ein Timeout ist leicht zu implementieren: Stellen Entwickler nach Erstellen des Actors fest, dass die letzten Eingaben zu lange zurückliegen, können sie die erneute Dateneingabe einfach mit einer entsprechenden Fehlermeldung quittieren. Der Zustand des Actors wird entsprechend gesetzt und damit sind keine weiteren Änderungen möglich.

Da stets nur ein einziger Actor für einen Vorgang zuständig ist, der Messages nacheinander verarbeitet, treten keine Race Conditions auf, wie das folgende Beispiel aus der Praxis verdeutlicht. Im Rahmen eines Projekts einer Online-Klickstrecke für einen Antragsvorgang werden nach jeder Seite die Daten übermittelt. In dieser Konstellation treten – in seltenen Fällen – Datenverluste auf, wenn zwei Requests kurz nacheinander erfolgen.

Während der erste Request die Daten aus der Datenbank lädt, einen externen Service anfragt und anschließend alle Daten zurück in die Datenbank schreibt und der zweite Request ebenfalls die Daten liest, die übermittelten Daten einfügt und dann den kompletten Datensatz zurückschreibt, kann es zum Verlust von Daten kommen. Das passiert genau dann, wenn die Anfrage des externen Services zu lange dauert (mehrere Sekunden). Der zweite Request überholt in diesem Fall den ersten, sodass die von ihm gespeicherten Daten vom ersten Request überschrieben werden – eine klassische Race Condition.

Bei korrekter Implementierung mit Actors kann der Fall nicht auftreten. Der Actor bearbeitet einen Request nach dem anderen. Dabei ändert sich jeweils nur sein interner Zustand. Da für jeden Vorgang ein eigener Actor zuständig ist, entsteht auch kein Bottleneck. Es lassen sich beliebig viele Vorgänge gleichzeitig bearbeiten. Das Sichern des internen Zustands übernimmt die Actor-Persistenz.

Ein weiterer Vorteil gegenüber dem Read-Modify-Write Pattern liegt in der geringeren Anzahl der Datenbankzugriffe. Vor allem Lesezugriffe sind praktisch nur beim Wiederherstellen alter Vorgänge aus der Datenbank notwendig, und die lassen sich als eine einzige Query ausführen, die alle benötigten Datensätze der Reihe nach zurückgibt. Entwicklerinnen und Entwickler können die Datenbank daher stark auf Schreibzugriffe optimieren, beziehungsweise auf für diese Zwecke ausgelegte Datenbanken wie Cassandra, aber auch auf MariaDB, MySQL oder PostgreSQL zurückgreifen.

Neben den offensichtlichen Vorteilen gilt es allerdings auch Einschränkungen zu berücksichtigen. Im Fall von Cluster-Sharding beispielsweise, durch eine Unterbrechung in der Netzwerkverbindung innerhalb eines Clusters, müssen Entwickler verhindern, dass zwei Singletons eines Cluster-Coordinator entstehen. Auch wenn der Node, der ein Singleton erstellt hat, plötzlich offline ist, kann das ein Problem darstellen. Bis ein Node aus dem Cluster entfernt und anschließend ein neues Singleton erstellt ist, dauert es ein paar Sekunden. Die Anwendung muss mit dieser Unterbrechung umgehen können.

Um Microservices performant skalieren zu können, greifen Entwicklerinnen und Entwickler häufig auf die containerisierte Bereitstellung mit Kubernetes zurück. Wichtig dabei sind nicht nur das Hinzufügen neuer Ressourcen, sondern auch das Entfernen nicht mehr benötigter. Akka-Cluster und Kubernetes ergänzen sich bei der Skalierung. Fügt Kubernetes neue Instanzen hinzu, nimmt Akka diese in den Cluster auf. Kubernetes leitet alle Requests über eine Round-Robin-Verteilung gleichmäßig an alle Knoten weiter. Da sämtliche Vorgänge über Actors abgebildet sind, kann der Cluster intern die Requests zum passenden Actor routen. Neue Vorgänge werden an den Node Coordinators mit den wenigsten Vorgängen erstellt. Der Cluster balanciert sich selbst aus.

Beendet Kubernetes eine Instanz wegen geringer Auslastung, sollte zuvor idealerweise der Knoten aus dem Cluster entfernt werden, um dessen Restrukturieren zu beschleunigen. Die Actors auf den entfernten Knoten fallen dabei zwar einfach weg, lassen sich aber leicht auf einem anderen Node des Clusters wiederherstellen. Das sollte vorzugsweise auf einem Node geschehen, der aktuell weniger Actors verwaltet. Der Cluster balanciert sich auf diese Weise wieder aus.

Scala und die Akka HTTP Library bieten als Alternative zum REST-Endpoint-Ansatz und dem von Spring für nebenläufige Berechnungen genutzten Threading-Modell interessante Optionen für Microservices. Besonders die Möglichkeit, alle Requests zu einem Vorgang zu serialisieren und nacheinander abzuarbeiten, ist mit anderen Tools nicht immer so leicht umzusetzen. Mit dem Actor-Modell lassen sich nicht nur typische Probleme wie Race Conditions vermeiden, der Ansatz skaliert auch über mehrere Nodes bis hin zur containerisierten Bereitstellung von Microservices auf Clustern mit Kubernetes.

Wer mit den Möglichkeiten der Akka HTTP Library an Grenzen stößt, sollte einen Blick auf die darauf aufbauenden und von Lightbend gepflegten Frameworks Play und Lagom werfen. Während Play [13] als Web Application Framework für Java und Scala auf Basis von Akka und Akka Stream ein reaktives Modell und Skalierbarkeit bietet, ist das Lagom Framework [14] als Teil der Akka-Plattform generell darauf ausgerichtet, flexible, skalierbare Anwendungen auf Basis reaktiver Microservices zu entwickeln.

Christian Mang
ist beim Softwaredienstleister jambit in München tätig. In seiner Rolle als Senior Software Architect begleitet er bei jambit verschiedene Projekte [15] von Kunden im Bereich Banking & Insurance.

(map [16])


URL dieses Artikels:
https://www.heise.de/-5075590

Links in diesem Artikel:
[1] https://martinfowler.com/bliki/CQRS.html
[2] https://de.wikipedia.org/wiki/Endlicher_Automat
[3] https://github.com/chrmang/scala-microservice
[4] https://www.heise.de/blog/Aktor-basierter-Entwurf-3150346.html
[5] https://de.wikipedia.org/wiki/Actor_Model
[6] https://doc.akka.io/docs/akka/current/typed/actors.html
[7] https://github.com/chrmang/scala-microservice/blob/main/src/main/scala/com/jambit/memory/MemoryUserRegistry.scala
[8] https://doc.akka.io/docs/akka/current/typed/index-persistence.html
[9] https://github.com/chrmang/scala-microservice/blob/main/src/main/scala/com/jambit/database/DatabaseUserRegistry.scala
[10] https://doc.akka.io/docs/akka/current/typed/index-cluster.html
[11] https://doc.akka.io/docs/akka/current/typed/cluster-singleton.html
[12] https://github.com/chrmang/scala-microservice
[13] https://www.playframework.com/
[14] https://www.lightbend.com/lagom-framework-part-of-akka-platform
[15] https://www.jambit.com/?pk_campaign=heise-mang
[16] mailto:map@ix.de