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.

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

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

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

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.

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

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.

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

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
    }
}

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

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');
    }
}

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;
    }
}

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.

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.

Streaming RxJS

TODO

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.

HTTP

TODO: Not implemented yet.

WebSockets

@deepkit/rpc-tcp RpcWebSocketServer and Browser WebSocket or Node ws package.

TCP

@deepkit/rpc-tcp RpcNetTcpServer and RpcNetTcpClientAdapter

Peer To Peer

TODO