基于 Protobuf 的 RPC 实现(Python 语言)
作者:光火
由于最近做项目时有用到相关技术,而网络中的资料一来比较琐碎,二来大部分是 C ++
实现,故在此总结提炼一个 Python
版本的教程
Protobuf
Protocol Buffers (protobuf)
是由 Google
公司开发的一款数据描述语言,可用于数据存储、数据交换、通讯协议等方面。protobuf
现阶段支持 C++
、Java
、Python
三种语言,并且在效率和兼容性上都很好。简单来说,创建 .proto
文件,就相当于定义数据结构,规定消息发送的格式与内容
protobuf
由三部分组成:proto
:即使用proto
语法的文本文件,用于定义数据格式protoc
:protobuf
编译器 (compile
),用于将proto
文件编译成不同语言的实现,如此不同语言中的数据就可以和protobuf
格式的数据进行交互protobuf
运行时 (runtime
):protobuf
运行时所需要的库,用于和protoc
编译生成的代码进行交互
下面,让我们看一个具体的实例:
syntax = "proto3";
message Guests {
repeated string names = 1;
repeated bytes photos = 2;
}
message Invitation {
enum Date {
Monday = 0;
Tuesday = 1;
Wednesday = 2;
}
Date date = 1;
string host = 2;
string address = 3;
Guests guests = 4;
}
可惜掘金目前不支持 protobuf
语法的高亮显示。为了便于阅读,笔者张贴一下 IDE
的截图
- 首行的
syntax = "proto3"
代表使用proto3
语法 message
为定义消息的关键字,Guests
和Invitation
则是消息的名称。上文中,我们定义了一个宴请相关的消息,里面包含时间、主办方、地址、宾客等内容。可以看到,message
内部还可以包含枚举类,并支持嵌套消息(倘若需要将其他.proto
文件定义的消息嵌套进来,可以使用import
关键字)repeated
限定符表示字段可以出现任意多次(包括0
次),可以理解为数组- 关于字段支持的类型,可以查看这篇文章,只是该作者使用的是
proto2
语法,所以会有required
、optional
等限定符,不过也很容易理解:blog.csdn.net/qq_31347869… - 尾端的序号用于规定字段的顺序,只要一个消息里的字段序号不重复即可。所谓顺序,指的是不同字段在序列化后的二进制数据中的布局位置,比如上文中
address
字段编码后的数据一定位于host
之后 - 对于
Protocol Buffer
而言,标签值为1 ~ 15
的字段在编码后可以得到优化(少一个字节)。所以应考虑让比较常用的字段或者repeated
类型的字段标签位于1 ~ 15
,如此能节省编码后的字节数量。由于repeated
的每个元素都需要重复编码该标识号,所以对repeated
的域进行优化最为现实
在编写好代码后,可以通过内置的 protoc
编译器对 protobuf
文件进行编译
protoc --proto_path=$SRC_DIR --python_out=$DST_DIR $SRC_DIR/xxx.proto
--proto_path
指定待编译的.proto
文件所在的源目录,该选项可以同时指定多个--python_out
表示生成Python
代码- 代码经过编译后,就会生成一个
xxx_pb2.py
文件
RPC
RPC
远程过程调用 (remote procedure call
),是一种封装了各层网络协议,并包含序列化和反序列化功能的一种通讯框架。它使应用程序之间可以相互通信,并遵从 server/client
模型。在使用形式上,客户端 client
调用服务端 server
提供的接口,就像是调用本地函数一样
基于 protobuf
的 gRPC
相当于序列化和反序列化功能是用 protobuf
实现的 (gRPC
是 Google
开源的 RPC
框架)
- 序列化:将数据结构或对象转换成二进制串的过程
- 反序列化:将在序列化过程中所生成的二进制串转换成数据结果或对象的过程
简单来说,RPC
就是从一台机器(客户端)上,通过参数传递的方式,调用另一台机器(服务端)上的一个函数或方法(统称为服务),并得到返回结果。RPC
会隐藏底层通讯细节,不需要直接处理 Socket
或 Http
通讯
- 一般流程
- 编写
proto
文件,编译生成xxx_pb2.py
、xxx.pb2_grpc.py
,在server
、client
端进行引用xxx_pb2.py
:用于和protobuf
数据进行交互xxx_pb2_grpc.py
:用于和grpc
进行交互
- 编写
- 环境配置
# 安装 grpc 相关的 python 模块 pip install grpcio # 安装 python 下的 protoc 编译器 # 使用 protoc 编译 proto 文件,生成 python 语言的实现 pip install grpcio-tools
- 编译
proto
文件python -m grpc_tools.protoc --python_out=. --grpc_python_out=. -I. helloworld.proto
--python_out=.
: 编译生成处理protobuf
相关代码的路径, 这里是生成到当前目录--grpc_python_out=.
: 编译生成处理grpc
相关代码的路径, 这里是生成到当前目录-I. helloworld.proto
:proto
文件的路径, 这里的proto
文件在当前目录- 倘若想将文件生成到上级目录,可以采用:
python -m grpc_tools.protoc --python_out=.. --grpc_python_out=.. -I. helloworld.proto
应用实例
假设某地要召开一场学术研讨会,而有意参会者需要提前向会方提供个人信息,以获得许可。这些信息包括:姓名、照片、以及一些自愿提交的数值信息
会方 (server
) 接收到这些请求 (request
) 后,会进行一定的筛选。首先,如果申请者在会议的内部名单中,则直接通过。否则,就看申请人是否愿意为这次机会支付一定的费用
对于所有申请人,会方都会返回一个 response
,内容包括最终审核结果、相应的原因、及一张标注了 yes
或 no
的申请人图片
我们根据情景做一个简单的实现
- 编写
proto
文件
syntax = "proto3";
service Seminar {
rpc evaluate(AttendRequest) returns (AttendReply) {}
}
message AttendRequest {
string name = 1;
bytes photo = 2;
map<string, int32> info = 3;
}
message AttendReply {
bool accept = 1;
string reason = 2;
}
- 编译
proto
文件
python -m grpc_tools.protoc --python_out=. --grpc_python_out=. -I. seminar.proto
这里我们使用了 gRPC
,编译后会自动生成 seminar_pb2.py
以及 seminar_pb2_grpc.py
- 在
server.py
实现evaluate
函数
import cv2
import grpc
import signal
import numpy as np
from concurrent import futures
import seminar_pb2_grpc
from seminar_pb2 import AttendReply
class Seminar(seminar_pb2_grpc.SeminarServicer):
def __init__(self):
self.host = "Alice"
self.address = "New York"
def evaluate(self, request, context):
response = AttendReply()
name_list = ['Bob', 'Dave', 'Eve', 'Francis', 'Victor', 'Susan']
input_image = cv2.imdecode(np.frombuffer(request.photo, np.uint8), cv2.IMREAD_COLOR)
if request.name in name_list:
response.accept = True
response.reason = "invited"
output_image = cv2.putText(input_image, 'Yes', (50, 50), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)
elif 'money' in request.info.keys() and request.info['money'] > 10000:
response.accept = True
response.reason = "paid"
output_image = cv2.putText(input_image, 'Yes', (50, 50), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)
else:
response.accept = False
response.reason = "uninvited and unpaid"
output_image = cv2.putText(input_image, 'No', (50, 50), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2)
photo_processed = cv2.imencode(".png", output_image)[1].tobytes()
with open("output_image.png", "wb") as f:
f.write(photo_processed)
return response
def serve():
server = grpc.server(futures.ThreadPoolExecutor(max_workers=3))
seminar_pb2_grpc.add_SeminarServicer_to_server(Seminar(), server)
server.add_insecure_port('[::]:50051')
server.start()
print("grpc server start ...")
try:
signal.pause()
except KeyboardInterrupt:
server.stop(0)
print("退出")
if __name__ == '__main__':
serve()
- 实现
client.py
import grpc
import seminar_pb2_grpc
from seminar_pb2 import AttendRequest
def run(name: str, info: dict):
channel = grpc.insecure_channel('localhost:50051')
stub = seminar_pb2_grpc.SeminarStub(channel)
try:
request = AttendRequest()
photo = open("./icon.jpg", "rb").read()
request.name = name
request.photo = photo
for key in info:
request.info[key] = info[key]
response = stub.evaluate(request)
if response.accept:
print(name, " 参会成功")
print("原因: %s" % response.reason)
else:
print(name, " 参会失败")
print("原因: %s" % response.reason)
except Exception as error:
print(error)
if __name__ == '__main__':
user_name = 'Bob'
user_info = {'money': 0, 'age': 83}
run(name=user_name, info=user_info)
user_name = 'Sum'
user_info = {'money': 50000}
run(name=user_name, info=user_info)