理解REST和RPC
REST
越来越多的人开始意识到,网站即软件,而且是一种新型的软件。
网站开发,完全可以采用软件开发的模式。但是传统上,软件和网络是两个不同的领域,很少有交集;软件开发主要针对单机环境,网络则主要研究系统之间的通信。
互联网的兴起,使得这两个领域开始融合,现在我们必须考虑,如何开发在互联网环境中使用的软件。
RESTful架构,就是目前最流行的一种互联网软件架构。它结构清晰、符合标准、易于理解、扩展方便,所以正得到越来越多网站的采用。
EST这个词,是Roy Thomas Fielding在他2000年的博士论文中提出的。
"本文研究计算机科学两大前沿----软件和网络----的交叉点。长期以来,软件研究主要关注软件设计的分类、设计方法的演化,很少客观地评估不同的设计选择对系统行为的影响。而相反地,网络研究主要关注系统之间通信行为的细节、如何改进特定通信机制的表现,常常忽视了一个事实,那就是改变应用程序的互动风格比改变互动协议,对整体表现有更大的影响。我这篇文章的写作目的,就是想在符合架构原理的前提下,理解和评估以网络为基础的应用软件的架构设计,得到一个功能强、性能好、适宜通信的架构。"
Fielding将他对互联网软件的架构原则,定名为REST,即Representational State Transfer的缩写。我对这个词组的翻译是"表现层状态转化"。
如果一个架构符合REST原则,就称它为RESTful架构。
要理解RESTful架构,最好的方法就是去理解Representational State Transfer这个词组到底是什么意思,它的每一个词代表了什么涵义。如果你把这个名称搞懂了,也就不难体会REST是一种什么样的设计。
资源(Resources)
所谓"资源",就是网络上的一个实体,或者说是网络上的一个具体信息。它可以是一段文本、一张图片、一首歌曲、一种服务,总之就是一个具体的实在。你可以用一个URI(统一资源定位符)指向它,每种资源对应一个特定的URI。要获取这个资源,访问它的URI就可以,因此URI就成了每一个资源的地址或独一无二的识别符。
REST的名称"表现层状态转化"中,省略了主语。"表现层"其实指的是"资源"(Resources)的"表现层"。
表现层(Representation)
"资源"是一种信息实体,它可以有多种外在表现形式。我们把"资源"具体呈现出来的形式,叫做它的"表现层"(Representation)。
比如,文本可以用txt格式表现,也可以用HTML格式、XML格式、JSON格式表现,甚至可以采用二进制格式;图片可以用JPG格式表现,也可以用PNG格式表现。
URI只代表资源的实体,不代表它的形式。严格地说,有些网址最后的".html"后缀名是不必要的,因为这个后缀名表示格式,属于"表现层"范畴,而URI应该只代表"资源"的位置。它的具体表现形式,应该在HTTP请求的头信息中用Accept和Content-Type字段指定,这两个字段才是对"表现层"的描述。
状态转化(State Transfer)
访问一个网站,就代表了客户端和服务器的一个互动过程。在这个过程中,势必涉及到数据和状态的变化。
互联网通信协议HTTP协议,是一个无状态协议。这意味着,所有的状态都保存在服务器端。因此,如果客户端想要操作服务器,必须通过某种手段,让服务器端发生"状态转化"(State Transfer)。
客户端用到的手段,只能是HTTP协议。具体来说,就是HTTP协议里面,四个表示操作方式的动词:GET、POST、PUT、DELETE。它们分别对应四种基本操作:GET用来获取资源,POST用来新建资源(也可以用于更新资源),PUT用来更新资源,DELETE用来删除资源。
综合上面的解释,我们总结一下什么是RESTful架构:
- 每一个URI代表一种资源;
- 客户端和服务器之间,传递这种资源的某种表现层;
- 客户端通过四个HTTP动词,对服务器端资源进行操作,实现"表现层状态转化"。
RESTful API more
必须有一种统一的机制,方便不同的前端设备与后端进行通信。这导致API构架的流行,甚至出现"API First"的设计思想。RESTful API是目前比较成熟的一套互联网应用程序的API设计理论。
HTTP
REST本身并没有创造新的技术、组件或服务,而隐藏在RESTful背后的理念就是使用Web的现有特征和能力, 更好地使用现有Web标准中的一些准则和约束。虽然REST本身受Web技术的影响很深, 但是理论上REST架构风格并不是绑定在HTTP上,只不过目前HTTP是唯一与REST相关的实例。 所以上面描述的REST也是通过HTTP实现的REST。
RPC
RPC(Remote Procedure Call),即远程过程调用,是一个分布式系统间通信的必备技术。RPC 最核心要解决的问题就是在分布式系统间,如何执行另外一个地址空间上的函数、方法,就仿佛在本地调用一样。
下面依次展开每个部分。
传输(Transport)
TCP 协议是 RPC 的 基石,一般来说通信是建立在 TCP 协议之上的,而且 RPC 往往需要可靠的通信,因此不采用 UDP。
RPC 传输的 message 也就是 TCP body 中的数据,这个 message 也同样可以包含 header+body。body 也经常叫做 payload。
TCP 协议栈存在端口的概念,端口是进程获取数据的渠道。
I/O 模型(I/O Model)
做一个高性能 /scalable 的 RPC,需要能够满足:
- 服务端尽可能多的处理并发请求
- 同时尽可能短的处理完毕。
Socket I/O 可以看做是二者之间的桥梁,如何更好地协调二者,去满足前面说的两点要求,有一些模式(pattern)是可以应用的。RPC 框架可选择的 I/O 模型严格意义上有 5 种,这里不讨论基于 信号驱动 的 I/O(Signal Driven I/O)。它们分别是:
- 传统的阻塞 I/O(Blocking I/O)
- 非阻塞 I/O(Non-blocking I/O)
- I/O 多路复用(I/O multiplexing)
- 异步 I/O(Asynchronous I/O)
这里不细说每种I/O模型。这里举一个形象的例子,读者就可以领会这四种I/O的区别,就用银行办业务 这个生活的场景描述。
传统的阻塞 I/O模型
一个柜员服务所有客户,可见当客户填写单据的时候也就是发生网络I/O的时候,柜员(也就是宝贵的线程或者进程)就会被阻塞,白白浪费了 CPU 资源,无法服务后面的请求。
如果一个柜员不够,那么就并发处理,对应采用线程池或者多进程方案,一个客户对应一个柜员,这明显加大了并发度,在并发不高的情况下性能够用,但是仍然存在柜员被 I/O 阻塞的可能。
I/O 多路复用
存在一个大堂经理,相当于代理,它来负责所有的客户,只有当客户写好单据后,才把客户分配一个柜员处理,可以想象柜员不用阻塞在 I/O 读写上,这样柜员效率会非常高,这也就是 I/O 多路复用的精髓。
异步 I/O
完全不存在大堂经理,银行有一个天然的“高级的分配机器”,柜员注册自己负责的业务类型,例如 I/O 可读,那么由这个“高级的机器”负责I/O读,当可读时候,通过回调机制,把客户已经填写完毕的单据主动交给柜员,回调其函数完成操作。
重点说下高性能,并且工业界普遍使用的方案,也就是后两种。
I/O 多路复用
基于内核,建立在epoll或者kqueue上实现,I/O多路复用最大的优势是用户可以在一个线程内同时处理多个Socket的I/O请求。通过一个线程监听全部的TCP连接,有任何事件发生就通知用户态处理即可。
异步 I/O
这里重点说下同步 I/O 和异步I/O,理论上前三种模型都叫做同步I/O,同步是指用户线程发起I/O请求后需要等待或者轮询内核I/O完成后再继续,而异步是指用户线程发起I/O请求直接退出,当内核I/O操作完成后会通知用户线程来调用其回调函数。
I/O 多路复用往往对应 Reactor 模式,异步 I/O 往往对应 Proactor。
Reactor 一般使用epoll+事件驱动的经典模式,通过分治的手段,把耗时的网络连接、安全认证、编码等工作交给专门的线程池或者进程去完成,然后再去调用真正的核心业务逻辑层,这在 *nix 系统中被广泛使用。
著名的 Redis、Nginx、Node.js 的 Socket I/O 都用的这个,而 Java 的 NIO 框架 Netty 也是,Spark 2.0 RPC 所依赖的同样采用了 Reactor 模式。
Proactor在*nix中没有很好的实现,但是在Windows上大放异彩(例如 IOCP 模型)。
说个具体的例子,Thrift 作为一个融合了 序列化+RPC 的框架,提供了很多种 Server 的构建选项。
在Reactor中实现读:
- 注册读就绪事件和相应的事件处理器
- 事件分离器等待事件
- 事件到来,激活分离器,分离器调用事件对应的处理器。
- 事件处理器完成实际的读操作,处理读到的数据,注册新的事件,然后返还控制权。
在Proactor中实现读:
- 处理器发起异步读操作(注意:操作系统必须支持异步IO)。在这种情况下,处理器无视IO就绪事件,它关注的是完成事件。
- 事件分离器等待操作完成事件
- 在分离器等待过程中,操作系统利用并行的内核线程执行实际的读操作,并将结果数据存入用户自定义缓冲区,最后通知事件分离器读操作完成。
- 事件分离器呼唤处理器。
- 事件处理器处理用户自定义缓冲区中的数据,然后启动一个新的异步操作,并将控制权返回事件分离器。
可以看出,两个模式的相同点,都是对某个IO事件的事件通知(即告诉某个模块,这个IO操作可以进行或已经完成)。在结构上,两者也有相同点:demultiplexor负责提交IO操作(异步)、查询设备是否可操作(同步),然后当条件满足时,就回调handler;不同点在于,异步情况下(Proactor),当回调handler时,表示IO操作已经完成;同步情况下(Reactor),回调handler时,表示IO设备可以进行某个操作(can read or can write)。
Schema 和序列化(Schema & Data Serialization)
序列化和反序列化,是做对象到二进制数据的转换。
程序是可以理解对象的,对象一般含有schema或者结构,基于这些语义来做特定的业务逻辑处理。
考察一个序列化框架一般会关注以下几点:
- Encoding format。是 human readable 还是 binary。
- Schema declaration。也叫作契约声明,基于 IDL,比如 Protocol Buffers/Thrift,还是自描述的,比如 JSON、XML。另外还需要看是否是强类型的。
- 语言平台的中立性。比如 Java 的 Native Serialization 就只能自己玩,而 Protocol Buffers 可以跨各种语言和平台。
- 新老契约的兼容性。比如 IDL 加了一个字段,老数据是否还可以反序列化成功。
- 和压缩算法的契合度。跑benchmark和实际应用都会结合各种压缩算法,例如 gzip、snappy。
- 性能。这是最重要的,序列化、反序列化的时间,序列化后数据的字节大小是考察重点。
序列化方式非常多,常见的有 Protocol Buffers, Avro,Thrift,XML,JSON,MessagePack,Kyro,Hessian,Protostuff,Java Native Serialize,FST。
协议结构(Wire Protocol)
TCP 只是 binary stream 通道,是binary数据的可靠搬用工,它不懂 RPC 里面包装的是什么。而在一个通道上传输 message,势必涉及 message 的识别。
举个例子,正如下图中的例子,ABC+DEF+GHI 分 3 个 message,也就是分 3 个 Frame 发送出去,而接收端分四次收到 4 个 Frame。
Socket I/O 的工作完成得很好,可靠地传输过去,这是 TCP 协议保证的,但是接收到的是 4 个 Frame,不是原本发送的 3 个 message 对应的 3 个 Frame。
这种情况叫做发生了 TCP 粘包和半包 现象,AB、H、I 的情况叫做半包,CDEFG的情况叫做粘包。虽然顺序是对的,但是分组完全和之前对应不上。
这时候应用层如何做语义级别的 message 识别是个问题,只有做好了协议的结构,才能把一整个数据片段做序列化或者反序列化处理。
比如:memcache的换行符、http中的固定长度头。
可靠性(Reliability)
RPC 框架不光要处理 Network I/O、序列化、协议栈。还有很多不确定性问题要处理,这里的不确定性就是由 网络的不可靠 带来的麻烦。
例如如何保持长连接心跳?网络闪断怎么办?重连、重传?连接超时?这些都非常的细碎和麻烦,所以说开发好一个稳定的 RPC 类库是一个非常系统和细心的工程。
但是好在工业界有一群人就致力于提供平台似的解决方案,例如 Java 中的 Netty,它是一个强大的异步、事件驱动的网络 I/O 库,使用 I/O 多路复用的模型,做好了上述的麻烦处理。
它是面向对象设计模式的集大成者,使用方只需要会使用 Netty 的各种类,进行扩展、组合、插拔,就可以完成一个高性能、可靠的 RPC 框架。
著名的 gRPC Java 版本、Twitter 的 Finagle 框架、阿里巴巴的 Dubbo、新浪微博的 Motan、Spark 2.0 RPC 的网络层(可以参考 kraps-rpc:https://github.com/neoremind/kraps-rpc)都采用了这个类库。
易用性(Ease of use)
RPC 是需要让上层写业务逻辑来实现功能的,如何优雅地启停一个 server,注入 endpoint,客户端怎么连,重试调用,超时控制,同步异步调用,SDK 是否需要交换等等,都决定了基于 RPC 构建服务,甚至 SOA 的工程效率与生产力高低。这里不做展开,看各种 RPC 的文档就知道他们的易用性如何了。
工业界的 RPC 框架一览
国内
- Dubbo。来自阿里巴巴 http://dubbo.I/O/
- Motan。新浪微博自用 https://github.com/weibocom/motan
- Dubbox。当当基于dubbo的https://github.com/dangdangdotcom/dubbox
- rpcx。基于 Golang 的 https://github.com/smallnest/rpcx
- Navi & Navi-pbrpc。作者开源的 https://github.com/neoremind/navi https://github.com/neoremind/navi-pbrpc
国外
- Thrift from facebook https://thrift.apache.org
- Avro from hadoop https://avro.apache.org
- Finagle by twitter https://twitter.github.I/O/finagle
- gRPC by Google http://www.grpc.I/O (Google inside use Stuppy)
- Hessian from cuacho http://hessian.caucho.com
- Coral Service inside amazon (not open sourced)
上述列出来的都是现在互联网企业常用的解决方案,暂时不考虑传统的 SOAP,XML-RPC 等。这些是有网络资料的,实际上很多公司内部都会针对自己的业务场景,以及和公司内的平台相融合(比如监控平台等),自研一套框架,但是殊途同归,都逃不掉刚刚上面所列举的 RPC 的要考虑的各个部分。
既然有 HTTP 请求,为什么还要用 RPC 调用
http好比普通话,rpc好比团伙内部黑话。讲普通话,好处就是谁都听得懂,谁都会讲。
讲黑话,好处是可以更精简、更加保密、更加可定制,坏处就是要求“说”黑话的那一方(client端)也要懂,而且一旦大家都说一种黑话了,换黑话就困难了。
言归正传
这个问题其实是有理解误区的,首先 http 和 rpc 并不是一个并行概念。rpc是远端过程调用,其调用协议通常包含传输协议和编码协议。
传输协议包含: 如著名的 [gRPC](grpc / grpc.io) 使用的 http2 协议,也有如dubbo一类的自定义报文的tcp协议。编码协议包含: 如基于文本编码的 xml json,也有二进制编码的 protobuf binpack 等。
因此问题应该是:为什么要使用自定义 tcp 协议的 rpc 做后端进程通信?
要解决这个问题就应该搞清楚 http 使用的 tcp 协议,和我们自定义的 tcp 协议在报文上的区别。首先要否认一点 http 协议相较于自定义tcp报文协议,增加的开销在于连接的建立与断开。
http协议是支持连接池复用的,也就是建立一定数量的连接不断开,并不会频繁的创建和销毁连接。另外要说的是http也可以使用protobuf这种二进制编码协议对内容进行编码,因此二者最大的区别还是在传输协议上。
通用定义的http1.1协议的tcp报文包含太多废信息,一个POST协议的格式大致如下:
HTTP/1.0 200 OK
Content-Type: text/plain
Content-Length: 137582
Expires: Thu, 05 Dec 1997 16:00:00 GMT
Last-Modified: Wed, 5 August 1996 15:55:28 GMT
Server: Apache 0.84
<html>
<body>Hello World</body>
</html>
即使编码协议也就是body是使用二进制编码协议,报文元数据也就是header头的键值对却用了文本编码,非常占字节数。如上图所使用的报文中有效字节数仅仅占约 30%,也就是70%的时间用于传输元数据废编码。当然实际情况下报文内容可能会比这个长,但是报头所占的比例也是非常可观的。
自定义tcp协议可以极大地简化传输头内容。
所谓的效率优势是针对http1.1协议来讲的,http2.0协议已经优化编码效率问题,像grpc这种rpc库使用的就是http2.0协议。这么来说吧http容器的性能测试单位通常是kqps,自定义tpc协议则通常是以10kqps到100kqps为基准简单来说成熟的rpc库相对http容器,
更多的是封装了“服务发现”,"错误重试"一类面向服务的高级特性。可以这么理解,rpc框架是面向服务的更高级的封装。如果把一个http server容器上封装一层服务发现和函数代理调用,那它就已经可以做一个rpc框架了。
附:thrift和http共存的一个示例