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.
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.
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.
Siehe Supervision für die Direktiven-Semantik; sie gelten in der typed-Form identisch.
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.
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.
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.
