比較 JSON 和 protobuf 並介紹 protobuf編碼、語法及 protobuf over HTTP 示例 | 不輟集

比較 JSON 和 protobuf 並介紹 protobuf編碼、語法及 protobuf over HTTP 示例

目錄
  1. 1. JSON 是什麼?
  2. 2. protobuf 是什麼?
  3. 3. 開始使用 protobuf
  4. 4. JSON 和 protobuf 的編碼大不同
    1. 4.1. Varint 編碼
    2. 4.2. protobuf 編碼規則
    3. 4.3. 解讀序列 086c 1205 446fb6b79
    4. 4.4. ZigZag 編碼
  5. 5. proto3 語法
    1. 5.1. proto 風格
  6. 6. 不適用 protobuf 的情況
  7. 7. 問答
    1. 7.1. 為什麼 protobuf 叫 protobuf?
    2. 7.2. protobuf 是否可以在 HTTP 中使用?
    3. 7.3. 示例:HTTP over protobuf
      1. 7.3.1. Go 服務端
      2. 7.3.2. Java 服務端
  8. 8. 閱讀更多

Protocol Buffers(簡稱 protobuf),是 Google 推出的一種數據交換格式,採用 Varint 和 ZigZag 等二進制編碼,數據壓縮效果顯著,可用來傳輸數據或持久化數據。

JSON 是什麼?

JSON 全稱爲 JavaScript Object Notation(JavaScript對象標記),即 JS對象的字符串表示。其採用文本編碼,是現今最通用的數據交換格式。2001年3月,State Software公司的聯合創始人設計了此種格式,並隨後進行了標準化。現在有 ECMA-404(2013年)和 RFC-8259(2017年)兩種標準。

protobuf 是什麼?

protobuf 全稱爲 Protocol Buffers(“協議緩衝”),是一種數據壓縮性能優秀的數據存儲和交換格式。其採用二進制編碼,通常跟 gRPC 一起使用。

2001年 Google公司內部誕生了proto1版本,並隨後在2008年以BSD協議開源了proto2,2016年釋出proto3正式版。

對於 proto2,官方推出了針對 C++、Java、C# 和 Python 語言的 protobuf編譯器 protoc;而在 proto3 中,增加了對 Dart、GO、Kotlin 和 Ruby 的官方支持。另外,第三方有提供對 JavaScript 和 PHP 等等語言的支持。

開始使用 protobuf

(1)下載編譯器並設置環境變量。

# protoc
PB_REL="https://github.com/protocolbuffers/protobuf/releases"
curl -LO $PB_REL/download/v3.15.8/protoc-3.15.8-osx-x86_64.zip
unzip protoc-3.15.8-osx-x86_64.zip -d ~/go/bin/protoc-3.15.8
cp ~/go/bin/protoc-3.15.8/bin/protoc ~/go/bin

# protoc-gen-go 和 protoc-gen-go-grpc
go get google.golang.org/protobuf/cmd/protoc-gen-go \
google.golang.org/grpc/cmd/protoc-gen-go-grpc
export PATH="$PATH:$(go env GOPATH)/bin"

(2)選擇使用 proto2 還是 proto3,不同的版本有不同的語法,且不兼容。相對而言,proto3 的語法更簡單。

(3)根據語法編寫 proto 文件。

syntax = "proto3";

option go_package = "/pb";

option java_package = "com.example.m.pb";
option java_outer_classname = "AnimalProto";

package pb;

message Animal{
// reserved 1 to 10;
// reserved "id";
int64 id = 1;
string name = 2;
}

(4)生成需要的語言代碼

protoc --go_out=.  animal.proto

生成後的文件 animal.pb.go 如下:

// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.27.1
// protoc v3.15.8
// source: animal.proto

package pb

import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
)

const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)

type Animal struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields

Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
}

...

JSON 和 protobuf 的編碼大不同

我們舉一個例子來說明會比較直觀。

柯基犬

這是一隻柯基犬,它叫 Dokky,編號 12。我們可以將這些信息結構化成對象如下:

type Animal struct{
Id int64 // 12
Name string // "Dokky"
}

編寫程序分別對該 Animal 進行 JSON 和 protobuf 序列化:

func marshal() {
dokky := pb.Animal{
Id: 12,
Name: "Dokky",
}

pbs, _ := proto.Marshal(&dokky)
printBytes(pbs)

jbs, _ := json.Marshal(&dokky)
printBytes(jbs)
}

func printBytes(bs []byte) {
println(len(bs), hex.EncodeToString(bs))
}

結果:

9 080c1205446f6b6b79
24 7b226964223a31322c226e616d65223a22446f6b6b79227d

(1)JSON 序列化

{
"id": 12,
"name": "Dokky"
}

二進制形式(Unicode編碼):

7b
22 6964 22 3a 3132 2c
22 6e616d65 22 3a 22 446f6b6b79 22
7d

共佔用 24 個字節。

(2)protobuf 序列化

086c 1205 446fb6b79

共佔用 9 個字節。

通過輸出的結果簡單對比兩種序列化方式可見,protobuf

  1. 不編碼變量名;
  2. 沒有額外的 {}[],:"" 等字符;
  3. 對整型不使用文本編碼。

要具體解讀這串序列,我們還得先來了解下 protobuf 的編碼方式。

Varint 編碼

Varint 即 Variable int,可變長整型編碼,是 protobuf 中最主要的編碼方式。其採用小端二進制編碼,即 LSB(Least Significant Bit,最低有效位)置於低地址。每個字節的首位即 MSB(Most Significant Bit,最高有效位)用來標識下個字節是否需要讀取, 1 表示需要,0 反之。

以數字 12 為例(爲簡便起見,假定類型爲 int16):

      二進制序列:0000 0000 0000 1100
從末尾開始7位一組: 000 1100
添加MSB:0000 1100
十六進制表示:0C

再以數字 255 為例(爲簡便起見,假定類型爲 int16):

      二進制序列:0000 0000 1111 1110
從末尾開始7位一組: 111 1110 000 0001
添加MSB:1111 1110 0000 0001
十六進制表示:FE01

protobuf 編碼規則

一般來說,在protobuf 中每個字段拆分成四個部分進行編碼,依次是:

protobuf 字段結構

  1. number:字段序號;
  2. type:字段類型,佔用 3 個比特位,與 number 一起構成字段標識 tag,佔用一個或多個字節,採用 Varint 編碼;其取值含義見下表;
  3. length(可選):字段長度,當 type 爲 2 時有值;
  4. value:字段值,爲零值的字段不會進行編碼。
Type Meaning Used for
0(000) Varint(可變長整型) int32、int64、uint32、uint64、sint32、sint64、bool、enum
1(001) 64-bit(固定64位) fixed64、sfixed64、double
2(010) Length-delimited(指定長度) string、bytes、embedded message、packed repeated fields
5(101) 32-bit(固定32位) fixed32、sfixed32、float

解讀序列 086c 1205 446fb6b79

08 => 00001 000 => number: 1, type: 0
0C => value: 12

序號爲 1 的字段,類型爲 Varint,值爲 12。

12 => 00010 010 => number: 2, type: 2

05 => length: 5

446fb6b79 => value: Dokky

序號爲 2 的字段,類型爲 Length-delimited,值爲 Dokky。

ZigZag 編碼

前面我們討論了非負數的 Varint 編碼,現在我們來看看負數。其實對於負數,並不建議使用 int32 或 int64 等類型,而應使用 sint32 或 sint64,即帶符號的整型。為什麼呢?下面我們對 -1 分別指定 int 32 和 sint32 類型進行編碼:

(1)int32(-1)

使用 int32 或 int64 類型時,進行 Varint 編碼,負數總是佔用 10 個字節。因此 int32(-1) 編碼爲:

      二進制序列:11111111  11111111  11111111  11111111
11111111 11111111 11111111 11111111
從尾部開始7位一組: 1111111 1111111 1111111 1111111
1111111 1111111 1111111 1111111 1111111 0000001
添加MSB:11111111 11111111 11111111 11111111
11111111 11111111 11111111 11111111 11111111 00000001
十六進制表示:FF FF FF FF
FF FF FF FF FF 01

(2)sint32(-1)

當使用 sint32 或 sint64 時,會先進行 ZigZag 編碼再進行 Varint 編碼。ZigZag 就是將負數放到整數前面,交替進行編碼,呈“Z” 或 “之” 字形,故有此稱。

對 sint32, 數字 n 的 ZigZag 編碼爲:(n << 1) ^ (n >> 31)

對 sint64, 數字 n 的 ZigZag 編碼爲:(n << 1) ^ (n >> 63)

因此 sint32(-1) 編碼爲:

      二進制序列: 11111111  11111111  11111111  11111111
n << 1: 11111111 11111111 11111111 11111110
n >> 31: 11111111 11111111 11111111 11111111
異或: 00000000 00000000 00000000 00000001
從尾部開始7位一組: 0000001
添加MSB: 00000001
十六進制表示: 01

可見,int32(-1) 佔用 10 個字節,而 sint32(-1) 佔用 1 個字節。在值可能爲負數的情況下,應使用帶符號整型,可以大大減少負數的編碼量。

proto3 語法

// 指定使用的語言版本
syntax = "proto3";

// 指定生成文件的 Go 包路徑,使用 '/' 分隔
option go_package = "/pb";

// 指定生成文件的 Java 包路徑,使用 '.' 分隔
option java_package = "com.example.m.pb";
// 定義生成文件的 Java 類名
option java_outer_classname = "AnimalProto";

// 指定包名
package pb;

// 定義一個名為 Animal 的消息
message Animal{
// 定義一個名為 id 的 int64 字段
int64 id = 1;
// 定義一個名為 name 的 string 字段
string name = 2;
// 保留第3~5的字段序號
reserved 3 to 5;
// 保留字段名 kind
reserved "kind";
// 使用 enum 類型
Vision vision = 6;

// 以下字段的值只允許是 HumanDifference 或 OtherAnimalDifference
// 設置其中一個值將會清除另一個的值
oneof difference{
// 使用自定義的結構類型
HumanDifference human = 8;
OtherAnimalDifference other = 9;
}
}

// 定義一個枚舉類型,按風格要求第一個值須設置為未指定
enum Vision{
VISION_UNSPECSIFIED = 0;
VISION_GREAT = 1;
VISION_BURRED = 2;
VISION_BLIND = 3;
}

message HumanDifference{
// 定義一個名為 lang 的 string 數組
repeated string langs = 1;
}

message OtherAnimalDifference{
// 字段名採用全小寫下划線連接
repeated string natural_enemies = 1;
}

注意:

  1. 建议将变量序号 1~15 的给最常用的字段用,并保留扩展;
  2. message 可以导入;
  3. 字段有默认零值,零值不会被序列化;
  4. 字段序號一旦指定,一般不改動,否則會向後不兼容。
  5. 當值為 1~128(2^7) 時使用 int32 類型僅用 1 個字節,當值為 129~2^14 時僅用 2 個字節;
  6. 對大於 2^28 的數,需要用到 5 個字節及以上的空間,這時候使用 fixed32 或 fixed64 更為划算。

proto3 定義了以下的標量類型(Scalar Type, hold one value at a time)

Scalar Type Meaning Go Type Java/Kotlin Type
double 雙精度浮點型 float64 double
float 浮點型 float float
int32 使用Varint編碼的32位整型 int32 int
int64 使用Varint編碼的64位整型 int64 long
uint32 使用Varint編碼的無符號32位整型 uint32 long
uint64 使用Varint編碼的無符號64位整型 uint64 long
sint32 使用Varint編碼的有符號32位整型 int32 int
sint64 使用Varint編碼的有符號64位整型 int64 long
fixed32 固定32位的整型 uint32 int
fixed64 固定64位的整型 uint64 long
sfixed32 固定長度的有符號32位整型 int32 int
sfixed64 固定長度的有符號64位整型 int64 long
bool 布爾型 bool boolean
string 採用UTF-8或者7位ASCII碼編碼的字符串 string String
bytes 字節序列 []byte ByteString

proto 風格

  1. 文件名、字段名建議使用全小寫字母加下划線(lower_snake_case)
  2. 每行長度限定在 80 個字符;
  3. 使用 2 個空格作為縮緊;
  4. 字符串使用雙引號包裹;
  5. repeated 類型的字段名使用複數形式;
  6. enum 類型第一個枚舉值建議以 UNSPECIFIED 為後綴標明為「未指定值」,值為 0;
  7. RPC 服務名及方法名採用大駝峰。

不適用 protobuf 的情況

  1. 目前官方僅支持 C++、C#、Dart、Go、Java、Kotlin、Python 和 Ruby,其它語言有的有第三方支持,需慎重考慮;
  2. 傳輸超過 1MB 的 message 時建議另擇策略,protobuf 並非爲大數據集而設計;
  3. 當枚舉值的零值在應用中有特殊含義時,不應使用 protobuf 中的 enum,因 protobuf 中的 enum 默認零值只應該是未指定的意思;
  4. 需要使用到 protobuf 中未定義或不完善的類型時。

問答

為什麼 protobuf 叫 protobuf?

Protocol 即協議,指的是商定的消息協議或者說消息格式,它存放在 proto 文件中。

而 Buffers 呢?

在早期的實現中並沒有編譯器自動生成類,而是有一個名為 ProtocolBuffer 的類實現了一個 Buffer,用戶通過 AddValue(tag, value) 方法添加 tag/value 對(以原始字節方式存儲)到 Buffer中,然後再讀取出來。這個 tag 其實是用來標示字段序號和類型,value 則是編碼後的字段值。

這個 Buffer 在新的版本中已經不在,但名稱保留下來了。

protobuf 是否可以在 HTTP 中使用?

gRPC 基於 HTTP2 二進制協議,比 HTTP 文本協議傳輸數據量小;而且長連接,減少建立連接的消耗。protobuf 是一種二進制的數據交換格式,序列化後傳輸的數據量很小,可以說 protobuf + gRPC 結合很登對。

通常 JSON + HTTP 在上層應用中很流行,JSON 是一種文本型的數據交換格式,易讀,簡單地將對象轉為 JSON 字符串,然後再以其二進制形式傳輸即可。

但其實 protobuf 是可以在 HTTP 協議中運用的,使用 Content-Type 為 application/x-protobuf,將使用 proto 序列化後的二進制流傳輸到客戶端,客戶端再使用 proto 的反序列化方法解析即可。

示例:HTTP over protobuf

Go 服務端

server.go

package main

import (
"encoding/hex"

"example.com/m/pb"
"github.com/gin-gonic/gin"
"google.golang.org/protobuf/proto"
)

func main() {
s := gin.Default()
s.GET("/whoami", func(c *gin.Context) {
animal := pb.Animal{
Id: 12,
Name: "Dokky",
}
bs, _ := proto.Marshal(&animal)
println(hex.EncodeToString(bs))
c.Data(200, "application/x-protobuf", bs)
})
s.Run()
}

server_test.go

package main

import (
"encoding/hex"
"io/ioutil"
"net/http"
"testing"
"example.com/m/pb"
"google.golang.org/protobuf/proto"
)

func TestWhoami(t *testing.T) {
request, err := http.NewRequest("GET", "http://localhost:8080/whoami", nil)
if err != nil {
t.Error(err)
}
request.Header.Add("Content-Type", "application/x-protobuf")
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Error(err)
}
bs, err := ioutil.ReadAll(response.Body)
if err != nil {
t.Error(err)
}
t.Log(hex.EncodeToString(bs))
whami := &pb.Animal{}
proto.Unmarshal(bs, whami)
t.Log(whami)
}

運行命令:

# 運行服務端
go run server.go
# 運行測試程序
go test -test.v server_test.go
# 或直接通過 curl 訪問
curl --header "Content-Type: application/x-protobuf" localhost:8080/whoami

Java 服務端

Server.java

package com.example.m;

import com.example.m.pb.AnimalProto;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import org.apache.commons.codec.binary.Hex;

import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.util.List;

public class Server {
public static void main(String[] args) throws IOException {
HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 8080), 0);
server.createContext("/whoami", new WhoamiHandler());
server.start();
}

static class WhoamiHandler implements HttpHandler{
@Override
public void handle(HttpExchange exchange) throws IOException {
List<String> contentTypes = exchange.getRequestHeaders().get("Content-Type");
if (contentTypes==null || (contentTypes.size()>0 && !contentTypes.get(0).equals("application/x-protobuf"))){
exchange.sendResponseHeaders(404,0);
exchange.close();
return;
}

OutputStream outputStream = exchange.getResponseBody();
AnimalProto.Animal animal = AnimalProto.Animal.newBuilder().setId(12).setName("Dokky").build();
byte[] bs = animal.toByteArray();
System.out.println(Hex.encodeHexString(bs));
exchange.sendResponseHeaders(200, bs.length);
outputStream.write(bs);
outputStream.flush();
outputStream.close();
exchange.close();
}
}
}

ServerTest.java

package com.example.m;

import com.example.m.pb.AnimalProto;
import org.apache.commons.codec.binary.Hex;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.junit.jupiter.api.Test;
import sun.misc.IOUtils;

import java.io.IOException;

class ServerTest {

@Test
public void testServer() throws IOException {
CloseableHttpClient httpClient = HttpClients.createDefault();
HttpGet httpGet = new HttpGet("http://localhost:8080/whoami");
httpGet.setHeader("Content-Type","application/x-protobuf");
CloseableHttpResponse response = httpClient.execute(httpGet);
byte[] bs = IOUtils.readAllBytes(response.getEntity().getContent());
System.out.printf("%d, %s\n",response.getCode(), Hex.encodeHexString(bs));
AnimalProto.Animal animal = AnimalProto.Animal.parseFrom(bs);
System.out.printf("%d, %s\n", animal.getId(),animal.getName());
EntityUtils.consume(response.getEntity());
httpClient.close();
}
}

閱讀更多

  1. protobuf 官方文檔
  2. protobuf 代碼倉庫