新人前端,有不足之处请多多指教。
远程过程调用,英文全称Remote Procedure Call(RPC),是指一种像调用本地服务一样调用远程服务的协议。需要注意的是,RPC规则本身不包含具体的传输协议,只是一种协议规范。
本DEMO基于JavaScript实现了一个简易的RPC风格的服务调用。在本DEMO中,client、server并不代指具体的客户端、服务端,而是指调用方和被调用方。
实现思路
基本使用方式如下:
// client.ts
const client = createRPCClient();
client.math.add(1, 2).then((result: any) => {
console.log(result); // 3
})
// server.ts
const services = {
math: {
add: (a: number, b: number) => {
return a + b
}
}
}
createRPC()
.addServices(services)
.start()
接下来要完成的工作就是实现createRPCClient和createRPC().addServices方法。
createRPCClient的的工作是要将client.math.add(1, 2)转换为一个rpc.call('math.add', [1, 2])的调用,在这里可以使用Proxy来实现,代码如下:
// client.ts
const rpc: any = {}
function createRPCClient() {
const rpcProxy = (paths: string[]) => {
return new Proxy(new Function(), {
get: (_target: any, prop: string) => {
return rpcProxy([...paths, prop])
},
apply: (_target: any, _thisArg: any, args: any) => {
return rpc.CALL(paths.join('.'), args)
}
})
}
return new Proxy(
{},
{
get: (_target: any, prop: string) => {
return rpcProxy([prop])
}
}
)
}
在函数createRPCClient中,我们通过对Proxy的使用,实现了一个递归的代理对象,当调用client.math.add(1, 2)时,会生成一个路径数组['math', 'add'],然后调用rpc.CALL('math.add', [1, 2])。
{% note info %} rpc.CALL方法是一个虚拟方法,在实际使用中需要根据具体的传输协议实现,例如fetch、websocket、axios,也可以是electron中的进程间通信,例如ipcRenderer.invoke。 {% endnote %}
接下来我们实现createRPC().addServices方法,这个方法的的实现比较简单,只需根据路径查找到对应的服务方法并调用即可。
// server.ts
const server: any = {}
function createRPC() {
return {
addServices: (services: any) => {
server.on((path: string, args: any[]) => {
const service = lodashGet(services, path)
if (service) {
return service(...args)
} else {
throw new Error(`Service not found: ${path}`)
}
})
return {
start: () => {
console.log('Server started')
}
}
},
}
}
类型定义
通过这种方式实现的RPC调用,在client端调用时,IDE无法提供代码提示,这是因为我们没有定义client端的类型。为了解决这个问题,我们可以通过TypeScript的类型定义文件来实现。
// rpc.d.ts
declare interface Service {
math: {
add: (a: number, b: number) => Promise<number>;
};
}
declare function createRPCClient(): Service;
该服务类型文件通常由服务方提供类型定义说明,客户端通过自动化工具生成客户端类型定义,这样可以保证客户端和服务端的接口一致性。
{% note info %} 服务方类型定义说明实践中通常为Swagger、OpenAPI等接口文档,或者RPC协议本身提供的IDL文件,例如Thrift、gRPC等。 {% endnote %}
如果调用方与服务方处于同一项目中,我们或许可以直接通过类型推导来实现IDE的代码提示。这部分可以参考electron-prisma-todos主进程和渲染进程的实现方式。类型推导示例代码如下:
// server.ts
export type Services = typeof services
// rpc.d.ts
import type { Services } from './server'
type IfPromise<T> = T extends Promise<infer R> ? Promise<R> : Promise<T>
type Promisify<T> = T extends (...args: infer K) => infer R
? (...args: K) => IfPromise<R>
: T extends object
? { [K in keyof T]: Promisify<T[K]> }
: T
declare function createRPCClient(): Promisify<Services>
其他功能
根据不同场景,我们可以在RPC实现中添加一些其他功能,例如:
- 错误处理
- 参数校验
- 超时处理
- 重试机制
- 日志记录
- 调用链追踪
题外话
目前有一种说法认为在RPC调用形式上应该与本地调用形式应当有明显区别,其目的是为了让调用方清晰地知道这是一个远程调用,需要调用方去处理可能存在的问题,例如网络延迟、服务不可用等。其使用区别主要在使用RPC.call(服务名称、参数)代替直接调用服务方法。
// 两种写法
RPC.call('math.add', [1, 2])
RPC.math.add(1, 2)