更快更小!ProtoBuf 入門詳解
作者 | dorabwzhang
什么是 Proto Buffer
Proto Buffer 是一種語言中立的、平臺(tái)中立的、可擴(kuò)展的序列化結(jié)構(gòu)數(shù)據(jù)的方法。
Protocol Buffers are a language-neutral, platform-neutral extensible mechanism for serializing structured data.
這是來自官網(wǎng) Overview 頁面對于 Protobuf 的簡介,拋開繁雜的修飾詞,Protobuf 的核心是序列化結(jié)構(gòu)數(shù)據(jù),為了更好地理解 Protobuf,我們首先需要知道序列化是什么?
序列化指的是將一個(gè)數(shù)據(jù)結(jié)構(gòu)或者對象轉(zhuǎn)換為某種能被跨平臺(tái)識(shí)別的字節(jié)格式,以便進(jìn)行跨平臺(tái)存儲(chǔ)或者網(wǎng)絡(luò)傳輸。
例如前端和后端可能使用不同的編程語言,它們內(nèi)部的數(shù)據(jù)表示方式可能不兼容。序列化提供了一種語言無關(guān)的格式來表示數(shù)據(jù),這樣不同的系統(tǒng)就可以理解和處理這些數(shù)據(jù)。在我們?nèi)粘_M(jìn)行前后端開發(fā)時(shí),后端通常會(huì)返回 JSON 格式的數(shù)據(jù),前端拿到后再進(jìn)行相關(guān)的渲染,JSON 其實(shí)就是序列化的一種表現(xiàn)形式。而我們這里提到的 Proto Buffer 就是序列化的其他表現(xiàn)形式。事實(shí)上除了 JSON 與 Protobuf 還有很多種形式,例如 XML、YAML。
回到正題,我們來看看 Protobuf 所具備的特性:
- 語言中立與平臺(tái)中立: Protobuf 不依賴于某一種特殊的編程語言或者操作系統(tǒng)的機(jī)制,意味著我們可以在多種編程環(huán)境中直接使用。(大部分序列化機(jī)制其實(shí)都具有這個(gè)特性,但是某些編程語言提供了內(nèi)置的序列化機(jī)制,這些機(jī)制可能只在該語言的生態(tài)系統(tǒng)內(nèi)有效,例如 Python 的 pickle 模塊)
- 可拓展:Protobuf 可以在不破壞現(xiàn)有代碼的情況下,更新消息類型。具體表現(xiàn)為向后兼容與向前兼容,這一點(diǎn)將在后文做出更詳細(xì)的解釋。
- 更小更快:序列化的目的之一是進(jìn)行網(wǎng)絡(luò)傳輸,在傳輸過程中數(shù)據(jù)流越小傳輸速度自然越快,可以整體提升系統(tǒng)性能。Protobuf 利用字段編號(hào)與特殊的編碼方法巧妙地減少了要傳遞的信息量,并且使用二進(jìn)制格式,相比于 JSON 的文本格式,更節(jié)省空間。
工作流程
假設(shè)我想要將 Person 的信息在前后端之間進(jìn)行傳遞,如果說采用傳統(tǒng) JSON 的形式,那我們可能會(huì)寫出下面這樣的代碼:
// 要發(fā)送的數(shù)據(jù)對象
const data = {
username: 'exampleUser',
password: 'examplePassword'
};
// 將數(shù)據(jù)轉(zhuǎn)換為 JSON 格式的字符串
const jsonData = JSON.stringify(data);
// 發(fā)送 POST 請求
fetch('https://your-api-endpoint.com/login', {
method: 'POST', // 請求方法
headers: {
'Content-Type': 'application/json' // 指定內(nèi)容類型為 JSON
},
body: jsonData // 請求體
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json(); // 解析 JSON 響應(yīng)
})
.then(data => {
console.log(data); // 處理響應(yīng)數(shù)據(jù)
})
.catch(error => {
console.error('There has been a problem with your fetch operation:', error);
});
上述代碼做了下面這幾件事:
- 利用工具函數(shù) JSON.stringify 將要發(fā)送的數(shù)據(jù)對象序列化。(對于復(fù)雜的數(shù)據(jù)結(jié)構(gòu),如果不進(jìn)行序列化,直接發(fā)送 text/plain 的數(shù)據(jù),后端顯然是無法準(zhǔn)確理解目標(biāo)數(shù)據(jù)的,所以序列化在傳輸結(jié)構(gòu)化的數(shù)據(jù)時(shí)起到極其重要的作用)。
- 將序列化后的數(shù)據(jù)使用 fetch 進(jìn)行網(wǎng)絡(luò)傳輸。
- 利用工具函數(shù) response.json 將返回的序列化數(shù)據(jù)反序列化得到目標(biāo)數(shù)據(jù),此時(shí)反序列化后的 data 就是一個(gè)正兒八經(jīng)的 JavaScript 對象,我們可以直接拿來使用。
上述過程其實(shí)就是網(wǎng)絡(luò)傳輸結(jié)構(gòu)化數(shù)據(jù)的通用方法,而 JSON 只是實(shí)現(xiàn)這一目的的常用格式。想必此時(shí)你對序列化的概念已經(jīng)有了足夠的理解,序列化其實(shí)就像一個(gè)翻譯官,將一種編程語言中的數(shù)據(jù)結(jié)構(gòu)轉(zhuǎn)換成一種通用的格式,以便其他編程語言或者其他系統(tǒng)能夠理解和處理。下面我們來看看,如果說我們使用 Proto Buffer 來作為這個(gè)翻譯官,我們的工作流程是怎樣的?
(1) 定義數(shù)據(jù)結(jié)構(gòu):首先,開發(fā)者使用.proto文件來定義數(shù)據(jù)結(jié)構(gòu)。這個(gè)文件是一種領(lǐng)域特定語言(DSL),用來描述數(shù)據(jù)消息的結(jié)構(gòu),包括字段名稱、類型(如整數(shù)、字符串、布爾值等)、字段標(biāo)識(shí)號(hào)等等。
syntax = "proto3";
// 有點(diǎn)類似 TypeScript 的 interface
message Person {
string name = 1;
int32 id = 2;
string email = 3;
}
為什么需要額外定義 proto 文件呢?Proto Buffer 能夠利用該文件中的定義,去做很多方面的事情,例如生成多種編程語言的代碼方便跨語言服務(wù)通信,例如借助字段編碼與類型來壓縮數(shù)據(jù)獲得更小的字節(jié)流,再例如提供一個(gè)更加準(zhǔn)確類型系統(tǒng),為數(shù)據(jù)提供強(qiáng)類型保證。 聽上去或許比較抽象,這里先用一個(gè)簡單的例子來說明 proto 文件的好處之一:如果我們采用 JSON 進(jìn)行序列化,由于 JSON 的類型比較寬松,比如數(shù)字類型不區(qū)分整數(shù)和浮點(diǎn)數(shù),這可能會(huì)導(dǎo)致在不同的語言間交換數(shù)據(jù)時(shí)出現(xiàn)歧義,而 proto 中我們可以定義 float int32 等等更加具體的類型。至于其他好處,希望我能在后文中讓大家逐步理解。
(2) 生成工具函數(shù)代碼:接下來,我們需要使用 protobuf 編譯器(protoc)處理.proto文件,生成對應(yīng)目標(biāo)語言(如C++、Java、Python等)的源代碼。這些代碼包含了數(shù)據(jù)結(jié)構(gòu)的類定義(稱為消息類)以及用于序列化和反序列化的函數(shù)。
(3) 使用生成的代碼進(jìn)行網(wǎng)絡(luò)傳輸:當(dāng)需要發(fā)送數(shù)據(jù)或者接收到消息對象時(shí),我們就可以利用生成代碼中所提供的序列化與反序列化函數(shù)對數(shù)據(jù)進(jìn)行處理了,就像我們使用 JSON.stringify 那樣。
值得注意的是,在利用 Protobuf 進(jìn)行網(wǎng)絡(luò)數(shù)據(jù)傳輸時(shí),確保通信雙方擁有一致的 .proto 文件至關(guān)重要。缺少了相應(yīng)的 .proto 文件,通信任何一方都無法生成必要的工具函數(shù)代碼,進(jìn)而無法解析接收到的消息數(shù)據(jù)。與 JSON 這種文本格式不同,后者即便在沒有 JSON.parse 反序列化函數(shù)的情況下,人們?nèi)阅艽笾峦茢喑鱿?nèi)容。相比之下,Protobuf 序列化后的數(shù)據(jù)是二進(jìn)制字節(jié)流,它并不適合人類閱讀,且必須通過特定的反序列化函數(shù)才能正確解讀數(shù)據(jù)。Protobuf 的這種設(shè)計(jì)在提高數(shù)據(jù)安全性方面具有優(yōu)勢,因?yàn)槿鄙?nbsp;.proto 文件就無法解讀數(shù)據(jù)內(nèi)容。然而,這也意味著在通信雙方之間需要維護(hù)一致的 .proto 文件,隨著項(xiàng)目的擴(kuò)展,這可能會(huì)帶來額外的維護(hù)成本。
實(shí)際應(yīng)用
由于筆者從事的是前端工作,所以此處將使用 Node.js 及其相關(guān)生態(tài)進(jìn)行舉例。由于 protobuf 官方提供的 protoc 并不直接支持由 proto 文件生成 js 代碼,所以我們需要借助一些額外的工具。
倉庫地址:protobuf.js | Github
(1) 安裝所需依賴:npm install protobufjs protobufjs-cli。
(2) 在 src 下新建一個(gè) protos 目錄用于存放 .proto 文件,新建一個(gè) User.proto 文件,添加以下內(nèi)容:
syntax = "proto3";
// 有點(diǎn)類似 TypeScript 的 interface
message Person {
string name = 1;
int32 id = 2;
string email = 3;
}
(3) 在 package.json 中添加一條腳本命令,該命令將會(huì)把所有的 proto 文件編譯到一個(gè) js 模塊中并且生成相應(yīng)的類型聲明。該命令行指令的其他用法請參考上文倉庫中的 README 文件。
syntax = "proto3";
message User {
uint32 id = 1;
string name = 2;
string email = 3;
string password = 4;
}
嘗試運(yùn)行:
npm run proto
會(huì)得到:
protoRoot.js
簡單觀察,我們可以發(fā)現(xiàn)該文件中定義了 User 類以及一些其他的工具函數(shù)。這些工具函數(shù)的具體用法可以參考API 文檔,基本的工作流程如下:
- 對原始的 JavaScript 對象使用 verify 進(jìn)行類型校驗(yàn),隨后使用 create 創(chuàng)建為消息實(shí)例,再利用 encode 將其編碼為二進(jìn)制串。
- 對于二進(jìn)制串,使用 decode 解碼為消息實(shí)例,隨后通過 toObject 轉(zhuǎn)換為原始的 JavaScript 對象。
編寫 index.ts 代碼如下:該代碼展示了將 JavaScript 對象序列化并進(jìn)行網(wǎng)絡(luò)傳輸?shù)倪^程,也模擬了收到 protobuf 數(shù)據(jù)后將其反序列化的過程。
import axios from "axios";
import * as root from "./protoRoot";
const encodeMessage = ()=> {
const payload = {
id: 2333,
name: 'dora',
email: 'dora@mmm.com',
password: '123456',
deprecated: true,
}
// verify 只會(huì)校驗(yàn)數(shù)據(jù)的類型是否合法,并不會(huì)校驗(yàn)是否缺少或增加了數(shù)據(jù)項(xiàng)。
// 雖然上面的對象中多出了一個(gè) deprecated 屬性, 但是 verify 函數(shù)并不會(huì)報(bào)錯(cuò)。事實(shí)上多余的屬性在 encode 時(shí)會(huì)被忽略
const invalid = root.User.verify(payload);
if( invalid) {
console.log(invalid);
throw Error(invalid);
}
const message = root.User.create(payload);
const buffer = root.User.encode(message).finish();
return buffer;
}
const buffer = encodeMessage();
axios.post('http://localhost:3000/',buffer,{
headers: {
'Content-Type': 'application/octet-stream',
responseType: 'arraybuffer',
},
}).then((res)=>{
const buffer = Buffer.from(res.data);
const message = root.User.decode(buffer);
const user = root.User.toObject(message);
console.log(user);
})
// 可以簡單起一個(gè) express 項(xiàng)目來模擬傳輸過程
const express = require("express");
const bodyParser = require("body-parser");
const app = express();
app.use(bodyParser.raw({ type: 'application/octet-stream', limit: '2mb' }));
const port = 3000;
app.post("/", (req, res) => {
const data = req.body;
console.log(data);
res.type('application/octet-stream')
res.send(data);
});
app.listen(port, () => {
console.log(`Server listening at http://localhost:${port}`);
});
以上就是使用 protobuf 進(jìn)行網(wǎng)絡(luò)通信的簡單 demo,事實(shí)上實(shí)際工作中可能還涉及更加復(fù)雜的過程,但由于筆者能力有限暫時(shí)無法給出比較合適的例子。在后文中我將嘗試對 proto 的原理進(jìn)行淺顯的解釋。
語法指南 v3
1.基本語法
讓我們以上面定義的 proto 代碼為例:
syntax = "proto3"; // 指定使用的語法版本, 默認(rèn)情況下是 proto2
// 定義包含四個(gè)字段的消息 User
message User {
uint32 id = 1; // 字段 id 的類型為 uint32,編號(hào) 1
string name = 2; // 字段 name 的類型為 string,編號(hào) 2
string email = 3; // ...
string password = 4;
}
需要注意的是 syntax = "proto3"; 必須是文件的第一個(gè)非空的非注釋行。
在聲明 protobuf 文件的語法版本之后,我們就可以開始定義消息結(jié)構(gòu)。這個(gè)過程在語法上有點(diǎn)類似于 TypeScript 中的 interface 。在定義字段時(shí),必須指明字段的類型,名稱以及一個(gè)唯一的字段編號(hào)。
(1) 類型:proto 提供了豐富的類型系統(tǒng),包括無符號(hào)整數(shù) uint32 、有符號(hào)整數(shù) sint32、浮點(diǎn)數(shù) float 、字符串、布爾等等,你可以在這個(gè)鏈接中查看完整的類型描述。當(dāng)然,除了為字段指定基本的類型意外,你還可以為其指定 enum 或是自定義的消息類型。
(2) 字段編號(hào):每個(gè)字段都需要一個(gè)唯一的數(shù)字標(biāo)識(shí)符,也就是字段編號(hào)。這些編號(hào)在序列化和反序列化過程中至關(guān)重要,因?yàn)樗麄儗⑻娲侄蚊Q出現(xiàn)在序列化后二進(jìn)制數(shù)據(jù)流中。在使用 JSON 序列化數(shù)據(jù)時(shí),其結(jié)果中往往包含人類刻度的字段名稱,例如 { "id": "123456" } ,但是在 protobuf 中,序列化后的結(jié)果中只會(huì)包含字段編號(hào)而非字段名稱,例如在本例中, id 的編號(hào)為 1,那我序列化后的結(jié)果只會(huì)包含 1 而非 id 。這種方法有點(diǎn)類似于 HTTP 的頭部壓縮,可以顯著減少傳輸過程中的數(shù)據(jù)流大小。 事實(shí)上字段編號(hào)的使用是 proto 中非常重要的一環(huán),在使用中務(wù)必遵循以下原則:
- 字段編號(hào)一旦被分配后就不應(yīng)更改,這是為了保持向后兼容性(咱們會(huì)在后文詳細(xì)說明)。
- 編號(hào)在 [1,15] 范圍內(nèi)的字段編號(hào)在序列化時(shí)只占用一個(gè)字節(jié)。因此,為了優(yōu)化性能,對于頻繁使用的字段,盡可能使用該范圍內(nèi)的數(shù)字。同時(shí)也要為未來可能添加的常用字段預(yù)留一些編號(hào)(不要一股腦把 15 之內(nèi)的編號(hào)都用了!)
- 字段編號(hào)從 1 開始,最大值是 29 位,字段號(hào) 19000,19999 是為 Protocol Buffers 實(shí)現(xiàn)保留的。如果在消息定義中使用這些保留字段號(hào)之一,協(xié)議緩沖區(qū)編譯器將報(bào)錯(cuò)提示。
(3) (可選)字段標(biāo)簽:除了上述三個(gè)必須設(shè)置的元素外,你還可以選擇性設(shè)置字段標(biāo)簽:
- optional : 之后字段被顯式指定時(shí),才會(huì)參與序列化的過程,否則該字段將保持默認(rèn)值,并且不會(huì)參與序列化。在 proto3 中所有字段默認(rèn)都是可選的,并不需要使用這個(gè)關(guān)鍵字來聲明字段,除非在某些情況下我們需要區(qū)分字段是否被設(shè)置過。在 proto3 中,如果字段未被設(shè)置,它將不會(huì)包含在序列化的消息之中。在 JavaScript 中,如果一個(gè)字段被指定為 optional 并且沒有設(shè)置值,在解析后的對象將不會(huì)包含該字段(如果沒有指定 optional 將會(huì)包含該字段的默認(rèn)值)。
- repeated:以重復(fù)任意次數(shù)(包括零次)的字段。它們本質(zhì)上是對應(yīng)數(shù)據(jù)類型列表的動(dòng)態(tài)數(shù)組。
- map:成對的鍵/值字段類型,語法類似 Typescript 中的 Record 。
(4) 保留字段:如果你通過完全刪除字段或?qū)⑵渥⑨寔砀孪㈩愋停瑒t未來其他開發(fā)者對類型進(jìn)行自己的更新時(shí)就有可能重用字段編號(hào)。當(dāng)舊版本的代碼遇到新版本生成的消息時(shí),由于字段編號(hào)的重新分配,可能會(huì)引發(fā)解析錯(cuò)誤或不預(yù)期的行為。為了避免這種潛在的兼容性問題,protobuf 提供 reserved 關(guān)鍵字來明確標(biāo)記不再使用的字段編號(hào)或標(biāo)識(shí)符,如果將來的開發(fā)者嘗試使用這些標(biāo)識(shí)符,proto 編譯器將會(huì)報(bào)錯(cuò)提醒。
message Foo {
reserved 2, 15, 9 to 11, 40 to max;
// 9 to 11 表示區(qū)間 [9,11], 40 to max 表示區(qū)間 [40, 編號(hào)的最大值]
reserved "foo", "bar";
}
2.默認(rèn)值
在解析消息時(shí),如果編碼的消息中并不包含某個(gè)不具有字段標(biāo)簽的字段,那么解析后對象中的響應(yīng)字段將設(shè)置為該字段的默認(rèn)值。默認(rèn)值的規(guī)則如下:
- 對于 string ,默認(rèn)值為空字符串
- 對于 byte , 默認(rèn)值為空字節(jié)
- 對于 bool , 默認(rèn)值為 false
- 對于數(shù)字類型,默認(rèn)值為 0
- 對于 enum 類型,默認(rèn)值為第一個(gè)定義的枚舉值(編號(hào)為 0)
假設(shè)某個(gè)字段具有 optional 字段標(biāo)簽(或是其他什么的標(biāo)簽),那么在解析后的對象中將不會(huì)存在這些字段。
3.兼容性
如果現(xiàn)有的消息類型不再滿足您的需求,你可以對其進(jìn)行一定程度的變更。
- 如果添加新字段,請勿更改任何現(xiàn)有字段的字段編號(hào)。舊的消息依然能被新生成的工具函數(shù)解析,新增的字段將會(huì)使用默認(rèn)值;同樣,新的消息也能被舊的工具函數(shù)所解析(新增的字段將會(huì)被忽略)。
- 如果刪除字段,請記得保留字段編號(hào),以免在未來重復(fù)使用導(dǎo)致預(yù)期之外的錯(cuò)誤。
- 如果你想要進(jìn)行字段類型的變更,一種方式是刪除原有字段隨后新建一個(gè),另外一個(gè)方式就是直接修改某些可以無縫兼容的類型(例如 int32 轉(zhuǎn)變?yōu)?nbsp;int64 ,顯然不會(huì)丟失信息),具體有哪些屬性是兼容的,可以查閱字段更新說明
除了上述提到的語法之外,其實(shí)還有很多進(jìn)階的操作,例如:
- 聲明 Package 關(guān)鍵字區(qū)分命名空間。
- 使用 oneof 類型表示特殊的消息(包含多個(gè)字段,但這些字段在任何給定時(shí)間只能有一個(gè)字段被設(shè)置)
- 使用 service 定義定義服務(wù)端接口的請求與響應(yīng)格式。
- 使用 import 導(dǎo)入其他文件中的消息定義。
請自行參考官方文檔吧!一個(gè)包含了大部分語法特性的代碼如下:
syntax = "proto3"; // 指定 protobuf 的版本
package example; // 定義包名
// 導(dǎo)入其他 protobuf 文件
import "google/protobuf/timestamp.proto";
import "other_package/other_file.proto";
// 定義一個(gè)枚舉類型
enum State {
UNKNOWN = 0; // 枚舉值必須從 0 開始
STARTED = 1;
RUNNING = 2;
STOPPED = 3;
}
// 定義一個(gè)消息類型
message Person {
// 定義一個(gè)字符串字段
string name = 1; // 字段編號(hào)必須是唯一的正整數(shù)
// 定義一個(gè)整型字段
int32 id = 2; // 這里的 2 是字段編號(hào)
// 定義一個(gè)布爾字段
bool has_pony = 3;
// 定義一個(gè)浮點(diǎn)字段
float salary = 4;
// 定義一個(gè)枚舉字段
State state = 5;
// 定義一個(gè)重復(fù)字段(類似于列表)
repeated string emails = 6;
// 定義一個(gè)嵌套消息
message Address {
string line1 = 1;
string line2 = 2;
string city = 3;
string country = 4;
string postal_code = 5;
}
// 定義一個(gè)嵌套消息字段
Address address = 7;
// 定義一個(gè) map 字段(類似于字典)
map<string, string> phone_numbers = 8;
// 定義一個(gè)任意類型字段
google.protobuf.Any any_field = 9;
// 定義一個(gè)時(shí)間戳字段
google.protobuf.Timestamp last_updated = 10;
// 定義一個(gè)從其他文件導(dǎo)入的消息類型字段
other_package.OtherMessage other_field = 11;
// 定義一個(gè) oneof 字段,可以設(shè)置其中一個(gè)字段
oneof test_oneof {
string name = 12;
int32 id = 13;
bool is_test = 14;
}
}
// 定義一個(gè)服務(wù)
service ExampleService {
// 定義一個(gè) RPC 方法,請求類型為 GetPersonRequest 響應(yīng)類型為 Person
rpc GetPerson(GetPersonRequest) returns (Person);
}
// 定義 GetPerson RPC 方法的請求消息類型
message GetPersonRequest {
int32 person_id = 1;
}
4.數(shù)據(jù)編碼
該部分內(nèi)容細(xì)節(jié)主要參考官方文檔: protobuf.dev/program...
Protobuf 采用了一種稱為 Tag-Length-Value(TLV)的編碼方案,在開始之前,我們可以利用以下函數(shù)幫助我們打印出編碼后的二進(jìn)制序列方便觀察:
function toBinaryString(uint8Array) {
return Array.from(uint8Array).map(byte => byte.toString(2).padStart(8, '0'));
}
/*
const payload = {
name: 'dora',
}
const message = root.User.create(payload);
const buffer = root.User.encode(message).finish();
console.log(toBinaryString(buffer));
*/
現(xiàn)在讓我們對一個(gè) string 類型的數(shù)據(jù) t 進(jìn)行編碼,可以得到序列: 00001010 00000001 01110100 。
這三個(gè)字節(jié)分別對應(yīng)了 protobuf 編碼的三個(gè)內(nèi)容:(在 protobuf 中每個(gè)字節(jié)的首位都是控制位,用于表示隨后的字節(jié)是否需要和自己屬于同一個(gè)字段)
5.Tag
標(biāo)簽由字段編號(hào)與字段類型組成,其編碼格式為:(field_number << 3) | wire_type
例如 0 | 0001 | 010 表示當(dāng)前字段的類型是 3(010),字段編號(hào)是 1 (0001)。對于更大的字段編號(hào)例如 18,其 Tag 部分編碼序列可能為: 10010010 00000001 ,第一個(gè)字節(jié)去除控制位與字段類型剩下 0010 與后續(xù)字節(jié)逆序(考慮到大端小端字節(jié)序)拼接形成 00000001 0010(2+16=18) 。這就是為什么對于頻繁使用的字段最好將其字段編號(hào)設(shè)置在 [1,15] 之間,因?yàn)檫@樣編碼后的 tag 部分只會(huì)占據(jù)一個(gè)字節(jié),能有效利用空間。另外從編碼后的結(jié)果來看,我們只保留了字段對應(yīng)的編號(hào),并沒有把字段的名稱也添加進(jìn)來,這能夠非常有效地減少字節(jié)流大小。
那么字段類型是什么呢?
字段類型用于告訴解析器它后面的有效載荷有多大,從而允許舊的解析起跳過他們不理解的新字段。前面這句話其實(shí)是官方文檔做出的解釋,當(dāng)個(gè)人認(rèn)為理解起來較為困難。最好結(jié)合實(shí)際來看,例如對于 I32 類型 ,其有效載荷是固定 4 個(gè)字節(jié)的,也就是說 Tag 之后的 4 個(gè)字節(jié)是屬于當(dāng)前字段的;對于 LEN 類型,其有效載荷則需要通過后續(xù) length 部分的編碼才能確定;而對于 VARINT 類型,其有效載荷長度由編碼后的數(shù)字長度決定(并不需要由 length 部分決定)。那么舊的解析器遇到未知的字段時(shí),只需要根據(jù)不同字段類型的規(guī)則跳過特定長度的有效載荷就能夠跳過那些無法理解的字段了。所有字段類型如下:
6.Length
對于具有長度的字段,例如字符串、列表等等,編碼后的序列需要顯式指定字段的長度。對于上面的例子,長度為 1 的字符串 t 編碼后的第二個(gè)字節(jié)就是用來指定字符串長度的00000001,后續(xù)的字節(jié)則用來表示每個(gè)字符的 ASCII 值。
7.Varint 編碼
Varint 編碼是一種用于壓縮數(shù)據(jù)的變長編碼方法,特別適用于編碼較小的正整數(shù),但對于大整數(shù)和負(fù)數(shù)來說,反而會(huì)表現(xiàn)糟糕。 在傳統(tǒng)的 int32 類型中,一個(gè)數(shù)字比如 150(128 + 16 + 4 + 2) 會(huì)占用四個(gè)字節(jié): 00000000 00000000 00000000``10010110。可以發(fā)現(xiàn)前 3 個(gè)字節(jié)都是 0 并沒有攜帶有效信息,導(dǎo)致存儲(chǔ)空間的浪費(fèi)。Varint 編碼通過變長編碼優(yōu)化了這一點(diǎn),將同樣的數(shù)字 150 編碼為僅需要兩個(gè)字節(jié)的序列: 10010110 00000001 。
Varint 編碼的工作原理如下:每個(gè)字節(jié)的最高位(最左邊的一位)用作控制位,指示隨后的字節(jié)是否也屬于這個(gè)數(shù)的編碼。如果該位為 1,則表示后續(xù)還有字節(jié);如果是 0,則表示這是最后一個(gè)字節(jié)。每個(gè)字節(jié)剩余的七位則用于表示實(shí)際的數(shù)字。(對于變長編碼,顯然我們需要一個(gè)信息位來表示是否到達(dá)了編碼末尾。)
10010110 00000001 // 原始字節(jié)流
// 10010110 開頭的 1 說明后面字節(jié) 00000001 也是編碼的一部分
0010110 0000001 // 丟棄信息位
0000001 0010110 // 由于 varint 編碼時(shí)采用小段順序,我們需要將其調(diào)換順序轉(zhuǎn)換為大段順序
00000010010110 // 鏈接有效載荷
128 + 16 + 4 + 2 = 150 // 解釋為無符號(hào) 64 位整數(shù)
這就是 Varint 編碼的工作原理,上述例子也說明了在處理小整數(shù)時(shí)的確非常高效。但它在編碼較大的整數(shù)時(shí)會(huì)需要更多的字節(jié),這是因?yàn)槊總€(gè)字節(jié)只能貢獻(xiàn)七位有效數(shù)據(jù)。對于負(fù)數(shù),由于它們在計(jì)算機(jī)中通常以補(bǔ)碼形式表示,這使得它們在 varint 編碼中看起來像是非常大的整數(shù),因此編碼效率也不理想。例如 -5: 11111111 11111111 11111111 11111011,對于 sintN 類型的數(shù)據(jù), protobuf 中采用的是后文將要提到 ZigZag 編碼。
ZigZag 編碼
工作原理:將所有整數(shù)映射成無符號(hào)整數(shù),然后再采用 Varint 編碼方式編碼。 其基本思想是負(fù)數(shù)和正數(shù)進(jìn)行交錯(cuò),使得負(fù)數(shù)映射為奇數(shù),例如 0 -> 0, -1 -> 1, 1 -> 2, -2 -> 3 。這樣的好處是,無論正負(fù),數(shù)值的絕對大小都能較為緊湊地表示。具體的映射方式如下:
(n << 1) ^ (n >> 31) // 32 bit
(n << 1) ^ (n >> 63) // 64 bit
具體而言,當(dāng)我們使用 ZigZag 對 -5 進(jìn)行編碼時(shí),結(jié)果為 00001001 。
最佳實(shí)踐
建議閱讀官方文檔:protobuf.dev/program...
- 不要重復(fù)使用字段編號(hào),如果你想要?jiǎng)h除某個(gè)字段,請使用 reserved 關(guān)鍵字保留該字段對應(yīng)的字段編號(hào)。
- 不要輕易改變已有字段的類型,盡管在某些情況下是安全的。
- 在單獨(dú)的文件中定義廣泛使用的消息類型。
- 避免使用語言的關(guān)鍵字作為字段名稱。
- 不要依賴于 protobuf 序列化的穩(wěn)定性
- map 序列化時(shí)的順序是不確定的。
- 不要使用序列化后的內(nèi)容作為 key。
- 不要通過比較序列化后的內(nèi)容來確定兩條消息是否相同。
個(gè)人建議:
- 常用字段盡量使用 [1,15] 內(nèi)的字段編碼,也注意為日后可能的拓展保留該區(qū)間的字段;
- 盡量使用小整數(shù)。
- 如果負(fù)數(shù)占據(jù)數(shù)據(jù)的大多數(shù),請使用 sintN 類型。
總結(jié)
序列化目的是跨平臺(tái)存儲(chǔ)或者網(wǎng)絡(luò)傳輸,而 protobuf 作為一款序列化的協(xié)議,其最主要的特點(diǎn)就是序列化后的數(shù)據(jù)更小,傳輸更快并且具有良好的兼容性;主要的缺點(diǎn)就是通信雙方必須維護(hù)好 proto 定義文件。