RPC 的生命周期
现在我们理解了在 gRPC 中可以执行的基本 RPC 操作以及不同类型的 RPC,我们可以深入了解 RPC 的生命周期。在这一节中,我们将从整体概念出发,首先解释当客户端发送请求、服务器接收请求、发送响应并且客户端接收响应时发生的事情。然后,我们将进一步深入,讨论三个阶段:
-
连接阶段 – 客户端连接到服务器时发生了什么?
-
客户端侧 – 客户端发送消息时发生了什么?
-
服务器侧 – 服务器接收消息时发生了什么?
|
gRPC 有多个不同语言的实现。最初的实现是在 C++ 中,有些实现只是 C++ 代码的封装。然而,gRPC Go 是一个独立的实现。这意味着它是从零开始在 Go 中实现的,并且没有封装 C++ 代码。因此,在这一节中,我们将专门讨论 gRPC Go,其他实现可能会有所不同。 |
在深入细节之前,让我们从全局角度出发,定义一些概念。首先,我们需要明确的是,gRPC 是由用户代码中生成的代码驱动的。这意味着我们只与 gRPC API 的少数几个点进行交互,绝大多数情况下我们处理的是基于我们 Protocol Buffer 服务定义生成的代码。现在不用太担心这一点;我们将在最后一节详细讲解。
第二个重要的概念是 传输 的概念。传输可以看作是连接管理器,它负责在各个参与者之间发送/接收原始字节。它包含一个读写流,设计上可以无序地在网络上进行读写操作。对于我们而言,最重要的一点是,我们可以在 io.Reader 上调用读取(read),也可以在 io.Writer 上调用写入(write)。
最后,最后一个要澄清的事情是客户端和服务器非常相似。客户端上调用的所有函数也会在服务器上调用。它们只会被调用在不同的对象上(例如,ClientTransport 和 ServerTransport)。
现在,我们理解了这些概念后,我们可以看看 RPC 生命周期的可视化表示。
我们可以看到,我们只需定义一个通用的参与者,它将同时表示服务器和客户端。然后,我们可以看到生成的代码将通过调用一个名为 SendMsg 的函数直接与 gRPC 框架进行交互。
正如它的名称所示,SendMsg 函数用于通过网络发送数据。这个 SendMsg 函数将调用一个更低级的函数,名为 Write。这是传输层中由 io.Writer 提供的函数。一旦完成,另一个参与者将在 io.Reader 上进行读取,接着是 RcvMsg 函数,最后,用户代码将接收到数据。
接下来,我们将深入探讨 gRPC 通信中的关键部分。与所有通过网络传输的数据一样,客户端需要连接到服务器,因此我们将从连接的具体细节开始讲解。
连接
为了创建连接,客户端代码将调用一个名为 Dial 的函数,并传入目标 URI 和一些选项作为参数。当 Dial 请求被接收时,gRPC 框架会根据 RFC 3986 解析(Resolver)目标地址,并根据 URI 的方案创建一个 Resolver。因此,例如,如果我们使用 dns:// 方案(这是 gRPC 在 URI 中省略方案或提供未知方案时使用的默认方案),gRPC 将创建一个 dnsResolver 对象。
dnsResolver 之后,解析器将执行其任务,即解析主机名并返回一个可以连接的地址列表。基于这些地址,gRPC 会根据用户在 Dial 选项中传递的配置创建一个负载均衡器。框架默认提供两种负载均衡器:
-
Pick first(默认值),它连接到第一个能够连接的地址,并将所有 RPC 发送到该地址。
-
Round robin,它连接到所有地址,并依次、按顺序将每个 RPC 发送到每个后端。
正如我们所看到的,负载均衡器的目标是找出客户端应该在哪些地址上创建连接。因此,它会返回一个地址列表,gRPC 应该连接到这些地址,然后 gRPC 会创建一个通道(channel),这是 RPC 使用的连接抽象,以及子通道(subchannels),这些是负载均衡器可以用来将数据定向到一个或多个后端的连接抽象。
|
最终,用户代码将收到一个 ClientConn 对象,该对象用于关闭连接,但最重要的是,它将用于创建一个在生成的代码中定义的客户端对象,并且我们将能够在该对象上调用 RPC 端点。最后需要注意的是,默认情况下,整个过程是非阻塞的。这意味着,gRPC 不会等待连接建立完成后再返回 ClientConn 对象。
|
由于是非阻塞的,所以返回 |
客户端
现在我们已经建立了连接,可以开始考虑发送请求了。假设我们已经生成了代码,并且它有一个 Greet RPC 端点。对于当前的目的,具体它做什么并不重要;它只是一个 API 端点。
为了发送请求,用户代码将简单地调用 Greet 端点。这将触发 gRPC 框架中的一个名为 NewStream 的函数。这个函数的名称有点误导,因为这里的流(stream)并不一定代表一个流式 RPC。实际上,无论你是否正在进行流式 RPC,这个函数都会被调用,并且它会创建一个 ClientStream 对象。所以,在这里,Stream 大致相当于所有 RPC 的抽象。
在创建 ClientStream 的过程中,gRPC 框架将执行两个动作。第一个是调用负载均衡器以获取一个可以使用的子通道(subchannel)。这个动作是根据连接创建时选择的负载均衡策略来完成的。第二个动作是与传输(transport)交互。gRPC 框架将创建 ClientTransport,它包含用于发送和接收数据的读写流,并且它将发送头部信息到服务器以启动 RPC 调用。
一旦完成这些操作,gRPC 框架将简单地将 ClientStream 返回给生成的代码,生成的代码将通过另一个对象对其进行封装,提供给用户代码一个较小的函数集来调用(例如,Send、Recv 等)。
服务器端
自然地,在发送请求之后,我们期望从服务器收到响应。正如我们现在所知道的,客户端发送了一个头部来启动一个 RPC 调用。这个头部将由 ServerTransport 处理。此时,服务器知道客户端想要请求 Greet RPC 端点。
接下来,传输层将发送一个 transport.Stream 对象给 gRPC 框架。然后,这个流将被轻量地封装在一个 ServerStream 对象中,并传递给生成的代码。此时,生成的代码已经知道应该调用哪个用户代码函数。这是因为用户代码将函数注册到特定的 RPC 端点上。
到此为止,服务器会处理接收到的数据,并将响应返回到对应的传输通道(transport)以发送给客户端。ClientTransport 将读取该响应并将其返回给用户代码。