Eigene Serializer
Für Formate jenseits von JSON / CBOR gibt es zwei Wege:
- Avro und Protobuf werden mitgeliefert —
AvroSerializerundProtobufSerializernehmen 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ügenext.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.
Das Serializer-Interface
Abschnitt betitelt „Das Serializer-Interface“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— obmanifest()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.
Binding nach Klasse
Abschnitt betitelt „Binding nach Klasse“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
instanceoftransitiv ist).
Wann ein eigener Serializer
Abschnitt betitelt „Wann ein eigener Serializer“Drei gute Einsatzfälle:
- Cross-Language-Interop — dein Actor-System muss mit Nicht-JS-Services über Protobuf / Avro / ähnlich sprechen.
- Schema-erzwungene Evolution — Protobuf-Schema-Dateien in der Versionskontrolle sind die kanonischen Typen; das Framework liest aus ihnen.
- 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.
Avro und Protobuf, fertig mitgeliefert
Abschnitt betitelt „Avro und Protobuf, fertig mitgeliefert“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 }).
Was sie tun, was Handverdrahtung übersieht
Abschnitt betitelt „Was sie tun, was Handverdrahtung übersieht“Jeder ist ein dünner Wrapper, aber genau die dünnen Stellen beißen:
avsckann kein einfachesUint8Arraydekodieren. Es greift innerhalb vonfromBufferaufBuffer-interne Methoden zu, und das Base64-Framing übergibt genau ein einfachesUint8Array— ein handgeschriebener Avro-Serializer kodiert also sauber und scheitert beim Replay, auf Bun, Node und Deno gleichermaßen.AvroSerializerüberbrückt das.protobufjsliefert 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.ProtobufSerializerlöst sie ab.protobufjsvalidiert 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.
toObjectmit gefüllten Defaults und 64-Bit-Feldern als Strings, weil eineMessage-Instanz und einLongnichts sind, was ein Journal speichern kann. Abwählbar mit.withPlainObjects(false).
Eine ID wählen
Abschnitt betitelt „Eine ID wählen“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.
Warum ein kompilierter Typ und kein .proto-Pfad
Abschnitt betitelt „Warum ein kompilierter Typ und kein .proto-Pfad“.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-Stringprotobuf.Root.fromJSON(descriptor).lookupType('Order') // ein gebündelter Descriptorimport { Order } from './generated/order.js'; // generierter statischer CodeHandgeschriebenes 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.
MessagePack-Beispiel
Abschnitt betitelt „MessagePack-Beispiel“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 einemSerializationErrorfehl, 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.
Ein Format pro Store, oder eines pro Version
Abschnitt betitelt „Ein Format pro Store, oder eines pro Version“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.
Pro Klasse vs. systemweit
Abschnitt betitelt „Pro Klasse vs. systemweit“ext.register(s); ext.bind(Class, s.id) → nur diese Klasse über s routenext.setDefault(s) → der Default, den die Extension für ungebundene Werte nutztPro 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.
Schema-Evolution mit eigenen Serializern
Abschnitt betitelt „Schema-Evolution mit eigenen Serializern“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.
Registrierung bei der Extension
Abschnitt betitelt „Registrierung bei der Extension“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.
Wohin als Nächstes
Abschnitt betitelt „Wohin als Nächstes“- Serialization-Übersicht — das große Bild.
- JSON-Serializer — der Default.
- CBOR-Serializer — die binäre Alternative.
- Schema-Registry — ein anderes Wire-Format pro Event-Version.
- Migration-Übersicht — Schema-Evolutions-Muster.
