在 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