在 Python 中應用 protobuf

本次我們來聊一聊 protobuf,它是一個數據序列化和反序列化協議,因此它和 json 的定位是一樣的。當客戶端需要傳遞數據給服務端時,會將內存中的對象序列化成一個可以在網絡中傳輸的二進制流,服務端收到之後再反序列化,得到內存中的對象。

不過既然都有 json 了,還會出現 protobuf,那就說明 protobuf 相較於 json 有着很大的優勢。來看一下優缺點:

總結一下,protobuf 全稱爲 Protocol Buffer,它是 Google 開發的一種輕量並且高效的結構化數據存儲格式,性能要遠遠優於 json 和 xml。另外 protobuf 經歷了兩個版本,分別是 protobuf2 和 protobuf3,目前主流的版本是 3,因爲更加易用。

下面就來開始學習 protobuf 吧。

但是別忘記安裝,直接 pip3 install grpcio grpcio-tools protobuf 即可

編寫一個簡單的 protobuf 文件

protobuf 文件有自己的語法格式,所以相比 json 它的門檻要高一些。我們創建一個文件,文件名爲 girl.proto。

protobuf 文件的後綴是 .proto

// syntax 負責指定使用哪一種 protobuf 服務
// 注意:syntax 必須寫在非註釋的第一行
syntax = "proto3";

// 包名, 這個目前不是很重要, 你刪掉也是無所謂的
package girl;

// 把 UserInfo 當成 Python 中的類
// name 和 age 當成綁定在實例上的兩個屬性
message UserInfo {
  string name = 1;  // = 1表示第1個參數
  int32 age = 2;
}

protobuf 文件編寫完成,然後我們要用它生成相應的 Python 文件,命令如下:

我們要用 protobuf 文件生成 Python 文件,所以 --python_out 負責指定 Python 文件的輸出路徑,這裏是當前目錄;-I 表示從哪裏尋找 protobuf 文件,這裏也是當前目錄;最後的 girl.proto 就是指定的 protobuf 文件了。

我們執行該命令,會發現執行完之後多了一個 girl_pb2.py,我們直接用即可。注意:這是基於 protobuf 自動生成的 Python 文件,我們不要修改它。如果參數或返回值需要改變,那麼應該修改 protobuf 文件,然後重新生成 Python 文件。

然後我們來看看採用 protobuf 協議序列化之後的結果是什麼,不是說它比較高效嗎?那麼怎能不看看它序列化之後的結果呢,以及它和 json 又有什麼不一樣呢?

import orjson
import girl_pb2

# 在 protobuf 文件中定義了 message UserInfo
# 那麼我們可以直接實例化它,而參數則是 name 和 age
# 因爲在 message UserInfo 裏面指定的字段是 name 和 age
user_info = girl_pb2.UserInfo(age=17)

# 如果不使用 protobuf,那麼我們會選擇創建一個字典
user_info2 = {"name""satori""age": 17}

# 然後來看看序列化之後的結果
# 調用 SerializeToString 方法會得到序列化之後的字節串
print(user_info.SerializeToString())
"""
b'\n\x06satori\x10\x11'
"""
# 如果是 json 的話
print(orjson.dumps(user_info2))
"""
b'{"name":"satori","age":17}'
"""

可以看到使用 protobuf 協議序列化之後的結果要比 json 短,平均能得到一倍的壓縮。序列化我們知道了,那麼如何反序列化呢?

import orjson
import girl_pb2

# 依舊是實例化一個對象,但是不需要傳參
user_info = girl_pb2.UserInfo()
# 傳入序列化之後的字節串,進行解析(反序列化)
user_info.ParseFromString(b'\n\x06satori\x10\x11')
print(user_info.name)  # satori
print(user_info.age)  # 17

# json 也是同理,通過 loads 方法反序列化
user_info2 = orjson.loads(b'{"name":"satori","age":17}')
print(user_info2["name"])  # satori
print(user_info2["age"])  # 17

所以無論是 protobuf 還是 json,都是將一個對象序列化成二進制字節串。然後根據序列化之後的字節串,再反序列出原來的對象。只不過採用 protobuf 協議進行序列化和反序列化,速度會更快,並且序列化之後的數據壓縮比更高,在傳輸的時候耗時也會更少。

然後還有一個關鍵地方的就是,json 這種數據結構比較鬆散。你在返回 json 的時候,需要告訴調用你接口的人,返回的 json 裏面都包含哪些字段,以及類型是什麼。但 protobuf 則不需要,因爲字段有哪些、以及相應的類型,都必須在文件裏面定義好。別人只要拿到 .proto 文件,就知道你要返回什麼樣的數據了,一目瞭然。

在服務端之間傳輸 protobuf

如果兩個服務需要彼此訪問,那麼最簡單的方式就是暴露一個 HTTP 接口,服務之間發送 HTTP 請求即可彼此訪問,至於請求數據和響應數據,則使用 JSON。

所以通過 HTTP + JSON 是最簡單的方式,也是業界使用最多的方式。但這種方式的性能不夠好,如果是同一個內網的多個服務,那麼更推薦使用 gRPC + protobuf。關於 gRPC 以後再聊,我們來看看 protobuf 數據在 HTTP 請求中是如何傳遞的。

首先還是編寫 .proto 文件。

// 文件名:girl.proto
syntax = "proto3";

package girl;


message Request {
    string name = 1;  
    int32 age = 2;
}

message Response {
    string info = 1;
}

一個 protobuf 文件中可以定義任意個 message,在生成 Python 文件之後每個 message 會對應一個同名的類。然後我們執行之前的命令,生成 Python 文件。

接下來使用 Tornado 編寫一個服務:

from abc import ABC
from tornado import web, ioloop
import girl_pb2


class GetInfoHandler(web.RequestHandler, ABC):

    async def post(self):
        # 拿到客戶端傳遞的字節流
        # 這個字節流應該是由 girl_pb2.Request() 序列化得到的
        content = self.request.body
        # 下面進行反序列化
        request = girl_pb2.Request()
        request.ParseFromString(content)
        # 獲取裏面的 name 和 age 字段的值
        name = request.name
        age = request.age
        # 生成 Response 對象
        response = girl_pb2.Response(
            info=f"name: {name}, age: {age}"
        )
        # 但 Response 對象不能直接返回,需要序列化
        return await self.finish(response.SerializeToString())


app = web.Application(
    [("/get_info", GetInfoHandler)]
)
app.listen(9000)

ioloop.IOLoop.current().start()

整個過程很簡單,和 JSON 是一樣的。然後我們來訪問一下:

import requests
import girl_pb2

# 往 localhost:9000 發請求
# 參數是 girl_pb2.Request() 序列化後的字節流
payload = girl_pb2.Request(
    , age=17
).SerializeToString()

# 發送 HTTP 請求,返回 girl_pb2.Response() 序列化後的字節流
content = requests.post("http://localhost:9000/get_info",
                        data=payload).content
# 然後我們反序列化
response = girl_pb2.Response()
response.ParseFromString(content)
print(response.info)
"""
name: 古明地覺, age: 17
"""

所以 protobuf 本質上也是一個序列化和反序列化協議,在使用上和 JSON 沒有太大區別。只不過 JSON 對應的 Python 對象是字典,而 protobuf 則是單獨生成的對象。

protobuf 的基礎數據類型

在不涉及 gRPC 的時候,protobuf 文件是非常簡單的,你需要返回啥結構,那麼直接在 .proto 文件裏面使用標識符 message 定義即可。

message 消息名稱 {
    類型 字段名 = 1;
    類型 字段名 = 2;
    類型 字段名 = 3;
}

但是類型我們需要說一下,之前用到了兩個基礎類型,分別是 string 和 int32,那麼除了這兩個還有哪些類型呢?

以上是基礎類型,當然還有複合類型,我們一會單獨說,先來演示一下基礎類型。編寫 .proto 文件:

// 文件名:basic_type.proto
syntax = "proto3";

package basic_type;

message BasicType {
    // 字段的名稱可以和類型名稱一致,這裏爲了清晰
    // 我們就直接將類型的名稱用作字段名
    int32 int32 = 1;
    sint32 sint32 = 2;
    uint32 uint32 = 3;
    fixed32 fixed32 = 4;
    sfixed32 sfixed32 = 5;

    int64 int64 = 6;
    sint64 sint64 = 7;
    uint64 uint64 = 8;
    fixed64 fixed64 = 9;
    sfixed64 sfixed64 = 10;
    double double = 11;
    float float = 12;

    bool bool = 13;
    string string = 14;
    bytes bytes = 15;
}

然後我們來生成 Python 文件,命令如下:

python3 -m grpc_tools.protoc --python_out=. -I=. basic_type.proto

執行之後,會生成 basic_type_pb2.py 文件,我們測試一下:

import basic_type_pb2

basic_type = basic_type_pb2.BasicType(
    int32=123,
    sint32=234,
    uint32=345,
    fixed32=456,
    sfixed32=789,

    int64=1230,
    sint64=2340,
    uint64=3450,
    fixed64=4560,
    sfixed64=7890,

    double=3.1415926,
    float=2.71,

    bool=True,
    string="古明地覺",
    bytes=b"satori",
)

# 定義一個函數,接收序列化之後的字節流
def parse(content: bytes):
    obj = basic_type_pb2.BasicType()
    # 反序列化
    obj.ParseFromString(content)
    print(obj.int32)
    print(obj.sfixed64)
    print(obj.string)
    print(obj.bytes)
    print(obj.bool)

parse(basic_type.SerializeToString())
"""
123
7890
古明地覺
b'satori'
True
"""

很簡單,沒有任何問題,以上就是 protobuf 的基礎類型。然後再來看看符合類型,以及一些特殊類型。

repeat 和 map

repeat 和 map 是一種複合類型,可以把它們當成 Python 的列表和字典。

// 文件名:girl.proto
syntax = "proto3";

package girl;

message UserInfo {
    // 對於 Python 而言
    // repeated 表示 hobby 字段的類型是列表
    // string 則表示列表裏面的元素必須都是字符串
    repeated string hobby = 1;   
    // map<string, string> 表示 info 字段的類型是字典
    // 字典的鍵值對必須都是字符串
    map<string, string> info = 2;
}

我們執行命令,生成 Python 文件,然後導入測試一下。

import girl_pb2

user_info = girl_pb2.UserInfo(
    hobby=["唱""跳""rap""🏀"],
    info={"name""古明地覺""age""17"}
)

print(user_info.hobby)
print(user_info.info)
"""
['唱', '跳', 'rap', '🏀']
{'name': '古明地覺', 'age': '17'}
"""

結果正常,沒有問題。但需要注意:對於複合類型而言,在使用的時候有一個坑。

import girl_pb2

# 如果我們沒有給字段傳值,那麼會有一個默認的零值
user_info = girl_pb2.UserInfo()

print(user_info.hobby)  # []
print(user_info.info)  # {}

# 對於複合類型的字段來說,我們不能單獨賦值
try:
    user_info.hobby = ["唱""跳""rap""🏀"]
except AttributeError as e:
    print(e)
"""
Assignment not allowed to repeated field "hobby" in protocol message object.
"""

# 先實例化,然後單獨給字段賦值,只適用於基礎類型
# 因此我們需要這麼做
user_info.hobby.extend(["唱""跳""rap""🏀"])
user_info.info.update({"name""古明地覺""age""17"})
print(user_info.hobby)
print(user_info.info)
"""
['唱', '跳', 'rap', '🏀']
{'name': '古明地覺', 'age': '17'}
"""

所以這算是一個需要注意的點,也不能叫坑吧,總之注意一下即可。

message 的嵌套

通過標識符 message 即可定義一個消息體,大括號裏面的則是參數,但參數的類型也可以是另一個 message。換句話說,message 是可以嵌套的。

// 文件名:girl.proto
syntax = "proto3";

package girl;

message UserInfo {
    repeated string hobby = 1;
    // BasicInfo 定義在外面也是可以的
    message BasicInfo {
        string name = 1;
        int32 age = 2;
        string address = 3;
    }
    BasicInfo basic_info = 2;
}

生成 Python 文件,導入測試一下。

import girl_pb2

# 在 protobuf 文件中,BasicInfo 定義在 UserInfo 裏面
# 所以 BasicInfo 在這裏對應 UserInfo 的一個類屬性
# 如果定義在全局,那麼直接通過 girl_pb2 獲取即可
basic_info = girl_pb2.UserInfo.BasicInfo(
    )

user_info = girl_pb2.UserInfo(
    hobby=['唱''跳''rap''🏀'],
    basic_info=basic_info
)

print(user_info.hobby)
"""
['唱', '跳', 'rap', '🏀']
"""
print(user_info.basic_info.name)
print(user_info.basic_info.age)
print(user_info.basic_info.address)
"""
古明地覺
17
地靈殿
"""

以上是 message 的嵌套,或者說通過 message 定義的消息體,也可以作爲字段的類型。

枚舉類型

再來聊一聊枚舉類型,它通過 enum 標識符定義。

// 裏面定義了兩個成員,分別是 MALE 和 FEMALE
enum Gender {
    MALE = 0;
    FEMALE = 1;
}

這裏需要說明的是,對於枚舉來說,等號後面的值表示成員的值。比如一個字段的類型是 Gender,那麼在給該字段賦值的時候,要麼傳 0 要麼傳 1。因爲枚舉 Gender 裏面只有兩個成員,分別代表 0 和 1。

而我們前面使用 message 定義消息體的時候,每個字段後面跟着的值則代表序號,從 1 開始,依次遞增。至於爲什麼要有這個序號,是因爲我們在實例化的時候,可以只給指定的部分字段賦值,沒有賦值的字段則使用對應類型的零值。那麼另一端在拿到字節流的時候,怎麼知道哪些字段被賦了值,哪些字段沒有被賦值呢?顯然要通過序號來進行判斷。

下面來編寫 .proto 文件。

// 文件名:girl.proto
syntax = "proto3";

package girl;

// 枚舉成員的值必須是整數
enum Gender {
    MALE = 0;
    FEMALE = 1;
}

message UserInfo {
    string name = 1;
    int32 age = 2;
    Gender gender = 3;
}

message Girls {
    // 列表裏面的類型也可以是 message 定義的消息體
    repeated UserInfo girls = 1;
}

輸入命令生成 Python 文件,然後導入測試:

import girl_pb2

user_info1 = girl_pb2.UserInfo(
    , age=17,
    gender=girl_pb2.Gender.Value("FEMALE"))

user_info2 = girl_pb2.UserInfo(
    , age=400,
    # 傳入一個具體的值也是可以的
    gender=1)

girls = girl_pb2.Girls(girls=[user_info1, user_info2])
print(girls.girls[0].name, girls.girls[1].name)
print(girls.girls[0].age, girls.girls[1].age)
print(girls.girls[0].gender, girls.girls[1].gender)
"""
古明地覺 芙蘭朵露
17 400
1 1
"""

枚舉既可以定義在全局,也可以定義在某個 message 裏面。

.proto 文件的導入

.proto 文件也可以互相導入,我們舉個例子。下面定義兩個文件,一個是 people.proto,另一個是 girl.proto,然後在 girl.proto 裏面導入 people.proto。

/* 文件名:people.proto */

syntax = "proto3";
// 此時的包名就很重要了,當該文件被其它文件導入時
// 需要通過這裏的包名,來獲取內部的消息體、枚舉等數據
package people;

message BasicInfo {
    string name = 1;
    int32 age = 2;
}


/* 文件名:girl.proto */
syntax = "proto3";
// 導入 people.proto,
import "people.proto";

message PersonalInfo {
    string phone = 1;
    string address = 2;
}

message Girl {
    // 這裏的 BasicInfo 是在 people.proto 裏面定義的
    // people.proto 裏面的 package 指定的包名爲 people
    // 所以這裏需要通過 people. 的方式獲取
    people.BasicInfo basic_info = 1;
    PersonalInfo personal_info = 2;
}

然後執行命令,基於 proto 文件生成 Python 文件,顯然此時會有兩個 Python 文件。

python3 -m grpc_tools.protoc --python_out=. -I=. girl.proto 

python3 -m grpc_tools.protoc --python_out=. -I=. people.proto

import girl_pb2
import people_pb2

basic_info = people_pb2.BasicInfo(age=17)
personal_info = girl_pb2.PersonalInfo(phone="18838888888",
                                      address="地靈殿")
girl = girl_pb2.Girl(basic_info=basic_info,
                     personal_info=personal_info)
print(girl.basic_info.name)  # 古明地覺
print(girl.basic_info.age)  # 17
print(girl.personal_info.phone)  # 18838888888
print(girl.personal_info.address)  # 地靈殿

以上就是 proto 文件的導入,不復雜。

一些常用的方法

.proto 文件在生成 .py 文件之後,裏面的一個消息體對應一個類,我們可以對類進行實例化。而這些實例化的對象都有哪些方法呢?我們總結一下常用的。

首先重新編寫 girl.proto,然後生成 Python 文件。

syntax = "proto3";

message People {
    string name = 1;
    int32 age = 2;
}

message Girl {
    People people = 1;
    string address = 2;
    int32 length = 3;
}

內容很簡單,我們測試一下。

import girl_pb2

girl = girl_pb2.Girl(
    people=girl_pb2.People(age=17),
    address="地靈殿",
    length=152
)

# SerializeToString:將對象序列化成二進制字節串
content = girl.SerializeToString()

# ParseFromString:將二進制字節串反序列化成對象
girl2 = girl_pb2.Girl()
girl2.ParseFromString(content)
print(
    girl2.people.name,
    girl2.people.age,
    girl2.address,
    girl2.length
)  # 古明地覺 17 地靈殿 152

# 以上兩個是最常用的方法

# MergeFrom:將一個對象合併到另一個對象上面
girl = girl_pb2.Girl(address="紅魔館"length=145)
# 我們先實例化了 Girl,後實例化 People
# 接下來要將它綁定到 girl 的 people 字段上
people = girl_pb2.People(age=400)
# 但 girl.people = people 是會報錯的,因爲只有標量才能這麼做
# 所以我們可以通過 girl.people.xxx = people.xxx 進行綁定
# 但如果 people 的字段非常多,那麼會很麻煩
# 因此這個時候可以使用 MergeFrom
girl.people.MergeFrom(people)
print(
    girl.people.name, girl.people.age
)  # 芙蘭朵露 400

# 同理還有 MergeFromString,接收的是序列化之後的字節串
people.name, people.age = "魔理沙"15
girl.people.MergeFromString(people.SerializeToString())
print(
    girl.people.name, girl.people.age
)  # 魔理沙 15

非常簡單,但我們發現還少了點什麼,就是它和 Python 的字典能不能互相轉化呢?答案是可以的,但需要導入專門的函數。

from google.protobuf.json_format import (
    MessageToJson,
    MessageToDict
)
import girl_pb2

girl = girl_pb2.Girl(
    people=girl_pb2.People(age=17),
    address="地靈殿",
    length=152
)

# 轉成 JSON
print(MessageToJson(girl))
"""
{
  "people": {
    "name": "\u53e4\u660e\u5730\u89c9",
    "age": 17
  },
  "address": "\u5730\u7075\u6bbf",
  "length": 152
}
"""

# 轉成字典
print(MessageToDict(girl))
"""
{'people': {'name': '古明地覺', 'age': 17}, 
 'address': '地靈殿', 'length': 152}
"""

同理,如果我們有一個字典,也可以轉成相應的對象。

from google.protobuf.json_format import (
    ParseDict
)
import girl_pb2

data = {'people'{'name''魔理沙''age': 16},
        'address''魔法森林''length': 156}
girl = girl_pb2.Girl()
# 基於字典進行解析
ParseDict(data, girl)
print(girl.people.name)  # 魔理沙
print(girl.people.age)  # 16
print(girl.address)  # 魔法森林
print(girl.length)  # 156

以上就是工作中的一些常用的方法。

小結

以上就是 protobuf 相關的內容,核心就是編寫 .proto 文件,然後生成 Python 文件。它在業務中發揮的作用,和 json 是類似的,都是將對象轉成二進制之後再通過網絡進行傳輸。接收方在收到字節流之後,將其反序列化成內存中的對象,然後獲取內部的字段。

但是 protobuf 比 json 的性能要優秀很多,並且通過 .proto 文件定義好結構,約束性也要更強一些。

最後補充一點,.proto 文件裏面還可以定義很多和 gRPC 相關的內容,關於 gRPC 我們以後再聊。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/iMrP71HOTDGeMMfakaRF0w