Serialisation

Serialisierung ist der Prozess der Umwandlung von Datentypen in ein Format, das sich beispielsweise für den Transport oder die Speicherung eignet. Die Deserialisierung ist der Prozess, der dies wieder rückgängig macht. Dies geschieht verlustfrei, d. h. die Daten können in und aus einem Serialisierungsziel konvertiert werden, ohne dass Datentypinformationen oder die Daten selbst verloren gehen.

In JavaScript erfolgt die Serialisierung normalerweise zwischen JavaScript-Objekten und JSON. JSON unterstützt nur String, Number, Boolean, Objekte und Arrays. JavaScript hingegen unterstützt viele weitere Typen wie BigInt, ArrayBuffer, typisierte Arrays, Date, benutzerdefinierte Klasseninstanzen und viele mehr. Um nun JavaScript-Daten mit JSON an einen Server zu übertragen, benötigen Sie einen Serialisierungsprozess (auf dem Client) und einen Deserialisierungsprozess (auf dem Server), oder umgekehrt, wenn der Server Daten als JSON an den Client sendet. Die Verwendungen von JSON.parse und JSON.stringify ist dabei oft nicht ausreichend, da diese nicht verlustfrei arbeitet.

Dieser Serialisierungsprozess ist bei nicht trivialen Daten unbedingt notwendig, da JSON selbst schon bei Basistypen wie einem Datum seine Informationen verliert. Ein new Date wird schließlich als String in JSON serialisiert:

const json = JSON.stringify(new Date);
//'"2022-05-13T20:48:51.025Z"

Wie zu sehen ist, ist das Ergebnis von JSON.stringify ein JSON string. Deserialisiert man dieses nun wieder mit JSON.parse, erhält man nicht ein Date Objekt, sondern einen String.

const value = JSON.parse('"2022-05-13T20:48:51.025Z"');
//"2022-05-13T20:48:51.025Z"

Zwar gibt es diverse Workarounds, um JSON.parse das Deserialisieren von Date-Objekten beizubringen, so sind diese jedoch fehleranfällig und wenig performant. Um das typen-sichere Serialisieren und Deserialisieren für diesen Fall und vielen anderen Typen zu ermöglichen, ist ein Serialisierungsprozess notwendig.

Es sind vier Hauptfunktionen verfügbar: serialize, cast/deserialize und validatedDeserialize. Unter der Haube dieser Funktionen wird der global verfügbare JSON-Serializer von @deepkit/type standardmäßig verwendet, es kann jedoch auch ein eigenes Serialisierungsziel genutzt werden.

Deepkit Type unterstützt benutzerdefinierte Serialisierungsziele, ist aber bereits mit einem mächtigen JSON-Serialisierungsziel ausgestattet, der Daten als JSON-Objekte serialisiert und anschließend mit JSON.stringify korrekt und sicher als JSON umgewandelt werden kann. Mit @deepkit/bson kann auch BSON als Serialisierungsziel verwendet werden. Wie ein eigenes Serialisierungsziel erstellt werden kann (zum Beispiel für einen Datenbanktreiber), kann in der Sektion Custom Serializer erfahren werden.

Zu beachten ist, dass obwohl Serializer auch Daten auf ihre Kompatibilität geprüft werden, sind diese Validierungen anders als die Validierung in Validation. Lediglich die Funktion cast ruft nach dem erfolgreichen Deserialisieren auch den vollen Validierungsprozess aus dem Kapitel Validation auf, und wirft einen Fehler, wenn die Daten nicht valide sind.

Alternativ kann auch validatedDeserialize verwendet werden, um nach dem Deserialisieren zu validieren. Eine weitere Alternative ist manuelle die Funktionen validate oder validates auf deserialisierte Daten von der Funktion deserialize aufzurufen, siehe Validation. Alle Funktionen aus der Serialisierung und Validation werfen bei Fehlern ein ValidationError aus @deepkit/type.

Cast

Todo

Serialisierung

import { serialize } from '@deepkit/type';

class MyModel {
    id: number = 0;
    created: Date = new Date;

    constructor(public name: string) {
    }
}

const model = new MyModel('Peter');

const jsonObject = serialize<MyModel>(model);
//{
//  id: 0,
//  created: '2021-06-10T15:07:24.292Z',
//  name: 'Peter'
//}
const json = JSON.stringify(jsonObject);

Die Funktion serialize wandelt die übergebenen Daten per default mit dem JSON-Serializer in ein JSON Objekt um, das heisst: String, Number, Boolean, Objekt, oder Array. Das Ergebnis davon kann dann sicher mittels JSON.stringify in ein JSON umgewandelt werden.

Deserialisierung

Die Funktion deserialize wandelt die übergebenen Daten per default mit dem JSON-Serializer in den entsprechenden angegebenen Typen um. Der JSON-Serializer erwartet dabei ein JSON-Objekt, das heisst: String, Number, Boolean, Objekt, oder Array. Dies erhält man in der Regel aus einem JSON.parse Aufruf.

import { deserialize } from '@deepkit/type';

class MyModel {
    id: number = 0;
    created: Date = new Date;

    constructor(public name: string) {
    }
}

const myModel = deserialize<MyModel>({
    id: 5,
    created: 'Sat Oct 13 2018 14:17:35 GMT+0200',
    name: 'Peter',
});

//from JSON
const json = '{"id": 5, "created": "Sat Oct 13 2018 14:17:35 GMT+0200", "name": "Peter"}';
const myModel = deserialize<MyModel>(JSON.parse(json));

Wenn dabei bereits der richtige Datentyp übergeben wird (zum Beispiel bei created ein Date-Objekt), dann wird dieser genommen wie er ist.

Es kann als ersten Typenargument nicht nur eine Klasse, sondern jeder TypeScript Typ angegeben werden. So können auch Primitives oder sehr komplexe Typen übergeben werden:

deserialize<Date>('Sat Oct 13 2018 14:17:35 GMT+0200');
deserialize<string | number>(23);

Weiche Typenkonvertierung

In dem Deserialisierungsprozess ist eine weiche Typenkonvertierung implementiert. Das bedeutet, dass String und Number für String-Typen oder eine Number für einen String-Typen akzeptiert und automatisch umgewandelt werden kann. Dies ist zum Beispiel nützlich, wenn Daten über eine URL angenommen und an den Deserializer übergeben werden. Da es sich bei der URL immer um einen String handelt, versucht Deepkit Type, die Typen dennoch für Number und Boolean aufzulösen.

deserialize<boolean>('false')); //false
deserialize<boolean>('0')); //false
deserialize<boolean>('1')); //true

deserialize<number>('1')); //1

deserialize<string>(1)); //'1'

Folgende weichen Typenkonvertierungen sind in dem JSON-Serializer eingebaut:

  • number|bigint: Number oder Bigint akzeptieren String, Number, und BigInt. Es werden parseFloat oder BigInt(x) bei einer notwendigen Umwandlung genutzt.

  • boolean: Boolean akzeptiert Number and String. 0, '0', 'false' wird interpretiert als false. 1, '1', 'true' wird interpretiert als true.

  • string: String akzeptiert Number, String, Boolean, und viele mehr. Alle Nicht-String Werte werden automatisch mit String(x) umgewandelt.

Das Weiche Umwandeln kann auch deaktiviert werden:

const result = deserialize(data, {loosely: false});

Es wird bei invaliden Daten dann nicht versucht diese umzuwandeln und stattdessen eine Fehlermeldung geworfen.

Type-Decorators

Integer

Group

Excluded

Mapped

Embedded

Naming Strategy

Benutzerdefinierter Serializer

Standardmäßig wird @deepkit/type mit einem JSON-Serialisierer und einer Typ-Validierung für TypeScript-Typen geliefert. Sie können diesen erweitern und die Serialisierungsfunktionalität erweitern oder entfernen oder die Art und Weise der Validierung ändern, da die Validierung auch mit dem Serializer verbunden ist.

New Serialisierer

Ein Serializer ist einfach eine Instanz der Klasse Serializer mit registrierten Serializer-Vorlagen. Serializer-Vorlagen sind kleine Funktionen, die JavaScript-Code für den JIT-Serializer-Prozess erstellen. Für jeden Typ (String, Number, Boolean usw.) gibt es eine eigene Serializer-Vorlage, die für die Rückgabe von Code für die Datenkonvertierung oder -validierung zuständig ist. Dieser Code muss mit der JavaScript-Engine kompatibel sein, die der Benutzer verwendet.

Nur während der Ausführung der Compiler-Vorlagenfunktion haben Sie (oder sollten Sie) vollen Zugriff auf den vollständigen Typ. Die Idee ist, dass Sie alle Informationen, die für die Konvertierung eines Typs erforderlich sind, direkt in den JavaScript-Code einbetten sollten, was zu hochgradig optimiertem Code (auch JIT-optimierter Code genannt) führt.

Im folgenden Beispiel wird ein leerer Serializer erstellt.

import { EmptySerializer } from '@deepkit/type';

class User {
    name: string = '';
    created: Date = new Date;
}

const mySerializer = new EmptySerializer('mySerializer');

const user = deserialize<User>({ name: 'Peter', created: 0 }, undefined, mySerializer);
console.log(user);
$ ts-node app.ts
User { name: 'Peter', created: 0 }

Wie Sie sehen können, wurde nichts umgewandelt (created ist immer noch eine Zahl, aber wir haben sie als Date definiert). Um das zu ändern, fügen wir eine Serializer-Vorlage für die Deserialisierung des Typs Datum hinzu.

mySerializer.deserializeRegistry.registerClass(Date, (type, state) => {
    state.addSetter(`new Date(${state.accessor})`);
});

const user = deserialize<User>({ name: 'Peter', created: 0 }, undefined, mySerializer);
console.log(user);
$ ts-node app.ts
User { name: 'Peter', created: 2021-06-10T19:34:27.301Z }

Jetzt wandelt unser Serialisierer den Wert in ein Date-Objekt um.

Um dasselbe für die Serialisierung zu tun, registrieren wir eine weitere Serialisierungsvorlage.

mySerializer.serializeRegistry.registerClass(Date, (type, state) => {
    state.addSetter(`${state.accessor}.toJSON()`);
});

const user1 = new User();
user1.name = 'Peter';
user1.created = new Date('2021-06-10T19:34:27.301Z');
console.log(serialize(user1, undefined, mySerializer));
{ name: 'Peter', created: '2021-06-10T19:34:27.301Z' }

Unser neuer Serialisierer konvertiert das Datum im Serialisierungsprozess nun korrekt vom Date-Objekt in einen String.

Beispiele

Um viele weitere Beispiele zu sehen, können Sie einen Blick in den Code des JSON-Serializers werfen, der in Deepkit Type enthalten ist.

Erweitern eines Serialisierers

Wenn Sie einen bereits vorhandenen Serialisierer erweitern möchten, können Sie dies über die Klassenvererbung tun. Dies funktioniert, weil Serialisierer so geschrieben werden sollten, dass sie ihre Vorlagen im Konstruktor registrieren.

class MySerializer extends Serializer {
    constructor(name: string = 'mySerializer') {
        super(name);
        this.registerTemplates();
    }

    protected registerTemplates() {
        this.deserializeRegistry.register(ReflectionKind.string, (type, state) => {
            state.addSetter(`String(${state.accessor})`);
        });

        this.deserializeRegistry.registerClass(Date, (type, state) => {
            state.addSetter(`new Date(${state.accessor})`);
        });

        this.serializeRegistry.registerClass(Date, (type, state) => {
            state.addSetter(`${state.accessor}.toJSON()`);
        });
    }
}
const mySerializer = new MySerializer();