在 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 中,可自行下載嘗試。