RPC
RPC是指远程过程调用,允许在远程服务器上调用函数(过程),就像调用本地函数一样。与HTTP客户-服务器通信不同的是,分配不是通过HTTP方法和URL进行的,而是通过函数名称进行的。要发送的数据作为正常的函数参数被传递,服务器上的函数调用结果被发回给客户端。
RPC的优点是客户端-服务器的抽象更轻,因为没有使用头文件、URL、查询字符串或类似的东西。其缺点是,服务器上通过RPC的功能不能轻易被浏览器调用,往往需要一个特殊的客户端。
RPC的一个关键特征是,客户端和服务器之间的数据被自动序列化和反序列化。由于这个原因,类型安全的RPC客户端通常是可能的。因此,一些RPC框架强迫用户以特定的格式提供类型(参数类型和返回类型)。这可以是自定义DSL的形式,如gRPC(协议缓冲区)和GraphQL的代码生成器,也可以是JavaScript模式生成器的形式。数据的额外验证也可以由RPC框架提供,但不是所有的框架都支持。
在 Deepkit RPC 中,函数的类型是从 TypeScript 代码本身中提取的(见 Runtime Types),因此不需要使用代码生成器或手动定义它们。Deepkit支持参数和结果的自动序列化和反序列化。只要从Validation中定义了额外的约束,这些约束也会被自动验证。这使得通过RPC进行的通信极为类型安全和有效。Deepkit RPC中通过`rxjs`对流媒体的支持,也使这个RPC框架成为实时通信的合适工具。
为了说明RPC背后的概念,下面的代码。
//server.ts
class Controller {
hello(title: string): string {
return 'Hello ' + title
}
}
像 "hello "这样的方法通常在服务器上的一个类中实现,然后可以从远程客户端调用。
//client.ts
const client = new RpcClient('localhost');
const controller = client.controller<Controller>();
const result = await controller.hello('World'); // => 'Hello World';
由于RPC从根本上是基于异步通信的,所以通信主要是通过HTTP,但也可以通过TCP或WebSockets发生。这意味着TypeScript本身的所有函数调用都被转换为一个`承诺'。有了相应的`await',可以异步接收结果。
Isomorphic TypeScript
只要一个项目在客户端(通常是前端)和服务器(后端)使用TypeScript,它就被称为Isomorphic TypeScript。那么,基于TypeScript的类型安全的RPC框架对这样的项目来说是特别有利的,因为类型可以在客户端和服务器之间共享。
为了利用这一点,在两边都使用的类型应该被换成一个单独的文件或包。在各自的页面上进行导入,然后再次合并它们。
//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
接口`UserControllerApi`在这里充当了客户端和服务器之间的契约。服务器必须正确地实现这一点,客户端可以消费它。
向后兼容可以用与普通本地API相同的方式实现:要么将新的参数标记为可选,要么添加一个新的方法。
虽然也可以通过`import type { UserController } from './server.ts’直接导入`UserController',但这有其他缺点,比如不支持名义类型(这意味着类的实例不能用`instanceof’检查)。
Installation
由于Deepkit RPC是基于运行时类型的,因此必须正确安装`@deepkit/type`。参见运行时类型安装。
如果这样做成功了,就可以安装`@deepkit/rpc`或Deepkit框架,该框架已经在引擎盖下使用该库。
npm install @deepkit/rpc
请注意,`@deepkit/rpc`中的控制器类是基于TypeScript装饰器的,这个功能必须通过`experimentalDecorators`相应启用。
如果服务器和客户端都有自己的`package.json`,则必须安装`@deepkit/rpc`包。
要通过TCP与服务器通信,必须在客户端和服务器上安装`@deepkit/rpc-tcp`包。
对于WebSocket通信,它还需要服务器上的包。而浏览器中的客户端则使用官方标准中的 "WebSocket"。
npm install @deepkit/rpc-tcp
一旦客户端要通过WebSocket在没有`WebSocket`的环境中使用(例如NodeJS),客户端就需要`ws`包。
npm install ws
使用
下面是一个基于WebSockets和`@deepkit/rpc`的低级API的全功能例子。一旦使用Deepkit框架,控制器就会通过应用模块提供,而不需要手动实例化RpcKernel。
文件: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();
档。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);
服务器控制器
远程过程调用中的 "过程 "也被称为行动。这样的动作被定义为一个类中的方法,并且用`@rpc.action`装饰器来标记。该类本身被`@rpc.controller`装饰器标记为控制器,并分配了一个唯一的名字。然后在客户中引用这个名字,以解决正确的控制器。任何数量的控制器都可以被定义和注册。
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;
}
}
只有那些同时被标记为"@rpc.action() "的方法才能被客户端处理。
类型必须是明确指定的,不能推断。这一点很重要,因为序列化器需要确切地知道类型是什么,以便将它们转换成二进制数据(BSON)或JSON。
依赖注入
控制器类由`@deepkit/injector`的依赖注入容器管理。如果使用Deepkit框架,这些控制器会自动访问提供控制器的模块的提供者。
在Deepkit框架中,控制器被实例化在依赖性注入作用域`rpc`中,因此所有的控制器都能自动访问这个作用域中的各种提供者。这些额外的提供者是`HttpRequest`(可选),RpcInjectorContext
,SessionState
,RpcKernelConnection
,和`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();
然而,只要手动实例化一个`RpcKernel',DI容器也可以被传递到那里。然后,RPC控制器通过这个DI容器被实例化。
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);
参见依赖注入以了解更多。
名义类型
当客户端收到来自函数调用的数据时,它先前在服务器上被序列化,然后在客户端被反序列化。如果现在在函数的返回类型中使用了类,它们会在客户端被重构,但会失去它们的名义身份和所有方法。为了抵制这种行为,类可以通过一个唯一的ID注册为名义类型。对于所有在RPC API中使用的类都应该这样做。
要注册一个类,必须使用装饰器`@entity.name('id')`。
import { entity } from '@deepkit/type';
@entity.name('user')
class User {
id!: number;
firstName!: string;
lastName!: string;
get fullName() {
return this.firstName + ' ' + this.lastName;
}
}
只要这个类现在被用作一个函数的结果,它的身份就被保留了。
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
错误转发
RPC函数可以抛出错误。默认情况下,这些错误被转发到客户端并在那里再次抛出。如果使用了自定义错误类,其名义类型应被启用。参见RPC名义类型。
@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
}
}
安全
默认情况下,所有RPC函数都可以从任何客户端调用。点对点通信功能在默认情况下也被激活。为了能够准确地设置哪个客户被允许做什么,`RpcKernelSecurity’类可以被覆盖。
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;
}
}
要使用这个,要么把它的一个实例传给`RpcKernel`。
const kernel = new RpcKernel(undefined, new MyKernelSecurity);
或者在Deepkit Framework应用程序的情况下,`RpcKernelSecurity’类被覆盖了一个提供者。
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();
认证/会话
对象`session`默认是一个匿名会话,这意味着客户端没有认证自己。一旦它想进行认证,就会调用`authenticate`方法。 `authenticate`方法收到的令牌来自客户端,可以有任何值。
一旦客户端设置了一个令牌,只要调用第一个RPC函数或手动`client.connect()`,就会进行认证。
const client = new RpcWebSocketClient('localhost:8081');
client.token.set('123456789');
const controller = client.controller<Controller>('myController');
这里`RpcKernelSecurity.authenticate`接收到令牌`123456789`并可以相应地返回另一个会话。这个返回的会话将被传递给所有其他的方法,如`hasControllerAccess`。
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');
}
}
控制器访问
`hasControllerAccess`方法可用于确定客户端是否被允许执行特定的RPC功能。这个方法在每次RPC函数调用时都会执行。如果它返回 "false",则拒绝访问,并向客户抛出一个错误。
在`RpcControllerAccess`中,有几条关于RPC函数的有价值的信息。
interface RpcControllerAccess {
controllerName: string;
controllerClassType: ClassType;
actionName: string;
actionGroups: string[];
actionData: { [name: string]: any };
}
组和其他数据可以通过装饰器`@rpc.action()`来改变。
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;
}
}
变形误差
由于抛出的错误会自动转发到客户端,并附上所有信息,如错误信息和堆栈跟踪,这可能会不必要地公布敏感信息。要改变这一点,可以在`transformError`方法中修改抛出的错误。
class MyKernelSecurity extends RpcKernelSecurity {
transformError(error: Error) {
//wrap in new error
return new Error('Something went wrong: ' + error.message);
}
}
请注意,一旦错误被转换为一般的 "错误",完整的堆栈跟踪和错误的身份就会丢失。因此,在客户端的错误上不能使用`instanceof`检查。
如果在两个微服务之间使用Deepkit RPC,并且客户端和服务器因此处于开发者的完全控制之下,那么转变错误就很少有必要。另一方面,如果客户端在一个未知的人的浏览器中运行,那么你应该在`transformError’中非常小心地确定你想披露的信息。如果有疑问,每个错误都应该用一个通用的 "错误 "进行转换,以确保没有内部细节被泄露。在这一点上,记录错误将是一个好主意。
依赖注入
如果直接使用Deepkit RPC库,则要实例化`RpcKernelSecurity`类本身。如果这个类需要一个数据库或一个记录器,这必须被传入本身。
如果使用Deepkit框架,该类由依赖注入容器实例化,从而自动访问应用程序的所有其他提供者。
另见依赖性注入。