gRPC 是一套高效、跨平台、基於 HTTP/2 的 遠端程序呼叫(Remote Procedure Call, RPC) 框架,有低延遲、串流等特色,近年來被廣泛用於微服務間的通訊。
補充:gRPC 最早是由 Google 所創造,在 2015 年正式開源,現已納入 CNCF 孵化項目。
gRPC 是一套 RPC 框架,我們需要先了解一下 RPC 是什麼東西,簡單來說,RPC 就是讓 A 設備上的程式可以呼叫 B 設備提供的函式,在撰寫程式時,感覺就像是呼叫自己程式提供的函式,無需關注通訊的方式。
RPC 的設計下,在 Client 端與 Server 端都會存在一個叫 樁(Stub) 的角色,這些 Stub 會依照定義好的介面來實作,Client Stub 負責提供函式讓 Client 應用程式可以執行,執行後由 Client Stub 處理通訊與資料打包的工作,Server Stub 則負責解析並呼叫實際存在的函式,再以類似 Client Stub 的處理方式進行回應。
gRPC 也是遵循這個概念進行實作,在 Client 端會有 gRPC Stub 來提供函式、處理通訊與資料打包,gRPC Server 負責解析、呼叫實際存在的函式並進行回應。
gRPC 預設使用 Protocol Buffer 當作 介面描述語言(Interface Definition Language, IDL),那什麼是 Protocol Buffer 呢?它是用來定義介面、序列化格式的解決方案,能夠跨語言使用,提供比 JSON 更輕量的二進制資料,也因為資料是二進制的,所以大幅提升了資料的隱匿性。gRPC 基於 Protocol Buffer 來定義服務的介面與資料的格式,不僅實現了介面的定義,還利用 Protocol Buffer 的特性來降低傳輸的延遲並提升資料的隱匿性,真是一舉數得。
補充:Protocol Buffer 同樣由 Google 開源。
注意:Protocol Buffer 目前有兩種版本,分別是
proto2
與proto3
,gRPC 官方建議採用的版本為proto3
,故後續都會以proto3
來進行說明。
Protocol Buffer 本身有自己的副檔名 - .proto
,檔案名稱要使用 小寫蛇形的方式命名,如:lower_snake_case.proto
,如果要採用 proto3
的話,會以關鍵字 syntax
來宣告並放在檔案的第一行。下方是範例程式碼:
syntax = "proto3";
在 Protocol Buffer 的定義,每一筆資料都是由 訊息(Message) 組成,用 TypeScript 的角度來說的話,就像是一個 interface
。下方是一個用 TypeScript 定義的 User
介面:
interface User {
first_name: string;
middle_name: string;
last_name: string;
age: number;
}
用 Protocol Buffer 來表示的話,則會使用 message
關鍵字,並在每個欄位的開頭宣告型別,而每個欄位都必須賦予序號:
syntax = "proto3";
message User {
string first_name = 1;
string middle_name = 2;
string last_name = 3;
int32 age = 4;
}
除了上面使用的型別以外,Message 也可以是其他 Message 的型別:
syntax = "proto3";
message User {
string first_name = 1;
string middle_name = 2;
string last_name = 3;
int32 age = 4;
}
message Result {
User user = 1;
}
注意:根據 Protocol Buffer 官方的 Style Guide,定義
message
的名稱要使用 帕斯卡命名(PascalCase),如:CreateUser
,而欄位名稱要使用 小寫蛇形命名,如:first_name
。
補充:剛接觸 Protocol Buffer 可能會覺得很奇怪,為什麼要賦予序號?這部份稍後會進行說明。
Protocol Buffer 針對欄位定義的型別採用的是 Scalar Value Types,下方是提供的型別關鍵字:
bool
:布林值。string
:字串,必須採用 UTF-8 或 7-bit ASCII 編碼,且長度不得超過 2^32
。bytes
:可以包含任意位元組的資料,但不得超過 2^32
。double
:雙精度浮點數。float
:單精度浮點數。int32
:32
位元帶正負號的整數。int64
:64
位元帶正負號的整數。uint32
:32
位元不帶正負號的整數。uint64
:64
位元不帶正負號的整數。sint32
:與 int32
相似,但較有效地處理負數。sint64
:與 int64
相似,但較有效地處理負數。fixed32
: 固定 32
位元不帶正負號的整數,如果數字經常大於 2^28
建議使用,效率較佳。fixed64
:固定 64
位元不帶正負號的整數,如果數字經常大於 2^56
建議使用,效率較佳。sfixed32
:固定 32
位元帶正負號的整數。sfixed64
:固定 64
位元帶正負號的整數。注意:這些定義在轉換成對應語言時會有不同的型別,以 Golang 來說,
double
對應的會是double64
,詳細資訊可以參考官方文件。
Protocol Buffer 有支援 列舉(Enum),使用關鍵字 enum
進行定義。下方是範例程式碼:
syntax = "proto3";
enum Gender {
MALE = 0;
FEMALE = 1;
OTHERS = 2;
}
在使用上有些規則需要注意:
0
。32
位元整數內。注意:根據 Protocol Buffer 官方的 Style Guide,Enum 的名稱要使用 PascalCase,如:
StatusCode
,而欄位名稱要使用 大寫蛇形命名,如:NOT_FOUND
。
前面有提到在 Message 裡面的每個欄位都必須賦予序號,這個序號是讓 Protocol Buffer 在二進制狀態下識別欄位的識別碼,可定義的區間為 1
到 536,870,911
,但有些限制需要注意:
19,000
到 19,999
不得使用,這個區間是 Protocol Buffer 實作用的。1
到 15
之間,細節可以參考官方文件。保留欄位需要在 Message 裡面使用 reserved
關鍵字來標示哪些欄位、欄位名稱被保留。下方是範例程式碼,將 10
、12
、20
到 26
標示為保留,同時也將欄位名稱 job
標示為保留:
syntax = "proto3";
message User {
reserved 10, 12, 20 to 26;
reserved "job";
string first_name = 1;
string middle_name = 2;
string last_name = 3;
int32 age = 4;
}
注意:以上幾點限制 非常重要,尤其是針對修改欄位序號與刪除欄位的操作,除非可以 100% 確定修改後的
proto
會同時在所有微服務應用程式中進行更新,且不會有任何一個用舊proto
編碼的資料存在,但這種可能性極低,所以應避免。
補充:如果對 Protocol Buffer 是如何編碼有興趣的話,可以參考官方的說明文件。
Protocol Buffer 設計了特定欄位標籤來針對欄位做特殊處理,比如:是否為非必要的、是否會有重複欄位的訊息等。
如果有一個欄位不是必填,那可以將該欄位用關鍵字 optional
標示。下方為範例程式碼:
syntax = "proto3";
message User {
string first_name = 1;
optional string middle_name = 2;
string last_name = 3;
int32 age = 4;
}
當標示為 optional
的欄位沒有被設置時,它不會被編碼並傳輸,但資料解析時將會以 預設值(Default Value) 表示,下方列出各個型別下的預設值:
string
:預設為 空字串。bytes
:預設為 空 Byte。bool
:預設為 false
。0
。enum
:預設為 第一個列舉數值,也就是 0
。message
:預設值取決於使用的語言,可以參考官方文件。如果有一個欄位會出現 0
次或數次,可以將該欄位用關鍵字 repeated
標示,用比較簡單的說法即陣列型別。下方為範例程式碼:
syntax = "proto3";
message User {
string first_name = 1;
string middle_name = 2;
string last_name = 3;
int32 age = 4;
repeated string tags = 5;
}
注意:根據 Protocol Buffer 官方的 Style Guide,定義
repeated
的欄位名稱要使用 複數型態,如:tags
。
如果有一個欄位用於建立映射關係,可以將該欄位用關鍵字 map
標示。map
的定義方式如下:
map<key_type, value_type> map_field = N;
key_type
可以是 integer
或 string
,而 value_type
可以是除了 map
以外的任何型別。
下方是範例程式碼:
syntax = "proto3";
message User {
string first_name = 1;
string middle_name = 2;
string last_name = 3;
int32 age = 4;
map<string, string> relationship = 5;
}
Map 在使用上需要特別注意下列幾個事項:
repeated
關鍵字。key_type
,會以最後一個為主;但如果是從文字格式解析到重覆 key_type
則可能會失敗。Protocol Buffer 提供 巢狀型別(Nested Type),透過這種方式可以更好地歸納、分類 Message。下方是範例程式碼:
syntax = "proto3";
message User {
message Name {
string first_name = 1;
optional string middle_name = 2;
string last_name = 3;
}
Name name = 1;
int32 age = 2;
}
Nested Type 可以被重用,甚至在閱讀上會更加值觀。下方是範例程式碼:
syntax = "proto3";
message User {
message Name {
string first_name = 1;
optional string middle_name = 2;
string last_name = 3;
}
Name name = 1;
int32 age = 2;
}
message OtherMessage {
User.Name name = 1;
}
實務上我們不太可能把所有的定義放在同一個 .proto
裡,會把不同類型的定義放在一起,並拆分成多個檔案,Protocol Buffer 提供了 import
關鍵字讓我們能引入其他 .proto
的定義。下方規劃了兩個 .proto
檔,分別是 user.proto
與 user_result.proto
:
.
└─ user
├─ user.proto
└─ user_result.proto
user.proto
定義了 User
:
syntax = "proto3";
message User {
string first_name = 1;
string middle_name = 2;
string last_name = 3;
int32 age = 4;
}
user_result.proto
定義了 Result
,並引入 user.proto
,進而使用 User
:
syntax = "proto3";
import "user.proto";
message Result {
User user = 1;
}
如果希望避免 .proto
之間的 Message 名稱發生衝突,可以將 .proto
的內容進行 打包(Package),透過 package
關鍵字來定義該 Package 名稱。下方為範例程式碼,將 user.proto
定義 Package 名稱為 user.definition
:
syntax = "proto3";
package user.definition;
message User {
string first_name = 1;
string middle_name = 2;
string last_name = 3;
int32 age = 4;
}
接著,調整 user_result.proto
的內容,要將 User
換成以 Package 的方式取得 User
:
syntax = "proto3";
import "user.proto";
message Result {
user.definition.User user = 1;
}
撰寫完的 Protocol Buffer 定義可以透過 Protocol Buffer Compiler 產生特定語言的必要程式碼,像是:Golang、Java、Python 等。
最簡單的安裝方式是從 GitHub Release 下載需要的版本來使用,如果是 Mac 的開發者則可以使用 Homebrew 進行安裝:
$ brew install protobuf
補充:從 GitHub Release 安裝可以參考這篇文章。
安裝完之後,可以透過下方指令查詢 Protocol Buffer Compiler 版本:
$ protoc --version
要執行 Protocol Buffer Compiler 的話,需要先了解一些基本的參數設置,下方列出了最重要的兩個參數:
--proto_path
:用來指定 .proto
的根目錄,如果不指定則會以下只零食的目錄位置作為根目錄,另外,也可以用縮寫 -I
來代替 --proto_path
。--<XXX>_out
:用來指定要編譯的程式語言程式碼以及其輸出位置,<XXX>
即程式碼代號。這裡整理了部份程式語言的參數名稱以及對應的來源供參考:
--cpp_out
:輸出 C++ 程式碼,細節可參考 官方文件。--java_out
: 輸出 Java 程式碼,細節可參考 官方文件。--python_out
:輸出 Python 程式碼,細節可參考 官方文件。--go_out
:輸出 Golang 程式碼,細節可參考 官方文件。--ruby_out
:輸出 Ruby 程式碼,細節可參考 官方文件。--objc_out
:輸出 Objective-C 程式碼,細節可參考 官方文件。--csharp_out
:輸出 C# 程式碼,細節可參考 官方文件。--php_out
:輸出 PHP 程式碼,細節可參考 官方文件。補充:為什麼沒有看到 JavaScript 或 TypeScript 的相關參數呢?事實上官方沒有將它們列在文件裡,但其實是有提供
--js_out
的,只是在使用上與其他程式語言較不同,且需要額外安裝 protoc-gen-js,故在此就不特別針對 JavaScript 的部份進行編譯,至於 TypeScript 的部份,我們會使用其他套件來輔助編譯,這部分留到後面再進一步說明。
下方是編譯的指令,這裡我們針對 user_result.proto
進行編譯,並將其編譯成 Python 程式碼,<IMPORT_PATH>
輸入 .proto
的根目錄、<DST_DIR>
輸入要輸出的檔案路徑:
$ protoc --proto_path=<IMPORT_PATH> --python_out=<DST_DIR> user_result.proto
注意:如果使用相對路徑的話,需注意當前終端機所在位置。
補充:Protocol Buffer Compiler 支援一次輸出多種程式語言的程式碼,只需要輸入好參數即可,以輸出 Python 與 Java 為例,只需要帶入
--python_out=<DST_DIR>
與--java_out=<DST_DIR>
即可。
執行完畢後,就會看到指定的輸出路徑出現了 Python 的檔案:
在 RPC 的設計下,經常會需要為服務定義介面,以利產生相對應的 Stub 與介面,gRPC 也遵循著這個設計方式,那要如何替服務定義介面呢?事實上,Protocol Buffer 有提供 service
關鍵字來宣告型別。下方是範例程式碼:
syntax = "proto3";
message User {
string name = 1;
int32 age = 2;
}
message GetUserRequest {
string name = 1;
}
message GetUserResponse {
User user = 1;
}
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}
可以看到定義了三個 Message,分別是 User
、GetUserRequest
、GetUserResponse
,同時定義了 UserService
這個服務介面,它使用 rpc
關鍵字提供了 GetUser
函式,輸入值的介面即 GetUserRequest
,回傳值的部份使用 returns
表示,這裡回傳值的介面即 GetUserResponse
。
注意:根據 Protocol Buffer 官方的 Style Guide,定義
service
的名稱要使用 PascalCase,如:UserService
,其提供的函式也要使用 PascalCase,如:GetUser
。
補充:關於 RPC 函式設計的最佳實踐可以參考官方文件。
針對服務介面的設計,可能會因為 RPC 的生命週期而有不同的撰寫方式,gRPC 提供了下列四種 RPC 設計:
Unary RPC 是最簡單的 RPC 類型,就是我們最熟悉的 Request-response 模式,一個 Request 對應一個 Response。
寫法上與上方的範例相同:
syntax = "proto3";
// ...
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}
與 Unary RPC 相似,但是由 Client 端發起一個 Request,Server 端會以 串流 的形式持續回應資料。下方是範例程式碼,可以看到在 returns
的地方使用了 stream
關鍵字:
syntax = "proto3";
// ...
service LogService {
rpc GetLogs(GetLogsRequest) returns (stream GetLogResponse);
}
與 Server Streaming RPC 相反,是由 Client 發起串流的 Request,將資料持續發送給 Server 端,Server 端在接收、處理完資料後,由一個 Response 進行回應。下方是範例程式碼,可以看到 stream
是加在函式的參數前面:
syntax = "proto3";
// ...
service FileService {
rpc UploadFiles(stream UploadFileRequest) returns (UploadFilesResponse);
}
這是雙向串流的 RPC 類型,Client 會發起串流的 Request,將資料持續發送給 Server 端,Server 端也以串流的形式持續回應資料。下方是範例程式碼,可以看到 stream
同時加在函式的參數前面以及 returns
的地方:
syntax = "proto3";
// ...
service FileService {
rpc UploadFiles(stream UploadFileRequest) returns (stream UploadFileResponse);
}
Metadata 是用於特定 RPC 的資訊,像是:驗證資訊等,會以 key value pairs 的形式存在,在使用上需要特別注意以下幾點:
-
、_
、.
組成,並且 不能 以 grpc-
開頭。-bin
作為後綴。補充:存取 Metadata 的方式取決於使用的程式語言,這部份下一篇章會進一步說明。
回顧一下本篇的重點內容,一開始先針對 gRPC 做了基本的介紹,包含:gRPC 是什麼?RPC 如何運作?Protocol Buffer 跟 gRPC 的關係等。
接著,開始介紹 Protocol Buffer 要如何撰寫,包含:Message 的定義、資料型別有哪些、Service 要如何定義等。
最後再回頭講解 gRPC 提供的四種 RPC 類型,分別是:Unary RPC、Server Streaming RPC、Client Streaming RPC 與 Bidirectional Streaming RPC。
由於 gRPC 的介紹很豐富,故拆分成上下兩篇來完成一系列的 gRPC Transporter 介紹,在閱讀完本篇內容後,應該就具備了基礎的 gRPC 知識,下一篇會正式進入 NestJS gRPC Transporter 的介紹,敬請期待!