这偏文章介绍一个为 JavaScript 设计的 RPC 库: cpcall。 相关地址: github npm
背景
一些场景
首先说几个场景,以及他们存在什么问题。
不同进程之间的通信
以 pm2 为例,当我们执行命令 pm2 start xxx.js时,实际上是运行了 cli 进程,然后 cli 进程通知 daemon 进程去 fork("xxx.js")(得到一个 xxx.js 的进程,这个进程是 daemon 的子进程),最后 cli 进程结束运行。
那这里就涉及到了一些逻辑:
- cli 通知 daemon 去 fork
- daemon 将结果告诉 cli
- 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 这个库。但是我觉得它存在下列问题:
- 发布订阅模式,不易推断参数类型和返回值类型,对 TS 不友好
- 处理返回值使用 callback, 可读性差。
- 局限于 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 分别代表两个不同的进程
cpcall
功能
- 远程调用可操作远程代理对象,与原生 JavaScript 调用语法几乎无差别
- 可使用 ECMA 装饰器定义服务。 查看装饰器的使用
- 与协议无关,可用于基于 TCP、IPC、WebSocket 等
- 双端远程调用
- 数据传输默认采用 JBOD 二进制编码。相比 JSON,有如下优势:
- 更多的数据类型。如 bigint、Set、Map、RegExp、Error、UInt8Array 等(查看支持的数据类型),这意味着在调用远程方法时,你可以直接传递这些参数,而无需进行转换
- 更小的数据大小。对于常见场景,编码后大小 大概是 JSON 的 70%,
- 无需定义数据结构,非常适合动态类型语言
与一些框架的区别
与 tRpc、gRpc、socket.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 并没有实现,示例我也没有时间写了,但是是可行的,不过只是单向调用。
最后
最后说一句,这个库是我写的, 大家感兴趣的化可以当做玩具试一下。有什么想法欢迎提出