JavaScript RPC风格服务调用简易实现

116 阅读3分钟

新人前端,有不足之处请多多指教。

远程过程调用,英文全称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()

接下来要完成的工作就是实现createRPCClientcreateRPC().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)

参考资料