Python-gRPC实践(1)--gRPC简介

本文总阅读量

前言

接触gRPC比较早, 但我不怎么喜欢在Python中使用gRPC, 因为Python中的官方gRPC框架易用性太烂了, 只提供基本功能, 附带的其他功能要不就不完善, 要不文档就只有简单几句话, 啥东西都得去原本的项目找, 用起来都不顺心(毕竟不是Goole的亲儿子,支持的力度肯定比较少)。

而且在Python项目中引入gRPC框架后, 项目就很难去维护了, 所以我基本不用, 为此我自己还开发了一款用于asyncioRPC框架–rap, 如果你恰好是基于asyncio生态构建项目, 且需要用到gRPC框架的,那么推荐接触下非官方的gRPC项目–python-betterproto

吐槽归吐糟, 但是还是得用, 因为其他语言的项目都用了gRPC, 不同团队服务的通信需要依赖gRPC进行通信, 所以这块硬骨头还是得啃下去。 在啃的过程非常艰辛, 因为官方文档太少了, 相关文章也不多, 中文社区零零散散, 英文社区的文章会比较多, 但很多搜索出来的都是Go-gRPC相关的, 这就很扎心了, 而这个系列文章就是我啃完的一个总结。

好了, 唠嗑完毕, 以下是文章的正式内容。

1.什么是RPC

在了解gRPC之前, 先了解什么叫RPC, RPC是Remote Procedure Call的简称, 中文称为远程过程调用, 它允许不同的进程或者不同的机器的程序互相调用。 其实按照这个定义,平时使用的HTTP(Restful API)请求也算RPC, 因为主流的HTTP(Restful API)在不同的服务之间兼容性虽是最棒的, 也有成熟的生态, 所以很多公司的内部服务还是以HTTP来互相调用, 但是由于传输体积很大, 这种方式的请求速度并不是很快, 传输性能不佳。

不过现在很多公司的内部服务间的调用越来越多, 调用链变长, 如果还用HTTP(Restful API)的方式做内部服务的调用, 那整个调用链的时间就变长, 同时增加了系统开销, 需要一些别的方案来解决这些问题;同时由于这些服务通常都是在内网, 这些服务只要内部协议兼容就行, 不用过多的去考虑外部因素, 追求的是更小的传输体积, 更快的传输速度(所以内网间用UDP也是可以的), 同时对系统的性能消耗较低, 所以就有了各种RPC协议诞生。

目前市面上各种RPC协议虽然互不兼容, 但是他们基本上只有在传输协议和序列化协议有较大的不同, 传输协议和序列化这两点恰好就是与传输体积, 传输速度和系统性能消耗的问题有关。
其中传输协议是为了解决传输体积和传输速度的问题, 一般使用的是TCP, UDP传输协议或者是直接基于HTTP自定义的应用层协议, 而序列化协议主要解决的是通用性流行性成熟性, 空间占用时间占用, 一般RPC定制的序列化, 都追求较少的空间占用(减少网络传输压力)和时间占用(减少机器的序列化时间), 其次再满足其它3个特性, 兼容其它语言等。

由于本文的重点是在于gRPC, 关于RPC以及RPC传输协议和序列化协议的说明可以通过我的另一篇文章RPC框架编写实践–最小RPC框架的依赖进行了解

2.gRPC

gRPC是Google开源的高性能、通用的RPC框架, 前面的g在不同的版本都有对应的意思(戳这了解), 但是我觉得就是Google的意思, 毕竟在它的亲儿子Go使用gRPC太方便了, 基本上是开箱即用。
gRPC的特点是:

  • gRPC使用HTTP/2协议,HTTP/2解决并优化了HTTP1.1的一些缺陷, 带来了更多强大功能,如多路复用、二进制帧、头部压缩、推送机制。这些功能给设备带来重大益处,如节省带宽、降低TCP连接次数、节省CPU使用等。而且目前很多框架都提供对HTTP的支持(如Nginx), 所以适用范围广;
  • 默认使用谷歌开源的Protocol Buffer(类似于XML、JSON的数据序列化结构协议),传输速率、解析速度都很快、压缩率高,性能整体都比XML和JSON好, 同时支持类型声明,可以生成良好的文档和示例。
  • 语言中立(功能提供上确实算中立…),支持各种流行语言(C++、C#、Java、Go、Python等)都能用,轻松实现跨语言通信;本身不限于任何平台。
  • 基于 IDL 文件定义服务,通过 proto3 工具生成指定语言的数据结构、服务端接口以及客户端 Stub;
  • 除了HTTP(Restful API)的一请求一响应外, 还支持单向,双向的流API。

不过由于这些特点, 造成了gRPC的通用性和自解析性差, 所以比较适合与移动端和内部应用以及一些偏内部合作的场景, 而不太适用了网页端这种追求通用的场景(因为通用的场景一般没办法快速的升级到新的协议)。

3.gRPC的请求过程

gRPC的传输协议是基于HTTP协议, 前面说到HTTP协议的性能不咋样, 那为什么gRPC还采用它呢, 这是因为gRPC采用的是HTTP/2, 它相对于我们常用的HTTP/1.1有了很大的提升和改进。

我们常用HTTP的请求形式是one-by-one, 这种请求只会请求一次响应一次, 如果没有进行优化, 每次请求都会经历创建TCP, 进行请求,读取响应, 销毁TCP四个阶段, 而TCP的创建和销毁是非常浪费性能并且会增加很多调用时间开支, 同时对于服务端来说会占用更多的文件描述符, 这样也会影响到服务器的性能的。 所以到了HTTP/1.1时就做了一点改进, 首先是支持了连接复用, 也就是持久化链接, 这种方式可以让客户端对同一域名持有一个或多个不会用完即段的TCP连接, 减少频繁创建和销毁连接带来了开销, 以及过多TCP链接带来的系统资源占用。
但是在这种模式下收发请求的形式上类似于一个FIFO队列, 只有前面请求处理完了, 后续的请求才会发送出去, 不然就只能一直等着, 如果前面的请求一直卡住, 后面的请求就无法发送出去, 造成队首阻塞的问题。要解决这样的问题很简单, 但需要更改协议。由于HTTP本来就是基于TCP的, 所以它也能像流一样进行传输, HTTP/2针对这一点做了一些改动, 使其支持流传输, 此外HTTP/2规定了帧是HTTP/2中最小的信息单位, 这个帧可以用来描述各种数据, 比如请求的Header、Body或者是用来做控制标识, 如打开关闭连接等。同时每个帧都附带了一个流ID来标识这个帧是属于哪个流, 这样就能解决HTTP/2提到的队首阻塞问题, 客户端可以根据标识ID将不同的响应与请求一一匹配, 从而解决了无法识别同一连接在同一时刻收到两个请求的问题, 提升请求效率, 这个涉及到HTTP/2的最重要的技术特征–多路复用。

这里只做必要性的说明, 具体说明可以通过我的另一篇文章RPC框架编写实践–最小RPC框架的依赖进行了解。

简单的文字说明可能有点难以理解, 可以直接通过捉包来分析HTTP/1.1和HTTP/2之间的不同:

3.1.HTTP调用捉包分析

首先是HTTP/1, 我先基于Flask启动一个Web服务, Web服务代码如下:

1
2
3
4
5
6
7
8
9
10
11
from flask import Flask

app = Flask(__name__)


@app.route("/")
def demo() -> str:
return "ok"


app.run(port=8000)

该服务监听端口8000, 当收到”/“的访问时,会返回”ok”的文本到客户端, 然后启动捉包工具并在命令行执行如下命令:

1
2
3
4
➜  ~ curl 127.0.0.1:8000/
ok%
➜ ~ curl 127.0.0.1:8000/
ok%

通过命令可以发现请求了两次Web服务的”/“链接, 并都得到响应体:”OK”, 现在我们来看看捉包结果:

从图中可以看到有四条绿色的且Protocol字段为HTTP的数据, 这代表两次请求中各自的一发一收, 同时可以看到INFO字段中的[SYN],[FIN]成对出现两次, 第一次的发送端口是33480, 第二次的发送端口是33450, 由于四元组的不同, 所以他们并不是同一个TCP请求。同时通过数据行数可以发现, 为了实现这两个请求(共4个HTTP数据),TCP在背后做了大量的准备和善后工作。

接着我们来看看其中的一条请求包数据详情(也就是上图中No.158的数据):

图中Hypertext Transfer Protocol就是本次的请求数据(上面那些是更底层的TCP数据, 与本文无关), 通过数据可以看到该请求是我通过了GET方法调用了url:/, 同时使用的是HTTP/1.1协议, 此外还有UA, Accept等Header数据。

接下来再看看No.158对应的响应数据No.164数据包详情:

同样只看Hypertext Transfer Protocol的数据, 可以发现里面存放着服务器返回的各种Header数据, 并在Line-based text data中则显示我们的响应体”ok”。

现在简单的HTTP捉包分析先告一段落, 转而去看看gRPC的捉包有何不同。

3.2.gRPC调用捉包分析

gRPC的测试接口是用到了后面示例的gRPC项目中的user.delete_user接口, 其中客户端调用的参数是uid=999, 但由于找不到用户, 服务端会在metadata写入Python的异常数据, 然后返回的是标准的gRPC异常。

具体请求方法和内容后面再详细阐述,proto文件可以参考user.proto

本次的gRPC的测试执行顺序是先启动服务端, 然后启动服务端并进行两次请求,最后先关闭客户端再关闭服务端, 具体的捉包数据如图:

通过整个捉包数据可以简单的发现, Protocol字段里面有TCP, HTTP2, gRPC三种,其中里面有2行Protocol字段为GRPC的数据, 这两行数据的INFO中带有POST /user.User/delete_user(在gRPC的规定中, URL按/${包名}.${服务名}/${接口名}格式命名), 可以认为这两行就是两次调用, 但是整个数据包里面只出现过一次[SYN]和一次[FIN], 代表这两次请求只创建过一个TCP链接, 也只使用了一个TCP链接。

接下来看一下gRPC的第一个请求传输流程的相关流:

  • 1:No.494客户端发送用户的请求到服务端
  • 2:No.496客户端发送一个HTTP2的PING给服务端
  • 3:No.498服务端返回一个HTTP2的PING给客户端
  • 4:No.512服务端返回HTTP2的Header给客户端
  • 5:No.514服务端返回HTTP2的Body给客户端

从相关流中可以发现, 为了维持健壮性, HTTP2会有一些其它的机制, 如No.496和No.498的PING, 而且服务端返回数据时也可以拆成Header和Body两个数据流返回, 需要注意的是HTTP/2没有要求一定要先返回Header再返回Body, 它可以先返回一个Header, 再返回多个Body, 最后再返回Header, 就像gRPC的流响应中, 会先返回Header,告知HTTP状态, 然后返回Body传输信息, 最后再返回Header告知gRPC状态。

接下来查看跟请求相关的No.494, No.512以及No.514三个包, 首先是No.494:

可以看到HTTP2的包比较复杂, 现在看到的都是我指定Wireshark解码为HTTP2后展示出来的, 其中里面的Stream ID:1是指明这个帧的归属ID, 然后里面展示出来的Header都是本次请求的信息, 如scheme,method, path等以及我自定义的Header:customer-user-agent, request_id等其它Header, 可以看到这些都与我们常用的HTTP/1.1很像, 同时他们占用的字节数很多,达到了281。 而再往下看到的GRPC MessageProtocol Buffers分别占用了5字节和5字节, 其中GRPC Message的长度5字节是固定的, 因为gRPC要求在Protobuf字节流前面加一个五字节的前缀,第一个字节表示字节流是否被压缩,后四个字节叫作Length-Prefixed Message, 它的作用是存储数据长度。如果熟悉HTTP协议,会知道HTTP可以使用Content-Encoding表示压缩算法,使用Content-Length指定数据长度, 那么gRPC为啥还要另起炉灶重新定义是否压缩和长度呢, 这是因为gRPC除了支持常见的one-by-one请求类型外, 还支持如下几种类型:

  • 请求流:客户端可以不断发送新的请求消息。 该类型的典型使用场景是客户端发推送或者上报埋点。
  • 响应流:建立连接后,服务端一直返回消息。 该类型的典型使用场景是服务端的订阅推送。
  • 双向流:类似于HTTP中的WebSocket, 双端可以同时收发消息。 该类型的典型使用场景是类似于聊天室之类的信息交互。

而流传输都是同享同一个HTTP Header信息的, 然后就会出现这样一个场景: 先传了一条Header信息, 接着再传两条gRPC的内容信息, 如果按照HTTP中Content-Length的长度获取内容数据的话, 就会出现获取内容跟实际发送的内容不一致, 所以只能给每个信息单独加上一个字节的前缀来表示压缩和长度信息了。

了解完了GRPC Message的规定后, 再次回到请求体的分析, 由于我没有配置proto文件, 解码后的Protocol Buffers并没有展示出是哪个Message, 但依然能看到Field的值为1, value的长度为3, 值为999(解码错了, 393939实际上是999), 结合我的proto文件:

1
2
3
4
// delete user
message DeleteUserRequest {
string uid = 1;
}

可以看出gRPC只传输了值和值对应的编号, 然后对应端收到数据后会根据编号进行序列化, 转化为各语言对应的值。

最后就是我们的响应数据分析了, 我示例的响应是一个比较特殊的响应, 由于服务端找不到用户, 会直接抛出Python的异常, 然后我在里面实现了一个拦截器, 这个拦截器发现函数异常了, 就把异常数据放在gRPCmetadate里面通过Header返回, 然后返回一个gRPC的标准异常响应Header, 所以由于没有返回内容, 该响应并不会返回Body数据。
通过上面的流程分析可以知道, 本次响应会分开两次返回, 第一个返回的数据是gRPCmetadata数据, 也就是HTTP2的Header数据, 里面有我定义的字段exc_nameexc_info以及其它常见的HTTP header:

然后由于本次并没有Prococol数据, 所以在本次捉包数据没有发现, 只有另一条Header数据, Header里面包含grpc-status以及grpc-message字段:

至此gRPC响应的数据分析已经结束了, 可以发现gRPC的包比HTTP/1.1的包复杂一些, 功能也更多, 同时都能复用同一条连接, 接下来我们再来简单的看看gRPC的简要请求流程。

3.总结

正因为这些, gRPC在选用HTTP协议时, 才不会担忧性能的问题, 同时又能使用一些现成的HTTP生态如Nginx, 也能方便的通过一个端口来支持HTTP/1.1请求和HTTP/2请求(Golang有自带实现, Python就没 T_T)。此外, 在看完请求过程后, 可以看到gRPC传输协议只是一个基于HTTP/2拓展传输的协议, 它只解决了RPC中的传输问题, 剩下的序列化问题还需要Protocol Buffer来解决, 而且份量也很大, 将在下一节介绍它。

查看评论