protobuf從聽過到了解

本文主要針對Protobuf進行介紹,主要針對版本proto2,給出demo來講解proto語法,并對其中部分編解碼原理進行講解,最后進行總結(jié)和思考

介紹

官網(wǎng) https://developers.google.com/protocol-buffers/docs/overview

Protocol buffers are a flexible, efficient, automated mechanism for serializing structured data – think XML, but smaller, faster, and simpler. You define how you want your data to be structured once, then you can use special generated source code to easily write and read your structured data to and from a variety of data streams and using a variety of languages. You can even update your data structure without breaking deployed programs that are compiled against the "old" format.

特點:

靈活高效,自動的序列化,反序列化機制,定義IDL,生成代碼,支持跨語言,有很好的兼容性

適用場景

**Protocol buffers 很適合做數(shù)據(jù)存儲或 RPC 數(shù)據(jù)交換格式??捎糜谕ㄓ崊f(xié)議、數(shù)據(jù)存儲等領(lǐng)域的語言無關(guān)、平臺無關(guān)、可擴展的序列化結(jié)構(gòu)數(shù)據(jù)格式**。

歷史背景

proto2 和 proto3 的名字看起來有點撲朔迷離,那是因為當我們最初開源的 protocol buffers 時,它實際上是 Google 的第二個版本了,所以被稱為 proto2,這也是我們的開源版本號從 v2 開始的原因。初始版名為 proto1,從 2001 年初開始在谷歌開發(fā)的。

現(xiàn)實應(yīng)用: grpc

Demo

proto文件

proto文件,類似idl定義,下面文件是a.proto

`syntax="proto2";`

`package main;`
`message Person {`
`required string name = 1;`
`required int32 age = 2;`
` }`
` message Person1 {`
`required string name = 1;`
`optional int32 age = 2;`
`  }`

main.go

package main

import (
    "fmt"
    "io/ioutil"

    "github.com/golang/protobuf/proto"
)

//go run *.go

//protoc  --go_out . a.proto
/**
message Person {
     required string name = 1;
     required int32 age = 2;
}
*/

func main() {
    //testPerson()
    testPerson1()
}
func testPerson() {
    // s 1
    //[10 1 115 16 1]
    // s 2
    //[10 1 115 16 2]
    // s 3
    // [10 1 115 16 3]
    // a 3
    // [10 1 97 16 3]
    // z 3
    // [10 1 122 16 3]
    // z 300
    // [10 1 122 16 172 2]
    // z 255
    // [10 1 122 16 255 1]
    // z 256
    // [10 1 122 16 128 2]
    // z 257
    // [10 1 122 16 129 2]
    // z 258
    // [10 1 122 16 130 2]
    // z 259
    // [10 1 122 16 131 2]
    // z 127
    // [10 1 122 16 127]
    // z 128
    // [10 1 122 16 128 1]
    // z 129
    // [10 1 122 16 129 1]
    // z 131
    // [10 1 122 16 131 1]
    /**
      z 512
      10 1 122 16 128 4
      z 511
      [10 1 122 16 255 3]
      z 5110
      [10 1 122 16 246 39]
      zz 5100
      [10 2 122 122 16 246 39]
    */

    // tag << 3| wiretype
    /**
      10 = 1<<3  | string type = 8|2 = 10
      1 = 字符串長度
      字符串的ascii碼
      16 = 2<<3 | varint type = 16|0 = 16

      127
      0111 1111

      128 = 2^8
      1000000
      低7位+前綴1 + 低0位 + 剩余位
      = (1)000000 + (0)0000001

      511
      =1 1111 1111
      1+低七位,補0+高2位
      1 111111 000000 11
      255 3

      5110
      0001 0011 1111 0110
      100111 1110110
      11110110 0100111
      246 39
    */
    name := "z"
    age := int32(150)
    person := &Person{
        Name: &name,
        Age:  &age,
    }
    fmt.Println("person : ", person)

    fname := "address.dat"
    // 將person進行序列化
    out, err := proto.Marshal(person)
    fmt.Println(out)
    if err != nil {
        fmt.Println("Failed to encode address person:", err)
    }
    // 將序列化的內(nèi)容寫入文件
    if err := ioutil.WriteFile(fname, out, 0644); err != nil {
        fmt.Println("Failed to write address person:", err)
    }

    // 讀取寫入的二進制數(shù)據(jù)
    in, err := ioutil.ReadFile(fname)
    if err != nil {
        fmt.Println("Error reading file:", err)
    }

    // 定義一個空的結(jié)構(gòu)體
    person2 := &Person{}
    // 將從文件中讀取的二進制進行反序列化
    if err := proto.Unmarshal(in, person2); err != nil {
        fmt.Println("Failed to parse address person:", err)
    }

    fmt.Println("person2: ", person2)
}

func testPerson1() {
    name := "a"
    age := int32(1)
    person := &Person{
        Name: &name,
        Age:  &age,
    }
    fmt.Println("person : ", person)

    fname := "address.dat"
    // 將person進行序列化
    out, err := proto.Marshal(person)
    fmt.Println(out)
    if err != nil {
        fmt.Println("Failed to encode address person:", err)
    }
    // 將序列化的內(nèi)容寫入文件
    if err := ioutil.WriteFile(fname, out, 0644); err != nil {
        fmt.Println("Failed to write address person:", err)
    }

    // 讀取寫入的二進制數(shù)據(jù)
    in, err := ioutil.ReadFile(fname)
    if err != nil {
        fmt.Println("Error reading file:", err)
    }

    // 定義一個空的結(jié)構(gòu)體
    person2 := &Person1{}
    // 將從文件中讀取的二進制進行反序列化
    if err := proto.Unmarshal(in, person2); err != nil {
        fmt.Println("Failed to parse address person:", err)
    }

    fmt.Println("person2: ", person2)
}

proto語法

語法規(guī)則詳見 https://developers.google.com/protocol-buffers/docs/proto

這里作出部分說明
在 proto 中,所有結(jié)構(gòu)化的數(shù)據(jù)都被稱為 message。
如果開頭第一行不聲明 ?syntax = "proto3";?,則默認使用 proto2 進行解析。

分配字段編號

每個消息定義中的每個字段都有**唯一的編號**。這些字段編號用于標識消息二進制格式中的字段,并且在使用消息類型后不應(yīng)更改。請注意,范圍 1 到 15 中的字段編號需要一個字節(jié)進行編碼,包括字段編號和字段類型。范圍 16 至 2047 中的字段編號需要兩個字節(jié)。所以你應(yīng)該保留數(shù)字 1 到 15 作為非常頻繁出現(xiàn)的消息元素。請記住為將來可能添加的頻繁出現(xiàn)的元素留出一些空間。

字段規(guī)則
repeated 0-n
optional 0-1
required 1

保留字段

如果您通過完全刪除某個字段或?qū)⑵渥⑨尩魜砀孪㈩愋?,那么未來的用戶可以在對該類型進行自己的更新時重新使用該字段號。如果稍后加載到了的舊版本 .proto 文件,則會導(dǎo)致服務(wù)器出現(xiàn)嚴重問題,例如數(shù)據(jù)混亂,隱私錯誤等等。確保這種情況不會發(fā)生的一種方法是指定刪除字段的字段編號(或名稱,這也可能會導(dǎo)致 JSON 序列化問題)為 reserved。如果將來的任何用戶試圖使用這些字段標識符,Protocol Buffers 編譯器將會報錯。

message Foo {
reserved 2, 15, 9 to 11;
reserved "foo", "bar";
}

各個語言標量類型對應(yīng)關(guān)系

https://developers.google.com/protocol-buffers/docs/proto3#scalar

枚舉,默認值,嵌套定義,MAP,定義service,

不展開

更新一個message

如果后面發(fā)現(xiàn)之前定義 message 需要增加字段了,這個時候就體現(xiàn)出 Protocol Buffer 的優(yōu)勢了,不需要改動之前的代碼。不過需要滿足以下 10 條規(guī)則

不要改動原有字段的數(shù)據(jù)結(jié)構(gòu)。

如果您添加新字段,新字段應(yīng)該用optional或者repeated,則任何由代碼使用“舊”消息格式序列化的消息仍然可以通過新生成的代碼進行分析。您應(yīng)該記住這些元素的默認值,以便新代碼可以正確地與舊代碼生成的消息進行交互。同樣,由新代碼創(chuàng)建的消息可以由舊代碼解析:舊的二進制文件在解析時會簡單地忽略新字段。

只要字段號在更新的消息類型中不再使用,字段可以被刪除。您可能需要重命名該字段,可能會添加前綴“OBSOLETE_”,或者標記成保留字段號 reserved,以便將來的 .proto 用戶不會意外重復(fù)使用該號碼。

int32,uint32,int64,uint64 和 bool 全都兼容。這意味著您可以將字段從這些類型之一更改為另一個字段而不破壞向前或向后兼容性。如果一個數(shù)字從不適合相應(yīng)類型的線路中解析出來,則會得到與在 C++ 中將該數(shù)字轉(zhuǎn)換為該類型相同的效果(例如,如果將 64 位數(shù)字讀為 int32,它將被截斷為 32 位)。

sint32 和 sint64 相互兼容,但與其他整數(shù)類型不兼容。

只要字節(jié)是有效的UTF-8,string 和 bytes 是兼容的。

嵌入式 message 與 bytes 兼容,如果 bytes 包含 message 的 encoded version。

fixed32與sfixed32兼容,而fixed64與sfixed64兼容。

enum 就數(shù)組而言,是可以與 int32,uint32,int64 和 uint64 兼容(請注意,如果它們不適合,值將被截斷)。但是請注意,當消息反序列化時,客戶端代碼可能會以不同的方式對待它們:例如,未識別的 proto3 枚舉類型將保留在消息中,但消息反序列化時如何表示是與語言相關(guān)的。(這點和語言相關(guān),上面提到過了)Int 域始終只保留它們的值。

將單個值更改為新的成員是安全和二進制兼容的。如果您確定一次沒有代碼設(shè)置多個字段,則將多個字段移至新的字段可能是安全的。將任何字段移到現(xiàn)有字段中都是不安全的。(注意字段和值的區(qū)別,字段是 field,值是 value)

未知字段

未知數(shù)字段是 protocol buffers 序列化的數(shù)據(jù),表示解析器無法識別的字段。例如,當一個舊的二進制文件解析由新的二進制文件發(fā)送的新數(shù)據(jù)的數(shù)據(jù)時,這些新的字段將成為舊的二進制文件中的未知字段。

Proto3 實現(xiàn)可以成功解析未知字段的消息,但是,實現(xiàn)可能會或可能不會支持保留這些未知字段。你不應(yīng)該依賴保存或刪除未知域。對于大多數(shù) Google protocol buffers 實現(xiàn),未知字段在 proto3 中無法通過相應(yīng)的 proto 運行時訪問,并且在反序列化時被丟棄和遺忘。這是與 proto2 的不同行為,其中未知字段總是與消息一起保存并序列化。

編碼原理

https://developers.google.com/protocol-buffers/docs/encoding

Base 128 Varints 編碼

Varint 是一種緊湊的表示數(shù)字的方法。它用一個或多個字節(jié)來表示一個數(shù)字,值越小的數(shù)字使用越少的字節(jié)數(shù)。這能減少用來表示數(shù)字的字節(jié)數(shù)。

Varint 中的每個字節(jié)(最后一個字節(jié)除外)都設(shè)置了最高有效位(msb),這一位表示還會有更多字節(jié)出現(xiàn)。每個字節(jié)的低 7 位用于以 7 位組的形式存儲數(shù)字的二進制補碼表示

如果用不到 1 個字節(jié),那么最高有效位設(shè)為 0 ,如下面這個例子,

1 用一個字節(jié)就可以表示,所以 msb 為 0.

0000 0001

如果需要多個字節(jié)表示,msb 就應(yīng)該設(shè)置為 1 。

例如 300,如果用 Varint 表示的話:

二進制

0000 0001 0010 1100 =>

10 0101100 => 逆序,加msb

(1)0101100 (000000)10

image

即172 2

例如5110

*二進制* *0001**0011**1111**0110* *=>*

0100111 1110110 =》 逆序,加msb

11110110 00100111 =>

*結(jié)果* *246**39*

本來一個int32,要占4個字節(jié)長度的數(shù)字,這里只占了2個字節(jié)(246占8位 39占8位)

那 Varint 是怎么編碼的呢?

下面代碼是 Varint int 32 的編碼計算方法。

image

解碼是可逆的過程

1.如果是多個字節(jié),先去掉每個字節(jié)的 msb(通過邏輯或運算),每個字節(jié)只留下 7 位。

2.每7位給拼接起來

image

varint一定更壓縮嗎

讀到這里可能有讀者會問了,Varint 不是為了緊湊 int 的么?那 300 本來可以用 2 個字節(jié)表示,現(xiàn)在還是 2 個字節(jié)了,哪里緊湊了,花費的空間沒有變啊?!

Varint 確實是一種緊湊的表示數(shù)字的方法。它用一個或多個字節(jié)來表示一個數(shù)字,值越小的數(shù)字使用越少的字節(jié)數(shù)。這能減少用來表示數(shù)字的字節(jié)數(shù)。比如對于 int32 類型的數(shù)字,一般需要 4 個 byte 來表示。但是采用 Varint,對于很小的 int32 類型的數(shù)字,則可以用 1 個 byte 來表示。當然凡事都有好的也有不好的一面,采用 Varint 表示法,大的數(shù)字則需要 5 個 byte 來表示。從統(tǒng)計的角度來說,一般不會所有的消息中的數(shù)字都是大數(shù),因此大多數(shù)情況下,采用 Varint 后,可以用更少的字節(jié)數(shù)來表示數(shù)字信息。

300 如果用 int32 表示,需要 4 個字節(jié),現(xiàn)在用 Varint 表示,只需要 2 個字節(jié)了??s小了一半!

Message Structure 編碼

protocol buffer 中 message 是一系列鍵值對。message 的二進制版本只是使用字段號(field's number 和 wire_type)作為 key。每個字段的名稱和聲明類型只能在解碼端通過引用消息類型的定義(即 ?.proto? 文件)來確定。這一點也是人們常常說的 protocol buffer 比 JSON,XML 安全一點的原因,如果沒有數(shù)據(jù)結(jié)構(gòu)描述 ?.proto? 文件,拿到數(shù)據(jù)以后是無法解釋成正常的數(shù)據(jù)的。

image

當消息編碼時,鍵和值被連接成一個字節(jié)流。當消息被解碼時,解析器需要能夠跳過它無法識別的字段。這樣,可以將新字段添加到消息中,而不會破壞不知道它們的舊程序。這就是所謂的 “向后”兼容性。

為此,線性的格式消息中每對的“key”實際上是兩個值,其中一個是來自?.proto?文件的字段編號,加上提供正好足夠的信息來查找下一個值的長度。在大多數(shù)語言實現(xiàn)中,這個 key 被稱為 tag。

image

key 的計算方法

即?(field_number << 3) | wire_type?,換句話說,key 的最后 3 位表示的就是 ?wire_type?。

假設(shè)遇到

required int32 a = 1;

對應(yīng)key計算值即為

000 1000

即 1(field_num)<<3| 0 (varint)

Signed Integers 編碼

Protobuf中采用Zigzag,正負數(shù)交叉的方式來表示有符號數(shù)。

image

我們知道,計算機中采用補碼的方式來表示有符號數(shù),其最高位是符號位,正數(shù)的補碼等于原碼,負數(shù)的補碼等于原碼符號位不變,其余位取反再加1。所以如果我們使用int32表示1和-1,則-1需要表示成很大的正數(shù),需要5個字節(jié):

1(10進制) = 00000000_00000000_00000000_00000001(補碼)

= 00000001(Varint int32)

-1(10進制) = 11111111_11111111_11111111_11111111(補碼)

= 11111111_11111111_11111111_11111111_00001111(假如用Varint表示)

我們注意到絕對值很小的正負數(shù)其前面都是0或者1,如果我們能將重復(fù)的位壓縮,則可以很大的節(jié)省空間。對于正數(shù)比較好處理,把前面無意義的0去掉就行。對于負數(shù),則可以先把符號位移到最低位,再對數(shù)據(jù)位求反,就可以把前面的0都壓縮了。-1和1的變換過程如下:

image

Non-varint Numbers

Non-varint 數(shù)字比較簡單,double 、fixed64 的 wire_type 為 1,在解析時告訴解析器,該類型的數(shù)據(jù)需要一個 64 位大小的數(shù)據(jù)塊即可。同理,float 和 fixed32 的 wire_type 為5,給其 32 位數(shù)據(jù)塊即可。兩種情況下,都是高位在后,低位在前。

說 Protocol Buffer 壓縮數(shù)據(jù)沒有到極限,原因就在這里,因為并沒有壓縮 float、double 這些浮點類型。

字符串

image

字符串對應(yīng)wire_type=2,是一種指定長度的編碼方式:key + length + content,key 的編碼方式是統(tǒng)一的,length 采用 varints 編碼方式,content 就是由 length 指定長度的 Bytes。

message Test2 {

optional string b = 2;

}

設(shè)置該值為"testing",二進制格式查看:

12 07 74 65 73 74 69 6e 67

12(16進制) = 18(10進制) = 2(field_num)<<3 | 2(wire_type)

07(16進制) = 7(10進制), testing長度為7

74(16) = 120(10) = 't' (比如 'a'=97)

性能:

參考referhttps://www.infoq.cn/article/json-is-5-times-faster-than-protobuf里面最后的圖

總結(jié),重點:

總結(jié)

Protocol Buffer 利用 varint 原理壓縮數(shù)據(jù)以后,二進制數(shù)據(jù)非常緊湊,option 也算是壓縮體積的一個舉措。所以 pb 體積更小,如果選用它作為網(wǎng)絡(luò)數(shù)據(jù)傳輸,勢必相同數(shù)據(jù),消耗的網(wǎng)絡(luò)流量更少。但是并沒有壓縮到極限,float、double 浮點型都沒有壓縮。

Protocol Buffer 比 JSON 和 XML 少了 {、}、: 這些符號,體積也減少一些。

Protocol Buffer 另外一個核心價值在于提供了一套工具,一個編譯工具,自動化生成 get/set 代碼。簡化了多語言交互的復(fù)雜度,使得編碼解碼工作有了生產(chǎn)力。

Protocol Buffer 不是自我描述的,離開了數(shù)據(jù)描述 .proto 文件,就無法理解二進制數(shù)據(jù)流。這點即是優(yōu)點,使數(shù)據(jù)具有一定的“加密性”,也是缺點,數(shù)據(jù)可讀性極差。所以 Protocol Buffer 非常適合內(nèi)部服務(wù)之間 RPC 調(diào)用和傳遞數(shù)據(jù)。

Protocol Buffer 具有向后兼容的特性,更新數(shù)據(jù)結(jié)構(gòu)以后,老版本依舊可以兼容,這也是 Protocol Buffer 誕生之初被寄予解決的問題。因為編譯器對不識別的新增字段會跳過不處理。

重點和思考

總結(jié)

Protocol Buffer 利用 varint 原理壓縮數(shù)據(jù)以后,二進制數(shù)據(jù)非常緊湊,option 也算是壓縮體積的一個舉措。所以 pb 體積更小,如果選用它作為網(wǎng)絡(luò)數(shù)據(jù)傳輸,勢必相同數(shù)據(jù),消耗的網(wǎng)絡(luò)流量更少。但是并沒有壓縮到極限,float、double 浮點型都沒有壓縮。
Protocol Buffer 比 JSON 和 XML 少了 {、}、: 這些符號,體積也減少一些。
Protocol Buffer 另外一個核心價值在于提供了一套工具,一個編譯工具,自動化生成 get/set 代碼。簡化了多語言交互的復(fù)雜度,使得編碼解碼工作有了生產(chǎn)力。
Protocol Buffer 不是自我描述的,離開了數(shù)據(jù)描述 .proto 文件,就無法理解二進制數(shù)據(jù)流。這點即是優(yōu)點,使數(shù)據(jù)具有一定的“加密性”,也是缺點,數(shù)據(jù)可讀性極差。所以 Protocol Buffer 非常適合內(nèi)部服務(wù)之間 RPC 調(diào)用和傳遞數(shù)據(jù)。
Protocol Buffer 具有向后兼容的特性,更新數(shù)據(jù)結(jié)構(gòu)以后,老版本依舊可以兼容,這也是 Protocol Buffer 誕生之初被寄予解決的問題。因為編譯器對不識別的新增字段會跳過不處理。

重點和思考

編解碼:
    varint編解碼算法,以及優(yōu)劣壓縮是否到極致
    tag計算方式,字段名的意義作用時機
    zigzag處理有符號數(shù)
    Tag - Length - Value處理string等

如何評價一個編解碼算法,協(xié)議:
    編解碼時間,針對不同類型的支持
    壓縮空間
    前后兼容性: 
        向后兼容性的保證:不認識的字段跳過
        改動,加字段,刪字段,字段改名字
    可讀性,安全性,自解釋性
    跨語言
    生態(tài)和工具支持:
        compiler和runtime
        compiler類似代碼生成器,根據(jù)proto生成代碼
            對比公司現(xiàn)有工具
        runtime即序列化和反序列化
            對比公司現(xiàn)有工具

思考題:

1.proto里面的字段名有什么意義,對于生成pb文件有什么作用

沒用,client,server同類型同field_num哪怕字段名不一樣,也可以順利解析

` message Person {`
 `required string name = 1;`
 `required int32 age = 2;`
` }`
` message Person1 {`
`       required string name = 1;`
`       optional int32 age1 = 2;`
`  }`

可以用Person{"zxc",-2}去序列化生成二進制文件,再用Person1的proto反序列化,得到name="zxc",age1=-2

2.repeated,optional這些在proto中是如何生效的
序列化和反序列化的時候會檢查,但是并不會體現(xiàn)在二進制文件中,也就是同樣一個proto,把required改成proto,進行一樣的賦值,生成的二進制不會有任何差別

` message Person {`
`required string name = 1;`
`required int32 age = 2;`
` }`
`Person("a","1")序列化后是[10  1  97  16  1]`
`message Person1 { `
`optional string name = 1;`
`optional int32 age = 2;`
`}`

同樣的Person1("a","1")序列化后也是[10 1 97 16 1]

refer

http://www.itdecent.cn/p/c1723e5f6a46 安裝配置和demo

https://developers.google.com/protocol-buffers 官網(wǎng)

https://halfrost.com/protobuf_encode/ https://halfrost.com/protobuf_decode/ 比較好的材料,官網(wǎng)翻譯版 mainly refered

https://zhuanlan.zhihu.com/p/73549334 簡單描述

https://izualzhy.cn/protobuf-encode-varint-and-zigzag 搞圖

測評
https://www.infoq.cn/article/json-is-5-times-faster-than-protobuf

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容