gRPC python封装深度学习算法教程

最近需要提供一个包含多个神经网络推理的python代码供gRPC调用,即我需要在这个主程序的基础上封装一个支持gRPC的服务端(server)。本教程的目的在于通过简单的代码,来帮助有需求的朋友使用python来构建属于自己的gRPC服务端/客户端。

0. 前言

最近需要用grpc调用我们的算法模块, 对于我来讲,就是需要提供一个grpc的server,供它们的go或者c++的client进行消费。那么, 在python里面如何定义一个完整的server–client,并且使其跑的非常好是个很重要的任务。

1. gRPC的官方介绍

中文官网的python接口例子直接放在grpc的github中,可能需要我们进一步的挖掘,这里,为了避免繁琐,我将通过一个简单的例子来说明如何将我们的任务封装为gRPC的服务端(server),并开启客户端(client)对其进行调用。

在此之前,先简单介绍一下什么是gRPC:

1.1 什么是gRPC
  • gRPC 是一个高性能、开源和通用的 RPC(远程过程调用) 框架,面向移动和 HTTP/2 设计。目前提供 C、Java 和 Go 语言版本,分别是:grpc, grpc-java, grpc-go. 其中 C 版本支持 C, C++, Node.js, Python, Ruby, Objective-C, PHP 和 C# 支持.
    gRPC 基于 HTTP/2 标准设计,带来诸如双向流、流控、头部压缩、单 TCP 连接上的多复用请求等特。这些特性使得其在移动设备上表现更好,更省电和节省空间占用。

  • 在 gRPC 里客户端(client)应用可以像调用本地对象一样直接调用另一台不同的机器上服务端(server)应用的方法,使得您能够更容易地创建分布式应用和服务。与许多 RPC 系统类似,gRPC 也是基于以下理念:① 定义一个服务② 指定其能够被远程调用的方法(包含参数和返回类型)③ 在服务端实现这个接口,并运行一个 gRPC 服务器来处理客户端调用。 在客户端拥有一个存根(stub)能够像服务端一样的方法。

在这里插入图片描述

  • gRPC 客户端和服务端可以在多种环境中运行和交互 - 从 google 内部的服务器到你自己的笔记本,并且可以用任何 gRPC 支持的语言来编写。所以,你可以很容易地用 Java 创建一个 gRPC 服务端,用 Go、Python、Ruby 来创建客户端。此外,Google 最新 API 将有 gRPC 版本的接口,使你很容易地将 Google 的功能集成到你的应用里。
1.2 使用 protocol buffers

gRPC 默认使用 protocol buffers,这是 Google 开源的一套成熟的结构数据序列化机制(当然也可以使用其他数据格式如 JSON)。正如你将在下方例子里所看到的,你用 proto files 创建 gRPC 服务,用 protocol buffers 消息类型来定义方法参数和返回类型。你可以在 Protocol Buffers 文档找到更多关于 Protocol Buffers 的资料。
Protocol buffers 版本
尽管 protocol buffers 对于开源用户来说已经存在了一段时间,例子内使用的却一种名叫 proto3 的新风格的 protocol buffers,它拥有轻量简化的语法、一些有用的新功能,并且支持更多新语言。当前针对 Java 和 C++ 发布了 beta 版本,针对 JavaNano(即 Android Java)发布 alpha 版本,在protocol buffers Github 源码库里有 Ruby 支持, 在golang/protobuf Github 源码库里还有针对 Go 语言的生成器, 对更多语言的支持正在开发中。 你可以在 proto3 语言指南里找到更多内容, 在与当前默认版本的发布说明比较,看到两者的主要不同点。更多关于 proto3 的文档很快就会出现。虽然你可以使用 proto2 (当前默认的 protocol buffers 版本), 我们通常建议你在 gRPC 里使用 proto3,因为这样你可以使用 gRPC 支持全部范围的的语言,并且能避免 proto2 客户端与 proto3 服务端交互时出现的兼容性问题,反之亦然。

ps: 我这里使用的都是protobuf作为gRPC约定的中间数据传输格式定义。虽然可以用json,但是我没看到这方面的教程。

2. 基本步骤

因为官方教程有比较全面的grpc的各语言接口的安装教程,我这里以python为例,来说明对深度学习应用,我们应该如何搭建一个基于grpc的server–client。

第1步:定义服务(实现自己的hellogdh.proto)

一个 RPC 服务通过参数和返回类型来指定可以远程调用的方法,gRPC 通过 protocol buffers 来实现。使用 protocol buffers 接口定义语言来定义服务方法,用 protocol buffer 来定义参数和返回类型。客户端和服务端均使用服务定义生成的接口代码。

本文的hellogdh.proto定义如下[2]:

// Copyright 2015 gRPC authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// 参考资料:python版gRPC快速入门一
// https://blog.csdn.net/Eric_lmy/article/details/81355322
syntax = "proto3";

option java_multiple_files = true;
option java_package = "io.grpc.gdh.proto";
option java_outer_classname = "GdhProto";
option objc_class_prefix = "HLW";

package hellogdh;

// 定义服务.
service Greeter {
  // ① 简单rpc.
  rpc SayHello (HelloRequest) returns (HelloReply) {}

  // ② 应答式流rpc.
  rpc LstSayHello (HelloRequest) returns (stream HelloReply) {}
}

// 客户端传的消息: HelloRequest.
message HelloRequest {
  string name = 1;
}
// 服务端发送的消息: HelloReply.
// 不能使用int, 只能使用int32这种.
// 1, 2, 3表示顺序.
message HelloReply {
  int32 num_people = 1;
// repeated 定义列表对应的结构.
  repeated int32 point = 2;
}

其中,syntax = "proto3"表示使用proto3版本。option相关的东西我都没咋动;service Greeter的意思是定义了一个叫做Greeter的服务。这个服务下面有两种,关于gRPC可以定义的服务有4种,下面会详细说明。

定义完毕之后,生成client和server的代码(Note:我之前以为client和server的代码是自己写的,实际实践后才知道,是根据xxx.proto生成的!!根本不需要我们自己写!

执行这一步需要安装好一些工具如下:

sudo apt-get install protobuf-compiler-grpc 
sudo apt-get install protobuf-compiler

对我的环境(ubuntu18.04 python3.6) 执行:

protoc -I ./grpc --python_out=./grpc --grpc_out=./grpc --plugin=protoc-gen-grpc=`which grpc_python_plugin` hellogdh.proto

在对应的目录下回生成两个文件hellogdh_pb2_grpc.pyhellogdh_pb2.py

其中, hellogdh_pb2.py包括:

  • 定义在hellogdh.proto中的消息类(Message)
  • 定义在hellogdh.proto中的服务的抽象类:
    BetaHellogdhServicer, 定义了Hellogdh 服务实现的接口
    BetaHellogdhStub, 定义了可以被客户端用来激活的Hellogdh RPC的存根
  • 应用到的函数:
    beta_create_Hellogdh_server: 根据BetaHellogdhServicer对象创建一个gRPC服务器(server专用)。
    beta_create_Hellogdh_stub: 客户端用于创建存根stub(client专用)。
第2步:实现server部分代码.

本部分分别以简单调用(单项RPC)服务端流RPC为例进行说明,实际上,gRPC允许4种类型服务方法(如果想完整的学习,还是建议看官方文档的例子[1]):

在这里插入图片描述
在这里插入图片描述
对我而言,因为我需要把多进程的python程序的最后输出队列封装给gRPC的server进程,所以我首先需要把待处理的队列(Queue)传入gRPC server的进程,再在这个进程中定义好overwrite一些helloworld_pb2.py的方法。

最后,在主进程中启动所有的神经网络任务进程和gRPC进程,并阻塞(join(),join的作用是保证当前进程正常结束, 即不会因为主进程先退出而把未执行完的子进程kill掉。)。

代码如下,参考自github grpc/examples/python下的route_guide

import sys
sys.path.append("..")

import grpc
import hellogdh_pb2
import hellogdh_pb2_grpc
from concurrent import futures

import cv2
import time
import numpy as np
from utils.config import *
import logging
from capture import queue_put, queue_get, queue_img_put
from module.Worker import PoseWorker
import multiprocessing as mp
from multiprocessing import Pool, Queue, Lock


# 0.0 grpc.
def grpc_server(queue):
    class gdhServicer(hellogdh_pb2.BetaGreeterServicer):

        def SayHello(self, request, context):
            # Note: 传参的时候必须要显式指定参数名称, 而不能lynxi_pb2.HelloReply(1, [5, 10])
            if request.name == 'gdh':
                return hellogdh_pb2.HelloReply(num_people=1, point=[1, 1])
            else:
                return hellogdh_pb2.HelloReply(num_people=55, point=[1, 1])

        def LstSayHello(self, request, context):
            while request.name == 'gdh':
                data = queue.get()
                # 因为是服务端流式模式,所以用yield。
                yield hellogdh_pb2.HelloReply(num_people=data.num, point=[data.point[0], data.point[1]])

    # 1. 之前启动server的方式.
    # server = helloworld_gdh_pb2.beta_create_Greeter_server(gdhServicer())

    # 2. 在route_guide里面学到的启动server的方式.
    server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
    lynxi_pb2_grpc.add_GreeterServicer_to_server(gdhServicer(), server)
    server.add_insecure_port('[::]:50051')
    # 因为 start() 不会阻塞,如果运行时你的代码没有其它的事情可做,你可能需要循环等待。
    server.start()

    try:
        while True:
            # time.sleep(_ONE_DAY_IN_SECONDS)
            time.sleep(5)
    except KeyboardInterrupt:
        server.stop()


# 2.1 对每个任务建立一个队列.
pose_queue_raw = Queue()
monitor_queue_raw = Queue()
pose_out_queue = Queue()

# key: 名称, val[0]: 队列, val[1]: 加载好的模型.
queues = {'pose': pose_queue_raw, 'monitor': monitor_queue_raw}

#                     pose
#                  /
#                 /
# 3. 生产者-消费者 ---  detect
#                 \
#                  \
#                     face_detect

processes = []
for key, val in queues.items():
    processes.append(mp.Process(target=queue_put, args=(val,)))
    if key == 'pose':
        processes.append(PoseWorker(val, pose_out_queue))
    else:
        processes.append(mp.Process(target=queue_get, args=(val, )))


processes.append(mp.Process(target=grpc_server, args=(pose_out_queue, )))
[process.start() for process in processes]
[process.join() for process in processes]

这段代码的意思是将PoseWorker处理得到的队列pose_out_queue喂给gRPC server进程,并设置好根据client发来的请求来发送处理好的数据。queue_putqueue_get是将视频的帧封装后放入队列A和从队列A中读取并显示的函数。

import cv2
from multiprocessing import Queue, Process
from PIL import Image, ImageFont, ImageDraw
import cv2

def queue_put(q, video_name="/home/samuel/gaodaiheng/handup.mp4"):
    cap = cv2.VideoCapture(video_name)
    while True:
        is_opened, frame = cap.read()
        q.put(frame) if is_opened else None

def queue_get(q, window_name='image'):
    cv2.namedWindow(window_name, flags=cv2.WINDOW_NORMAL)
    while True:
        frame = q.get()
        cv2.imshow(window_name, frame)
        cv2.waitKey(1)

需要额外注意的是,PoseWorker是继承multiprocessing.Process类的进程,其大体定义如下:

from multiprocessing import Queue, Process

class PoseWorker(Process):
    """
        Pose estimation姿态估计.
    """
    def __init__(self, queue, out_queue):
        Process.__init__(self, name='PoseProcessor')
        # 输入队列和输出队列.
        self.in_queue = queue
        self.out_queue = out_queue

    def run(self):
        #set enviornment
        os.environ["CUDA_VISIBLE_DEVICES"] = "0"

        #load models
        import tensorflow as tf
        ...
        model = load_model(xxx)
        ...
        while True:
            # 从入的队列中消费数据.
        	frame = self.in_queue.get()
            # 喂入模型推理得到结果.
            result = model.inference(frame)
            # 将结果放回到生产者中.
            self.out_queue.put(result)
第3步:实现server部分代码.

和第2步类似,代码如下,参考自github grpc/examples/python[3]下的route_guide

# coding: UTF-8
"""
    @author: samuel ko
"""
import os
import grpc
import hellogdh_pb2 as helloworld_gdh_pb2
import hellogdh_pb2_grpc as helloworld_gdh_pb2_grpc
import time

_ONE_DAY_IN_SECONDS = 60*60*24

# 1. 为了能调用服务的方法,我们得先创建一个 存根。
# 我们使用 .proto 中生成的 route_guide_pb2 模块的函数beta_create_RouteGuide_stub。


def run():
    with grpc.insecure_channel('localhost:50051') as channel:
        # 1) 存根方式1.
        stub = helloworld_gdh_pb2.GreeterStub(channel)
        # 2) 存根方式2.
        # stub = helloworld_gdh_pb2_grpc.GreeterStub(channel)
        print("-------------- ① 简单RPC --------------")
        # response = stub.SayHello(helloworld_gdh_pb2.HelloRequest(name='gdh'))
        features = stub.SayHello(helloworld_gdh_pb2.HelloRequest(name='gdh'))
        print(features)

        print("-------------- ② 服务端流式RPC --------------")
        features = stub.LstSayHello(helloworld_gdh_pb2.HelloRequest(name='gdh'))
        for feature in features:
            print("哈哈哈 %s at %s, %s" % (feature.num_people, feature.point[0], feature.point[1]))


if __name__ == "__main__":
    run()

最后,就会打印出符合我们服务端设定的数据结构…

补充知识:protobuf 支持的python数据结构.

在proto的Message定义中, 我们支持python的string等类型, 在proto中需要显式标明, 我推测是由于gRPC是支持多种语言接口的,有些语言是强类型的(C/C++, Go),所以务必需要显式标明数据类型, 避免带来不必要的麻烦:

message HelloRequest {
  string name = 1;
}

其中, 1, 2, 3表示的是参数的顺序. 我们支持的数据类型如下:

  • string
  • float
  • int32/uint32 (不支持int16和int8)
  • bool
  • repeated int以及我们自定义的Message.
    这里需要特别强调, repeated 表示不定长的数组, 里面可以放built-in的类型,或者自己额外封装的message. 很灵活. 对应python的list.
	message BoxInfos {
	    message BoxInfo {
	        uint32 x0 = 1;
	        uint32 y0 = 2;
	        uint32 x1 = 3;
	        uint32 y1 = 4;
	    }
    repeated BoxInfo boxInfos = 1;
}
  • bytes 字节流, 可以用于传递图片. 不过一般性在gRPC中, 每条消息的大小都不大(1MB左右?) 所以一般性都是传图片的绝对路径?
  • map<string, int> 字典,对应python的dict, 不过需要显式指定key和value的类型.

总结

截至目前,一个封装多进程神经网络算法的python版 gRPC server-client就已经圆满完成, 因为我也是刚接触,可能有理解上的偏差,恳请各位指正, 非常感谢~

参考资料

[1] gRPC–python中文官网
[2] python版gRPC快速入门一
[3] grpc/examples/python

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章