gRPC-go源码(1):连接管理
1 写在前面
在这个系列的文章中,我们将会从源码的层面学习和理解gRPC
。
整个系列的文章的计划大概是这样的:我们会先从客户端开始,沿着调用路径逐步分析到服务端,以模块为粒度进行学习,考虑这个模块是为了解决什么问题,然后思考gRPC
应该怎么去解决这个问题。在分析完这部分的架构设计后,我们会在接下来的一篇文章中研究具体的代码实现。
因此,这个系列的文章不会像之前的源码分析那样贴一大段的代码,然后加上注释。这样做不但使得阅读成本很高,而且很难学到除了代码实现以外的东西。
我们会先从客户端开始,沿着调用路径,逐步分析到服务端。
2 什么是RPC
在阅读gRPC的源码之前,我们先思考实现一个RPC框架,应该提供什么样的功能?
在我们上一篇文章的内容中,我们已经知道了gRPC
的使用方式。简单的来讲,就是对于同一个方法,在服务端实现具体的逻辑,在客户端发起调用,就能够实现“远程过程调用”。
那么,我们要怎么实现这个过程呢?
那么我们很容易可以推测,无论是客户端还是服务端,在我们调用的方法背后肯定还封装了一套复杂的逻辑,负责把客户端的调用“发送”到服务端中,而服务端中也封装了一套复杂的逻辑,负责接收客户端发送过来的请求,并根据接收到的数据选择对应的方法,执行完后把结果“返回”给客户端。
于是我们会接着推测,这部分复杂的逻辑有什么呢?
我们以客户度为例:首先,我们需要跟服务端建立连接。当我们调用某个远程方法的时候,我们需要令服务端得知客户端调用的是哪个方法、有哪些参数等,这意味着我们需要设计一种协议,这个协议承载了以上的信息。最后,把我们的数据塞进这个协议中,编码成二进制的格式,塞进网络中。
而对于服务端来说也是一样的,从网络IO
中接收到二进制的数据之后需要进行解码,然后根据解码后的数据得知需要调用的方法名、参数,在执行完相应的方法后将结果发送回客户端中。
这样就足够了吗?
还不够,我们还需要通过一种方式,将以上的逻辑封装起来,避免每次调用的时候都写这么一大堆的重复代码。也就是说,我们的开发人员不需要知道底层调用细节,他只需要定义方法和调用方法,剩下的都交给框架。
至此,我们就实现了一个最基本RPC框架。
但是你可能会有一个问题,如果RPC框架只是提供了一个通信的功能,那么他存在的意义是什么呢?
如果只是为了解决通信的问题,我们不需要费尽心思来开发这么一个新的框架,我们可以用RESTful API
,甚至你也可以直接把数据塞进TCP
报文中。
答案是这样的,虽然我们称RPC
为远程过程调用,但是RPC
框架不仅仅是能够实现服务间的通信,它还提供了一些服务治理、负载均衡、流量控制等方面的功能。
因此,当我们谈到了RPC框架
这个话题的时候,通常我们说是提供了以远程过程调用为核心的一整套解决方案。
3 如何实现gRPC
上一节中,我们聊了聊一个RPC框架应该提供哪些功能。在这一节中,我们来聊聊gRPC实现了哪些功能。
3.1 连接管理
为了让连接变得更可靠和高效,gRPC需要对连接进行管理。
考虑这样的一种情景,由于公司规模的扩大、流量的增加,gRPC
的服务端由单机扩展成了一个集群。这个时候,我们的客户端需要调用服务端中的某一个方法,那么这个客户端需要向哪台机器建立连接,发送数据呢?
如果我们把这个问题划分的更具体,那么可以需要解决的问题如下:
-
假设现在这个集群里面有很多台机器,那么我们该怎么告知客户端每台服务端机器的
ip:port
呢? -
假设我们新增或减少了一些
gRPC
的服务端,客户端该怎么更新它所维护的ip:port
列表呢? -
假设客户端当前请求的服务端,存在了多个
ip:port
,那么这个客户端该向哪个连接发送数据呢?
这几个问题可以归结为,gRPC如何解决服务注册、服务发现、负载均衡的问题。
然而,gRPC
并没有提供诸如Spring Cloud
、Dubbo
等框架的服务注册、服务发现的功能。
我想gRPC
这么做的原因大概是为了能够提供更灵活的服务发现和负载均衡功能。
3.2 Resolver
Resolver
称为解析器,能够将客户端传入的“符合某种规则的名称”解析为IP地址列表。
假设你定义了一种地址格式:aaa:///bbb-project/ccc-srv
然后Resolver
会将这个地址解析成好几个ip:port
,代表了提供ccc-srv
服务集群的所有机器地址。
这就是Resolver的作用。
那么,Resolver是怎么进行解析的呢?换句话说,Resolver是如何做到输入某种地址,输出一串IP地址呢?
这部分的工作需要由用户自己实现。
gRPC提供的是插件式的Resolver
功能,他会根据用户传入的aaa:///bbb-project/ccc-srv
,选择一个能够解析aaa
的Resolver
,并进行解析,得到ip:port
列表。
3.3 Balancer
Balancer称为负载均衡器,负责在Resolver解析出的一串地址中,选择其中的一个建立连接。
至于如何选择,也是由用户自己编写LB的逻辑。
也就是说,gRPC
实现了基础的逻辑,但是也提供了很强大的插件式编程的能力,将很多操作都留给开发人员自己去做选择。
不过,很大的灵活度对应的是很复杂的代码结构,直接去看源码可能会让人摸不着头脑。所以在这一篇的文章中我们先来介绍整体的一个设计逻辑,在下一篇文章中我们再来聊具体的细节。
3.4 Wrapper
我们在上面聊到,gRPC
的Resolver
和Balancer
都是支持自定义的。我们可以自己定义各种不同的Resolver
和Balancer
,来应对不同场景的需求。
这么做虽然增加了代码复杂度,但是却能够让gRPC变得更灵活,能够对各种复杂情景提供支持。
那么,要怎么才能够实现插件式的编程呢?
答案是使用装饰器模式。
装饰模式(Decorator)也叫包装器模式(Wrapper)。GOF在《设计模式》一书中给出的定义为:动态地给一个对象添加一些额外的职责。
装饰器模式是指动态地给一个对象添加一些额外的职责,就增加功能来说装饰模式比生成子类更为灵活。它通过创建一个包装对象,也就是装饰来包裹真实的对象。
这么说可能有点抽象,我们直接上图:
首先创建一个resolver
接口,并设计一些具体的resolver
实现类:
然后我们还需要一个resolver
的包装器,里面包含了真正的resolver
。
当我们的gRPC需要调用ResolverNow方法的时候,他只需要调用resolverWrapper
中的Resolve()
方法,在这个方法中来调用真正的resoleNow()
逻辑:
只要理解了这个设计模式,gRPC Client
端建立连接的代码,你就能看懂一大半了。
至此,gRPC
对连接的管理就结束了。
4 总结
最后我们再梳理一遍gRPC
是如何管理连接的。
在第一次建立连接时,gRPC
会调用服务端地址相对应的Resolver
,来解析出所有能够提供服务的服务端地址。随后,经过指定的Balancer,选择其中的一个地址,建立连接。
如果是已经建立过连接,在Resolver
中存在一个协程,监听了服务的状态,当存在新上线或下线的服务,会重新进行地址解析,来获取新的服务端地址集合,随后通过Balancer
来选择一个地址,建立连接。
在这篇文章的铺垫下,阅读具体的实现代码可能就会比较容易了。对于这部分的代码,我们会在下一篇文章中进行分析。
而对于连接的建立,逻辑也比较复杂,我们在后面的文章中继续分析。
写在最后
首先,谢谢你能看到这里!
这篇文章带有了比较强的主观判断,因为作者才疏学浅,对于gRPC
的设计思路可能存在了误会。如果你觉得有哪里是我说的不对的,还请不吝赐教,谢谢你!
在下一篇文章中,我也会尽可能的把代码也梳理的比较简单一些,敬请期待。
最后,如果有任何问题,都可以留言或者在公众号“红鸡菌”中找到我。
再次感谢你的阅读:)