在 cnb.cool 的任務集功能區中,我們使用了 bun 作為服務端,負責任務集視圖的相關讀寫能力,積累了一定的經驗。整體來説 bun 的寫法和 Nodejs 幾乎一致,但對於“提供 gRPC 服務”相關的知識,現網所能找到的資料較少,因此專門記錄下來。
關於 bun 和 gRPC 的介紹就不在此展開了,感興趣的同學請自行搜索。
一、初始化
參考官網的方式,首先把 bun 安裝到機器上(本文開發環境為 MacOS)。
curl -fsSL https://bun.sh/install | bash
接下來就可以初始化我們的項目並安裝 grpc 依賴了。
bun init -y
bun install @grpc/grpc-js @grpc/proto-loader
回頭在 package.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 來舉個例子:
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()。接下來我們就要開始學習如何實現這個服務。
回到根目錄,按照如下的結構組織代碼:
.
├── index.ts
└── src
├── protos
│ └── hello.proto
├── server.ts
└── services
└── sayHello.ts
核心的代碼為 src/server.ts,第一個就要去實現它。
我們的思路如下:
- 一個 gRPC server 就是一個實例:可以通過 new 實例化;
- 它提供了一個方法允許我們添加不同的服務:
addService()函數,允許傳入不同的.proto文件和對應的實現代碼; - 一個啓動的命令:
start()函數,允許傳入 host 和 port。
因此它的雛形是這樣的:
class GrpcServer {
private server: grpc.Server
addService(protoService: any, serviceMap: { [key: string]: any }) {}
start(host: string; port: string | number) {}
}
在實現具體的邏輯代碼之前,不得不吐槽一下官方教程真的藏得有點深。其教程最核心的代碼如下:
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 啓動服務器。理解了官網的寫法後,便可以移植到我們的實現當中來。
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。
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 的具體實現代碼了:
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 服務了:
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)
執行啓動命令後,控制枱將會輸出
🌐 gRPC listening on 0.0.0.0:50051
使用BloomRPC調試工具,可以驗證到該服務已經正常運行。
三、開發模式下熱更新能力的提供
在實際的工作開發中,我們肯定會不斷地修改代碼,細心的同學肯定會發現,上述的代碼無法使用 bun 提供的熱更新指令 --hot。一旦修改代碼,一定會報錯:
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(),強行終止服務。
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 中,可自行下載嘗試。