Utilities
Das Framework löst für sich selbst eine Handvoll Probleme, die in dem Code
wieder auftauchen, den du darauf aufbaust: „es gibt vielleicht keinen Wert”
ausdrücken, ohne null zu benutzen; einen Fehlschlag tragen, ohne zu werfen;
etwas einmalig bei der ersten Nutzung berechnen; ein Paar von beiden Enden her
nachschlagen; etwas so benennen, dass der Name nicht erraten werden kann; einen
Wert auf einem Pfad ausgeben, auf dem ein Wurf den gerade gemeldeten Fehler
ersetzen würde; und von einem Paket abhängen, das du nicht verpflichtend machen
willst.
Das sind die Antworten, die es ohnehin schon benutzt — exportiert über den
Haupteinstiegspunkt. Keine davon ist Pflicht, um actor-ts zu benutzen: greif
zu einer, wenn du das Problem hast, das sie löst.
import { BidirectionalMap, BidirectionalMultiMap, Lazy, none, safeStringify, some, tryOf,} from 'actor-ts';Option — ein Wert, den es vielleicht nicht gibt
Abschnitt betitelt „Option — ein Wert, den es vielleicht nicht gibt“Option<T> ist Some<T> | None. Zieh es T | null vor, wenn eine Domänen-API
etwas zurückgibt oder ein Feld lange lebt: der Typ erzwingt, dass der leere Fall
an der Nutzungsstelle behandelt wird, statt dass man an ihn denken muss.
import { fromNullable, none, some, type Option } from 'actor-ts';
const leader: Option<string> = some('node-a');leader.map((address) => address.toUpperCase()).getOrElse('<none>'); // 'NODE-A'none.getOrElse('<none>'); // '<none>'
fromNullable(process.env.SEED_NODE); // Some | None, aus einem Nullable| Export | Zweck |
|---|---|
some(value) / none | Erzeugt eine. none ist eine geteilte Instanz, keine Factory. |
fromNullable(value) | null / undefined → None, alles andere → Some. |
fromPredicate(value, pred) | Some, wenn das Prädikat hält, sonst None. |
firstSome(...options) | Das erste Some, sonst None. |
Die API spiegelt Scalas, wo es praktikabel ist: map, flatMap, filter,
filterNot, fold, getOrElse, orElse, exists, forall, contains,
forEach, toArray, toNullable, isSome, isNone. isSome() und
isNone() sind Type Guards, und beide Klassen funktionieren mit
P.instanceOf(Some) aus ts-pattern.
An drei Stellen ist ein Nullable weiterhin die richtige Wahl, und das Framework
nutzt es dort bewusst: in Feldern auf heißen Pfaden, wo der Wrapper im Profil
auftauchen würde; an Serialisierungsgrenzen, die über null round-trippen
müssen; und bei optionalen Parametern mit bequemen Defaults.
Try — eine Berechnung, die geworfen haben könnte
Abschnitt betitelt „Try — eine Berechnung, die geworfen haben könnte“Try<T> ist Success<T> | Failure. Es macht aus einem throw einen Wert, den
du weiterreichen kannst — genau das brauchst du, wenn der Fehlschlag reisen
soll: in eine Nachricht, über ein ask, in eine Zustandsmaschine.
import { tryOf } from 'actor-ts';
const parsed = tryOf(() => JSON.parse(payload));
parsed .map((body) => body.userId) .recover(() => 'anonymous') .getOrElse('anonymous');tryOf(compute) führt die Funktion aus und fängt, was sie wirft;
success(value) und failure(error) erzeugen direkt eines. Über die
gemeinsamen map / flatMap / fold / getOrElse / orElse / filter /
forEach hinaus bietet Try recover (Fehler auf einen Wert abbilden),
recoverWith (auf ein weiteres Try), get (erneut werfen), toNullable und
toError. trySequence(tries) macht aus einem Array von Try<T> ein
Try<T[]>, das beim ersten Fehlschlag scheitert.
Either — zwei Ausgänge, von denen keiner ein Fehler ist
Abschnitt betitelt „Either — zwei Ausgänge, von denen keiner ein Fehler ist“Either<L, R> ist Left<L> | Right<R>, rechtsbetont: map und flatMap
arbeiten auf der rechten Seite. Nimm es statt Try, wenn die Fehlerseite ein
Domänenwert ist, den du typisiert haben willst — ein Validierungsbefund, ein
Ablehnungsgrund — und kein geworfener Fehler.
import { left, right, type Either } from 'actor-ts';
function parseAge(raw: string): Either<string, number> { const value = Number(raw); return Number.isInteger(value) && value >= 0 ? right(value) : left(`not an age: ${raw}`);}
parseAge('42').fold((reason) => `rejected: ${reason}`, (age) => `age ${age}`);mapLeft, bimap und swap arbeiten auf der anderen Seite; isLeft() und
isRight() sind Type Guards. eitherOf(compute) fängt einen Wurf in ein
Left<Error>, und eitherSequence sammelt ein Array so ein wie trySequence.
Lazy — einmal berechnen, bei der ersten Nutzung
Abschnitt betitelt „Lazy — einmal berechnen, bei der ersten Nutzung“Lazy<T> ist Scalas lazy val: die Berechnung läuft beim ersten get() und
das Ergebnis wird gemerkt — ein geworfener Fehlschlag ebenso. Ein teures oder
seiteneffektbehaftetes Setup passiert also genau einmal, egal wie viele Aufrufer
fragen.
import { Lazy, lazyImportModule } from 'actor-ts';
const redisLazy = Lazy.of(() => lazyImportModule<typeof import('ioredis')>('ioredis'));
// Der erste Aufruf importiert; jeder weitere liefert dasselbe Promise.const redis = await redisLazy.get();Lazy.of(compute) (als lazy aliasiert) und Lazy.evaluated(value) erzeugen
eines. get() erzwingt die Berechnung, peek() liefert den Wert nur, wenn er
schon berechnet wurde, getSync() packt eine aufgelöste asynchrone Zelle aus,
und map / flatMap / forEach komponieren, ohne zu erzwingen. reset() und
setOverride(value) sind für Tests da — sie sind der Grund, warum ein lazy
importiertes Backend gegen ein Fake getauscht werden kann, ohne den
Produktionspfad anzufassen.
BidirectionalMap — eine Map mit gepflegter Umkehrung
Abschnitt betitelt „BidirectionalMap — eine Map mit gepflegter Umkehrung“BidirectionalMap<K, V> ist eine Map<K, V>, die zusätzlich value → key
beantwortet. Sie existiert, weil ein handgeschriebener Reverse-Index zwei Maps
sind, die im Gleichschritt gepflegt werden müssen — und wenn sie
auseinanderlaufen, ist der Fehler still: ein veralteter Eintrag beantwortet
weiter ein Paar, das es längst nicht mehr gibt.
import { BidirectionalMap } from 'actor-ts';
const seats = new BidirectionalMap<string, number>();seats.set('ada', 1);seats.set('grace', 2);
seats.get('ada'); // 1seats.getKey(2); // 'grace' ← die Richtung, die eine Map nicht kannseats.deleteValue(1); // entfernt das Paar auf beiden SeitenSie implementiert Map<K, V> vollständig und passt damit überall dort hinein,
wo eine Map erwartet wird — new Map(bidirectionalMap), Spread, for…of,
forEach.
Über Map hinaus | Zweck |
|---|---|
getKey(value) / hasValue(value) / deleteValue(value) | Die Rückrichtung. |
trySet(key, value) | Bindet nur, wenn dabei nichts entfernt wird; sonst false. |
inverse() | Dieselbe Map andersherum gelesen — eine Sicht, keine Kopie. |
reverseEntries() | [value, key]-Paare. |
getOrInsertKey(value, defaultKey) | Wie getOrInsert, von der Wertseite aus; liefert einen Key. |
getOrInsertComputedKey(value, callback) | Dasselbe, der Key wird bei Bedarf erzeugt. callback läuft höchstens einmal. |
Beide Richtungen liegen auf einer Map, also vergleichen beide per
SameValueZero: NaN funktioniert als Key und als Wert, 0 und -0 sind
dasselbe, und zwei strukturell gleiche Objekte sind zwei verschiedene Werte.
Wenn du strukturelle Identität brauchst, indiziere eine abgeleitete Zeichenkette
— genau das tut der Shard des Frameworks selbst, der auf ref.path.toString()
schlüsselt statt auf den Ref.
Sie darf im persistenten State liegen
Abschnitt betitelt „Sie darf im persistenten State liegen“Anders als eine gewöhnliche Klasse überlebt eine BidirectionalMap den
Round-Trip durch Journal, Snapshot oder Durable State als echte Instanz —
ohne Event-Adapter, ohne
Serializer-Registrierung, ohne irgendetwas an der Grenze. Der getaggte
JSON-Baum kennt sie so, wie er Map und Set kennt.
type State = { seats: BidirectionalMap<string, number> };
class SeatingPlan extends PersistentActor<Command, Event, State> { initialState(): State { return { seats: new BidirectionalMap() }; } // Nach einem Neustart ist state.seats wieder eine BidirectionalMap — und // state.seats.getKey(2) funktioniert, obwohl nur die Vorwärtspaare je // geschrieben wurden.}Auf die Platte gehen nur die Vorwärtspaare; die Umkehrung wird beim Dekodieren rekonstruiert. Die vollständige Round-Trip-Tabelle steht unter was Events und State enthalten dürfen.
Ein Store, der mit withSerializer(new CborSerializer()) konfiguriert ist,
trägt sie ebenfalls, unter CBOR-Tag 27. Das war nicht immer so — bis
#1036 hat der CBOR-Codec
sie fallen lassen, zusammen mit Map und Set, ohne etwas zu melden.
BidirectionalMultiMap — dasselbe für eine Viele-zu-viele-Relation
Abschnitt betitelt „BidirectionalMultiMap — dasselbe für eine Viele-zu-viele-Relation“BidirectionalMultiMap<L, R> setzt viele Linke zu vielen Rechten in Beziehung
und beantwortet beide Richtungen. Sie existiert für die Form, die eine
Subscription-Registry hat: ein Subscriber hält viele Topics, ein Topic hat viele
Subscriber — und die Nachricht, die dir sagt, dass ein Subscriber weg ist, trägt
nur den Subscriber.
import { BidirectionalMultiMap } from 'actor-ts';
const subscriptions = new BidirectionalMultiMap<string, string>();subscriptions.add('news', 'ada');subscriptions.add('news', 'grace');subscriptions.add('sport', 'ada');
subscriptions.get('news'); // Set { 'ada', 'grace' }subscriptions.getKeys('ada'); // Set { 'news', 'sport' } ← die Rückrichtungsubscriptions.size; // 3 — Paare, nicht Teilnehmer
subscriptions.deleteRight('ada'); // ada verlässt alle Topics auf einmalsubscriptions.hasLeft('sport'); // false — sport hielt nur ada und ist mit wegDie letzte Zeile ist die Invariante, die man kennen sollte: einen leeren
Teilnehmer gibt es nicht. Wer seinen letzten Partner verliert, verschwindet aus
beiden Richtungen. Ein Topic, das mit einem leeren Subscriber-Set zurückbleibt,
ist für eine Paarzählung unsichtbar, belegt weiter das Cap, das deine Topics
begrenzt — und inverse() würde etwas herausgeben, das mit nichts in Beziehung
steht.
| Über ein Paar Maps hinaus | Zweck |
|---|---|
add(left, right) / delete(left, right) | Ein Paar, beide Richtungen. add ist idempotent. |
get(left) / getKeys(right) | Alles, womit eine Seite in Beziehung steht. Leeres Set, wenn es sie nicht gibt — nie undefined, du kannst also ohne Guard iterieren. |
deleteLeft(left) / deleteRight(right) | Einen Teilnehmer vollständig entfernen — der Terminated-Fall. |
hasLeft(left) / hasRight(right) | Ob dieser Teilnehmer mindestens einen Partner hat. |
lefts() / rights() | Die Teilnehmer, jeder genau einmal. |
inverse() | Die Relation andersherum gelesen — eine Sicht, keine Kopie. size stimmt auf beiden Seiten. |
Verglichen wird per SameValueZero, wie bei BidirectionalMap und aus
demselben Grund: beide Richtungen liegen auf Map und Set. Zwei strukturell
gleiche Objekte sind zwei verschiedene Teilnehmer — indiziere also eine
abgeleitete Zeichenkette, wenn du strukturelle Identität brauchst. Genau das tun
die Aufrufstellen des Frameworks selbst: sie schlüsseln Subscriber auf
ref.path.toString() statt auf den Ref. Ein Terminated trägt den self-Ref
der Zelle, der nicht das Objekt sein muss, das subscribed hat; der Pfad ist die
Identität, auf die sich beide Seiten einigen.
Sie darf ebenfalls im persistenten State liegen
Abschnitt betitelt „Sie darf ebenfalls im persistenten State liegen“Wie ihre 1:1-Schwester überlebt sie den Round-Trip durch Journal, Snapshot oder Durable State als echte Instanz — ohne Adapter, ohne Registrierung. Geschrieben wird nur die Vorwärtsrichtung, als Adjazenzliste; die Umkehrung wird beim Dekodieren rekonstruiert, sodass die beiden Hälften nicht widersprüchlich zurückkommen können. Der CBOR-Vorbehalt von oben gilt genauso.
Zufallszeichenketten und Bezeichner
Abschnitt betitelt „Zufallszeichenketten und Bezeichner“| Export | Zweck |
|---|---|
randomString(length, options?, exists?) | length Zeichen aus den aktivierten Klassen. Voreinstellung: alphanumerisch. exists darf auf Position zwei stehen, wenn du keine Optionen übergibst. |
randomHex(length, exists?) | length Kleinbuchstaben-Hex-Zeichen — für Wire-Formate, die [0-9a-f] vorschreiben. |
randomId(length, exists?) | length Zeichen für etwas, das du selbst benennen musst. |
randomUuid(exists?) | Eine v4-UUID — für einen Bezeichner, der mit keinem anderswo erzeugten kollidieren darf. |
RandomStringOptions | { lowerCase?, upperCase?, digits? } — jedes standardmäßig true. |
ExistsPredicate | (candidate: string) => boolean — die optionale Kollisionsprüfung, die alle vier annehmen. true heißt „vergeben, zieh noch einmal“. |
Die ersten drei ziehen aus globalThis.crypto, korrigieren den Modulo-Bias, den
ein schlichtes byte % alphabet.length einführt, und liefern exakt die
angefragte Länge — nie weniger. randomUuid delegiert an
globalThis.crypto.randomUUID().
import { randomId, randomString, randomUuid } from 'actor-ts';
const sessionId = randomId(12); // '9f3c1ab0e7d2'const coupon = randomString(8, { lowerCase: false, digits: true }); // 'K7Q2XP4M'const persistenceId = randomUuid(); // 'f81d4fae-7dec-41d0-a765-00a0c91e6bf6'randomId ist die richtige Wahl, wenn du einen Aktor benennst. Ein Aktorname
landet in einem Aktorpfad, und ein Pfad ist eine Adresse: auf dem Cluster-Wire
kann alles, was einen Pfad erzeugen kann, auch dorthin senden. Ein Zähler macht
diese Adresse erratbar — wer eine kennt, kennt die nächste. Genau deshalb
benennt das Framework anonyme Aktoren und die Antwort-Refs von ask von hier
aus statt aus $1, $2, $3.
randomHex existiert getrennt, weil manche Alphabete keine Stilfrage sind. W3C
Trace-Context schreibt [0-9a-f]{32} und [0-9a-f]{16} für einen
traceparent-Header vor, und eine Gegenstelle lehnt alles andere ab.
randomUuid steht am anderen Ende der Entropie-Skala als randomId, und die
Trennung zwischen beiden lohnt sich. randomId(12) sind ~48 Bit: unerratbar unter den Namen,
die ein Prozess gleichzeitig hält — mehr Eindeutigkeit muss ein Aktorname nicht
tragen. randomUuid() sind 122 Zufallsbits, und so viel braucht es, damit ein
Bezeichner von einem unterscheidbar bleibt, der in einem anderen Prozess, auf
einer anderen Maschine, Jahre später und ohne jede Abstimmung erzeugt wurde —
eine PersistenceId für ein neues Aggregat, eine Correlation-Id, die über einen
Broker läuft, ein Schlüssel, den ein anderes System liest.
Ziehen, bis eine frei ist
Abschnitt betitelt „Ziehen, bis eine frei ist“Alle vier nehmen als letztes Argument ein optionales Prädikat und ziehen erneut,
solange es true antwortet:
import { randomUuid } from 'actor-ts';
const userId = randomUuid((id) => state.users.has(id));also genau die Schleife, die bisher an der Aufrufstelle stand:
let userId: UserId;do { userId = randomUuid();} while (state.users.has(userId));Der Callback ist diese while-Bedingung. Deshalb heißt er exists, und
deshalb bedeutet true „zieh noch einmal“ — ein Akzeptanz-Prädikat wäre die
Negation der Schleife, die es ersetzt, und würde jeder Map- oder
Set-gestützten Aufrufstelle ein ! verpassen. randomString nimmt ihn auf
Position zwei, wenn du keine Optionen übergibst, und auf Position drei, wenn du
es tust: randomString(8, exists), randomString(8, { digits: false }, exists).
Es liest nur, es schreibt nicht. Der akzeptierte Wert wird nirgends für dich
vermerkt — was exists befragt, bleibt deine Sache.
Werte ausgeben, bei denen schon etwas schiefging
Abschnitt betitelt „Werte ausgeben, bei denen schon etwas schiefging“safeStringify(value, maxLength?) stellt einen beliebigen Wert als Zeichenkette
dar und wirft dabei nie.
import { Actor, safeStringify } from 'actor-ts';
class Worker extends Actor<unknown> { onReceive(message: unknown) { this.log.warn(`unhandled message: ${safeStringify(message)}`); }}JSON.stringify wirft bei einer zyklischen Struktur und bei einem BigInt.
Damit riskierst du, wenn du damit eine Logzeile oder eine Fehlermeldung baust,
das gerade gemeldete Problem durch ein anderes zu ersetzen — geworfen aus dem
meldenden Code heraus, wo ein Aufrufer am wenigsten damit rechnet. Ein
onReceive, das eine unerwartete Nachricht loggt, ist genau diese Lage: die
Nachricht ist unbekannt, also weiß man nichts über ihre Form.
Zyklen kollabieren zu [Circular], BigInt wird mit n-Suffix dargestellt,
Funktionen und Symbole werden benannt, und das Ergebnis ist gedeckelt
(standardmäßig 8 KiB), statt zu einer mehrere Megabyte großen Zeichenkette
anzuwachsen, die beim Bauen die Event-Loop blockiert.
Optionale Peer-Dependencies
Abschnitt betitelt „Optionale Peer-Dependencies“lazyImportModule(name, options?) importiert ein Modul dynamisch und wirft,
wenn es fehlt, einen Fehler, der das Paket und den Installationsbefehl nennt.
import { Lazy, lazyImportModule } from 'actor-ts';
const redisLazy = Lazy.of(() => lazyImportModule<typeof import('ioredis')>('ioredis', { context: 'MyCacheActor' }),);
// MyCacheActor requires the 'ioredis' package. Install it with: npm install ioredis// Original error: Cannot find module 'ioredis'| Export | Zweck |
|---|---|
lazyImportModule<T>(name, options?) | import(name), oder ein „fehlende Peer-Dependency”-Fehler. |
LazyImportOptions | { context?, installHint? } — wer es brauchte und wie man es installiert. |
Auf diesem Muster sind die Broker, Caches und Persistence-Backends des
Frameworks aufgebaut, und deshalb scheitern sie alle auf dieselbe Weise: das
Modul darf keine harte Abhängigkeit sein, also muss der Import dynamisch sein,
also ist der voreingestellte Fehlerfall ein nacktes Cannot find module, das
nichts darüber sagt, welches Paket fehlt oder was dagegen zu tun ist. In
Kombination mit Lazy — ebenfalls über den Haupteinstiegspunkt exportiert —
passiert der Import genau einmal, bei der ersten Nutzung statt beim Erzeugen.
Options-Builder und -Validatoren
Abschnitt betitelt „Options-Builder und -Validatoren“OptionsBuilder und OptionsValidator sind die beiden Basisklassen hinter
jeder XOptions-Familie im Framework — MqttOptions,
SqliteJournalOptions, ActorSystemOptions und die übrigen. Zum Benutzen
dieser Options brauchst du sie nicht; du brauchst sie, um eigene in derselben
Form zu schreiben.
import { OptionsBuilder, OptionsValidator } from 'actor-ts';
type MyOptionsType = { readonly port?: number; readonly host?: string };
class MyOptionsBuilder extends OptionsBuilder<MyOptionsType> { withPort(port: number): this { return this.set('port', port); } withHost(host: string): this { return this.set('host', host); }}
class MyOptionsValidator extends OptionsValidator<MyOptionsType> { constructor() { super('MyOptions'); }
protected rules(_settings: Partial<MyOptionsType>): void { this.port('port'); // No-op, solange ungesetzt; sonst OptionsError this.nonEmptyString('host'); }}Ein Builder ist seine Settings: das geschützte set schreibt jedes Feld als
eigene aufzählbare Property, eine Instanz ist also strukturell ein Beutel der
Felder, die du gesetzt hast — und ein Plain-Objekt ist damit austauschbar.
Die Check-Helfer (port, positiveNumber, positiveInt, nonNegativeInt,
numberInRange, oneOf, nonEmptyString, nonEmptyArray, url, dazu fail
für Sonderregeln) nehmen nur den Feldnamen — er wird gegen deinen
Options-Typ auf Tippfehler geprüft, und ein Helfer auf einem Feld des falschen
Typs ist ein Compile-Fehler. Jeder ist ein No-op, solange das Feld undefined
ist: ein ungesetztes Optional besteht also immer, und wo Pflichtfelder erzwungen
werden, bleibt dir überlassen. Ein abgelehnter Wert wirft OptionsError, das
daneben exportiert wird, damit du es gezielt fangen kannst.
Die Validierung läuft einmal, zum Zeitpunkt des Verbrauchs, auf den zusammengeführten Settings — damit sind Builder, Plain-Objekt und HOCON gleichermaßen abgedeckt, und feldübergreifende Regeln sehen die endgültigen Werte. Wie die Schichten auflösen, steht unter Konfiguration.
Wie weiter
Abschnitt betitelt „Wie weiter“- Logging — wo
safeStringifymeistens landet. - Aktoren erzeugen — Benennung, und was ein Aktorpfad preisgibt.
- Persistente Aktoren — was State enthalten darf und wie er round-trippt.
- Konfiguration — wie Options, HOCON und Defaults sich schichten.
- Installation — welche Abhängigkeiten des Frameworks optional sind.
