在 cnb.cool 的任务集功能区中,我们使用了 bun 作为服务端,负责任务集视图的相关读写能力,积累了一定的经验。整体来说 bun 的写法和 Nodejs 几乎一致,但对于"提供 gRPC 服务"相关的知识,现网所能找到的资料较少,因此专门记录下来。
关于 bun 和 gRPC 的介绍就不在此展开了,感兴趣的同学请自行搜索。
一、初始化
参考官网的方式,首先把 bun 安装到机器上(本文开发环境为 MacOS)。
bash
curl -fsSL https://bun.sh/install | bash
接下来就可以初始化我们的项目并安装 grpc 依赖了。
bash
bun init -y
bun install @grpc/grpc-js @grpc/proto-loader
回头在 package.json
里面加入调试的启动命令:
json
{
...
"scripts": {
"dev": "bun --hot index.ts"
},
...
}
由于 bun 是一个能够直接运行 ts 代码的 runtime,所以也非常推荐直接使用 ts 来写我们的 server 端代码。
回到项目根目录,新建一个 index.ts
,随便写入一句console.log('hello world')
,执行 yarn dev
,便可看到控制台输出了"hello world"字段。修改这里的代码,由于启动时加入了 --hot
的缘故,所以它会实时热更新并运行新的代码,这样就免去每次都要重新手动运行的繁琐步骤了。
二、代码实现
要学习在 bun 中架设 gRPC 服务,首先得要有一份符合要求的 .proto
文件。这里用一个最简单的 Hello World 来举个例子:
proto
syntax = "proto3";
package demo;
message SayHelloRequest {
string name = 1;
}
message SayHelloResponse {
int32 code = 1;
string message = 2;
}
service Hello {
rpc SayHello (SayHelloRequest) returns (SayHelloResponse);
}
可以很直观地看到,我们定义了一个叫做 Hello
的服务,它提供了一个 rpc 调用函数 SayHello()
。接下来我们就要开始学习如何实现这个服务。
回到根目录,按照如下的结构组织代码:
bash
.
├── index.ts
└── src
├── protos
│ └── hello.proto
├── server.ts
└── services
└── sayHello.ts
核心的代码为 src/server.ts
,第一个就要去实现它。
我们的思路如下:
- 一个 gRPC server 就是一个实例:可以通过 new 实例化;
- 它提供了一个方法允许我们添加不同的服务:
addService()
函数,允许传入不同的.proto
文件和对应的实现代码; - 一个启动的命令:
start()
函数,允许传入 host 和 port。
因此它的雏形是这样的:
ts
class GrpcServer {
private server: grpc.Server
addService(protoService: any, serviceMap: { [key: string]: any }) {}
start(host: string; port: string | number) {}
}
在实现具体的逻辑代码之前,不得不吐槽一下官方教程真的藏得有点深。其教程最核心的代码如下:
js
function getServer() {
var server = new grpc.Server();
server.addService(routeguide.RouteGuide.service, {
getFeature: getFeature,
listFeatures: listFeatures,
recordRoute: recordRoute,
routeChat: routeChat
});
return server;
}
var routeServer = getServer();
routeServer.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () => {
routeServer.start();
});
和我们的思路对应,它也是先通过 addService
添加服务,再通过 bindAsync
绑定 host 和 port 启动服务器。理解了官网的写法后,便可以移植到我们的实现当中来。
ts
import grpc from '@grpc/grpc-js';
export default class GrpcServer {
private server: grpc.Server = new grpc.Server();
addService(protoService: any, serviceMap: { [key: string]: any }) {
this.server.addService(protoService, serviceMap);
}
async start(host: string, port: string | number) {
this.server.bindAsync(`${host}:${port}`, grpc.ServerCredentials.createInsecure(), (err, port) => {
if (err != null) {
return console.error(err);
}
console.log(`🌐 gRPC listening on ${host}:${port}`);
});
}
}
为了正确地提供 protoService
参数到 addService()
,我们需要写一个 getProto()
方法。该方法通过 @grpc/proto-loader
加载给到的 .proto
文件,返回一个 grpc.GrpcObject
。
ts
export const getProto = (name: string) => grpc.loadPackageDefinition(
protoLoader.loadSync(
path.join(cwd(), `src/protos/${name}.proto`),
{
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true
},
)
);
接下来我们便可编写 SayHello
的具体实现代码了:
ts
export default function SayHello(call: { request: any }, callback: any) {
const { name } = call.request;
callback(null, {
code: 0,
message: `Hello ${name}`,
})
}
注意,这里的 call: { request: any }
对应着 hello.proto
中的 message SayHelloRequest
,这里定义了需要传入一个类型为 string 的参数 name
。
callback
的第一个参数是 Error
对象,在出现错误的时候可以把错误传递进去,如果没有错误则填入 null
即可。第二参数则对应了 hello.proto
中的 message SayHelloResponse
。
最后回到 index.ts
,我们便可以直接启动一个最简单的 gRPC 服务了:
ts
import GrpcServer, { getProto } from './src/server';
import SayHello from './src/services/sayHello';
const server = new GrpcServer();
const proto = (getProto('hello').demo as any).Hello.service; // 注意这里的写法。对照 `hello.proto`,找到具体的那个 service
server.addService(proto, { SayHello });
server.start('0.0.0.0', 50051)
执行启动命令后,控制台将会输出
csharp
🌐 gRPC listening on 0.0.0.0:50051
使用BloomRPC调试工具,可以验证到该服务已经正常运行。
三、开发模式下热更新能力的提供
在实际的工作开发中,我们肯定会不断地修改代码,细心的同学肯定会发现,上述的代码无法使用 bun 提供的热更新指令 --hot
。一旦修改代码,一定会报错:
bash
E No address added out of total 1 resolved
462 | return bindResult.port;
463 | }
464 | else {
465 | const errorString = `No address added out of total ${addressList.length} resolved`;
466 | logging.log(constants_1.LogVerbosity.ERROR, errorString);
467 | throw new Error(`${errorString} errors: [${bindResult.errors.join(',')}]`);
^
error: No address added out of total 1 resolved errors: [Failed to listen at 0.0.0.0]
at /Users/jrainlau/Desktop/bun-grpc-server-demo/node_modules/@grpc/grpc-js/build/src/server.js:467:31
at processTicksAndRejections (native:7:39)
该错误的原因是在热更新的时候,并没有杀掉上一次的 gRPC 服务,导致热更新后无法再使用同样的 host 和 port。查遍了官网和 Google 都没有找到对应的解法,最后愣是在源码 @grpc/grpc-js/build/src/server.js
中找到了一个方法 forceShutdown()
,强行终止服务。
diff
async start(host: string, port: string | number) {
+ if ((globalThis as any).grpcServer) {
+ (globalThis as any).grpcServer.forceShutdown();
+ }
this.server.bindAsync(`${host}:${port}`, grpc.ServerCredentials.createInsecure(), (err, port) => {
if (err != null) {
return console.error(err);
}
console.log(`🌐 gRPC listening on ${host}:${port}`);
+ (globalThis as any).grpcServer = this.server;
});
}
实现方式也很简单,在每次调用 start()
进行启动的时候,判断全局底下是否仍有残留的实例,如果有就调用 forceShutdown()
方法杀掉它。
最后,本文有关的代码都在仓库 bun-grpc-server-demo 中,可自行下载尝试。