1

【前五篇】系列文章传送门:

  1. 网络协议 14 - 流媒体协议:要说爱你不容易
  2. 网络协议 15 - P2P 协议:小种子大学问
  3. 网络协议 16 - DNS 协议:网络世界的地址簿
  4. 网络协议 17 - HTTPDNS:私人定制的 DNS 服务
  5. 网络协议 18 - CDN:家门口的小卖铺

    这几年微服务很火,想必各位博友或多或少的都接触过。微服务概念中,
各服务间的相互调用是不可或缺的一环。你知道微服务之间是通过什么方式相互调用的吗?

    你可能说,这还不简单,用 socket 呗。服务之间分调用方和被调用方,我们就建立一个 TCP 或者 UDP 连接进行通信就好了。

    说着说着,你可能就会发现,这事儿没那么简单。

    我们就拿最简单的场景:

客户端调用一个加法函数,将两个整数加起来,返回它们的和。

    如果放在本地调用,那是简单的不能再简单,但是一旦变成了远程调用,门槛一下子就上去了。

    首先,你要会 socket 编程,至少要先了解咱们这个系列的所有协议 ,然后再看 N 本砖头厚的 socket 程序设计的书,学会咱们了解过的几种 socket 程序设计的模型。

    这就使得本来大学毕业就能干的一项工作,变成了一件五年工作经验都不一定干好的工作,而且,搞定了 socket 程序设计,才是万里长征的第一步,后面还有很多问题呢。

存在问题

问题一:如何规定远程调用的语法?
    客户端如何告诉服务端,我是一个加法,而另一个是减法。是用字符串 “add” 传给你,还是传给你一个整数,比如 1 表示加法,2 表示减法?

    服务端又该如果告诉客户端,我这个是加法,目前只能加整数,不能加小数和字符串。而另一个加法 “add1”,它能实现小数和整数的混合加法,那返回值是什么?正确的时候返回什么,错误的时候又返回什么?

问题二:如何传递参数?
    是先传两个整数,后传一个操作数 “add”,还是先传操作符,再传两个整数?

    另外,如果我们是用 UDP 传输,把参数放在一个报文里还好,但如果是 TCP,是一个流,在这个流里面如何区分前后两次调用?

问题三:如何表示数据?
    在我们的加法例子中,传递的就是一个固定长度的 int 值,这种情况还好,如果是变长的类型,是一个结构体,甚至是一个类,应该怎么办呢?即使是 int,在不同的平台上长度也不同,该怎么办呢?

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

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

解决问题

    看到这么多问题,是不是很头疼?还记得咱们了解 http 的时候,认识的协议三要素吗?

    本地调用函数里很多问题,比如词法分析、语法分析、语义分析等待,这些问题编译器基本上都帮我们解决了,但是在远程调用中,这些问题我们都要自己考虑。

协议约定问题

    很多公司对于这个问题,是弄一个核心通信组,里面都是 socket 编程的大牛,实现一个统一的库,让其他业务组的人来调用,业务的人不需要知道中间传输的细节。

    通信双方的语法、语义、格式、端口、错误处理等,都需要调用方和被调用方开会商量,双方达成一致。一旦有一方改变,要及时通知对方,否则就会出现问题。

    但是,不是每个公司都能通过这种大牛团队解决问题的,而是使用已经实现好的框架。

    有一个大牛(Bruce Jay Nelson)通过一篇论文,定义了 RPC 的调用标准。后面所有 RPC 框架都是按照这个标准模式来的。

整个过程如下:

  1. 客户端的应用想发起一个远程调用时,它实际上是通过本地调用方的 Stub。它负责将调用的接口、方法和参数,通过约定的协议规范进行编码,并通过本地 RPCRuntime 进行传输,将调用网络包发送到服务器;
  2. 服务端的 RPCRuntime 收到请求后,交给提供方 Stub 进行编码,然后调用服务端的方法,获取结果,并将结果编码后,发送给客户端;
  3. 客户端的 RPCRuntime 收到结果,发给调用方 Stub 解码得到结果,返回给客户端。

    上面过程中分了三个层次:客户端、Stub 层、服务端。

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

    最早的 RPC 的一种实现方式称为 Sun RPCONC RPC。Sun 公司是第一个提供商业化 RPC 库和 RPC 编译器的公司。这个 RPC 框架是在 NFS 协议中使用的。

    NFS(Network File System)就是网络文件系统。要使 NFS 成功运行,就要启动两个服务端,一个 mountd,用来挂载文件路径。另一个是 nfsd,用来读写文件。NFS 可以在本地 mount 一个远程的目录到本地目录,从而实现让本地用户在本地目录里面读写文件时,操作是是远程另一台机器上的文件。

    远程操作和远程调用的思路是一样的,就像本地操作一样,所以 NFS 协议就是基于 RPC 实现的。当然,无论是什么 RPC,底层都是 socket 编程。

    XDR(External Data Representation,外部数据表示法)是有一个标准的数据压缩格式,可以表示基本的数据类型,也可以表示结构体。

    这里有几种基本的数据类型。

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

  • XID 唯一标识请求和回复。请求是 0,回复是 1;
  • RPC 有版本号,两端要匹配 RPC 协议的版本号。如果不匹配,就会返回 Deny,原因是 RPC_MISMATCH;
  • 程序有编号。如果服务端找不到这个程序,就会返回 PROG_UNAVAIL;
  • 程序有版本号。如果程序的版本号不匹配,就会返回 PROG_MISMATCH;
  • 一个程序可以有多个方法,方法也有编号,如果找不到方法,就会返回 PROG_UNAVAIL;
  • 调用需要认证鉴权,如果不通过,返回 Deny;
  • 最后是参数列表,如果参数无法解析,返回 GABAGE_ARGS;

    为了可以成功调用 RPC,在客户端和服务端实现 RPC 的时候,首先要定义一个双方都认可的程序、版本、方法、参数等。

    对于上面的加法而言,双方约定为一个协议定义文件,同理,如果是 NFS、mount 和读写,也会有类似的定义。

    有了协议定义文件,ONC RPC 会提供一个工具,根据这个文件生成客户端和服务器端的 Stub 程序。

    最下层的是 XDR 文件,用于编码和解码参数。这个文件是客户端和服务端共享的,因为只有双方一致才能成功通信。

    在客户端,会调用 clnt_create 创建一个连接,然后调用 add_1,这是一个 Stub 函数,感觉是在调用本地函数一样。其实是这个函数发起了一个 RPC 调用,通过调用 clnt_call 来调用 ONC RPC 的类库,来真正发送请求。调用的过程较为复杂,后续再进行专门的说明。

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

    服务端将结果返回服务端的 Stub,Stub 程序发送结果给客户端 Stub,客户端 Stub 收到结果后就返回给客户端的应用程序,从而完成这个调用过。

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

传输问题

    前三个问题解决了,但是错误、重传、丢包以及性能问题还没有解决,这些问题我们统称为传输问题。这个 Stub 层就无能为力了,而是由 ONC RPC 的类库来实现。

    在这个类库中,为了解决传输问题,对于每一个客户端,都会创建一个传输管理层,而每一次 RPC 调用,都会是一个任务,在传输管理层,你可以看到熟悉的队列机制、拥塞窗口机制等。

    由于在网络传输的时候,经常需要等待,而同步的方式往往效率比较低,因而也就有 socket 的异步模型。

    为了能够异步处理,对于远程调用的处理,往往是通过状态机来实现的。只有当满足某个状态的时候,才进行下一步,如果不满足状态,不是在那里等待,而是将资源留出来,用来处理其他的 RPC 调用。

    如上图,从图也可以看出,这个状态转换图还是很复杂的。

    首先,进入起始状态,查看 RPC 的传输层队列中有没有空闲的位置,可以处理新的 RPC 任务,如果没有,说明太忙了,直接结束或重试。如果申请成功,就可以分配内存,获取服务端的端口号,然后连接服务器。

    连接的过程要有一段时间,因而要等待连接结果,如果连接失败,直接结束或重试。如果连接成功,则开始发送 RPC 请,然后等待获取 RPC 结果。同样的,这个过程也需要时间,如果发送出错,就重新发送,如果连接断开,要重新连接,如果超时,要重新传输。如果获取到结果,就可以解码,正常结束。

    这里处理了连接失败、重试、发送失败、超时、重试等场景,因而实现一个 RPC 框架,其实很有难度。

服务发现问题

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

    portmapper 会启动在一个众所周知的端口上,RPC 程序由于是用户自己写的,会监听在一个随机端口上,但是 RPC 程序启动的时候,会向 portmapper 注册。

    客户端要访问 RPC 服务端这个程序的时候,首先查询 portmapper,获取 RPC 服务端程序的随机端口,然后向这个随机端口建立连接,开始 RPC 调用。

从下图中可以看出,mount 命令的 RPC 调用就是这样实现的。

小结

  • 远程调用看起来用 socket 编程就可以了,其实是很复杂的,要解决协议约定问题、传输问题和服务发现问题;
  • ONC RPC 框架以及 NFS 的实现,给出了解决上述三大问题的示范性实现,也就是公用协议描述文件,并通过这个文件生成 Stub 程序。RPC 的传输一般需要一个状态机,需要另外一个进程专门做服务发现。

参考:

  1. 刘超-趣谈网络协议系列课;
  2. 如何给老婆解释什么是RPC;

北国风光
1.7k 声望279 粉丝

做一个靠谱的人,凡事有交代,件件有着落,事事有回音!