bun 實現 gRPC 伺服器

jrainlau發表於2024-12-09

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,第一個就要去實現它。

我們的思路如下:

  1. 一個 gRPC server 就是一個例項:可以透過 new 例項化;
  2. 它提供了一個方法允許我們新增不同的服務:addService() 函式,允許傳入不同的 .proto 檔案和對應的實現程式碼;
  3. 一個啟動的命令: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 中,可自行下載嘗試。

相關文章