基于 Protobuf 的 RPC 实现(Python 语言)

418 阅读6分钟

基于 Protobuf 的 RPC 实现(Python 语言)

作者:光火

邮箱:victor_b_zhang@163.com

由于最近做项目时有用到相关技术,而网络中的资料一来比较琐碎,二来大部分是 C ++ 实现,故在此总结提炼一个 Python 版本的教程

Protobuf

Protocol Buffers (protobuf) 是由 Google 公司开发的一款数据描述语言,可用于数据存储、数据交换、通讯协议等方面。protobuf 现阶段支持 C++JavaPython 三种语言,并且在效率和兼容性上都很好。简单来说,创建 .proto 文件,就相当于定义数据结构,规定消息发送的格式与内容


  • protobuf 由三部分组成:
    • proto :即使用 proto 语法的文本文件,用于定义数据格式
    • protocprotobuf 编译器 (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 的截图

image.png

  • 首行的 syntax = "proto3" 代表使用 proto3 语法
  • message 为定义消息的关键字,GuestsInvitation 则是消息的名称。上文中,我们定义了一个宴请相关的消息,里面包含时间、主办方、地址、宾客等内容。可以看到,message 内部还可以包含枚举类,并支持嵌套消息(倘若需要将其他 .proto 文件定义的消息嵌套进来,可以使用 import 关键字)
  • repeated 限定符表示字段可以出现任意多次(包括 0 次),可以理解为数组
  • 关于字段支持的类型,可以查看这篇文章,只是该作者使用的是 proto2 语法,所以会有 requiredoptional 等限定符,不过也很容易理解: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 提供的接口,就像是调用本地函数一样

基于 protobufgRPC 相当于序列化和反序列化功能是用 protobuf 实现的 (gRPCGoogle 开源的 RPC 框架)

  • 序列化:将数据结构或对象转换成二进制串的过程
  • 反序列化:将在序列化过程中所生成的二进制串转换成数据结果或对象的过程

简单来说,RPC 就是从一台机器(客户端)上,通过参数传递的方式,调用另一台机器(服务端)上的一个函数或方法(统称为服务),并得到返回结果。RPC 会隐藏底层通讯细节,不需要直接处理 SocketHttp 通讯


  • 一般流程
    • 编写 proto 文件,编译生成 xxx_pb2.pyxxx.pb2_grpc.py,在 serverclient 端进行引用
      • 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,内容包括最终审核结果、相应的原因、及一张标注了 yesno 的申请人图片


我们根据情景做一个简单的实现

  • 编写 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;
}

image.png

  • 编译 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)