Yuanchieh's Blog

Yuanchieh's Blog

生命是長期而持續的累積

07 Jun 2020

gRPC 介紹與 Nodejs 實作分享

一般的 API Endpoint 設計採用 RESTful API規範,不過 RESTful 比較指稱的是 Client/Server 的架構設計而不是侷限於 API Endpoint 的設計規範,更準確的說法是 HTTP + JSON 格式,佔據多數的 API 設計近一二十年 (SOAP/WSDL 因為工作中沒有使用過就不描述)

但因應新的網路應用程式,不斷有新的設計嘗試去更進 API 設計,近年有 Facebook 提出了 GraphQL,將讀取資料的彈性交還給 Client,適應多屏幕多裝置 Client 的應用場景

又或是今天要探討的 gRPC,由 Google 基於 http/2 提出且廣泛應用在微服務架構中,主要希望改善幾個問題

  1. API 文件化與 Client 實作繁雜
    一般的 API 設計是 Server 開好 HTTP endpoint 後,定義好參數並撰寫文件,接著 Client 再閱讀文件實作;
    好處是解耦的很徹底,不管有幾個 Client 或是使用什麼程式語言,按照 API 介面實作即可;
    但同樣的要維護時相對成本也比較高,像是參數的名稱與型別,都必須雙方去維護與閱讀文件,再用程式碼實作,中間多了一層的轉換
    gRPC 預設採用 protocol buffer 當作 IDL (介面定義語言),將 Endpoint 提供的服務/參數/回傳值都定義好名稱與型別,當作 Server / Client 實作的 Interface,大幅降低雙方溝通的成本
  2. 增加傳輸效率
    gRPC 預設使用 protocol buffer 當作編碼/解碼傳輸內容,比用文字描述的 JSON 在檔案大小與網路傳輸效率上更具備優勢,專案中有提供 Android client benchmark,gRPC 的延遲遠比 JSON 要快上許多
  3. 應用彈性
    傳統的 HTTP 就是 Request/Response 一來一往,在 grpc 中稱為 Unary Call,但是 gRPC 額外提供 streaming,可以分批在同一個 request response 中傳送多次 payload,設計更有彈性的溝通方式
  4. HTTP verb 並無法完整描述資源的操作
    之前設計 API 頭痛的是 HTTP 的動詞 (GET/POST) 等無法完整描述所有的操作,例如說批次刪除,後來參照 Google API 設計文件有找到解法,但還是有這麼一點彆扭
    採用 gRPC 就沒有這方面的規範(與困擾 ?!

目前 gRPC 由 Google 開源並主力維護,採用的大廠也有不少,也支援許多程式語言 Java/JS(Nodejs & browser)/Python/PHP 等等,Android/iOS App 也都有支援的 Library;
觀念上要找到映射於 HTTP 還蠻容易的,像是 Http header 對應 gRPC metadata / Http Status Code 對應 gRPC 也有同樣的回傳格式

以下內容包含

  1. protocol buffer 簡介與編碼機制
  2. gPRC 四種傳輸方式介紹與 server 端實作
  3. 錯誤處理與驗證機制 (*Warning 有坑)

但目前不包含 Client side 實作

Nodejs 實作

以下會實作一個簡單的 To-Do List,demo code 於此 grpc-demo

定義 .proto 檔案

在專案路徑下,建立一個資料夾 ./protos

import "google/protobuf/empty.proto";

syntax = "proto3";

package ToDoService;

message ToDoItem {
    string title=1;
    string author=2;
    bool isDone=3;
    double createDate=4;
}

message ToDoList {
    repeated ToDoItem ToDoList = 1;
}

message GetQueryOptions {
    string author = 1;
}

service ToDoService {
    rpc CreateToDo (ToDoItem) returns (ToDoList);
    rpc createMultiToDo (stream ToDoItem) returns (google.protobuf.Empty);
    rpc GetToDoListByAuthor (GetQueryOptions) returns (stream ToDoItem);
    rpc GetToDoListByAuthorOnFly (stream GetQueryOptions) returns (stream ToDoItem);
}
  1. import 可以從其他地方載入 proto 檔案使用裡面的宣告
  2. syntax = "proto3";
    標註使用 protobuffer version
  3. message 宣告型別名稱 { 型別 參數名 = 順序}
    型別部分可以參考官網,有提供 int / uint / float / string 等多樣基礎型別,也可以使用自定義型別;
    順序的部分,從 1 開始一路遞增,型別內部的參數順序不能重複,且越小的數字通常是越常使用到的參數,詳見後續補充
  4. service 服務名稱 { rpc 函式名稱 (參數) returns (回傳值) }
    如果希望將 proto 用於 rpc,就需要宣告 service 類別,stream 表示參數或回傳值可能會是批次傳送 payload
    這邊定義了四個函式,建立 ToDoItem / streaming 批次建立 ToDoItem / 依照作者批次取得 ToDoItem / 動態改變搜尋作者並動態依照條件回傳 ToDoItem

Server side 實作

const path = require('path');

const grpc = require('grpc');
const protoLoader = require('@grpc/proto-loader');

const toDoServiceImplementations = require('./implementations/todoService');

async function main() {
    const server = new grpc.Server();

    const PROTO_PATH = path.join(__dirname, './protos/todo.proto');
    const packageDefinition = protoLoader.loadSync(
        PROTO_PATH,
        {
            keepCase: true,
            longs: String,
            enums: String,
            defaults: true,
            oneofs: true
        });
    const toDoProto = grpc.loadPackageDefinition(packageDefinition).ToDoService;

    server.addService(toDoProto.ToDoService.service, toDoServiceImplementations);
    server.bind('0.0.0.0:50051', grpc.ServerCredentials.createInsecure());
    server.start();
}

main();

以上步驟大概是

  1. 建立 grpc server instance
  2. 載入上一步定義的 proto
  3. 將 proto 與實作的 function 結合
  4. server bind port 並開始運行

接著看 implementation 部分

const grpc = require('grpc');

let todoList = [
    { title: 'Hello', author: 'Hello2', isDone: true, createDate: 1.4 },
    { title: 'Hello2', author: 'Hello', isDone: true, createDate: 1.4 },
    { title: 'Hello3', author: 'Hello', isDone: true, createDate: 1.4 },
    { title: 'world', author: 'world', isDone: true, createDate: 1.4 },
    { title: 'world2', author: 'Hello', isDone: true, createDate: 1.4 },
    { title: 'world3', author: 'Hello2', isDone: null, createDate: "1.4" }
];

function CreateToDo(call, callback) {
    const clientToken = call.metadata.get('token')?.[0];
    if(clientToken !== 'Secret'){
        return callback({
            error: grpc.status.PERMISSION_DENIED,
            message: "No token"
        })
    }
    todoList.push(call.request);

    if(todoList.length > 5){
        return callback({
            error: grpc.status.OUT_OF_RANGE,
            message: "too many ToDoItem"
        })
    }

    callback(null, {
        ToDoList: todoList
    })
}

async function createMultiToDo(call, callback) {
    call.on('data', (data) => {
        todoList.push(data);
    })

    call.on('end', () => {
        callback(null, {});
    })
}

function GetToDoListByAuthor(call) {
    const author = call.request.author;

    async function main() {
        let isAny = false;
        for (const todoItem of todoList) {
            if (author === todoItem.author) {
                isAny = true;
                call.write(todoItem);
                await wait(1);
            }
        }

        if (isAny === false) {
            return call.emit('error', grpc.status.PERMISSION_DENIED)
        }

        call.end()
    }
    main()
}

async function GetToDoListByAuthorOnFly(call) {
    let author = null;
    call.on('data', (data) => {
        console.log(data)
        author = data.author;
        main();
    });

    call.on('end', () => {
        call.end();
    });

    async function main() {
        for (const todoItem of todoList) {
            if (author === todoItem.author) {
                call.write(todoItem);
            }
            await wait(3);
        }
    }
}

async function wait(sec) {
    return new Promise((res) => setTimeout(() => res(), sec * 1000));
}

module.exports = {
    CreateToDo,
    createMultiToDo,
    GetToDoListByAuthor,
    GetToDoListByAuthorOnFly,
}

可以看到 handle function 共有四種

  1. handleUnaryCall(call, callback) // CreateToDo
  2. handleClientStreamingCall(call, callback) // createMultiToDo
  3. handleServerStreamingCall(call) // GetToDoListByAuthor
  4. handleBidiStreamingCall(call) // GetToDoListByAuthorOnFly

拆成 client / server 兩部分

  1. 如果 client 是送 unary data,則直接從 call.request 讀取傳送值
  2. 如果 client 是送 streaming data,則使用 call.on('data', (data)=>{}) / call.on('end', ()=>{}) 處理資料與傳送結束
  3. 如果 server 是送 unary data,則handle function 第二個參數為 callback,callback 常用前兩個參數,代表 errordata,如果沒有錯誤則回傳 callback(null, myData)
  4. 如果 server 是送 streaming data,則用 call.write(myData),結束傳送呼叫 call.end()

搭配的 GUI Client 工具可以參考 bloomrpc,載入 proto 檔案後就會自動跳出定義的 service 與預期的回傳結果,相當的方便,這也是使用 gRPC 的一大好處

傳入多餘的參數或型別錯誤

在使用預先型別定義的設計時,不免腦中浮現如果我不按照定義的話會怎麼樣?
如果是傳入多餘的型別,protobuffer 在 version 2 && version 3.5 以上會保留,目前版本到 3.12 了
如果是 型別不對,內部採用的 encode / decode 是基於 protobufjs,會使用 Number / Boolean 等 JS 類別來轉換型別,例如 number “123” 就會變成 123

錯誤處理

根據前面所描述,Server 回應時會分成 Unary 跟 Streaming,兩種的錯誤回傳機制不同;
這部分有點小坑,官方範例只有示範 Unary Response 時的錯誤處理,也就是呼叫 callback 的第一個參數

callback({
    error: grpc.status.OUT_OF_RANGE,
    message: "too many ToDoItem"
})

先前定義 service 時並沒有宣告錯誤回傳,這是因為 gRPC 內建錯誤訊息,格式如下

{
    string error
    string message
}

error 對應於 HTTP status code,可以從 grpc 的靜態參數取得如 grpc.status.PERMISSION_DENIED 等同於 403,message 則是自定義的字串,需注意 Nodejs 不支援 rich format,以下截自官方文件

This richer error model is already supported in the C++, Go, Java, Python, and Ruby libraries, and at least the grpc-web and Node.js libraries have open issues requesting it.

所以 gRPC client 收到的錯誤訊息會被轉成

{
  "error": "2 UNKNOWN: too many ToDoItem"
}

另外 Streaming response 傳遞錯誤的方式是

call.emit('error', grpc.status.PERMISSION_DENIED)

emit error 後會自動 close,目前只支援 status,不能傳遞物件,否則 connection 無法被 close,呼叫 call.end() 也沒有用

如果要有更豐富的錯誤格式,就需要自己定義了

驗證機制

gRPC 內建兩種驗證相關的機制,一種是 SSL/TLS,提供通訊上的點到點加密,另一種是 Google 服務的 OAuth Token 驗證機制,後者僅限於與 Google 服務對接才有用;
當然也可以用 middleware 方式自行實作驗證機制

驗證機制有兩種 scope,一種是 Channel Level,也就是適用於 gRPC 連線,另一個是 Call Level,也就是每次呼叫,這部分是使用於 Client side

var ssl_creds = grpc.credentials.createSsl(root_certs);
var stub = new helloworld.Greeter('myservice.example.com', ssl_creds);

這邊為了實作方便性,將 token 放置於 metadata 之中,要從 server side 讀取使用 call.metadata.get('Key') 即可

Cache?

另一個 HTTP (RESTful 架構下) 原有的設計是 Cache 機制,又分成 server / proxy / client 三者處理,gRPC 看起來有類似的規劃,但目前還在實驗階段 Provide support for caching GRPC method response #7945

Protocol buffer Encoding 機制

參考官方文件 Protocol buffer Encoding,先前提到 Protocol buffer 會將訊息編碼成 binary 格式,而 JSON 則維持文字格式,這邊簡單介紹 Protocol buffer 編碼的過程

Varints

varints 是一種用多個 bytes 表達數字的方式,除了最後一個 byte 外,其餘 byte 的第一位元表示後續是否還有 byte,byte 的順序為最低有效位(越前面的 byte 是低位) least significant group first.,所以實際上每個 byte 是用 7 bits 表達數值

例如說 1010 1100 0000 0010 代表 300,因為 1 010 1100 1 代表後面還有 byte 相連,0 000 0010 0 則表示他是最後一個 byte 了;
因為最低有效位,所以重組成 000 0010 010 1100,也就是 300

Key Value

其實 Protocol buffer 就是編碼一連串的 Key-Value,在編碼時會以 編碼號(5 bits) 類別(3 bits) 數值表示,例如說

message Test2 {
  optional string b = 2;
}

假設 b 儲存了 “testing”,那時記得編碼結果是 12 07 74 65 73 74 69 6e 67,拆解 12 成 0001 0010 => 00010 010 對應到欄位編號 2 + 數值類別 2(代表是自訂長度的類別,如 string / object 等);
接著 07 表示接下來 7 個 bytes 是數值表示;
後續的數值是 utf-8 編碼的顯示

接著看

message Test1 {
  optional int32 a = 1;
}

message Test3 {
  optional Test1 c = 3;
}

假設 Test1 a 儲存 150,則編碼結果hex 表示為 08 96 01,也就是 00001 000 欄位編號 1 + 數值編號 0 也就是 varints;
96 01 則是 1001 0110 + 0000 0001 並依照 varints 表示法轉乘 000 0001 001 0110 也就是 150

接著 Test3 儲存 Test1,假設 Test1 的 a 等於 150;
hex 表示法為 1a 03 08 96 01,也就是 00011 010,欄位 3的類別是 2,接下來 03 共 3 個bytes 為數值,也就是上一步的 08 96 01

總結

gRPC 會全面取代 HTTP + JSON 嗎?
這個問題或許有點像 Deno 會不會全面取代 Nodejs,現在談好像有點過早,畢竟 HTTP + JSON 行之有年,多方平台的支援度還是比較好,包含像 Proxy 等中介網路服務

但是 gRPC 在某些用途上,基於開發效率 / 傳輸速率等,確實很值得投資與嘗試的技術