Actor
Die Actor-Klasse ist das fundamentale Primitive in actor-ts. Jede
Komponente, die du durch Erweitern von Actor baust, bekommt eine
private Mailbox, eine single-threaded onReceive-Schleife,
Lifecycle-Hooks und eine Handvoll Context-Referenzen (self, sender,
system, log, context), um mit dem Rest der Runtime zu sprechen.
Alles andere im Framework — Supervision, Sharding, Persistenz, HTTP, Broker — baut auf den Primitives auf, die diese Seite abdeckt.
Was ein Actor ist
Abschnitt betitelt „Was ein Actor ist“Drei Eigenschaften definieren einen Actor in actor-ts:
- Eine private Mailbox. Nachrichten an den Actor landen in dieser Mailbox. Niemand — nicht einmal der Actor selbst — kann direkt in die Mailbox eines anderen Actors greifen.
- Eine single-threaded Nachrichtenschleife. Das Framework
verarbeitet Nachrichten aus der Mailbox eine nach der anderen. Zwei
Nachrichten laufen nie gleichzeitig für denselben Actor; innerhalb
von
onReceiveliest und mutierst du die Felder des Actors ohne Locks oder Atomics. - Ein Handle, keine Referenz auf die Instanz. Wenn du “eine
Nachricht an einen Actor sendest”, hast du in Wirklichkeit einen
ActorRef<T>— ein dünnes, ortstransparentes Handle. Das Framework hält die echteActor-Instanz. Du kannst Refs über den Cluster weitergeben, sie speichern, sie an andere Actors übergeben; die darunterliegende Klasse siehst du nie.
Einen definieren
Abschnitt betitelt „Einen definieren“import { match } from 'ts-pattern';import { Actor, ActorSystem } from 'actor-ts';
type IncrementCommand = { kind: 'increment' };type ResetCommand = { kind: 'reset' };type Command = IncrementCommand | ResetCommand;
class Counter extends Actor<Command> { private count = 0;
override onReceive(command: Command): void { match(command) .with({ kind: 'increment' }, () => this.onIncrement()) .with({ kind: 'reset' }, () => this.onReset()) .exhaustive(); this.log.info(`count is now ${this.count}`); }
private onIncrement(): void { this.count++; } private onReset(): void { this.count = 0; }}Drei Dinge, die auffallen:
- Der Typparameter
<Command>schränkt ein, welche Nachrichten dieser Actor akzeptiert.ref.tell({ kind: 'bogus' })ist ein TypeScript-Fehler an der Aufrufstelle. Siehe Nachrichten für die Discriminated-Union-Konvention, die diese Codebasis überall verwendet. override onReceive(command: Command): voidist der einzige Pflicht-Override. Die Basisklasse deklariert ihnabstract, der Compiler kompiliert also nicht, wenn du ihn vergisst.this.log,this.count— du hast vollen Zugriff auf Instanzfelder. Single-Threaded-Verarbeitung bedeutet, dass parallele Lese-/Schreibzugriffe kein Thema sind; du denkst über Zustand nach wie in einem Single-Threaded-Programm.
Einen spawnen
Abschnitt betitelt „Einen spawnen“Du machst nicht direkt new Counter(). Das Framework muss
kontrollieren, wann die Instanz erstellt wird (auf ihrem eigenen
Mailbox-Processing-Thread, nicht auf dem des Aufrufers), und es muss
Context injizieren (die self-Ref, die Supervisor-Strategie, die
Eltern-Referenz). Statt einer Instanz übergibst du also die Klasse
selbst:
const system = ActorSystem.create('demo');
// Anonymer Actor — das System wählt einen Namen.const counter = system.spawnAnonymous(Counter);
// Benannter Actor — nützlich für Log-Zeilen + Pfad-basiertes Lookup.const counter2 = system.spawn(Counter, 'counter');Ein Konstruktor mit Argumenten hat keine Null-Argument-Form, die
aufgerufen werden könnte — dieser Fall übergibt stattdessen eine Factory:
system.spawn(() => new Counter(database), 'counter'). Das optionale
dritte Argument, ActorOptions, trägt Supervisor-Strategie, Dispatcher-
und Mailbox-Wahl. Siehe Actors erzeugen
für die volle Schnittstelle.
Nachrichten senden
Abschnitt betitelt „Nachrichten senden“Die zurückgegebene ActorRef hat ein wichtiges Verb: tell(message).
counter.tell({ kind: 'increment' });counter.tell({ kind: 'increment' });counter.tell({ kind: 'reset' });tell ist fire-and-forget. Es legt die Nachricht in die Mailbox
und kehrt sofort zurück — der Actor verarbeitet sie in seinem eigenen
Rhythmus. Für Request/Response siehe
Ask-Pattern.
Die Context-Referenzen
Abschnitt betitelt „Die Context-Referenzen“Innerhalb von onReceive (und den Lifecycle-Hooks) geben dir vier
Referenzen Zugriff auf die Runtime:
class Echo extends Actor<{ text: string }> { override onReceive(message: { text: string }): void { // 1. `this.self` — eine Ref auf mich. Gib das weiter, wenn andere // Actors mit mir sprechen können sollen. this.log.info(`I am ${this.self.path}`);
// 2. `this.sender` — Option<ActorRef> für den Absender dieser // Nachricht. `None`, wenn kein Absender angehängt war (z.B. // `tell` von außerhalb des Actor-Systems). Verwende // `.forEach(...)`, um nur dann zu antworten, wenn es einen gibt. this.sender.forEach((replyTo) => replyTo.tell({ echo: message.text }));
// 3. `this.system` — das ActorSystem. Top-Level-Actors spawnen, // Timer planen, Extensions registrieren. void this.system;
// 4. `this.log` — ein Logger, der bereits mit dem Pfad des Actors // als strukturiertem Log-Kontext vorverdrahtet ist. this.log.debug('echo done');
// (5. `this.context` — der volle ActorContext für tiefere APIs: // `context.spawnAnonymous(...)` für Kinder, `context.watch(...)` für // Death-Watch, `context.become(...)` zum Verhaltenswechsel.) void this.context; }}sender ist Option<ActorRef>, weil nicht jede Nachricht einen Sender
hat: ein tell von außerhalb der Actor-Welt (z.B. von einem
HTTP-Handler) hängt None an. Verwende .forEach, um nur zu
antworten, wenn einer da ist, oder .getOrElse(system.deadLetters),
wenn du einen Fallback willst.
this.cluster, auf einem geclusterten System
Abschnitt betitelt „this.cluster, auf einem geclusterten System“Ein Actor in einem System, das einem Cluster beigetreten ist, erreicht diesen auf dieselbe Weise — ohne dass irgendetwas in den Konstruktor gereicht werden muss:
class RoomActor extends Actor<RoomMessage> { override preStart(): void { this.log.info(`hosted on ${this.cluster.selfAddress}`); this.log.info(`${this.cluster.upMembers().length} member(s) up`); }}this.cluster wirft, wenn das System nie beigetreten ist — unter der
Annahme, dass Code, der danach greift, bereits weiß, dass er geclustert
läuft. Denselben Kompromiss geht this.entityId ein. Wenn der Actor
in beiden Fällen funktionieren muss, frage this.context.cluster: das
ist ein Option<Cluster> und antwortet stattdessen mit None:
this.context.cluster.forEach((cluster) => cluster.subscribe(onEvent));Beide lesen bei jedem Zugriff auf system.cluster durch — ein Actor,
der bereits lief, als Cluster.join(...) fertig wurde, bekommt den
Cluster also mit. Siehe Cluster-Überblick.
Lifecycle-Hooks
Abschnitt betitelt „Lifecycle-Hooks“Die Basisklasse definiert vier Lifecycle-Hooks; alle sind standardmäßig No-Ops. Überschreibe die, die du brauchst.
class FileWriter extends Actor<{ line: string }> { private handle!: FileHandle;
override async preStart(): Promise<void> { this.handle = await fs.open(this.config.path, 'a'); this.log.info('opened ' + this.config.path); }
override onReceive(message: { line: string }): void { this.handle.write(message.line + '\n').catch((e) => this.log.warn(e)); }
override async postStop(): Promise<void> { await this.handle.close(); this.log.info('closed ' + this.config.path); }}Die vier Hooks:
| Hook | Wann er läuft | Default |
|---|---|---|
preStart() | Nach der Konstruktion, vor der ersten Nachricht. Dateien öffnen, mit Brokern verbinden, Subscriber registrieren. | No-Op |
postStop() | Nachdem der Actor gestoppt wurde (Kinder bereits gestoppt). Ressourcen schließen, deregistrieren. Läuft einmal. | No-Op |
preRestart(reason, message?) | Vor einem Restart, auf der gleich-zu-verwerfenden Instanz. Ruft standardmäßig postStop() auf. | ruft postStop() |
postRestart(reason) | Auf der frischen Instanz nach einem Restart. Ruft standardmäßig preStart() auf. | ruft preStart() |
Alle vier können Promise<void> zurückgeben — das Framework wartet
vor der nächsten Phase ab. Ein Throw in preStart wird an den
Supervisor eskaliert.
Sich selbst benennen — displayName()
Abschnitt betitelt „Sich selbst benennen — displayName()“Kein Lifecycle-Hook, aber die andere Methode, die sich zu überschreiben lohnt. Sie bestimmt, wie ein Actor in Log-Zeilen und im DevTools-Baum heißt; per Default sein voller Pfad:
class UserEntity extends Actor<UserCommand> { override displayName(): string { return `User(${this.entityId})`; }
override onReceive(command: UserCommand): void { this.log.info('recovery complete'); // → [...] INFO actor-ts://shop/.../entity-da76e1eb-... - User(test-user-590) - recovery complete }}Der Name kommt zur Zeile dazu, er ersetzt den Pfad nicht, und er wird
bei jedem Record aufgelöst — er darf also aus Zustand abgeleitet sein
und folgt einem Restart. Halt ihn billig; ein Throw fällt auf den Pfad
zurück. ActorOptions.withDisplayName(...) an der Spawn-Stelle sticht ihn,
genauso wie withSupervisorStrategy(...) supervisorStrategy()
sticht. Die ganze Geschichte in
Logging.
Actors können Kinder haben. Verwende context.spawn aus dem
onReceive der Eltern heraus:
class Parent extends Actor<{ kind: 'add-child'; name: string }> { override onReceive(message: { kind: 'add-child'; name: string }): void { const child = this.context.spawn(Worker, message.name); this.log.info(`spawned child ${child.path}`); }}Der volle Pfad des Kindes wird /<parent>/<child-name>. Kinder sind
an den Lebenszyklus der Eltern gebunden — wenn die Eltern stoppen,
stoppen zuerst alle Kinder (postStop läuft von unten nach oben).
Supervision-Entscheidungen für ein Kind gehen an die
Supervisor-Strategie der Eltern.
Siehe Actor-Pfade für die Pfad-Hierarchie und Supervision dafür, wie die Fehlerausbreitung zwischen Eltern und Kindern funktioniert.
Async onReceive
Abschnitt betitelt „Async onReceive“onReceive kann async sein (gibt Promise<void> zurück):
class DbWriter extends Actor<{ row: Row }> { override async onReceive(message: { row: Row }): Promise<void> { await this.db.insert(message.row); this.log.debug('wrote row ' + message.row.id); }}Das Framework wartet auf das zurückgegebene Promise, bevor es die
nächste Nachricht aus der Mailbox holt. Dass db.insert pro Actor
seriell läuft, ist also garantiert — kein Bedarf für einen
Transaktion-pro-Nachricht-Schutz.
Der Trade-off ist Durchsatz: ein langsames await blockiert die
Nachrichtenverarbeitung dieses Actors. Andere Actors sind nicht
betroffen (sie haben ihre eigenen Mailboxes), aber wenn du einen Actor
pro HTTP-Request erstellt hast und jeder 500 ms auf einen DB-Call
wartet, ist dein Durchsatz auf einen Request pro 500 ms pro Actor
begrenzt.
Der übliche Fix: einen Child-Actor pro lang laufender Task spawnen, den langsamen Op laufen lassen und ihn dem Parent das Ergebnis tellen lassen:
class DbWriterFanout extends Actor<{ row: Row }> { override onReceive(message: { row: Row }): void { // Spawn-and-forget: Kind erledigt den langsamen DB-Write, Parent // bleibt für die nächste Nachricht in der Mailbox verfügbar. this.context.spawnAnonymous(() => new SingleShotWriter(this.db, message.row)); }}Häufige Fallstricke
Abschnitt betitelt „Häufige Fallstricke“Wie es weitergeht
Abschnitt betitelt „Wie es weitergeht“- Nachrichten — die Discriminated-Union-Konvention, die jeder Actor für seinen eingehenden Typ verwendet.
- Actor-Pfade — wie Eltern/Kind-Hierarchien Adressen bilden.
- Supervision — was
passiert, wenn
onReceiveeinen Throw wirft. - Become und Stash — Verhaltenswechsel zur Laufzeit und Puffern von Nachrichten.
- Ask-Pattern — Request/Response, wenn Fire-and-Forget nicht reicht.
Die Actor-Klassen-API-Referenz hat die
vollständige Signatur für jede hier diskutierte Methode.
