在 Python 中应用 protobuf

简介: 在 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 文件,命令如下:

9b678f718300430b2f8c44de71780531.png

我们要用 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(name="satori", 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(
    name="古明地觉", 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,那么除了这两个还有哪些类型呢?

ccd1903ecdbff24f0838b1539c7116c4.png

以上是基础类型,当然还有复合类型,我们一会单独说,先来演示一下基础类型。编写 .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(
    name="古明地觉", age=17, address="地灵殿")
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(
    name="古明地觉", age=17,
    gender=girl_pb2.Gender.Value("FEMALE"))
user_info2 = girl_pb2.UserInfo(
    name="芙兰朵露", 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(name="古明地觉", 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(name="古明地觉", 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(name="芙兰朵露", 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(name="古明地觉", 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 我们以后再聊。

相关文章
|
16天前
|
存储 数据采集 人工智能
Python编程入门:从零基础到实战应用
本文是一篇面向初学者的Python编程教程,旨在帮助读者从零开始学习Python编程语言。文章首先介绍了Python的基本概念和特点,然后通过一个简单的例子展示了如何编写Python代码。接下来,文章详细介绍了Python的数据类型、变量、运算符、控制结构、函数等基本语法知识。最后,文章通过一个实战项目——制作一个简单的计算器程序,帮助读者巩固所学知识并提高编程技能。
|
28天前
|
人工智能 安全 Java
Java和Python在企业中的应用情况
Java和Python在企业中的应用情况
48 7
|
26天前
|
机器学习/深度学习 Python
堆叠集成策略的原理、实现方法及Python应用。堆叠通过多层模型组合,先用不同基础模型生成预测,再用元学习器整合这些预测,提升模型性能
本文深入探讨了堆叠集成策略的原理、实现方法及Python应用。堆叠通过多层模型组合,先用不同基础模型生成预测,再用元学习器整合这些预测,提升模型性能。文章详细介绍了堆叠的实现步骤,包括数据准备、基础模型训练、新训练集构建及元学习器训练,并讨论了其优缺点。
43 3
|
26天前
|
机器学习/深度学习 算法 数据挖掘
线性回归模型的原理、实现及应用,特别是在 Python 中的实践
本文深入探讨了线性回归模型的原理、实现及应用,特别是在 Python 中的实践。线性回归假设因变量与自变量间存在线性关系,通过建立线性方程预测未知数据。文章介绍了模型的基本原理、实现步骤、Python 常用库(如 Scikit-learn 和 Statsmodels)、参数解释、优缺点及扩展应用,强调了其在数据分析中的重要性和局限性。
55 3
|
1月前
|
存储 监控 安全
如何在Python Web开发中确保应用的安全性?
如何在Python Web开发中确保应用的安全性?
|
1月前
|
存储 人工智能 搜索推荐
Memoripy:支持 AI 应用上下文感知的记忆管理 Python 库
Memoripy 是一个 Python 库,用于管理 AI 应用中的上下文感知记忆,支持短期和长期存储,兼容 OpenAI 和 Ollama API。
94 6
Memoripy:支持 AI 应用上下文感知的记忆管理 Python 库
|
26天前
|
存储 前端开发 API
Python在移动应用开发中的应用日益广泛
Python在移动应用开发中的应用日益广泛
43 10
|
19天前
|
缓存 开发者 Python
深入探索Python中的装饰器:原理、应用与最佳实践####
本文作为技术性深度解析文章,旨在揭开Python装饰器背后的神秘面纱,通过剖析其工作原理、多样化的应用场景及实践中的最佳策略,为中高级Python开发者提供一份详尽的指南。不同于常规摘要的概括性介绍,本文摘要将直接以一段精炼的代码示例开篇,随后简要阐述文章的核心价值与读者预期收获,引领读者快速进入装饰器的世界。 ```python # 示例:一个简单的日志记录装饰器 def log_decorator(func): def wrapper(*args, **kwargs): print(f"Calling {func.__name__} with args: {a
33 2
|
19天前
|
机器学习/深度学习 人工智能 自然语言处理
探索未来编程:Python在人工智能领域的深度应用与前景###
本文将深入探讨Python语言在人工智能(AI)领域的广泛应用,从基础原理到前沿实践,揭示其如何成为推动AI技术创新的关键力量。通过分析Python的简洁性、灵活性以及丰富的库支持,展现其在机器学习、深度学习、自然语言处理等子领域的卓越贡献,并展望Python在未来AI发展中的核心地位与潜在变革。 ###
|
26天前
|
机器学习/深度学习 自然语言处理 语音技术
Python在深度学习领域的应用,重点讲解了神经网络的基础概念、基本结构、训练过程及优化技巧
本文介绍了Python在深度学习领域的应用,重点讲解了神经网络的基础概念、基本结构、训练过程及优化技巧,并通过TensorFlow和PyTorch等库展示了实现神经网络的具体示例,涵盖图像识别、语音识别等多个应用场景。
48 8