JS RPC 实现: 客户端-服务端双向远程调用

424 阅读4分钟

这偏文章介绍一个为 JavaScript 设计的 RPC 库: cpcall。 相关地址: github npm

背景

一些场景

首先说几个场景,以及他们存在什么问题。

不同进程之间的通信

以 pm2 为例,当我们执行命令 pm2 start xxx.js时,实际上是运行了 cli 进程,然后 cli 进程通知 daemon 进程去 fork("xxx.js")(得到一个 xxx.js 的进程,这个进程是 daemon 的子进程),最后 cli 进程结束运行。

那这里就涉及到了一些逻辑:

  1. cli 通知 daemon 去 fork
  2. daemon 将结果告诉 cli
  3. cli 输出结果,然后 cli 进程结束运行
父子进程通信

父进程可能需要主动向子进程执行某些方法,同时子进程也可能需要调用父进程的某些方法。

还是以 pm2 为例, daemon 进程需要显示 xxxx.js 进程的内存占用,需要在子进程调用 process.memoryUsage()方法,然后将内存信息发给 daemon

实现这个可以在子进程每隔一段时间调用一次process.memoryUsage(),然后通过 process.send() 发给 daemon 进程。但这会面临性能问题。因为我们只有在某些时候需要查看内存而已。
另外一个办法是在 只有当 daemon 进程发送指令过来时,xxxx.js 进程才会调用 process.memoryUsage()然后发回给父进程。

看完这个例子,应该能看出了一些问题:

  • 发布订阅模式,没有好的类型提示,对 TS 不友好
  • 涉及到返回值,处理过程变得复杂
  • 随着消息类型增加,代码会变得越来越复杂,可读性也会变得越来越差。
前后端的通信

传统 http 请求已经能达到了前端主动向后端执行某些操作,但后端不能主动向前端执行操作。可通过 WebSocket 解决这个问题。 但纯使用 WebSocket 与 上述原生 nodejs 进程通信类型存在着相同问题。当然可以用 socket.io 这个库。但是我觉得它存在下列问题:

  1. 发布订阅模式,不易推断参数类型和返回值类型,对 TS 不友好
  2. 处理返回值使用 callback, 可读性差。
  3. 局限于 WebSocket 协议

什么是 RPC

RPC(Remote Procedure Call)远程过程调用。一个通俗的描述是:客户端可以直接调用远程计算机上的对象方法,并得到返回值,就像调用本地应用程序中的对象一样。

cpcall 基于 TCP 的远程调用 示例

client.ts

import { connect, Socket } from "node:net";
import { createSocketCpc } from "cpcall";

const socket = connect(8888);
socket.on("connect", async () => {
  const cpc = createSocketCpc(socket);
  const remote = cpc.genCaller<typeof globalThis>();
  await remote.console.log("ha ha");
  await cpc.close();
});

这里直接暴露了 globalThis server.ts

import net from "node:net";
import { createSocketCpc } from "cpcall";
const server = new net.Server(async function (socket) {
  const cpc = createSocketCpc(socket);
  cpc.exposeObject(globalThis);
  cpc.onClose.catch(console.error);
});
server.listen(8888);

RPC 流程

下图为从 ProtX 调用远程端 PortY 的 PortYService.methodD() 方法的流程,ProtX 和 PortY 分别代表两个不同的进程

rpc_flowsheet.png

cpcall

功能

  • 远程调用可操作远程代理对象,与原生 JavaScript 调用语法几乎无差别
  • 可使用 ECMA 装饰器定义服务。 查看装饰器的使用
  • 与协议无关,可用于基于 TCP、IPC、WebSocket 等
  • 双端远程调用
  • 数据传输默认采用 JBOD 二进制编码。相比 JSON,有如下优势:
    • 更多的数据类型。如 bigint、Set、Map、RegExp、Error、UInt8Array 等(查看支持的数据类型),这意味着在调用远程方法时,你可以直接传递这些参数,而无需进行转换
    • 更小的数据大小。对于常见场景,编码后大小 大概是 JSON 的 70%,
  • 无需定义数据结构,非常适合动态类型语言

与一些框架的区别

tRpcgRpcsocket.io 有什么区别?
gRpc, 基于 http2 协议。 不能进行双向调用,另外他需要定义数据结构,对动态类型语言不友好。它用的 ProtoBuf,性能优势在 JavaScript 上无法发挥。
tRpc, 基于 http,不能进行双向调用。虽然支持 webSocket,但是,那些写法确实复杂....
socket.io ,上面已经介绍过。

一些示例

基于 WebSocket 的RPC示例

这是一个基于 WebSocket 的示例。服务端运行在 Node,客户端运行在浏览器

server.js

import { WebSocketServer } from "npm:ws";
import http from "node:http";
import { createWebSocketCpcOnOpen } from "cpcall";
const server = new http.Server();
const wsServer = new WebSocketServer({ server });
wsServer.on("connection", async (ws) => {
  const cpc = await createWebSocketCpcOnOpen(ws);
  cpc.exposeObject(globalThis);
  cpc.onClose.catch(console.error);
});
server.listen(8887);
}

client.js

import { createWebSocketCpcOnOpen } from "https://esm.sh/cpcall";

const ws = new WebSocket("ws://127.0.0.1:8887");
const cpc = await createWebSocketCpcOnOpen(ws);
cpc.exposeObject(globalThis);
const remote = cpc.genCaller();
await remote.console.log("ha ha");

await cpc.close();

cpcall vs socket.io

这是 Socket.io 官网的一个示例. 查看示例

socket.io

server

socket.on("update item", (arg1, arg2, callback) => {
  console.log(arg1); // 1
  console.log(arg2); // { name: "updated" }
  callback({ status: "ok" });
});

client

socket.emit("update item", "1", { name: "updated" }, (response) => {
  console.log(response.status); // ok
});

cpcall

server

cpc.exposeObject({
  updateItem: (arg1, arg2) => {
    console.log(arg1); // 1
    console.log(arg2); // { name: "updated" }
    return { status: "ok" };
  },
});

client

//caller 是远程代理对象,由 cpc.genCaller() 生成
const res = await caller.updateItem("1", { name: "updated" });
console.log(res);

基于 http,单向调用

这个在 cpcall 并没有实现,示例我也没有时间写了,但是是可行的,不过只是单向调用。

最后

最后说一句,这个库是我写的, 大家感兴趣的化可以当做玩具试一下。有什么想法欢迎提出