Zum Inhalt springen
Deutsch

Nachrichten

Eine Nachricht ist jeder Wert, der an einen Actor gesendet wird. Das Typsystem erzwingt, welche Nachrichten ein gegebener Actor akzeptiert (über den Actor<TMsg>-Typparameter), und die Runtime liefert Nachrichten eine nach der anderen an das onReceive des Actors aus.

Diese Seite deckt die Konventionen ab, denen actor-ts-Code beim Strukturieren von Nachrichten folgt — wie sie aussehen, wie du auf ihnen dispatched, was du NICHT in sie packen solltest und welche Muster am häufigsten vorkommen.

Das wichtigste einzelne Muster in dieser Codebasis: Nachrichten sind Discriminated Unions, getaggt mit einem kind-Feld.

type CounterCmd =
| { readonly kind: 'inc' }
| { readonly kind: 'dec' }
| { readonly kind: 'set'; readonly to: number }
| { readonly kind: 'get'; readonly replyTo: ActorRef<number> };

Diese Form bringt drei Dinge:

  1. Compile-time-Exhaustiveness. match(cmd).with(...).exhaustive() (über ts-pattern) weigert sich zu kompilieren, wenn du einen neuen kind hinzufügst und vergisst, ihn zu behandeln. Die actor-ts-Codebasis verwendet dieses Muster überall — siehe Pattern Matching.
  2. Typ-Narrowing innerhalb von Handlern. Innerhalb des .with({ kind: 'set' }, ...)-Arms ist m.to automatisch als number typisiert — keine Casts, kein as never.
  3. Stabilität des Wire-Formats. Wenn eine Nachricht eine Prozess- oder Cluster-Grenze überquert, liest der Serializer kind, um zu entscheiden, wie er den Wert wieder zusammensetzt. Siehe Serialisierung.

Die Konvention ist kind: string, klein geschrieben, kebab-case für mehrere Wörter. Großgeschriebenes Kind funktioniert auch, ist aber nicht das, was das Framework verwendet.

import { Actor, ActorSystem, Props, type ActorRef } from 'actor-ts';
import { match } from 'ts-pattern';
type CounterCmd =
| { readonly kind: 'inc' }
| { readonly kind: 'dec' }
| { readonly kind: 'get'; readonly replyTo: ActorRef<number> };
class Counter extends Actor<CounterCmd> {
private count = 0;
override onReceive(cmd: CounterCmd): void {
match(cmd)
.with({ kind: 'inc' }, () => { this.count++; })
.with({ kind: 'dec' }, () => { this.count--; })
.with({ kind: 'get' }, (m) => m.replyTo.tell(this.count))
.exhaustive();
}
}
const system = ActorSystem.create('counters');
const counter = system.spawnAnonymous(Props.create(() => new Counter()));
counter.tell({ kind: 'inc' });
counter.tell({ kind: 'inc' });
counter.tell({ kind: 'inc' });
// Später nach dem Count über eine Reply-to-Ref fragen — siehe Ask-Pattern.

Wenn du später eine kind: 'reset'-Variante zu CounterCmd hinzufügst, schlägt das .exhaustive()-Compile fehl, bis du einen .with({ kind: 'reset' }, ...)-Arm hinzufügst. Kein Runtime-Crash, keine still verschluckte Nachricht — der Compiler fängt es ab.

Nachrichten sollten unveränderlich sein, sobald sie eingereiht sind. Zwei Gründe:

  1. Dieselbe Nachricht kann in manchen Szenarien zweimal verarbeitet werden — Restart-nach-Fehler spielt gepufferte Nachrichten erneut ab; Reliable-Delivery sendet unbestätigte erneut. Mutation zwischen Auslieferungen führt zu “Phantom”-Zustandsänderungen.
  2. Cross-Process-Sends serialisieren die Nachricht beim Senden und deserialisieren sie beim Empfangen. Der Empfänger bekommt eine Kopie; das Mutieren des Sender-Objekts nach tell hat keinen Effekt auf die Kopie — aber diese Asymmetrie erzeugt verwirrende Bugs (der lokale Fall sieht die Mutation, der Remote-Fall nicht).

Praktische Faustregeln:

  • Verwende readonly-Eigenschaften (readonly kind, readonly replyTo, …). Der TypeScript-Compiler fängt dann versehentliche Schreibzugriffe ab.
  • Bevorzuge schlichte Objekte ({ kind: 'x', n: 1 }) gegenüber Klassen — sie serialisieren trivial, keine Methoden-Verluste über Prozessgrenzen hinweg.
  • Behandle Arrays / verschachtelte Objekte innerhalb einer Nachricht als tief unveränderlich. Wenn du “updaten” musst, gib eine neue Nachricht zurück; mutiere nicht die eingehende.

tell ist Fire-and-Forget. Für Request/Response ist die Konvention, ein replyTo: ActorRef<ReplyType>-Feld in der Request-Nachricht zu inkludieren:

type Get = { readonly kind: 'get'; readonly replyTo: ActorRef<number> };
type ReplyOK = { readonly kind: 'reply-ok'; readonly value: number };
class Counter extends Actor<Get> {
private count = 42;
override onReceive(cmd: Get): void {
cmd.replyTo.tell(this.count);
}
}

Der Aufrufer liefert eine Ref, an die der Actor die Antwort sendet. Drei Quellen für diese Ref:

  • this.self, wenn der Aufrufer ein anderer Actor ist, der die Antwort an sich selbst geliefert haben will.
  • this.sender, wenn der Aufrufer auf eine frühere Nachricht reagiert und die Antwort an den ursprünglichen Frager gehen soll. Hinweis: this.sender ist Option<ActorRef> — leer, wenn die Nachricht von außerhalb des Actor-Systems kam.
  • Eine wegwerfbare “Ask”-Probe — was das Ask-Pattern für dich baut. Gibt ein Promise<Reply> zurück.

Das Reply-to-Ref-Idiom ist umständlicher als ein Methodenaufruf, bringt aber drei Dinge: Requests und Replies sind zeitlich entkoppelt (kein await-Chain), die Antwort kann von einem anderen Actor kommen als dem, der gefragt wurde, und derselbe Code funktioniert lokal und cross-Cluster.

Ein häufiger Formfehler: replyTo: ActorRef<number> zu deklarieren, wenn der Actor manchmal mit { kind: 'reply-ok' } und manchmal mit { kind: 'reply-error' } antwortet.

Verwende eine Discriminated Union auch für den Reply-Typ:

type GetReply =
| { readonly kind: 'reply-ok'; readonly value: number }
| { readonly kind: 'reply-error'; readonly reason: string };
type Get = { readonly kind: 'get'; readonly replyTo: ActorRef<GetReply> };

Das onReceive des Aufrufers kann dann auf dem kind der Antwort match()en, um die Erfolgs- und Fehlerpfade auf dieselbe Weise zu unterscheiden, wie es seine eigenen Befehle dispatched.

Für Actors, die Events persistieren (PersistentActor), trennt das Framework Commands (was zu tun ist) von Events (was bereits passiert ist):

type DepositCmd = { readonly kind: 'deposit'; readonly amount: number };
type Deposited = { readonly kind: 'deposited'; readonly amount: number; readonly ts: number };
class Account extends PersistentActor<DepositCmd, Deposited, { balance: number }> {
// Command-Handler: validieren + Event persistieren
async onCommand(state, cmd: DepositCmd): Promise<void> {
if (cmd.kind === 'deposit') {
const event: Deposited = { kind: 'deposited', amount: cmd.amount, ts: Date.now() };
await this.persist(event, () => { /* Seiteneffekte nach dem Persistieren */ });
}
}
// Event-Handler: nur Zustands-Update — läuft sowohl beim Schreiben als auch bei Recovery
onEvent(state, event: Deposited): { balance: number } {
if (event.kind === 'deposited') return { balance: state.balance + event.amount };
return state;
}
}

Der Split ist wichtig, weil Events der dauerhafte Record sind, die beim Restart erneut abgespielt werden. Commands sind transient (die Anfrage, die das Event erzeugt hat); Events sind ewig (der Journal-Eintrag). Sie eindeutig zu benennen — Vergangenheitsform für Events (Deposited), Imperativ für Commands (DepositCmd) — hält die konzeptionelle Linie klar.

Siehe PersistentActor für die vollständige Event-Sourcing-Geschichte.

  • Pattern Matching — das match().exhaustive()-Idiom, das in jedem Beispiel auf dieser Seite verwendet wird.
  • Ask-Patternref.ask(msg, timeout) gibt ein Promise für die Antwort zurück und vermeidet den manuellen Reply-to-Ref-Tanz.
  • Actor — die Basisklasse, die onReceive-Signatur, die Context-Referenzen, die Reply-Refs produzieren.
  • Serialisierung — was mit deinen Nachrichten passiert, wenn sie eine Prozess-/Cluster-Grenze überqueren; wie du eigene Serializer registrierst, wenn der JSON-Default nicht passt.
  • PersistentActor — wo der Cmd-vs-Event-Split tragend wird.