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.
Die vier Konstruktoren
Abschnitt betitelt „Die vier Konstruktoren“receive — Handler mit Context + Nachricht
Abschnitt betitelt „receive — Handler mit Context + 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.
receiveMessage — Handler ohne Context
Abschnitt betitelt „receiveMessage — Handler ohne Context“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) => ...).
receiveWithSignal — Handler + Lifecycle-Signale
Abschnitt betitelt „receiveWithSignal — Handler + Lifecycle-Signale“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-Kind | Wann |
|---|---|
'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.
Der Handler gehört zu diesem Behavior, nicht zum Actor. Ein
Übergang zu einem Behaviors.receive, das kein onSignal deklariert,
meldet ihn ab — eine Zustandsmaschine, die post-stop-Cleanup
braucht oder den Tod eines beobachteten Actors als Signal statt als
Nachricht will, deklariert onSignal also in jedem Zustand, der es
braucht:
const draining = Behaviors.receiveWithSignal<Message>( (context, message) => Behaviors.same, (context, signal) => { cleanUp(); return Behaviors.same; },);
// Behält das Cleanup — der nächste Zustand deklariert seinen eigenen Handler.const working = Behaviors.receiveWithSignal<Message>( (context, message) => draining, (context, signal) => { cleanUp(); return Behaviors.same; },);
// Verliert es — ein einfaches `receive` deklariert keine Signale, ab hier// feuert `post-stop` also nirgends und ein `Terminated` erreicht den// Receive-Handler wieder als gewöhnliche Nachricht.const forgetful = Behaviors.receiveWithSignal<Message>( (context, message) => Behaviors.receive((innerContext, innerMessage) => Behaviors.same), (context, signal) => { cleanUp(); return Behaviors.same; },);Die Sentinels sind die eine Ausnahme: Behaviors.stopped lässt den
Handler installiert, denn ein post-stop-Handler, der genau in dem
Moment aufhört zu funktionieren, in dem der Actor sich selbst stoppt,
wäre nutzlos.
setup — den Context einmal einfangen
Abschnitt betitelt „setup — den Context einmal einfangen“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.
Die Dekoratoren
Abschnitt betitelt „Die Dekoratoren“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.
withTimers
Abschnitt betitelt „withTimers“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.
withStash
Abschnitt betitelt „withStash“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 — einschließlich der beiden
wichtigsten Zusagen: unstashAll() legt die gepufferten Nachrichten
vorne in die Mailbox zurück, sodass sie vor allem behandelt werden,
was währenddessen ankam, und was beim Stoppen oder Neustarten noch
geparkt ist, geht zu Dead Letters, statt zu verschwinden.
Zwei Unterschiede zur Context-Form, beide beabsichtigt. Die Kapazität
ist die, die du withStash übergibst — pro Behavior, statt einer für den
ganzen Actor. Und stash(message) nimmt die Nachricht explizit, sie muss
also nicht die gerade behandelte sein — weshalb ein aus diesem Puffer
erzeugter Dead Letter auch keinen ursprünglichen Sender trägt.
supervise(behavior).onFailure(strategy)
Abschnitt betitelt „supervise(behavior).onFailure(strategy)“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.
maxRetries und withinTimeRangeMs begrenzen, wie oft Restart
feuern darf. Das Beispiel oben gewährt fünf Restarts pro
gleitender Minute; der sechste Fehler innerhalb dieser Minute
eskaliert stattdessen — der Fehler wird an die Zelle des Actors
weitergeworfen, wo die Strategie des Parents entscheidet.
maxRetries: -1, der Default einer handgebauten
OneForOneStrategy, ist unbegrenzt; maxRetries: 0 restartet gar
nicht erst.
Die Zählung gehört zum supervise-Wrapper, nicht zur Strategie —
ein Strategie-Wert, den du mehreren Behaviors gibst, verschafft
also jedem sein eigenes Kontingent. Sie summiert sich über die
Restarts, die sie zählt; nur ein neuer supervise-Wrapper
beginnt von vorn.
Die Eskalation jenseits der Grenze schichtet die beiden
Supervisoren, statt sie zu duplizieren. Ein Restart, den der
Parent gewährt, baut einen brandneuen Actor — dieses Kontingent pro
Behavior beginnt damit von vorn, und die äußere Grenze ist die
Strategie des Parents, standardmäßig zehn Restarts pro Minute. Ein
Behavior, das immer wirft, hört also nach maxRetries auf, an Ort
und Stelle zu kreisen, und stoppt endgültig, sobald auch das
Kontingent des Parents aufgebraucht ist.
Geltungsbereich: der Wrapper überlebt den Teilbaum
Abschnitt betitelt „Geltungsbereich: der Wrapper überlebt den Teilbaum“supervise installiert einen Geltungsbereich, und der hält so lange
wie der Actor. Ein Behavior, zu dem das umschlossene übergeht, ist
weiterhin überwacht, obwohl der Wrapper im zurückgegebenen Wert
nirgends auftaucht:
const supervised = Behaviors .supervise(Behaviors.receiveMessage<Message>((message) => nextPhase)) .onFailure(strategy);// Wirft `nextPhase`, wird das ebenfalls durch `strategy` geleitet.Das ist dieselbe Regel, der intercept folgt, und aus
demselben Grund: der Wrapper steuert seinen Seiteneffekt einmal bei,
und die Runtime erinnert sich an die Strategie. Behaviors.stopped
ist der Weg aus einem Supervisions-Geltungsbereich heraus; ein
Übergang ist es nicht.
Verschachtelung: die Geltungsbereiche schichten sich
Abschnitt betitelt „Verschachtelung: die Geltungsbereiche schichten sich“Zwei Wrapper sind zwei Geltungsbereiche, und der innere hat das erste Wort:
const layered = Behaviors .supervise( Behaviors.supervise(leaf).onFailure(retryTwice), // innen ) .onFailure(giveUpAndStop); // außenDie innerste Strategie entscheidet. Directive.Escalate — und ein
Restart-Kontingent, das der Geltungsbereich aufgebraucht hat — geben
denselben Fehler an den nächsten Bereich nach außen weiter, genau
das, was Escalate überall sonst im Framework bedeutet. Erst wenn es
hinter dem äußersten Wrapper herausfällt, verlässt der Fehler den
Actor, wo die Parent-Strategie der Zelle übernimmt. layered oben
versucht es also zweimal innerhalb des Actors und stoppt ihn dann,
statt dass die Erschöpfung des inneren Wrappers direkt zum Parent
geht.
Ein Geltungsbereich, den ein laufendes Behavior installiert — ein
Handler, der ein frisches Behaviors.supervise(...) zurückgibt —
verschachtelt sich in die bereits aktiven, statt sie zu verdrängen.
Ein Restart, den ein äußerer Bereich entscheidet, löst dessen eigenes Kind neu auf und baut damit die Wrapper darunter neu auf — die inneren Bereiche kommen also mit vollem Restart-Kontingent zurück. Das ist dieselbe Aufteilung, die ein Restart bereits auf Interceptoren anwendet: was innerhalb des Wrappers lebt, gehört zu dem, was neu startet.
Siehe Supervision für die Direktiven-Semantik; sie gelten in der typed-Form identisch — mit einem Unterschied darin, wie die Grenze gezählt wird (dort vermerkt).
intercept
Abschnitt betitelt „intercept“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.
monitor
Abschnitt betitelt „monitor“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.
logMessages
Abschnitt betitelt „logMessages“const traced = Behaviors.logMessages(orders);
const audited = Behaviors.logMessages(orders, { level: 'info', formatter: (message) => `order ${message.orderId}`,});| Option | Default | Bedeutung |
|---|---|---|
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. |
formatter | eingebaut | Rendert 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.
Die fünf Sentinels
Abschnitt betitelt „Die fünf Sentinels“Werte, die du aus einem Handler zurückgibst, um eine Übergangsentscheidung auszudrücken:
| Sentinel | Bedeutung |
|---|---|
Behaviors.same | Behalte das aktuelle Behavior. Der Handler-Closure läuft erneut bei der nächsten Nachricht. |
Behaviors.stopped | Stoppe den Actor. Äquivalent zu context.stopSelf() in der untyped-Form. |
Behaviors.unhandled | Diese Nachricht wird hier nicht behandelt; route zu Dead Letters. |
Behaviors.empty | Das Behavior akzeptiert Nachrichten, tut aber nichts. Nützlich als Platzhalter. |
Behaviors.ignore | Verwirf 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.
Ein Multi-Behavior-Beispiel
Abschnitt betitelt „Ein Multi-Behavior-Beispiel“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:
initializingstasht alles, bis einconfigureankommt, dann wechselt es nachready(url)nach dem Wiederabspielen des Stashs.readybehandelt Requests unter Verwendung der eingefangenenurl.
Die Übergänge sind explizite Returns; der Zustand lebt in
Closure-Parametern; es gibt kein this, um das man sich kümmern
muss.
Kompositions-Reihenfolge
Abschnitt betitelt „Kompositions-Reihenfolge“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.
supervise um supervise liest sich genauso und ist die eine
Komposition, in der die Reihenfolge bestimmt, wer entscheidet: der
innerste Wrapper wird zuerst gefragt und gibt den Fehler nach außen
weiter. Siehe
Verschachtelung
oben.
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.
Kollabieren ist aber nicht dasselbe wie Ablaufen. Ein
supervise-Geltungsbereich überlebt den Wert, der ihn installiert hat,
und deckt ab, wohin der Actor als nächstes übergeht; nur der
Wrapper-Knoten ist weg. Ein Signal-Handler ist das Gegenteil — er
ist ein Feld des receive-Behaviors, das ihn deklariert hat, und
verschwindet daher mit diesem Behavior.
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.
Wie es weitergeht
Abschnitt betitelt „Wie es weitergeht“- Typed Actor — die Runtime, die ein Behavior interpretiert.
- Spawn Typed —
system.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.
