Zum Inhalt springen
Deutsch

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.

Drei Eigenschaften definieren einen Actor in actor-ts:

  1. 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.
  2. 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 onReceive liest und mutierst du die Felder des Actors ohne Locks oder Atomics.
  3. 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 echte Actor-Instanz. Du kannst Refs über den Cluster weitergeben, sie speichern, sie an andere Actors übergeben; die darunterliegende Klasse siehst du nie.
import { Actor, ActorSystem, Props } from 'actor-ts';
type Cmd =
| { kind: 'inc' }
| { kind: 'reset' };
class Counter extends Actor<Cmd> {
private count = 0;
override onReceive(cmd: Cmd): void {
if (cmd.kind === 'inc') this.count++;
else if (cmd.kind === 'reset') this.count = 0;
this.log.info(`count is now ${this.count}`);
}
}

Drei Dinge, die auffallen:

  • Der Typparameter <Cmd> 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(cmd: Cmd): void ist der einzige Pflicht-Override. Die Basisklasse deklariert ihn abstract, 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.

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 eine Factory:

const system = ActorSystem.create('demo');
// Anonymer Actor — das System wählt einen Namen.
const counter = system.spawnAnonymous(Props.create(() => new Counter()));
// Benannter Actor — nützlich für Log-Zeilen + Pfad-basiertes Lookup.
const counter2 = system.spawn(Props.create(() => new Counter()), 'counter');

Props.create(...) packt die Factory + Supervisor-Strategie + Dispatcher-Wahl in einen Wert. Siehe Props für die volle Schnittstelle.

Die zurückgegebene ActorRef hat ein wichtiges Verb: tell(message).

counter.tell({ kind: 'inc' });
counter.tell({ kind: 'inc' });
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.

Innerhalb von onReceive (und den Lifecycle-Hooks) geben dir vier Referenzen Zugriff auf die Runtime:

class Echo extends Actor<{ text: string }> {
override onReceive(msg: { 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: msg.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.

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(msg: { line: string }): void {
this.handle.write(msg.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:

HookWann er läuftDefault
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.

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(msg: { kind: 'add-child'; name: string }): void {
const child = this.context.spawn(
Props.create(() => new Worker()),
msg.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.

onReceive kann async sein (gibt Promise<void> zurück):

class DbWriter extends Actor<{ row: Row }> {
override async onReceive(msg: { row: Row }): Promise<void> {
await this.db.insert(msg.row);
this.log.debug('wrote row ' + msg.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(msg: { 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(Props.create(() =>
new SingleShotWriter(this.db, msg.row)
));
}
}
  • 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 onReceive einen 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.