微服务中的调用协议的发展

微服务现在大行其道,不同服务之间的调用方式也经过一系列的发展,这次我们就一起看看他们的发展过程,一起看不仅有利于统一理解,也有助于我们看清变化的原因。

我们就拿最简单的场景,客户端调用一个加法函数,将两个整数加起来,返回它们的和。如果放在本地调用,那是简单的不能再简单了,只要稍微学过一种编程语言,三下五除二就搞定了。

但是一旦变成了远程调用,门槛一下子就上去了。首先你要会 Socket 编程,至少先要把咱们这门网络协议课学一下,然后再看 N 本砖头厚的 Socket 程序设计的书,学会咱们学过的几种 Socket 程序设计的模型。这就使得本来大学毕业就能干的一项工作,变成了一件五年工作经验都不一定干好的工作,而且,搞定了 Socket 程序设计,才是万里长征的第一步。后面还有很多问题呢!

远程调用需要解决的五个问题

问题一:如何规定远程调用的语法?

客户端如何告诉服务端,我是一个加法,而另一个是乘法。我是用字符串“add”传给你,还是传给你一个整数,比如 1 表示加法,2 表示乘法?服务端该如何告诉客户端,我的这个加法,目前只能加整数,不能加小数,不能加字符串;而另一个加法“add1”,它能实现小数和整数的混合加法。那返回值是什么?正确的时候返回什么,错误的时候又返回什么?

问题二:如果传递参数?

我是先传两个整数,后传一个操作符“add”,还是先传操作符,再传两个整数?是不是像咱们数据结构里一样,如果都是 UDP,想要实现一个逆波兰表达式,放在一个报文里面还好,如果是 TCP,是一个流,在这个流里面,如何将两次调用进行分界?什么时候是头,什么时候是尾?把这次的参数和上次的参数混了起来,TCP 一端发送出去的数据,另外一端不一定能一下子全部读取出来。所以,怎么才算读完呢?

问题三:如何表示数据?

在这个简单的例子中,传递的就是一个固定长度的 int 值,这种情况还好,如果是变长的类型,是一个结构体,甚至是一个类,应该怎么办呢?如果是 int,不同的平台上长度也不同,该怎么办呢?

问题四:如何知道一个服务端都实现了哪些远程调用?

从哪个端口可以访问这个远程调用?假设服务端实现了多个远程调用,每个可能实现在不同的进程中,监听的端口也不一样,而且由于服务端都是自己实现的,不可能使用一个大家都公认的端口,而且有可能多个进程部署在一台机器上,大家需要抢占端口,为了防止冲突,往往使用随机端口,那客户端如何找到这些监听的端口呢?

问题五:发生了错误、重传、丢包、性能等问题怎么办?

本地调用没有这个问题,但是一旦到网络上,这些问题都需要处理,因为网络是不可靠的,虽然在同一个连接中,我们还可通过 TCP 协议保证丢包、重传的问题,但是如果服务器崩溃了又重启,当前连接断开了,TCP 就保证不了了,需要应用自己进行重新调用,重新传输会不会同样的操作做两遍,远程调用性能会不会受影响呢?

RPC协议

协议约定问题

一个大牛 Bruce Jay Nelson 写了一篇论文Implementing Remote Procedure Calls,定义了 RPC 的调用标准。后面所有 RPC 框架,都是按照这个标准模式来的。

当客户端的应用想发起一个远程调用时,它实际是通过本地调用本地调用方的 Stub。它负责将调用的接口、方法和参数,通过约定的协议规范进行编码,并通过本地的 RPCRuntime 进行传输,将调用网络包发送到服务器。

服务器端的 RPCRuntime 收到请求后,交给提供方 Stub 进行解码,然后调用服务端的方法,服务端执行方法,返回结果,提供方 Stub 将返回结果编码后,发送给客户端,客户端的 RPCRuntime 收到结果,发给调用方 Stub 解码得到结果,返回给客户端。

这里面分了三个层次,对于用户层和服务端,都像是本地调用一样,专注于业务逻辑的处理就可以了。对于 Stub 层,处理双方约定好的语法、语义、封装、解封装。对于 RPCRuntime,主要处理高性能的传输,以及网络的错误和异常。

在 RPC 的调用过程中,所有的数据类型都要封装成类似的格式。而且 RPC 的调用和结果返回,也有严格的格式。

  • XID 唯一标识一对请求和回复。请求为 0,回复为 1。

  • RPC 有版本号,两端要匹配 RPC 协议的版本号。如果不匹配,就会返回 Deny,原因就是 RPC_MISMATCH。

  • 程序有编号。如果服务端找不到这个程序,就会返回 PROG_UNAVAIL。

  • 程序有版本号。如果程序的版本号不匹配,就会返回 PROG_MISMATCH。

  • 一个程序可以有多个方法,方法也有编号,如果找不到方法,就会返回 PROC_UNAVAIL。

  • 调用需要认证鉴权,如果不通过,则 Deny。

  • 最后是参数列表,如果参数无法解析,则返回 GABAGE_ARGS。

在客户端,会调用 clnt_create 创建一个连接,然后调用 add_1,这是一个 Stub 函数,感觉是在调用本地一样。其实是这个函数发起了一个 RPC 调用,通过调用 clnt_call 来调用 ONC RPC 的类库,来真正发送请求。调用的过程非常复杂,一会儿我详细说这个。

当然服务端也有一个 Stub 程序,监听客户端的请求,当调用到达的时候,判断如果是 add,则调用真正的服务端逻辑,也即将两个数加起来。

服务端将结果返回服务端的 Stub,这个 Stub 程序发送结果给客户端,客户端的 Stub 程序正在等待结果,当结果到达客户端 Stub,就将结果返回给客户端的应用程序,从而完成整个调用过程。

有了这个 RPC 的框架,前面五个问题中的前三个“如何规定远程调用的语法?”“如何传递参数?”以及“如何表示数据?”基本解决了,这三个问题我们统称为协议约定问题

传输问题

但是错误、重传、丢包、性能等问题还没有解决,这些问题我们统称为传输问题。这个就不用 Stub 操心了,而是由 ONC RPC 的类库来实现。这是大牛们实现的,我们只要调用就可以了。

服务发现问题

传输问题解决了,我们还遗留一个问题,就是问题四“如何找到 RPC 服务端的那个随机端口”。这个问题我们称为服务发现问题。在 ONC RPC 中,服务发现是通过 portmapper 实现的。

portmapper 会启动在一个众所周知的端口上,RPC 程序由于是用户自己写的,会监听在一个随机端口上,但是 RPC 程序启动的时候,会向 portmapper 注册。客户端要访问 RPC 服务端这个程序的时候,首先查询 portmapper,获取 RPC 服务端程序的随机端口,然后向这个随机端口建立连接,开始 RPC 调用。从图中可以看出,mount 命令的 RPC 调用,就是这样实现的。