RPC与gRPC框架


RPC的语义是远程过程调用,在一般的印象中,就是将一个服务调用封装在一个本地方法中,让调用者像使用本地方法一样调用服务,对其屏蔽实现细节。而具体的实现是通过调用方和服务方的一套约定,基于TCP长连接进行数据交互达成。上面的解释似云里雾里,仅仅了解到这种程度是远远不够的,还需要更进一步,以相对底层抽象的视角来理解RPC。

三个特点

广义上来讲,所有本应用程序外的调用都可以归类为 RPC,不管是分布式服务,第三方服务的 HTTP 接口,还是读写 Redis 的一次请求。从抽象的角度来讲,它们都一样是 RPC,由于不在本地执行,都有三个特点:

  • 需要事先约定调用的语义 (接口语法)
  • 需要网络传输
  • 需要约定网络传输中的内容格式

以一次Redis调用为例,执行redis.set("rpc", 1)这个调用,其中:

  • set及其参数("rpc", 1),就是对调用语义的约定,由redis的API给出
  • RedisServer会监听一个服务端口,通过TCP传输内容,用异步事件驱动实现高并发
  • 底层库会约定数据如何进行编解码,如何标识命令和参数,如何表示结果,如何表示数据的结尾等等

这三个特点都是因为调用不在本地而不得不衍生出来的问题,也因此决定了RPC的形态。所有的RPC解决方案都是在解决这三个问题,不断地在提出更加优良的解决方案,试图达到更好的性能,更低的使用成本。 本文也将围绕这三个特点来展开内容。常规的RPC一般都是基于一个大的内部服务,进行分布式拆分,由于其语义上以本地方法的作为入口,那么天然的就更倾向于具备高性能、支持复杂参数和返回值、跨语言等特性。下图是RPC调用的过程示意图:

内容组织约定

Stub会负责封装命令和参数,并以特定的数据格式进行打包。其中命令、参数和返回值的需要客户端和服务端的Stub事先进行协商,双方都需要维护一份完全一样的方法及参数列表。更进一步需要知道对方如何进行压缩打包,如何压缩结构体,如何压缩Class等等,并严格按照标准进行解压缩,中途有任何一丝的差错都会的导致调用失败。所以一般情况下可能会对数据进行一定的校验,同时要协商方法、参数等错误时如何返回。 这是一个比较繁杂的过程,混合了调用语法内容解压缩两部分内容,可被理解为如何组织内容的问题。

网络传输

搞定了协议约定问题后,接下来就是要通过Runtime进行内容传输了,这又是一大难题,一般是需要通过Socket编程来实现,使用TCP或UDP来进行传输,如果是UDP可以用数据报来区分每一次请求和回复。但如果是字节流的TCP,就需要用特殊的方式来标示请求或回复的末尾,用来区分不同的请求。同时当对调用性能有要求时,可能会使用Socket的异步编程模型,消除等待中的消耗,这会引入事件机制,通过状态机来解析处理或回复请求。当出现超时、丢包等情况时还进行做重试、重传、报错等等。

拆解到协议约定和网络传输时,就会发现实现RPC调用是一件非常复杂的事情,自己实现千难万难,接下来就了解一番已有的,针对协议约定和网络传输的解决方案。当然,在技术高度成熟的今天,已经有很多先烈将传输问题解决掉了,接下来就了解一下已有的,针对协议约定和网络传输的解决方案。

RESTfull HTTP JSON

RESTfull 是一种资源状态转换的架构风格,也可以用来实现 RPC, 互联网对 HTTP 超广泛的支持,使得这相当简单,也是大多数情况下的首选。通过 HTTP 协议来进行内容传输,Header 用来约定编码、body 大小等,彼此以\r\n来分割,Header 和 body 之间通过两个连续的\r\n来间隔,能很容易地区分不同的请求。通过 Url 和对应参数来标示要调用的方法和参数。在 body 中用 JSON 对内容进行编码,极易跨语言,不需要约定特定的复杂编码格式和 Stub 文件。在版本兼容性上非常友好,扩展也很容易。众多的优点使得这种方案广受欢迎。不过也有其无法避开的弱点:

  • HTTP 的 header 和 Json 的数据冗余和低压缩率使得传输性能差
  • JSON 难以表达复杂的参数类型,如结构体等

gRPC HTTP2.0 Protobuf

gRPC 在 Go 语言中大放异彩,越来越多的小伙伴在使用。gRPC 是一个高性能、开源和通用的 RPC 框架,面向移动和 HTTP/2 设计。目前提供 C、Java 和 Go 语言版本,分别是:grpc, grpc-java, grpc-go. 其中 C 版本支持 C, C++, Node.js, Python, Ruby, Objective-C, PHP 和 C# 支持。gRPC 基于 HTTP/2 标准设计,带来诸如双向流、流控、头部压缩、单 TCP 连接上的多复用请求等特性。这些特性使得其在移动设备上表现更好,更省电和节省空间占用。

  • Protobuf 进行数据编码,提高数据压缩率
  • 使用 HTTP2.0 弥补了 HTTP1.1 的不足
  • 同样在调用方和服务方使用协议约定文件,提供参数可选,为版本兼容留下缓冲空间

protobuf是一款用 C++ 开发的跨语言、二进制编码的数据序列化协议,以超高的压缩率著称。gRPC为了性能上的提升选择丢弃json、xml这种传统策略,使用 Protocol Buffers(简称protobuf),它是Google开发的一种跨语言、跨平台、可扩展的用于序列化数据协议。作为一个以跨语言为目标的序列化方案,protobuf能做到一份.proto文件走天下,不管什么语言,都能以同一份proto文件作为约定。

// ***.proto文件
syntax = "proto3";
package id_rpc;
message BusinessType {// 定义参数
  string name = 1; //参数字段
}

message UniqueId {// 定义返回值
  uint64 id = 1;
  string business_type = 2;
}

service UniqueIdService {// 定义服务,可以调用 MakeUniqueId 方法
  rpc MakeUniqueId(BusinessType) returns (UniqueId){}
}

对于 JSON 等文本形式的序列化协议来说,protobuf 能有几十倍空间和性能提升, 比如传输123,文本类的需要 3 个字节 (ascii 31 32 33) 来传输,而二进制类只需要一个字节 (01111011) 就可以表示。

protobuf 编解码详解

为什么它能有这个好的压缩效果? 我们先从编码的角度来思考,如何对一个对象进行编解码。以json编码为例,当遇到下一个字段用,隔开就行,遇到下一层级用{表示,这样可以将内容依次铺开成一个完整的字符串。解析时按照{ , }等字符也能原样还原字段和层次结构。

但protobuf为了减小体积不能使用这些分隔符,抛几个问题:

  • 它该怎么分隔字段表达层次结构呢?
  • 字段value一般分为两种,一种是定长的,例如一个int,它最多4个字节;第二种是变长的,如字符串,你不知道它在哪儿结束。该如何表示?
  • 对于定长的int,如果对应值是1,那用4个字节表达是不是有些浪费,该如何节省?

对于此,protobuf将数据类型做了分类(Wire Type),并提供不同的编解码方式:

值得关注的有两种:

  • Varint,解决定长类型的空间浪费,例如值为1的int32只用1字节,避免用四字节,达到压缩的效果。 T - V
  • Length-delimi,用来表达长度不定的内容,如string、嵌套数据、数组。 T - L - V
T - V

T - V 的含义是:

  • T: tag,包含两部分数据: 对应字段的Wire Type(这可以知道是那种分类), 字段的数字标号(tagNum)(可以在proto中找到是哪个字段,这样就避开了传字段名)。其打包方式是: (tagNum<<3) | WireType
  • 如果在proto中没有找到对应的tagNum则会跳过,这样提供了兼容能力
  • V: value, 对应字段的值,解析了T,就知道value表达的是哪个字段、什么类型、如何解析了

protobuf编码的结果就是一组组 T-V对依次紧凑排列,message有几个字段,就有几对。对于特定的RPC请求,proto中是有明确的请求、回复 message定义的,将T-V对去套对应的message,即可解析出对象。

紧接着上文预留的一个问题不可跳过,紧凑排列的T-V对,是如何进行分隔的?:

  • 如何分隔 T 和 V,该从哪个解析V ?
  • 如何分隔 T-V对,该从哪儿开始解析下一对?

T - V 对是一堆紧凑排列二进制串,里面没有分隔符,其解决方案是:

  • 征用了每个字节的最高位,如果最高位是1,说明数据没解析完,下个字节还要继续解析,如果字节高位是0,说明当前T或V解析完了,下一个字节开始是其他的T或V
  • 大端排列,高位在后,低位在前
  • 小于 128 的数字 都可以用 1个字节 表示(用8个bit表达7个bit,一bit当作标志位)
  • 大于 128 的数字,比如 300(00000001 00101100),会用两个字节来表示:10101100 00000010 (大端)

T - V 编码举例:

message request {
    int63 user_id = 1; // tagNum = 1, wireType = 0, 
}

假设 value为 2, 则编码出的T-V为: 
+-----+---+-----------------+
|00001|000|00000010|
+-----+---+-----------------+
tagNum type   data


假设 value为 300, 则编码出的T-V为: 
 第一个字节    第二       第三
+-----+---+-----------------------+
|00001|000| 10101100  00000010| 下个T-V
+-----+---+-----------------------+
tagNum type     data

Tag高位=0: 一个byte
data的第一个字节最高位为1,说明下一个字节还要继续读

这篇文章有更多关于Protocol Buffer的语法介绍

工程落地

RPC作为分布式系统的桥梁,在解决以上三大问题之外,还得需要进行工程落地,这就是RPC框架的核心职责。其要解决的问题有:

  • 集成服务发现的能力
  • 负载均衡、限流、熔断等常规操作
  • 服务方并发能力、稳定性
  • 请求方资源利用、池化、容错等

整体的目标是将RPC调用落地,为分布式系统赋能。这是一个系统性的工程。

总结

RPC从抽象的角度来看:

  • 具有需要约定调用语法
  • 需要约定内容编码方式
  • 需要网络传输

这三大特点,进一步可以归纳为协议约定问题网络传输问题,本文的主要内容都围绕这两大问题,并介绍常见的解决方案,借此对建立RPC更深的理解。


Author: 顺坚
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint polocy. If reproduced, please indicate source 顺坚 !
评论
 Previous
数据库行列转换 数据库行列转换
在进行报表开发时,或同一个用户的多条数据,查看起来比较费劲,经常会遇到行列转换操作。在查阅别人博客时也会遇到大大小小的坑,故在此总结一下几种常用数据库的行列转换的可行方法。 需求首先说明一下我们的诉求,行列转换分为行转列,和列转行。建立一个
2020-11-21
Next 
Servlet的本质 Servlet的本质
作为一个Web开发者,Servlet是每天工作过程中都要打交道的老伙伴了。尽管现在随着Spring Boot的流行,几乎不需要再实现Servlet接口了,但是Spring Boot默认Web容器是Tomcat,Servlet仍然在我们看不见
2020-11-08
  TOC