Zum Inhalt springen
Deutsch

Eigene Serializer

Für Formate jenseits von JSON / CBOR gibt es zwei Wege:

  • Avro und Protobuf werden mitgeliefertAvroSerializer und ProtobufSerializer nehmen ein kompiliertes Schema entgegen, das du mitbringst, und erledigen die Verdrahtung.
  • Alles andere — MessagePack, FlatBuffers, ein maßgeschneidertes Format — ist eine Implementierung des Serializer<T>-Interfaces, an bestimmte Nachrichtenklassen gebunden.
import { Serializer, SerializationExtensionId } from 'actor-ts';
class ProtoOrderSerializer implements Serializer<Order> {
readonly id = 100;
readonly name = 'order-protobuf';
readonly includesManifest = false;
manifest(_obj: Order): string { return ''; }
toBinary(obj: Order): Uint8Array {
return OrderProto.encode(obj).finish();
}
fromBinary(bytes: Uint8Array, _manifest: string): Order {
return OrderProto.decode(bytes);
}
}
// Verdrahten:
const ext = system.extension(SerializationExtensionId);
const orderSerializer = new ProtoOrderSerializer();
ext.register(orderSerializer); // zur ID-Lookup-Tabelle hinzufügen
ext.bind(Order, orderSerializer.id); // Order darüber routen (id 100)

Jetzt löst die Extension jede Order-Instanz zu Protobuf auf; jeder andere Wert fällt auf den Default-Serializer (JSON) zurück.

interface Serializer<T = unknown> {
readonly id: number;
readonly name: string;
includesManifest: boolean;
manifest(obj: T): string;
toBinary(obj: T): Uint8Array;
fromBinary(bytes: Uint8Array, manifest: string): T;
}
  • id — eindeutige Zahl ≥ 100 (1–99 für Built-ins reserviert). In jeden Frame eingebettet; teilt dem Empfänger mit, welchen Serializer er verwenden soll.
  • name — nur Diagnose.
  • includesManifest — ob manifest() brauchbare Infos zurückgibt.
  • manifest(obj) — gibt einen String zurück, der den konkreten Typ identifiziert. Verwende es, wenn ein Serializer mehrere Typen behandelt und der Decoder wissen muss, welchen.
  • toBinary / fromBinary — das eigentliche Encode / Decode.
ext.register(orderSerializer);
ext.register(paymentSerializer);
ext.register(cancelSerializer);
ext.bind(Order, orderSerializer.id);
ext.bind(Payment, paymentSerializer.id);
ext.bind(Cancellation, cancelSerializer.id);

Die Extension mappt Werte auf Serializer über den Konstruktor: value instanceof Class → den an diese Klasse gebundenen Serializer nutzen.

Das heißt:

  • Plain-Objekte matchen kein Binding → fallen auf den Default zurück (JSON oder CBOR).
  • Klasseninstanzen matchen ihre Klassen-Bindung.
  • Subklassen matchen die Bindung des Parents (weil instanceof transitiv ist).

Drei gute Einsatzfälle:

  1. Cross-Language-Interop — dein Actor-System muss mit Nicht-JS-Services über Protobuf / Avro / ähnlich sprechen.
  2. Schema-erzwungene Evolution — Protobuf-Schema-Dateien in der Versionskontrolle sind die kanonischen Typen; das Framework liest aus ihnen.
  3. Spezifische Perf-/Größenanforderungen — FlatBuffers für Zero-Copy-Reads, MessagePack für eine Größe zwischen JSON + CBOR.

Für typische Apps ohne diese Einschränkungen reichen JSON oder CBOR.

Beide Formate werden als fertige Serializer mitgeliefert. Was du mitbringst, ist das kompilierte Schema; die Schema-Bibliothek bleibt eine Abhängigkeit deines Projekts — genau wie zodCodec ein Schema entgegennimmt, statt zod zu importieren. actor-ts importiert avsc und protobufjs nie selbst, sondern akzeptiert alles mit der passenden Form; generierter statischer Code (pbjs, ts-proto) passt damit ebenfalls.

import avsc from 'avsc';
import { AvroSerializer, AvroSerializerOptions } from 'actor-ts';
const avroType = avsc.Type.forSchema({
name: 'Deposited',
type: 'record',
fields: [
{ name: 'amount', type: 'int' },
{ name: 'currency', type: 'string' },
],
});
const avroOptions = AvroSerializerOptions.create<Deposited>()
.withAvroType(avroType)
.withId(100);
const avroSerializer = new AvroSerializer(avroOptions);
import protobuf from 'protobufjs';
import { ProtobufSerializer, ProtobufSerializerOptions } from 'actor-ts';
const root = protobuf.parse(`
syntax = "proto3";
package shop;
message Order { string id = 1; int32 amount = 2; }
`).root;
const protobufOptions = ProtobufSerializerOptions.create<Order>()
.withMessageType(root.lookupType('shop.Order'))
.withId(101);
const protobufSerializer = new ProtobufSerializer(protobufOptions);

Ein einfaches Options-Objekt funktioniert identisch — new AvroSerializer({ avroType, id: 100 }).

Jeder ist ein dünner Wrapper, aber genau die dünnen Stellen beißen:

  • avsc kann kein einfaches Uint8Array dekodieren. Es greift innerhalb von fromBuffer auf Buffer-interne Methoden zu, und das Base64-Framing übergibt genau ein einfaches Uint8Array — ein handgeschriebener Avro-Serializer kodiert also sauber und scheitert beim Replay, auf Bun, Node und Deno gleichermaßen. AvroSerializer überbrückt das.
  • protobufjs liefert gepoolte Bytes zurück. Writer.finish() ist ein Fenster in einen gemeinsamen Schreib-Pool (in einer Messung ~28 KB Offset in einem 64-KB-Pool auf Node). So gespeichert ist die Zeile eine Sicht auf den Speicher anderer Nachrichten. ProtobufSerializer löst sie ab.
  • protobufjs validiert beim Kodieren nicht. verify() ist ein separater Aufruf; der Serializer macht ihn, damit ein falsch typisiertes Feld ein Schreibfehler mit Feldnamen ist statt eines späteren Rätsels.
  • Bytes eines fremden Schemas dekodieren meist „erfolgreich“. Avro trägt keine Feld-Tags und Protobuf keinen Nachrichtennamen, fremde Bytes werden also zu plausiblem Unsinn. Beide Serializer schreiben ein Manifest (per Default aus dem Record-Namen bzw. dem voll qualifizierten Namen) und verweigern Payloads, die unter einem anderen geschrieben wurden.
  • Protobuf dekodiert zu einem einfachen Objekt. toObject mit gefüllten Defaults und 64-Bit-Feldern als Strings, weil eine Message-Instanz und ein Long nichts sind, was ein Journal speichern kann. Abwählbar mit .withPlainObjects(false).

id ist Pflicht und muss ≥ 100 sein — 1–99 gehören den eingebauten (JSON = 1, CBOR = 2), und der Konstruktor lehnt alles Kleinere ab. Die ID ist ein Wire-Vertrag, der in jeder gespeicherten Zeile steckt: änderst du sie, hören alte Zeilen auf zu dekodieren.

.proto zur Laufzeit zu laden hieße Dateisystemzugriff — im Browser nicht verfügbar, auf Deno berechtigungspflichtig und überall eine Sache mehr, die neben dem Bundle ausgeliefert werden muss. Den kompilierten Message-Typ entgegenzunehmen lässt dir die Wahl, und keiner der drei Wege erzwingt einen Build-Schritt:

protobuf.parse(protoSource).root.lookupType('Order') // ein Quell-String
protobuf.Root.fromJSON(descriptor).lookupType('Order') // ein gebündelter Descriptor
import { Order } from './generated/order.js'; // generierter statischer Code

Handgeschriebenes Protobuf, wenn du mehr Kontrolle brauchst

Abschnitt betitelt „Handgeschriebenes Protobuf, wenn du mehr Kontrolle brauchst“

ProtobufSerializer schreibt ein festes Manifest. Muss das Manifest pro Wert berechnet werden — etwa ein Versions-Tag, das aus der Nachricht gelesen wird —, implementierst du das Interface direkt:

import { Serializer } from 'actor-ts';
import { Order } from './generated/order_pb.js';
class OrderProtoSerializer implements Serializer<Order> {
readonly id = 100;
readonly name = 'order-pb';
readonly includesManifest = true;
manifest(obj: Order): string {
return `order.v${obj.getVersion()}`;
}
toBinary(obj: Order): Uint8Array {
return obj.serializeBinary();
}
fromBinary(bytes: Uint8Array, manifest: string): Order {
const order = Order.deserializeBinary(bytes);
if (manifest && manifest !== `order.v${order.getVersion()}`) {
throw new Error(`version mismatch: ${manifest} vs v${order.getVersion()}`);
}
return order;
}
}
const orderSerializer = new OrderProtoSerializer();
ext.register(orderSerializer);
ext.bind(Order, orderSerializer.id);

Das Manifest trägt Versionsinfo; der Decoder verifiziert. In Kombination mit Protobufs Wire-Level-Rückwärtskompatibilität kannst du Schemas weiterentwickeln, während alte Nachrichten korrekt dekodieren.

import { encode, decode } from '@msgpack/msgpack';
class MessagePackSerializer implements Serializer<unknown> {
readonly id = 101;
readonly name = 'msgpack';
readonly includesManifest = false;
manifest(): string { return ''; }
toBinary(obj: unknown): Uint8Array { return encode(obj); }
fromBinary(bytes: Uint8Array): unknown { return decode(bytes); }
}
// Als systemweiter Default:
ext.setDefault(new MessagePackSerializer());

MessagePack sitzt zwischen JSON (Größe, Parse-Speed) und Protobuf (typisierte Schemas). Nützlich, wenn du ein CBOR-ähnliches Format mit breiteren Cross-Language-Libraries willst.

Einen eigenen Serializer für die Persistenz nutzen

Abschnitt betitelt „Einen eigenen Serializer für die Persistenz nutzen“

Jeder Journal, Snapshot-Store und Durable-State-Store akzeptiert eine serializer-Option — der Weg, auf dem ein eigener Serializer gespeicherte Rows erreicht. Der Store framt jede Row selbstbeschreibend ({"__serialized__":{"id":…,"manifest":…,"data":"<base64>"}}), sodass Rows, die vor der Konfiguration geschrieben wurden, weiter über den Default-Tagged-JSON-Codec dekodieren und beide Formate in einem Stream koexistieren:

const journalOptions = SqliteJournalOptions.create()
.withPath('./journal.db')
.withSerializer(new MessagePackSerializer());
const journal = new SqliteJournal(journalOptions);

Die Register<X>Plugins-Bundles nehmen ein geteiltes withSerializer(...), das auf alle Stores des Backends verteilt wird (der eigene serializer eines Leafs gewinnt). Zwei Regeln zum Einplanen:

  • Eine geframte Row zu lesen erfordert einen Serializer mit derselben id — wird er entfernt oder getauscht, schlagen Reads alter geframter Rows mit einem SerializationError fehl, der die benötigte id nennt.
  • Die In-Memory-Stores ignorieren die Option: ihr Round-Trip nutzt immer den Default-Codec, der strenger ist, als ein eigener Serializer sein kann — die sichere Richtung für einen Dev/Test-Default.

withSerializer gilt storeweit: ein Format für jede Payload, die der Store schreibt. Wenn verschiedene Versionen eines Events verschiedene Formate brauchen — eine v1 liegt bereits als Avro auf der Platte, eine v2 soll Protobuf sein —, verpackst du den Serializer als Codec und registrierst ihn pro Version in der SchemaRegistry:

import { serializerCodec } from 'actor-ts';
registry.register('BankAccount.Deposited', 1, {
codec: serializerCodec(avroSerializer),
});
registry.register('BankAccount.Deposited', 2, {
codec: serializerCodec(protobufSerializer),
upcastFromPrev: (v1: DepositedV1): DepositedV2 => ({ ...v1, currency: 'USD' }),
});

Jedes Format existiert weiterhin genau einmal, als Serializer; serializerCodec passt es nur an den Pro-Version-Slot der Registry an.

ext.register(s); ext.bind(Class, s.id) → nur diese Klasse über s routen
ext.setDefault(s) → der Default, den die Extension für ungebundene Werte nutzt

Pro Klasse ist sicherer — behält den Default-Fallback für Dinge, die du nicht zu binden gedacht hast. Systemweit ist einheitlich — jeder ungebundene Wert geht durch denselben Serializer.

Für die meisten Apps: pro Klasse für spezifische Typen (Protobuf’te Events), JSON-Default für alles andere.

Eigene Serializer sollten Vorwärts- + Rückwärtskompatibilität behandeln:

fromBinary(bytes: Uint8Array, manifest: string): Order {
if (manifest === 'order.v1') {
return migrateV1ToV2(Order.deserializeBinary(bytes));
}
return Order.deserializeBinary(bytes);
}

Bei Protobuf ist das Wire-Format selbst vorwärtskompatibel (neue Felder werden von altem Code ignoriert). Du brauchst explizite Migration nur, wenn sich die Semantik ändert (Renames, Splits) — von deinem eigenen Serializer auf die gleiche Weise behandelt, wie die Migration-Adapter des Frameworks die Evolution von JSON-Formaten behandeln.

const ext = system.extension(SerializationExtensionId);
// 1. Jeden Serializer registrieren (fügt ihn zur ID-Lookup-Tabelle hinzu):
ext.register(orderSerializer);
ext.register(paymentSerializer);
// 2. Klassen an die ID eines registrierten Serializers binden (Routing):
ext.bind(Order, orderSerializer.id);
ext.bind(Payment, paymentSerializer.id);

register fügt einen Serializer der ID-Lookup-Tabelle hinzu (damit der Decoder ihn per ID findet und bind ihn referenzieren kann). bind fügt dann Routing hinzu — mappt eine Klasse auf die ID eines bereits registrierten Serializers. Du musst registern, bevor du bindst; das Binden einer nicht registrierten ID wirft.