protobuf 介绍,Python 和 Go 编写 rpc 服务(gRPC)

楔子

以前写过一篇关于 rpc 相关的博客,但是很浅显,所以近期准备重新翻写一遍。

什么是 rpc

rpc 指的是远程过程调用(Remote Procedure Call),简单理解就是一个节点请求另一个节点提供的服务。

假设有两台服务器 A 和 B,一个部署在 A 服务器上的应用,想要调用 B 服务器上某个应用提供的函数 / 方法。但由于不在同一个内存空间,所以不能直接调用,而是需要通过网络来表达调用的语义和传达调用的数据。

显然与 rpc 对应的则是本地过程调用,我们本地调用一个函数便是最常见的本地过程调用。

但是很明显,将本地过程调用变成远程过程调用会面临各种各样的问题。

我们以一个简单的本地过程调用(Python 函数)为例:

def add(a, b):
    return a + b 

total = add(1, 2)

我们调用了 add 函数,查看它的字节码的话,会发现分为以下几步:

  • 1. LOAD_NAME: 加载变量 add 指向的函数对象
  • 2. LOAD_CONST: 将 1 和 2 两个整数压入运行时栈
  • 3. CALL_FUNCTION: 进行函数调用,将函数返回值保存在栈顶
  • 4. STORE_NAME: 从栈顶弹出返回值,赋值给 total

当然我们这里不是为了介绍函数的执行过程,只是为了表明在本地调用一个函数是极其简单的,但如果是远程过程调用就不一样了。假设我们上面的 add 函数部署在另一个节点上,那么我们在本地要如何去调用呢?显然这么做的话,我们需要面临如下问题:

1. Call 的 id 映射

远程服务中肯定不止一个函数,那么我们要怎么告诉远程机器,我们调用的是 add 函数,而不是 sub 或者其它的函数呢?首先在本地调用中,我们直接通过函数指针即可,编译器或解释器会自动帮我们找到指针指向的函数。但是在远程调用中是不行的,因为它们不在同一个节点,自然更不在同一进程,而两个进程的地址空间是不一样的。所以在 rpc 中,每个函数必须都有一个唯一的 ID,客户端在远程过程调用时,必须要附上这个 ID。然后客户端和服务端还需要各自维护一个 "函数和 Call id 之间的映射关系",相同的函数对应的 Call id 必须一致。当客户端需要进程远程调用时,根据映射关系找到函数对应的 Call  id,传递给服务端;然后服务端再根据 Call id 找到要调用的函数,然后进行调用。


2. 序列化和反序列化

这个相信你很熟悉,在做 web 开发的时候我们经常会用到。比如 Python 编写的 web 服务返回一个字典,那么它要如何变成 Go 的 map 呢?显然是先将 Python 的字典序列化成 json,然后 Go 再将 json 反序列化成 map。而 json 便是两者之间的媒介,它是一种数据格式,也是一种协议。这在 rpc 中也是同理,因为是远程调用,那么必然要涉及的数据的传输。那么问题来了,我们调用的时候肯定是需要传递参数的,那么这些参数要怎么传递呢?而且客户端和服务端使用的语言也是可以不一样的,比如客户端使用 Python,服务端使用 C++、Java 等等,而不同语言对应的数据结构不同,例如我们不可能在 C++、Java 里面操作 Python 中的字典、类实例等等。

所以还是协议,这是显而易见、而且最直接的解决办法。我们在传递参数的时候可以将内存中的对象序列化成一个可以在网络中传输的二进制对象,这个对象不是某个语言独有的,而是大家都认识。然后传输之后,服务端再将这个对象反序列化成对应语言的数据结构,同理服务端返回内容给客户端也是相同的过程。所以我们还是想到了 http + json,因为 它们用的太广泛了,客户端发送 http 请求,通过 json 传递参数;然后服务端处理来自客户端的请求,并将传递的 json 反序列化成对应的数据结构,并执行相应的逻辑;执行完毕之后,再将返回的结果也序列化成 json 交给客户端,客户端再将其反序列化。显然这是一个非常非常非常通用的流程,而实现了 rpc 的框架(gRPC)也是同样的套路,只不过它没有采用 http + json 的方式,因为这种协议是非常松散的,至于 gRPC 到底用的是什么协议我们后面说。


3. 网络传输

因为是远程调用,那么必然涉及到网络的传输,因此就需要有一个网络传输层。网络传输层需要把 Call id 和序列化的参数字节流返回传递给服务端,服务端逻辑执行完毕之后再将结果序列化并返回给客户端。只要能完成这个过程,那么都可以作为传输层使用。因此 rpc 所使用的协议是可以有多种的,只要能完成传输即可,尽管大部分 rpc 框架使用的都是 TCP 协议,但其实 UDP 也可以,而 gRPC 则直接使用了 HTTP2。另外,Java 的 Netty 也属于这层的东西。

补充一下,为什么 gRPC 没有采用 HTTP 协议。首先在 OSI 网络模型中,TCP 是属于传输层,而 HTTP 是基于 TCP 之上的应用层,所以 HTTP 协议基于 TCP 协议。如果是通过 HTTP 连接能够发起一个请求,那么使用 TCP 连接同样可以,当然我们也可以基于 TCP 协议自己封装一个类似于 HTTP 的应用层协议。

而 HTTP 协议有一个问题,那就是连接是一次性的,服务端一旦返回结果那么连接就断开了。所以很多时候我们更愿意基于 TCP 连接自己去实现,而不是使用 HTTP,因此对于性能要求高的场景,使用 HTTP 是很麻烦的。所以 HTTP 后来发展到了 2.0 版本,它是可以保持长连接的,我们目前用的都是 1.1。而 gRPC 便是基于 HTTP 2.0 设计的,由于 HTTP 2.0 能完成长连接,那么使用它的好处会非常多,因为它是兼容 HTTP 1.1 的,不需要我们自己再基于 TCP 去封装了。

rpc、http 以及 restful 之间的区别

可能这三者之间的概念容易让人产生混淆,下面来区分一下。

rpc 和 http

首先我们说如果想实现 rpc,那么必须要先解决两个问题:

  • 1. 数据的序列化和反序列化
  • 2. 网络传输协议

而 http 协议本身属于网络传输协议的一种,想实现 rpc 也需要依赖网络传输协议。而 rpc 不仅可以使用 http 协议,也可以使用 tcp 协议。所以结论很清晰了,http 协议只是实现 rpc 框架的一种选择,你可以选择它,也可以不选择它,因此这两者不是竞争关系。

rpc 和 restful

rpc 和 restful 之间也不是互斥的,我们通常对外提供服务的时候一般都是通过 http 请求的方式。而任何一种请求都是要有一种规范的,正所谓无规矩不成方圆嘛,而是 restful 便是相应的规范。既然是规范,那么我们完全可以不遵守,所以 restful 只是一种规范而已,它和 rpc 之间实际上是没有太大关系的。

使用 Python 开发 rpc

rpc 技术在架构设计上由四部分组成,分别是:客户端、客户端存根、服务端、服务端存根。

客户端(client):服务调用发起方,也称为服务消费者。

客户端存根(client stub):该程序运行在客户端所在的计算机上,主要用来存储要调用的服务器的地址。另外该程序还负责将客户端请求远程服务器程序的数据信息打包成数据包,通过网络发送给服务端的 stub 程序;其实还要接收服务端 stub 程序发送的调用结果的数据包,并解析返回给客户端。

服务端(server):远端计算机机器上运行的程序,其中有客户端调用的方法。

服务端存根(server stub):和客户端存根作用类似,负责接收客户端 stub 程序通过网络发送的请求消息数据包,并调用服务端相应的方法,完成功能调用;然后将调用的结果打包成数据包,发送给客户端的 stub 程序、

了解完了 rpc 技术的组成结构,我们来看一下具体是如何实现客户端到服务端的调用的。实际上,如果我们想要在网络中的任意两台计算机之间实现远程过程调用,需要解决很多问题,比如:

  • 两台物理机器在网络中要建立稳定可靠的通信连接
  • 两台服务器的通信协议的定义问题,即两台服务器上的程序要如何识别对方的请求和返回结果。也就是说,两台服务器必须都能够识别对方发来的消息,并能够解析出其中的请求含义或返回含义,然后才能进行处理,这其实就是数据通信协议所需要完成的工作

我们通过流程图来说明 rpc 每一步的调用过程:

整体逻辑还是很简单的,解释一下的话就是:

  • 1. 客户端想要发起一个远程过程调用,首先调用本地客户端 stud 程序
  • 2. 客户端 stub 程序接收了客户端的功能调用请求,将客户端请求调用的方法名、携带的参数等信息做序列化操作,打包成数据包
  • 3. 客户端 stub 程序查找远程服务器程序的 IP 地址,通过网络发送给服务端的 stub 程序
  • 4. 服务端 stub 程序接收到客户端发送的数据包信息,并使用和客户端 stub 相同的协议对数据包进行反序列化,得到请求的方法名和请求参数等信息
  • 5. 服务端 stub 程序准备相关数据,调用本地 server 对应的功能方法,并传入相应的参数,进行业务处理
  • 6. 服务端程序根据已有业务逻辑执行调用过程,待业务执行结束,将执行结果返回给服务端 stub 程序
  • 7. 服务端 stub 程序将调用结果按照约定的协议进行序列化,并通过网络发送给客户端 stub 程序
  • 8. 客户端 stub 程序接收到服务端 stub 发送的返回数据,对数据进行反序列化操作,然后将最终结果再交给客户端
  • 9. 客户端请求发起者得到调用结果,整个 rpc 过程结束

rpc 中你需要知道的东西

通过上面的描述,我们已经了解 rpc 是什么以及它的整个流程,我们可以把 rpc 看成是一系列操作的集合。其中包含了很多对数据的操作,以及网络通信,但是有两个细节我们没有说。

  • 1. 客户端存根和服务端存根要怎么生成
  • 2. 序列化和反序列化使用的是什么数据协议(不是 json)

1. 动态代理技术:我们提到的 client stub  和 server stub,在具体的编码和开发实践中,都是通过动态代理技术自动生成的。

2. 序列化和反序列化:在 rpc 的调用过程中,我们可以看到数据需要在一台机器上传输到另外一台机器上。在互联网上,所有数据都是以字节的形式进行传输的,而我们在编程的过程中,往往都是使用数据对象。因此想要在网络上将数据对象和相关变量进行传输,就需要对数据对象进行序列化和反序列化操作。

  • 序列化:把对象转换成字节序列的过程称为对象的序列化,也就是编码的过程
  • 反序列化:把字节序列恢复成对象的过程称为对象的反序列化,也就是解码的过程

而序列化和反序列化也是要遵循相应的数据协议的,比如 json、xml,而 rpc 框架中使用更为广泛的是 Protobuf,这也是数据编解码的一种协议。

Protobuf(Google Protocol Buffers)是 Google 提供的一个语言无关、平台无关、可扩展的,用于序列化结构数据的工具库,它可用于(数据)通信协议、数据存储等。类似于 json,但是比 json 具有更高的转化效率,时间效率和空间效率都是 json 的 3 到 5 倍。并且具有跨语言性,支持:Python、Go、Java、C++、JavaScript 等等。

基于 xmlrpc 库实现一个 rpc

Python 实际上提供了一个内置的库叫做 xmlrpc,从名字上看也是基于 xml 实现的 rpc,也就是它的数据传输是通过 xml 实现的。

from xmlrpc.server import SimpleXMLRPCServer

# 我们看到这里只需要编写业务逻辑,至于函数映射等逻辑是存根所做的事情
# 所以这和 web 服务是不一样的,并且也没有数据的编码和解码,客户端只需要专注于业务逻辑即可
class Vtuber:

    vtubers = ["神乐七奈", "夏色祭", "凑-阿库娅"]

    def show_vtubers(self):
        return self.vtubers

    def add_vtuber(self, vtuber):
        if vtuber not in self.vtubers:
            self.vtubers.append(vtuber)

    def remove_vtuber(self, vtuber):
        if vtuber in self.vtubers:
            self.vtubers.remove(vtuber)


vtuber = Vtuber()
# 调用 SimpleXMLRPCServer,绑定ip和端口
# 如果服务端某个函数返回了 None,那么需要指定 allow_none=True,否则客户端调用时会报错
server = SimpleXMLRPCServer(("localhost", 6666), allow_none=True)
# 将实例对象注册给 rpc server
server.register_instance(vtuber)
server.serve_forever()

从代码来看,暴露出来的接口逻辑还是非常简单的,没有像 web 框架那样需要进行 url 的映射(Django 是 urlconfig、flask 是 route 等等),也不需要显示地进行数据的序列化和反序列化。那么下面来看看客户端的编写:

from xmlrpc import client

# 这里只需要指定服务端的 ip 地址即可,通过 url 的方式
server = client.ServerProxy("http://localhost:6666")

# 然后可以通过 server 直接调用里面的方法,非常的方便
print(server.show_vtubers())  # ['神乐七奈', '夏色祭', '凑-阿库娅']
server.add_vtuber("时雨羽衣")
print(server.show_vtubers())  # ['神乐七奈', '夏色祭', '凑-阿库娅', '时雨羽衣']
server.remove_vtuber("凑-阿库娅")
print(server.show_vtubers())  # ['神乐七奈', '夏色祭', '时雨羽衣']

从这里我们感觉似乎 xmlrpc 挺好用的,但如果你通过 web 框架也是同样可以做到 xmlrpc 的效果,只不过 web 框架的目的不在于此。而且 xmlrpc 还有一个局限性,就是客户端只能通过 xmlrpc 提供的客户端去访问,你像浏览器、requests 包都做不到,因为一个是 xml 协议、一个是 http 协议。而 web 框架是暴露出一个 url,可以支持不同的途径去访问,只要你能发送 http 请求即可。所以 web 框架更强调是灵活性,而 rpc 更强调的是本地调用效果,所以 rpc 更常在内部调用。

xmlrpc 库在数据的序列化和反序列化所使用的协议显然是 xml,而除了 xml 还有 json,只不过 Python 官方没有提供基于 json 进行序列化和反序列化的 rpc 库。

显然 rpc 中一个非常重要的一步就是数据的序列化和反序列化,如果你能实现一个更好的数据序列化和反序列化协议,那么你能实现一个更好的 rpc 框架。

zerorpc 实现 rpc 调用

zerorpc 是利用 zeroMQ 消息队列 + msg 消息序列化(二进制)来实现类似于 gRPC(一个 rpc 框架,我们后面重点介绍)的功能,并且还能够跨语言调用(Nodejs 和 Python)。主要使用到 zeroMQ 的通信模式 ROUTER-DEALER,并模拟 grpc 的请求响应式和应答流式 rpc,而且还支持 PUB-SUB 通信模式的远程调用。

但是注意:zerorpc 并不需要我们安装一个 zeroMQ 消息队列,它是一个 Python 第三方库,只是使用了里面的通信模式而已。zerorpc 依赖 msgpack-python pyzmq future greenlet gevent,直接 pip install zerorpc 即可,会自动解决依赖。另外我们看到它依赖 gevent,说明 zerorpc 是支持并发的。

一元调用

一元调用类似于 xmlrpc,创建一个 rpc 服务,将一个类注册到里面,然后监听端口即可。

import zerorpc

class Vtuber:

    vtubers = ["神乐七奈", "夏色祭", "凑-阿库娅"]

    def show_vtubers(self):
        return self.vtubers

    def add_vtuber(self, vtuber):
        if vtuber not in self.vtubers:
            self.vtubers.append(vtuber)

    def remove_vtuber(self, vtuber):
        if vtuber in self.vtubers:
            self.vtubers.remove(vtuber)


vtuber = Vtuber()
server = zerorpc.Server(vtuber)
server.bind("tcp://0.0.0.0:7777")
server.run()

以上是服务端的代码,可以看到不同的框架虽然调用方式不同,但整体都是大同小异的。然后我们编写客户端的代码:

import zerorpc

client = zerorpc.Client()
client.connect("tcp://127.0.0.1:7777")
print(client.show_vtubers())  # ['神乐七奈', '夏色祭', '凑-阿库娅']

在功能上,没有太大的区别,但是很明显速度要比 xmlrpc 快很多。因此如果我们需要在业务上实现一个简单的 rpc,那么 zerorpc 是我们的首选。

流式调用

什么是流式调用呢?举个简单的例子,我们服务里面的某个函数执行了数据查询,那么可以查询完毕之后一次性返回,也可以查询一部分就返回一部分,像水流一样源源不断。

import zerorpc

class StreamingRPC:

    # 必须使用该装饰器进行装饰,否则会有异常
    @zerorpc.stream
    def streaming_range(self, start, end, step=1):
        return range(start, end, step)


server = zerorpc.Server(StreamingRPC())
server.bind("tcp://0.0.0.0:7777")
server.run()

然后是客户端:

import zerorpc

client = zerorpc.Client()
client.connect("tcp://127.0.0.1:7777")
for item in client.streaming_range(1, 10, 2):
    print(item)
"""
1
3
5
7
9
"""

我们目前的客户端服务端通信方式都是通过 tcp 直连的方式,使用 msgpack 将消息序列化之后直接通过 tcp 将数据传送过去,而 zerorpc 是不需要依赖 zeroMQ 消息队列的。但是它也可以选择使用 zeroMQ 消息队列,客户端将消息发送到队列中,服务端从队列中取出消息,业务逻辑执行完毕之后再将结果序列化并放到队列中,客户端再去取。

因此这种方式就实现了解耦,而且也实现了异步,客户端发送完毕之后就没必要等待了。可以先去做别的事情,没事往队列里面看一看,如果有数据就取出来,没有的话就继续干其它的。此外还可以实现限流,只要给消息队列设置一个容量即可,当然也可以实现复杂均衡等等,这个不是我们的重点,我们的重点的是 gRPC。

所以 zerorpc 相对还是比较完善的,支持 Nodejs 和 Python,但为什么我们不选择它呢?首先是生态,zerorpc 的 生态不如 gRPC;以及语言的支持,gRPC 支持绝大部分的语言,而且它的功能要比 zerorpc 更强大。

使用 Go 开发 rpc

下面我们来看看如何在 Go 中进行 rpc 开发,当然有了 Python 开发 rpc 的经验,在使用 Go 的时候就简单多了。

这里我使用的是 go1.14,采用的是 go module,强烈建议你也使用这种包管理方式。因为早期的 Go 在包管理方面实际上做的不是太完善,特别是需要将整个工程目录放在 GOPATH 的 src 下真的是诟病无数。但是自从引入了 go module 就方便许多了,因此后续在贴代码的时候文件所在的层级目录就不说了。

我们以一个简单的 hello world 为例,看看怎么使用 Go 开进行 rpc 的开发。首先编写服务端:

package main

import (
    "fmt"
    "net"
    "net/rpc"
)

// 定义一个简单的结构体,里面不包含任何的成员,第一个例子越简单越好
// 我们可以把这个结构体想象成 Python 中的类
type HelloService struct {

}

// 然后绑定方法
// 接收两个参数: string *string,返回一个 error,参数和返回值类型比较固定,我们没有选择的权利
func (server *HelloService) Hello (request string, reply *string) error {
    // 函数的返回值是一个 error,但显然该服务返回给客户端的数据不可能是一个 error
    // 没错,返回值是用过 reply 指定的,它是一个指针,我们可以修改它
    *reply = fmt.Sprintf("hello %s", request)
    return nil
}

func main() {
    // 接下来要做什么了?显然是实例化一个 Server,然后将 HelloService 注册到 rpc 服务当中,然后客户端调用它绑定的方法
    // 这里叫 listener
    listener, _ := net.Listen("tcp", ":9999")

    // 注册:第一个参数是名字(很重要,介绍客户端的时候会说);第二个参数结构体实例的指针,相当于 Python 中的实例对象
    _ = rpc.RegisterName("Hello Service", &HelloService{})

    // 启动服务,等待连接的到来
    // 而一旦新的连接进来的时候,会返回一个连接,然后 rpc.ServeConn,后续的一系列操作都由该 rpc 来接管了
    conn, _ := listener.Accept()
    rpc.ServeConn(conn)
}

然后是客户端:

package main

import (
    "fmt"
    "net/rpc"
)

func main() {
    // 1. 建立连接
    client, err := rpc.Dial("tcp", "localhost:9999")
    if err != nil {
        fmt.Println("客户端创建失败,失败原因:", err)
        return
    }
    // 调用
    var reply string
    // 通过 client.Call 来实现,第一个参数就是服务端在注册结构体的时候使用的名字(不是结构体的名字),调用的时候在后面加上 .方法名即可
    // 所以我们在服务端注册的时候,起名字还是要规范一些,尽管我们这里出现了空格也没有什么问题
    // 这里我们调用了里面的 Hello 方法,第二个参数和第三个参数就是服务端的方法接收的参数
    if err := client.Call("Hello Service.Hello", "古明地觉", &reply); err != nil {
        fmt.Println("调用失败,失败原因:", err)
        return
    }
    // reply 就是返回值(对于当前逻辑而言),我们传递指针进去,执行完毕之后发现被修改了
    fmt.Println(reply)  // hello 古明地觉
}

整体还是很简单的,但是我们观察一下服务端的最后两个代码:

	// 程序一直阻塞在这里,等待连接
    conn, _ := listener.Accept()
    // 到来之后进行处理,处理完毕之后程序结束
    rpc.ServeConn(conn)

所以我们这里服务端是一次性的,如果想要一直监听的话应该这么做:

    for {
        conn, _ := listener.Accept()
        go rpc.ServeConn(conn)
    }

无限循环,每一个连接单独开启一个 goroutine 去处理。

替换 rpc 的序列化协议为 json

我们演示了基于 Go 的 rpc 实现,但是它存在一些问题,就是客户端在调用的时候必须要知道服务端在注册结构体时指定的名字,所以它相对于 Python 的开发就显得不是太好用了。

另外,Go 和 rpc 可不可以实现跨语言调用呢,它的序列化协议是什么,能否替换成常见的 json 呢。

首先 Go 采用的序列化协议是其独有的 Gob 协议,因此 Python 是无法调用的,但它是可以被替换成 json 的,下面我们就来实现一下。

package main

import (
    "fmt"
    "net"
    "net/rpc"
    "net/rpc/jsonrpc"
)

type HelloService struct {

}

func (server *HelloService) Hello (request string, reply *string) error {
    *reply = fmt.Sprintf("hello %s", request)
    return nil
}

func main() {
    listener, _ := net.Listen("tcp", ":9999")
    _ = rpc.RegisterName("Hello Service", &HelloService{})
    for {
        conn, _ := listener.Accept()
        // 其它部分不变,这里改成如下
        go rpc.ServeCodec(jsonrpc.NewServerCodec(conn))
    }
}

然后是客户端,也需要做一些略微的改动:

package main

import (
    "fmt"
    "net"
    "net/rpc"
    "net/rpc/jsonrpc"
)

func main() {
    // 这里需要使用 net.Dial 进行连接,之前使用 rpc 是因为需要采用 Gob 协议,但是现在我们不需要了
    // 而 net.Dial 的返回结果是连接,就不再是客户端了,当然我们后面要根据这个连接来创建客户端
    conn, err := net.Dial("tcp", "localhost:9999")
    if err != nil {
        fmt.Println("连接建立失败,失败原因:", err)
        return
    }
    var reply string
    // 服务端是 NewServerCodec,客户端是 NewClientCodec 来进行数据的编解码
    client := rpc.NewClientWithCodec(jsonrpc.NewClientCodec(conn))
    // 然后调用的方式不变,但是序列化之后的数据变了,因为不是同一种协议
    if err := client.Call("Hello Service.Hello", "古明地觉", &reply); err != nil {
        fmt.Println("调用失败,失败原因:", err)
        return
    }
    fmt.Println(reply)  // hello 古明地觉
}

所以此时也是可以实现的,但是问题来了, 既然 Go 的服务端在接收数据和返回数据采用的都是 json,那么就意味着任何支持 json 的语言都是可以调用的。只要我们知道数据的转换格式即可,我们客户端在调用的时候使用的是 client.Call,但是在传输的时候会进行编码,如果能知道数据在编码之后的格式,那么任何支持 json 的语言都可以调用。

而编码格式如下:

{"method": "Hello Service.Hello", "params": ["古明地觉"], "id": 0}

method 是方法;params 是参数,即便只有一个元素也要写成数组,而 id 就是 Call id 了。

既然知道了编码之后的数据结构,那么我们就可以使用 Python 进行调用了。

"""
问一句,我们可以使用 requests 进行发送请求吗?
思考一下就知道是不可以的,因为 requests 发送的请求采用的是 HTTP 协议
发送出去的都是 HTTP 协议的文本,当然本质上也是一个字符串,由 header + 请求内容 组成

但是并不代表我们就不能使用 requests 发送,首先使用 requests 是可以连接到 Go 编写的服务端的
因为 HTTP 连接也是基于 TCP 连接的,只不过 Go 的 tcp 服务只负责解析请求的内容
而 requests 发送的数据除了请求内容之外还有很多的 header
但 Go 的 tcp 服务不负责解析这些 header,它只解析请求内容,所以此时采用 requests 是不行的
并不是它不能够发送请求
"""
from pprint import pprint
import asyncio
import simplejson as json

# 所以下面我们使用 asyncio 来进行模拟,当然你也可以使用 socket
async def f(name: str):
    # 建立 tcp 连接
    reader, writer = await asyncio.open_connection(
        "localhost", 9999)  # type: asyncio.StreamReader, asyncio.StreamWriter
    # 创建请求体,并且需要编码成字节
    payload = json.dumps({"method": "Hello Service.Hello", "params": [name], "id": 0}).encode("utf-8")
    # 发送数据
    writer.write(payload)
    await writer.drain()
    # 读取数据
    data = await reader.readuntil(b"\n")
    writer.close()
    return json.loads(data)


async def main():
    name_lst = ["古明地觉", "古明地恋", "雾雨魔理沙", "琪露诺", "芙兰朵露"]
    loop = asyncio.get_running_loop()
    task = [loop.create_task(f(name)) for name in name_lst]
    result = await asyncio.gather(*task)
    return result


res = asyncio.run(main())
pprint(res)
"""
[{'error': None, 'id': 0, 'result': 'hello 古明地觉'},
 {'error': None, 'id': 0, 'result': 'hello 古明地恋'},
 {'error': None, 'id': 0, 'result': 'hello 雾雨魔理沙'},
 {'error': None, 'id': 0, 'result': 'hello 琪露诺'},
 {'error': None, 'id': 0, 'result': 'hello 芙兰朵露'}]
"""

我们看到此时 Python 也是可以调用的,因此我们就基于 rpc 实现了 Python 和 Go 的交互。当然不仅是 Python,你使用其它的任何语言都是可以的,只要它支持 json 协议即可。

当然我们还可以将 rpc 封装成 http 服务,只不过我们需要手动编写客户端存根和服务端存根对应的逻辑,而这些是可以自动生成的。下面我们就来介绍我们重点:gRPC,以及它所使用的数据序列化协议 protobuf。

gRPC 入门

gRPC 是一个高性能、通用的开源 rpc 框架,由 Google 为了面向移动应用开发,而基于 HTTP/2 协议、protobuf(protocol buffers)数据序列化协议所设计。gRPC 是一个通用型框架,适用于微服务开发,支持众多的开发语言,基本上主流语言都支持(都有对应的库)。

gRPC 还提供了一种简单的方法来精确地定义服务,以及为 IOS、Android 和后台支持服务自动生成可靠性很强的客户端功能库。客户端充分利用高级流和链接功能,从而有助于节省带宽、降低 TCP 连接次数、节省 CPU 使用、增加电池寿命等等。

所以 gRPC 是一个 rpc 框架,protobuf 是一个数据序列化反序列化协议,因此 protobuf 是可以独立存在的。比如我们使用 http,我们也可以不返回 json,而是返回一个 protobuf,这也是可以的。

protobuf 简介

  • 1. 习惯了 json、xml 数据存储格式的我们,很少会使用 Protocol Buffer,即便你听说过它,但也很少用它
  • 2. Protocol Buffer 是 Google 开发的一种轻量 & 高效的结构化数据存储格式,性能比 json、xml 强太多
  • 3. protobuf 经历了 protobuf2 和 protobuf3,而 pb3 相比 pb2 简化了许多、变得更加易用,目前主流的版本是 pb3

protobuf 的优缺点

Python 下的 gRPC 初体验

protobuf 还是非常重要的,我们如果想使用 gRPC 服务,就需要先编写一个 protobuf 文件,然后根据这个文件生成对应语言的客户端存根和服务端存根。存根帮我们做好了函数 ID 映射、以及数据序列化反序列化,导入它们即可使用,而我们则只需要专注于业务逻辑即可。

所以使用 gRPC 重点是编写 protobuf 文件,下面我们来感受一下,不过在这之前需要先安装。

pip install grpcio grpcio-tools protobuf -i https://pypi.tuna.tsinghua.edu.cn/simple

下面我们来编写 protobuf 文件,它有自己的语法格式,所以相比 json 它的门槛比较高。我们的文件名就叫 matsuri.proto,protobuf 文件的后缀是 .proto。

// syntax 是指定使用哪一种 protobuf 服务, 现在使用的都是 "proto3"
syntax = "proto3";

// 包名, 这个不是很重要, 你删掉也是无所谓的
package test;

// 编写服务, 每个服务里面有相应的函数(对应 restful 视图函数)
// service 表示创建服务
service Matsuri {
  //使用 rpc 定义函数, 参数名为 matsuri_request, 返回值为 matsuri_response
  rpc hello_matsuri(matsuri_request) returns (matsuri_response){}
}
// 所以我们是创建了一个名为 Matsuri 的服务, 服务里面有一个 hello_matsuri 的函数
// 函数接收一个名为 matsuri_request 的参数, 并返回一个 matsuri_response, 至于结尾的 {} 我们后面再说
// 另外参数 matsuri_request、返回值 matsuri_response 是哪里来的呢? 所以我们还要进行定义

// 注意: matsuri_request 虽然是参数, 但我个人更愿意把它称之为参数的载体
// 比如下面定义两个变量 name 和 age, 客户端会把它们放在 matsuri_request 里面, 在服务端中也会通过 matsuri_request 来获取
message matsuri_request {
  string name = 1; // = 1表示第1个参数
  int32 age = 2;
}

// matsuri_response 同理, 虽然它是返回值, 但我们返回的显然是 result, 只不过需要放在 matsuri_response 里面
// 具体内容在代码中会有体现
message matsuri_response {
  string result = 1;
}

所以有人可能已经发现了,这个 protobuf 文件就是定义一个服务的框架。然后我们就要用这个 protobuf 文件,来生成对应的 Python 服务端和客户端文件。

python -m grpc_tools.protoc --python_out=. --grpc_python_out=. -I. matsuri.proto

--python_out 和 --grpc_python_out 表示输出路径,. 表示输出到当前路径;-I 表示从哪里寻找 protobuf 文件,这里也是当前目录,matsuri.proto 表示转化的 protobuf 文件。

执行完之后我们看到多出了两个文件,这个是自动帮你生成的,matsuri_pb2.py 是给 protobuf 用的,matsuri_pb2_grpc.py 是给 gRPC 用的。而这两个文件可以用来帮助我们编写服务端和客户端,我们来简单尝试一下,具体细节后面会补充。

# 服务端
# 导入 grpc 第三方库
import grpc
# 导入自动生成的两个 py 文件, 还是那句话, matsuri_pb2 是给 protobuf 用的, matsuri_pb2_grpc 是给 grpc 用的
# 这两个文件的名字比较类似, 容易搞混
import matsuri_pb2 as pb2
import matsuri_pb2_grpc as pb2_grpc


# 我们在 protobuf 里面创建的服务叫 Matsuri, 所以 pb2_grpc 会给我们提供一个名为 MatsuriServicer 的类
# 我们直接继承它即可, 当然我们这里的类名叫什么就无所谓了
class Matsuri(pb2_grpc.MatsuriServicer):

    # 我们定义的服务里面有一个 hello_matsuri 的函数
    def hello_matsuri(self, matsuri_request, context):
        """
        matsuri_request 就是相应的参数(载体): name、age都在里面
        当然我们也可以不叫 matsuri_request, 直接叫 request 也是可以的, 它只是一个变量名
        :param request:
        :param context:
        :return:
        """
        name = matsuri_request.name
        age = matsuri_request.age

        # 里面返回是 matsuri_response, 注意: 必须是这个名字, 因为我们在 protobuf 文件中定义的就是 matsuri_response
        # 这个 matsuri_response 内部只有一个字符串类型的 result, result 需要放在 matsuri_response 里面
        return pb2.matsuri_response(result=f"name is {name}, {age} years old")


if __name__ == '__main__':
    # 创建一个 gRPC 服务
    # 里面传入一个线程池, 我们这里就启动 4 个线程吧
    from concurrent.futures import ThreadPoolExecutor
    grpc_server = grpc.server(ThreadPoolExecutor(max_workers=4))
    # 将我们定义的类的实例对象注册到 gRPC 服务中, 我们看到这些方法的名字都是基于我们定义 protobuf 文件
    pb2_grpc.add_MatsuriServicer_to_server(Matsuri(), grpc_server)
    # 绑定ip和端口
    grpc_server.add_insecure_port("127.0.0.1:22222")
    # 启动服务
    grpc_server.start()

    # 注意: 如果直接这么启动的话, 会发现程序启动之后就会立刻停止
    # 因为里面的线程应该是守护线程, 主线程一结束服务就没了
    # 所以我们还需要调用一个 wait_fort_termination
    grpc_server.wait_for_termination()

然后我们启动服务端,会发现可以正常启动。

注意:如果你发现报错了,出现如下异常:module 'google.protobuf.descriptor' has no attribute '_internal_create_key',说明是你的 protobuf 版本过低导致的,可以通过升级 protobuf 来解决。

pip install --upgrade protobuf -i https://pypi.tuna.tsinghua.edu.cn/simple

服务端编写完毕,下面我们来编写客户端。

# 客户端
import grpc
import matsuri_pb2 as pb2
import matsuri_pb2_grpc as pb2_grpc


# 定义一个频道, 连接至服务端监听的端口
channel = grpc.insecure_channel("127.0.0.1:22222")
# 生成客户端存根 
client = pb2_grpc.MatsuriStub(channel=channel)

# 然后我们就可以直接调用 Matsuri 服务里面的函数了
print("准备使用服务了~~~~")
while True:
    name, age = input("请输入姓名和年龄, 并使用逗号分割:").split(",")
    # 调用函数, 传入参数 matsuri_request, name 和 age 位于 matsuri_request 中; 因为不能直接发送, 需要序列化成 protobuf
    # 注意: 必须是 matsuri_request, 因为我们在 protobuf 文件定义的就是 matsuri_request
    matsuri_response = client.hello_matsuri(
        pb2.matsuri_request(name=name, age=int(age))
    )
    # result 位于返回值 matsuri_response 中, 直接通过属性访问的形式获取
    # 而之所以能够这么做, 也是客户端存根在背后为我们完成的, 当然这里也可以不叫 matsuri_response, 它只是一个变量名
    print(matsuri_response.result)

下面我们来执行客户端,调用一下试试。

所以整体逻辑还是比较简单的,当然这是因为背后有很多细节都自动帮我们完成了,而核心就是那两个自动生成的文件,我们只需要关注业务逻辑即可。但是注意:自动生成的两个文件,我们不要擅自改动它,除非你对 protobuf 协议非常的了解。

然后问题来了,我们来看看采用 protobuf 协议序列化之后的结果是什么,不是说它比较高效吗?那么我们怎能不看看它序列化之后的结果呢,以及它和 json 又有什么不一样呢?

import matsuri_pb2 as pb2

request = pb2.matsuri_request(name="koishi", age=15)
# 调用 SerializeToString 方法会得到一个二进制的字符串
print(request.SerializeToString())  # b'\n\x07matsuri\x10\x10'

# 这个字符串显然我们看不懂,我们暂时也不去深究它的意义,总之这就是 protobuf 序列化之后的结果
# 而且我们还可以将其反序列化,不然服务端接收到之后也不认识啊
request2 = pb2.matsuri_request()
request2.ParseFromString(b'\n\x06koishi\x10\x0f')
print(request2.name)  # koishi
print(request2.age)  # 15
"""
是可以正常反序列化的,所以我们不认识没关系,protobuf 认识就行
那么 b'\n\x06koishi\x10\x0f' 到底是啥意思呢?
首先里面的 \x06 表示后面的 6 个字符代表 name 参数的值,而之所以是 name 不是 age
是因为我们在定义 protobuf 文件的时候,name 参数的位置是第 1 个
而 \x0f 就是 16 进制的 15
"""
# 然后来看看 json
import simplejson as json
print(json.dumps({"name": "koishi", "age": 15}).encode("utf-8"))  # b'{"name": "koishi", "age": 15}'

# 可以看到 protobuf 协议序列化之后的结果要比 json 短, 平均能得到一倍的压缩

以上便是 Python 实现简单的 gRPC 服务,可以看到至少在业务层面还是比较简单的。

grpc 文件的 import 问题

我们看一下自动生成的 grpc 文件,里面存在一个问题。

import matsuri_pb2 as matsuri__pb2

在 matsuri_pb2_grpc.py 中导入了 matsuri_pb2.py,因为 grpc 需要使用 protobuf,但是这样导包是不是存在一些问题呢?首先这两个文件必须要在同一个目录,这是毋庸置疑的,否则会导入失败。但是这行代码还隐含了我们的客户端和服务端代码也要和这两个文件在同一个目录,但如果不是这样会有什么后果呢?我们画一张图:

假设我们的工程目录叫 dir1,我们的客户端、服务端代码位于该目录中,但是自动生成的两个 py 文件我们将其放到一个单独的目录 dir2 中。这个时候如果客户端或服务端导入 matsuri_pb2_grpc.py 的时候会发生什么问题?显然会报错,因为客户端或服务端在导入的时候,工作区是 dir1 目录,然后 matsuri_pb2_grpc 中再 import matsuri_pb2 显然就找不到了,因为它位于 dir2 目录中。

所以如果我们要将其放在一个单独的目录中(假设叫 grpc_helper),那么我们应该将 matsuri_pb2_grpc 中的导入逻辑改成这样子:

from . import matsuri_pb2 as matsuri__pb2

然后客户端和服务端使用下面的方式导入:

from grpc_helper import matsuri_pb2 as pb2
from grpc_helper import matsuri_pb2_grpc as pb2_grpc

这样做就没有问题了,所以要注意相应的导包逻辑。但是这并不能算是一个 bug,因为 Python 的导包逻辑就是如此,而在生成文件的时候它也显然不可能猜测出你要把文件放在哪个目录中,所以这种情况我们需要手动设置。

go 下的 gRPC 初体验

看完了 Python 的 gRPC,我们再来看看 Go 的 gRPC,既然了解了 Python 的 gRPC,那么再看 Go 的 gRPC 会简单很多。

首先要安装 protoc,它是 protobuf 的编译工具,它适用于所有的语言,我们去 https://github.com/protocolbuffers/protobuf/releases 下载对应操作系统的 protoc 即可,这里我下载的是 protoc-3.14.0-win64.zip。解压之后,将里面的 bin 目录添加到环境变量中。

我们生成 Python 对应的文件时,是通过 grpc_tools 这个模块来帮我们生成的,它里面实现了 protoc 的功能。但是对于 Go 而言,我们需要使用 protoc 这个编译工具,当然这个工具也适用于 Python。

然后安装 Go 的第三方包:

go env -w GO111MODULE=on
go env -w GOPROXY=https://goproxy.cn,direct
go get -u github.com/golang/protobuf/protoc-gen-go
go get -u google.golang.org/grpc

安装之后,我们来编写 protobuf 文件,我们说 protobuf 是支持多语言的,所以只需要做一些简单的修改即可。

syntax = "proto3";
// 这里需要指定我们生成的是 go 的 package,其它语言也是同理
// 然后里面的 ".;yoyoyo" 是什么鬼? 首先 go 的源文件一定要有一个 package,而 .; 后面的 yoyoyo 便是指定对应的包名
option go_package = ".;yoyoyo";

// 注意:由于 Go 的可导出性,我们需要将名称改成大写,而且最好遵循驼峰命名法
service Matsuri {
  rpc HelloMatsuri(MatsuriRequest) returns (MatsuriResponse){}
}

message MatsuriRequest {
  string name = 1; // = 1表示第1个参数
  int32 age = 2;
}

message MatsuriResponse {
  string result = 1;
}

然后生成 Go 的源文件,命令如下:

protoc --go_out=plugins=grpc:. -I . matsuri.proto

相信这些命令不需要解释了,都是写死的,执行完之后会发现自动帮你生成了一个 matsuri.pb.go 源文件。注意:只有一个文件,不光是 Go,其它语言都只生成一个文件,只有 Python 是两个文件。

所以在 Go 里面不存在类似于 Python 中的导包问题,因为它只有一个文件。

由于我们生成的 Go 文件中的 package 是 yoyoyo,那么我们最好新建一个目录 yoyoyo,然后将其放在里面。然后我们来编写服务端和客户端代码,首先是服务端。

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "matsuri/yoyoyo"
    "net"
)

type Matsuri struct {

}
// 接收两个参数,和 Python 类似,Python 的 hello_matsuri 函数的第一个参数是 matsuri_request,第二个参数是 context
// matsuri_request 就是实际数据的载体,它是 matsuri_pb2.matsuri_request(),而 context 我们没有用上,后面会介绍
// 在 Go 中也是如此,只不过这两个参数是相反的。
// 第一个参数是 context(这里叫 ctx),它是 context.Context 类型;第二个参数是 matsuri_request,类型是 *yoyoyo.MatsuriRequest(通过包名去调用)
// 然后返回值为 *yoyoyo.MatsuriResponse 和 error
func (m *Matsuri) HelloMatsuri (ctx context.Context, matsuri_request *yoyoyo.MatsuriRequest) (*yoyoyo.MatsuriResponse, error) {
    // 我们看到在 proto 文件中是小写的 name 和 age,但是在生成文件的时候自动帮我们变成了 Name 和 Age,所以 protoc 在生成文件的时候会自动帮我们处理不同语言的逻辑
    name := matsuri_request.Name
    age := matsuri_request.Age
    return &yoyoyo.MatsuriResponse{Result: fmt.Sprintf("name: %s, age %d", name, age)}, nil
}

func main() {
    // 创建服务端
    server := grpc.NewServer()
    // 注册, 第一个参数是服务端, 第二个参数是必须实现 HelloMatsuri(context.Context, *MatsuriRequest) (*MatsuriResponse, error) 方法的接口
    yoyoyo.RegisterMatsuriServer(server, &Matsuri{})
    // 监听端口
    listener, err := net.Listen("tcp", ":33333")
    if err != nil {
        fmt.Println(err)
        return
    }
    // 开启服务,每来一个连接,内部会开一个协程去处理
    if err = server.Serve(listener); err != nil {
        fmt.Println(err)
        return
    }
}

服务端编写完毕之后直接启动,发现没有问题,接下来我们编写客户端。

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "matsuri/yoyoyo"
)

func main() {
    conn, err := grpc.Dial("127.0.0.1:33333", grpc.WithInsecure())
    if err != nil {
        fmt.Println(err)
        return
    }
    defer func() { _ = conn.Close() }()
    client := yoyoyo.NewMatsuriClient(conn)
    response, _ := client.HelloMatsuri(
        context.Background(),
        &yoyoyo.MatsuriRequest{Name: "夏色祭", Age: 16},
    )
    fmt.Println(response.Result)  // name: 夏色祭, age 16
}

我们看到整体没有任何问题,还是很简单的。

Python 和 Go 相互调用

下面我们来看看如何实现 Python 和 Go 之间的互相调用,Python 编写服务端,Go 去访问;Go 编写服务端,Python 去访问。

注意:如果实现互相调用,那么它们 proto 文件中的类、方法等信息要完全一致。

下面来编写 proto 文件,为了方便这里的 proto 文件名我们就不改了,还叫原来的 matsuri.proto:

syntax = "proto3";

// 如果是 Go 的话,那么只需要加上 option go_package = ".;包名";
option go_package = ".;包名";
service Mea {
  rpc HelloMea(request) returns (response){}
}
message request {
  string name = 1;
  int32 age = 2;
}

message response {
  string result = 1;
}

编写 Go 的服务端:

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "matsuri/yoyoyo"
    "net"
)

type Mea struct {

}
func (m *Mea) HelloMea (ctx context.Context, request *yoyoyo.Request) (*yoyoyo.Response, error) {
    // 参数 request 是小写, 这里自动帮我们变成了大写,同理还有下面的 response
    name := request.Name
    age := request.Age
    return &yoyoyo.Response{Result: fmt.Sprintf("你好: %s, %d 岁的单亲妈妈", name, age)}, nil
}

func main() {
    // 创建服务端
    server := grpc.NewServer()
    yoyoyo.RegisterMeaServer(server, &Mea{})
    listener, err := net.Listen("tcp", ":33333")
    if err != nil {
        fmt.Println(err)
        return
    }
    if err = server.Serve(listener); err != nil {
        fmt.Println(err)
        return
    }
}

编写 Python 的服务端:

import grpc
import matsuri_pb2_grpc as pb2_grpc
import matsuri_pb2 as pb2


class Mea(pb2_grpc.MeaServicer):

    def HelloMea(self, request, context):
        name = request.name
        age = request.age

        return pb2.response(result=f"name is {name}, {age} years old")


if __name__ == '__main__':
    from concurrent.futures import ThreadPoolExecutor
    grpc_server = grpc.server(ThreadPoolExecutor(max_workers=4))
    pb2_grpc.add_MeaServicer_to_server(Mea(), grpc_server)
    grpc_server.add_insecure_port("127.0.0.1:22222")
    grpc_server.start()
    grpc_server.wait_for_termination()

两个语言的服务端,我们就编写完毕了,Python 服务端监听 22222 端口,Go 服务端监听 33333 端口,那么下面来编写客户端。Python 客户端调用 Go 服务端,Go 客户端调用 Python 服务端。

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "matsuri/yoyoyo"
)

func main() {
    // 连接 22222 端口,这是 Python 服务
    conn, err := grpc.Dial("127.0.0.1:22222", grpc.WithInsecure())
    if err != nil {
        fmt.Println(err)
        return
    }
    defer func() { _ = conn.Close() }()
    client := yoyoyo.NewMeaClient(conn)
    response, _ := client.HelloMea(
        context.Background(),
        &yoyoyo.Request{Name: "神乐 mea", Age: 38},
    )
    fmt.Println(response.Result)  // name is 神乐 mea, 38 years old
}

我们看到 Go 调用 Python 的服务是完全没有问题的,那么 Python 调用 Go 呢?

import grpc
import matsuri_pb2_grpc as pb2_grpc
import matsuri_pb2 as pb2
channel = grpc.insecure_channel("127.0.0.1:33333")
client = pb2_grpc.MeaStub(channel=channel)
response = client.HelloMea(
    pb2.request(name="神乐 mea", age=38)
)
print(response.result)  # 你好: 神乐 mea, 38 岁的单亲妈妈

此时我们就通过 gRPC 和 protobuf 完成了 Python 和 Go 之间的 rpc 调用。

gRPC 的流模式

上面我们介绍了 gRPC 的使用方式,下面介绍 gRPC 中的 stream,也就是流模式。流模式可以源源不断地推送数据,因此很适合传输一些大数据,或者服务端和客户端之间进行长时间的数据交互。比如客户端可以向服务端订阅一个数据,服务端就可以利用 stream 源源不断地向客户端推送数据。

而流模式总共有以下几种:

  • 服务端数据流模式(Server-side streaming RPC)
  • 客户端数据流模式(Client-side streaming RPC)
  • 双向数据流模式(Bidirectional-side streaming RPC)

而我们上面一直演示的例子,使用的都是简单模式(Simple RPC),下面来介绍剩余的三种流模式。


服务端数据流:

这种模式是客户端发起一次请求,服务端返回一段连续的数据流。典型的例子是客户端向服务端发送一个股票代码,服务端就把该股票的实时数据源源不断地返回给客户端。


客户端数据流:

与服务端数据流模式相反,这次是客户端不断地向服务端发送数据,而在发送结束后,由服务端返回一个响应,典型的例子是物联网终端向服务器发送数据。比如大棚里面的温度传感器,显然要把里面的温度实时上报给服务器。


双向数据流:

顾名思义,这是客户端和服务端都可以向对方发送数据流,这个时候双方的数据可以同时互相发送,也就是实现实时交互,典型的例子是聊天机器人。

然后我们来编写 proto 文件,实现一下上面的几种流模式。

syntax = "proto3";

option go_package = ".;yoyoyo";

message StreamRequestData {
  string data = 1;
}

message StreamResponseData {
  string data = 1;
}

service StreamTest {
  // 服务端流模式,在返回值前面加上一个 stream
  rpc GetStream(StreamRequestData) returns (stream StreamResponseData){}
  // 客户端流模式,在参数前面加上一个 stream
  rpc PutStream(stream StreamRequestData) returns (StreamResponseData){}
  // 双向流模式
  rpc AllStream(stream StreamRequestData) returns (stream StreamResponseData){}
}

// 所以我们看到一个服务里面的方法可以有很多个,并且这里面的参数和返回值都是 StreamRequestData 和 StreamResponseData
// 但是不同的方法,我们也可以指定不同的参数

下面我们就来生成对应的 Go 源文件,然后编写对应的服务端,我们以 Go 来演示,Python 也是类似的。

package main

import (
    "fmt"
    "google.golang.org/grpc"
    "matsuri/yoyoyo"
    "net"
)

// 还是定义一个结构体,然后为结构体绑定方法
type Server struct {
}

// 但是这是流模式,绑定的方法里面的参数和返回值还和之间一样吗?我们看一下自动生成的文件吧
/*
type StreamTestServer interface {
	// 服务端流模式,在返回值前面加上一个 stream
	GetStream(*StreamRequestData, StreamTest_GetStreamServer) error
	// 客户端流模式,在参数前面加上一个 stream
	PutStream(StreamTest_PutStreamServer) error
	// 双向流模式
	AllStream(StreamTest_AllStreamServer) error
}
*/
// 我们后面在使用 RegisterStreamTestServer 进行注册的时候,第二个参数接收的实际上是一个接口 StreamTestServer
// 所以如果你想注册成服务的话,那么就必须实现上面三个方法。而且我们看到,在自动生成代码的是帮我们把注释也加上去了
func (s *Server) GetStream(request *yoyoyo.StreamRequestData, res yoyoyo.StreamTest_GetStreamServer) error {
    return nil
}

func (s *Server) PutStream(res yoyoyo.StreamTest_PutStreamServer) error {
    return nil
}

func (s *Server) AllStream(res yoyoyo.StreamTest_AllStreamServer) error {
    return nil
}

func main() {
    // 直接进行 grpc 服务端创建、注册等逻辑没有变化
    server := grpc.NewServer()
    yoyoyo.RegisterStreamTestServer(server, &Server{})
    listener, err := net.Listen("tcp", ":33333")
    if err != nil {
        fmt.Println(err)
        return
    }
    if err = server.Serve(listener); err != nil {
        fmt.Println(err)
        return
    }
}

整体逻辑是没有问题的,但是现在还不能执行,因为方法里面只返回了一个 nil。这里我们看到流模式对应函数的参数的和返回值,与之前的简单模式是不一样的。因为这是肯定的,流模式要求源源不断地返回,所以肯定不能通过 return 语句实现,因此流模式的返回值只有一个 error。了解了这些之后,我们再来编写里面的方法。

func (s *Server) GetStream(request *yoyoyo.StreamRequestData, res yoyoyo.StreamTest_GetStreamServer) error {
    // 那么服务端流模式要如何返回数据呢?答案是通过 res.Send 方法即可
    data := request.Data
    i := 1
    for i < 6{
        // 但 Send 的里面的内容还是 StreamResponseData,因为要被序列化嘛
        _ = res.Send(&yoyoyo.StreamResponseData{Data: fmt.Sprintf("%s%d", data, i)})
        i ++
        time.Sleep(time.Second)
    }
    return nil
}

func (s *Server) PutStream(res yoyoyo.StreamTest_PutStreamServer) error {
    return nil
}

func (s *Server) AllStream(res yoyoyo.StreamTest_AllStreamServer) error {
    return nil
}

这里我们先编写 GetStream,然后编写客户端去访问。

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "matsuri/yoyoyo"
)

func main() {
    conn, err := grpc.Dial("127.0.0.1:33333", grpc.WithInsecure())
    if err != nil {
        fmt.Println(err)
        return
    }
    defer func() { _ = conn.Close() }()
    client := yoyoyo.NewStreamTestClient(conn)
    // 注意:客户端调用依旧是之前的模式,因为它是基于 proto 文件来的
    response, _ := client.GetStream(
        context.Background(),
        &yoyoyo.StreamRequestData{Data: "神乐"},
    )
    // 然后我们可以进行测试了
    for {
        // 当服务端返回之后,那么 err 会得到一个 EOF
        data, err := response.Recv()
        if err != nil {
            fmt.Println(err)
            break
        }
        fmt.Println(data)
    }
    /*
    data:"神乐1"
    data:"神乐2"
    data:"神乐3"
    data:"神乐4"
    data:"神乐5"
    EOF
    */
}

打印的是一个结构体,我们可以调用里面的 Data 成员,当然这不是重点,重点是数据是实时返回的。


然后是 PutStream,客户端不断向服务端发送数据,这两个过程是相同、但又相反的。

func (s *Server) PutStream(res yoyoyo.StreamTest_PutStreamServer) error {
    for {
        data, err := res.Recv()
        if err != nil {
            break
        }
        fmt.Println(data)
    }
    return nil
}

服务端的代码如上,和之前的客户端没有什么区别。然后客户端调用,和之前的服务端也是类似,所以它们是相同、但又相反的。

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "matsuri/yoyoyo"
    "time"
)

func main() {
    conn, err := grpc.Dial("127.0.0.1:33333", grpc.WithInsecure())
    if err != nil {
        fmt.Println(err)
        return
    }
    defer func() { _ = conn.Close() }()
    client := yoyoyo.NewStreamTestClient(conn)
    // 只需要接收一个 context
    response, _ := client.PutStream(
        context.Background(),
    )
    for i := 0; i < 5; i++ {
        _ = response.Send(&yoyoyo.StreamRequestData{Data: fmt.Sprintf("%s%d", "椎名真白", i)})
        time.Sleep(time.Second)
    }
}

服务端会打印输出,至于打印内容就不贴了,最后再来看一下双向流模式。

双向流模式的话,既可以 Send 又可以 Recv,我们直接开启两个协程去处理即可,服务端和客户端都是如此。先来看看服务端:

func (s *Server) AllStream(res yoyoyo.StreamTest_AllStreamServer) error {
    var wg = new(sync.WaitGroup)
    wg.Add(2)
    go func() {
        for ;; {
            if data, err := res.Recv(); err != nil {
                fmt.Println(err)
                break
            } else {
                fmt.Println("收到客户端消息:", data.Data)
            }
        }
        wg.Done()
    }()

    go func() {
        i := 0
        for ;; i++ {
            if err := res.Send(&yoyoyo.StreamResponseData{Data: fmt.Sprintf("%s%d", "我是服务端", i)}); err != nil {
                fmt.Println(err)
                break
            }
            time.Sleep(time.Second)
        }
        wg.Done()
    }()
    wg.Wait()
    return nil
}

可以看到,逻辑的话就相当于将服务端流模式和客户端流模式组合起来了,客户端也是一样,逻辑高度一致。

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "matsuri/yoyoyo"
    "sync"
    "time"
)

func main() {
    conn, err := grpc.Dial("127.0.0.1:33333", grpc.WithInsecure())
    if err != nil {
        fmt.Println(err)
        return
    }
    defer func() { _ = conn.Close() }()
    client := yoyoyo.NewStreamTestClient(conn)
    // 只需要接收一个 context
    res, _ := client.AllStream(
        context.Background(),
    )
    // 逻辑和客户端类似
    var wg = new(sync.WaitGroup)
    wg.Add(2)
    go func() {
        for {
            if data, err := res.Recv(); err != nil {
                fmt.Println(err)
                break
            } else {
                fmt.Println("收到服务端消息:", data.Data)
            }
        }
        wg.Done()
    }()
    go func() {
        i := 0
        for ;;i++{
            // 这里需要注意,服务端 Send 的时候,里面的内容是 &StreamResponseData
            // 但客户端 Send 的内容是 &StreamRequestData
            if err := res.Send(&yoyoyo.StreamRequestData{Data: fmt.Sprintf("%s%d", "我是客户端", i)}); err != nil {
                fmt.Println(err)
                break
            }
            time.Sleep(time.Second)
        }
        wg.Done()
    }()
    wg.Wait()
}

执行服务端和客户端代码的话,会发现不断打印输出,当然我们这里没有做退出时候的处理,可以自己完善一下。

gRPC 进阶

protobuf 和 gRPC 还有很多内容,我们没有说,它们还不止这么简单,下面来了解更多的内容。

常用的 protobuf 数据类型

一个标量消息字段可以含有一个类型,然后在生成文件的时候会自动转换成与该语言对应的类型。那么 proto 文件中都可以定义哪些类型呢?它们和 Python、Go 之间的对应关系又是什么呢?

  • 定义string类型参数: string name = 1;
  • 定义bytes类型参数: bytes stream = 1;
  • 定义bool类型参数: bool flag = 1;
  • 定义int32类型参数: int32 age = 1;
  • 定义int64类型参数: int64 count = 1;
  • 定义float类型参数: float amount = 1;
  • 定义repeated类型参数: repeated string hobby = 1;
  • 定义map类型参数: map<string, string> data = 1;

另外,即使我们没有网 request 里面传递参数,那么服务端在通过 request 获取属性的时候也不会报错,会得到一个对应的类型的零值。这些类型后面会慢慢说。

Python 操作符合类型的一个坑

像 repeated、map 我们称之为符合类型,但是 Python 在操作它们的时候有一个坑,必须要注意一下。假设 request 里面有一个参数 id_lst,是一个 repeated  int32 类型。

# 按照之前的赋值方式是完全可以的,任何类型的参数都是如此
req1 = pb2.request(id_lst=[1, 2, 3])

# 但如果这么做是不行的
req2 = pb2.request()
req2.id_lst = [1, 2, 3]
# 这种方式只适用于标量,对于 repeated、map,甚至里面还可以是一个 request,对于它们不能采用这种方式
# 但是 id_lst 已经被解析为列表了,只不过它是一个空列表
req2.id_lst.extend([1, 2, 3])
# 这么做是可以的

所以这算是一个需要注意的点,也不能叫坑吧,总之注意一下即可,最好的办法还是直接调用的 request 的时候直接赋值。

option go_package 的作用

这个选项我们在 Python 中是不需要的,但是在 Go 里面是需要的(如果没有的话也不会报错,但是会抛警告,然后生成的文件的包名就是 proto 文件的名字)。当时我们是通过这个选项指定的包名,但其实这个选项的设置是比较灵活的,我们还可以自己指定生成文件的目录。

option go_package="common/stream/protobuf/v1"

如果改成上面这种方式的话,那么会把生成的文件放在执行命令的目录下的 common/stream/protobuf/v1 中,并且包名是 v1。如果目录不存在的话,会自动创建。并且这个选项对 Python 是无任何影响的,所以一个针对于 Go 的 proto 文件,拿给 Python 也是完全可以使用的。

proto 文件中引入其它的 proto 文件

比如我们要定义多个服务,这些服务都有一个相同的方法,比如:调用 Ping,返回字符串 Pong,那么我们可以把这些公共的写在一个单独的文件中,然后去导入它。

// 文件名 common.proto
syntax = "proto3";

// 我们调用 Ping 是不需要传递参数的,但是 proto 文件要求我们必须定义
// 所以我们定义一个空的消息体即可,就叫 Empty
message Empty {}

message Pong {
  string result = 1;
}

然后我们在其它的 proto 文件(hello.proto)导入它:

syntax = "proto3";
import "common.proto";
import "google/protobuf/empty.proto";  // 导入内置的 proto 文件
option go_package = ".;yoyoyo";  // 对 Python 无影响

message HelloRequest {
  string name = 1;
}

message HelloResponse {
  string message = 1;
}

service Hello {
  rpc SayHello (HelloRequest) returns (HelloResponse) {}
  rpc Ping(google.protobuf.Empty) returns (Pong);
}
// 注意到 google.protobuf.Empty 是我们从内置的 proto 文件中导入的,Pong 我们定义在 common.proto 文件中
// 但是我们将它们所在的文件都 import 进来了,所以可以直接使用

然后我们来生成对应的 Python 文件,注意两个 proto 文件都要生成,所以当前目录下会多出 4 个文件。

import grpc
import hello_pb2_grpc as pb2_grpc
import hello_pb2 as pb2


class Hello(pb2_grpc.HelloServicer):

    def SayHello(self, request, context):
        name = request.name
        return pb2.HelloResponse(result=f"你好: {name}")

    def Ping(self, request, context):
        return pb2.HelloResponse(result="Pong")


if __name__ == '__main__':
    from concurrent.futures import ThreadPoolExecutor
    grpc_server = grpc.server(ThreadPoolExecutor(max_workers=4))
    pb2_grpc.add_HelloServicer_to_server(Hello(), grpc_server)
    grpc_server.add_insecure_port("127.0.0.1:22222")
    grpc_server.start()
    grpc_server.wait_for_termination()

以上是服务端,不写注释了,相信现在很容易看懂了,然后是客户端:

import grpc
import hello_pb2_grpc as pb2_grpc
import hello_pb2 as pb2
from google.protobuf.empty_pb2 import Empty

channel = grpc.insecure_channel("127.0.0.1:22222")
client = pb2_grpc.HelloStub(channel=channel)

response = client.Ping(
    Empty()
)
print(response.result)  # Pong

response = client.SayHello(
    pb2.HelloRequest(name="神乐 mea")
)
print(response.result)  # 你好: 神乐 mea

对于 Go 语言也是同理,可以自己测试一下,两个 Go 文件要放在一个包里面。

以上就是 proto 文件的导入,但是说实话如果不是复用的特别多的话,还是不要用导入的方式,因为每一个 proto 文件都要生成对应的两个 py 文件。

message 的嵌套

我们说 message 后面的就是参数的载体,然后花括号里面的就是我们想要传递的参数。但是 message 也是可以嵌套的,下面来举个栗子。

syntax = "proto3";
option go_package = ".;yoyoyo";  // 对 Python 无影响

message HelloRequest {
  string name = 1;
}

message HelloResponse {
  string result1 = 1;
  // 这个声明我们放在外面也是可以的
  message Response {
    int32 age = 1;
    string hobby = 2;
  }
  // 然后声明
  Response result2 = 2;
}

service Hello {
  rpc SayHello(HelloRequest) returns (HelloResponse) {}
}

下面编写代码,服务端基本没有变化,只是将 Ping 这个方法给去掉了。

import grpc
import hello_pb2_grpc as pb2_grpc
import hello_pb2 as pb2


class Hello(pb2_grpc.HelloServicer):

    def SayHello(self, request, context):
        name = request.name
        # 里面的 result1 的设置很简单,但 result2 要怎么设置呢?
        # 我们看到 result2 这个返回的载体是谁呢?是 Response,而它在 HelloResponse 下面
        # 所以直接通过 HelloResponse.Response 调用即可
        return pb2.HelloResponse(result1=f"你好: {name}",
                                 result2=pb2.HelloResponse.Response(age=38, hobby="money,money,money"))


if __name__ == '__main__':
    from concurrent.futures import ThreadPoolExecutor
    grpc_server = grpc.server(ThreadPoolExecutor(max_workers=4))
    pb2_grpc.add_HelloServicer_to_server(Hello(), grpc_server)
    grpc_server.add_insecure_port("127.0.0.1:22222")
    grpc_server.start()
    grpc_server.wait_for_termination()

然后是客户端来进行调用:

import grpc
import hello_pb2_grpc as pb2_grpc
import hello_pb2 as pb2

channel = grpc.insecure_channel("127.0.0.1:22222")
client = pb2_grpc.HelloStub(channel=channel)

response = client.SayHello(
    pb2.HelloRequest(name="神乐 mea")
) 
# 返回一个 HelloResponse 对象,里面有两个属性 result1 和 result2
# result2 是一个 Response 对象,里面有 age 和 hobby 属性
print(response.result1)  # 你好: 神乐 mea
print(response.result2.age)  # 38
print(response.result2.hobby)  # money,money,money


然后我们来试一下 Go 语言,它和 Python 还不太一样,在 Python 中直接通过 response.result2.age 即可,但是在 Go 中不是这样的,它有自己的一套命名规则。我们来生成 Go 源文件,注意 proto 文件不需要做任何改动。

protoc --go_out=plugins=grpc:. -I . hello.proto

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "matsuri/yoyoyo"
    "net"
)

type Server struct {
}

func (s *Server) SayHello (ctx context.Context, request *yoyoyo.HelloRequest) (*yoyoyo.HelloResponse, error) {
    name := request.Name
    res := new(yoyoyo.HelloResponse)
    res.Result1 = fmt.Sprintf("你好呀, %s", name)
    // Result1 我们设置完了,但是 Result2 该怎么办?难道通过 res.Result2 = yoyoyo.HelloResponse.Response 吗?
    // Go 不是 Python,它没有类的概念。由于我们返回的是 HelloResponse,里面又嵌套了一个 Response,所以 Go 为它起了一个专门的名字 HelloResponse_Response
    // 相当于直接用下划线拼接起来了
    res.Result2 = new(yoyoyo.HelloResponse_Response)
    res.Result2.Age = 38
    res.Result2.Hobby = "money,money,money"
    return res, nil
}

func main() {
    // 直接进行 grpc 服务端创建、注册等逻辑没有变化
    server := grpc.NewServer()
    yoyoyo.RegisterHelloServer(server, &Server{})
    listener, err := net.Listen("tcp", ":33333")
    if err != nil {
        fmt.Println(err)
        return
    }
    if err = server.Serve(listener); err != nil {
        fmt.Println(err)
        return
    }
}

再来看看客户端,客户端的解析也是类似的。

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "matsuri/yoyoyo"
)

func main() {
    conn, err := grpc.Dial("127.0.0.1:33333", grpc.WithInsecure())
    if err != nil {
        fmt.Println(err)
        return
    }
    defer func() { _ = conn.Close() }()
    client := yoyoyo.NewHelloClient(conn)
    response, _ := client.SayHello(context.Background(), &yoyoyo.HelloRequest{Name: "神楽めあ"})
    // 获取 Result1
    fmt.Println(response.Result1)  // 你好呀, 神楽めあ
    // 获取 Result2,返回的是一个 Response,直接调用里面的属性即可
    fmt.Println(response.Result2.Age)  // 38
    fmt.Println(response.Result2.Hobby)  // money,money,money
    // 当你在使用 Goland 开发 Go 程序的时候,你会发现是提示功能真的舒服。不管变量是什么类型,可以调用哪些属性都会提示给你
    // 当然我们也可以 Ctrl + 左键 点击跳转,哪怕它藏得再深,你都可以准确定位到它的具体位置,这就是静态语言的优点
    // 每个变量都有明确的类型,通过 IDE 的提示你就知道自己的代码有没有问题
}

我们看到是完全没有问题的,当然我们之前的 Python 客户端也是可以连接的,只需要把端口改一下即可,通过 Go 客户端连接 Python 服务端也是如此。

protobuf 中的枚举类型

然后我们再来看看 protobuf 中的枚举类型,当然 repeated 我们也一块说了吧。

老规矩,还是先定义 proto 文件。

syntax = "proto3";
option go_package = ".;yoyoyo";  // 对 Python 无影响

message HelloRequest {
  // 里面没有任何成员
}

// 定义一个枚举,也可以放在 HelloResponse 里面实现
enum Gender {
  MALE = 0;
  FEMALE = 1;
}
// 之前的 Response,我们直接拿到外面
message Response {
  string hobby = 1;
}

message HelloResponse {
  int32 age = 1;
  // 此时的 gender 就是一个枚举类型的变量
  Gender gender = 2;
  // 这里定义一个 hobby,它是一个数组,数组里面都是 Response,Response 里面含有一个 hobby 成员
  repeated Response hobby = 3;
}

service Hello {
  rpc SayHello(HelloRequest) returns (HelloResponse) {}
}

然后编写服务端:

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "matsuri/yoyoyo"
    "net"
)

type Server struct {
}

func (s *Server) SayHello (ctx context.Context, request *yoyoyo.HelloRequest) (*yoyoyo.HelloResponse, error) {
    res := new(yoyoyo.HelloResponse)
    res.Age = 38
    // Gender 是一个 int32 类型的变量,至于为什么,后面会说
    res.Gender = 1
    // 然后这里的类型是 *yoyoyo.Response 的切片,但是注意:不是 HelloResponse_Response ,而是 Response
    // 因为我们在定义 Response 的时候将它写在了外面,不是嵌套在 HelloResponse 里面的,所以这导致了两个名字不一样,这一点要注意
    res.Hobby = []*yoyoyo.Response{{Hobby: "草莓牛奶"}, {Hobby: "纳豆"}, {Hobby: "money"}, {Hobby: "阿宅"}}
    return res, nil
}

func main() {
    // 直接进行 grpc 服务端创建、注册等逻辑没有变化
    server := grpc.NewServer()
    yoyoyo.RegisterHelloServer(server, &Server{})
    listener, err := net.Listen("tcp", ":33333")
    if err != nil {
        fmt.Println(err)
        return
    }
    if err = server.Serve(listener); err != nil {
        fmt.Println(err)
        return
    }
}

客户端进行调用:

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "matsuri/yoyoyo"
)

func main() {
    conn, err := grpc.Dial("127.0.0.1:33333", grpc.WithInsecure())
    if err != nil {
        fmt.Println(err)
        return
    }
    defer func() { _ = conn.Close() }()
    client := yoyoyo.NewHelloClient(conn)
    // 不需要参数,所以 HelloRequest 传递一个空的结构体
    response, _ := client.SayHello(context.Background(), &yoyoyo.HelloRequest{})
    fmt.Println(response.Age)  // 38
    fmt.Println(response.Gender)  // FEMALE
    fmt.Println(response.Hobby)  // [hobby:"草莓牛奶" hobby:"纳豆" hobby:"money" hobby:"阿宅"]
    for _, v := range response.Hobby {
        fmt.Print(v.Hobby, " ")  
    }  // 草莓牛奶 纳豆 money 阿宅
}

完全没有任何问题,结果和我们预期是一致的,但我们注意一下里面的 Gender 属性。我们在 proto 文件中是这么做的,MALE = 0,FEMALE = 1,然后当我们返回 1 的时候,客户端调用会得到字符串 "FEMALE"。至于为什么能实现这种结果,我们还是要到 protoc 生成的 Go 文件中看一下,也就是 hello.pb.go。

// 定义一个枚举,也可以放在 HelloResponse 里面实现
type Gender int32

const (
	Gender_MALE   Gender = 0
	Gender_FEMALE Gender = 1
)

// Enum value maps for Gender.
var (
	Gender_name = map[int32]string{
		0: "MALE",
		1: "FEMALE",
	}
	Gender_value = map[string]int32{
		"MALE":   0,
		"FEMALE": 1,
	}
)

以上包含注释的所有内容,都是 protoc 帮我们自动生成的,我们看到 Gender 是一个 int32 的别名,然后定义了两个变量 Gender_MALE 和 Gender_FEMALE,所以除了手动指定 0 或者 1,还可以直接使用 Gender_MALE 和 Gender_FEMALE。当然这不是重点,重点是下面它做了一个映射,所以才能返回字符串。

然后再来看看 Python 调用会怎么样,记得一定要先根据 proto 文件生成对应的 Python 文件,否则是没法调用的。

import grpc
import hello_pb2_grpc as pb2_grpc
import hello_pb2 as pb2

# 连接到 Go 的服务端,它监听 33333 端口
channel = grpc.insecure_channel("127.0.0.1:33333")
client = pb2_grpc.HelloStub(channel=channel)

response = client.SayHello(
    # 传递一个 Empty(), 也可以传递 HelloRequest 里面什么都不写
    pb2.HelloRequest()
)
print(response.age)  # 38
print(response.gender)  # 1
print(response.hobby)
"""
[hobby: "\350\215\211\350\216\223\347\211\233\345\245\266"
, hobby: "\347\272\263\350\261\206"
, hobby: "money"
, hobby: "\351\230\277\345\256\205"
]
"""
# 上面的值是使用八进制数表示的,比如:8 进制的 97 是 141,那么 "\141\141\141" 就等同于 "aaa"
# 同理 16 进制的 97 是 61,那么 "\x61\x61\x61" 也表示 "aaa"
print([item.hobby for item in response.hobby])  # ['草莓牛奶', '纳豆', 'money', '阿宅']

比较尴尬的是,Python 调用 gender 返回的是 1,所以我们还需要通过 proto 文件确定相关的映射关系。

所以 Python 和 Go 之间通过 gRPC 是很容易实现交互的,主要是两者必须要根据同一份 proto 文件生成对应的源文件才可以。里面的服务(service)、方法、参数和返回值的载体应该要大写,至于载体里面的成员是否大写就没有要求了。如果是小写,那么 Go 会自动变成大写,但是 Python 通过小写也能够进行调用。

protobuf 中的 map 类型

这个 map 类型用起来应该是非常开心的,这种数据结构非常的方便,那么还是来介绍一下该如何定义。

map 在定义的时候,是需要指定 key、value 的类型的,声明方式类似于 C++。

syntax = "proto3";
option go_package = ".;yoyoyo";  // 对 Python 无影响

message HelloRequest {
  // 里面没有任何成员
}

message Response {
  string hobby = 1;
}

message HelloResponse {
  // 我们只返回一个 info,信息啥的都在里面
  map<string, string> info = 1;
}

service Hello {
  rpc SayHello(HelloRequest) returns (HelloResponse) {}
}

然后生成源文件,编写服务端:

func (s *Server) SayHello (ctx context.Context, request *yoyoyo.HelloRequest) (*yoyoyo.HelloResponse, error) {
    res := new(yoyoyo.HelloResponse)
    res.Info = map[string]string{"name": "夏色祭", "age": "16", "hobby": "斯哈斯哈"}
    return res, nil
}

其它地方不变,只把方法里面的内容修改一下即可,然后是客户端:

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "matsuri/yoyoyo"
)

func main() {
    conn, err := grpc.Dial("127.0.0.1:33333", grpc.WithInsecure())
    if err != nil {
        fmt.Println(err)
        return
    }
    defer func() { _ = conn.Close() }()
    client := yoyoyo.NewHelloClient(conn)
    response, _ := client.SayHello(context.Background(), &yoyoyo.HelloRequest{})
    fmt.Println(response.Info)  // map[age:16 hobby:斯哈斯哈 name:夏色祭]
    fmt.Println(response.Info["name"])  // 夏色祭
}

最后还是用 Python 来调用一下,看看会返回什么结果,首先我们猜测肯定是返回一个字典:

import grpc
import hello_pb2_grpc as pb2_grpc
import hello_pb2 as pb2

channel = grpc.insecure_channel("127.0.0.1:33333")
client = pb2_grpc.HelloStub(channel=channel)

response = client.SayHello(
    pb2.HelloRequest()
)
print(response.info)  # {'name': '夏色祭', 'age': '16', 'hobby': '斯哈斯哈'}

和我们猜测是一样的,不过说实话,它不返回字典还能返回啥。

protobuf 中的 timestamp 类型

尽管当前已经有很多类型让我们使用,但是还少了一个重要的时间类型。当然时间类型我们可以通过 int64 来实现,也就是传递一个时间戳,然后拿到这个时间戳之后再转化即可。但是我们可以不可以通过 protobuf 直接定义这个类型呢?答案是可以的,只不过这个类型不是内置的,而是需要导入标准 proto 文件,在那里面有相关的实现。

syntax = "proto3";
import "google/protobuf/timestamp.proto";
option go_package = ".;yoyoyo";  // 对 Python 无影响

message HelloRequest {
}

message Response {
  string hobby = 1;
}

message HelloResponse {
  map<string, string> info = 1;
  google.protobuf.Timestamp t = 2;
}

service Hello {
  rpc SayHello(HelloRequest) returns (HelloResponse) {}
}

我们多加一个成员,表示当前时间。但是问题来了,这个 Timestamp 是内置的 proto 文件里面的,我们在使用的时候显然不能直接用。因为它不是在我们的编写的 proto 文件中定义的,所以生成 hello.pb.go 里面是没有相关类型的。所以需要从其它地方中导入,而导入的 proto 文件也已经告诉我们位置了,我们不是导入了 google/protobuf/timestamp.proto 吗?那就去里面看看呗。

syntax = "proto3";

package google.protobuf;

option csharp_namespace = "Google.Protobuf.WellKnownTypes";
option cc_enable_arenas = true;
option go_package = "github.com/golang/protobuf/ptypes/timestamp";
option java_package = "com.google.protobuf";
option java_outer_classname = "TimestampProto";
option java_multiple_files = true;
option objc_class_prefix = "GPB";

我们看到里面有很多的 package,但是我们只关注 go 的 package,我们看到了后面的一串路径,导入的时候就从这里面导入即可。

注意:如果你想查看这个 proto 文件的话,你需要使用 Pycharm 或者 Goland,并且里面的内容是放在 IDE 内部的 jar 里面的。

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "google.golang.org/protobuf/types/known/timestamppb"
    "matsuri/yoyoyo"
    "net"
    "time"
)

type Server struct {
}

func (s *Server) SayHello (ctx context.Context, request *yoyoyo.HelloRequest) (*yoyoyo.HelloResponse, error) {
    res := new(yoyoyo.HelloResponse)
    res.Info = map[string]string{"name": "夏色祭", "age": "16", "hobby": "斯哈斯哈"}
    // 但是我们不直接从 proto 文件中指定的路径去找,不是说找不到,只是说我们实例化不方便
    // 我们可以通过 timestamppb.New 来实例化,这样会方便很多
    res.T = timestamppb.New(time.Now())
    return res, nil
}

func main() {
    server := grpc.NewServer()
    yoyoyo.RegisterHelloServer(server, &Server{})
    listener, err := net.Listen("tcp", ":33333")
    if err != nil {
        fmt.Println(err)
        return
    }
    if err = server.Serve(listener); err != nil {
        fmt.Println(err)
        return
    }
}

然后客户端来调用,这里不使用 Go 的客户端,而是使用 Python 的客户端。如果 Python 能正常调用,那么 Go 一定能。

import grpc
import hello_pb2_grpc as pb2_grpc
import hello_pb2 as pb2

channel = grpc.insecure_channel("127.0.0.1:33333")
client = pb2_grpc.HelloStub(channel=channel)

response = client.SayHello(
    pb2.HelloRequest()
)
print(response.info)  # {'name': '夏色祭', 'age': '16', 'hobby': '斯哈斯哈'}
# 我们可以通过 seconds 来获取对应的时间戳,然后转换成时间,Go 也是同理,只不过需要首字母大写
print(response.t.seconds) 

以上我们就介绍了 protobuf 中的 timestamp 类型,说实话用的还是不多,还是指定 int64、然后手动生成时间戳更方便一些。

Python 编写 gRPC 的另一种姿势

我们之前是根据 proto 文件生成两个 py 文件,但其实可以不用生成,我们举个栗子:

import grpc
import sys
from pathlib import Path

sys.path.append(str(Path(sys.prefix) / "Lib/site-packages/grpc_tools/_proto"))

# 这里的 grpc.protos_and_services("hello.proto") 会返回两个模块对象
# 正好等价于生成的两个 py 文件:_pb2、_pb2_grpc。
# 但是注意:在使用之前要先将之前手动生成的两个 py 文件删掉,如果它们和你当前的客户端或服务端在同一个目录的话
pb2, pb2_grpc = grpc.protos_and_services("hello.proto")
"""
import hello_pb2_grpc as pb2_grpc
import hello_pb2 as pb2
"""
# grpc.protos_and_services("hello.proto") 返回的 pb2, pb2_grpc 和 hello.proto 生成的两个模块是等价的

channel = grpc.insecure_channel("127.0.0.1:33333")
client = pb2_grpc.HelloStub(channel=channel)

response = client.SayHello(
    pb2.HelloRequest()
)
print(response.info)  # {'name': '夏色祭', 'age': '16', 'hobby': '斯哈斯哈'}
print(response.t.seconds)  # 1532016000

我们看到这样就方便多了,根本不需要生成两个文件了,不仅是客户端、服务端也是如此。但是我们注意到,我们上面将 site-packages 目录下的 grpc_tools/_proto 加入到了 sys.path 中,这是因为内置的 proto 文件在这里面。如果是这种导入方式的话,那么必须要有这一步,否则是找不到这个文件的。

之前我们还觉得 Python 比较麻烦,别的语言只有一个文件,唯独它有两个,但是现在看来 Python 反而要更简单一些,因为一个文件都没有了。

grpc 搭配 asyncio 使用

我们在使用 Python 编写 gRPC 服务端的时候,使用的是线程池;而 Go 编写服务端,虽然我们没有手动启动 goroutine,但是内部在处理连接的时候是使用 goroutine 来处理的,因此 Go 的并发量会非常高。但有时我们也会使用 Python 编写 gRPC 服务,那么能不能想个办法提高一下它的并发量呢?显然如果想做到这一点,那么 Python 也使用协程就可以了,那么下面我们就来介绍 gRPC 如何搭配 asyncio 使用。

grpc 这个库官方提供了一个 aio 子模块,但是支持的还不是特别完美,并且文档也不全,所以这里推荐另一个库叫 grpclib。安装的话,直接 pip 安装即可。

然后编写我们的 proto 文件,和之前类似,但是简化了许多。

syntax = "proto3";
option go_package = ".;yoyoyo";  // 对 Python 无影响

message HelloRequest {
}

message HelloResponse {
  map<string, string> info = 1;
}

service Hello {
  rpc SayHello(HelloRequest) returns (HelloResponse) {}
}

然后我们来生成对应的 py 文件,注意:我们现在使用的是 grpclib,所以命令变了,需要将 grpc_python_out 换成 grpclib_python_out。

python -m grpc_tools.protoc --python_out=. --grpclib_python_out=. -I. hello.proto

对于 grpclib 的话,还是需要先生成 py 文件的,下面来看看如何使用 grpclib 来编写服务端:

import asyncio
from grpclib.server import Server
# 生成的文件叫 hello_pb2 和 hello_grpc(不是 hello_pb2_grpc)
import hello_grpc as pb2_grpc
import hello_pb2 as pb2


# 之前继承的类叫做 HelloServicer,现在变成了 HelloBase
class Hello(pb2_grpc.HelloBase):
    # 它是一个抽象基类,我们必须实现里面定义的方法
    async def SayHello(self, stream):
        # 这个返回的 request 就相当于之前说的 grpc 中的参数的载体
        # 这里我们没有参数,所以没啥卵用,只是获取一下
        request = await stream.recv_message()
        # 然后返回内容,不使用 return,使用 stream.send_message,里面接收 HelloResponse 对象
        await stream.send_message(pb2.HelloResponse(info={"name": "凑阿库娅", "age": "5"}))


async def main(host="127.0.0.1", port=22222):
    server = Server([Hello()])
    await server.start(host, port)
    await server.wait_closed()


if __name__ == '__main__':
    asyncio.run(main())

然后是客户端,我们先来看看 Go 的客户端可不可以调用,用之前的 proto 文件生成一下 Go 对应的源文件,然后编写客户端。

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "matsuri/yoyoyo"
)

func main() {
    conn, err := grpc.Dial("127.0.0.1:22222", grpc.WithInsecure())
    if err != nil {
        fmt.Println(err)
        return
    }
    defer func() { _ = conn.Close() }()
    client := yoyoyo.NewHelloClient(conn)
    response, _ := client.SayHello(context.Background(), &yoyoyo.HelloRequest{})
    fmt.Println(response.Info)  // map[age:5 name:凑阿库娅]
    fmt.Println(response.Info["name"])  // 凑阿库娅
}

我们看到是没有任何问题的,然后再来使用 Python 的客户端。Python 的服务端使用异步,但是对于客户端来说,其实使不使用异步是无所谓的。我们先来看看之前同步的 grpc 是否可以访问(其实肯定可以,Go 都可以更不用说 Python 了):

import grpc
import sys
from pathlib import Path

sys.path.append(str(Path(sys.prefix) / "Lib/site-packages/grpc_tools/_proto"))

pb2, pb2_grpc = grpc.protos_and_services("hello.proto")

channel = grpc.insecure_channel("127.0.0.1:22222")
client = pb2_grpc.HelloStub(channel=channel)

response = client.SayHello(
    pb2.HelloRequest()
)
print(response.info)  # {'name': '凑阿库娅', 'age': '5'}

因为是 grpc,所以我们没有使用 grpclib 对应的两个文件,总之调用是没有问题的。那么下面来看看如何使用 grpclib 的客户端:

import asyncio
from grpclib.client import Channel
import hello_pb2 as pb2
import hello_grpc as pb2_grpc  # 采用 grpc 的命名习惯

async def main():
    async with Channel("127.0.0.1", 22222) as channel:
        hello = pb2_grpc.HelloStub(channel)
        # 没有参数,传递一个空的过去即可
        res = await hello.SayHello(pb2.HelloRequest())
        return res.info
        

if __name__ == '__main__':
    print(asyncio.run(main()))  # {'name': '凑阿库娅', 'age': '5'}

我们看到也是可以的,当然一些注释就没写了,相信此时已经不需要再写了。

因此客户端可以使用 asyncio 发起连接,服务端可以使用 asyncio 维护大量连接,而且也不会影响彼此之间的调用。比如:Go 使用 grpc 编写的服务端,那么 Python 使用 grpc 和 grpclib 客户端都是可以使用的;Python 使用 grpclib 编写的服务端,Go 的 grpc 客户端、Python 的 grpc 客户端也是可以调用的;Python grpc 编写的服务端,Go 的 grpc 客户端、Python 的 grpclib 客户端同样是可以调用的。因此不用担心使用 asyncio 之后会出现调用问题,它们之间的互相调用是不受影响的。

如果你希望使用 Python 编写 rpc 服务,但是对并发量要求不高,那么可以使用 grpc 框架;如果你希望支持高并发,那么就是用 grpclib 来编写 rpc 服务端,使用 asyncio 来维护上万个连接是很轻松的。但如果是线程池,想要维护上万个线程是根本不可能的。

gRPC 的 metadata 机制

gRPC 可以让我们像本地调用一样实现远程调用,对于每一次的 RPC 调用,都会通过 metadata 来传递一些额外的信息。正如 HTTP 请求一样,我们在发送数据的时候,服务端接收到的难道只有数据吗?显然不是的,除了我们发送的数据之外还有请求头,而我们发送的数据叫做请求头,它们会组合起来形成 HTTP 报文发给服务端;服务端返回的时候也不仅仅只是数据,还有响应头。之所以要存在头部信息,是为了 client 和 server  能够为彼此提供一些信息,比如:cookie。而 RPC 调用也是如此,存在一个 metadata,它的作用和 http header 是类似的。而且生命周期也一样,http 中的 header 的生命周期是一次 HTTP 请求,而 metadata 的生命周期就是一次 RPC 调用。

通过 HTTP 的 header,我们应该很好理解 RPC 的 metadata,因为有些数据不一定非要放到 request 里面。

metadata 是以 key-value 的形式存储数据的,其中 key 是字符串,value 是字符串组成的数组(切片、列表)。

下面我们就来操作一波,还以之前的 hello.proto 为例,尽量把业务逻辑搞简单一点

syntax = "proto3";
option go_package = ".;yoyoyo";  // 对 Python 无影响

message HelloRequest {
}

message HelloResponse {
  map<string, string> info = 1;
}

service Hello {
  rpc SayHello(HelloRequest) returns (HelloResponse) {}
}

这次我们先来编写客户端:

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "google.golang.org/grpc/metadata"
    "matsuri/yoyoyo"
)

func main() {
    conn, err := grpc.Dial("127.0.0.1:33333", grpc.WithInsecure())
    if err != nil {
        fmt.Println(err)
        return
    }
    defer func() { _ = conn.Close() }()
    client := yoyoyo.NewHelloClient(conn)
    // 这里创建 metadata,接收一个 map[string]string 返回一个 map[string][]string
    // 这里就随便写了
    md := metadata.New(map[string]string{"Content-Type": "Json", "Cookie": "SessionID"})
    // 或者我们还可以这么创建,通过 metadata.Pairs,里面的参数个数必须是偶数个,会两两组合
    // md = metadata.Pairs("Content-Type", "Json", "Cookie", "SessionID")
    // 以上两个方式都会自动将 key 转成小写

    // 创建 Context 对象,看到这个 Context,你应该明白了我们后面在服务端要怎么获取了,就是通过之前那个一直没有使用的 context 参数
    ctx := metadata.NewOutgoingContext(context.Background(), md)
    // 这里把 ctx 传进去即可,之前用的是 context.Background()
    response, _ := client.SayHello(ctx, &yoyoyo.HelloRequest{})
    // 我们在服务端将 metadata 里面的内容设置在返回值中
    fmt.Println(response.Info["content-type"])  // application/grpc 来自服务端
    fmt.Println(response.Info["cookie"])  // SessionID 来自服务端
}

然后我们来编写服务端,获取 metadata 信息。

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "google.golang.org/grpc/metadata"
    "matsuri/yoyoyo"
    "net"
)

type Server struct {
}

func (s *Server) SayHello (ctx context.Context, request *yoyoyo.HelloRequest) (*yoyoyo.HelloResponse, error) {
    res := new(yoyoyo.HelloResponse)
    // 获取客户端传递的 context 对象会交给参数 ctx,我们从中解析出 md
    md, _ := metadata.FromIncomingContext(ctx)
    // 注意:返回值是一个 []string
    content_type, cookie := md["content-type"], md["cookie"]
    res.Info = map[string]string{"content-type": content_type[0] + " 来自服务端", "cookie": cookie[0] + " 来自服务端"}
    return res, nil
}

func main() {
    server := grpc.NewServer()
    yoyoyo.RegisterHelloServer(server, &Server{})
    listener, err := net.Listen("tcp", ":33333")
    if err != nil {
        fmt.Println(err)
        return
    }
    if err = server.Serve(listener); err != nil {
        fmt.Println(err)
        return
    }
}

以上我们便实现了 metadata 机制,注意:除了我们自己设置的之外,metadata 里面还有一些自带的信息,可以自己尝试打印一下看看都有啥。

Python 操作 metadata

Go 操作 metadata 我们见识过了,那么看看 Python 如何操作,这里我们先来编写服务端,还是用之前的 hello.proto 文件。

import grpc
# 因为我们把 proto 文件里面的 timestamp 给删掉了,只保留一个 info
# 所以不需要导入内置的 proto 文件了,因此这里也就不需要设置环境变量了
pb2, pb2_grpc = grpc.protos_and_services("hello.proto")

class Hello(pb2_grpc.HelloServicer):

    def SayHello(self, request, context):
        # 调用 context 的 invocation_metadata 可以拿到 metadata
        md = context.invocation_metadata()
        # 通过 context.set_trailing_metadata() 可以设置 metadata
        context.set_trailing_metadata(
            tuple((k, v + " from server") for k, v in md)
        )
        return pb2.HelloResponse(info={"name": "mea", "age": "38"})


if __name__ == '__main__':
    from concurrent.futures import ThreadPoolExecutor
    grpc_server = grpc.server(ThreadPoolExecutor(max_workers=4))
    pb2_grpc.add_HelloServicer_to_server(Hello(), grpc_server)
    grpc_server.add_insecure_port("127.0.0.1:22222")
    grpc_server.start()
    grpc_server.wait_for_termination()

然后是客户端:

import grpc

pb2, pb2_grpc = grpc.protos_and_services("hello.proto")
channel = grpc.insecure_channel("127.0.0.1:22222")
client = pb2_grpc.HelloStub(channel=channel)

# 调用 SayHello.with_call 来传递,Python 显然就更简单了
response, call = client.SayHello.with_call(
    pb2.HelloRequest(),
    metadata=(
        # 注意:里面的第一个元素相当于 key,一定要小写,否则报错
        # 另外不能有横杠,否则显示不出来
        ("content_type", "Json"),
        ("cookie", "SessionId"),
    )
)

# 返回值
print(response.info)  # {'name': 'mea', 'age': '38'}
# 通过 call 获取 metadata
for k, v in call.trailing_metadata():
    print(k, "----------", v)
"""
content_type ---------- Json from server
cookie ---------- SessionId from server
user-agent ---------- grpc-python/1.32.0 grpc-c/12.0.0 (windows; chttp2) from server
"""

我们看到它返回了一个 user-agent,通过它的 value 我们能看出服务端是可以区分到底是哪种语言的客户端调用的。

grpc 拦截器 - Go

对于做 web 开发而言,拦截器是非常常见的,基本上所有的 web 框架都会提供一个拦截器的功能。拦截器的功能很好理解,就是请求到来在进入处理逻辑之前会先被拦截,然后做一些预处理,比如判断是否登录、通过 User-Agent 判断是否是爬虫等等。

rpc 服务也是如此,尽管是客户端来调用,但我们仍然需要拦截器,因为我们不希望在业务层面上做大量重复的通用逻辑。而 gRPC 内置了拦截器的设置,我们只需要实现拦截器的逻辑,然后把拦截器配置到服务端或者客户端即可。下面我们来看看如何在 Go 的 gRPC 中实现拦截器,还是基于之前的 hello.proto 文件,尽量保证逻辑简单,先来编写服务端:

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "google.golang.org/grpc/metadata"
    "matsuri/yoyoyo"
    "net"
)

type Server struct {
}

func (s *Server) SayHello (ctx context.Context, request *yoyoyo.HelloRequest) (*yoyoyo.HelloResponse, error) {
    res := new(yoyoyo.HelloResponse)
    res.Info = map[string]string{"name": "神乐七奈"}
    return res, nil
}

// 下面是拦截器的逻辑,我们编写一个函数,然后传递到 grpc.UnaryInterceptor 中即可返回一个拦截器(ServerOption),然后创建 Server 的时候传进去(可以是任意个)
// 然后函数的类型如下:
// type UnaryServerInterceptor func(ctx context.Context, req interface{}, info *UnaryServerInfo, handler UnaryHandler) (resp interface{}, err error)
func MyInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
    // ctx 就相当于 SayHello 里面的 ctx、req 就相当于 SayHello 的 request(使用的时候需要先做一下类型断言)
    // 注意:并不是这里调用完毕之后再去调用服务端的方法,服务端的方法能否调用取决于拦截器里面的逻辑
    // 参数 handler 就表示对应的服务端的方法,我们必须执行 handler(ctx, req),等价于 SayHello(ctx, request)
    // 如果没有这一步的话,那么服务端的方法是不会被调用的,我们做一下判断吧
    md, _ := metadata.FromIncomingContext(ctx)
    // 用户必须通过 metadata 传递一个名为 "matsuri" 的 key、然后值为 "fubuki",否则就是验证失败
    if val, ok := md["matsuri"]; ok && val[0] == "fubuki" {
        // 执行服务端的方法
        return handler(ctx, req)
    } else {
        // 否则的话直接返回,仍然要返回一个 *yoyoyo.HelloResponse
        res := new(yoyoyo.HelloResponse)
        res.Info = map[string]string{"error": "缺少名为 \"matsuri\" 的 key,或者该 key 对应的 value 不正确"}
        return res, nil        
        // 或者你还可以将错误信息放在 error 中,grpc 下面有一个 status 用来返回错误、codes 用来设置状态码
        // status.Error(codes.Unauthenticated, "缺少名为 \"matsuri\" 的 key,或者该 key 对应的 value 不正确")
    }
}

func main() {
    // 传递一个函数,返回一个 ServerOption
    opt := grpc.UnaryInterceptor(MyInterceptor)
    // 放入到 NewServer 中
    server := grpc.NewServer(opt)
    yoyoyo.RegisterHelloServer(server, &Server{})
    listener, err := net.Listen("tcp", ":33333")
    if err != nil {
        fmt.Println(err)
        return
    }
    if err = server.Serve(listener); err != nil {
        fmt.Println(err)
        return
    }
}

然后我们来编写客户端:

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "google.golang.org/grpc/metadata"
    "matsuri/yoyoyo"
)

func main() {
    conn, err := grpc.Dial("127.0.0.1:33333", grpc.WithInsecure())
    if err != nil {
        fmt.Println(err)
        return
    }
    defer func() { _ = conn.Close() }()
    client := yoyoyo.NewHelloClient(conn)
    // 先不传递
    md := metadata.New(map[string]string{})
    ctx := metadata.NewOutgoingContext(context.Background(), md)
    response, _ := client.SayHello(ctx, &yoyoyo.HelloRequest{})
    fmt.Println(response.Info["error"])  // 缺少名为 "matsuri" 的 key,或者该 key 对应的 value 不正确

    // 传递
    md = metadata.New(map[string]string{"matsuri": "fubuki"})
    ctx = metadata.NewOutgoingContext(context.Background(), md)
    response, _ = client.SayHello(ctx, &yoyoyo.HelloRequest{})
    fmt.Println(response.Info)  // map[name:神乐七奈]
}

我们看到以上便是拦截器,在进入方法之前先进入拦截器,如果不符合要求,那么拦截器直接就给你返回了;如果参数符合要求,那么再执行 handler(ctx, req)、也就是对应的方法,还是很好理解的。但是客户端也是可以设置拦截器的,但是说实话客户端的拦截器没有太大意义,客户端的拦截器更像是 Python 的装饰器,举个栗子:

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "google.golang.org/grpc/metadata"
    "matsuri/yoyoyo"
    "time"
)

// 客户端的拦截器函数是如下类型
// type UnaryClientInterceptor func(ctx context.Context, method string, req, reply interface{}, cc *ClientConn, invoker UnaryInvoker, opts ...CallOption) error
func ClientInterceptor(ctx context.Context, method string, req, reply interface{}, cc *grpc.ClientConn,
                       invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
    start := time.Now()
    // 客户端对应的函数逻辑
    err := invoker(ctx, method, req, reply, cc, opts...)
    fmt.Printf("用时 %s\n", time.Since(start))
    return err
}

func main() {
    // 生成拦截器
    opt := grpc.WithUnaryInterceptor(ClientInterceptor)
    // 这里将拦截器传进去
    conn, err := grpc.Dial("127.0.0.1:33333", grpc.WithInsecure(), opt)
    if err != nil {
        fmt.Println(err)
        return
    }
    defer func() { _ = conn.Close() }()
    client := yoyoyo.NewHelloClient(conn)
    // 先不传递
    md := metadata.New(map[string]string{})
    ctx := metadata.NewOutgoingContext(context.Background(), md)
    response, _ := client.SayHello(ctx, &yoyoyo.HelloRequest{})
    fmt.Println(response.Info["error"]) 

    // 传递
    md = metadata.New(map[string]string{"matsuri": "fubuki"})
    ctx = metadata.NewOutgoingContext(context.Background(), md)
    response, _ = client.SayHello(ctx, &yoyoyo.HelloRequest{})
    fmt.Println(response.Info) 
    /*
    用时 1.9953ms
    缺少名为 "matsuri" 的 key,或者该 key 对应的 value 不正确
    用时 998µs
    map[name:神乐七奈]
     */
}

有一个开源项目叫 go-grpc-middleware,可以去看一下,比如认证、日志、监控等服务,可以直接和流行的组件对接。不过一般都针对于服务端,客户端的话更常用的是重试机制。但是对于开发微服务而言,更重要的还是搞懂背后的机制。

grpc 拦截器 - Python

看完了 Go 的拦截器,再来看看的 Python 的拦截器,Python 实现拦截器的话比 Go 还要简单一些。

import grpc

pb2, pb2_grpc = grpc.protos_and_services("hello.proto")

class Hello(pb2_grpc.HelloServicer):

    def SayHello(self, request, context):
        if "flag" not in globals():
            return pb2.HelloResponse(info={"error": '缺少名为 "matsuri" 的 key,或者该 key 对应的 value 不正确'})
        globals().pop("flag")
        return pb2.HelloResponse(info={"name": "神乐七奈"})


# 定义拦截器,我们需要继承 grpc.ServerInterceptor,这是一个抽象基类,我们必须实现里面的一个方法,否则是会报错的
class MyInterceptor(grpc.ServerInterceptor):

    def intercept_service(self, continuation, handler_call_details):
        """continuation 等价于 Go 里面的 handler
           handler_call_details 是一个 grpc._server._HandlerCallDetails(继承了 namedtuple)
            里面有两个属性: method 表示要调用的方法、invocation_metadata 就是客户端传递的 metadata
        """
        md = handler_call_details.invocation_metadata
        for k, v in md:
            if k == "matsuri" and v == "fubuki":
                globals()["flag"] = 1
        # 我们必须通过 continuation(handler_call_details) 来调用方法,直接返回一个 Response 是不允许的
        # 所以这一点不如 Go,因此我们只能通过设置一个全局变量的方式来解决问题
        return continuation(handler_call_details)

if __name__ == '__main__':
    from concurrent.futures import ThreadPoolExecutor
    # 实例化的时候将拦截器放到里面来
    grpc_server = grpc.server(ThreadPoolExecutor(max_workers=4), interceptors=(MyInterceptor(),))
    pb2_grpc.add_HelloServicer_to_server(Hello(), grpc_server)
    grpc_server.add_insecure_port("127.0.0.1:22222")
    grpc_server.start()
    grpc_server.wait_for_termination()

上面是服务端,再来看看客户端:

import grpc

pb2, pb2_grpc = grpc.protos_and_services("hello.proto")
channel = grpc.insecure_channel("127.0.0.1:22222")
client = pb2_grpc.HelloStub(channel=channel)

response = client.SayHello(
    pb2.HelloRequest()
)
print(response.info)  # {'error': '缺少名为 "matsuri" 的 key,或者该 key 对应的 value 不正确'}

response, _ = client.SayHello.with_call(
    pb2.HelloRequest(),
    metadata=(("matsuri", "fubuki"),)
)
print(response.info)  # {'name': '神乐七奈'}

因此这就是 Python 的验证器的逻辑,目前是通过设置全局变量的方式来曲线救国,也许会有更优雅的逻辑。

grpc 的错误处理机制

下面我们来介绍一下 grpc 的错误处理机制,你可以自己尝试一下,如果服务端在调用方法的时候发生了错误,那么控制台是不会有任何显示的。而客户端只会得到一个服务调用失败,告诉我们服务端出现了错误,显然这是不行的;以及 Go 的异常处理和 Python 还不一样,那么在两者进行交互的时候要如何处理异常,这些细节我们都要把握到。

首先 gRPC 给我们内置了一些状态码,这些状态码如下,它们都是一个整型:

  • ok,值为 0,代表成功调用
  • CANCELLED,值为 1,调用操作被取消,特别是调用方
  • UNKNOWN,值为 2,代表未知错误
  • INVALID_ARGUMENT,值为 3,代表参数不合法
  • DEADLINE_EXCEEDED,值为 4,代表操作完成前就已经超时了
  • NOT_FOUND,值为 5,方法不存在
  • ALREADY_EXISTS,值为 6,客户端尝试创建一个已经存在的方法
  • PERMISSION_DENIED,值为 7,客户端尝试调用一个没有权限调用的方法
  • UNAUTHEDTICATED,值为 16,客户端在调用某个方法时没有经过认证
  • RESOURCE_EXHAUSTED,值为 8,资源被耗尽
  • FAILED_PRECONDITION,值为 9,前置条件不满足,比如删除一个目录,但是目录非空
  • ABORTED,值为 10,操作被取消,特别是由于并发所导致的问题
  • OUT_OF_RANGE,值为 11,范围越界,比如读取一个文件结尾之后的部分
  • UNIMPLEMENTED,值为 12,服务端定义了、但是没有实现此方法
  • INTERNAL,值为 13,内部错误
  • UNAVAILABLE,值为 14,服务不可用
  • DATA_LOSS,值为 15,数据丢失

我们来演示一下,服务端如何返回异常,客户端如何捕捉异常,proto 文件和原来保持一致,但是为了更好的演示,我们增加一个参数:

syntax = "proto3";
option go_package = ".;yoyoyo";  // 对 Python 无影响

message HelloRequest {
  string name = 1;
}

message HelloResponse {
  map<string, string> info = 1;
}

service Hello {
  rpc SayHello(HelloRequest) returns (HelloResponse) {}
}

先来看看 Python 的服务端:

import grpc

pb2, pb2_grpc = grpc.protos_and_services("hello.proto")

class Hello(pb2_grpc.HelloServicer):
    def SayHello(self, request, context):
        name = request.name
        if name != "神乐七奈":
            # 调用 context.set_code 来设置状态码,只要设置的不是 grpc.StatusCode.OK,那么客户端在调用时一律会产生异常
            context.set_code(grpc.StatusCode.NOT_FOUND)
            # 设置异常信息
            context.set_details("不存在 SayHello 方法")
        return pb2.HelloResponse(info={"name": "神乐七奈"})


if __name__ == '__main__':
    from concurrent.futures import ThreadPoolExecutor
    grpc_server = grpc.server(ThreadPoolExecutor(max_workers=4))
    pb2_grpc.add_HelloServicer_to_server(Hello(), grpc_server)
    grpc_server.add_insecure_port("127.0.0.1:22222")
    grpc_server.start()
    grpc_server.wait_for_termination()

再来看看 Python 的客户端:

import grpc

pb2, pb2_grpc = grpc.protos_and_services("hello.proto")
channel = grpc.insecure_channel("127.0.0.1:22222")
client = pb2_grpc.HelloStub(channel=channel)

response= client.SayHello(pb2.HelloRequest(name="神乐七奈"))
print(response.info)  # {'name': '神乐七奈'}

try:
    response= client.SayHello(pb2.HelloRequest(name="神乐七奈~"))
except Exception as e:
    print(e)
"""
<_InactiveRpcError of RPC that terminated with:
	status = StatusCode.NOT_FOUND
	details = "不存在 SayHello 方法"
	debug_error_string = "{"created":"@1613751719.885000000","description":"Error ", ...}"
>
"""
# 我们看到客户端抛出了异常,那我们如何才能拿到里面的信息呢
try:
    response = client.SayHello(pb2.HelloRequest(name="神乐七奈~"))
except Exception as e:
    arg = e.args[0]
    print(arg.details)  # 不存在 SayHello 方法
    print(arg.code.name)  # NOT_FOUND
    print(arg.code.value)  # (5, 'not found')

# 虽然上面可以实现,但是我们应该使用其它的异常,grpc 为我们提供了一个 grpc.RpcError
try:
    response = client.SayHello(pb2.HelloRequest(name="神乐七奈~"))
except grpc.RpcError as e:
    print(e.details())  # 不存在 SayHello 方法
    print(e.code().name)  # NOT_FOUND
    print(e.code().value)  # (5, 'not found')

然后我们来看看如何在 Go 里面进行错误处理,首先用 proto 文件生成 Go 文件。

然后编写 Go 的服务端:

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "google.golang.org/grpc/codes"
    "google.golang.org/grpc/status"
    "matsuri/yoyoyo"
    "net"
)

type Server struct {
}

func (s *Server) SayHello (ctx context.Context, request *yoyoyo.HelloRequest) (*yoyoyo.HelloResponse, error) {
    name := request.Name
    if name != "神乐七奈" {
        // 我们之前说可以通过 status 设置异常、codes 设置状态码
        return nil, status.Error(codes.NotFound, "不存在此方法")
    }
    res := new(yoyoyo.HelloResponse)
    res.Info = map[string]string{"name": "神乐七奈"}
    return res, nil
}


func main() {
    server := grpc.NewServer()
    yoyoyo.RegisterHelloServer(server, &Server{})
    listener, err := net.Listen("tcp", ":33333")
    if err != nil {
        fmt.Println(err)
        return
    }
    if err = server.Serve(listener); err != nil {
        fmt.Println(err)
        return
    }
}

接下来先不编写 Go 客户端,用 Python 客户端测试一下试试:

import grpc

pb2, pb2_grpc = grpc.protos_and_services("hello.proto")
# 改成 Go 服务的端口
channel = grpc.insecure_channel("127.0.0.1:33333")
client = pb2_grpc.HelloStub(channel=channel)

response= client.SayHello(pb2.HelloRequest(name="神乐七奈"))
print(response.info)  # {'name': '神乐七奈'}

try:
    response = client.SayHello(pb2.HelloRequest(name="神乐七奈~"))
except grpc.RpcError as e:
    print(e.details())  # 不存在此方法
    print(e.code().name)  # NOT_FOUND
    print(e.code().value)  # (5, 'not found')

我们看到没有任何问题,然后编写 Go 客户端:

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "google.golang.org/grpc/status"
    "matsuri/yoyoyo"
)

func main() {
    conn, err := grpc.Dial("127.0.0.1:33333", grpc.WithInsecure())
    if err != nil {
        fmt.Println(err)
        return
    }
    defer func() { _ = conn.Close() }()
    client := yoyoyo.NewHelloClient(conn)

    response, err := client.SayHello(context.Background(), &yoyoyo.HelloRequest{})
    if err != nil {
        st, ok := status.FromError(err)
        if !ok {
            fmt.Println("错误解析失败")
            return
        }
        // st.Message() 返回打印信息,一个字符串
        // st.Code() 返回状态码,Code 类型、uint32 的别名
        fmt.Println("错误信息:", st.Message())
        fmt.Println("状态码:", st.Code())
    }
    /*
    错误信息: 不存在此方法
    状态码: NotFound
     */
    response, _ = client.SayHello(context.Background(), &yoyoyo.HelloRequest{Name: "神乐七奈"})
    fmt.Println(response.Info)  // map[name:神乐七奈]
}

Go 客户端访问也是没有问题的,当然你也可以试试 Go 调用 Python 服务端,也是没有任何问题的。尽管不同语言的错误处理机制不同,但是 gRPC 已经帮你规避掉了。

grpc 的超时机制

rpc 调用涉及网络传输,如果出现了网络故障、抖动等等,那么会造成长时间不返回的情况,这个时候我们可以设置一个超时时间。我们在 Python 的服务端中 sleep 10 秒,然后客户端去调用:

import grpc

pb2, pb2_grpc = grpc.protos_and_services("hello.proto")
channel = grpc.insecure_channel("127.0.0.1:22222")
client = pb2_grpc.HelloStub(channel=channel)


try:
    response = client.SayHello(pb2.HelloRequest(name="神乐七奈"), timeout=3)  # 3 秒钟超时
except grpc.RpcError as e:
    print(e.details())  # Deadline Exceeded
    print(e.code().name)  # DEADLINE_EXCEEDED
    print(e.code().value)  # (4, 'deadline exceeded')

我们看到报了一个 DEADLINE_EXCEEDED,这是 gRPC 内置的错误。然后看看 Go 如何设置超时:

package main

import (
    "context"
    "fmt"
    "google.golang.org/grpc"
    "google.golang.org/grpc/status"
    "matsuri/yoyoyo"
    "time"
)

func main() {
    // 这里调用 Python 的服务端,端口 22222
    conn, err := grpc.Dial("127.0.0.1:22222", grpc.WithInsecure())
    if err != nil {
        fmt.Println(err)
        return
    }
    defer func() { _ = conn.Close() }()
    client := yoyoyo.NewHelloClient(conn)

    // 使用 context.WithTimeout, 3 秒钟后取消
    ctx, cancel := context.WithTimeout(context.Background(), time.Second * 3)
    defer cancel()  // 这一步也可以不要,3s 后自动取消
    _, err = client.SayHello(ctx, &yoyoyo.HelloRequest{})
    if err != nil {
        st, ok := status.FromError(err)
        if !ok {
            fmt.Println("错误解析失败")
            return
        }
        // st.Message() 返回打印信息,一个字符串
        // st.Code() 返回状态码,Code 类型、uint32 的别名
        fmt.Println("错误信息:", st.Message())
        fmt.Println("状态码:", st.Code())
    }
    /*
    错误信息: context deadline exceeded
    状态码: DeadlineExceeded
    */
}

直接利用了标准库 context 方法。

小结

以上就是 Python 和 Go 编写 rpc 服务的一些知识点,总的来说 gRPC 是非常值得我们掌握的。

posted @ 2018-07-03 21:54  古明地盆  阅读(5391)  评论(0编辑  收藏  举报