Zum Inhalt springen
Deutsch

Behaviors

Der Behaviors-Namespace ist eine Sammlung von Kombinatoren, die Behavior<T>-Werte bauen. Ein Behavior beschreibt, was der Actor tut, wenn die nächste Nachricht ankommt — und welches Behavior er danach annimmt. Die Lebenszeit eines Actors ist nur eine Sequenz von Behaviors: jeder Handler gibt das nächste zurück.

import { ActorSystem, Behaviors } from 'actor-ts';
type Message = { kind: 'tick' };
const ticker = Behaviors.receive<Message>((context, message) => {
context.log.info(`tick at ${Date.now()}`);
return Behaviors.same; // bleibe ein Ticker
});
const system = ActorSystem.create('demo');
const ref = system.spawnTypedAnonymous(ticker);

Behaviors.receive baut das häufigste Behavior: einen Handler, der auf jeder Nachricht läuft und das nächste Behavior zurückgibt. Der same-Sentinel sagt “bleibe wie ich bin” — derselbe Closure behandelt die nächste Nachricht.

const counter = (n: number): Behavior<Message> => Behaviors.receive<Message>((context, message) =>
match(message)
.with({ kind: 'increment' }, () => counter(n + 1))
.with({ kind: 'decrement' }, () => counter(n - 1))
.with({ kind: 'get' }, (m) => { m.replyTo.tell(n); return Behaviors.same; })
.exhaustive());

Der Handler empfängt sowohl einen TypedActorContext<T> (zum Spawnen von Kindern, Loggen, Beobachten) als auch die Nachricht. Gibt das nächste Behavior zurück — Behaviors.same behält den Closure; ein frisches counter(n + 1) nimmt einen neuen Closure mit aktualisiertem Zustand an.

const counter = (n: number): Behavior<Message> => Behaviors.receiveMessage<Message>((message) =>
match(message)
.with({ kind: 'increment' }, () => counter(n + 1))
.otherwise(() => Behaviors.same));

Shortcut für den häufigen Fall, dass du den Context nicht brauchst. Äquivalent zu Behaviors.receive((_context, message) => ...).

const watcher = Behaviors.receiveWithSignal<Message>(
(context, message) => {
// User-Nachricht behandeln...
return Behaviors.same;
},
(context, signal) => match(signal)
.with({ kind: 'terminated' }, (s) => {
context.log.info(`watched actor ${s.ref.path} stopped`);
return Behaviors.same;
})
.otherwise(() => Behaviors.same),
);

Der Signal-Handler feuert für Lifecycle-Events:

Signal-KindWann
'post-stop'Der Actor stoppt. Verwende für Cleanup.
'pre-restart'Der Supervisor wird den Actor gleich neu starten. signal.reason ist der Fehler.
'terminated'Ein beobachteter Actor hat gestoppt. signal.ref ist seine Ref.

Das ist das typed-DSL-Äquivalent zum Überschreiben von postStop, preRestart und dem Behandeln von Terminated-Nachrichten in der untyped-Form.

const myActor = Behaviors.setup<Message>((context) => {
context.log.info(`I'm starting at ${context.path}`);
const helper = context.spawn(helperBehavior, 'helper');
return Behaviors.receive((_context, message) => {
helper.tell(message); // helper im Closure eingefangen
return Behaviors.same;
});
});

setup läuft einmal beim Start des Actors. Verwende es für einmalige Initialisierung, die der Receive-Handler einschließen soll: Kinder spawnen, context.self für die Kinder zum Wissen einfangen, externe Verbindungen öffnen.

Hier benennt sich ein Behavior auch selbst. Jedes Behavior läuft in derselben TypedActor-Klasse, es gibt also keine eigene Subklasse, auf der du Actor.displayName() überschreiben könntest — context.setDisplayName ist der Weg dorthin, mit derselben Wirkung: Log-Zeilen und der DevTools-Baum bekommen neben dem Pfad ein lesbares Label.

const cart = (customerId: string): Behavior<Message> => Behaviors.setup((context) => {
context.setDisplayName(`Cart(${customerId})`);
return Behaviors.receive((_context, message) => { /* ... */ return Behaviors.same; });
});

Ohne das meldet ein ganzer Baum typed Actors TypedActor als Klasse, was nichts darüber sagt, welcher welcher ist. Ruf es jederzeit auf, nicht nur aus setup — ein Name, der sich erst nach der ersten Nachricht ergibt, lässt sich dann setzen. Für einen Namen, den die Spawn-Stelle schon kennt, tut ActorOptions.withDisplayName(...) dasselbe, ohne das Behavior zu betreten.

Sechs Kombinatoren wickeln ein anderes Behavior mit zusätzlichen Fähigkeiten. Die ersten drei geben dem inneren Behavior etwas, das es allein nicht erreichen könnte — einen Timer-Scheduler, einen Stash-Puffer, einen Supervisor. Die letzten drei sitzen davor, auf dem Weg, den jede Nachricht nimmt.

import { Behaviors, type TimerScheduler } from 'actor-ts';
const heartbeat = Behaviors.withTimers<Message>((timers) => {
timers.startTimerWithFixedDelay('hb', { kind: 'tick' }, 5_000);
return Behaviors.receiveMessage((message) =>
match(message)
.with({ kind: 'tick' }, () => { console.log('heartbeat'); return Behaviors.same; })
.otherwise(() => Behaviors.same));
});

Die TimerScheduler-API ist dieselbe, die context.timers in der untyped-Form bietet. withTimers fängt sie in einem Closure ein, sodass der Receive-Handler Zugriff hat, ohne bei jeder Nachricht über context.timers gehen zu müssen.

const init = Behaviors.withStash<Message>(100, (stash) => {
return Behaviors.receive((context, message) =>
match(message)
.with({ kind: 'ready' }, () => {
stash.unstashAll(); // alle gepufferten Nachrichten erneut abspielen
return ready;
})
.otherwise((m) => {
stash.stash(m); // alles andere für später parken
return Behaviors.same;
}));
});
const ready = Behaviors.receive<Message>((context, message) => {
// Nachrichten normal behandeln
return Behaviors.same;
});

Kapazitäts-gebundener Stash, mit stash / unstashAll / isEmpty / isFull / size. Gleiche Semantik wie das untyped context.stash, exponiert als Wert statt über Context.

import { Behaviors, OneForOneStrategy, Directive } from 'actor-ts';
const supervised = Behaviors
.supervise(myReceiveBehavior)
.onFailure(new OneForOneStrategy(
(err) => Directive.Restart,
{ maxRetries: 5, withinTimeRangeMs: 60_000 },
));

Wickele ein Behavior mit einer Supervisor-Strategie. Vom inneren Handler geworfene Fehler werden durch die Strategie geleitet — Restart re-initialisiert das Behavior (zurück zur initialen Form), Stop terminiert, Resume überspringt die fehlschlagende Nachricht.

Siehe Supervision für die Direktiven-Semantik; sie gelten in der typed-Form identisch.

const guarded = Behaviors.intercept<Message>(inner, (context, message, next) => {
if (message.kind === 'ping') return Behaviors.same; // verwerfen — inner sieht sie nie
return next(context, message); // oder delegieren
});

Der Interceptor läuft bei jeder Nachricht zuerst und entscheidet, was danach passiert: next(context, message) aufrufen, um zu delegieren, eine andere Nachricht übergeben, um sie auf dem Weg hinein zu transformieren, oder ein Behavior zurückgeben, ohne next überhaupt aufzurufen, um sie zu verwerfen. Was er zurückgibt, wird zum nächsten Behavior des inneren Behaviors.

Der Wrapper überlebt die Übergänge des inneren Behaviors. Das ist der Teil, den man sich merken sollte: ein inneres Behaviors.receive, das bei jeder Nachricht ein frisches Behavior zurückgibt — die normale Form einer Zustandsmaschine — wird auch bei der nächsten abgefangen, und bei jeder danach. Der einzige Weg hinaus ist Behaviors.stopped, wo es nichts mehr abzufangen gibt.

Vom Interceptor geworfene Fehler werden genau wie Fehler des inneren Handlers behandelt: sie erreichen ein umschließendes supervise. Das Abfangen erfasst nur User-Nachrichten; Lifecycle-Signale gehen direkt an den Handler von receiveWithSignal.

Der Typ ist T → T — ein Interceptor beobachtet, transformiert oder verwirft, er ändert nie den Nachrichtentyp des Actors.

const audit = system.spawnTyped(auditBehavior, 'audit');
const monitored = Behaviors.monitor(audit, orders);

Leitet jede Nachricht an audit weiter, bevor orders sie behandelt. Nützlich für Audit-Trails und, in Tests, für eine Probe, die auf den vom Actor empfangenen Verkehr prüft:

const probe = kit.createTestProbe();
const ref = kit.system.spawnTypedAnonymous(Behaviors.monitor(probe, orders));

Erst weiterleiten, dann zustellen ist die bewusste Reihenfolge: der Monitor sieht eine Nachricht auch dann, wenn ihre Behandlung den Actor zum Absturz bringt — genau der Fall, für den man die Spur am dringendsten will. Die Zustellung ist Fire-and-Forget und ihre Fehler werden geschluckt: ein kaputter Abgriff darf den Actor nicht mit sich reißen.

const traced = Behaviors.logMessages(orders);
const audited = Behaviors.logMessages(orders, {
level: 'info',
formatter: (message) => `order ${message.orderId}`,
});
OptionDefaultBedeutung
level'debug''debug' oder 'info'. Jede Nachricht zu loggen ist eine Diagnose; sie auf warn oder error zu melden würde genau das Signal vergiften, nach dem ein Operator filtert — deshalb gibt es die nicht.
formattereingebautRendert die ganze Zeile. Darf nicht werfen — tut er es doch, wird stattdessen die eingebaute Zeile ausgegeben, denn eine Diagnose, die den beobachteten Actor umbringt, ist schlimmer als eine, die sich etwas schlechter liest.

Die eingebaute Zeile ist received <kind> und benennt die Nachricht über ihre Diskriminante. Bei einer Klasseninstanz fällt sie auf den Klassennamen zurück, danach auf typeof — ein blankes Objektliteral meldet Object als Konstruktor, was nichts aussagen würde.

Die Zeile wird nur gebaut, wenn der Logger des Actors sie tatsächlich ausgeben würde; das Ganze in einem System stehen zu lassen, das auf warn loggt, kostet also einen Vergleich pro Nachricht statt eines formatierten Strings.

Werte, die du aus einem Handler zurückgibst, um eine Übergangsentscheidung auszudrücken:

SentinelBedeutung
Behaviors.sameBehalte das aktuelle Behavior. Der Handler-Closure läuft erneut bei der nächsten Nachricht.
Behaviors.stoppedStoppe den Actor. Äquivalent zu context.stopSelf() in der untyped-Form.
Behaviors.unhandledDiese Nachricht wird hier nicht behandelt; route zu Dead Letters.
Behaviors.emptyDas Behavior akzeptiert Nachrichten, tut aber nichts. Nützlich als Platzhalter.
Behaviors.ignoreVerwirf jede Nachricht still (kein Dead-Letter-Routing).

Die ersten drei sind im Alltag am nützlichsten. empty und ignore existieren für Sonderfälle — ein “dieser Actor ist absichtlich erstmal still”-Stub oder eine Senke, die Traffic schlucken soll.

import { match } from 'ts-pattern';
import { ActorSystem, Behaviors, type Behavior } from 'actor-ts';
type ConfigureMessage = { kind: 'configure'; url: string };
type RequestMessage = { kind: 'request'; payload: string };
type Message = ConfigureMessage | RequestMessage;
const initializing = Behaviors.withStash<Message>(100, (stash) =>
Behaviors.receive<Message>((context, message) =>
match(message)
.with({ kind: 'configure' }, (m) => {
stash.unstashAll();
return ready(m.url);
})
.otherwise((m) => {
stash.stash(m);
return Behaviors.same;
})),
);
const ready = (url: string): Behavior<Message> =>
Behaviors.receive<Message>((context, message) =>
match(message)
.with({ kind: 'request' }, (m) => {
context.log.info(`POST ${url}: ${m.payload}`);
return Behaviors.same;
})
.otherwise(() => Behaviors.same));
const system = ActorSystem.create('demo');
system.spawnTypedAnonymous(initializing);

Zwei Behaviors:

  • initializing stasht alles, bis ein configure ankommt, dann wechselt es nach ready(url) nach dem Wiederabspielen des Stashs.
  • ready behandelt Requests unter Verwendung der eingefangenen url.

Die Übergänge sind explizite Returns; der Zustand lebt in Closure-Parametern; es gibt kein this, um das man sich kümmern muss.

Dekoratoren komponieren von außen nach innen. Behaviors.supervise(Behaviors.withTimers(...)) bedeutet “supervise das Timer-verwendende Behavior”; Behaviors.withTimers(Behaviors.supervise(...)) bedeutet “gib dem supervised Inneren die Timer.” In der Praxis:

const supervised = Behaviors
.supervise(Behaviors.withTimers((timers) =>
Behaviors.receive((context, message) => Behaviors.same)
))
.onFailure(strategy);

supervise ist außen um das withTimers, die Strategie überwacht also die ganze Konstruktion. Das ist fast immer die richtige Verschachtelung.

Interceptoren liest man genauso, und weil sie bei jeder Nachricht laufen, ist die Reihenfolge direkt beobachtbar:

const traced = Behaviors.logMessages(Behaviors.monitor(auditRef, inner));

logMessages ist ganz außen, loggt also zuerst, dann leitet der Monitor weiter, dann behandelt inner. Jeder Wrapper entscheidet, ob der nächste weiter innen die Nachricht überhaupt bekommt — ein Interceptor, der ohne Aufruf von next zurückkehrt, stoppt alles darunter.

Eine Asymmetrie ist wissenswert. Die anderen Dekoratoren werden beim Start des Actors aufgelöst: sie steuern ihren Seiteneffekt bei (Timer einfangen, Strategie installieren) und kollabieren in das Behavior, das sie erzeugt haben. Ein Interceptor kann das nicht, denn er muss auch bei der nächsten Nachricht da sein — er bleibt also um das gewickelt, was das innere Behavior wird.

Dieselbe Unterscheidung entscheidet, was ein Restart neu aufbaut. supervise startet das neu, was es umschließt: ein Interceptor innerhalb des Wrappers gehört damit zum frischen Behavior und wird mit ihm neu aufgebaut; einer außerhalb nicht — er beobachtet über den Restart hinweg weiter. In beiden Verschachtelungen bleibt er danach genau einmal installiert: ein crash-loopender Actor sammelt keine Kopien seines eigenen Monitors an, und monitor fängt nicht an, eine Nachricht doppelt zuzustellen, weil der Actor vorher neu gestartet ist.

  • Typed Actor — die Runtime, die ein Behavior interpretiert.
  • Spawn Typedsystem.spawnTyped, context.spawnTyped, typedActor.
  • Supervision — was Behaviors.supervise(...).onFailure(...) intern verwendet.
  • Become und Stash (untyped) — die OO-Äquivalente zu Behaviors.withStash + Behavior-Switching via Return-Werte.