1. Einführung

TypeScript ist JavaScript das skaliert. Eine Sprache, die designt ist, komplexe Anwendungsfälle umzusetzen. Es erlaubt das Schreiben von JavaScript mit Typen, die bei dem Kompilieren geprüft und entfernt werden. Diese dadurch entstehende Typensicherheit erlaubt das einfachere Schreiben und Warten von komplexen Anwendungen. Genau das ist unter anderem, was TypeScript so populär gemacht hat: Es erlaubt das Finden von Fehlern im Code insbesondere in großem Maßstab viel schneller und einfacher als würde das mit reinem JavaScript möglich sein - und arbeitet dabei nicht gegen, sondern mit JavaScript.

Deepkit ist TypeScript das skaliert. Ein Framework geschrieben in TypeScript für TypeScript, das designt ist, sehr komplexe Software in TypeScript zu entwickeln. Es bringt viele Design-Muster bekannt aus den Enterprise zu TypeScript und führt komplett neue Features ein, die so nur mit TypeScript’s neuem Typensystem möglich sind, um so die Entwicklungsgeschwindigkeit vor allen in Teams zu erhöhen. Auch kleine Anwendungen können von diesem neuen Ansatz profitieren, da Deepkit viele Libraries für sehr gängige Anwendungsfälle mitbringt, die einzeln oder in Kombination genutzt werden können. Das Framework selbst ist dabei so agil wie möglich und komplex wie nötig designt, um nicht nur schnell erste Ergebnisse zu erhalten, sondern auch langfristig die Entwicklungsgeschwindigkeit aufrechtzuerhalten.

JavaScript ist mittlerweile die größte Entwicklercommunity weltweit und liefert dem Entwickler eine entsprechend große Auswahlmöglichkeit von vielen Libraries und Tools, um die Bedürfnisse eines Projektes abzudecken. Dabei ist es nicht immer ganz einfach, die richtige Library zu finden. Oft unterscheiden sich die Philosophien, API und Code-Qualitäten dieser so stark, dass viel Klebecode und zusätzliche Abstraktionen vom Entwickler eingeführt werden müssen, damit diese Libraries überhaupt untereinander einwandfrei funktionieren. Das Bereitstellen von Kernfunktionen, die so gut wie jedes Projekt benötigt, in schön abstrahierten Libraries zusammengeführt in einem Framework durch eines Herstellers beziehungsweise einer Community hat sich in den letzten Jahrzehnten immer wieder bewährt: Java Spring, PHP Symfony/Laravel, und C++ QT sind nur einige sehr bekannte und erfolgreiche Beispiele dazu. Diese Frameworks bieten dem Entwickler oft weit verbreitete und jahrzehnte bewährte Konzepte, die in Libraries oder Komponenten umgesetzt sind, um so bequem untereinander harmonierend bedarfsgerecht angewendet werden zu können. Die angebotene Funktionalitäten und Design-Patterns sind dabei nicht gewürfelt, sondern basierend auf mitunter über jahrzehnte alte Konzepte, die sich durch den Kampf im Wettbewerb mit alternativen Ideen bewährt haben.

JavaScript hat über die Jahre hinweg massive Fortschritte verzeichnet, sodass mittlerweile auch immer mehr Design-Muster aus dem Enterprise-Umfeld angewendet werden können. Design-Muster, die sich vermehrt in immer mehr Libraries, Framework, und Tools finden lassen. Dabei hat JavaScript und auch TypeScript jedoch das Problem, dass um viele bewährte Enterprise-Muster effizient anzuwenden, entscheidende Funktionen in der Sprache selbst fehlen. Das heisst nicht, dass diese Design-Muster generell nicht angewendet werden können, sondern dass diese weniger effizient als in anderen aktuellen Sprachen sind.

TypeScript entfernt bei der Kompilierung, sobald TypeScript in JavaScript umgewandelt wird, seine Typeninformationen vollständig, sodass keine Informationen darüber in dem erzeugten JavaScript oder zur Laufzeit existiert. Es ist unstrittig, dass Typen sehr wertvoll während der Entwicklung und beim Prüfen der Korrektheit des Programs sind. Typen haben jedoch auch einen enormen Wert zur Laufzeit. Dieser Wert spiegelt sich da wider, wo zur Laufzeit Daten umgewandelt (konvertiert/serialisiert), Daten validiert, Meta-Informationen an Objekte hinzugefügt, oder Interface-Informationen benötigt werden. Bei diesen und vielen anderen Anwendungsfällen können Typeninformationen zur Laufzeit sehr nützlich sein, da sie den Libraries die notwendigen Informationen zu verfügung stellen, um Funktionalitäten effizient bereitzustellen. Aktuell nutzen viele dieser Anwendungsfälle stattdessen Alternativen, die das Typensystem von TypeScript unvollständig nachahmen und den Entwickler zwingen, Typen auf eine neue Art und Weise zu schreiben, die mit der Syntax von TypeScript nichts mehr zu tun hat. Das Resultat ist, dass TypeScript’s mächtiges Typensystem hier nicht mehr seine Stärke ausspielen kann und weniger ergonomische und weniger effizientere Arbeitsweisen stattdessen genutzt werden müssen.

1.1. Deepkit Framework

Deepkit hat einen Type-Compiler entwickelt, der die Typen-Informationen bestehen lässt und es so erlaubt, zur Laufzeit dynamische Typen zu berechnen und existierende Typeninformationen zur Laufzeit auszulesen. Mit diesem Paradigmenwechsel sind komplett neue Arbeitsweisen möglich, die den genannten Anwendungsfällen die benötigten Informationen bereitstellen, die das Entwickeln von komplexer Software radikal vereinfachen, und dem Code mehr Aussagekraft verleiht. Es ist damit zum ersten mal möglich, die volle Stärke und Aussagekraft von TypeScript auch zur Laufzeit zu nutzen.

Basierend auf diesem Paradigmenwechsel hat Deepkit eine ganze Reihe von Libraries für Anwendungsfälle entwickelt, die in so gut wie jedem Program zu finden sind: Validierung, Serialisierung, Datenbank Abstraktion, CLI parser, HTTP Router, RPC Framework, Logger, Template-System, Event-System und vieles mehr. Der grundlegende Unterschied zu anderen Libraries ist, dass Typeninformationen im Zentrum der Funktionalität stehen und soviel TypeScript wie möglich zur Laufzeit wiederverwendet werden soll, sodass weniger Boilerplate vom Entwickler geschrieben werden muss und auch komplexen Programmen auf einen Blick anzusehen ist, was sie machen. Eines der Schlüsselfunktionen von TypeScript ist es schließlich, auch komplexem Code einen Ausdruck zu verleihen und Deepkit bringt diese Vorteile der Ausdrucksstärke in die Laufzeit in Form eines mächtigen Frameworks, um nun auch die Applikation-Architektur mit entsprechenden Enterprise-Patterns besser zu skalieren.

Deepkit besteht dabei aus zwei großen Bereichen: Zum einen die Deepkit Libraries und das Deepkit Framework. Die Deepkit Libraries sind eine ganze Familie alleinstehender TypeScript Libraries (NPM Pakete), die ein Thema gut können und optimiert, gut getestet, sowie ausgelegt sind, sich gegenseitig optimal zu ergänzen. Ein Projekt kann einzelne Deepkit Libraries benutzen, oder das gesamte Deepkit Framework, welches alle Fähigkeiten der Libraries zusammenbringt und durch zusätzliche Tools wie den Debugger ergänzt. Alles zusammen gesehen erlaubt es dem Entwickler komplexe, schnelle, und produktionsbereite Anwendungen zu entwickeln. Deepkit unterstützt dabei eine ganze Reihe von Anwendungsfällen. Von einfachen Command-Line Tools (CLI Programmen) zu Web-Anwendungen und Micro-Services bis hin zu Desktop- oder Mobile-Anwendungen. Dabei ist der Code so ausgelegt, dass er in jeder bekannten JavaScript-Engine läuft (Browser wie auch NodeJS) und sich wunderbar auch in andere Frameworks wie Angular, React, und Vue integrieren lässt. Der Anspruch hinter Deepkit Framework ist es Clean-Code, SOLID-Prinzipien, und Enterprise Design-Muster anzuwenden, um entsprechend hohe Code-Qualität nicht nur anbieten zu können, sondern dem User erlauben, diese ebenfalls anzuwenden. Auch versucht Deepkit in seiner Dokumentation und Beispielen dieselben Prinzipien zu bewerben, zwingt den Entwickler jedoch nicht, diese selbst zu verfolgen.

1.2. High-Performance

Eines der schwierigsten Probleme in der Software-Entwicklung ist es, eine hohe Entwicklungsgeschwindigkeit auch nach Monaten oder Jahren aufrechtzuerhalten, insbesondere, wenn der Code und das Team wachsen. Es gibt viele Frameworks, die einem einen schnellen Einstieg versprechen und mit denen man in kürzester Zeit auch alleine schon komplexere Anwendungen zusammenschraubt. Diese haben jedoch meist gemeinsam das Problem, dass die Entwicklungsgeschwindigkeit drastisch abnimmt umso älter das Projekt oder umso größer das Team wird. Hierbei ist es nicht selten, das selbst nach bereits wenigen Monaten und nur einer Handvoll Entwicklern die Entwicklungsgeschwindigkeit dermaßen einbricht, dass diese auf 1 % der ursprünglichen Geschwindigkeit abfällt. Um diesem Phänomen entgegenzuwirken ist es notwendig etablierte Design-Patterns anzuwenden und im Voraus das richtige Framework und Libraries zu verwenden. Enterprise Design-Patterns haben sich aus dem Grunde etabliert, da sie auch bei größeren Anwendungen und großen Teams exzellent skalieren. Korrekt angewendet entfalten sie ihre Fähigkeiten besonders dann, wenn ein Projekt über längere Zeit (mehrere Monate bis Jahre) entwickelt werden soll.

Design-Patterns haben zwar in der Theorie ihre Vorzüge, doch gibt es in der Praxis zu fast jedem Pattern auch seine Nachteile. Diese Nachteile sind unterschiedlich je nach Sprache und Framework ausgeprägt, da die Sprache und das Framework selbst festlegt wie ergonomisch ein Pattern angewendet werden kann. Nur weil ein bestimmtes Pattern in einer Sprache angewendet werden kann, bedeutet das nicht, dass man damit auch automatisch besser und schneller entwickelt. Manche Sprachen sind besser geeignet als andere, um gewisse Patterns anzuwenden. Mit JavaScript oder gar TypeScript selbst sind diverse Design-Patterns zwar oft im Kern nutzbar, doch gibt es hierbei Limitierungen, die die User-Experience und damit Schnelligkeit massiv beeinträchtigen. Zum Beispiel können Typescript-Decorators mit all seinen Eigenheiten notwendig werden, wenn ein Dependency Injection Framework dies so festlegt und darauf basiert. Deepkit stellt mit dem Runtime-Typensystem sicher, dass auf maximal ergonomische Art und so wenig Boilerplate wie möglich diese Design-Patterns angewendet werden können und schaltet damit ihre Kraft erst so richtig frei, sodass die hohe Entwicklungsgeschwindigkeit nicht nur initial, sondern auch langfristig aufrechterhalten wird.

1.3. Isomorphic TypeScript

Eines der größten Vorteile von TypeScript ist es, dass komplexer Code in vielen Anwendungsfällen besser geschrieben werden kann. Das schließt Frontend, Backend, CLI tools, Mobile und Desktop-Apps, und vieles andere ein. Wenn ein Projekt diese Anwendungsfälle umfasst und fast ausschließlich auf TypeScript setzt, nennt man dies Isomorphic TypeScript. Durch das Verwenden von TypeScript in soviel Code wie möglich kann massiv an Entwicklungsgeschwindigkeit zugelegt werden. So sind folgende Vorteile dann plötzlich vorhanden:

  • Code kann zwischen den Abteilungen geteilt werden (Frontend, Backend, Microservice, etc).

    • Models, Typen und Interfaces

    • Validation

    • Business logic

  • Ein einheitliches Audit-System eines einzigen Paketmanagers.

  • Wiederverwendung von bekannten third-party Libraries in allen Abteilungen.

  • Wissensteilung innerhalb der Teams.

  • Recruitment vereinfacht sich auf eine Gruppe (und auch noch die Größte: JavaScript-Entwickler).

Deepkit Framework und sein Runtime-Typensystem sind darauf ausgelegt diese und mehr Vorteile von Isomorphic TypeScript auf das äußerste auszunutzen, so dass seine maximale Kräfte zum Vorschein kommen.

Alte Vorgehensweisen wie der Dual-Stack (Frontend und Backend in verschiedenen Sprachen) können hier bei weitem nicht mehr mithalten, da alleine der Kontextswitch zwischen den Sprachen bereits enorm Energie und Zeit kostet. Alle weiteren Vorteile, die bereits erläutert wurden, lässt es sogar zu einem unfairen Vergleichen werden. Ein isomorpher Techstack wie TypeScript ist, richtig angewendet, auf fundamentaler Ebene um ein vielfaches schneller in der Entwicklungszeit als jede Kombination aus einem Dual-Stack für Backend/Frontend wie Java/JavaScript, PHP/JavaScript, oder gar JavaScript/JavaScript. Da eine höhere Entwicklungsgeschwindigkeit auch bedeutet für dieselben Features weniger Zeit zu benötigen, heisst das auch, dass Isomorphic TypeScript bares Geld einspart. Neben all den bereits vorgestellten Vorteilen ist dies das Killer-Argument, um Isomorphic TypeScript in all den nächsten insbesondere kommerziellen Projekten anzuwenden.

2. Runtime Types

Typeninformationen in TypeScript zur Laufzeit zur Verfügung zu stellen ändert vieles. Es erlaubt neue Arbeitsweisen, die zuvor nur über Umwege oder gar nicht möglich waren. Das Deklarieren von Typen und Schemas ist mittlerweile ein großer Teil moderner Entwicklungsprozessen geworden. So sind GraphQL, Validatoren, ORMs, und Encoder wie zum Beispiel ProtoBuf, und viele mehr darauf angewiesen, Schema-Informationen auch zur Laufzeit zur Verfügung zu haben, um so fundamentale Funktionalitäten überhaupt erst bereitstellen zu können. Diese Tools und Libraries verlangen vom Entwickler teilweise komplett neue Sprachen zu lernen, die sehr spezifisch für den Anwendungsfall entwickelt worden sind. So haben ProtoBuf und GraphQL ihre eigene Deklarationssprache, auch Validatoren basieren oft auf eigene Schema-APIs oder gar JSON-Schema, welches ebenfalls eine eigenständige Art ist, Strukturen zu definieren. Einige davon verlangen bei jeder Änderung das Ausführen von Code-Generatoren, um die Schema-Informationen auch der Laufzeit bereitzustellen. Ein anderes bekanntes Muster ist, experimentelle TypeScript Decorators zu verwenden, um Meta-Informationen an Klassen der Laufzeit zur Verfügung zu stellen.

Aber ist das alles nötig? TypeScript bietet eine sehr mächtige Sprache, um auch sehr komplexe Strukturen zu beschreiben. Tatsächlich ist TypeScript mittlerweile Touring-Complete, was grob bedeutet, dass theoretisch jede Art von Program in TypeScript abbildbar ist. Natürlich hat dies seine praktischen Grenzen, der wichtige Punkt ist jedoch, dass TypeScript in der Lage ist, jegliche Deklarationsformate wie GraphQL, ProtoBuf, JSON-Schema, und viele andere komplett zu ersetzen. In Kombination mit einem Typensystem zur Laufzeit ist es möglich, all die beschriebenen Tools und deren Anwendungsfälle in TypeScript selbst ohne jeglichen Code-Generator abzudecken. Warum gibt es aber noch keine Lösung, die genau dies erlaubt?

Historisch gesehen ist TypeScript in den letzten Jahren einem massiven Wandel unterzogen worden. Es wurde diverse male komplett neu geschrieben, hat grundlegende Features erhalten, und unterlief eine ganze Reihe von Iterationen und Breaking-Changes. Mittlerweile ist TypeScript jedoch an einem Produkt-Market-Fit angekommen, das die Geschwindigkeit, in der grundlegende Innovationen und Breaking-Changes passieren, stark verlangsamt. TypeScript hat sich bewährt und gezeigt, wie ein äußerst charmantes Typensystem für eine hochdynamische Sprache wie JavaScript auszusehen hat. Der Markt hat diesen Vorstoß dankend angenommen und eine neue Äre in der Entwicklung mit JavaScript eingeleitet.

Genau dann ist der richtige Zeitpunkt gekommen, Tools auf der Sprache selbst in fundamentaler Ebene aufzusetzen, um so das oben beschriebene möglich zu machen. Deepkit möchte der Anstoß sein, um über jahrzehnte bewährte Design-Muster aus dem Enterprise von Sprachen wie Java und PHP nicht nur fundamental zu TypeScript zu bringen, sondern in einer neuen und besseren Art, die nicht gegen, sondern mit JavaScript arbeitet. Durch Typeninformationen zur Laufzeit sind diese nun zum ersten Mal nicht nur prinzipiell möglich, sondern erlauben ganz neue viel einfacherer Design-Muster, die mit Sprachen wie Java und PHP nicht möglich sind. TypeScript selbst hat hier das Fundament gelegt, um mit ganz neue Ansätzen in starker Kombination mit Bewährtem dem Entwickler das Leben beträchtlich zu vereinfachen.

Typeninformationen zur Laufzeit auszulesen ist die Fähigkeit auf die Deepkit in seinem Fundament aufsetzt. Die API der Deepkit Libraries sind maßgeblich darauf ausgerichtet, soviel TypeScript Typeninformation wie möglich zu verwenden, um so effizient wie möglich zu sein. Typensystem zur Laufzeit bedeutet, dass Typeninformationen zur Laufzeit auslesbar und dynamische Typen berechenbar sind. Das heisst, dass zum Beispiel bei Klassen alle Eigenschaften und bei Funktionen alle Parameter und Return-Typen ausgelesen werden können.

Nehmen wir als Beispiel diese Funktion:

function log(message: string): void {
    console.log(message);
}

In JavaScript selbst können mehrere Informationen zu Laufzeit ausgelesen werden. Zum Beispiel der Name der Funktion (sofern nicht mit einem Minimizer abgeändert wurde):

log.name; //‘log’

Zum anderen kann die Anzahl der Parameter ausgelesen werden:

log.length; //1

Mit ein bisschen mehr Code kann auch ausgelesen werden, wie die Parameter heissen. Das ist jedoch ohne einen rudimentären JavaScript-Parser oder RegExp auf log.toString() nicht ohne weiteres zu bewerkstelligen, sodass ab hier schon schluss ist. Da TypeScript die obige Funktion wie folgt in JavaScript übersetzt:

function log(message) {
    console.log(message);
}

sind die Informationen, dass message vom Typ string und der Return-Typ vom Type void ist nicht mehr verfügbar. Diese Informationen wurden unwiderruflich von TypeScript zerstört.

Mit einem Typensystem zur Laufzeit können jedoch diese Informationen überleben, so dass man die Typen von message und den Return-Typ programmatisch auslesen kann.

import { typeOf, ReflectionKind } from '@deepkit/type';

const type = typeOf(log);
type.kind; //ReflectionKind.function
type.parameters[0].name; //'message'
type.parameters[0].type; //{kind: ReflectionKind.string}
type.return; //{kind: ReflectionKind.void}

Deepkit macht genau das möglich. Es hängt sich in die Kompilierung von TypeScript ein und stellt sicher, dass alle Typeninformationen in dem generierten JavaScript eingebaut sind. Funktionen wie typeOf() (nicht zu verwechseln mit dem operator typeof, mit kleinem o) erlauben dem Entwickler dann darauf zuzugreifen. Es können daher auch Libraries entwickelt werden, die auf diesen Typeninformationen basieren und so dem Entwickler es erlauben, bereits geschriebene TypeScript Typen für eine ganze Palette von Anwendungsmöglichkeiten zu verwenden.

2.1. Installation

Um Deepkit’s Runtime Typessystem zu installieren werden zwei Pakete benötigt. Der Typen-Compiler in @deepkit/type-compiler und die dazu nötige Runtime in @deepkit/type. Der Typen-Compiler kann dabei in package.json devDependencies installiert werden, da er nur zur Build-Zeit benötigt wird.

npm install --save @deepkit/type
npm install --save-dev @deepkit/type-compiler

Laufzeit Typeninformationen werden standardmäßig nicht generiert. Es muss "reflection": true in der Datei tsconfig.json gesetzt werden, um es in allen Dateien im gleichen Ordner dieser Datei oder in allen Unterordnern zu aktivieren. Wenn Decorators verwenden werden sollen, muss "experimentalDecorators": true in der tsconfig.json aktiviert werden. Dies ist nicht unbedingt erforderlich, um mit @deepkit/type zu arbeiten, aber für bestimmte Funktionen anderen Deepkit Libraries und in @deepkit/framework notwendig.

Datei: tsconfig.json

{
  "compilerOptions": {
    "module": "CommonJS",
    "target": "es6",
    "moduleResolution": "node",
    "experimentalDecorators": true
  },
  "reflection": true
}

2.1.1. Type compiler

TypeScript selbst erlaubt es nicht, den Typen-Compiler über eine tsconfig.json zu konfigurieren. Es ist entweder nötig, die TypeScript Compiler API direkt oder ein Build-System wie Webpack mit ts-loader zu benutzen. Um diesen unangenehmen Weg den Benutzern von Deepkit zu ersparen, installiert sich der Deepkit Typen-Compiler automatisch selbst in node_modules/typescript sobald @deepkit/type-compiler installiert wird (dies geschieht über NPM install hooks). Dies macht es möglich, dass alle Buildtools, die auf das lokal installierte TypeScript (das in node_modules/typescript) zugreifen, automatisch den Typen-Compiler aktiviert haben. Dadurch funktioniert tsc, Angular, webpack, ts-node, und einige andere Tools automatisch mit dem Deepkit Typen-Compiler.

Falls der Typen-Compiler nicht erfolgreich automatisch installiert werden konnte (weil zum Beispiel NPM install hooks deaktiviert sind), kann dies manuell mit folgendem Kommando nachgeholt werden:

node_modules/.bin/deepkit-type-install

Beachten Sie, dass deepkit-type-install ausführt werden muss, wenn die lokale Typescript-Version aktualisiert wurde (zum Beispiel, wenn sich die Typescript-Version in package.json geändert hat und npm install ausgeführt wird).

2.1.2. Webpack

Wenn der Typen-Compiler in einem Webpack-Build verwenden werden solle, kann dies mit dem Paket ts-loader (oder jedem anderen Typescript-Loader, der die Registrierung von Transformatoren unterstützt) tun.

Datei: webpack.config.js

const typeCompiler = require('@deepkit/type-compiler');

module.exports = {
  entry: './app.ts',
  module: {
    rules: [
      {
        test: /\.tsx?$/,
          use: {
            loader: 'ts-loader',
            options: {
              //this enables @deepkit/type's type compiler
              getCustomTransformers: (program, getProgram) => ({
                before: [typeCompiler.transformer],
                afterDeclarations: [typeCompiler.declarationTransformer],
              }),
            }
          },
          exclude: /node_modules/,
       },
    ],
  },
}

2.2. Typen-Decorators

Typen-Decorators sind normale TypeScript-Typen, die Meta-Informationen beinhalten, um zur Laufzeit das Verhalten diverser Funktionen zu verändern. Deepkit liefert bereits einige Typen-Decorators mit, die einige Anwendungsfälle abdecken. So kann zum Beispiel eine Klassen-Eigenschaft als Primary-Key, als Referenz, oder Index markiert werden. Die Datenbank Library kann diese Information zur Laufzeit nutzen, um so die korrekten SQL Queries ohne vorherige Code-Generation zu erstellen. Es können auch Validator-Einschränkungen wie zum Beispiel MaxLength, Maximum, oder Positive an einen beliebigen Typen hinzugefügt werden. Auch kann dem Serializer mitgeteilt werden, wie ein bestimmter Wert zu serialisieren bzw deserialisieren ist. Zusätzlich ist es möglich, komplett eigene Type-Decorators zu erstellen und zur Laufzeit auszulesen, um so sehr individuell das Typensystem zur Laufzeit zu verwenden.

Deepkit kommt mit einer ganzen Reihe von Typen-Decorators, die alle direkt aus @deepkit/type benutzt werden können. Sie sind designt, nicht aus mehreren Libraries zu kommen, um so Code nicht direkt an eine bestimmte Library wie zum Beispiel Deepkit RPC oder Deepkit Database zu koppeln. Das erlaubt das einfachere Wiederverwenden von Typen, auch im Frontend, obwohl zum Beispiel Datenbank Typen-Decorators genutzt werden.

Folgend ist eine Liste von vorhandenen Type-Decorators. Der Validator und Serializer von @deepkit/type und @deepkit/bson sowie Deepkit Database von @deepkit/orm nutzten diese Informationen unterschiedlich. Siehe die entsprechenden Kapitel, um mehr darüber zu erfahren.

2.2.1. Integer/Float

Integer und Floats sind als Basis als number definiert und hat mehrere Untervarianten:

Type Description

integer

An integer of arbitrary size.

int8

An integer between -128 and 127.

uint8

An integer between 0 and 255.

int16

An integer between -32768 and 32767.

uint16

An integer between 0 and 65535.

int32

An integer between -2147483648 and 2147483647.

uint32

An integer between 0 and 4294967295.

float

Same as number, but might have different meaning in database context.

float32

A float between -3.40282347e+38 and 3.40282347e+38. Note that JavaScript is not able to check correctly the range due to precision issues, but the information might be handy for the database or binary serializers.

float64

Same as number, but might have different meaning in database context.

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

interface User {
    id: integer;
}

Hier ist zur Laufzeit die id des Users eine Number, wird jedoch in der Validierung und Serialisierung als Integer interpretiert. Das heisst, dass hier zum Beispiel keine Floats in Validation genutzt werden dürfen und der Serializer Floats automatisch in Integer umwandeln.

import { is, integer } from '@deepkit/type';

is<integer>(12); //true
is<integer>(12.5); //false

Die Untertypen können genauso benutzt werden und sind sinnvoll, wenn ein bestimmter Nummernbereich erlaubt werden soll.

import { is, int8 } from '@deepkit/type';

is<int8>(-5); //true
is<int8>(5); //true
is<int8>(-200); //false
is<int8>(2500); //false

2.2.2. Float

2.2.3. UUID

UUID v4 wird in der Datenbank in der Regel als Binary abgespeichert und in JSON als String.

import { is, UUID } from '@deepkit/type';

is<UUID>('f897399a-9f23-49ac-827d-c16f8e4810a0'); //true
is<UUID>('asd'); //false

2.2.4. MongoID

Marks this field as ObjectId for MongoDB. Resolves as a string. Is stored in the MongoDB as binary.

import { MongoId, serialize, is } from '@deepkit/type';

serialize<MongoId>('507f1f77bcf86cd799439011'); //507f1f77bcf86cd799439011
is<MongoId>('507f1f77bcf86cd799439011'); //true
is<MongoId>('507f1f77bcf86cd799439011'); //false

class User {
    id: MongoId = ''; //will automatically set in Deepkit ORM once user is inserted
}

2.2.5. Bigint

Per default the normal bigint type serializes as number in JSON (and long in BSON). This has however limitation in what is possible to save since bigint in JavaScript has an unlimited potential size, where numbers in JavaScript and long in BSON are limited. To bypass this limitation the types BinaryBigInt and SignedBinaryBigInt are available.

BinaryBigInt is the same as bigint but serializes to unsigned binary with unlimited size (instead of 8 bytes in most databases) in databases and string in JSON. Negative values will be converted to positive (abs(x)).

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

interface User {
    id: BinaryBigInt;
}

const user: User = {id: 24n};

serialize<User>({id: 24n}); //{id: '24'}

serialize<BinaryBigInt>(24); //'24'
serialize<BinaryBigInt>(-24); //'0'

Deepkit ORM stores BinaryBigInt as a binary field.

SignedBinaryBigInt is the same as BinaryBigInt but is able to store negative values as well. Deepkit ORM stores SignedBinaryBigInt as binary. The binary has an additional leading sign byte and is represented as an uint: 255 for negative, 0 for zero, or 1 for positive.

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

interface User {
    id: SignedBinaryBigInt;
}

2.2.6. MapName

To change the name of a property in the serialization.

import { serialize, deserialize, MapName } from '@deepkit/type';

interface User {
    firstName: string & MapName<'first_name'>;
}

serialize<User>({firstName: 'Peter'}) // {first_name: 'Peter'}
deserialize<User>({first_name: 'Peter'}) // {firstName: 'Peter'}

2.2.7. Group

Properties can be grouped together. For serialization you can for example exclude a group from serialization. See the chapter Serialization for more information.

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

interface Model {
    username: string;
    password: string & Group<'secret'>
}

serialize<Model>(
    { username: 'Peter', password: 'nope' },
    { groupsExclude: ['secret'] }
); //{username: 'Peter'}

2.2.8. Data

Each property can add additional meta-data that can be read via the Reflection API. See Runtime Types Reflection for more information.

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

interface Model {
    username: string;
    title: string & Data<'key', 'value'>
}

const reflection = ReflectionClass.from<Model>();
reflection.getProperty('title').getData()['key']; //value;

2.2.9. Excluded

Each property can be excluded from the serialization process for a specific target.

import { serialize, deserialize, Excluded } from '@deepkit/type';

interface Auth {
    title: string;
    password: string & Excluded<'json'>
}

const item = deserialize<Auth>({title: 'Peter', password: 'secret'});

item.password; //undefined, since deserialize's default serializer is called `json`

item.password = 'secret';

const json = serialize<Auth>(item);
json.password; //again undefined, since serialize's serializer is called `json`

2.2.10. Embedded

Marks the field as an embedded type.

import { PrimaryKey, Embedded, serialize, deserialize } from '@deepkit/type';

interface Address {
    street: string;
    postalCode: string;
    city: string;
    country: string;
}

interface User  {
    id: number & PrimaryKey;
    address: Embedded<Address>;
}

const user: User {
    id: 12,
    address: {
        street: 'abc', postalCode: '1234', city: 'Hamburg', country: 'Germany'
    }
};

serialize<User>(user);
{
    id: 12,
    address_street: 'abc',
    address_postalCode: '1234',
    address_city: 'Hamburg',
    address_country: 'Germany'
}

//for deserialize you have to provide the embedded structure
deserialize<User>({
    id: 12,
    address_street: 'abc',
    //...
});

It’s possible to change the prefix (which is per default the property name).

interface User  {
    id: number & PrimaryKey;
    address: Embedded<Address, {prefix: 'addr_'}>;
}

serialize<User>(user);
{
    id: 12,
    addr_street: 'abc',
    addr_postalCode: '1234',
}

//or remove it entirely
interface User  {
    id: number & PrimaryKey;
    address: Embedded<Address, {prefix: ''}>;
}

serialize<User>(user);
{
    id: 12,
    street: 'abc',
    postalCode: '1234',
}

2.2.11. Entity

To annotate interfaces with entity information. Only used in the database context.

import { Entity, PrimaryKey } from '@deepkit/type';

interface User extends Entity<{name: 'user', collection: 'users'> {
    id: number & PrimaryKey;
    username: string;
}

2.2.12. InlineRuntimeType

TODO

2.2.13. ResetDecorator

TODO

2.2.14. Database

TODO: PrimaryKey, AutoIncrement, Reference, BackReference, Index, Unique, DatabaseField.

2.2.15. Validation

TODO

Benutzerdefinierte Type-Decorators

Ein Typen-Decorator kann wie folgt definiert werden:

type MyAnnotation = {__meta?: ['myAnnotation']};

Als Konvention ist definiert, dass ein Typen-Decorator ein Object-Literal mit einem einzigen optionalen Property __meta ist, das ein Tuple als Typ hat. Der erste Eintrag in diesem Tuple ist sein eindeutiger Name und alle weiteren Tuple Einträge beliebige Optionen. So kann ein Typen-Decorator mit zusätzlichen Optionen ausgestattet werden.

type AnnotationOption<T extends {title: string}> = {__meta?: ['myAnnotation', T]};

Genutzt wird der Typen-Decorator mit dem Intersection-Operator &. Es können beliebig viele Typen-Decorators an einem Typen genutzt werden.

type Username = string & MyAnnotation;
type Title = string & & MyAnnotation & AnnotationOption<{title: 'Hello'}>;

Ausgelesen können die Typen-Decorators über die Typen-Objekte von typeOf<T>() und metaAnnotation:

import { typeOf, metaAnnotation } from '@deepkit/type';

const type = typeOf<Username>();
const annotation = metaAnnotation.getForName(type, 'myAnnotation'); //[]

Das Resultat in annotation ist entweder ein Array mit Optionen, wenn der Typen-Decorator myAnnotation genutzt wurde oder undefined wenn nicht. Hat der Typen-Decorator zusätzliche Optionen wie in AnnotationOption zu sehen, sind die übergebenen Werte in dem Array zu finden. Bereits mitgelieferte Typen-Decorators wie MapName, Group, Data, etc haben ihre eigenen Annotation-Objekt:

import { typeOf, Group, groupAnnotation } from '@deepkit/type';
type Username = string & Group<'a'> & Group<'b'>;

const type = typeOf<Username>();
groupAnnotation.getAnnotations(type); //['a', 'b']

Siehe Runtime Types Reflection, um mehr darüber zu erfahren.

2.3. External Classes

Since TypeScript does not include type information per default, imported types/classes from other packages (that did not use @deepkit/type-compiler) will not have type information available.

To annotate types for an external class, use annotateClass and make sure this function is executed in the bootstrap phase of your application before the imported class is used somewhere else.

import { MyExternalClass } from 'external-package';
import { annotateClass } from '@deepkit/type';

interface AnnotatedClass {
    id: number;
    title: string;
}

annotateClass<AnnotatedClass>(MyExternalClass);

//all uses of MyExternalClass return now the type of AnnotatedClass
serialize<MyExternalClass>({...});

//MyExternalClass can now also be used in other types
interface User {
    id: number;
    clazz: MyExternalClass;
}

MyExternalClass can now be used in serialization functions and in the reflection API.

To following shows how to annotate generic classes:

import { MyExternalClass } from 'external-package';
import { annotateClass } from '@deepkit/type';

class AnnotatedClass<T> {
    id!: T;
}

annotateClass(ExternalClass, AnnotatedClass);

2.4. Reflection

Um mit den Typeninformationen selbst direkt zu arbeiten, gibt es dabei zwei grundlegende Varianten: Type-Objekte und Reflection-Klassen. Die Reflection-Klassen werden weiter unten behandelt. Die Funktion typeOf gibt Typen-Objekte zurück, die ganz simple object literals sind. Es beinhaltet immer ein kind welches eine Nummer ist und mittels dem Enum ReflectionKind seine Bedeutung erlangt. ReflectionKind ist in dem Paket @deepkit/type wie folgt definiert:

enum ReflectionKind {
  never,    //0
  any,     //1
  unknown, //2
  void,    //3
  object,  //4
  string,  //5
  number,  //6
  boolean, //7
  symbol,  //8
  bigint,  //9
  null,    //10
  undefined, //11

  //... and even more
}

Es gibt eine ganze Reihe von möglichen Typen-Objekten, die zurückgegeben werden können. Die einfachsten sind dabei never, any, unknown, void, null, und undefined, welche wie folgt dargestellt werden:

{kind: 0}; //never
{kind: 1}; //any
{kind: 2}; //unknown
{kind: 3}; //void
{kind: 10}; //null
{kind: 11}; //undefined

Die Nummer 0 zum Beispiel ist der erste Eintrag des ReflectionKind Enums, in diesem Fall never, die Nummer 1 der zweite Eintrag, hier any, und so weiter. Entsprechend sind primitive Typen wie string, number, boolean wie folgt dargestellt:

typeOf<string>(); //{kind: 5}
typeOf<number>(); //{kind: 6}
typeOf<boolean>(); //{kind: 7}

Diese recht simplen Typen haben keine weiteren Informationen an dem Typen-Objekt, da sie direkt als Typen-Argument zu typeOf übergeben wurden. Werden jedoch Typen über Typen-Aliase übergeben, sind zusätzliche Informationen an dem Typen-Objekt zu finden.

type Title = string;

typeOf<Title>(); //{kind: 5, typeName: 'Title'}

In diesem Fall ist der Name des Type-Alias Title ebenfalls vorhanden. Ist ein Type-Alias ein Generic, werden die übergebenen Typen ebenfalls an dem Typen-Objekt verfügbar.

type Title<T> = T extends true ? string : number;

typeOf<Title<true>>();
{kind: 5, typeName: 'Title', typeArguments: [{kind: 7}]}

Ist der übergebene Type das Ergebnis eines Index-Access Operators, ist der Container und der Index-Type vorhanden:

interface User {
  id: number;
  username: string;
}

typeOf<User['username']>();
{kind: 5, indexAccessOrigin: {
    container: {kind: Reflection.objectLiteral, types: [...]},
    Index: {kind: Reflection.literal, literal: 'username'}
}}

Interfaces und Object-Literals sind beide als Reflection.objectLiteral ausgegeben und beinhalten die Properties und Methoden in dem types array.

interface User {
  id: number;
  username: string;
  login(password: string): void;
}

typeOf<User>();
{
  kind: Reflection.objectLiteral,
  types: [
    {kind: Reflection.propertySignature, name: 'id', type: {kind: 6}},
    {kind: Reflection.propertySignature, name: 'username',
     type: {kind: 5}},
    {kind: Reflection.methodSignature, name: 'login', parameters: [
      {kind: Reflection.parameter, name: 'password', type: {kind: 5}}
    ], return: {kind: 3}},
  ]
}

type User  = {
  id: number;
  username: string;
  login(password: string): void;
}
typeOf<User>(); //returns the same object as above

Index Signatures sind ebenfalls in dem types array.

interface BagOfNumbers {
    [name: string]: number;
}


typeOf<BagOfNumbers>;
{
  kind: Reflection.objectLiteral,
  types: [
    {
      kind: Reflection.indexSignature,
      index: {kind: 5}, //string
      type: {kind: 6}, //number
    }
  ]
}

type BagOfNumbers  = {
    [name: string]: number;
}
typeOf<BagOfNumbers>(); //returns the same object as above

Klassen sind ähnliche zu Object Literals und haben ihre Properties und Methods ebenfalls unter einem types array zusätzlich zu classType welches eine Referenz auf die Klasse selbst ist.

class User {
  id: number = 0;
  username: string = '';
  login(password: string): void {
     //do nothing
  }
}

typeOf<User>();
{
  kind: Reflection.class,
  classType: User,
  types: [
    {kind: Reflection.property, name: 'id', type: {kind: 6}},
    {kind: Reflection.property, name: 'username',
     type: {kind: 5}},
    {kind: Reflection.method, name: 'login', parameters: [
      {kind: Reflection.parameter, name: 'password', type: {kind: 5}}
    ], return: {kind: 3}},
  ]
}

Beachte, dass der Type von Reflection.propertySignature zu Reflection.property und Reflection.methodSignature zu Reflection.method geändert wurde. Da Properties und Methoden an Klassen zusätzliche Attribute aufweisen, sind diese Informationen ebenfalls abrufbar. Letztere beinhalten zusätzlich visibility, abstract, und default. Typen-Objekte von Klassen beinhalten nur die Properties und Methoden der Klasse selbst und nicht der Super-Klassen. Das ist konträr zu Typen-Objekten von interfaces/object-literals, welche alle property signatures und method signatures aller Elternteile aufgelöst in types haben. Um die Property und Methoden der Super-Klassen aufzulösen, kann entweder ReflectionClass und dessen ReflectionClass.getProperties() (siehe nachfolgende Abschnitte) oder resolveTypeMembers() von @deepkit/type genutzt werden.

Es gibt eine ganze Hülle und Fülle von Typen-Objekten. So zum Beispiel für literal, template literals, promise, enum, union, array, tuple, und viele mehr. Um herauszufinden, welche es alle gibt und welche Informationen bereitstehen, empfiehlt es sich Type von @deepkit/type zu importieren. Es ist ein union mit allen Möglichen Subtypes wie z.b. TypeAny, TypeUnknonwn, TypeVoid, TypeString, TypeNumber, TypeObjectLiteral, TypeArray, TypeClass, und viele mehr. Dort ist dann die genaue Struktur zu finden.

2.4.1. Type Cache

Type-Objekte sind für Type-Aliase, Funktionen, und Klassen gecached sobald keine Generic-Argument übergeben ist. Das heisst konkret, dass ein Aufruf zu typeOf<MyClass>() immer das selbe Objekt zurückgibt.

type MyType = string;

typeOf<MyType>() === typeOf<MyType>(); //true

Sobald jedoch eine Generic-Type benutzt wird, werden immer neue Objekte erzeugt, selbst wenn der übergebene Typen immer dasselbe ist. Das ist so, da theoretisch unendlich viele Kombinationen möglich sind und so ein Cache effektiv ein Memory-Leak darstellen würde.

type MyType<T> = T;

typeOf<MyType<string>>() === typeOf<MyType<string>>();
//false

Sobald ein Typ jedoch in einen rekursiven Typen mehrfach instantiiert wird, ist dieser gecacht. Die Dauer des Cache ist allerdings nur auf den Moment der Berechnung des Types limitiert und ist danach nicht mehr existent. Auch ist zwar das Type-Objekt gecacht, doch wird eine neue Referenz zurückgegeben und ist nicht das exakt selbe Objekt.

type MyType<T> = T;
type Object = {
   a: MyType<string>;
   b: MyType<string>;
};

typeOf<Object>();

MyType<string> ist gecacht solange Object berechnet wird. Die PropertySignature von a und b haben dadurch zwar denselben type aus dem Cache, sind jedoch nicht dasselbe Type-Objekt.

Alle nicht-root Type-Objekte haben eine parent Eigenschaft, welche in der Regel auf den umschließenden Elternteil zeigen. Dies ist wertvoll, um zum Beispiel herauszufinden, ob ein Type bestandteil eines union ist oder nicht.

type ID = string | number;

typeOf<ID>();
*Ref 1* {
  kind: ReflectionKind.union,
  types: [
    {kind: ReflectionKind.string, parent: *Ref 1* } }
    {kind: ReflectionKind.number, parent: *Ref 1* }
  ]
}

Ref 1 zeigt dabei auf das eigentliche union Type-Objekt.

Bei zwischengespeicherten Type-Objekten wie oben exemplarisch aufgezeigt, sind die parent Eigenschaften nicht immer die echten Elternteile. So zum Beispiel bei einer Klasse, die mehrfach genutzt wird, zeigen zwar unmittelbaren Typen in types (TypePropertySignature und TypeMethodSignature) auf das korrekte TypeClass, aber die type dieser Signature-Typen zeigen auf die Signature-Typen des TypeClass des Cache-Eintrages. Das ist wichtig zu wissen, um so nicht unendlich die parent-Struktur auszulesen, sondern nur der unmittelbare Elternteil. Die Tatsache, dass der parent nicht unendliche Genauigkeit hat, ist Performance-Gründen geschuldet.

2.4.2. JIT Cache

Im weiteren Verlauf werden einige Funktionen und Features beschrieben, die oft auf die Type-Objekte basieren. Um einige davon performant umzusetzen, braucht es einen JIT-Cache (just in time) pro Type-Objekt. Die kann via getJitContainer(type) bereitgestellt werden. Diese Funktion gibt ein simples Objekt zurück, auf den beliebig Daten gespeichert werden können. Solange keine Referenz auf das Objekt gehalten wird, löscht es sich automatisch durch den GC sobald das Type-Objekt selbst auch nicht mehr referenziert wird.

2.4.3. Reflection-Klassen

Zusätzlich zu der typeOf<>() Funktion gibt es diverse Reflection-Klassen, die eine OOP-Alternative zu den Type-Objekten bieten. Die Reflection-Klassen sind nur für Klassen, Interface/Object-literale und Funktionen und deren direkte Unter-Typen (Properties, Methods, Parameter) vorhanden. Alle tieferen Typen müssen wieder mit den Type-Objekten ausgelesen werden.

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

interface User {
    id: number;
    username: string;
}


const reflection = ReflectionClass.from<User>();

reflection.getProperties(); //[ReflectionProperty, ReflectionProperty]
reflection.getProperty('id'); //ReflectionProperty

reflection.getProperty('id').name; //'id'
reflection.getProperty('id').type; //{kind: ReflectionKind.number}
reflection.getProperty('id').isOptional(); //false

2.4.4. Typeninformation empfangen

Um selbst Funktionen bereitzustellen, die auf Typen operieren, kann es nützlich sein, dem User anzubieten, einen Typen manuell zu übergeben. Zum Beispiel könnte bei einer Validierungsfunktion es sinnvoll sein, als ersten Type-Argument den zu wünschenden Typen bereitzustellen und als erstes Funktionsargument die zu validierende Daten.

validate<string>(1234);

Damit diese Funktion den Typ string erhält, muss es dieses dem Typen-Compiler mitteilen.

function validate<T>(data: any, type?: ReceiveType<T>): void;

ReceiveType mit der Referenz auf den ersten Typenargumenten T signalisiert dem Typen-Compiler, dass jeder Aufruf zu validate den Type an zweiter Stelle (da type an zweiter Stelle deklariert ist) stellen soll. Um zur Laufzeit dann die Informationen auszulesen, wird die Funktion resolveReceiveType genutzt.

import { resolveReceiveType, ReceiveType } from '@deepkit/type';

function validate<T>(data: any, type?: ReceiveType<T>): void {
    type = resolveReceiveType(type);
}

Es ist nützlich, das Ergebnis derselben Variable zuzuweisen, um nicht unnötig eine neue anzulegen. In type ist nun entweder ein Typen-Objekt abgelegt oder es wird ein Fehler geworfen, wenn zum Beispiel kein Typen-Argument übergeben wurde, Deepkit’s Typen-Compiler nicht richtig installiert wurde, oder das Emitieren von Typeninformationen nicht aktiviert ist (siehe dazu die Sektion Installation weiter oben).

2.5. Bytecode

Um im Detail zu lernen, wie Deepkit die Typeninformationen im JavaScript enkodiert und ausliest, ist dieses Kapitel gedacht. Es erklärt, wie die Typen konkret in Bytecode umgewandelt, im JavaScript emittiert, und anschließen zur Laufzeit interpretiert werden.

2.5.1. Typen-Compiler

Der Type-Compiler (in @deepkit/type-compiler) ist dafür Verantwortlich, die definierten Typen in den TypeScript Dateien auszulesen und in ein Bytecode zu kompilieren. Dieser Bytecode hat alles, was nötig ist, um die Typen in der Laufzeit auszuführen. Zum Zeitpunkt dieses Buches ist der Type-Compiler ein sogenannter TypeScript Transformer. Dieser Transformer ist ein Plugin für den TypeScript Compiler selbst und wandelt ein TypeScript AST (Abstract Syntax Tree) in ein anderen TypeScript AST um. Deepkit’s Typen-Compiler liest in diesem Prozess den AST aus, produziert den dazugehörigen Bytecode, und fügt diesen in den AST ein.

TypeScript selbst erlaubt es nicht, dieses Plugin aka Transformer über eine tsconfig.json zu konfigurieren. Es ist entweder nötig, die TypeScript Compiler API direkt zu benutzen, oder ein Buildsystem wie Webpack mit ts-loader. Um diesen unangenehmen Weg den Benutzern von Deepkit zu ersparen, installiert sich der Deepkit Typen-Compiler automatisch selbst in node_modules/typescript sobald @deepkit/type-compiler installiert wird. Dies macht es möglich, dass alle Buildtools, die auf das lokal installierte TypeScript (das in node_modules/typescript) zugreifen, automatisch den Typen-Compiler aktiviert haben. Dadurch funktioniert tsc, Angular, webpack, ts-node, und einige andere Tools automatisch mit Deepkit’s Typen-Compiler.

Ist das automatische Ausführen von NPM install scripts nicht aktiviert und wird so das lokal installierte Typescript nicht modifiziert, so muss dieser Prozess manuell ausgeführt werden, sofern man das möchte. Alternative kann auch der Typen-Compiler manuell in einen Buildtool wie zum Beispiel webpack genutzt werden. Siehe dazu die Sektion Installation weiter oben.

2.5.2. Bytecode Encoding

Der Bytecode ist eine Folge von Befehlen für eine Virtuelle Maschine und ist im JavaScript selbst als Array mit Referenzen und String (dem eigentlichen Bytecode) enkodiert.

//TypeScript
type TypeA = string;

//generated JavaScript
const typeA = ['&'];

Die vorhandenen Befehle selbst sind jeweils ein Byte groß und in @deepkit/type-spec als ReflectionOp Enum zu finden. Zum Zeitpunkt dieses Buches ist der Befehlssatz über 81 Befehle gross.

enum ReflectionOp {
    never,
    any,
    unknown,
    void,
    object,

    string,
    number,

    //...many more
}

Eine Folge von Befehlen wird enkodiert als einen String um Speicherplatz zu sparen. So wird ein Typ string[] als Bytecode Program [string, array] konzeptionell dargestellt, welches die Bytes [5, 37] hat und mit folgendem Algorithmus enkodiert:

function encodeOps(ops: ReflectionOp[]): string {
    return ops.map(v => String.fromCharCode(v + 33)).join('');
}

Entsprechend wird aus einer 5 ein &-Zeichen und aus einer 37 ein F-Zeichen. Zusammen wird daraus &F und in Javascript als ['&F'] emittiert.

//TypeScript
export type TypeA = string[];

//generated JavaScript
export const __ΩtypeA = ['&F'];

Um Namenskonflikte vorzubeugen, erhält jeder Typ ein "__Ω" als Prefix. Für jeden explizit definierten Typen, der exportiert oder von einem exportierten Typen genutzt wird, wird ein Bytecode das JavaScript emittiert. Auch Klassen und Funktionen erhalten einen Bytecode direkt als Eigenschaft.

//TypeScript
function log(message: string): void {}

//generated JavaScript
function log(message) {}
log.__type = ['message', 'log', 'P&2!$/"'];

2.5.3. Virtuelle Maschine

Eine virtuelle Maschine (in @deepkit/type die Klasse Processor) zur Laufzeit ist dafür zuständig den encodierten Bytecode zu dekodieren und auszuführen. Sie gibt immer ein Typen-Objekt zurück, siehe weiter oben die Sektion Reflection.

3. Validation

Validation ist der Prozess um Daten auf Korrektheit zu prüfen. Korrektheit ist dann gegeben, wenn der Typ der Richtige ist und zusätzliche definierte Einschränkungen erfüllt sind. Dabei unterscheidet Deepkit generell zwischen Typenvalidierung und die Validierung von zusätzlichen Einschränkungen.

Es wird immer dann Validation benutzt, wenn Daten aus einer Quelle stammen, die als unsicher gilt. Unsicher bedeutet, dass keine garantierten Annahmen über die Typen oder Inhalte der Daten getroffen werden können und somit die Daten buchstäblichen jeden beliebigen Wert zur Laufzeit haben könnte. So sind Daten aus Usereingaben generell als nicht sicher einzustufen. Daten aus einem HTTP-Request (query parameter, body), CLI-Argumente, oder einer eingelesenen Datei müssen validiert werden. Wenn eine Variable als Nummer deklariert ist, muss sich auch eine Nummer darin befinden, ansonsten kann das Program abstürzen oder eine Sicherheitslücke entstehen.

In einem Controller einer HTTP-Route zum Beispiel ist somit oberstes Gebot, jede Usereingabe (query parameter, body) zu prüfen. Hierbei ist besonders im Umfeld von TypeScript zu beachten, dass keine Type-Casts verwenden werden, da diese fundamental unsicher sind.

app.post('/user', function(request) {
    const limit = request.body.limit as number;
});

Dieser oft gesehene Code stellt ein Fehler da, der zum Programmabsturz oder zu einer Sicherheitslücke führen kann, da ein Type-Cast as number verwendet wurde, der keinerlei Sicherheiten zur Laufzeit bereitstellt. Der User kann als limit einfach einen String übergeben und das Program würde dann mit einem String in limit arbeiten, obwohl der Code darauf basiert, dass es eine Nummer sein muss. Um diese Sicherheit zur Laufzeit zu erhalten gibt es Validatoren und Type-Guards. Auch könnte ein Serializer benutzt werden, um limit in eine Nummer umzuwandeln. Hierzu findet sich in Serialization mehr Informationen.

Validation ist essenzieller Bestandteil jeder Anwendung und sollte besser einmal zuviel als einmal zu wenig genutzt werden. Deepkit stellt viele Validationsoptionen bereit und hat eine high-performance Implementierung, sodass sich in den allermeisten Fällen kein Gedanke um die Ausführungszeit gemacht werden muss. Nutzen Sie soviel Validation wie möglich, im Zweifel einmal mehr, um auf der sicheren Seite zu stehen.

Dabei haben viele Komponenten von Deepkit wie z.b. der HTTP-Router, die RPC-Abstraktion, aber auch die Datenbank Abstraktion selbst Validation eingebaut und wird automatisch ausgeführt, sodass es in vielen Fällen nicht nötig ist, dies manuell zu machen. In den dazugehörigen Kapiteln (CLI, HTTP, RPC, Database) ist genaustens erklärt, wann eine Validation automatisch geschieht. Stellen Sie dabei sicher, dass Ihnen bekannt ist, an welchen Stellen Einschränkungen beziehungsweise Typen definieren werden müssen und nutzen Sie kein any, damit diese Validierungen automatisch gut und sicher funktionieren. So können Sie sich eine ganze Menge manueller Arbeit sparen, um den Code sauber und sicher zu halten.

3.1. Benutzung

Die Basisfunktion des Validators ist es, ein Wert auf seinen Typ zu prüfen. Zum Beispiel ob eine Wert ein String ist. Dabei geht es nicht darum, was der String beinhaltet, sondern lediglich um seinen Typ. Typen gibt es viele in Typescript: string, number, boolean, bigint, objects, classes, interface, generics, mapped types, und viele mehr. Durch TypeScripts mächtiges Typensystem sind eine große Vielzahl von unterschiedlichsten Typen vorhanden.

In JavaScript selbst können primitive Typen mit dem typeof operator analysiert werden. Für komplexere Typen wie interfaces, mapped types, oder generische Set/Map ist das nicht mehr so einfach möglich und es wird eine Validator-Library wie zum Beispiel @deepkit/type nötig. Deepkit ist dabei die einzige Lösung, die es erlaubt, alle TypenScript Typen direkt ohne Umwege zu validieren.

In Deepkit kann eine Typevalidierung entweder über die Funktion validate, is, oder assert vorgenommen werden. Die Funktion is ist dabei ein sogenannter Type-Guard und assert eine Type-Assertion. Beide werden erst in der nächsten Sektion erklärt. Die Funktion validate gibt ein Array von gefundenen Fehlern und bei Erfolg ein leeres Array zurück. Jeder Eintrag in diesem Array beschreibt dabei den genaue Fehlercode und die Fehlermeldung sowie auch den Pfad sobald komplexere Typen wie Objekte oder Arrays validiert werden.

Die Benutzung aller drei Funktionen geschieht grob auf die selbe Weise. So wird als ersten Typenargument der Typ angegeben beziehungsweise referenziert und als erstes Funktionsargument die Daten übergeben.

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

const errors = validate<string>('abc'); //[]
const errors = validate<string>(123); //[{code: 'type', message: 'Not a string'}]

Wird mit komplexeren Typen wie Klassen oder Interfaces gearbeitet, kann das Array auch mehrere Einträge beinhalten.

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

interface User {
    id: number;
    username: string;
}

validate<User>({id: 1, username: 'Joe'}); //[]

validate<User>(undefined); //[{code: 'type', message: 'Not a object'}]

validate<User>({});
//[
//  {path: 'id', code: 'type', message: 'Not a number'}],
//  {path: 'username', code: 'type', message: 'Not a string'}],
//]

Der Validator unterstützt dabei auch tiefe rekursive Typen. Pfade werden dann mit einem Punkt getrennt angegeben.

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

interface User {
    id: number;
    username: string;
    supervisor?: User;
}

validate<User>({id: 1, username: 'Joe'}); //[]

validate<User>({id: 1, username: 'Joe', supervisor: {}});
//[
//  {path: 'supervisor.id', code: 'type', message: 'Not a number'}],
//  {path: 'supervisor.username', code: 'type', message: 'Not a string'}],
//]

Nutzen Sie dabei die Vorteile, die TypeScript ihnen bietet. So können komplexere Typen wie ein User an mehreren Stellen wiederverwendet werden, ohne diesen immer wieder erneut zu deklarieren. Ist zum Beispiel ein User ohne seine id zu valideren, können TypeScript Utitilies genutzt werden, um schnell und effizient abgeleitete Untertypen zu erstellen. Ganz im Sinne von DRY (Don’t Repeat Yourself).

type UserWithoutId = Omit<User, 'id'>;

validate<UserWithoutId>({username: 'Joe'}); //valid!

Deepkit hat als einziges großes Framework die Möglichkeit, auf TypeScripte Typen auf diese Art und Weise zur Laufzeit zuzugreifen. Möchten Sie Typen in Frontend und Backend nutzen, können Typen in eine eigene Datei ausgelagert werden und so überall importiert werden. Nutzen Sie diese Möglichkeit zu Ihrem Vorteil, um den Code effizient und sauber zu halten.

Ein Type-Cast (konträr zur Type-Guard) in TypeScript ist kein Konstrukt zur Laufzeit, sondern wird nur im Typensystem selbst behandelt. Es ist keine sichere Variante, um unbekannten Daten einen Typ zuzuweisen.

const data: any = ...;

const username = data.username as string;

if (username.startsWith('@')) { //might crash
}

Der Code as string ist dabei nicht sicher. Die Variable data könnte buchstäblichen jeden Wert haben, so zum Beispiel {username: 123}, or gar {}, und hätte zur Folge, dass username nicht ein String ist, sondern etwas völlig anderes und daher der Code username.startsWith('@') zu einem Fehler führen wird, so dass im schlimmsten Fall das Programm abstürzt. Um zur Laufzeit garantiert festzustellen, dass data hier eine Eigenschaft username mit dem Type String hat, müssen Type-Guards verwendet werden.

Type-Guards sind Funktionen, die TypeScript einen Hinweis darüber geben, welche Type die übergeben Daten zur Laufzeit garantiert haben. Mit diesem Wissen ausgestattet, verfeinert ("narrowed") TypeScript dann den Typ im weiteren Codeverlauf. Aus zum Beispiel any kann somit ein String, oder ein anderer Typ auf eine sichere Weise gemacht werden. Wenn also Daten vorliegen, von denen der Typ nicht bekannt ist (any oder unknown), hilft ein Type-Guard diesen basierend auf den Daten selbst genauer sicher einzugrenzen. Dabei ist der Type-Guard nur so sicher wie seine Implementieren. Machen Sie dabei einen Fehler, kann das schwere Folgen nach sich ziehen, da fundamentale Annahmen sich plötzlich als unwahr herausstellen.

3.2. Type-Guard

Ein Type-Guard auf den obige genutzten Typen User könnte in einfachster Form wie folgt aussehen. Zu beachten ist, dass die obigen erklärten Besonderheiten mit NaN hier nicht Bestandteil sind und somit dieser Type-Guard nicht ganz korrekt ist.

function isUser(data: any): data is User {
    return 'object' === typeof data
           && 'number' === data.id
           && 'string' === data.username;
}

isUser({}); //false

isUser({id: 1, username: 'Joe'}); //true

Ein Type-Guard gibt immer ein Boolean zurück und wird in der Regel direkt in einer If-Bedienung genutzt.

const data: any = await fetch('/user/1');

if (isUser(data)) {
    data.id; //can be safely accessed and is a number
}

Für jeden Type-Guard eine eigene Funktion zu schreiben, besonders für komplexere Typen, und diese dann immer wieder anzupassen, wenn ein Typ sich verändert, ist äußerst mühsam, fehleranfällig, und nicht effizient. Daher bietet Deepkit die Funktion is an, welche automatisch für jeden beliebigen TypeScript Typen einen Type-Guard bereitstellt. Diese berücksichtigt dann auch automatisch Besonderheiten wie das oben erwähnte Problem mit NaN. Die Funktion is macht dabei dasselbe wie validate, nur gibt sie statt einem Array von Fehler schlicht ein Boolean zurück.

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

is<string>('abc'); //true
is<string>(123); //false


const data: any = await fetch('/user/1');

if (is<User>(data)) {
    //data is guaranteed to be of type User now
}

Ein öfter aufzufindendes Pattern ist, bei fehlerhafter Validierung direkt ein Fehler zurückzugeben, sodass nachfolgender Code nicht ausgeführt wird. Das kann an diversen Stellen genutzt werden, ohne den kompletten Flow des Codes abzuändern.

function addUser(data: any): void {
    if (!is<User>(data)) throw new TypeError('No user given');

    //data is guaranteed to be of type User now
}

Alternativ kann eine TypeScript type assertion verwenden werden. Die Funktion assert wirft automatisch einen Fehler, wenn die gegebenen Daten nicht auf einen Typen korrekt validiert. Die spezielle Signatur der Funktion, welche TypeScript type assertions auszeichnet, hilft TypeScript dabei, die übergebene Variable automatisch zu verfeinern ("narrowing").

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

function addUser(data: any): void {
    assert<User>(data); //throws on invalidate data

    //data is guaranteed to be of type User now
}

Nutzen Sie auch hier die Vorteile, die TypeScript ihnen bietet. Typen können wiederverwendet oder durch diverse TypeScript Funktionen angepasst werden.

3.3. Error Reporting

Die Funktionen is, assert und validates geben ein Boolean als Resultat zurück. Um genaue Informationen über fehlgeschlagenen Validations-Regeln zu erhalten, kann die Funktion validate benutzt werden. Sie gibt ein leeres Array zurück, wenn alles erfolgreich validiert wurde. Bei Fehlern enthält das Array ein oder mehrere Einträge mit folgender Struktur:

interface ValidationErrorItem {
    /**
     * The path to the property. Might be a deep path separated by dot.
     */
    path: string;
    /**
     * A lower cased error code that can be used to identify this error and translate.
     */
    code: string,
    /**
     * Free text of the error.
     */
    message: string,
}

Die Funktion erhält als erstes Typen-Argument ein beliebigen TypeScript Typ und als erstes Argument die zu validierende Daten.

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

validate<string>('Hello'); //[]
validate<string>(123); //[{code: 'type', message: 'Not a string', path: ''}]

validate<number>(123); //[]
validate<number>('Hello'); //[{code: 'type', message: 'Not a number', path: ''}]

Es können hierbei auch komplexe Typen wie Interfaces, Klassen, oder Generics benutzt werden.

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

interface User {
    id: number;
    username: string;
}

validate<User>(undefined); //[{code: 'type', message: 'Not an object', path: ''}]
validate<User>({}); //[{code: 'type', message: 'Not a number', path: 'id'}]
validate<User>({id: 1}); //[{code: 'type', message: 'Not a string', path: 'username'}]
validate<User>({id: 1, username: 'Peter'}); //[]

3.4. Einschränkungen

Zusätzlich zum Prüfen der Typen können weitere beliebige Einschränkungen an einen Typen hinzugefügt werden. Das Prüfen von diesen zusätzlichen Inhalts-Einschränkungen erfolgt automatisch, nachdem die Typen selbst geprüft wurden. Dies geschieht in allen Validierungsfunktionen wie validate, is, and assert. Eine Einschränkung kann dabei zum Beispiel sein, dass ein String eine bestimmte minimale oder maximale Länge haben muss. Diese Einschränkungen werden über die Typen-Decorators an den eigentlichen Typen hinzugefügt. Dabei gibt es eine ganze Vielzahl von Dekorationen, die genutzt werden können. Eigene Decorators können bei erweitertem Bedarf nach Belieben selbst definiert und genutzt werden.

type Username = string & MinLength<3>;

Mit & können beliebig viele Typen-Decorators an den eigentlichen Typ hinzugefügt werden. Das Ergebnis, hier Username, kann dann in allen Validierungsfunktionen aber auch in anderen Typen genutzt werden.

is<Username>('ab'); //false, because minimum length is 3
is<Username>('Joe'); //true

interface User {
  id: number;
  username: Username;
}

is<User>({id: 1, username: 'ab'}); //false, because minimum length is 3
is<User>({id: 1, username: 'Joe'}); //true

Die Funktion validate gibt dabei nützliche Fehlermeldungen, die von den Einschränkungen kommen.

const errors = validate<Username>('xb');
//[{ code: 'minLength', message: `Min length is 3` }]

Diese Informationen können zum Beispiel wunderbar auch an einem Formular automatisch dargestellt und mittels des code übersetzt werden. Durch den vorhandenen Pfad bei Objekten und Arrays können so Felder in einem Formular den passenden Fehler rausfiltern und anzeigen.

validate<User>({id: 1, username: 'ab'});
//{ path: 'username', code: 'minLength', message: `Min length is 3` }

Ein oft nützlicher Anwendungsfall ist auch eine E-Mail mit einer RegExp-Einschränkung zu definieren. Einmal den Typ definiert, kann er überall benutzt werden.

export const emailRegexp = /^\[email protected]\S+$/;
type Email = string & Pattern<typeof emailRegexp>

is<Email>('abc'); //false
is<Email>('[email protected]'); //true

Es können beliebig viele Einschränkungen hinzugefügt werden.

type ID = number & Positive & Maximum<1000>;

is<ID>(-1); //false
is<ID>(123); //true
is<ID>(1001); //true

3.4.1. Constraint Types

Validate<typeof myValidator>

Validierung mitteils einer benutzerdefinierten Validierungsfunktion. Siehe nächste Sektion Benutzerdefinierte Validator für mehr Informationen.

	type T = string & Validate<typeof myValidator>
Pattern<typeof myRegexp>

Defines a regular expression as validation pattern. Usually used for E-Mail validation or more complex content validation.

	const myRegExp = /[a-zA-Z]+/;
	type T = string & Pattern<typeof myRegExp>
Alpha

Validation for alpha characters (a-Z).

	type T = string & Alpha;
Alphanumeric

Validation for alpha and numeric characters.

	type T = string & Alphanumeric;
Ascii

Validation for ASCII characters.

	type T = string & Ascii;
Decimal<number, number>

Validation for string represents a decimal number, such as 0.1, .3, 1.1, 1.00003, 4.0, etc.

	type T = string & Decimal<1, 2>;
MultipleOf<number>

Validation of numbers that are a multiple of given number.

	type T = number & MultipleOf<3>;
MinLength<number>, MaxLength<number>

Validation for min/max length for arrays or strings.

	type T = any[] & MinLength<1>;

	type T = string & MinLength<3> & MaxLength<16>;
Includes<'any'> Excludes<'any'>

Validation for an array item or sub string being included/excluded

	type T = any[] & Includes<'abc'>;
	type T = string & Excludes<' '>;
Minimum<number>, Maximum<number>

Validation for a value being minimum or maximum given number. Same as >= and <=.

	type T = number & Minimum<10>;
	type T = number & Minimum<10> & Maximum<1000>;
ExclusiveMinimum<number>, ExclusiveMaximum<number>

Same as minimum/maximum but excludes the value itself. Same as > and <.

	type T = number & ExclusiveMinimum<10>;
	type T = number & ExclusiveMinimum<10> & ExclusiveMaximum<1000>;
Positive, Negative, PositiveNoZero, NegativeNoZero

Validation for a value being positive or negative.

	type T = number & Positive;
	type T = number & Negative;
BeforeNow, AfterNow

Validation for a date value compared to now (new Date)..

	type T = Date & BeforeNow;
	type T = Date & AfterNow;
Email

Simple regexp validation of emails via /^\[email protected]\S+$/. Is automatically a string, so no need to do string & Email.

	type T = Email;
integer

Ensures that the number is a integer in the correct range. Is automatically a number, so no need to do number & integer.

	type T = integer;
	type T = uint8;
	type T = uint16;
	type T = uint32;
	type T = int8;
	type T = int16;
	type T = int32;

See Special types: integer/floats for more information

3.4.2. Benutzerdefinierte Validator

Wenn die eingebauten Validatoren nicht ausreichen, können eigene Validierungsfunktionen erstellt und über den Validate-Decorator verwendet werden.

import { ValidatorError, Validate, Type, validates, validate }
  from '@deepkit/type';

function titleValidation(value: string, type: Type) {
    value = value.trim();
    if (value.length < 5) {
        return new ValidatorError('tooShort', 'Value is too short');
    }
}

interface Article {
    id: number;
    title: string & Validate<typeof titleValidation>;
}

console.log(validates<Article>({id: 1})); //false
console.log(validates<Article>({id: 1, title: 'Peter'})); //true
console.log(validates<Article>({id: 1, title: ' Pe     '})); //false
console.log(validate<Article>({id: 1, title: ' Pe     '})); //[ValidationErrorItem]

Beachten Sie, dass Ihre benutzerdefinierte Validierungsfunktion ausgeführt wird, nachdem alle eingebauten Typen-Validierungen aufgerufen wurden. Wenn ein Validator fehlschlägt, werden alle nachfolgenden Validatoren für den aktuellen Typen ausgelassen. Pro Typen ist nur ein Fehler möglich.

Generic Validator

In der Validator-Funktion ist das Typen-Objekt verfügbar, das verwendet werden kann, um weitere Informationen über den Typen zu erhalten, der den Validator verwendet. Es gibt auch eine Möglichkeit, eine beliebige Validator-Option zu definieren, die an den Validate-Typen übergeben werden muss und den Validator konfigurierbar macht. Mit diesen Informationen und ihren übergeordneten Referenzen können leistungsfähige generische Validatoren erstellt werden.

import { ValidatorError, Validate, Type, is, validate }
  from '@deepkit/type';

function startsWith(value: any, type: Type, chars: string) {
    const valid = 'string' === typeof value && value.startsWith(chars);
    if (!valid) {
        return new ValidatorError('startsWith', 'Does not start with ' + chars)
    }
}

type MyType = string & Validate<typeof startsWith, 'a'>;

is<MyType>('aah'); //true
is<MyType>('nope'); //false

const errors = validate<MyType>('nope');
//[{ path: '', code: 'startsWith', message: `Does not start with a` }]);

4. 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.

4.1. Cast

Todo

4.2. 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.

4.3. 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);

4.3.1. 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.

4.4. Type-Decorators

4.4.1. Integer

4.4.2. Group

4.4.3. Excluded

4.4.4. Mapped

4.4.5. Embedded

4.5. Naming Strategy

4.6. 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.

4.6.1. 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.

4.6.2. 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.

4.6.3. 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();

5. Dependency Injection

Dependency Injection (DI) ist ein Design-Pattern, bei dem Klassen und Funktionen ihre Abhängigkeiten empfangen. Es folgt dem Prinzip von Inversion of Control (IoC, zu Deutsch "Umkehrung der Steuerung") und hilft dabei vor allem komplexen Code besser zu separieren, um so die Testbarkeit, Modularität, und Übersichtlichkeit deutlich zu verbessern. Zwar gibt es noch andere Design-Patterns wie zum Beispiel den Service-Locator Pattern, um das Prinzip von IoC anzuwenden, jedoch hat sich DI als dominantes Pattern vor allem in Enterprise-Software etabliert.

Um das Prinzip von IoC zu veranschaulichen nachfolgend ein Beispiel:

import { HttpClient } from 'http-library';

class UserRepository {
    async getUsers(): Promise<Users> {
        const client = new HttpClient();
        return await client.get('/users');
    }
}

Die Klasse UserRepository hat dabei einen HttpClient als Abhängigkeit. Diese Abhängigkeit an sich ist nichts Auffälliges, allerdings ist problematisch, dass UserRepository den HttpClient selbst erstellt. Dies ist auf den ersten Blick naheliegend, hat jedoch seine Nachteile: Was, wenn wir den HttpClient austauschen möchten? Was, wenn wir UserRepository in einem Unit-Test testen wollen, ohne dass echte HTTP-Anfragen herausgehen dürfen? Woher wissen wir, dass die Klasse überhaupt einen HttpClient benutzt?

5.1. Inversion of Control

Im Gedanke von Inversion of Control (IoC) ist folgende alternative Variante, die den HttpClient als explizite Abhängigkeit im Constructor setzt (auch bekannt als Constructor-Injection).

class UserRepository {
    constructor(
        private http: HttpClient
    ) {}

    async getUsers(): Promise<Users> {
        return await this.http.get('/users');
    }
}

Nun ist nicht mehr UserRepository dafür verantwortlich den HttpClient anzulegen, sondern der User von UserRepository. Das ist Inversion of Control (IoC). Die Steuerung wurde umgedreht bzw. invertiert. Ganz konkret wendet dieser Code Dependency Injection an, denn Abhängigkeiten werden empfangen (injiziert) und nicht mehr selbst angelegt oder angefordert. Dependency Injection ist dabei nur eine Variante IoC anzuwenden.

5.2. Service Locator

Neben DI ist auch Service Locator (SL) eine Möglichkeit, das IoC Prinzip anzuwenden. Dies gilt gemeinhin als das Gegenstück zu Dependency Injection, da es Abhängigkeiten anfordert und nicht empfängt. Würde HttpClient im obigen Code wie folgt angefordert werden, würde man von einem Service Locator Pattern sprechen.

class UserRepository {
    async getUsers(): Promise<Users> {
        const client = locator.getHttpClient();
        return await client.get('/users');
    }
}

Die Funktion locator.getHttpClient kann dabei einen ganz beliebigen Namen tragen. Alternativen wären zum Beispiel Funktionsaufrufe wie useContext(HttpClient), getHttpClient(), await import("client"), oder ein Container-Aufruf wie container.get(HttpClient). Ein Import eines Globals ist eine etwas andere Variante eines Service Locators, bei dem das Module-System selbst als Locator benutzt wird:

import { httpClient } from 'clients'

class UserRepository {
    async getUsers(): Promise<Users> {
        return await httpClient.get('/users');
    }
}

Alle diese Varianten haben gemeinsam, dass sie die Abhängigkeit HttpClient explizit anfordern. Dieses Anfordern kann nicht nur an Properties als Default-Value geschehen, sondern auch irgendwo mitten im Code. Da mitten im Code bedeutet, dass es nicht Bestandteil eines Typen-Interfaces ist, ist die Nutzung des HttpClients versteckt. Abhängig der Variante wie der HttpClient angefordert wird, kann es mitunter sehr schwer oder komplett unmöglich sein, diesen durch eine andere Implementierung auszutauschen. Vor allem im Bereich von Unit-Tests und zwecks Übersichtlichkeit kann es hier zu Schwierigkeiten kommen, sodass der Service Locator mittlerweile in bestimmten Situationen als ein Anti-Pattern eingestuft wird.

5.3. Dependency Injection

Bei Dependency Injection wird nichts angefordert, sondern es wird explizit vom Nutzer bereitgestellt beziehungsweise von dem Code empfangen. Wie im Beispiel von Inversion of Control zu sehen, ist dort bereits das Dependency Injection Pattern angewendet worden. Konkret ist dort Constructor-Injection zu sehen, da die Abhängigkeit im Constructor deklariert ist. So muss UserRepository nun wie folgt genutzt werden.

const users = new UserRepository(new HttpClient());

Der Code, der UserRepository verwenden will, muss auch all seine Abhängigkeiten bereitstellen (injizieren). Ob HttpClient dabei jedes Mal neu erstellt oder jedes Mal derselbe genutzt werden soll, entscheidet nun der User der Klasse und nicht mehr die Klasse selbst. Es wird nicht mehr (aus der Sicht der Klasse) wie beim Service-Locator angefordert oder bei dem initialen Beispiel komplett selbst erstellt. Dieses Invertieren des Flows hat diverse Vorteile:

  • Der Code ist einfacher zu verstehen, da alle Abhängigkeiten explizit sichtbar sind.

  • Der Code ist einfacher zu testen, da alle Abhängigkeiten eindeutig sind und bei Bedarf einfach abgeändert werden können.

  • Der Code ist modularer, da Abhängigkeiten einfach ausgetauscht werden können.

  • Es fördert das Separation of Concern Prinzip, da UserRepository nicht mehr dafür verantwortlich ist, im Zweifel sehr komplexe Abhängigkeiten selbst zu erstellen.

Aber ein offensichtlicher Nachteil kann auch direkt erkannt werden: Muss ich nun wirklich alle Abhängigkeiten wie den HttpClient selbst anlegen bzw. verwalten? Ja und Nein. Ja, es gibt viele Fälle, in denen es völlig legitim ist, die Abhängigkeiten selbst zu verwalten. Eine gute API zeichnet sich dadurch aus, dass Abhängigkeiten nicht ausufern und die Nutzung selbst dann noch angenehm ist. Bei vielen Applikationen oder komplexen Libraries kann dies durchaus der Fall sein. Um eine sehr komplexe low-level API mit vielen Abhängigkeiten vereinfacht dem Nutzer bereitzustellen, sind Facades wunderbar geeignet.

5.4. Dependency Injection Container

Für komplexere Applikationen ist es hingegen nicht nötig alle Abhängigkeiten selbst zu verwalten, denn genau dafür ist ein sogenannter Dependency Injection Container da. Dieser legt nicht nur alle Objekte automatisch an, sondern "injiziert" die Abhängigkeiten auch ganz automatisch, sodass ein manueller "new" Aufruf nicht mehr notwendig ist. Dabei gibt es diverse Arten des Injizierens wie zum Beispiel Constructor-Injection, Method-Injection, oder Property-Injection. So sind auch komplizierte Konstruktionen mit vielen Abhängigkeiten einfach zu verwalten.

Ein Dependency Injection Container (auch DI Container oder IoC Container genannt) bringt Deepkit in @deepkit/injector mit oder bereits fertig integriert über App-Module in dem Deepkit Framework. Der obige Code würde mittels eines Low-Level API aus dem Paket @deepkit/injector wie folgt aussehen.

import { InjectorContext } from '@deepkit/injector';

const injector = InjectorContext.forProviders(
    [UserRepository, HttpClient]
);

const userRepo = injector.get(UserRepository);

const users = await userRepo.getUsers();

Das injector Objekt ist in diesem Fall der Dependency Injection Container. Statt mit "new UserRepository" liefert der Container eine Instanz von UserRepository mittels get(UserRepository) zurück. Um den Container statisch zu initialisieren wird der Funktion InjectorContext.forProviders eine Liste von Providern übergeben (in diesem Fall einfach die Klassen). Da sich bei DI alles um das Bereitstellen von Abhängigkeiten handelt, wird dem Container die Abhängigkeiten bereitgestellt (englisch "provided"), daher der Fachbegriff "Provider". Es gibt diverse Arten von Provider: ClassProvider, ValueProvider, ExistingProvider, FactoryProvider. Alle zusammen erlauben es sehr flexible Architekturen mit einem DI container abzubilden.

Alle Abhängigkeiten zwischen den Providern werden automatisch aufgelöst und sobald ein injector.get() Aufruf stattfindet, werden die Objekte und Abhängigkeiten angelegt, gecacht, und korrekt entweder als Constructor-Argument (Constructor-Injection) übergeben, als Property (Property-Injection) gesetzt, oder einem Methoden-Aufruf (Method-Injection) übergeben.

Um nun den HttpClient mit einem anderen auszutauschen, kann ein anderer Provider (hier der ValueProvider) für HttpClient definiert werden:

const injector = InjectorContext.forProviders([
    UserRepository,
    {provide: HttpClient, useValue: new AnotherHttpClient()},
]);

Sobald nun UserRepository mittels injector.get(UserRepository) angefordert wird, erhält es das AnotherHttpClient Objekt. Alternativ kann hier auch sehr gut ein ClassProvider genutzt werden, sodass alle Abhängigkeiten von AnotherHttpClient ebenfalls vom DI Container verwaltet werden.

const injector = InjectorContext.forProviders([
    UserRepository,
    {provide: HttpClient, useClass: AnotherHttpClient},
]);

Alle Arten von Providern werden in der Sektion Dependency Injection Providers aufgelistet und erklärt.

An dieser Stelle sei zu erwähnen, dass Deepkit’s DI Container nur mit Runtime Typen von Deepkit funktioniert. Das bedeutet, dass jeder Code, der Klassen, Typen, Interfaces, und Funktionen beinhaltet durch den Deepkit Type Compiler kompiliert werden muss, um so die Typeninformationen zur Laufzeit zur Verfügung zu haben. Siehe dazu das Kapitel Runtime Types.

5.5. Dependency Inversion

Das Beispiel von UserRepository unter Inversion of Control zeigt auf, dass UserRepository von einer niedrigeren Ebene, nämlich einer HTTP library, abhängt. Zusätzlich wird eine konkrete Implementierung (Klasse) statt einer Abstraktion (Interface) als Abhängigkeit deklariert. Dies mag auf den ersten Blick den Objekt-Orientierten Paradigmen entsprechen, kann aber insbesondere in komplexen und grossen Architekturen zu Problemen führen.

Eine alternative Variante wäre es, wenn die Abhängigkeit HttpClient in eine Abstraktion (Interface) überführt wird und so kein Code von einer HTTP-Library in UserRepository importiert wird.

interface HttpClientInterface {
   get(path: string): Promise<any>;
}

class UserRepository {
    concstructor(
        private http: HttpClientInterface
    ) {}

    async getUsers(): Promise<Users> {
        return await this.http.get('/users');
    }
}

Dies wird Dependency Inversion Prinzip genannt. UserRepository hat keine Abhängigkeit mehr direkt zu einer HTTP library und basiert stattdessen auf einer Abstraktion (Interface). Es löst damit zwei fundamentale Ziele in diesem Prinzip:

  • High-Level Module sollen nichts aus low-level Modulen importieren.

  • Implementierungen sollen auf Abstraktionen (Interfaces) basieren.

Das Zusammenführen der beiden Implementierungen (UserRepository mit einer HTTP-Library) kann nun über den DI Container geschehen.

import { HttpClient } from 'http-library';
import { UserRepository } from './user-repository';

const injector = InjectorContext.forProviders([
    UserRepository,
    HttpClient,
]);

Da Deepkit’s DI container in der Lage ist, abstrakte Abhängigkeiten (Interfaces) wie hier von HttpClientInterface aufzulösen, erhält UserRepository automatisch die Implementierung von HttpClient, da HttpClient das Interface HttpClientInterface implementiert hat. Dies geschieht entweder, indem HttpClient ganz konkret HttpClientInterface implementiert (class HttpClient implements HttpClientInterface), oder dadurch, dass HttpClient’s API schlicht kompatibel zu HttpClientInterface ist. Sobald HttpClient seine API abändert (zum Beispiel die Methode get entfernt) und so nicht mehr kompatibel zu HttpClientInterface ist, wirft der DI Container einen Fehler ("die Abhängigkeit HttpClientInterface wurde nicht bereitgestellt"). Hier ist der User, der beide Implementierungen zusammenbringen will, in der Pflicht eine Lösung zu finden. Als Beispiel könnte hier dann eine Adapter-Klasse registriert werden, die HttpClientInterface implementiert und die Methoden-Aufrufe korrekt an HttpClient weiterleitet.

Hier sei zu beachten, dass obwohl in Theorie das Dependency Inversion Prinzip seine Vorteile hat, so hat es in der Praxis auch erhebliche Nachteile. So führt es nicht nur zu mehr Code (da mehr Interfaces geschrieben werden müssen), sondern auch zu mehr Komplexität (da jede Implementierung für jede Abhängigkeit nun ein Interface hat). Dieser zu zahlende Preis lohnt sich erst dann, wenn die Applikation eine gewisse Größe erreicht hat und diese Flexibilität auch gebraucht wird. Wie jedes Design-Pattern und Prinzip hat auch dieses seinen Kosten-Nutzung-Faktor, welche vor seiner Anwendung durchdacht sein sollte. Design-Patterns sollen nicht für jeden noch so simplen Code pauschal und blind genutzt werden. Sind jedoch die Voraussetzungen wie zum Beispiel einer komplexen Architektur, grossen Applikationen, oder eines skalierendes Teams gegeben, entfaltet Dependency Inversion und andere Design-Patterns erst seine wahre Stärke.

5.6. Installation

Da Dependency Injection in Deepkit auf den Runtime Types basiert, ist es notwendig @deepkit/type bereits korrekt installiert zu haben. Siehe dazu Runtime Type Installation.

Falls dies erfolgreich geschehen ist, kann @deepkit/injector selbst installiert werden oder das Deepkit Framework welches die Library bereits unter der Haube benutzt.

npm install @deepkit/injector

Sobald die Library installiert ist, kann die API davon direkt benutzt werden.

5.7. Benutzung

Um Dependency Injection nun zu benutzen, gibt es drei Möglichkeiten.

  • Injector API (Low Level)

  • Module API

  • App API (Deepkit Framework)

Wenn @deepkit/injector ohne das Deepkit Framework benutzt werden soll, empfehlen sich die ersten zwei Varianten.

5.7.1. Injector API

Die Injector API wurde bereits in der Einführung zu Dependency Injection kennengelernt. Es zeichnet sich durch eine sehr einfache Benutzung mittels einer einzigen Klasse InjectorContext aus, die einen einzigen DI Container anlegt und ist besonders geeignet für einfachere Anwendungen ohne Module.

import { InjectorContext } from '@deepkit/injector';

const injector = InjectorContext.forProviders([
    UserRepository,
    HttpClient,
]);

const repository = injector.get(UserRepository);

Das injector Objekt ist in diesem Fall der Dependency Injection Container. Die Funktion InjectorContext.forProviders nimmt dabei ein Array von Providern entgegen. Siehe die Sektion Dependency Injection Providers, um zu erfahren, welche Werte übergeben werden können.

5.7.2. Module API

Eine etwas komplexere API ist die InjectorModule Klasse, welche es erlaubt, die Provider in unterschiedlichen Modulen auszulagern, um so mehrere encapsulated DI Container per Module zu erstellen. Auch erlaubt dies das Verwenden von Konfiguration-Klassen per Module, welche es vereinfacht, Konfigurationswerte automatisch validiert den Providern bereitzustellen. Module können sich untereinander importieren, Provider exportieren, um so eine Hierarchie und schön separierte Architektur aufzubauen.

Diese API sollte genutzt werden, wenn die Applikation komplexer ist und nicht das Deepkit Framework genutzt wird.

import { InjectorModule, InjectorContext } from '@deepkit/injector';

const lowLevelModule = new InjectorModule([HttpClient])
     .addExport(HttpClient);

const rootModule = new InjectorModule([UserRepository])
     .addImport(lowLevelModule);

const injector = new InjectorContext(rootModule);

Das injector Objekt ist in diesem Fall der Dependency Injection Container. Es können Provider in unterschiedliche Module aufgesplittet werden und dann mittels Module-Imports diese in unterschiedlichen Stellen wieder importiert werden. So entsteht eine natürliche Hierarchie, die die Hierarchie der Anwendung bzw. Architektur abbildet. Dem InjectorContext sollte dabei immer das oberste Modul in der Hierarchie gegeben werden, auch Root-Module oder App-Module genannt. Der InjectorContext hat hierbei dann nur einen vermittelnden Auftrag: Aufrufe auf injector.get() werden schlicht an das Root-Modul weitergeleitet. Es können jedoch auch Provider aus nicht-root Modulen erhalten werden, in dem man das Modul als zweites Argument übergibt.

const repository = injector.get(UserRepository);

const httpClient = injector.get(HttpClient, lowLevelModule);

All nicht-root Module sind per default verschlossen ("encapsulated"), sodass alle Provider in diesem Modul nur ihm selbst zur Verfügung stehen. Soll ein Provider auch anderen Modulen zur Verfügung stehen, muss dieser Provider exportiert werden. Durch das Exportieren wandert der Provider in das Eltern-Modul der Hierarchie und kann so genutzt werden.

Um alle Provider per default auf die oberste Ebene, dem Root-Module, zu exportieren, kann die Option forRoot genutzt werden. Dadurch können alle Provider von allen anderen Modulen genutzt werden.

const lowLevelModule = new InjectorModule([HttpClient])
     .forRoot(); //export all Providers to the root

5.7.3. App API

Sobald das Deepkit Framework benutzt wird, werden Module mit der @deepkit/app API definiert. Diese basiert auf der Module API, sodass die Fähigkeiten von dort ebenfalls verfügbar sind. Zusätzlich ist es möglich mit mächtigen Hooks zu arbeiten sowie Konfiguration-Loader zu definieren, um so noch dynamischere Architekturen abzubilden.

In Framework Modules ist näheres hierzu beschrieben.

5.8. Providers

In dem Dependency Injection Container gibt es mehrere Möglichkeiten Abhängigkeiten bereitzustellen. Die einfachste Variante ist dabei einfach die Angabe einer Klasse. Dies ist auch als short ClassProvider bekannt.

InjectorContext.forProviders([
    UserRepository
]);

Dies stellt einen speziellen Provider dar, da lediglich die Klasse spezifiziert wird. Alle anderen Provider müssen als Object-Literal angegeben werden.

Standardmäßig sind alle Provider als Singleton markiert, sodass zu jedem Zeitpunkt nur eine Instanz existiert. Um bei jedem Bereitstellen eine neue Instanz anzulegen, kann die Option transient genutzt werden. Dies führt dazu, dass Klassen jedes Mal neu erstellt werden oder Factories jedes Mal neu ausgeführt werden.

InjectorContext.forProviders([
    {provide: UserRepository, transient: true}
]);

5.8.1. ClassProvider

Neben dem short ClassProvider gibt es auch den regulären ClassProvider, welches ein Object-Literal statt einer Klasse ist.

InjectorContext.forProviders([
    {provide: UserRepository, useClass: UserRepository}
]);

Dies ist Äquivalent zu diesen beiden:

InjectorContext.forProviders([
    {provide: UserRepository}
]);

InjectorContext.forProviders([
    UserRepository
]);

Es kann genutzt werden, um einen Provider mit einer anderen Klasse auszutauschen.

InjectorContext.forProviders([
    {provide: UserRepository, useClass: OtherUserRepository}
]);

In diesem Beispiel wird die Klasse OtherUserRepository nun ebenfalls in dem DI Container verwaltet und all seine Abhängigkeiten automatisch aufgelöst.

5.8.2. ValueProvider

Statische Werte können mit diesem Provider bereitgestellt werden.

InjectorContext.forProviders([
    {provide: OtherUserRepository, useValue: new OtherUserRepository()},
]);

Da nicht nur Klassen-Instanzen als Abhängigkeiten bereitgestellt werden können, kann als useValue ein beliebiger Wert angegeben werden. Als Provider-Token könnte auch ein Symbol oder ein Primitive (string, number, boolean) genutzt werden.

InjectorContext.forProviders([
    {provide: 'domain', useValue: 'localhost'},
]);

Primitive Provider-Tokens müssen mit dem Inject-Typen als Abhängigkeit deklariert werden.

import { Inject } from '@deepkit/injector';

class EmailService {
    constructor(public domain: Inject<string, 'domain'>) {}
}

Mit der Kombination aus einem Inject-Alias und primitive Provider-Tokens können auch Abhängigkeiten aus Paketen bereitgestellt, die keine Runtime-Typeninformationen beinhalten.

import { Inject } from '@deepkit/injector';
import { Stripe } from 'stripe';

export type StripeService = Inject<Stripe, '_stripe'>;

InjectorContext.forProviders([
    {provide: '_stripe', useValue: new Stripe},
]);

Und dann auf der Nutzerseite wie folgt deklariert werden:

class PaymentService {
    constructor(public stripe: StripeService) {}
}

5.8.3. ExistingProvider

Es kann eine Weiterleitung zu einem bereits definierten Provider definiert werden.

InjectorContext.forProviders([
    {provide: OtherUserRepository, useValue: new OtherUserRepository()},
    {provide: UserRepository, useExisting: OtherUserRepository}
]);

5.8.4. FactoryProvider

Es kann eine Funktion genutzt werden, um einen Wert für den Provider bereitzustellen. Diese Funktion kann auch Parameter beinhalten, die wiederum von dem DI Container bereitgestellt werden. So sind andere Abhängigkeiten oder Konfiguration-Optionen zugreifbar.

InjectorContext.forProviders([
    {provide: OtherUserRepository, useFactory: () => {
        return new OtherUserRepository()
    }},
]);

InjectorContext.forProviders([
    {
        provide: OtherUserRepository,
        useFactory: (domain: RootConfiguration['domain']) => {
            return new OtherUserRepository(domain);
        }
    },
]);

InjectorContext.forProviders([
    Database,
    {
        provide: OtherUserRepository,
        useFactory: (database: Database) => {
            return new OtherUserRepository(database);
        }
    },
]);

5.8.5. InterfaceProvider

Neben Klassen und Primitives können auch Abstraktionen (Interfaces) bereitgestellt werden. Dies geschieht über die Funktion provide und ist dann besonders sinnvoll, wenn der zu bereitstellende Wert keine Typeninformationen beinhaltet.

import { provide } from '@deepkit/injector';

interface Connection {
    write(data: Uint16Array): void;
}

class Server {
   constructor (public connection: Connection) {}
}

class MyConnection {
    write(data: Uint16Array): void {}
}

InjectorContext.forProviders([
    Server,
    provide<Connection>(MyConnection)
]);

5.8.6. Asynchronous Providers

Asynchroner Provider sind aufgrund des Designs nicht möglich, da eine asynchroner Dependency Injection Container bedeuten würde, dass das Anfordern von Providern ebenfalls asynchron wäre und damit die gesamte Anwendung auf höchster Ebene bereits zur asynchronität gezwungen ist.

Um etwas asynchron zu initialisieren, sollte dieses Initialisieren in den Application Server Bootstrap verlagert werden, da dort die Events asynchron sein können. Alternativ kann eine Initialisierung manuell angestossen werden.

TODO: Explain it better, maybe example

Wenn mehrere Provider das Interface Connection implementiert haben, wird der letzte Provider genutzt.

Als Argument für provide() sind alle anderen Provider möglich.

const myConnection = {write: (data: any) => undefined};

InjectorContext.forProviders([
    provide<Connection>({useValue: myConnection})
]);

InjectorContext.forProviders([
    provide<Connection>({useFactory: () => myConnection})
]);

5.9. Constructor/Property Injection

In den meisten Fällen wird Constructor-Injection verwendet. Alle Abhängigkeiten werden dabei als Constructor-Argumente angegeben und werden vom DI Container automatisch injiziert.

class MyService {
    constructor(protected database: Database) {
    }
}

Optionale Abhängigkeiten sollten als solche gekennzeichnet werden, da sonst ein Fehler ausgelöst werden könnte, wenn kein Provider gefunden werden kann.

class MyService {
    constructor(protected database?: Database) {
    }
}

Eine Alternative zur Constructor-Injection ist die Property-Injection. Diese wird in der Regel verwendet, wenn die Abhängigkeit optional oder der Constructor sonst zu voll ist. Die Properties werden automatisch zugewiesen, sobald die Instanz erstellt ist (und damit der Constructor ausgeführt wurde).

import { Inject } from '@deepkit/injector';

class MyService {
    //required
    protected database!: Inject<Database>;

    //or optional
    protected database?: Inject<Database>;
}

5.10. Konfiguration

Der Dependency Injection Container erlaubt auch das Injizieren von Konfigurationsoptionen. Diese Configuration-Injection kann via Constructor-Injection oder Property-Injection empfangen werden.

Die Module API unterstützt dabei das Definieren einer Konfiguration-Definition, welche eine reguläre Klasse ist. Durch das Bereitstellen solch einer Klasse mit Properties agiert jedes Property als Konfiguration-Option. Durch die Art und Weise wie in TypeScript Klassen definiert werden können, erlaubt dies das Definieren eines Types und Default-Values pro Property.

class RootConfiguration {
    domain: string = 'localhost';
    debug: boolean = false;
}

const rootModule = new InjectorModule([UserRepository])
     .setConfigDefinition(RootConfiguration)
     .addImport(lowLevelModule);

Die Konfigurationsoptionen domain und debug können nun ganz bequem typen-sicher in Providern genutzt werden.

class UserRepository {
    constructor(private debug: RootConfiguration['debug']) {}

    getUsers() {
        if (this.debug) console.debug('fetching users ...');
    }
}

Die Werte der Optionen selbst können über configure() gesetzt werden.

	rootModule.configure({debug: true});

Optionen, die keinen Default-Value haben, aber trotzdem notwendig sind, können mit einem ! versehen werden. Dies zwingt den User des Modules dazu, den Wert bereitzustellen, da ansonsten es zu einem Fehler kommt.

class RootConfiguration {
    domain!: string;
}

5.10.1. Validation

Auch können alle Serialization und Validation Typen aus den vorherigen Kapiteln Validation und Serialization genutzt werden, um so sehr detailliert festzulegen, welchen Typ und inhaltliche Einschränkungen eine Option haben muss.

class RootConfiguration {
    domain!: string & MinLength<4>;
}

5.10.2. Injection

Konfigurationsoptionen können wie bereits gezeigt wie andere Abhängigkeiten sicher und einfach durch den DI Container injiziert werden. Als einfachste Methode ist das Referenzieren einer einzigen Option mittels dem Index-Access Operators:

class WebsiteController {
    constructor(private debug: RootConfiguration['debug']) {}

    home() {
        if (this.debug) console.debug('visit home page');
    }
}

Es können Konfigurationsoptionen nicht nur einzeln, sondern auch als Gruppe referenziert werden. Hierzu wird der TypeScript Utility-Typ Partial genutzt:

class WebsiteController {
    constructor(private options: Partial<RootConfiguration, 'debug' | 'domain'>) {}

    home() {
        if (this.options.debug) console.debug('visit home page');
    }
}

Um alle Konfigurationsoptionen zu erhalten, kann auch die Konfigurationsklasse direkt referenziert werden:

class WebsiteController {
    constructor(private options: RootConfiguration) {}

    home() {
        if (this.options.debug) console.debug('visit home page');
    }
}

Es wird jedoch empfohlen nur die Konfigurationsoptionen zu referenzieren, die auch wirklich genutzt werden. Das vereinfacht nicht nur Unit-Tests, sondern lässt auch einfacher einsehen, was nun konkret von dem Code gebraucht wird.

5.11. Scopes

Per Default sind alle Provider des DI Containers ein Singleton und werden dadurch nur einmal instantiiert. Das bedeutet in dem Beispiel von UserRepository gibt es immer nur eine Instanz von UserRepository während der gesamten Laufzeit. Zu keinem Zeitpunkt wird eine zweite Instanz erzeugt, außer der User macht dies manuell mit dem "new" Keyword.

Nun gibt es jedoch diverse Anwendungsfälle, in denen ein Provider nur für eine kurze Zeit instantiiert werden soll oder nur während eines bestimmten Ereignisses. Solch ein Ereignis könnte zum Beispiel ein HTTP-Request oder ein RPC-Call sein. Dies würde dann bedeuten, dass pro Ereignis jedes Mal eine neue Instanz erstellt wird und nachdem diese Instanz nicht mehr benutzt wird diese automatisch entfernt wird (durch den Garbage-Collector).

Ein HTTP-Request ist ein klassisches Beispiel für einen Scope. So können zum Beispiel Provider wie eine Session, ein User-Objekt, oder andere Request-bezogenen Provider auf diesen Scope registriert werden. Um einen Scope zu erstellen, wird lediglich ein beliebiger Scopename gewählt und dann bei den Providern angegeben.

import { InjectorContext } from '@deepkit/injector';

class UserSession {}

const injector = InjectorContext.forProviders([
    {provide: UserSession, scope: 'http'}
]);

Sobald ein Scope angegeben ist, ist dieser Provider nicht mehr direkt über den DI Container zu erhalten, sodass folgender Aufruf fehlschlägt:

const session = injector.get(UserSession); //throws

Stattdessen muss ein scoped DI Container erstellt werden. Dies würde jedes Mal geschehen sobald ein HTTP-Request reinkommt:

const httpScope = injector.createChildScope('http');

Auf diesen scoped DI Container können nun auch Provider angefordert werden, die in diesem Scope auch registriert sind, sowie alle Provider die keinen Scope definiert haben.

const session = httpScope.get(UserSession); //works

Da alle Provider per default Singleton sind, wird auch hier jeder Aufruf zu get(UserSession) immer dieselbe Instanz pro scoped Container zurückgeben. Erstellt man mehrere scoped Container werden auch mehrere UserSession angelegt.

Scoped DI Container haben die Fähigkeit, Werte dynamisch von außen zu setzen. So ist es zum Beispiel bei einem HTTP-Scope einfach möglich, die Objekte HttpRequest und HttpResponse zu setzen.

const injector = InjectorContext.forProviders([
    {provide: HttpResponse, scope: 'http'},
    {provide: HttpRequest, scope: 'http'},
]);

httpServer.on('request', (req, res) => {
    const httpScope = injector.createChildScope('http');
    httpScope.set(HttpRequest, req);
    httpScope.set(HttpResponse, res);
});

Applikationen, die mit dem Deepkit Framework arbeiten, haben per default einen http, einen rpc, und einen cli Scope. Siehe dazu jeweils das Kapitel CLI, HTTP, oder RPC.

5.12. Setup Calls

Setup-Calls erlauben es das Ergebnis eines Providers zu manipulieren. Das ist nützlich um zum Beispiel eine weitere Dependency Injection Variante, das Method-Injection, zu nutzen.

Setup-Calls sind nur mit der Modul-API beziehungsweise der App-API nutzbar und werden über dem Modul registriert.

class UserRepository  {
    private db?: Database;
    setDatabase(db: Database) {
       this.db = db;
    }
}

const rootModule = new InjectorModule([UserRepository])
     .addImport(lowLevelModule);

rootModule.setupProvider(UserRepository).setDatabase(db);

Die Methode setupProvider gibt dabei ein Proxy-Objekt von UserRepository zurück, auf welchem seine Methoden aufgerufen werden können. Zu beachten ist, dass diese Methoden-Aufrufen lediglich in eine Warteschlange platziert werden und zu diesem Zeitpunkt nicht ausgeführt werden. Entsprechend gibt es auch kein Return-Value zurück.

Neben Methoden-Aufrufen können auch Properties gesetzt werden.

class UserRepository  {
    db?: Database;
}

const rootModule = new InjectorModule([UserRepository])
     .addImport(lowLevelModule);

rootModule.setupProvider(UserRepository).db = db;

Auch diese Zuweisung wird lediglich in einer Warteschlange platziert.

Die Aufrufe beziehungsweise die Zuweisungen in der Warteschlange werden dann auf das eigentliche Resultat des Providers ausgeführt, sobald dieser erstellt wird. Das heisst bei einem ClassProvider werden diese auf die Klassen-Instanz angewendet, sobald die Instanz erstellt wird, bei einem FactoryProvider auf das Resultat der Factory, und bei einem ValueProvider auf den Provider.

Um nicht nur statische Werte, sondern auch andere Provider zu referenzieren kann die Funktion injectorReference verwendet werden. Diese gibt eine Referenz zu einem Provider zurück, welcher beim Ausführen der Setup-Calls ebenfalls vom DI Container angefordert wird.

class Database {}

class UserRepository  {
    db?: Database;
}

const rootModule = new InjectorModule([UserRepository, Database])
rootModule.setupProvider(UserRepository).db = injectorReference(Database);

Abstractions/Interfaces

Es können auch Setup-Calls einem Interface zugewiesen werden.

rootModule.setupProvider<DatabaseInterface>().logging = logger;

6. Event System

Ein Event-System ermöglicht es Anwendungskomponenten im selben Prozess miteinander zu kommunizieren, indem sie Ereignisse versenden und auf sie hören. Es hilft bei der Modularisierung des Codes, indem Nachrichten zwischen Funktionen gesendet werden, die nicht direkt voneinander wissen.

Die Anwendung oder Library eröffnet dabei die Möglichkeit an einem bestimmten Zeitpunkt der Ausführung zusätzliche Funktionen auszuführen. Diese zusätzlichen Funktionen registrieren sich dabei selbst als sogenannte Event-Listener.

Ein Event kann dabei vielfältig sein:

  • Die Anwendung fährt hoch oder runter.

  • Ein neuer User wurde erstellt oder gelöscht.

  • Eine Error wurde geworfen.

  • Ein neuer HTTP-Request ist hereingekommen.

Deepkit Framework und seine Libraries bieten bereits diverse Events an, auf die der Benutzer hören und reagieren kann. Es können jedoch auch beliebig viele eigene Events angelegt werden, um so die Anwendung modular erweiterbar zu machen.

Nachfolgend ein Beispiel der Low-Level API von @deepkit/event. Wenn Deepkit Framework genutzt wird, geschieht die Registrierung von Event-Listener nicht über EventDispatcher direkt sondern über Module.

import { EventDispatcher, EventToken } from '@deepkit/event';

const dispatcher = new EventDispatcher();
const MyEvent = new EventToken('my-event');

dispatcher.listen(MyEvent, (event) => {
    console.log('MyEvent triggered!');
});
dispatcher.dispatch(MyEvent);

6.1. Installation

Da das Event-System von Deepkit basiert auf den Runtime Types basiert, ist es notwendig @deepkit/type bereits korrekt installiert zu haben. Siehe dazu Runtime Type Installation.

Falls dies erfolgreich geschehen ist, kann @deepkit/event installiert werden oder das Deepkit Framework welches die Library bereits unter der Haube benutzt.

npm install @deepkit/event

Zu beachten ist, dass @deepkit/event für die Controller API auf TypeScript-Decorators basiert und dieses Feature entsprechend mit experimentalDecorators aktiviert werden muss, sobald die Controller API verwendet wird.

Datei: tsconfig.json

{
  "compilerOptions": {
    "module": "CommonJS",
    "target": "es6",
    "moduleResolution": "node",
    "experimentalDecorators": true
  },
  "reflection": true
}

Sobald die Library installiert ist, kann die API davon direkt benutzt werden.

6.2. Event Token

Im Mittelpunkt vom Event-System stehen die Event-Tokens. Sie sind Objekte, die die eindeutige Event-ID und den Event-Typen definieren. Über ein Event-Token kann ein Event ausgelöst und auf ein Event gehört werden. Dabei ist konzeptionell derjenige, der den Event eines Event-Tokens auslöst, auch der Besitzer dieses Event-Tokens. Das Event-Token entscheidet entsprechend darüber, welche Daten an dem Event verfügbar sind und ob asynchrone Event-Listener erlaubt werden.

const MyEvent = new EventToken('my-event');

TODO asynchrone

6.3. Event Types

TODO

6.4. Propagation

TODO. event.stop()

6.5. Dependency Injection

TODO

7. CLI

Command-line Interface (CLI) Programme sind Programme, die über das Terminal in Form von Text-Eingabe und Text-Ausgabe interagieren. Der Vorteil in dieser Variante mit der Anwendung zu interagieren, ist, dass lediglich ein Terminal entweder lokal oder über eine SSH-Verbindung bestehen muss.

Eine CLI-Anwendung in Deepkit hat den vollen Zugriff auf den DI-Container und kann so auf alle Provider und Konfigurationsoptionen zugreifen.

Die Argumente und Optionen der CLI-Anwendung werden über Methoden-Parameter via TypeScript Typen gesteuert und werden automatisch serialisiert und validiert.

CLI ist einer von drei Einstiegspunkten zu einer Deepkit Framework Anwendung. Im Deepkit Framework wird die Anwendung immer über ein CLI-Program gestartet, das selbst vom User in TypeScript geschrieben ist. Es gibt daher keine Deepkit spezifisches globales CLI tool, um eine Deepkit Anwendung zu starten. Auf diese Weise starten Sie den HTTP/RPC-Server, führen Migrationen aus oder führen eigene Befehle aus. Das alles geschieht über denselben Einstiegspunkt, dieselbe Datei. Sobald das Deepkit Framework durch den Import von FrameworkModule aus @deepkit/framework benutzt wird, erhält die Anwendung zusätzliche Commands für den Application Server, Migrations, und mehr.

Das CLI-Framework erlaubt es auf einfache Art eigene Commands zu registrieren und basiert dabei auf einfachen Klassen. Tatsächlich basiert es auf @deepkit/app, einem kleinen Paket, das nur für diesen Zweck gedacht ist und auch eigenständig ohne das Deepkit Framework verwendet werden kann. In diesem Paket finden sich Decorators, die benötigt werden, um die CLI-Controller-Klasse zu dekorieren.

Controller werden vom Dependency Injection Container verwaltet beziehungsweise instanziiert und können daher andere Provider verwenden. Siehe das Kapitel Dependency Injection für weitere Details.

7.1. Installation

Da CLI-Programme in Deepkit auf den Runtime Types basieren, ist es notwendig @deepkit/type bereits korrekt installiert zu haben. Siehe dazu Runtime Type Installation.

Falls dies erfolgreich geschehen ist, kann @deepkit/app installiert werden oder das Deepkit Framework welches die Library bereits unter der Haube benutzt.

npm install @deepkit/app

Zu beachten ist, dass @deepkit/app auf TypeScript-Decorators basiert und dieses Feature entsprechend mit experimentalDecorators aktiviert werden muss.

Datei: tsconfig.json

{
  "compilerOptions": {
    "module": "CommonJS",
    "target": "es6",
    "moduleResolution": "node",
    "experimentalDecorators": true
  },
  "reflection": true
}

Sobald die Library installiert ist, kann die API davon direkt benutzt werden.

7.2. Benutzung

Um einen Befehl für Ihre Anwendung zu erstellen, müssen Sie einen CLI-Controller erstellen. Dabei handelt es sich um eine einfache Klasse, die eine Methode exeecute hat und mit Informationen über den Befehl ausgestattet ist.

Datei: app.ts

#!/usr/bin/env ts-node-script
import { App, cli } from '@deepkit/app';

@cli.controller('test', {
    description: 'My first command'
})
class TestCommand {
    async execute() {
        console.log('Hello World')
    }
}

new App({
    controllers: [TestCommand]
}).run();

In dem Decorator @cli.controller wird als erstes Argument der eindeutige Name der CLI-Anwendung definiert. Weitere Optionen wie eine Beschreibung können im Objekt an der zweiten Stelle optional hinzufügt werden.

Dieser Code ist bereits eine komplette CLI-Anwendung und kann so gestartet werden:

$ ts-node ./app.ts
VERSION
  Node

USAGE
  $ ts-node app.ts [COMMAND]

COMMANDS
  test

Zu sehen ist, dass ein "test" Command verfügbar ist. Um dieses auszuführen, muss der Name als Argument übergeben werden:

$ ts-node ./app.ts test
Hello World

Es ist auch möglich, die Datei mittels chmod +x app.ts ausführbar zu machen, sodass der Command ./app.ts bereits ausreicht, um es zu starten. Zu beachten ist, dass dann ein sogenannter Shebang notwendig ist. Shebang bezeichnet die Zeichenkombination #! am Anfang eines Skriptprogramms. In dem Beispiel oben ist dies bereits vorhanden: #!/usr/bin/env ts-node-script und nutzt den Skript-Modus von ts-node.

$ ./app.ts test
Hello World

Auf diese Weise können beliebig viele Commands erstellt und registriert werden. Der in @cli.controller angegeben eindeutige Name sollte gut gewählt werden und erlaubt das Gruppieren von Commands mit dem : Zeichen (z.B. user:create, user:remove, etc).

7.3. Argumente

Um Argumente hinzuzufügen, werden neue Parameter auf die Methode execute hinzugefügt und mit dem Decorator @arg dekoriert.

import { cli, arg } from '@deepkit/app';

@cli.controller('test')
class TestCommand {
    async execute(
        @arg name: string
    ) {
        console.log('Hello', name);
    }
}

Wenn Sie diesen Befehl jetzt ausführen, ohne einen Namen anzugeben, wird ein Fehler ausgegeben:

$ ./app.ts test
RequiredArgsError: Missing 1 required arg:
name

Durch die Verwendung von --help erhalten Sie weitere Informationen über die erforderlichen Argumente:

$ ./app.ts test --help
USAGE
  $ ts-node-script app.ts test NAME

Sobald der Name als Argument übergeben wird, wird die Methode execute in TestCommand ausgeführt und der Name korrekt übergeben.

$ ./app.ts test "beautiful world"
Hello beautiful world

7.4. Flags

Flags sind eine weitere Möglichkeit, Ihrem Befehl Werte zu übergeben. Meist sind diese Optional, doch müssen es nicht sein. Parameter, die mit @flag name dekoriert sind, können via --name value oder --name=value übergeben werden.

import { flag } from '@deepkit/app';

class TestCommand {
    async execute(
        @flag id: number
    ) {
        console.log('id', id);
    }
}
$ ./app.ts test --help
USAGE
  $ ts-node app.ts test

OPTIONS
  --id=id  (required)

In der Hilfe-Ansicht ist in den "OPTIONS" nun zu sehen, dass ein --id Flag notwendig ist. Gibt man dieses Korrekt an, erhält der Command diesen Wert.

$ ./app.ts test --id 23
id 23

$ ./app.ts test --id=23
id 23

7.4.1. Boolean Flags

Flags haben den Vorteil, dass sie auch als wertlosen Flag verwendet werden können, um so zum Beispiel ein bestimmtes Verhalten zu aktivieren. Sobald ein Parameter als optionaler Boolean markiert ist, wird dieses Verhalten aktiviert.

import { flag } from '@deepkit/app';

class TestCommand {
    async execute(
        @flag remove: boolean = false
    ) {
        console.log('delete?', remove);
    }
}
$ ./app.ts test
delete? false

$ ./app.ts test --remove
delete? true

7.4.2. Multiple Flags

Um mehrere Werte demselben Flag zu übergeben, kann ein Flag als Array markiert werden.

import { flag } from '@deepkit/app';

class TestCommand {
    async execute(
        @flag id: number[] = []
    ) {
        console.log('ids', id);
    }
}
$ ./app.ts test
ids: []

$ ./app.ts test --id 12
ids: [12]

$ ./app.ts test --id 12 --id 23
ids: [12, 23]

7.4.3. Single Character Flags

Um einem Flag zu erlauben, auch als ein einzelner Charakter übergeben zu werden, kann @flag.char('x') genutzt werden.

import { flag } from '@deepkit/app';

class TestCommand {
    async execute(
        @flag.char('o') output: string
    ) {
        console.log('output: ', output);
    }
}
$ ./app.ts test --help
USAGE
  $ ts-node app.ts test

OPTIONS
  -o, --output=output  (required)


$ ./app.ts test --output test.txt
output: test.txt

$ ./app.ts test -o test.txt
output: test.txt

7.5. Optional / Default

Die Signatur der Methode execute definiert, welche Argument oder Flags optional sind. Ist der Parameter als Optional markiert, so muss er nicht angegeben werden.

class TestCommand {
    async execute(
        @arg name?: string
    ) {
        console.log('Hello', name || 'nobody');
    }
}
$ ./app.ts test
Hello nobody

Dasselbe für Parameter mit einem Default-Wert:

class TestCommand {
    async execute(
        @arg name: string = 'body'
    ) {
        console.log('Hello', name);
    }
}
$ ./app.ts test
Hello nobody

Dies gilt auch für Flags in derselben Art und Weise.

7.6. Serialization / Validation

Alle Argumente und Flags werden automatisch basierend auf dessen Typen deserialisiert, validiert und können mit zusätzlichen Einschränkungen versehen werden.

So sind Argument, die als Number definiert sind, in dem Controller auch garantiert immer eine echte Nummer, obwohl das Command-Line Interface auf Text und somit Strings basiert. Die Umwandlung passiert dabei automatisch mit dem Feature Weiche Typenkonvertierung.

class TestCommand {
    async execute(
        @arg id: number
    ) {
        console.log('id', id, typeof id);
    }
}
$ ./app.ts test 123
id 123 number

Zusätzliche Einschränkungen können mit den Typen-Decorators aus @deepkit/type definiert werden.

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

class TestCommand {
    async execute(
        @arg id: number & Positive
    ) {
        console.log('id', id, typeof id);
    }
}

Der Typ Postive bei id gibt an, dass nur positive Nummern gewollt sind. Übergibt der User nun eine negative Zahl, so wird der Code in execute gar nicht erst ausgeführt und es wird eine Fehlermeldung präsentiert.

$ ./app.ts test -123
Validation error in id: Number needs to be positive [positive]

Bei einer positiven Nummer funktioniert dies dann wieder wie zuvor. Durch diese zusätzliche sehr einfach zu bewerkstelligende Validierung, wird der Command deutlich robuster gegen Falscheingaben geschützt. Sieh dazu das Kapitel Validation für mehr Informationen.

7.7. Description

Um einen Flag oder Argument zu beschreiben, kann @flag.description beziehungsweise @arg.description genutzt werden.

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

class TestCommand {
    async execute(
        @arg.description('The users identifier') id: number & Positive,
        @flag.description('Delete the user?') remove: boolean = false,
    ) {
        console.log('id', id, typeof id);
    }
}

In der Hilfe-Ansicht erscheint diese Beschreibung hinter dem Flag beziehungsweise Argument:

$ ./app.ts test --help
USAGE
  $ ts-node app.ts test ID

ARGUMENTS
  ID  The users identifier

OPTIONS
  --remove  Delete the user?

7.8. Exit code

Der Exit-Code ist standardmäßig 0, was bedeutet, dass der Befehl erfolgreich ausgeführt wurde. Um den Exit-Code zu ändern, sollten in der exucute-Methode eine Zahl ungleich 0 zurückgeben werden.

@cli.controller('test')
export class TestCommand {
    async execute() {
        console.error('Error :(');
        return 12;
    }
}
$ ./app.ts
Error :(
$ echo $?
12

7.9. Dependency Injection

Die Klasse des Commands wird vom DI Container verwaltet, sodass Abhängigkeiten definiert werden können, die über den DI Container aufgelöst werden.

#!/usr/bin/env ts-node-script
import { App, cli } from '@deepkit/app';
import { Logger, ConsoleTransport } from '@deepkit/logger';

@cli.controller('test', {
    description: 'My super first command'
})
class TestCommand {
    constructor(protected logger: Logger) {
    }

    async execute() {
        this.logger.log('Hello World!');
    }
}

new App({
    providers: [{provide: Logger, useValue: new Logger([new ConsoleTransport]}],
    controllers: [TestCommand]
}).run();

8. HTTP

HTTP-Abfragen zu bearbeiten ist mitunter die bekannteste Aufgabe für einen Server. Er wandelt dabei einen Input (HTTP-Request) in einen Output (HTTP-Response) um und führt dabei eine bestimmte Aufgabe aus. Ein Client kann dabei über einem HTTP-Request auf vielfältige Art und Weisen Daten an den Server senden, die korrekt ausgelesen und behandelt werden müssen. So sind neben dem HTTP-Body auch HTTP-Query oder HTTP-Header Werte möglich. Wie Daten konkret verarbeitet werden, hängt vom Server ab. Er ist es, der definiert, wo und wie die Werte vom Client zu senden sind.

Hierbei ist oberste Priorität nicht nur das korrekt auszuführen, was der User erwartet, sondern jeglichen Input aus dem HTTP-Request korrekt umzuwandeln (deserialisieren) und zu validieren.

Die Pipeline, in der ein HTTP-Request auf dem Server durchläuft, kann vielfältig und komplex sein. Viele einfache HTTP-Libraries übergeben für eine bestimmte Route lediglich den HTTP-Request und die HTTP-Response, und erwarten vom Entwickler, den HTTP-Response direkt zu bearbeiten. Eine Middleware-API erlaubt dabei die Pipeline beliebig zu erweitern.

Express Beispiel

const http = express();
http.get('/user/:id', (request, response) => {
    response.send({id: request.params.id, username: 'Peter' );
});

Dies ist für simple Anwendungsfälle sehr gut zugeschnitten, wird aber schnell unübersichtlich, wenn die Anwendung wächst, da alle Ein- und Ausgaben manuell serialisiert beziehungsweise deserialisiert und validiert werden müssen. Auch muss überlegt werden wie Objekte und Services wie zum Beispiel eine Datenbank Abstraktion aus der Anwendung selbst erhaltenen werden können. Es zwingt den Developer eine Architektur selbst obendrauf zu setzen, die diese zwingenden Funktionalitäten abbildet.

Deepkit’s HTTP Library nutzt die Stärke von TypeScript und Dependency Injection. Serialisierung/Deserialisierung und Validierung von jeglichen Werten passieren automatisch anhand der definierten Typen. Auch erlaubt es das Definieren von Routen entweder über eine funktionale API wie in dem Beispiel oben oder über Controller-Klassen, um die unterschiedlichen Bedürfnisse einer Architektur abzudecken.

Es kann dabei entweder mit einem bereits vorhandenen HTTP-Server wie Node’s http Modul oder mit dem Deepkit Framework genutzt werden. Beide API-Varianten haben Zugriff auf den Dependency Injection Container und können so bequem Objekte wie eine Datenbank-Abstraktion und Konfigurationen aus der Anwendung beziehen.

Deepkit Beispiel

import { Positive } from '@deepkit/type';
import { http } from '@deepkit/http';

//Functional API
router.get('/user/:id', (id: number & Positive, database: Database) => {
    //id is guaranteed to be a number and positive.
    //database is injected by the DI Container.
    return database.query(User).filter({id}).findOne();
});

//Controller API
class UserController {
    constructor(private database: Database) {}

    @http.GET('/user/:id')
    user(id: number & Positive) {
        return this.database.query(User).filter({id}).findOne();
    }
}

8.1. Installation

Da CLI-Programme in Deepkit auf den Runtime Types basieren, ist es notwendig @deepkit/type bereits korrekt installiert zu haben. Siehe dazu Runtime Type Installation.

Falls dies erfolgreich geschehen ist, kann @deepkit/app installiert werden oder das Deepkit Framework welches die Library bereits unter der Haube benutzt.

npm install @deepkit/http

Zu beachten ist, dass @deepkit/http für die Controller API auf TypeScript-Decorators basiert und dieses Feature entsprechend mit experimentalDecorators aktiviert werden muss, sobald die Controller API verwendet wird.

Datei: tsconfig.json

{
  "compilerOptions": {
    "module": "CommonJS",
    "target": "es6",
    "moduleResolution": "node",
    "experimentalDecorators": true
  },
  "reflection": true
}

Sobald die Library installiert ist, kann die API davon direkt benutzt werden.

8.2. Funktionale API

Die funktionale API basiert auf Funktionen und können über die Router Registry, der über den DI Container der App bezogen werden kann, registriert werden.

import { App } from '@deepkit/app';
import { FrameworkModule } from '@deepkit/framework';
import { HttpRouterRegistry } from '@deepkit/http';

const app = new App({
    imports: [new FrameworkModule]
});

const router = app.get(HttpRouterRegistry);

router.get('/', () => {
    return "Hello World!";
});

app.run();

Die Router Registry kann auch in Event Listener oder im Bootstrap bezogen werden, sodass basierend auf Modulen, Konfigurationen und sonstigen Providern diverse Routen registriert werden.

import { App } from '@deepkit/app';
import { FrameworkModule } from '@deepkit/framework';

const app = new App({
    bootstrap: (router: HttpRouterRegistry) => {
        router.get('/', () => {
            return "Hello World!";
        });
    },
    imports: [new FrameworkModule]
});

Sobald Module verwendet werden, können funktionale Routen ebenfalls von Modulen dynamisch bereitgestellt werden.

import { App, createModule } from '@deepkit/app';
import { FrameworkModule } from '@deepkit/framework';
import { HttpRouterRegistry } from '@deepkit/http';

class MyModule extends createModule({}) {
    override process() {
        const router = this.setupGlobalProvider(HttpRouterRegistry);

        router.get('/', () => {
            return "Hello World!";
        });
    }
}

const app = new App({
    imports: [new FrameworkModule, new MyModule]
});

Siehe Framework Modules, um mehr über App Module zu erfahren.

8.3. Controller API

Die Controller API basiert auf Klassen und kann dabei über die App-API unter der Option controllers registriert werden.

import { App } from '@deepkit/app';
import { FrameworkModule } from '@deepkit/framework';
import { http } from '@deepkit/http';

class MyPage {
    @http.GET('/')
    helloWorld() {
        return "Hello World!";
    }
}

new App({
    controllers: [MyPage],
    imports: [new FrameworkModule]
}).run();

Sobald Module verwendet werden, können Controller ebenfalls von Modulen bereitgestellt werden.

import { App, createModule } from '@deepkit/app';
import { FrameworkModule } from '@deepkit/framework';
import { http } from '@deepkit/http';

class MyPage {
    @http.GET('/')
    helloWorld() {
        return "Hello World!";
    }
}

class MyModule extends createModule({
    controllers: [MyPage]
}) {
}

const app = new App({
    imports: [new FrameworkModule, new MyModule]
});

Um dynamisch (je nach Konfigurationoption zum Beispiel) Controller bereitzustellen, kann der process-Hook verwendet werden.

class MyModuleConfiguration {
    debug: boolean = false;
}

class MyModule extends createModule({
    config: MyModuleConfiguration
}) {
    override process() {
        if (this.config.debug) {
            class DebugController {
                @http.GET('/debug/')
                root() {
                    return 'Hello Debugger';
                }
            }
            this.addController(DebugController);
        }
    }
}

Siehe Framework Modules, um mehr über App Module zu erfahren.

8.4. HTTP Server

Sofern Deepkit Framework genutzt wird, ist dort ein HTTP Server bereits eingebaut. Die HTTP-Library kann jedoch auch ohne den Einsatz des Deepkit Frameworks mit einem eigenen HTTP-Server genutzt werden.

import { Server } from 'http';
import { HttpRequest, HttpResponse } from '@deepkit/http';

const app = new App({
    controllers: [MyPage],
    imports: [new HttpModule]
});

const httpKernel = app.get(HttpKernel);

new Server(
    { IncomingMessage: HttpRequest, ServerResponse: HttpResponse, },
    ((req, res) => {
        httpKernel.handleRequest(req as HttpRequest, res as HttpResponse);
    })
).listen(8080, () => {
    console.log('listen at 8080');
});

8.5. HTTP Client

todo: fetch API, validation, und cast.

8.6. Route Names

Routen können einen eindeutigen Namen erhalten, welcher bei einer Weiterleitung referenziert werden kann. Je nach API unterscheidet sich die Art wie ein Name definiert wird.

//functional API
router.get({
    path: '/user/:id',
    name: 'userDetail'
}, (id: number) => {
    return {userId: id};
});

//controller API
class UserController {
    @http.GET('/user/:id').name('userDetail')
    userDetail(id: number) {
        return {userId: id};
    }
}

Von allen Routen mit einem Namen kann die URL durch Router.resolveUrl() angefordert werden.

import { HttpRouter } from '@deepkit/http';
const router = app.get(HttpRouter);
router.resolveUrl('userDetail', {id: 2}); //=> '/user/2'

8.7. Dependency Injection

Die Router-Funktionen sowie die Controller-Klassen und Controller-Methoden können beliebige Abhängigkeiten definieren, die durch den Dependency Injection Container aufgelöst werden. So ist es zum Beispiel möglich bequem an eine Datenbank-Abstraktion oder Logger zu kommen.

Wenn zum Beispiel eine Datenbank als Provider zur Verfügung gestellt wurde, kann diese injiziert werden:

class Database {
    //...
}

const app = new App({
    providers: [
        Database,
    ],
});

Funktionaler API:

router.get('/user/:id', async (id: number, database: Database) => {
    return await database.query(User).filter({id}).findOne();
});

Controller API:

class UserController {
    constructor(private database: Database) {}

    @http.GET('/user/:id')
    async userDetail(id: number) {
        return await this.database.query(User).filter({id}).findOne();
    }
}

//alternatively directly in the method
class UserController {
    @http.GET('/user/:id')
    async userDetail(id: number, database: Database) {
        return await database.query(User).filter({id}).findOne();
    }
}

Siehe Dependency Injection für mehr Informationen.

8.8. Input

Alle nachfolgenden Input-Variationen funktionen bei der funktionalen wie auch der Controller API gleich. Sie erlauben es, Daten aus einen HTTP-Request typen-sicher und entkoppelt auszulesen. Dies führt nicht nur zu einer deutlichen erhöhten Sicherheit, sondern auch einfacheres Unit-Testen, da streng genommen nicht einmal ein HTTP-Request Objekt existieren muss, um die Route zu testen.

Alle Parameter werden dabei automatisch in den definierten TypeScript-Typen umgewandelt (deserialisiert) und validiert. Dies geschieht über das @deepkit/type Paket und seinen Serialization und Validation Features.

Der Einfachheit halber sind nachfolgend alle Beispiel mit der funktionalen API abgebildet.

8.8.1. Path Parameters

Path Parameter sind Werte, die aus der URL der Route extrahiert werden. Der Typ des Wertes richtet sich nach dem Typen an dem dazugehörigen Parameter der Funktion beziehungsweise Methode. Die Umwandlung geschieht automatisch mit dem Feature Weiche Typenkonvertierung.

router.get('/:text', (text: string) => {
    return 'Hello ' + text;
});
$ curl http://localhost:8080/galaxy
Hello galaxy

Ist ein Path Parameter als ein anderer Typ als String definiert, so wird dieser korrekt umgewandelt.

router.get('/user/:id', (id: number) => {
    return `${id} ${typeof id}`;
});
$ curl http://localhost:8080/user/23
23 number

Es können auch zusätzliche Validierung-Einschränken auf den Typen angewendet werden.

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

router.get('/user/:id', (id: number & Positive) => {
    return `${id} ${typeof id}`;
});

Alle Validierung-Typen aus @deepkit/type können angewendet werden. Hierzu ist mehr in HTTP Validation zu finden.

Die Path Parameter haben standardmäßig bei dem URL-Matching [^/]+ als Regular-Expression gesetzt. Das RegExp dazu kann wie folgt angepasst werden:

import { HttpRegExp } from '@deepkit/http';
import { Positive } from '@deepkit/type';

router.get('/user/:id', (id: HttpRegExp<number & Positive, '[0-9]+'>) => {
    return `${id} ${typeof id}`;
});

Dies ist nur in Ausnahmefällen nötig, da oft die Typen in Kombination mit Validierung-Typen selbst bereits mögliche Werte korrekt einschränken.

8.8.2. Query Parameters

Query Parameter sind Werte aus der URL hinter dem ?-Zeichen und können mit dem Typ HttpQuery<T> ausgelesen werden. Der Name des Parameters entspricht dabei dem Namen des Query-Parameters.

import { HttpQuery } from '@deepkit/http';

router.get('/', (text: HttpQuery<number>) => {
    return `Hello ${text}`;
});
$ curl http://localhost:8080/\?text\=galaxy
Hello galaxy

Auch Query Parameter sind automatisch deserialisiert und validiert.

import { HttpQuery } from '@deepkit/http';
import { MinLength } from '@deepkit/type';

router.get('/', (text: HttpQuery<string> & MinLength<3>) => {
    return 'Hello ' + text;
}
$ curl http://localhost:8080/\?text\=galaxy
Hello galaxy
$ curl http://localhost:8080/\?text\=ga
error

Alle Validierung-Typen aus @deepkit/type können angewendet werden. Hierzu ist mehr in HTTP Validation zu finden.

Warnung: Parameterwerte werden nicht escaped/sanitized. Ihre direkte Rückgabe in einer Zeichenkette in einer Route als HTML öffnet eine Sicherheitslücke (XSS). Stelle sicher, dass niemals externen Eingabe vertraut werden und filtere/sanitize/konvertiere Daten, wo nötig.

8.8.3. Query Model

Bei sehr vielen Query Parametern kann es schnell unübersichtlich werden. Um hier wieder Ordnung hereinzubringen, kann ein Model (Klasse oder Interface) genutzt werden, die alle möglichen Query-Parameter zusammenfasst.

import { HttpQueries } from '@deepkit/http';

class HelloWorldQuery {
    text!: string;
    page: number = 0;
}

router.get('/', (query: HttpQueries<HelloWorldQuery>) {
    return 'Hello ' + query.text + ' at page ' + query.page;
}
$ curl http://localhost:8080/\?text\=galaxy&page=1
Hello galaxy at page 1

Die Properties in dem angegebenen Model können alle TypeScript-Typen und Validierung-Typen beinhalten, die @deepkit/type unterstützt. Sieh dazu das Kapitel Serialization und Validation.

8.8.4. Body

Für HTTP-Methoden, die einen HTTP-Body erlauben, kann auch ein body model festgelegt werden. Der Body-Inhaltstyp von dem HTTP-Request muss entweder application/x-www-form-urlencoded, multipart/form-data oder application/json sein, damit Deepkit dies automatisch in JavaScript Objekte umwandeln kann.

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

class HelloWorldBody {
    text!: string;
}

router.post('/', (body: HttpBody<HelloWorldBody>) => {
    return 'Hello ' + body.text;
}

8.8.5. Header

8.8.6. Stream

Manual Validation Handling

Um manuell die Validierung des Body-Models zu übernehmen, kann ein spezieller Typ HttpBodyValidation<T> benutzt werden. Er erlaubt es, auch invalide Body-Daten zu empfangen und ganz spezifisch auf Fehlermeldungen zu reagieren.

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

class HelloWorldBody {
    text!: string;
}

router.post('/', (body: HttpBodyValidation<HelloWorldBody>) => {
    if (!body.valid()) {
        // Houston, we got some errors.
        const textError = body.getErrorMessageForPath('text');
        return 'Text is invalid, please fix it. ' + textError;
    }

    return 'Hello ' + body.text;
})

Sobald valid() den Wert false zurückgibt, können die Werte in dem angegebenen Model in einem fehlerhaften Zustand sein. Das bedeutet, dass die Validierung fehlgeschlagen ist. Wenn HttpBodyValidation nicht verwendet wird und eine fehlerhafte HTTP-Request eingeht, würde die Anfrage direkt abgebrochen werden und der Code in der Funktion nie ausgeführt. Verwende HttpBodyValidation nur dann, wenn zum Beispiel Fehlermeldungen bezüglich des Bodys manuell in derselben Route verwertet werden sollen.

Die Properties in dem angegebenen Model können alle TypeScript-Typen und Validierung-Typen beinhalten, die @deepkit/type unterstützt. Sieh dazu das Kapitel Serialization und Validation.

File Upload

Ein spezieller Property-Typ an dem Body-Model kann genutzt werden, um dem Client zu erlauben, Dateien hochzuladen. Es können beliebig viele UploadedFile verwendet werden.

import { UploadedFile, HttpBody } from '@deepkit/http';
import { readFileSync } from 'fs';

class HelloWordBody {
    file!: UploadedFile;
}

router.post('/', (body: HttpBody<HelloWordBody>) => {
    const content = readFileSync(body.file.path);

    return {
        uploadedFile: body.file
    };
})
$ curl http://localhost:8080/ -X POST -H "Content-Type: multipart/form-data" -F "[email protected]/23931.png"
{
    "uploadedFile": {
        "size":6430,
        "path":"/var/folders/pn/40jxd3dj0fg957gqv_nhz5dw0000gn/T/upload_dd0c7241133326bf6afddc233e34affa",
        "name":"23931.png",
        "type":"image/png",
        "lastModifiedDate":"2021-06-11T19:19:14.775Z"
    }
}

Standardmäßig speichert der Router alle hochgeladenen Dateien in einen Temp-Ordner und entfernt diese, sobald der Code in der Route ausgeführt wurde. Es ist daher notwendig, die Datei in dem angegebenen Pfad in path auszulesen und an einen permanenten Ort zu speichern (lokale Festplatte, Cloud Storage, Datenbank).

8.9. Validation

Validation in einem HTTP-Server ist ein zwingend notwendige Funktionalität, da fast immer mit Daten gearbeitet wird, die nicht vertrauenswürdig sind. Um an so mehr Stellen Daten validiert werden, umso stabiler ist der Server. Die Validierung in HTTP-Routen kann bequem über Typen und Validierung-Einschränkungen genutzt werden und wird mit einem hoch-optimierten Validator aus @deepkit/type geprüft, sodass es nicht zu Performanceproblemen diesbezüglich kommen kann. Es ist daher dringend empfehlenswert diese Validierungsfähigkeiten auch zu nutzen. Besser ein Mal zu viel, als ein Mal zu wenig.

Alle Inputs wie Path-Parameter, Query-Parameter, und Body-Parameter werden automatisch auf den angegebenen TypeScript-Typ validiert. Sind zusätzliche Einschränkungen über Typen von @deepkit/type angegeben, werden diese ebenfalls geprüft.

import { HttpQuery, HttpQueries, HttpBody } from '@deepkit/http';
import { MinLength } from '@deepkit/type';

router.get('/:text', (text: string & MinLength<3>) => {
    return 'Hello ' + text;
}

router.get('/', (text: HttpQuery<string> & MinLength<3>) => {
    return 'Hello ' + text;
}

interface MyQuery {
     text: string & MinLength<3>;
}

router.get('/', (query: HttpQueries<MyQuery>) => {
    return 'Hello ' + query.text;
}

router.post('/', (body: HttpBody<MyQuery>) => {
    return 'Hello ' + body.text;
}

Siehe Validation für mehr Informationen dazu.

8.10. Output

Eine Route kann verschiedene Datenstrukturen zurückgeben. Einige von ihnen werden auf besondere Weise behandelt, wie z. B. Weiterleitungen und Templates, und andere, wie einfache Objekte, werden einfach als JSON gesendet.

8.10.1. JSON

Per Default werden normale JavaScript-Werte als JSON mit dem Header application/json; charset=utf-8 an den Client zurückgesendet.

router.get('/', () => {
    // will be sent as application/json
    return {hello: 'world'}
});

Ist ein expliziter Return-Typ bei der Funktion oder Methode angegeben, werden entsprechend dieses Typen die Daten in JSON mit dem Deepkit JSON Serializer serialisiert.

interface ResultType {
    hello: string;
}

router.get('/', (): ResultType => {
    // will be sent as application/json and additionalProperty is dropped
    return {hello: 'world', additionalProperty: 'value'};
});

8.10.2. HTML

Um HTML zu senden, gibt es zwei Möglichkeiten. Entweder wird das Objekt HtmlResponse oder Template-Engine mit TSX verwendet.

import { HtmlResponse } from '@deepkit/http';

router.get('/', () => {
    // will be sent as Content-Type: text/html
    return new HtmlResponse('<b>Hello World</b>');
});
router.get('/', () => {
    // will be sent as Content-Type: text/html
    return <b>Hello World</b>;
});

Die Template-Engine Variante mit TSX hat dabei den Vorteil, dass genutzte Variablen automatisch HTML-escaped werden. Siehe dazu Template.

8.10.3. Custom Content

Es ist neben HTML und JSON auch möglich Text- oder Binäre-Daten mit einer bestimmten Content-Type zu senden. Dies geschieht über das Objekt Response

import { Response } from '@deepkit/http';

router.get('/', () => {
    return new Response('<title>Hello World</title>', 'text/xml');
});

8.10.4. HTTP Errors

Es ist durch das Werfen von diversen HTTP-Errors möglich, die Verarbeitung eines HTTP-Requests sofort zu unterbrechen und den entsprechenden HTTP-Status des Errors auszugeben.

import { HttpNotFoundError } from '@deepkit/http';

router.get('/user/:id', async (id: number, database: Database) => {
    const user = await database.query(User).filter({id}).findOneOrUndefined();
    if (!user) throw new HttpNotFoundError('User not found');
    return user;
});

Per default werden alle Errors als JSON dem Client zurückgegeben. Dieses Verhalten kann man beliebig im Event-System unter dem Event httpWorkflow.onControllerError anpassen. Siehe dazu die Sektion HTTP Events.

Error class Status

HttpBadRequestError

400

HttpUnauthorizedError

401

HttpAccessDeniedError

403

HttpNotFoundError

404

HttpMethodNotAllowedError

405

HttpNotAcceptableError

406

HttpTimeoutError

408

HttpConflictError

409

HttpGoneError

410

HttpTooManyRequestsError

429

HttpInternalServerError

500

HttpNotImplementedError

501

Der Error HttpAccessDeniedError stellt hierbei eine besonderheit dar. Sobald er geworfen wird, springt der HTTP Workflow (sieh HTTP Events) nicht zu controllerError sondern zu accessDenied.

Benutzerdefinierte HTTP-Errors können mit createHttpError angelegt und geworfen werden.

export class HttpMyError extends createHttpError(412, 'My Error Message') {
}

8.10.5. Zusätzliche Header

Um den Header einer HTTP-Response zu verändert, kann auf den Objekten Response, JSONResponse, und HTMLResponse zusätzliche Methoden aufgerufen werden.

import { Response } from '@deepkit/http';

router.get('/', () => {
    return new Response('Access Denied', 'text/plain')
        .header('X-Reason', 'unknown')
        .status(403);
});

8.10.6. Redirect

Um eine 301 oder 302 Weiterleitung als Antwort zurückzugeben, kann Redirect.toRoute oder Redirect.toUrl verwendet werden.

import { Redirect } from '@deepkit/http';

router.get({path: '/', name: 'homepage'}, () => {
    return <b>Hello World</b>;
});

router.get({path: '/registration/complete'}, () => {
    return Redirect.toRoute('homepage');
});

Die Methode Redirect.toRoute verwendet hierbei den Namen der Route. Wie ein Routen-Name gesetzt werden kann, ist in der Sektion HTTP Route Name einzusehen. Wenn diese referenzierte Route (Query oder Pfad) Parameter beinhaltet, können diese über das zweite Argument angegeben werden:

router.get({path: '/user/:id', name: 'user_detail'}, (id: number) => {

});

router.post('/user', (user: HttpBody<User>) => {
    //... store user and redirect to its detail page
    return Redirect.toRoute('user_detail', {id: 23});
});

Alternativ kann auf eine URL mit Redirect.toUrl weitergeleitet werden.

router.post('/user', (user: HttpBody<User>) => {
    //... store user and redirect to its detail page
    return Redirect.toUrl('/user/' + 23);
});

Standardmäßig benutzen beide einen 302-Weiterleitung. Dies kann über das Argument statusCode angepasst werden.

8.11. Scope

Alle HTTP-Controller und funktionalen Routen werden innerhalb des http Dependency Injection Scope verwaltet. HTTP-Controller werden entsprechend für jeden HTTP-Request neu instantiiert. Das bedeutet auch, dass beide auf Provider, die für den Scope http registriert sind, zugreifen können. So sind zusätzlich HttpRequest und HttpResponse aus @deepkit/http als Abhängigkeit nutzbar. Wenn Deepkit Framework benutzt, ist auch SessionHandler aus @deepkit/framework verfügbar.

import { HttpResponse } from '@deepkit/http';

router.get('/user/:id', (id: number, request: HttpRequest) => {
});

router.get('/', (response: HttpResponse) => {
    response.end('Hello');
});

Es kann durchaus nützlich sein, Provider in den http Scope zu platzieren, um zum Beispiel Services für jeden HTTP-Request neu zu instantiieren. Sobald der HTTP-Request bearbeitet wurde, wird der http scoped DI Container gelöscht und so alle seine Provider Instanzen vom Garbage Collector (GC) aufgeräumt.

Siehe Dependency Injection Scopes, um zu erfahren, wie Provider in den http Scope platziert werden können.

8.12. Events

Das HTTP-Modul basiert auf einer Workflow-Engine, die verschiedene Event-Tokens bereitstellt, mit denen sich in den gesamten Prozess der Verarbeitung eines HTTP-Requests eingeklinkt werden kann.

Die Workflow-Engine ist dabei eine endliche State-Machine, die für jeden HTTP-Request eine neu State-Machine Instanz anlegt und dann von Position zu Position springt. Die erste Position ist dabei der start und die letzte die response. In jede Position kann zusätzlicher Code ausgeführt werden.

http workflow

Jedes Event-Token hat seinen eigenen Event-Typen mit zusätzlichen Informationen.

Event-Token Description

httpWorkflow.onRequest

When a new request comes in

httpWorkflow.onRoute

When the route should be resolved from the request

httpWorkflow.onRouteNotFound

When the route is not found

httpWorkflow.onAuth

When authentication happens

httpWorkflow.onResolveParameters

When route parameters are resolved

httpWorkflow.onAccessDenied

When access is denied

httpWorkflow.onController

When the controller action is called

httpWorkflow.onControllerError

When the controller action threw an error

httpWorkflow.onParametersFailed

When route parameters resolving failed

httpWorkflow.onResponse

When the controller action has been called. This is the place where the result is converted to a response.

Da alle HTTP-Events auf der Workflow-Engine basieren, kann deren Verhalten abgeändert werden, indem das angegebene Event benutzt wird und dort mit der event.next() Methode weitergesprungen wird.

Das HTTP-Modul verwendet seine eigenen Event-Listener auf diese Event-Tokens, um die Bearbeitung von HTTP-Requests zu implementieren. Alle diese Event-Listener haben eine Priorität von 100, d.h. wenn Sie auf ein Event hören, wird Ihr Listener standardmäßig zuerst ausgeführt (da die Standardpriorität 0 ist). Fügen Sie eine Priorität von über 100 hinzu, um nach den Event-Listener des HTTP-Modules zu laufen.

Nehmen wir zum Beispiel an, Sie wollen das Ereignis abfangen, bei dem ein Controller aufgerufen wird. Wenn ein bestimmter Controller aufgerufen werden soll, prüfen wir, ob der Benutzer Zugriff darauf hat. Wenn der Benutzer Zugriff hat, fahren wir fort. Aber falls nicht, springen wir zur nächsten Workflow-Position accessDenied. Dort wird dann das Prozedere eines Access-Denied automatisch weiterverarbeitet.

import { App } from '@deepkit/app';
import { FrameworkModule } from '@deepkit/framework';
import { HtmlResponse, http, httpWorkflow } from '@deepkit/http';
import { eventDispatcher } from '@deepkit/event';

class MyWebsite {
    @http.GET('/')
    open() {
        return 'Welcome';
    }

    @http.GET('/admin').group('secret')
    secret() {
        return 'Welcome to the dark side';
    }
}

class SecretRouteListeners {
    @eventDispatcher.listen(httpWorkflow.onController)
    onController(event: typeof httpWorkflow.onController.event) {
        if (event.route.groups.includes('secret')) {
            //check here for authentication information like cookie session, JWT, etc.

            //this jumps to the 'accessDenied' workflow state,
            // essentially executing all onAccessDenied listeners.

            //since our listener is called before the HTTP kernel one,
            // the standard controller action will never be called.
            //this calls event.next('accessDenied', ...) under the hood
            event.accessDenied();
        }
    }

    /**
     * We change the default accessDenied implementation.
     */
    @eventDispatcher.listen(httpWorkflow.onAccessDenied)
    onAccessDenied(event: typeof httpWorkflow.onAccessDenied.event): void {
        if (event.sent) return;
        if (event.hasNext()) return;

        event.send(new HtmlResponse('No access to this area.', 403));
    }
}

new App({
    controllers: [MyWebsite],
    listeners: [SecretRouteListeners],
    imports: [new FrameworkModule]
}).run();
$ curl http://localhost:8080/
Welcome
$ curl http://localhost:8080/admin
No access to this area

8.13. Security

8.14. Sessions

8.15. Middleware

HTTP middlewares allow you to hook into the request/response cycle as an alternative to HTTP events. Its API allows you to use all middlewares from the Express/Connect framework.

Middleware A middleware can either be a class (which is instantiated by the dependency injection container) or a simple function.

import { HttpMiddleware, httpMiddleware, HttpRequest, HttpResponse } from '@deepkit/http';

class MyMiddleware implements HttpMiddleware {
    async execute(request: HttpRequest, response: HttpResponse, next: (err?: any) => void) {
        response.setHeader('middleware', '1');
        next();
    }
}


function myMiddlewareFunction(request: HttpRequest, response: HttpResponse, next: (err?: any) => void) {
    response.setHeader('middleware', '1');
    next();
}

new App({
    providers: [MyMiddleware],
    middlewares: [
        httpMiddleware.for(MyMiddleware),
        httpMiddleware.for(myMiddlewareFunction),
    ],
    imports: [new FrameworkModule]
}).run();

8.15.1. Global

By using httpMiddleware.for(MyMiddleware) a middleware is registered for all routes, globally.

import { httpMiddleware } from '@deepkit/http';

new App({
    providers: [MyMiddleware],
    middlewares: [
        httpMiddleware.for(MyMiddleware)
    ],
    imports: [new FrameworkModule]
}).run();

8.15.2. Per Controller

You can limit middlewares to one or multiple controllers in two ways. Either by using the @http.controller or httpMiddleware.for(T).forControllers(). excludeControllers allow you to exclude controllers.

@http.middleware(MyMiddleware)
class MyFirstController {

}
new App({
    providers: [MyMiddleware],
    controllers: [MainController, UsersCommand],
    middlewares: [
        httpMiddleware.for(MyMiddleware).forControllers(MyFirstController, MySecondController)
    ],
    imports: [new FrameworkModule]
}).run();

8.15.3. Per Route Name

forRouteNames along with its counterpart excludeRouteNames allow you to filter the execution of a middleware per route names.

class MyFirstController {
    @http.GET('/hello').name('firstRoute')
    myAction() {
    }

    @http.GET('/second').name('secondRoute')
    myAction2() {
    }
}
new App({
    controllers: [MainController, UsersCommand],
    providers: [MyMiddleware],
    middlewares: [
        httpMiddleware.for(MyMiddleware).forRouteNames('firstRoute', 'secondRoute')
    ],
    imports: [new FrameworkModule]
}).run();

8.15.4. Per Action/Route

To execute a middleware only for a certain route, you can either use @http.GET().middleware() or httpMiddleware.for(T).forRoute() where forRoute has multiple options to filter routes.

class MyFirstController {
    @http.GET('/hello').middleware(MyMiddleware)
    myAction() {
    }
}
new App({
    controllers: [MainController, UsersCommand],
    providers: [MyMiddleware],
    middlewares: [
        httpMiddleware.for(MyMiddleware).forRoutes({
            path: 'api/*'
        })
    ],
    imports: [new FrameworkModule]
}).run();

forRoutes() allows as first argument several way to filter for routes.

{
    path?: string;
    pathRegExp?: RegExp;
    httpMethod?: 'GET' | 'HEAD' | 'POST' | 'PATCH' | 'PUT' | 'DELETE' | 'OPTIONS' | 'TRACE';
    category?: string;
    excludeCategory?: string;
    group?: string;
    excludeGroup?: string;
}

8.15.5. Path Pattern

path supports wildcard *.

httpMiddleware.for(MyMiddleware).forRoutes({
    path: 'api/*'
})

8.15.6. RegExp

httpMiddleware.for(MyMiddleware).forRoutes({
    pathRegExp: /'api/.*'/
})

8.15.7. HTTP Method

Filter all routes by a HTTP method.

httpMiddleware.for(MyMiddleware).forRoutes({
    httpMethod: 'GET'
})

8.15.8. Category

category along with its counterpart excludeCategory allow you to filter per route category.

@http.category('myCategory')
class MyFirstController {

}

class MySecondController {
    @http.GET().category('myCategory')
    myAction() {
    }
}
httpMiddleware.for(MyMiddleware).forRoutes({
    category: 'myCategory'
})

8.15.9. Group

group along with its counterpart excludeGroup allow you to filter per route group.

@http.group('myGroup')
class MyFirstController {

}

class MySecondController {
    @http.GET().group('myGroup')
    myAction() {
    }
}
httpMiddleware.for(MyMiddleware).forRoutes({
    group: 'myGroup'
})

8.15.10. Per Modules

You can limit the execution of a module for a whole module.

httpMiddleware.for(MyMiddleware).forModule(ApiModule)

8.15.11. Per Self Modules

To execute a middleware for all controllers/routes of a module where the middleware was registered use forSelfModules().

const ApiModule new AppModule({
    controllers: [MainController, UsersCommand],
    providers: [MyMiddleware],
    middlewares: [
        //for all controllers registered of the same module
        httpMiddleware.for(MyMiddleware).forSelfModules(),
    ],
});

8.15.12. Timeout

All middleware needs to execute next() sooner or later. If a middleware does not execute next() withing a timeout, a warning is logged and the next middleware executed. To change the default of 4seconds to something else use timeout(milliseconds).

const ApiModule = new AppModule({
    controllers: [MainController, UsersCommand],
    providers: [MyMiddleware],
    middlewares: [
        //for all controllers registered of the same module
        httpMiddleware.for(MyMiddleware).timeout(15_000),
    ],
});

8.15.13. Multiple Rules

To combine multiple filters, you can chain method calls.

const ApiModule = new AppModule({
    controllers: [MyController],
    providers: [MyMiddleware],
    middlewares: [
        httpMiddleware.for(MyMiddleware).forControllers(MyController).excludeRouteNames('secondRoute')
    ],
});

8.15.14. Express Middleware

Almost all express middlewares are supported. Those who access certain request methods of express are not yet supported.

import * as compression from 'compression';

const ApiModule = new AppModule({
    middlewares: [
        httpMiddleware.for(compress()).forControllers(MyController)
    ],
});

8.16. Resolver

Der Router unterstützt eine Möglichkeit zur Auflösung komplexer Parametertypen. Wenn zum Beispiel eine Route wie /user/:id gegeben ist, kann diese id mithilfe eines Resolvers in ein User-Objekt außerhalb der Route aufgelöst werden. Dies führt zu einer weiteren Abkopplung von der HTTP-Abstraktion und des Routen-Codes, und vereinfacht so weiter das Testen und die Modularität.

import { App } from '@deepkit/app';
import { FrameworkModule } from '@deepkit/framework';
import { http, RouteParameterResolverContext, RouteParameterResolver } from '@deepkit/http';

class UserResolver implements RouteParameterResolver {
    constructor(protected database: Database) {}

    async resolve(context: RouteParameterResolverContext) {
        if (!context.parameters.id) throw new Error('No :id given');
        return await this.database.getUser(parseInt(context.parameters.id, 10));
    }
}

@http.resolveParameter(User, UserResolver)
class MyWebsite {
    @http.GET('/user/:id')
    getUser(user: User) {
        return 'Hello ' + user.username;
    }
}

new App({
    controllers: [MyWebsite],
    providers: [UserDatabase, UserResolver],
    imports: [new FrameworkModule]
})
    .run();

Der Decorator in @http.resolveParameter gibt dabei an, welche Klasse mit dem UserResolver aufgelöst werden soll. Sobald nun die angegebene Klasse User als Parameter in der Funktion beziehungsweise Methode angegeben ist, wird der Resolver genutzt, um diese bereitzustellen.

Ist @http.resolveParameter an der Klasse angegeben, erhalten all Methoden dieser Klasse diesen Resolver. Der Decorator kann auch pro Methode angewendet werden:

class MyWebsite {
    @http.GET('/user/:id').resolveParameter(User, UserResolver)
    getUser(user: User) {
        return 'Hello ' + user.username;
    }
}

Auch kann die funktionale API genutzt werden:

router.add(
    http.GET('/user/:id').resolveParameter(User, UserResolver),
    (user: User) => {
        return 'Hello ' + user.username;
    }
);

Das Objekt User muss hierbei nicht zwingend von einem Parameter abhängen. So könnte er genauso gut von einer Session bzw. einem HTTP-Header abhängen, und nur dann bereitgestellt werden, wenn der Benutzer eingeloggt ist. In RouteParameterResolverContext sind viele Informationen über den HTTP-Request verfügbar, sodass viele Anwendungsfälle abbildbar sind.

Im Prinzip ist es auch möglich, komplexe Parametertypen über den Dependency Injection Container aus dem Scope http bereitstellen zu lassen, da diese ebenfalls in der Routen-Funktion bzw. Methode verfügbar sind. Dies hat jedoch den Nachteil, dass kein asynchrone Funktionsaufrufe verwendet werden können, da der DI container durchweg synchron ist.

9. RPC

RPC steht für Remote Procedure Call und erlaubt es, Funktionen (procedures) auf einem remote Server so aufzurufen als wäre es eine lokale Funktion. Im Gegensatz zu HTTP Client-Server Kommunikation geschieht die Zuordnung nicht über die HTTP-Methode und einer URL, sondern dem Funktionsnamen. Die zu sendenden Daten werden als normale Funktion-Argumente übergeben und das Resultat des Funktionsaufrufes auf dem Server an den Client zurückgesendet.

Der Vorteil von RPC besteht darin, dass die Client-Server Abstraktion leichtgewichtiger ist, da weder mit Headern, URLs, noch Query-Strings oder Ähnlichem gearbeitet wird. Der Nachteil ist, dass Funktionen auf einem Server via RPC nicht von einem Browser ohne weiteres aufgerufen werden können und es oft einen speziellen Client benötigt.

Ein Schlüsselfeature von RPC ist, dass die Daten zwischen dem Client und Server automatisch serialisiert und deserialisiert werden. Aus diesem Grund sind meist typen-sichere RPC-Clients möglich. Manche RPC-Frameworks zwingen den Benutzern daher, die Typen (Parameter-Types und Return-Types) in einem bestimmten Format bereitzustellen. Dies kann in Form von einer eigenen DSL wie bei gRPC (Protocol Buffers) und GraphQL mit einem Code-Generator sein oder in Form von einem JavaScript Schema-Builder. Zusätzliche Validierung der Daten kann das RPC-Framework ebenfalls anbieten, wird aber nicht von allen unterstützt.

In Deepkit RPC werden die Typen aus den Funktionen von dem TypeScript-Code selbst extrahiert (siehe Runtime Types), sodass es nicht nötig ist, ein Code-Generator zu verwenden oder diese manuell zu definieren. Deepkit unterstützt dabei das automatische Serialisieren und Deserialisieren von Parametern und Resultaten. Sobald zusätzliche Einschränkungen aus Validation definiert sind, werden diese auch automatisch validiert. Dies macht die Kommunikation über RPC extrem typen-sicher und effektiv. Die Unterstützung von Streaming via rxjs in Deepkit RPC macht dieses RPC-Framework auch zu einem geeigneten Tool für Echtzeitkommunikation.

Um das Konzept hinter RPC zu veranschaulichen folgender Code:

//server.ts
class Controller {
    hello(title: string): string {
        return 'Hello ' + title
    }
}

Eine Methode wie hello wird ganz normal innerhalb einer Klasse auf dem Server implementiert und kann dann von einem remote Client aufgerufen werden.

//client.ts
const client = new RpcClient('localhost');
const controller = client.controller<Controller>();

const result = await controller.hello('World'); // => 'Hello World';

Da RPC fundamental auf asynchroner Kommunikation basiert, ist die Kommunikation meist über HTTP, kann aber auch über TCP oder WebSockets geschehen. Das bedeutet, dass alle Funktionsaufrufe in TypeScript selbst zu einem Promise umgewandelt werden. Mit einem entsprechenden await kann das Resultat asynchron empfangen werden.

9.1. Isomorphic TypeScript

Sobald ein Projekt im Client (meist Frontend) und Server (backend) TypeScript genutzt wird, spricht man von Isomorphic TypeScript. Ein typen-sicheres RPC Framework, das auf TypeScript’s Typen basiert, ist dann besonders profitable für ein solches Projekt, da Typen zwischen Client und Server geteilt werden können.

Um diesen Vorteil zu nutzen, sollten Typen, die auf beiden Seiten genutzt werden, in eine eigene Datei oder Package ausgelagert werden. Das Importieren auf der jeweiligen Seite fügt diese dann wieder zusammen.

//shared.ts
export class User {
    id: number;
    username: string;
}

interface UserControllerApi {
    getUser(id: number): Promise<User>;
}

//server.ts
import { User } from './shared';
class UserController implements UserControllerApi {
    async getUser(id: number): Promise<User> {
        return await datbase.query(User).filter({id}).findOne();
    }
}

//client.ts
import { UserControllerApi } from './shared';
const controller = client.controller<UserControllerApi>();
const user = await controller.getUser(2); // => User

Das Interface UserControllerApi agiert hierbei als Vertrag zwischen Client und Server. Der Server muss dies korrekt implementieren und der Client kann es konsumieren.

Abwärtskompatiblität kann auf dieselbe Art und Weise umgesetzt werden wie bei einer normalen lokalen API auch: Entweder werden neue Parameter als optional markiert oder es wird eine neue Methode hinzugefügt.

Es ist zwar auch möglich, direkt UserController via import type { UserController } from './server.ts zu importieren, so hat dies jedoch andere Nachteile wie keine Unterstützung für nominale Typen (was bedeutet, dass Klassen-Instanzen nicht mit instanceof geprüft werden können).

9.2. Installation

Da Deepkit RPC auf den Runtime Types basieren, ist es notwendig @deepkit/type bereits korrekt installiert zu haben. Siehe dazu Runtime Type Installation.

Falls dies erfolgreich geschehen ist, kann @deepkit/rpc installiert werden oder das Deepkit Framework welches die Library bereits unter der Haube benutzt.

npm install @deepkit/rpc

Zu beachten ist, dass Controller-Klassen in @deepkit/rpc auf TypeScript-Decorators basieren und dieses Feature entsprechend mit experimentalDecorators aktiviert werden muss

Das Paket @deepkit/rpc muss auf dem Server und Client installiert werden, sofern beide ihre eigene package.json haben.

Um über TCP mit dem Server zu kommunizieren, muss das Paket @deepkit/rpc-tcp im Client und Server installiert werden.

Für eine WebSocket-Kommunikation braucht es das Paket ebenfalls auf dem Server. Der Client im Browser hingegen nutzt WebSocket aus dem offiziellen Standard.

npm install @deepkit/rpc-tcp

Sobald der Client über WebSocket auch in einer Umgebung genutzt werden soll, wo WebSocket nicht verfügbar ist (zum Beispiel NodeJS), so wird das Paket ws in dem Client benötigt.

npm install ws

9.3. Benutzung

Nachfolgend ein voll funktionsfähiges Beispiel basierend WebSockets und der low-level API von @deepkit/rpc. Sobald das Deepkit Framework benutzt wird, werden Controller über App-Module bereitgestellt und es wird kein RpcKernel manuell instantiiert.

Datei: server.ts

import { rpc, RpcKernel } from '@deepkit/rpc';
import { RpcWebSocketServer } from '@deepkit/rpc-tcp';

@rpc.controller('myController');
export class Controller {
    @rpc.action()
    hello(title: string): string {
        return 'Hello ' + title;
    }
}

const kernel = new RpcKernel();
kernel.registerController(Controller);
const server = new RpcWebSocketServer(kernel, 'localhost:8081');
server.start();

Datei: client.ts

import { RpcWebSocketClient } from '@deepkit/rpc';
import type { Controller } from './server';

async function main() {
    const client = new RpcWebSocketClient('localhost:8081');
    const controller = client.controller<Controller>('myController');

    const result = await controller.hello('World');
    console.log('result', result);

    client.disconnect();
}

main().catch(console.error);

9.4. Server Controller

Das "Procedure" in Remote Procedure Call wird auch gerne Action genannt. Eine solche Action wird als Methode in einer Klasse definiert und mit dem @rpc.action Decorator markiert. Die Klasse selbst wird dabei als Controller durch den @rpc.controller Decorator markiert und einen eindeutigen Namen vergeben. Dieser Name wird dann im Client referenziert, um den korrekten Controller anzusprechen. Es können beliebig viele Controller definiert und registriert werden.

import { rpc } from '@deepkit/rpc';

@rpc.controller('myController');
class Controller {
    @rpc.action()
    hello(title: string): string {
        return 'Hello ' + title;
    }

    @rpc.action()
    test(): boolean {
        return true;
    }
}

Nur Methoden, die auch als @rpc.action() markiert sind, können von einem Client angesprochen werden.

Typen müssen explizit angeben werden und können nicht inferred werden. Das ist wichtig, da der Serializer genau wissen muss, wie die Typen aussehen, um diese in Binärdaten (BSON) oder JSON umzuwandeln.

9.5. Client Controller

Der normale Flow in RPC ist es, dass der Client Funktionen auf dem Server ausführen kann. Es ist aber in Deepkit RPC auch möglich, dass der Server Funktionen auf dem Client ausführen kann. Um das zu erlauben, kann der Client ebenfalls einen Controller registrieren.

TODO

9.6. Dependency Injection

Die Controller-Klassen werden von dem Dependency Injection Container von @deepkit/injector verwaltet. Wenn das Deepkit Framework genutzt wird, haben diese Controller automatisch zugriff auf die Provider des Modules, die den Controller bereitstellen.

Controller werden in dem Deepkit Framework in dem Dependency Injection Scope rpc instantiiert, sodass alle Controller automatisch auf diverse Provider aus diesem Scope zugriff haben. Diese zusätzlichen Provider sind HttpRequest (optional), RpcInjectorContext, SessionState, RpcKernelConnection, und ConnectionWriter.

import { RpcKernel, rpc } from '@deepkit/rpc';
import { App } from '@deepkit/app';
import { Database, User } from './database';

@rpc.controller('my')
class Controller {
    constructor(private database: Database) {}

    @rpc.action()
    async getUser(id: number): Promise<User> {
        return await this.database.query(User).filter({id}).findOne();
    }
}

new App({
    providers: [{provide: Database, useValue: new Database}]
    controllers: [Controller],
}).run();

Sobald jedoch ein RpcKernel manuell instantiiert wird, kann dort auch ein DI Container übergeben werden. Der RPC Controller wird dann über diesen DI Container instantiiert.

import { RpcKernel, rpc } from '@deepkit/rpc';
import { InjectorContext } from '@deepkit/injector';
import { Database, User } from './database';

@rpc.controller('my')
class Controller {
    constructor(private database: Database) {}

    @rpc.action()
    async getUser(id: number): Promise<User> {
        return await this.database.query(User).filter({id}).findOne();
    }
}

const injector = InjectorContext.forProviders([
    Controller,
    {provide: Database, useValue: new Database},
]);
const kernel = new RpcKernel(injector);
kernel.registerController(Controller);

Siehe Dependency Injection, um mehr zu erfahren.

9.7. Nominal Types

Wenn Daten auf dem Client von dem Funktionsaufruf empfangen werden, wurden diese zuvor auf dem Server serialisiert und anschließend auf dem Client deserialisiert. Sind in dem Return-Typ der Funktion nun Klassen genutzt, werden diese im Client rekonstruiert, verlieren jedoch ihre nominale Identität und alle Methoden. Um diesem Verhalten entgegenzuwirken, können Klassen als nominale Typen über eine eindeutige ID registriert werden. Dies sollte für alle Klassen gemacht werden, die in einer RPC-API genutzt werden.

Um eine Klasse zu registrieren ist das Nutzen von dem Decorator @entity.name('id') nötig.

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

@entity.name('user')
class User {
    id!: number;
    firstName!: string;
    lastName!: string;
    get fullName() {
        return this.firstName + ' ' + this.lastName;
    }
}

Sobald diese Klasse nun als Resultat einer Funktion genutzt wird, wird ihre Identität gewahrt.

const controller = client.controller<Controller>('controller');

const user = await controller.getUser(2);
user instanceof User; //true when @entity.name is used, and false if not

9.8. Error Forwarding

RPC Funktionen können Fehler werfen. Diese Fehler werden standardmäßig an den Client weitergeleitet und dort erneut geworfen. Wenn eigene Error-Klassen genutzt werden, sollte ihr nominaler Typ aktiviert werden. Siehe dazu RPC Nominal Types.

@entity.name('@error:myError')
class MyError extends Error {}

//server
class Controller {
    @rpc.action()
    saveUser(user: User): void {
        throw new MyError('Can not save user');
    }
}

//client
//[MyError] makes sure the class MyError is known in runtime
const controller = client.controller<Controller>('controller', [MyError]);

try {
    await controller.getUser(2);
} catch (e) {
    if (e instanceof MyError) {
        //ops, could not save user
    } else {
        //all other errors
    }
}

9.9. Security

Standardmäßig sind alle RPC Funktionen von jedem Client aus aufrufbar. Auch ist das Feature Peer-To-Peer Kommunikation standardmäßig aktiviert. Um hier genau einstellen zu können, welcher Client was darf, kann die Klasse RpcKernelSecurity überschrieben werden.

import { RpcKernelSecurity, Session, RpcControllerAccess } from '@deepkit/type';

//contains default implementations
class MyKernelSecurity extends RpcKernelSecurity {
    async hasControllerAccess(session: Session, controllerAccess: RpcControllerAccess): Promise<boolean> {
        return true;
    }

    async isAllowedToRegisterAsPeer(session: Session, peerId: string): Promise<boolean> {
        return true;
    }

    async isAllowedToSendToPeer(session: Session, peerId: string): Promise<boolean> {
        return true;
    }

    async authenticate(token: any): Promise<Session> {
        throw new Error('Authentication not implemented');
    }

    transformError(err: Error) {
        return err;
    }
}

Um diese zu nutzen wird entweder dem RpcKernel eine Instanz davon übergeben:

const kernel = new RpcKernel(undefined, new MyKernelSecurity);

Oder im Falle einer Deepkit Framework Anwendung die Klasse RpcKernelSecurity mit einem Provider überschrieben.

import { App } from '@deepkit/type';
import { RpcKernelSecurity } from '@deepkit/rpc';
import { FrameworkModule } from '@deepkit/framework';

new App({
    controllers: [MyRpcController],
    providers: [
        {provide: RpcKernelSecurity, useClass: MyRpcKernelSecurity}
    ],
    imports: [new FrameworkModule]
}).run();

9.9.1. Authentication / Session

Das Objekt Session ist standardmäßig eine anonyme Session, was bedeutet, dass der Client sich nicht authentifiziert hat. Sobald er sich authentifizieren will, wird die Methode authenticate aufgerufen. Das Token, das die authenticate Methode erhält, kommt von dem Client und kann einen beliebigen Wert haben.

Sobald der Client einen Token setzt, wird die Authentifizierung ausgeführt, sobald die erste RPC Funktion oder manuell client.connect() aufgerufen wird.

const client = new RpcWebSocketClient('localhost:8081');
client.token.set('123456789');

const controller = client.controller<Controller>('myController');

Hier erhält RpcKernelSecurity.authenticate das Token 123456789 und kann entsprechend eine andere Session zurückgeben. Diese zurückgegebene Session wird dann an alle anderen Methoden wie der hasControllerAccess übergeben.

import { Session, RpcKernelSecurity } from '@deepkit/rpc';

class UserSession extends Session {
}

class MyKernelSecurity extends RpcKernelSecurity {
    async hasControllerAccess(session: Session, controllerAccess: RpcControllerAccess): Promise<boolean> {
        if (controllerAccess.controllerClassType instanceof MySecureController) {
            //MySecureController requires UserSession
            return session instanceof UserSession;
        }
        return true;
    }

    async authenticate(token: any): Promise<Session> {
        if (token === '123456789') {
            return new UserSession('username', token);
        }
        throw new Error('Authentication failed');
    }
}

9.9.2. Controller Access

Mit der Methode hasControllerAccess kann bestimmt werden, ob ein Client eine bestimmte RPC Funktion ausführen darf. Diese Methode wird bei jedem RPC Funktionsaufruf ausgeführt. Gibt diese false zurück, ist der Zugriff verweigert und es wird ein Fehler auf dem Client geworfen.

In RpcControllerAccess sind mehrere wertvolle Informationen über die RPC Funktion enthalten:

interface RpcControllerAccess {
    controllerName: string;
    controllerClassType: ClassType;
    actionName: string;
    actionGroups: string[];
    actionData: { [name: string]: any };
}

Gruppen und zusätzliche Daten sind über den Decorator @rpc.action() änderbar:

class Controller {
    @rpc.action().group('secret').data('role', 'admin')
    saveUser(user: User): void {
    }
}


class MyKernelSecurity extends RpcKernelSecurity {
    async hasControllerAccess(session: Session, controllerAccess: RpcControllerAccess): Promise<boolean> {
        if (controllerAccess.actionGroups.includes('secret')) {
            //todo: check
            return false;
        }
        return true;
    }
}

9.9.3. Transform Error

Da geworfene Fehler automatisch mit all seinen Informationen wie die Fehlermeldung und auch den Stacktrace dem Client weitergeleitet werden, könnte dies unerwünscht sensitive Informationen veröffentlichen. Um dies zu ändern, kann in der Methode transformError der geworfene Fehler abgeändert werden.

class MyKernelSecurity extends RpcKernelSecurity {
    transformError(error: Error) {
        //wrap in new error
        return new Error('Something went wrong: ' + error.message);
    }
}

Beachte, dass sobald der Error in einen generischen Error umgewandelt wird, der komplette Stacktrace und die Identität des Errors verloren gehen. Entsprechend kann in dem Client keine instanceof checks mehr auf den Error genutzt werden.

Wird Deepkit RPC zwischen zwei Microservices verwendet, und ist somit der Client und Server unter vollständiger Kontrolle des Entwicklers, so ist ein Transformieren des Errors nur selten nötig. Läuft der Client hingegen in einem Browser bei einem Unbekannten, so sollte in transformError genaustens darauf geachtet werden, welche Informationen man preisgeben möchte. Im Zweifel sollte jeder Error mit einem generischen Error umgewandelt werden, um so sicherzustellen, dass keine internen Details nach außen gelangen. Das Loggen des Errors würde sich an dieser Stelle dann anbieten.

9.9.4. Dependency Injection

Sofern die Deepkit RPC Library direkt benutzt wird, wird die RpcKernelSecurity Klasse selbst instantiiert. Benötigt diese Klasse eine Datenbank oder einen Logger, so muss dieser selbst übergeben werden.

Wenn das Deepkit Framework genutzt wird, wird die Klasse von dem Dependency Injection Container instantiiert und hat so automatisch Zugriff auf alle anderen Provider der Anwendung.

Siehe hierzu auch Dependency Injection.

9.10. Streaming RxJS

TODO

9.11. Transport Protocol

Deepkit RPC unterstützt mehrere Transportprotokolle. WebSockets ist dabei das Protokoll, das die beste Kompatibilität hat (da Browser es unterstützen) und gleichzeitig alle Features wie Streaming unterstützt. TCP ist in der Regel schneller und eignet sich hervorragend für die Kommunikation zwischen Servern (Microservices) oder Nicht-Browser Clients.

Deepkit’s RPC HTTP Protokoll ist dabei eine Variante, die besonders einfach im Browser zu debuggen ist, da jeder Funktionsaufruf ein HTTP-Request ist, hat jedoch seine Limitierungen wie kein Support für RxJS Streaming.

9.11.1. HTTP

TODO: Not implemented yet.

9.11.2. WebSockets

@deepkit/rpc-tcp RpcWebSocketServer and Browser WebSocket or Node ws package.

9.11.3. TCP

@deepkit/rpc-tcp RpcNetTcpServer and RpcNetTcpClientAdapter

9.12. Peer To Peer

TODO

10. Database

Deepkit bietet ein ORM, das es ermöglicht, auf Datenbanken auf moderne Art und Weise zuzugreifen. Entities werden dabei einfach über TypeScript Typen definiert:

import { entity, PrimaryKey, AutoIncrement, Unique, MinLength, MaxLength } from '@deepkit/type';

@entity.name('user')
class User {
    id: number & PrimaryKey & AutoIncrement = 0;
    created: Date = new Date;
    firstName?: string;
    lastName?: string;

    constructor(
        public username: string & Unique & MinLength<2> & MaxLength<16>,
        public email: string & Unique,
    ) {}
}

Dabei können beliebige TypeScript Typen und Validierung-Dekoratoren von Deepkit benutzt werden, um die Entity vollumfänglich zu definieren. Das Entity-Typensystem ist dabei so ausgelegt, dass diese Typen beziehungsweise Klassen ebenfalls in anderen Bereichen wie HTTP-Routen, RPC-Aktionen, oder Frontend benutzt werden können. Das verhindert, dass man zum Beispiel einen User mehrmals in der gesamten Applikation verteilt definiert hat.

10.1. Installation

Da Deepkit ORM auf den Runtime Types basiert, ist es notwendig @deepkit/type bereits korrekt installiert zu haben. Siehe dazu Runtime Type Installation.

Falls dies erfolgreich geschehen ist, kann @deepkit/orm selbst und ein Datenbank-Adapter installiert werden.

Falls Klassen als Entities verwendet werden sollen, muss experimentalDecorators in der tsconfig.json aktiviert werden:

{
  "compilerOptions": {
    "experimentalDecorators": true
  }
}

Sobald die Library installiert ist, kann ein Datenbank-Adapter installiert und die API davon direkt benutzt werden.

10.1.1. SQLite

npm install @deepkit/orm @deepkit/sqlite
import { SQLiteDatabaseAdapter } from '@deepkit/sqlite';

const database = new Database(new SQLiteDatabaseAdapter('./example.sqlite'), [User]);
const database = new Database(new SQLiteDatabaseAdapter(':memory:'), [User]);

10.1.2. MySQL

npm install @deepkit/orm @deepkit/mysql
import { MySQLDatabaseAdapter } from '@deepkit/mysql';

const database = new Database(new MySQLDatabaseAdapter({
    host: 'localhost',
    port: 3306
}), [User]);

10.1.3. Postgres

npm install @deepkit/orm @deepkit/postgres
import { PostgresDatabaseAdapter } from '@deepkit/postgres';

const database = new Database(new PostgresDatabaseAdapter({
    host: 'localhost',
    port: 3306
}), [User]);

10.1.4. MongoDB

npm install @deepkit/orm @deepkit/bson @deepkit/mongo
import { MongoDatabaseAdapter } from '@deepkit/mongo';

const database = new Database(new MongoDatabaseAdapter('mongodb://localhost/mydatabase'), [User]);

10.2. Benutzung

Es wird primär mit dem Database Objekt gearbeitet. Einmal instantiiert, kann es innerhalb der ganzen Anwendung genutzt werde, um Daten abzufragen oder zu manipulieren. Die Verbindung zur Datenbank wird dabei lazy initialisiert.

Dem Database Objekt wird ein Adapter übergeben, welcher aus den Datenbank-Adaptern Libraries kommt.

import { SQLiteDatabaseAdapter } from '@deepkit/sqlite';
import { entity, PrimaryKey, AutoIncrement } from '@deepkit/type';
import { Database } from '@deepkit/orm';

async function main() {
    @entity.name('user')
    class User {
        public id: number & PrimaryKey & AutoIncrement = 0;
        created: Date = new Date;

        constructor(public name: string) {
        }
    }

    const database = new Database(new SQLiteDatabaseAdapter('./example.sqlite'), [User]);
    await database.migrate(); //create tables

    await database.persist(new User('Peter'));

    const allUsers = await database.query(User).find();
    console.log('all users', allUsers);
}

main();

10.2.1. Database

10.2.2. Connection

Read Replica

10.3. Entity

Eine Entity ist entweder eine Klasse oder Object Literal (interface) und hat immer einen Primary Key. Die Entity wird mittels Typen-Dekoratoren aus @deepkit/type mit alle notwendigen Informationen dekoriert. Zum Beispiel wird ein Primary Key definiert sowie diverse Felder und ihre Validierungen-Einschränkungen. Dieser Felder spiegeln die Datenbank-Struktur ab, in der Regel eine Tabelle oder eine Collection.

Durch spezielle Typen-Dekoratoren wie Mapped<'name'> kann ein Feldnamen auch auf einen anderen Namen in der Datenbank abgebildet werden.

10.3.1. Klasse

import { entity, PrimaryKey, AutoIncrement, Unique, MinLength, MaxLength } from '@deepkit/type';

@entity.name('user')
class User {
    id: number & PrimaryKey & AutoIncrement = 0;
    created: Date = new Date;
    firstName?: string;
    lastName?: string;

    constructor(
        public username: string & Unique & MinLength<2> & MaxLength<16>,
        public email: string & Unique,
    ) {}
}

const database = new Database(new SQLiteDatabaseAdapter(':memory:'), [User]);
await database.migrate();

await database.persist(new User('Peter'));

const allUsers = await database.query(User).find();
console.log('all users', allUsers);

10.3.2. Interface

import { PrimaryKey, AutoIncrement, Unique, MinLength, MaxLength } from '@deepkit/type';

interface User {
    id: number & PrimaryKey & AutoIncrement = 0;
    created: Date = new Date;
    firstName?: string;
    lastName?: string;
    username: string & Unique & MinLength<2> & MaxLength<16>;
}

const database = new Database(new SQLiteDatabaseAdapter(':memory:'));
database.register<User>({name: 'user'});

await database.migrate();

const user: User = {id: 0, created: new Date, username: 'Peter'};
await database.persist(user);

const allUsers = await database.query<User>().find();
console.log('all users', allUsers);

10.3.3. Primitives

Primitive Datentypen wie String, Number (bigint), und Boolean werden gängige Datenbank-Typen abgebildet. Es wird dabei lediglich der TypeScript Type genutzt.

interface User {
    logins: number;
    username: string;
    pro: boolean;
}

10.3.4. Primary Key

Jede Entity braucht genau einen Primary Key. Mehrere Primary Keys werden nicht unterstützt.

Der Basis-Typ eines Primary Keys kann dabei beliebig sein. Oft wird eine Nummer oder UUID verwendet. Für MongoDB wird gerne die MongoId bzw ObjectID verwendet.

Bei Nummern bietet sich AutoIncrement an.

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

interface User {
    id: number & PrimaryKey;
}

10.3.5. Auto Increment

Felder, die beim Einfügen automatisch inkrementiert werden sollen, werden mit dem AutoIncrement Dekorator annotiert. Alle Adapter unterstützen auto-increment Werte. Der MongoDB Adapter verwendet eine zusätzliche Collection, um den Zähler zu verfolgen.

Ein Auto-Increment Feld ist ein automatischer Zähler und kann nur an einem Primary Key angewendet werden. Die Datenbank stellt automatisch sicher, dass eine ID nur einmal verwendet wird.

import { PrimaryKey, AutoIncrement } from '@deepkit/type';

interface User {
    id: number & PrimaryKey & AutoIncrement;
}

10.3.6. UUID

Felder, die vom Typ UUID (v4) sein sollten, werden mit dem Dekorator UUID annotiert. Der Laufzeittyp ist string und in der Datenbank selbst meist binär. Verwenden Sie die Funktion uuid(), um eine neue UUID v4 zu erzeugen.

import { uuid, UUID, PrimaryKey } from '@deepkit/type';

class User {
    id: UUID & PrimaryKey = uuid();
}

10.3.7. MongoDB ObjectID

Felder, die in MongoDB vom Typ ObjectID sein sollten, werden mit dem Dekorator MongoId annotiert. Der Laufzeittyp ist string und in der Datenbank selbst ObjectId (binär).

MongoID-Felder erhalten beim Einfügen automatisch einen neuen Wert. Es ist nicht zwingend nötig, den Feldnamen _id zu verwenden. Er kann einen beliebigen Namen haben.

import { PrimaryKey, MongoId } from '@deepkit/type';

class User {
    id: MongoId & PrimaryKey = '';
}

10.3.8. Optional / Nullable

Optionale Felder werden mit title?: string oder title: string | null als TypeScript-Typ deklariert. Man sollte nur eine Variante davon verwenden, normalerweise die optionale ? Syntax, die mit undefined funktioniert. Beide Varianten führen dazu, dass der Datenbank-Typ für alle SQL-Adapter NULLABLE ist. Der einzige Unterschied zwischen diesen Dekoratoren ist also, dass sie unterschiedliche Werte zur Laufzeit darstellen.

Im folgenden Beispiel ist das geänderte Feld optional und kann daher zur Laufzeit undefiniert sein, obwohl es in der Datenbank immer als NULL dargestellt wird.

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

class User {
    id: number & PrimaryKey = 0;
    modified?: Date;
}

Dieses Beispiel zeigt, wie der nullable Typ funktioniert. Sowohl in der Datenbank als auch in der Javascript-Laufzeit wird NULL verwendet. Dies ist ausführlicher als modified?: Date und wird nicht häufig verwendet.

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

class User {
    id: number & PrimaryKey = 0;
    modified: Date | null = null;
}

10.3.9. Database Type Mapping

Runtime type SQLite MySQL Postgres Mongo

string

text

longtext

text

string

number

float

double

double precision

int/number

boolean

integer(1)

boolean

boolean

boolean

date

text

datetime

timestamp

datetime

array

text

json

jsonb

array

map

text

json

jsonb

object

map

text

json

jsonb

object

union

text

json

jsonb

T

uuid

blob

binary(16)

uuid

binary

ArrayBuffer/Uint8Array/…​

blob

longblob

bytea

binary

Mit DatabaseField ist es möglich, ein Feld auf einen beliebigen Datenbank-Typen zu mappen. Der Typ muss eine gültige SQL-Anweisung sein, die unverändert an das Migrationssystem übergeben wird.

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

interface User {
    title: string & DatabaseField<{type: 'VARCHAR(244)'}>;
}

Um ein Feld für eine bestimmte Datenbank zu mappen, kann entweder SQLite, MySQL, oder Postgres benutzt werden.

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

interface User {
    title: string & SQLite<{type: 'text'}>;
}
MySQL
import { MySQL } from '@deepkit/type';

interface User {
    title: string & MySQL<{type: 'text'}>;
}
Postgres
import { Postgres } from '@deepkit/type';

interface User {
    title: string & Postgres<{type: 'text'}>;
}

10.3.10. Embedded Types

10.3.11. Default Values

Default-Werte werden

10.3.12. Default Expressions

10.3.13. Complex Types

10.3.14. Exclude

10.3.15. Database Specific Column Types

10.4. Session / Unit Of Work

Eine Session ist so etwas wie eine Arbeitseinheit. Sie verfolgt alles, was Sie tun, und hält die Änderungen automatisch fest, sobald commit() aufgerufen wird. Es ist der bevorzugte Weg, um Änderungen in der Datenbank auszuführen, da es Anweisungen in einer Weise bündelt, die es sehr schnell macht. Eine Session ist sehr leichtgewichtig und kann zum Beispiel leicht in einem Request-Response-Lebenszyklus erstellt werden.

import { SQLiteDatabaseAdapter } from '@deepkit/sqlite';
import { entity, PrimaryKey, AutoIncrement } from '@deepkit/type';
import { Database } from '@deepkit/orm';

async function main() {

    @entity.name('user')
    class User {
        id: number & PrimaryKey & AutoIncrement = 0;
        created: Date = new Date;

        constructor(public name: string) {
        }
    }

    const database = new Database(new SQLiteDatabaseAdapter(':memory:'), [User]);
    await database.migrate();

    const session = database.createSession();
    session.add(new User('User1'), new User('User2'), new User('User3'));

    await session.commit();

    const users = await session.query(User).find();
    console.log(users);
}

main();

Fügen Sie der Session mit session.add(T) neue Instanz hinzu oder entfernen Sie bereits vorhandene Instanzen mit session.remove(T). Sobald Sie mit dem Session-Objekt fertig sind, dereferenzieren Sie es einfach überall, damit der Garbage-Collector es entfernen kann.

Änderungen werden bei Entity-Instanzen, die über das Session-Objekt geholt werden, automatisch erkannt.

const users = await session.query(User).find();
for (const user of users) {
    user.name += ' changed';
}

await session.commit();//saves all users

10.4.1. Identity Map

Sessions bieten eine Identity-Map, die sicherstellt, dass es immer nur ein Javascript-Objekt pro Datenbank-Eintrag gibt. Wenn Sie zum Beispiel session.query(User).find() zweimal innerhalb derselben Sitzung ausführen, erhalten Sie zwei verschiedene Arrays, aber mit denselben Entitätsinstanzen darin.

Wenn Sie mit session.add(entity1) eine neue Entität hinzufügen und diese erneut abrufen, erhalten Sie genau dieselbe Entitätsinstanz entity1.

Wichtig: Sobald Sie anfangen, Sessions zu verwenden, sollten Sie deren Methode Session.query anstelle von Database.query verwenden. Nur bei Session-Queries ist die Identitätszuordnungsfunktion aktiviert.

10.4.2. Change Detection

10.4.3. Request/Response

10.5. Query

Ein Query ist ein Objekt, das beschreibt, wie Daten aus der Datenbank abgerufen oder geändert werden sollen. Es hat mehrere Methoden um das Query zu beschreiben und Abbruchmethoden die diese ausführen. Der Datenbankadapter kann die Query-API auf viele Arten erweitern, um Datenbank spezifische Features zu unterstützen.

Sie können ein Query erstellen, indem Sie Database.query(T) oder Session.query(T) verwenden. Wir empfehlen Sessions da es die Leistung verbessert.

@entity.name('user')
class User {
    id: number & PrimaryKey & AutoIncrement = 0;
    created: Date = new Date;
    birthdate?: Date;
    visits: number = 0;

    constructor(public username: string) {
    }
}

const database = new Database(...);

//[ { username: 'User1' }, { username: 'User2' }, { username: 'User2' } ]
const users = await database.query(User).select('username').find();

10.5.1. Filter

Ein Filter kann angewendet werden, um die Ergebnismenge einzuschränken.

//simple filters
const users = await database.query(User).filter({name: 'User1'}).find();

//multiple filters, all AND
const users = await database.query(User).filter({name: 'User1', id: 2}).find();

//range filter: $gt, $lt, $gte, $lte (greater than, lower than, ...)
//equivalent to WHERE created < NOW()
const users = await database.query(User).filter({created: {$lt: new Date}}).find();
//equivalent to WHERE id > 500
const users = await database.query(User).filter({id: {$gt: 500}}).find();
//equivalent to WHERE id >= 500
const users = await database.query(User).filter({id: {$gte: 500}}).find();

//set filter: $in, $nin (in, not in)
//equivalent to WHERE id IN (1, 2, 3)
const users = await database.query(User).filter({id: {$in: [1, 2, 3]}}).find();

//regex filter
const users = await database.query(User).filter({username: {$regex: /User[0-9]+/}}).find();

//grouping: $and, $nor, $or
//equivalent to WHERE (username = 'User1') OR (username = 'User2')
const users = await database.query(User).filter({
    $or: [{username: 'User1'}, {username: 'User2'}]
}).find();


//nested grouping
//equivalent to WHERE username = 'User1' OR (username = 'User2' and id > 0)
const users = await database.query(User).filter({
    $or: [{username: 'User1'}, {username: 'User2', id: {$gt: 0}}]
}).find();


//nested grouping
//equivalent to WHERE username = 'User1' AND (created < NOW() OR id > 0)
const users = await database.query(User).filter({
    $and: [{username: 'User1'}, {$or: [{created: {$lt: new Date}, id: {$gt: 0}}]}]
}).find();
Equal
Greater / Smaller
RegExp
Grouping AND/OR
In

10.5.2. Select

Um die Felder einzugrenzen, die von der Datenbank empfangen werden sollen, kann select('field1') verwendet werden.

const user = await database.query(User).select('username').findOne();
const user = await database.query(User).select('id', 'username').findOne();

Wichtig dabei ist, dass sobald ein Eingrenzungen der Felder über select stattfindet, die Ergebnisse keine Instanzen der Entity mehr sind, sondern lediglich Object-Literals.

const user = await database.query(User).select('username').findOne();
user instanceof User; //false

10.5.3. Order

Mit orderBy(field, order) kann die Reihenfolge der Einträge geändert werden. Es kann mehrere Male orderBy ausgeführt werden, um die Reihenfolge immer weiter zu verfeinern.

const users = await session.query(User).orderBy('created', 'desc').find();
const users = await session.query(User).orderBy('created', 'asc').find();

10.5.4. Pagination

Mit den Methoden itemsPerPage() und page() können die Ergebnisse paginiert werden. Seite beginnt bei 1.

const users = await session.query(User).itemsPerPage(50).page(1).find();

Mit den alternativen Methoden limit und skip können Sie manuell paginieren.

const users = await session.query(User).limit(5).skip(10).find();

10.5.5. Join

Standardmäßig werden Referenzen aus der Entity weder in Abfragen einbezogen noch geladen. Um ein Join in die Abfrage aufzunehmen, ohne den Verweis zu laden, verwenden Sie join() (left join) oder innerJoin(). Um einen Join in die Abfrage aufzunehmen und die Referenz zu laden, verwenden Sie joinWith() oder innerJoinWith().

Alle folgenden Beispiele gehen von diesen Modellschemata aus:

@entity.name('group')
class Group {
    id: number & PrimaryKey & AutoIncrement = 0;
    created: Date = new Date;

    constructor(public username: string) {
    }
}

@entity.name('user')
class User {
    id: number & PrimaryKey & AutoIncrement = 0;
    created: Date = new Date;

    group?: Group & Reference;

    constructor(public username: string) {
    }
}
//select only users with a group assigned (INNER JOIN)
const users = await session.query(User).innerJoin('group').find();
for (const user of users) {
    user.group; //error, since reference was not loaded
}
//select only users with a group assigned (INNER JOIN) and load the relation
const users = await session.query(User).innerJoinWith('group').find();
for (const user of users) {
    user.group.name; //works
}

Um Join-Abfragen zu ändern, verwenden Sie dieselben Methoden, jedoch mit dem use-Präfix: useJoin, useInnerJoin, useJoinWith oder useInnerJoinWith. Um die Änderung der Join-Abfrage zu beenden, verwenden Sie end(), um so die übergeordnete Abfrage zurückzubekommen.

//select only users with a group with name 'admins' assigned (INNER JOIN)
const users = await session.query(User)
    .useInnerJoinWith('group')
        .filter({name: 'admins'})
        .end()  // returns to the parent query
    .find();

for (const user of users) {
    user.group.name; //always admin
}

10.5.6. Aggregation

Mit Aggregationsmethoden können Sie Datensätze zählen und Felder aggregieren.

Die folgenden Beispiele gehen von diesem Modellschema aus:

@entity.name('file')
class File {
    id: number & PrimaryKey & AutoIncrement = 0;
    created: Date = new Date;

    downloads: number = 0;

    category: string = 'none';

    constructor(public path: string & Index) {
    }
}

groupBy ermöglicht es, das Ergebnis nach dem angegebenen Feld zu gruppieren.

await database.persist(
    cast<File>({path: 'file1', category: 'images'}),
    cast<File>({path: 'file2', category: 'images'}),
    cast<File>({path: 'file3', category: 'pdfs'})
);

//[ { category: 'images' }, { category: 'pdfs' } ]
await session.query(File).groupBy('category').find();

Es gibt mehrere Aggregationsmethoden: withSum, withAverage, withCount, withMin, withMax, withGroupConcat. Jede erfordert einen Feldnamen als erstes Argument und ein optionales zweites Argument, um den Alias zu ändern.

// first let's update some of the records:
await database.query(File).filter({path: 'images/file1'}).patchOne({$inc: {downloads: 15}});
await database.query(File).filter({path: 'images/file2'}).patchOne({$inc: {downloads: 5}});

//[{ category: 'images', downloads: 20 },{ category: 'pdfs', downloads: 0 }]
await session.query(File).groupBy('category').withSum('downloads').find();

//[{ category: 'images', downloads: 10 },{ category: 'pdfs', downloads: 0 }]
await session.query(File).groupBy('category').withAverage('downloads').find();

//[ { category: 'images', amount: 2 }, { category: 'pdfs', amount: 1 } ]
await session.query(File).groupBy('category').withCount('id', 'amount').find();

10.5.7. Returning

Mit returning können bei Änderungen via patch und delete zusätzliche Felder angefordert werden.

Vorsicht: Nicht in allen Datenbank-Adaptern sind die Felder atomar zurückgegeben. Verwenden Sie Transaktionen, um Datenkonsistenz sicherzustellen.

await database.query(User).patchMany({visits: 0});

//{ modified: 1, returning: { visits: [ 5 ] }, primaryKeys: [ 1 ] }
const result = await database.query(User)
    .filter({username: 'User1'})
    .returning('username', 'visits')
    .patchOne({$inc: {visits: 5}});

10.5.8. Find

Gibt ein Array an Einträgen zurück, die zu dem angegebenen Filter passen.

const users: User[] = await database.query(User).filter({username: 'Peter'}).find();

10.5.9. FindOne

Gibt ein Eintrag zurück, der zu dem angegebenen Filter passen. Wird kein Eintrag gefunden, wird ein ItemNotFound Fehler geworfen.

const users: User = await database.query(User).filter({username: 'Peter'}).findOne();

10.5.10. FindOneOrUndefined

Gibt ein Eintrag zurück, der zu dem angegebenen Filter passen. Wird kein Eintrag gefunden, wird undefined zurückgegeben.

const query = database.query(User).filter({username: 'Peter'});
const users: User|undefined = await query.findOneOrUndefined();

10.5.11. FindField

Gibt eine Liste eines Feldes zurück, der zu dem angegebenen Filter passen.

const usernames: string[] = await database.query(User).findField('username');

10.5.12. FindOneField

Gibt eine Liste eines Feldes zurück, der zu dem angegebenen Filter passen. Wird kein Eintrag gefunden, wird ein ItemNotFound Fehler geworfen.

const username: string = await database.query(User).filter({id: 3}).findOneField('username');

10.5.13. Patch

Patch ist eine Änderungsabfrage, die die in der Abfrage beschriebenen Datensätze patcht. Die Methoden patchOne und patchMany beenden die Abfrage und führen den Patch aus.

patchMany ändert alle Einträge in der Datenbank, die zu dem angegebenen Filter passen. Ist kein Filter gesetzt, wird die gesamte Tabelle geändert. Nutzen Sie patchOne, um immer nur einen Eintrag zu verändern.

await database.query(User).filter({username: 'Peter'}).patch({username: 'Peter2'});

await database.query(User).filter({username: 'User1'}).patchOne({birthdate: new Date});
await database.query(User).filter({username: 'User1'}).patchOne({$inc: {visits: 1}});

await database.query(User).patchMany({visits: 0});

10.5.14. Delete

deleteMany löscht alle Einträge in der Datenbank, die zu dem angegebenen Filter passen. Ist kein Filter gesetzt, wird die gesamte Tabelle gelöscht. Nutzen Sie deleteOne, um immer nur einene Eintrag zu löschen.

const result = await database.query(User)
    .filter({visits: 0})
    .deleteMany();

const result = await database.query(User).filter({id: 4}).deleteOne();

10.5.15. Has

Gibt zurück, ob mindestens ein Eintrag in der Datenbank existiert.

const userExists: boolean = await database.query(User).filter({username: 'Peter'}).has();

10.5.16. Count

Gibt die Anzahl der Einträge zurück.

const userCount: number = await database.query(User).count();

10.5.17. Lift

Das Lifting einer Abfrage bedeutet, dass ihr neue Funktionen hinzugefügt werden. Dies wird in der Regel entweder von Plugins oder komplexen Architekturen verwendet, um größere Abfrageklassen in mehrere praktische, wiederverwendbare Klassen aufzuteilen.

import { FilterQuery, Query } from '@deepkit/orm';

class UserQuery<T extends {birthdate?: Date}> extends Query<T>  {
    hasBirthday() {
        const start = new Date();
        start.setHours(0,0,0,0);
        const end = new Date();
        end.setHours(23,59,59,999);

        return this.filter({$and: [{birthdate: {$gte: start}}, {birthdate: {$lte: end}}]} as FilterQuery<T>);
    }
}

await session.query(User).lift(UserQuery).hasBirthday().find();

10.6. Repository

10.7. Relations

Beziehungen ermöglichen es Ihnen, zwei Entitäten auf eine bestimmte Art und Weise zu verbinden. Dies geschieht in Datenbanken in der Regel über das Konzept der Fremdschlüssel. Deepkit ORM unterstützt Relationen für alle offiziellen Datenbankadapter.

Eine Relation wird mit dem Reference-Dekorator annotiert. Normalerweise hat eine Relation auch eine umgekehrte Relation, die mit dem Typ BackReference annotiert wird, aber nur benötigt wird, wenn die umgekehrte Relation in einer Datenbankabfrage verwendet werden soll. Rückreferenzen sind nur virtuell.

10.7.1. One To Many

Die Entität, die einen Verweis speichert, wird in der Regel als die "besitzende Seite" oder diejenige, die den Verweis "besitzt", bezeichnet. Der folgende Code zeigt zwei Entitäten mit einer One-To-Many-Beziehung zwischen User und Post. Das bedeutet, dass ein User mehrere Post haben kann. Die Entität Post besitzt die Beziehung Post→User. In der Datenbank selbst gibt es nun ein Feld Post."author", das den Primärschlüssel von User enthält.

import { SQLiteDatabaseAdapter } from '@deepkit/sqlite';
import { entity, PrimaryKey, AutoIncrement, Reference } from '@deepkit/type';
import { Database } from '@deepkit/orm';

async function main() {
    @entity.name('user').collectionName('users')
    class User {
        id: number & PrimaryKey & AutoIncrement = 0;
        created: Date = new Date;

        constructor(public username: string) {
        }
    }

    @entity.name('post')
    class Post {
        id: number & PrimaryKey & AutoIncrement = 0;
        created: Date = new Date;

        constructor(
            public author: User & Reference,
            public title: string
        ) {
        }
    }

    const database = new Database(new SQLiteDatabaseAdapter(':memory:'), [User, Post]);
    await database.migrate();

    const user1 = new User('User1');
    const post1 = new Post(user1, 'My first blog post');
    const post2 = new Post(user1, 'My second blog post');

    await database.persist(user1, post1, post2);
}

main();

Referenzen werden in Abfragen standardmäßig nicht ausgewählt. Siehe dazu Section 10.5.5, “Join”.

10.7.2. Many To One

Ein Verweis hat in der Regel einen umgekehrten Verweis, der Many-to-One genannt wird. Es handelt sich nur um eine virtuelle Referenz, da sie nicht in der Datenbank selbst reflektiert wird. Eine Rückreferenz wird mit BackReference annotiert und wird hauptsächlich für Reflection und Query Joins verwendet. Wenn Sie eine BackReference von User zu Post hinzufügen, können Sie Post direkt aus User-Abfragen verbinden.

@entity.name('user').collectionName('users')
class User {
    id: number & PrimaryKey & AutoIncrement = 0;
    created: Date = new Date;

    posts?: Post[] & BackReference;

    constructor(public username: string) {
    }
}
//[ { username: 'User1', posts: [ [Post], [Post] ] } ]
const users = await database.query(User)
    .select('username', 'posts')
    .joinWith('posts')
    .find();

10.7.3. Many To Many

Eine Many-to-many-Beziehung ermöglicht es Ihnen, viele Datensätze mit vielen anderen zu verbinden. Sie kann zum Beispiel für Benutzer in Gruppen verwendet werden. Ein Benutzer kann in keiner, einer oder vielen Gruppen sein. Folglich kann eine Gruppe 0, einen oder viele Benutzer enthalten.

Many-to-many-Beziehungen werden normalerweise über eine Pivot-Entität implementiert. Die Pivot-Entität enthält die eigentlichen eigenen Referenzen auf zwei andere Entitäten, und diese beiden Entitäten haben Rückreferenzen auf die Pivot-Entität.

@entity.name('user')
class User {
    id: number & PrimaryKey & AutoIncrement = 0;
    created: Date = new Date;

    groups?: Group[] & BackReference<{via: typeof UserGroup}>;

    constructor(public username: string) {
    }
}

@entity.name('group')
class Group {
    id: number & PrimaryKey & AutoIncrement = 0;

    users?: User[] & BackReference<{via: typeof UserGroup}>;

    constructor(public name: string) {
    }
}

//the pivot entity
@entity.name('userGroup')
class UserGroup {
    id: number & PrimaryKey & AutoIncrement = 0;

    constructor(
        public user: User & Reference,
        public group: Group & Reference,
    ) {
    }
}

Mit diesen Entities können Sie nun Benutzer und Gruppen erstellen und sie mit der Pivot-Entität verbinden. Durch die Verwendung eines Rückverweises in User können wir die Gruppen direkt mit einer User-Abfrage abrufen.

const database = new Database(new SQLiteDatabaseAdapter(':memory:'), [User, Group, UserGroup]);
await database.migrate();

const user1 = new User('User1');
const user2 = new User('User2');
const group1 = new Group('Group1');

await database.persist(user1, user2, group1, new UserGroup(user1, group1), new UserGroup(user2, group1));

//[
//   { id: 1, username: 'User1', groups: [ [Group] ] },
//   { id: 2, username: 'User2', groups: [ [Group] ] }
// ]
const users = await database.query(User)
    .select('username', 'groups')
    .joinWith('groups')
    .find();

Um die Verknüpfung eines Benutzers mit einer Gruppe aufzuheben, wird der Datensatz der UserGroup gelöscht:

const users = await database.query(UserGroup)
    .filter({user: user1, group: group1})
    .deleteOne();

10.7.4. One To One

10.7.5. Constraints

On Delete/Update: RESTRICT | CASCADE | SET NULL | NO ACTION | SET DEFAULT

10.8. Inheritance

10.8.1. Table Per Class

10.8.2. Single Table Inheritance

10.9. Index

10.10. Case Sensitivity

10.11. Character Sets

10.12. Collations

10.13. Batching

10.14. Caching

10.15. Multitenancy

10.16. Events

Ereignisse sind eine Möglichkeit, sich in Deepkit ORM einzuklinken und ermöglichen es Ihnen, leistungsfähige Plugins zu schreiben. Es gibt zwei Kategorien von Ereignissen: Abfrage-Ereignisse und Unit-of-Work-Ereignisse. Plugin-Autoren verwenden in der Regel beide, um beide Möglichkeiten der Datenmanipulation zu unterstützen.

Events werden über Database.listen un einem Event-Token registriert. Es kann auch kurzlebige Event-Listener auf Sessions registriert werden.

import { Query, Database } from '@deepkit/orm';

const database = new Database(...);
database.listen(Query.onFetch, async (event) => {
});

const session = database.createSession();

//will only be executed for this particular session
session.eventDispatcher.listen(Query.onFetch, async (event) => {
});

10.16.1. Query Events

Abfrageereignisse werden ausgelöst, wenn eine Abfrage über Database.query() oder Session.query() ausgeführt wird.

Jedes Event hat seine eigenen zusätzlichen Eigenschaften wie den Typ der Entität, die Abfrage selbst und die Datenbanksitzung. Sie können die Abfrage überschreiben, indem Sie eine neue Abfrage auf Event.query setzen.

import { Query, Database } from '@deepkit/orm';

const database = new Database(...);

const unsubscribe = database.listen(Query.onFetch, async event => {
    //overwrite the query of the user, so something else is executed.
    event.query = event.query.filterField('fieldName', 123);
});

//to delete the hook call unsubscribe
unsubscribe();

Query hat dabei mehrere Event-Tokens:

Event-Token Description

Query.onFetch

Query.onDeletePre

Query.onDeletePost

Query.onPatchPre

Query.onPatchPost

10.16.2. Unit Of Work Events

Unit-of-Work-Ereignisse werden ausgelöst, wenn eine neue Session änderungen absetzt.

Event-Token Description

DatabaseSession.onUpdatePre

DatabaseSession.onUpdatePost

DatabaseSession.onInsertPre

DatabaseSession.onInsertPost

DatabaseSession.onDeletePre

DatabaseSession.onDeletePost

DatabaseSession.onCommitPre

10.17. Transactions

Eine Transaktion ist eine sequentielle Gruppe von Anweisungen, Abfragen oder Operationen wie Select, Insert, Update oder Delete, die als eine einzige Arbeitseinheit ausgeführt werden, die bestätigt oder rückgängig gemacht werden kann.

Deepkit unterstützt Transaktionen für alle offiziell unterstützten Datenbanken. Standardmäßig werden für jede Abfrage und Datenbanksitzung keine Transaktionen verwendet. Um Transaktionen zu aktivieren, gibt es zwei Hauptmethoden: Sessions und Callback.

10.17.1. Session Transactions

Sie können für jede erstellte Session eine neue Transaktion starten und zuweisen. Dies ist die bevorzugte Art der Interaktion mit der Datenbank, da Sie das Session-Objekt einfach weitergeben können und alle Abfragen, die von dieser Session instanziiert werden, automatisch seiner Transaktion zugewiesen werden.

Ein typisches Muster ist, alle Operationen in einen try-catch-Block zu verpacken und commit() in der allerletzten Zeile auszuführen (das nur ausgeführt wird, wenn alle vorherigen Befehle erfolgreich waren) und rollback() im catch-Block, um alle Änderungen zurückzunehmen sobald ein Fehler auftritt.

Obwohl es eine alternative API gibt (siehe unten), funktionieren alle Transaktionen nur mit Datenbanksitzungsobjekten. Um offene Änderungen aus der Unit-of-Work in einer Datenbanksitzung an die Datenbank zu übertragen, wird normalerweise commit() aufgerufen. In einer transaktionalen Sitzung überträgt commit() nicht nur alle ausstehenden Änderungen in die Datenbank, sondern schließt auch die Transaktion ab ("commits") und schließt damit die Transaktion. Alternativ können Sie session.flush() aufrufen, um alle anstehenden Änderungen ohne Commit und damit ohne Abschluss der Transaktion zu übertragen. Um eine Transaktion zu committen, ohne die Unit-of-Work zu leeren, verwenden Sie session.commitTransaction().

const session = database.createSession();

//this assigns a new transaction, and starts it with the very next database operation.
session.useTransaction();

try {
    //this query is executed in the transaction
    const users = await session.query(User).find();

    await moreDatabaseOperations(session);

    await session.commit();
} catch (error) {
    await session.rollback();
}

Sobald commit() oder rollback() in einer Session ausgeführt wird, wird die Transaktion freigegeben. Sie müssen dann useTransaction() erneut aufrufen, wenn Sie in einer neuen Transaktion weiterarbeiten wollen.

Bitte beachten Sie, dass sobald die erste Datenbankoperation in einer transaktionalen Session ausgeführt wird, wird die zugewiesene Datenbankverbindung dem aktuellen Sitzungsobjekt fest und exklusiv zugewiesen (sticky). Somit werden alle nachfolgenden Operationen auf derselben Verbindung (und somit in den meisten Datenbanken auf demselben Datenbankserver) ausgeführt. Erst wenn entweder die transaktionale Session beendet wird (commit oder rollback), wird die Datenbankverbindung wieder freigegeben. Es ist daher zu empfehlen, eine Transaktion nur so kurz wie nötig zu halten.

Wenn eine Session bereits mit einer Transaktion verbunden ist, gibt ein Aufruf von session.useTransaction() immer das gleiche Objekt zurück. Verwenden Sie session.isTransaction(), um zu prüfen, ob der Sitzung eine Transaktion zugeordnet ist.

Verschachtelte Transaktionen werden nicht unterstützt.

10.17.2. Transaktion Callback

Eine Alternative zu transaktionalen Sessions ist database.transaction(callback).

await database.transaction(async (session) => {
    //this query is executed in the transaction
    const users = await session.query(User).find();

    await moreDatabaseOperations(session);
});

Die Methode database.transaction(callback) führt einen asynchronen Callback innerhalb einer neuen transaktionalen Session aus. Wenn der Callback erfolgreich ist (das heisst kein Fehler geworfen wird), wird die Session automatisch committed (und damit ihre Transaktion committed und alle Änderungen geleert). Wenn der Callback fehlschlägt, führt die Sitzung automatisch rollback() aus, und der Fehler wird weitergeleitet.

10.17.3. Isolations

Viele Datenbanken unterstützen verschiedene Arten von Transaktionen. Um das Transaktionsverhalten zu ändern, können Sie verschiedene Methoden für das zurückgegebene Transaktionsobjekt von useTransaction() aufrufen. Die Schnittstelle dieses Transaktionsobjekts hängt von dem verwendeten Datenbankadapter ab. Zum Beispiel hat das von einer MySQL-Datenbank zurückgegebene Transaktionsobjekt andere Optionen als das von einer MongoDB-Datenbank zurückgegebene. Verwenden Sie die Code-Vervollständigung oder sehen Sie sich die Schnittstelle des Datenbankadapters an, um eine Liste der möglichen Optionen zu erhalten.

const database = new Database(new MySQLDatabaseAdapter());

const session = database.createSession();
session.useTransaction().readUncommitted();

try {
    //...operations
    await session.commit();
} catch () {
    await session.rollback();
}

//or
await database.transaction(async (session) => {
    //this works as long as no database operation has been exuected.
    session.useTransaction().readUncommitted();

    //...operations
});

Während Transaktionen für MySQL, PostgreSQL und SQLite standardmäßig funktionieren, müssen Sie MongoDB zunächst als "Replikatsatz" einrichten.

Um eine Standard-MongoDB-Instanz in ein Replikatset zu konvertieren, lesen Sie bitte die offizielle Dokumentation Convert a Standalone to a Replica Set.

10.18. Naming Strategy

10.19. Locking

10.19.1. Optimistic Locking

10.19.2. Pessimistic Locking

10.20. Custom Types

10.21. Logging

10.22. Migration

10.23. Seeding

10.24. Raw Database Access

10.24.1. SQL

10.24.2. MongoDB

10.25. App Configuration

10.26. Composite Primary Key

Composite Primary-Key bedeutet, eine Entität hat mehrere Primärschlüssel, die automatisch zu einem "zusammengesetzten Primärschlüssel" zusammengefasst werden. Diese Art der Modellierung der Datenbank hat Vor- und Nachteile. Wir sind der Meinung, dass zusammengesetzte Primärschlüssel enorme praktische Nachteile haben, die ihre Vorteile nicht rechtfertigen, sodass sie als schlechte Praxis betrachtet werden sollten und daher vermieden werden sollten. Deepkit ORM unterstützt keine zusammengesetzten Primärschlüssel. In diesem Kapitel erklären wir warum und zeigen (bessere) Alternativen auf.

10.26.1. Nachteile

Joins sind nicht trivial. Obwohl sie in RDBMS hochgradig optimiert sind, stellen sie in Anwendungen eine ständige Komplexität dar, die leicht aus dem Ruder laufen und zu Leistungsproblemen führen kann. Leistung nicht nur in Bezug auf die Ausführungszeit der Abfragen, sondern auch in Bezug auf die Entwicklungszeit.

10.26.2. Joins

Jeder einzelne Join wird komplizierter, je mehr Felder beteiligt sind. Während viele Datenbanken Optimierungen implementiert haben, um Joins mit mehreren Feldern nicht per se langsamer zu machen, erfordert es vom Entwickler, diese Joins ständig im Detail zu durchdenken, da z. B. das Vergessen von Schlüsseln zu subtilen Fehlern führen kann (da der Join auch ohne Angabe aller Schlüssel funktioniert) und der Entwickler daher die vollständige zusammengesetzte Primärschlüsselstruktur kennen muss.

10.26.3. Indizes

Indizes mit mehreren Feldern (die zusammengesetzte Primärschlüssel sind) leiden unter dem Problem der Feldreihenfolge in Abfragen. Während Datenbanksysteme bestimmte Abfragen optimieren können, ist es bei komplexen Strukturen schwierig, effiziente Operationen zu schreiben, die alle definierten Indizes korrekt nutzen. Bei einem Index mit mehreren Feldern (wie einem zusammengesetzten Primärschlüssel) ist es normalerweise erforderlich, die Felder in der richtigen Reihenfolge zu definieren, damit die Datenbank den Index tatsächlich verwenden kann. Wenn die Reihenfolge nicht korrekt angegeben ist (z. B. in einer WHERE-Klausel), kann dies leicht dazu führen, dass die Datenbank den Index überhaupt nicht verwendet und stattdessen eine vollständige Tabellendurchsuchung durchführt. Zu wissen, welche Datenbank-Abfrage auf welche Weise optimiert, ist ein fortgeschrittenes Wissen, über das neue Entwickler in der Regel nicht verfügen, das aber erforderlich ist, sobald Sie mit zusammengesetzten Primärschlüsseln arbeiten, damit Sie das Beste aus Ihrer Datenbank herausholen und keine Ressourcen verschwenden.

10.26.4. Migrationen

Sobald Sie entscheiden, dass eine bestimmte Entität ein zusätzliches Feld zur eindeutigen Identifizierung (und damit zum Composite Primary Key wird) benötigt, führt dies zur Anpassung aller Entitäten in Ihrer Datenbank, die Beziehungen zu dieser Entität haben.

Nehmen wir an, Sie haben z. B. eine Entität User mit zusammengesetztem Primärschlüssel und beschließen, in verschiedenen Tabellen einen Fremdschlüssel zu diesem User zu verwenden, z. B. in einer Pivot-Tabelle audit_log, groups und posts. Sobald Sie den Primärschlüssel von User ändern, müssen alle diese Tabellen in einer Migration ebenfalls angepasst werden.

Dies macht Migrationsdateien nicht nur viel komplexer, sondern kann auch zu größeren Ausfallzeiten bei der Ausführung von Migrationsdateien führen, da Schemaänderungen in der Regel entweder eine vollständige Datenbanksperre oder zumindest eine Tabellensperre erfordern. Je mehr Tabellen von einer großen Änderung wie einer Indexänderung betroffen sind, desto länger dauert die Migration. Und je größer eine Tabelle ist, desto länger dauert die Migration. Denken Sie an die Tabelle audit_log. Solche Tabellen haben in der Regel viele Datensätze (etwa Millionen), und Sie müssen sie bei einer Schemaänderung nur deshalb anfassen, weil Sie beschlossen haben, einen zusammengesetzten Primärschlüssel zu verwenden und dem Primärschlüssel von User ein zusätzliches Feld hinzuzufügen. Je nach Größe all dieser Tabellen werden Migrationsänderungen dadurch entweder unnötig teurer oder in einigen Fällen sogar so teuer, dass eine Änderung des Primärschlüssels von User finanziell nicht mehr vertretbar ist. Dies führt in der Regel zu Umgehungslösungen (z. B. Hinzufügen eines eindeutigen Indexes zur Benutzertabelle), die zu technischen Schulden führen und früher oder später auf der Liste der Altlasten landen.

Bei großen Projekten kann dies zu enormen Ausfallzeiten führen (von Minuten bis Stunden) und manchmal sogar zur Einführung eines völlig neuen Migrationsabstraktionssystems, das im Wesentlichen Tabellen kopiert, Datensätze in Geistertabellen einfügt und nach der Migration Tabellen hin und her verschiebt. Diese zusätzliche Komplexität wird wiederum jeder Entität aufgezwungen, die eine Beziehung zu einer anderen Entität mit einem zusammengesetzten Primärschlüssel hat, und wird umso größer, je größer Ihre Datenbankstruktur wird. Das Problem wird immer schlimmer, ohne dass es eine Möglichkeit gibt, es zu lösen (außer durch die vollständige Entfernung des zusammengesetzten Primärschlüssels).

10.26.5. Auffindbarkeit

Wenn Sie Datenbankadministrator oder Data Engineer/Scientist sind, arbeiten Sie in der Regel direkt an der Datenbank und erkunden die Daten, wenn Sie sie brauchen. Bei zusammengesetzten Primärschlüsseln muss jeder Benutzer, der SQL direkt schreibt, von allen beteiligten Tabellen den richtigen Primärschlüssel kennen (und die Spaltenreihenfolge, um korrekte Indexoptimierungen zu erhalten). Dieser zusätzliche Overhead erschwert nicht nur die Untersuchung von Daten, die Erstellung von Berichten usw., sondern kann auch zu Fehlern in älterem SQL führen, wenn ein zusammengesetzter Primärschlüssel plötzlich geändert wird. Das alte SQL ist wahrscheinlich immer noch gültig und läuft einwandfrei, liefert aber plötzlich falsche Ergebnisse, da das neue Feld im zusammengesetzten Primärschlüssel in der Verknüpfung fehlt. Es ist hierbei viel einfacher, lediglich einen Primärschlüssel zu haben. Dies erleichtert die Auffindbarkeit von Daten und stellt sicher, dass alte SQL-Abfragen auch dann noch korrekt funktionieren, wenn Sie sich entscheiden, die Art und Weise zu ändern, wie zum Beispiel ein Benutzerobjekt eindeutig identifiziert wird.

10.26.6. Überarbeitung

Sobald ein zusammengesetzter Primärschlüssel in einer Entität verwendet wird, kann ein Refactoring des Schlüssels zu einem erheblichen zusätzlichen Refactoring führen. Da eine Entität mit einem zusammengesetzten Primärschlüssel in der Regel kein einzelnes eindeutiges Feld hat, müssen alle Filter und Verknüpfungen alle Werte des zusammengesetzten Schlüssels enthalten. Das bedeutet in der Regel, dass der Code auf die Kenntnis des zusammengesetzten Primärschlüssels angewiesen ist, sodass alle Felder abgerufen werden müssen (z. B. für URLs wie /user/:key1/:key2). Sobald dieser Schlüssel geändert wird, müssen alle Stellen, an denen dieses Wissen explizit verwendet wird, wie URLs, benutzerdefinierte SQL-Abfragen und andere Stellen, umgeschrieben werden.

Während ORMs in der Regel Joins automatisch erstellen, ohne die Werte manuell zu spezifizieren, können sie nicht automatisch das Refactoring für alle anderen Anwendungsfälle wie URL-Strukturen oder benutzerdefinierte SQL-Abfragen abdecken, und vor allem nicht für Stellen, an denen das ORM gar nicht verwendet wird, wie in Berichtssystemen und allen externen Systemen.

10.26.7. ORM-Komplexität

Durch die Unterstützung von zusammengesetzten Primärschlüsseln steigt die Komplexität des Codes eines leistungsstarken ORM wie Deepkit ORM enorm an. Nicht nur, dass der Code und die Wartung komplexer und damit teurer werden, es werden auch mehr Edge-Cases von Benutzern auftreten, die behoben und gewartet werden müssen. Die Komplexität der Abfrageschicht, der Änderungserkennung, des Migrationssystems, der internen Verfolgung von Beziehungen usw. nimmt erheblich zu. Die Gesamtkosten, die mit dem Aufbau und der Unterstützung eines ORM mit zusammengesetzten Primärschlüsseln verbunden sind, sind alles in allem zu hoch und nicht zu rechtfertigen, weshalb Deepkit dies nicht unterstützt.

10.26.8. Vorteile

Abgesehen davon haben zusammengesetzte Primärschlüssel auch Vorteile, wenn auch nur sehr oberflächliche. Durch die Verwendung einer möglichst geringen Anzahl von Indizes für jede Tabelle wird das Schreiben (Einfügen/Aktualisieren) von Daten effizienter, da weniger Indizes gepflegt werden müssen. Außerdem wird die Struktur des Modells etwas sauberer (da es normalerweise eine Spalte weniger hat). Der Unterschied zwischen einem sequentiell geordneten, automatisch inkrementierenden Primärschlüssel und einem nicht inkrementierenden Primärschlüssel ist heutzutage jedoch völlig vernachlässigbar, da Festplattenplatz billig ist und der Vorgang in der Regel nur ein "Append-Only"-Vorgang ist, der sehr schnell ist.

Es mag sicherlich ein paar Randfälle geben (und für ein paar sehr spezifische Datenbanksysteme), in denen es zunächst besser ist, mit zusammengesetzten Primärschlüsseln zu arbeiten. Aber selbst in diesen Systemen könnte es insgesamt (unter Berücksichtigung aller Kosten) sinnvoller sein, sie nicht zu verwenden und zu einer anderen Strategie zu wechseln.

10.26.9. Alternative

Eine Alternative zu zusammengesetzten Primärschlüsseln ist die Verwendung eines einzigen automatisch inkrementierenden numerischen Primärschlüssels, in der Regel "id" genannt, und die Verlagerung des zusammengesetzten Primärschlüssels in einen eindeutigen Index mit mehreren Feldern. Je nach verwendetem Primärschlüssel (abhängig von der erwarteten Zeilenzahl) verwendet die "id" entweder 4 oder 8 Bytes pro Datensatz.

Durch den Einsatz dieser Strategie ist man nicht mehr gezwungen, über die oben beschriebenen Probleme nachzudenken und eine Lösung zu finden, was die Kosten für immer größer werdende Projekte enorm senkt.

Die Strategie bedeutet konkret, dass jede Entität ein "id"-Feld hat, normalerweise ganz am Anfang, und dieses Feld wird dann verwendet, um standardmäßig eindeutige Zeilen und in Joins zu identifizieren.

class User {
    id: number & PrimaryKey & AutoIncrement = 0;

    constructor(public username: string) {}
}

Als Alternative zu einem zusammengesetzten Primärschlüssel würden Sie stattdessen einen eindeutigen Mehrfeldindex verwenden.

@entity.index(['tenancyId', 'username'], {unique: true})
class User {
    id: number & PrimaryKey & AutoIncrement = 0;

    constructor(
        public tenancyId: number,
        public username: string,
    ) {}
}

Deepkit ORM unterstützt automatisch inkrementelle Primärschlüssel, auch für MongoDB. Dies ist die bevorzugte Methode zur Identifizierung von Datensätzen in Ihrer Datenbank. Für MongoDB können Sie jedoch die ObjectId (_id: MongoId & PrimaryKey = '') als einfachen Primärschlüssel verwenden. Eine Alternative zum numerischen, automatisch inkrementierenden Primärschlüssel ist eine UUID, die ebenso gut funktioniert (jedoch etwas andere Leistungsmerkmale aufweist, da die Indexierung teurer ist).

10.26.10. Zusammenfassung

Zusammengesetzte Primärschlüssel bedeuten im Wesentlichen, dass nach ihrer Einführung alle künftigen Änderungen und die praktische Verwendung mit wesentlich höheren Kosten verbunden sind. Während es zu Beginn wie eine saubere Architektur aussieht (weil man eine Spalte weniger hat), führt es zu erheblichen praktischen Kosten, sobald das Projekt tatsächlich entwickelt wird, und die Kosten steigen weiter, je größer das Projekt wird.

Betrachtet man die Asymmetrien zwischen Vor- und Nachteilen, so wird deutlich, dass zusammengesetzte Primärschlüssel in den meisten Fällen nicht zu rechtfertigen sind. Die Kosten sind viel größer als der Nutzen. Nicht nur für Sie als Benutzer, sondern auch für uns als Autor und Betreuer des ORM-Codes. Aus diesem Grund unterstützt Deepkit ORM keine zusammengesetzten Primärschlüssel.

10.27. Plugins

10.27.1. Soft-Delete

Das Soft-Delete Plugin ermöglicht es, Datenbankeinträge versteckt zu halten, ohne sie tatsächlich zu löschen. Wenn ein Datensatz gelöscht wird, wird er nur als gelöscht markiert und nicht wirklich gelöscht. Alle Abfragen filtern automatisch nach dieser gelöschten Eigenschaft, sodass es sich für den Benutzer so anfühlt, als ob er tatsächlich gelöscht wäre.

Um das Plugin zu verwenden, müssen Sie die SoftDelete-Klasse instanziieren und sie für jede Entität aktivieren.

import { entity, PrimaryKey, AutoIncrement } from '@deepkit/type';
import { SoftDelete } from '@deepkit/orm';

@entity.name('user')
class User {
    id: number & PrimaryKey & AutoIncrement = 0;
    created: Date = new Date;

    // this field is used as indicator whether the record is deleted.
    deletedAt?: Date;

    // this field is optional and can be used to track who/what deleted the record.
    deletedBy?: string;

    constructor(
        public name: string
    ) {
    }
}

const softDelete = new SoftDelete(database);
softDelete.enable(User);

//or disable again
softDelete.disable(User);
Löschen

Um Datensätze sanft zu löschen, verwenden Sie die üblichen Methoden: deleteOne oder deleteMany in einer Abfrage, oder Sie verwenden die Session, um sie zu löschen. Das Soft-Delete Plugin erledigt den Rest automatisch im Hintergrund.

Wiederherstellen

Gelöschte Datensätze können mithilfe einer aufgehobenen Abfrage über SoftDeleteQuery wiederhergestellt werden. Es hat restoreOne und restoreMany.

import { SoftDeleteQuery } from '@deepkit/orm';

await database.query(User).lift(SoftDeleteQuery).filter({ id: 1 }).restoreOne();
await database.query(User).lift(SoftDeleteQuery).filter({ id: 1 }).restoreMany();

Die Session unterstützt auch die Wiederherstellung von Elementen.

import { SoftDeleteSession } from '@deepkit/orm';

const session = database.createSession();
const user1 = session.query(User).findOne();

session.from(SoftDeleteSession).restore(user1);
await session.commit();
Hard Delete

Um Datensätze hart zu löschen, verwenden Sie eine gehobene Abfrage über SoftDeleteQuery. Dies stellt im Wesentlichen das alte Verhalten ohne das Plugin für eine einzelne Abfrage wieder her.

import { SoftDeleteQuery } from '@deepkit/orm';

await database.query(User).lift(SoftDeleteQuery).hardDeleteOne();
await database.query(User).lift(SoftDeleteQuery).hardDeleteMany();

//those are equal
await database.query(User).lift(SoftDeleteQuery).withSoftDeleted().deleteOne();
await database.query(User).lift(SoftDeleteQuery).withSoftDeleted().deleteMany();
Query deleted.

Bei einem "lifted" Query über SoftDeleteQuery können Sie auch gelöschte Datensätze einbeziehen.

import { SoftDeleteQuery } from '@deepkit/orm';

// find all, soft deleted and not deleted
await database.query(User).lift(SoftDeleteQuery).withSoftDeleted().find();

// find only soft deleted
await database.query(s).lift(SoftDeleteQuery).isSoftDeleted().count()
Deleted by

deletedBy kann über Abfrage und Sessions festgelegt werden.

import { SoftDeleteSession } from '@deepkit/orm';

const session = database.createSession();
const user1 = session.query(User).findOne();

session.from(SoftDeleteSession).setDeletedBy('Peter');
session.remove(user1);

await session.commit();
import { SoftDeleteQuery } from '@deepkit/orm';

database.query(User).lift(SoftDeleteQuery)
.deletedBy('Peter')
.deleteMany();

11. Template

Die Template-Engine ermöglicht es, typsichere, schnelle und sichere HTML-Templates zu schreiben. Sie basiert auf TSX und ist sofort einsatzbereit, sobald Sie die Dateierweiterung .tsx verwenden und die tsconfig.json entsprechend anpassen.

Wichtig dabei ist: Es ist nicht kompatibel zu React. Sobald React eingesetzt werden soll, ist @deepkit/template inkompatibel. Deepkit’s Template engine ist nur für SSR (Server-Side-Rendering) gedacht.

11.1. Installation

In your tsconfig you have to adjust following settings: jsx and jsxImportSource

{
  "compilerOptions": {
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "target": "ES2020",
    "moduleResolution": "node",

    "jsx": "react-jsx",
    "jsxImportSource": "@deepkit/template"
  }
}

Jetzt können Sie TSX direkt in Ihrem Controller verwenden.

#!/usr/bin/env ts-node-script
import { App } from '@deepkit/app';
import { FrameworkModule } from '@deepkit/framework';
import { http } from '@deepkit/http';

@http.controller('my-base-url/')
class MyPage {
    @http.GET('hello-world')
    helloWorld() {
        return <div style="color: red">Hello World</div>;
    }
}

new App({
    controllers: [MyPage],
    imports: [
        new FrameworkModule({
            debug: true,
        })
    ]
}).run();

Wenn Sie eine solches TSX in Ihrer Routenmethode zurückgeben, wird der HTTP-Inhaltstyp automatisch auf text/html; charset=utf-8 gesetzt.

11.2. Components

Sie können Ihre Templates so strukturieren, wie Sie es von React gewohnt sind. Entweder modularisieren Sie Ihr Layout in mehrere Funktions- oder Klassenkomponenten.

11.2.1. Function Components

Am einfachsten ist es, eine Funktion zu verwenden, die TSX zurückgibt.

async function Website(props: {title: string, children?: any}) {
    return <html>
        <head>
            <title>{props.title}</title>
        </head>
        <body>
            {props.children}
        </body>
    </html>;
}

class MyPage {
    @http.GET('hello-world')
    helloWorld() {
        return <Website title="Hello world">
            <h1>Great page</h1>
        </Website>;
    }
}
$ curl http://localhost:8080/hello-world
<html><head><title>Hello world</title></head><body><h1>Great page</h1></body></html>

Funktionskomponenten können (anders als in React) asynchron sein. Dies ist ein wichtiger Unterschied zu anderen Template-Engines, die Sie vielleicht kennen, wie React.

Alle Funktionen haben Zugriff auf den Dependency Injection Container und können ab dem dritten Parameter beliebige Dependencies referenzieren.

class Database {
    users: any[] = [{ username: 'Peter' }];
}

function UserList(props: {}, children: any, database: Database) {
    return <div>{database.users.length}</div>;
}

class MyPage {
    @http.GET('list')
    list() {
        return <UserList/>
    }
}

new App({
    controllers: [MyPage],
    providers: [Database],
    imports: [new FrameworkModule()]
}).run();

11.2.2. Class Components

Eine alternative Art, eine Komponente zu schreiben, ist eine Klassenkomponente. Sie werden im Dependency Injection Container behandelt und instanziiert und haben somit Zugriff auf alle im Container registrierten Dienste. Dadurch ist es möglich, in Ihren Komponenten beispielsweise direkt auf eine Datenquelle wie eine Datenbank zuzugreifen.

class UserList {
    constructor(
        protected props: {},
        protected children: any,
        protected database: SQLiteDatabase) {
    }

    async render() {
        const users = await this.database.query(User).find();

        return <div class="users">
            {users.map((user) => <UserDetail user={user}/>)}
        </div>;
    }
}

class MyPage {
    @http.GET('')
    listUsers() {
        return <UserList/>;
    }
}

Für Klassenkomponenten sind die ersten Konstruktorargumente reserviert. props kann beliebig definiert werden, children ist immer "any", und dann folgen optionale Abhängigkeiten, die Sie beliebig wählen können. Da Klassenkomponenten im Dependency Injection Container instanziiert werden, haben Sie Zugriff auf alle Ihre Services.

11.3. Dynamic HTML

Die Template-Engine hat alle verwendeten Variablen automatisch bereinigt, sodass Sie Benutzereingaben sicher direkt in der Vorlage verwenden können. Um dynamisches HTML zu rendern, können Sie die Funktion html verwenden.

import { html } from '@deepkit/template';
helloWorld() {
    const yes = "<b>yes!</b>";
    return <div style="color: red">Hello World. {html(yes)}</div>;
}

11.4. Optimization

Die Template-Engine versucht, den generierten JSX-Code so zu optimieren, dass es für NodeJS/V8 viel einfacher ist, den HTML-String zu generieren. Damit dies korrekt funktioniert, sollten Sie alle Ihre Komponenten aus der Hauptdatei app.tsx in separate Dateien verschieben. Eine Struktur könnte so aussehen:

.
├── app.ts
└── views
    ├── user-detail.tsx
    ├── user-list.tsx
    └── website.tsx

12. Framework

12.1. Installation

Deepkit Framework basiert auf Runtime Types in Deepkit Type. Stelle sicher, dass @deepkit/type korrekt installiert ist. Siehe dazu Runtime Type Installation.

npm install ts-node @deepkit/framework

Stellen Sie sicher, dass alle Peer-Abhängigkeiten installiert sind. Standardmäßig werden sie von NPM 7+ automatisch installiert.

Um Ihre Anwendung zu kompilieren, benötigen wir den TypeScript-Compiler und empfehlen ts-node, um die App einfach auszuführen.

Eine Alternative zur Verwendung von ts-node besteht darin, den Quellcode mit dem TypeScript-Compiler zu kompilieren und den JavaScript-Quellcode direkt auszuführen. Dies hat den Vorteil, dass sich die Ausführungsgeschwindigkeit für kurze Befehle drastisch erhöht. Allerdings wird dadurch auch zusätzlicher Workflow-Overhead erzeugt, indem der Compiler entweder manuell ausgeführt oder ein Watcher eingerichtet wird. Aus diesem Grund wird in dieser Dokumentation in allen Beispielen ts-node verwendet.

12.2. Erste Applikation

Da das Deepkit Framework keine Konfigurationsdateien oder eine spezielle Ordnerstruktur verwendet, können Sie Ihr Projekt so strukturieren, wie Sie es wünschen. Die einzigen beiden Dateien, die Sie für den Start benötigen, sind die TypeScript-Datei app.ts und die TypeScript-Konfiguration tsconfig.json.

Unser Ziel ist es, die folgenden Dateien in unserem Projektordner zu haben:

.
├── app.ts
├── node_modules
├── package-lock.json
└── tsconfig.json

Datei: tsconfig.json

{
  "compilerOptions": {
    "outDir": "./dist",
    "experimentalDecorators": true,
    "strict": true,
    "esModuleInterop": true,
    "target": "ES2020",
    "module": "CommonJS",
    "moduleResolution": "node"
  },
  "reflection": true,
  "files": [
    "app.ts"
  ]
}

Datei: app.ts

#!/usr/bin/env ts-node-script
import { App } from '@deepkit/app';
import { Logger } from '@deepkit/logger';
import { cli, Command } from '@deepkit/app';
import { FrameworkModule } from '@deepkit/framework';

@cli.controller('test')
export class TestCommand implements Command {
    constructor(protected logger: Logger) {
    }

    async execute() {
        this.logger.log('Hello World!');
    }
}

new App({
    controllers: [TestCommand],
    imports: [new FrameworkModule]
}).run();

In diesem Code sehen Sie, dass wir einen Testbefehl über die Klasse TestCommand definiert und eine neue Anwendung erstellt haben, die wir direkt mit run() ausführen. Durch das Ausführen dieses Skripts starten wir die App.

Mit dem Shebang in der ersten Zeile (#!…​) können wir unser Skript mit dem folgenden Befehl ausführbar machen.

chmod +x app.ts

Und dann ausführen:

$ ./app.ts
VERSION
  Node

USAGE
  $ ts-node-script app.ts [COMMAND]

TOPICS
  debug
  migration  Executes pending migration files. Use migration:pending to see which are pending.
  server     Starts the HTTP server

COMMANDS
  test

Um nun unseren Testbefehl auszuführen, führen wir folgenden Befehl aus.

$ ./app.ts test
Hello World

In Deepkit Framework geschieht nun alles über diese app.ts. Sie können die Datei beliebig umbennen oder weitere anlegen. Eigene CLI commands, HTTP/RPC server, Migration commands, usw werden alle über diesen Einstiegspunkt gestartet.

Um den HTTP/RPC-Server zu starten, führen Sie folgendes aus:

./app.ts server:start

Um Anfragen bedienen zu können, lesen Sie bitte das Kapitel HTTP oder RPC. Im Kapitel CLI kann mehr über CLI commands erfahren werden.

12.3. App

Über das App-Objekt startet wie Applikation.

Die run()-Methode list dabei die Argumente aus und führt den entsprechenden CLI-Controller aus. Da FrameworkModule eigene CLI-Controller bereitstellt, die zum Beispiel für das Starten des HTTP-Servers verantwortlich sind, können diese darüber aufgerufen werden.

Über das App-Objekt kann auch der Dependency Injection Container angesprochen werden, ohne dass ein CLI-Controller ausgeführt wird.

const app = new App({
    controllers: [TestCommand],
    imports: [new FrameworkModule]
});

//get access to all registered services
const eventDispatcher = app.get(EventDispatcher);

//then run the app, or do something else
app.run();

12.4. Module

Deepkit Framework ist hochgradig modular und ermöglicht es Ihnen, Ihre Anwendung in mehrere praktische Module aufzuteilen. Jedes Modul hat seine eigene Dependency Injektion Sub-Container, Konfiguration, Befehle und vieles mehr. Im Kapitel "Erste Applikation" haben Sie bereits ein Modul erstellt - das Root-Modul. new App benötigt fast die gleichen Argumente wie ein Modul, denn es erstellt das Root-Modul im Hintergrund für Sie automatisch.

Sie können dieses Kapitel überspringen, wenn Sie nicht vorhaben, Ihre Anwendung in Untermodule aufzuteilen, oder wenn Sie nicht vorhaben, ein Modul als Paket für andere zur Verfügung zu stellen.

Ein Modul ist eine einfache Klasse:

import { createModule } from '@deepkit/app';

export class MyModule extends createModule({}) {
}

Es hat zu diesem Zeitpunkt im Grunde keine Funktionalität, da seine Moduldefinition ein leeres Objekt ist und es keine Methoden hat, aber dies demonstriert die Beziehung zwischen Modulen und Ihrer Anwendung (Ihrem Stammmodul). Dieses Modul MyModule kann dann in Ihrer Anwendung oder in anderen Modulen importiert werden.

import { MyModule } from './module.ts'

new App({
    imports: [
        new MyModule(),
    ]
}).run();

Sie können nun diesem Modul Features hinzufügen, wie Sie es mit App tun würden. Die Argumente sind die gleichen, nur dass Importe in einer Moduldefinition nicht verfügbar sind. Fügen Sie HTTP/RPC/CLI-Controller, Dienste, eine Konfiguration, Event-Listener sowie verschiedene Modul-Hooks hinzu, um Module dynamischer zu gestalten.

12.4.1. Controllers

Module können Controller definieren, die von anderen Modulen verarbeitet werden. Wenn Sie zum Beispiel einen Controller mit Dekoratoren aus dem @deepkit/http-Paket hinzufügen, wird sein Modul HttpModule dies aufgreifen und die gefundenen Routen in seinem Router registrieren. Ein einzelner Controller kann mehrere solcher Dekoratoren enthalten. Es liegt an dem Modulautor, der Ihnen diese Dekoratoren gibt, wie er die Controller verarbeitet.

In Deepkit gibt es drei Pakete, die solche Controller verarbeitet: HTTP, RPC, und CLI. Siehe jeweils deren Kapitel, um mehr zu erfahren. Nachfolgend ist ein Beispiel eines HTTP-Controllers:

import { createModule } from '@deepkit/app';
import { http } from '@deepkit/http';
import { injectable } from '@deepkit/injector';

class MyHttpController {
    @http.GET('/hello)
    hello() {
        return 'Hello world!';
    }
}

export class MyModule extends createModule({
    controllers: [MyHttpController]
}) {}

//same is possible for App
new App({
    controllers: [MyHttpController]
}).run();

12.4.2. Provider

Wenn Sie einen Provider im providers-Bereich Ihrer Anwendung definieren, ist dieser in Ihrer gesamten Anwendung zugänglich. Bei Modulen hingegen werden diese Provider automatisch in den Subcontainer für die Injektion von Abhängigkeiten dieses Moduls gekapselt. Sie müssen jeden Provider manuell exportieren, um ihn für ein anderes Modul bzw. ihrer Anwendung verfügbar zu machen.

Um mehr darüber zu erfahren, wie Provider funktionieren, lesen Sie bitte das Kapitel Dependency Injection.

import { createModule } from '@deepkit/app';
import { http } from '@deepkit/http';
import { injectable } from '@deepkit/injector';

export class HelloWorldService {
    helloWorld() {
        return 'Hello there!';
    }
}

class MyHttpController {
    constructor(private helloService: HelloWorldService) {}

    @http.GET('/hello)
    hello() {
        return this.helloService.helloWorld();
    }
}

export class MyModule extends createModule({
    controllers: [MyHttpController],
    providers: [HelloWorldService],
}) {}

//same is possible for App
new App({
    controllers: [MyHttpController],
    providers: [HelloWorldService],
}).run();

Wenn ein Benutzer dieses Modul importiert, hat er keinen Zugriff auf HelloWorldService, da dieser im Subdependency-Injection-Container von MyModule gekapselt ist.

12.4.3. Exports

Um Provider im Modul des Importeurs verfügbar zu machen, können Sie den Token des Providers in exports aufnehmen. Dadurch wird der Provider im Wesentlichen eine Ebene nach oben in den Dependency-Injection-Container des übergeordneten Moduls - des Importeurs - verschoben.

import { createModule } from '@deepkit/app';

export class MyModule extends createModule({
    controllers: [MyHttpController]
    providers: [HelloWorldService],
    exports: [HelloWorldService],
}) {}

Wenn Sie andere Provider wie FactoryProvider, UseClassProvider usw. haben, sollten Sie trotzdem nur den Klassentyp in den Exporten verwenden.

import { createModule } from '@deepkit/app';

export class MyModule extends createModule({
    controllers: [MyHttpController]
    providers: [
        {provide: HelloWorldService, useValue: new HelloWorldService}
    ],
    exports: [HelloWorldService],
}) {}

We can now import that module and use its exported service in our application code.

#!/usr/bin/env ts-node-script
import { App } from '@deepkit/app';
import { cli, Command } from '@deepkit/app';
import { HelloWorldService, MyModule } from './my-module';

@cli.controller('test')
export class TestCommand implements Command {
    constructor(protected helloWorld: HelloWorldService) {
    }

    async execute() {
        this.helloWorld.helloWorld();
    }
}

new App({
    controllers: [TestCommand],
    imports: [
        new MyModule(),
    ]
}).run();

Lesen Sie das Kapitel Dependency Injection um mehr darüber zu erfahren.

12.5. Konfiguration

Im Deepkit Framework können Module und Ihre Anwendung über Konfigurationsoptionen verfügen. Eine Konfiguration kann z.B. aus Datenbank-URLs, Passwörtern, IPs usw. bestehen. Services, HTTP/RPC/CLI Controller sowie Template Funktionen können diese Konfigurationsoptionen über Dependency Injection auslesen.

Eine Konfiguration kann durch die Definition einer Klasse mit Eigenschaften definiert werden. Dies ist ein typsicherer Weg, um eine Konfiguration für Ihre gesamte Anwendung zu definieren, und ihre Werte werden automatisch serialisiert und validiert.

12.5.1. Beispiel

import { MinLength } from '@deepkit/type';
import { App } from '@deepkit/app';
import { FrameworkModule } from '@deepkit/framework';
import { http } from '@deepkit/http';

class Config {
    pageTitle: string & MinLength<2> = 'Cool site';
    domain: string = 'example.com';
    debug: boolean = false;
}

class MyWebsite {
    constructor(protected allSettings: Config) {
    }

    @http.GET()
    helloWorld() {
        return 'Hello from ' + this.allSettings.pageTitle + ' via ' + this.allSettings.domain;
    }
}

new App({
    config: Config,
    controllers: [MyWebsite],
    imports: [new FrameworkModule]
}).run();
$ curl http://localhost:8080/
Hello from Cool site via example.com

12.5.2. Konfigurationsklasse

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

export class Config {
    title!: string & MinLength<2>; //this makes it required and needs to be provided
    host?: string;

    debug: boolean = false; //default values are supported as well
}
import { createModule } from '@deepkit/app';
import { Config } from './module.config.ts';

export class MyModule extends createModule({
   config: Config
}) {}

Die Werte für die Konfigurationsoptionen können entweder im Konstruktor des Moduls, mit der Methode .configure() oder über Konfigurationslader (z.B. Umgebungsvariablenlader) bereitgestellt werden.

import { MyModule } from './module.ts';

new App({
   imports: [new MyModule({title: 'Hello World'}],
}).run();

Um die Konfigurationsoptionen eines importierten Moduls dynamisch zu ändern, können Sie den process Hook verwenden. Dies ist ein guter Ort, um entweder Konfigurationsoptionen umzuleiten oder ein importiertes Modul abhängig von der aktuellen Modulkonfiguration oder anderen Modulinstanzinformationen einzurichten.

import { MyModule } from './module.ts';

export class MainModule extends createModule({
}) {
    process() {
        this.getImportedModuleByClass(MyModule).configure({title: 'Changed'});
    }
}

Auf der Anwendungsebene funktioniert es etwas anders:

new App({
    imports: [new MyModule({title: 'Hello World'}],
})
    .setup((module, config) => {
        module.getImportedModuleByClass(MyModule).configure({title: 'Changed'});
    })
    .run();

Wenn das Root-Anwendungsmodul aus einem regulären Modul erstellt wird, funktioniert es ähnlich wie reguläre Module.

class AppModule extends createModule({
}) {
    process() {
        this.getImportedModuleByClass(MyModule).configure({title: 'Changed'});
    }
}

App.fromModule(new AppModule()).run();

12.5.3. Konfigurationsoptionen Auslesen

Um eine Konfigurationsoption in einem Dienst zu verwenden, können Sie die normale Dependency Injection verwenden. Es ist möglich, entweder das gesamte Konfigurationsobjekt, einen einzelnen Wert oder einen Teil der Konfiguration zu injizieren.

Partial

Um nur einen Teilbereich der Konfigurationswerte zu injizieren, verwenden Sie den Typ Pick.

import { Config } from './module.config';

export class MyService {
     constructor(private config: Pick<Config, 'title' | 'host'}) {
     }

     getTitle() {
         return this.config.title;
     }
}


//In unit tests, it can be instantiated via
new MyService({title: 'Hello', host: '0.0.0.0'});

//or you can use type aliases
type MyServiceConfig = Pick<Config, 'title' | 'host'};
export class MyService {
     constructor(private config: MyServiceConfig) {
     }
}
Single value

Um nur einen einzigen Wert zu injizieren, verwenden Sie den Indexzugriffsoperator.

import { Config } from './module.config';

export class MyService {
     constructor(private title: Config['title']) {
     }

     getTitle() {
         return this.title;
     }
}
All

To inject all config values, use the class as dependency.

import { Config } from './module.config';

export class MyService {
     constructor(private config: Config) {
     }

     getTitle() {
         return this.config.title;
     }
}

12.5.4. Debugger

Die Konfigurationswerte Ihrer Anwendung und aller Module können im Debugger angezeigt werden. Aktivieren Sie die Debug-Option im FrameworkModul und öffnen Sie http://localhost:8080/_debug/configuration.

import { App } from '@deepkit/app';
import { FrameworkModule } from '@deepkit/framework';

new App({
    config: Config,
    controllers: [MyWebsite],
    imports: [
        new FrameworkModule({
            debug: true,
        })
    ]
}).run();