这章挺难的,感觉离我比较远,不太好懂,简单记录吧。
这章主要讲访问远程服务,主要对比了RPC和REST的区别,可以结合知乎上的文章《既然有 HTTP 请求,为什么还要用 RPC 调用?》
这篇文章进行理解。
而对于远程服务调用,它的内容不单单只有REST、RPC,还有像SOAP,WebSocket,GraphQL等等,之后我会写文章进行具体说明。
1)看完这章,最大的一个问题,REST和RPC到底有什么区别?
首先REST是基于HTTP的,我们这个问题可以先转换成HTTP和RPC的区别?
HTTP和RPC同一级别,还是被RPC包含?答案是都可能。可以看下面这张图,如果你说的HTTP是HTTP通信协议,那么显然RPC是“包含”HTTP的,因为RPC指的是远程调用,是一个完整的远程调用方案,它包括了:接口规范+序列化反序列化规范+通信协议等。也就是只要是远程调用都可以叫RPC,RPC可以基于HTTP这种通信协议实现,比如gRPC,也可以基于其它的。
如果你说的HTTP是指基于HTTP的远程调用方案(包含了接口规范RESTful、序列化反序列化JSON等),那它是与RPC同级的。举个例子,Thrift(一种RPC架构)和基于HTTP的远程调用方案比较,在这种情况下两者相互比较就成了,Thrift通信协议和HTTP通信协议比较,Binaryprotocal序列化方案和JSON序列化方案比较等。
在说回REST和RPC,到底有什么区别?
通过上面的解释,REST和RPC可以说都是一种规范、方案或者风格,只不过REST是基于HTTP远程调用方案的,而RPC是比较自由的,定制化程度比较高的。这也解释了,为什么作者要将这两者并列在一起写。
2)文章内容总结
在解决远程服务调用时,第一个问题是进程间如何通信,方法有利用管道、信号、信号量、消息队列、共享内存,以及套接字借口,各有利弊、适用场景,其中最后一个适合同机器之间的进程通信。但是RPC(远程调用)远远比IPC(进程间通信)复杂很多,会有很多问题,像是网络并不可靠、存在延迟、安全问题等,不能简单的把远程通信类比为IPC。
在RPC中,会面临3个基本的问题,如何表示数据(序列化和反序列化)、如何传递数据(要考虑异常、超时、安全等)、如何表示方法。基于此发展的RPC面临新的问题,简单、普适、高性能这三点非常难以满足,比如功能如果多起来,协议就会变复杂,效率会受影响;要简单易用,那很多事情必须遵循约定而不是配置才行;要重视效率,那就需要采用二进制的序列化器和较底层的传输协议,支持的语言范围容易受限。所以在RPC的选择上,决定了获得一些利益的同时,要付出另外一些代价。最近几年,RPC框架朝着更高层次(不仅仅负责调用远程服务,还管理远程服务)与插件化方向发展的趋势,不再追求独立地解决RPC的全部三个问题,而是将一部分功能设计成“插件”,让用户自己去选择。
对于REST,REST与RPC在思想上差异的核心是抽象的目标不一样,即面向资源的编程思想与面向过程的编程思想。REST最主要的风格特点其实就是无状态,接口抽象。可以用RMM模型(分为3级)来衡量服务的设计多么REST。
它优点有,接口面向资源,具有层次结构,更易理解;完全基于HTTP,简化调用复杂度;标准化并且实现广泛,任何语言都实现都有HTTP接口,不同程序交互非常,如果是RPC,自己还得重新实现。
缺点也很多,像不适合应用于要求高性能传输的场景中,REST与HTTP完全绑定,在特定的场景中,无法使用适合的传输协议、序列化方式等;没有传输可靠性支持,无法知道对方是否收到信息,只能利用HTTP的幂等性进行重发;REST缺乏对资源进行“部分”和“批量”的处理能力,也就是面向资源并不非常的细粒度,要拿一个资源可能会多拿,无法批量是因为多次重复HTTP请求会报错,只能自己控制请求频率。
远程服务调用(Remote Procedure Call),也就是我们经常听说的RPC。RPC出现的最初目的,就是为了让计算机能够与调用本地方法一样去调用远程方法。
在同一进程内的通信,以Java为例,进程内的通信主要通过栈内存,通过压进去里面的地址或事其他来进行消息传递。如果是不同进程之间则无法利用这个。
解决进程之间如何交换数据的问题(起初我一直不知道进程间通信,到底是要传递什么,其实就是各种各样的数据),被称为“进程间通信”(Inter-Process Communication,IPC),有以下几种方式:
|
操作符,譬如:ps -ef | grep java
,ps与grep都有独立的进程,以上命令就通过管道操作符|
将ps命令的标准输出连接到grep命令的标准输入上(ps -ef命令用于显示当前系统中所有进程的详细信息,包括进程 ID、用户、CPU 占用率等;而grep java则用于过滤出包含“java”关键字的进程 )。kill -9 pid
(用于强制终止指定进程。kill命令用于发送信号给指定的进程或进程组;而-9则表示发送SIGKILL信号)。// 伪代码示例
// 定义信号量
semaphore S = 1; // 初始值为1,表示只有一个进程可以访问共享资源
// P1进程
P(S); // P1等待信号量可用
// 访问共享资源R1
V(S); // P1释放信号量,让其他进程可以访问共享资源
// P2进程
P(S); // P2等待信号量可用
// 访问共享资源R1
V(S); // P2释放信号量,让其他进程可以访问共享资源
之所以花费那么多篇幅来介绍IPC的手段,是因为最初计算机科学家们的想法,就是将RPC作为IPC的一种特例来看待的,这个观点在今天,仅分类上这么说也仍然合理,只是到具体操作手段上不会这么做了。
对于最后一个本地套接字借口这样做的好处是,由于Socket是网络栈的统一接口,它也理所当然地能支持基于网络的跨机器的进程间通信。此外,由于Socket是各个操作系统都有提供的标准接口,完全有可能把远程方法调用的通信细节隐藏在操作系统底层,从应用层面上看来可以做到远程调用与本地的进程间通信在编码上完全一致。这种透明的调用形式造成了程序员误以为通信是无成本的假象,因而被滥用以致于显著降低了分布式系统的性能。
本地调用与远程调用当做一样处理,这是犯了方向性的错误,把系统间的调用做成透明,反而会增加程序员工作的复杂度。比如两个进程通信,谁作为服务端,谁作为客户端?怎样进行异常处理?服务端出现多线程竞争怎么办?等等问题。如果想要远程调用透明化,需要为以下罪过买单(反话),1)网络是可靠的;2)延迟是不存在的;3)带宽是无限的;4)网络是安全的;5)拓扑结构是一成不变的;6)总会有一个管理员;7)不必考虑传输成本;8)网络都是同质化的。
所以,RPC应该是一种高层次的或者说语言层次的特征,而不是像IPC那样,是低层次的或者说系统层次的。
虽然有很多RPC协议,但都不外乎变着花样使用各种手段来解决一下三个基本问题:
那些面向透明的、简单的RPC协议,要么依赖于操作系统,要么依赖于特定语言,总有一些先天约束;那些面向通用的、普适的RPC协议,无法逃过使用复杂性的困扰;而那些意图通过技术手段来屏蔽复杂性的RPC协议,又不免受到性能问题的束缚。简单、普适、高性能这三点,似乎真的难以同时满足。
由于一直没有一个同时满足以上三点的“完美 RPC 协议”出现,所以远程服务器调用这领域里,逐渐进入了群雄混战的时代,距离“统一”是越来越远,并一直延续至今。现在,已经相继出现过 RMI(Sun/Oracle)、Thrift(Facebook/Apache)、Dubbo(阿里巴巴/Apache)、gRPC(Google)、Motan1/2(新浪)、Finagle(Twitter)、brpc(百度/Apache)、.NET Remoting(微软)、Arvo(Hadoop)、JSON-RPC 2.0(JSON-RPC工作组)等等。这些RPC功能、特点不尽相同,有的是某种语言私有,有的能支持跨越多门语言,有的运行在应用层HTTP协议之上,有的能直接运行于传输层TCP/UDP协议之上,但肯定不存在哪一款是“最完美的RPC”。今时今日,任何一款具有生命力的RPC框架,都不再去追求大而全的“完美”,而是有自己的针对性特点作为主要的发展方向,朝着面向对象发展,朝着性能发展朝着简化发展等等。
经历了 RPC 框架的战国时代,开发者们终于认可了不同的 RPC 框架所提供的特性或多或少是有矛盾的,很难有某一种框架说“我全部都要”。要把面向对象那套全搬过来,就注定不会太简单;功能多起来,协议就要弄得复杂,效率一般就会受影响;要简单易用,那很多事情就必须遵循约定而不是配置才行;要重视效率,那就需要采用二进制的序列化器和较底层的传输协议,支持的语言范围容易受限。决定了选择框架时在获得一些利益的同时,要付出另外一些代价。
最近几年,RPC框架有明显的朝着更高层次(不仅仅负责调用远程服务,还管理远程服务)与插件化方向发展的趋势,不再追求独立地解决RPC的全部三个问题,而是将一部分功能设计成扩展点,让用户自己去选择。框架聚焦于提供核心的、更高层次的能力,譬如提供负载均衡、服务注册、可观察性等方面的支持。这一类框架的代表有Facebook的Thrift与阿里的Dubbo。比如Dubbo,它默认有自己的传输协议(Dubbo协议),同时也支持其他协议;默认采用Hessian 2作为序列化器,如果你有JSON的需求,可以替换为 Fastjson,如果你对性能有更高的追求,可以替换为Kryo (opens new window)等效率更好的序列化器。这种设计在一定程度上缓和了RPC框架必须取舍,难以完美的缺憾。
REST与RPC在思想上差异的核心是抽象的目标不一样,即面向资源的编程思想与面向过程的编程思想两者之间的区别。
而概念上的不同是指REST并不是一种远程服务调用协议,它就不是一种协议。协议都带有一定的规范性和强制性,它只有一些指导原则,但并不受强制的约束,所以REST只能说是风格,并且能完全达到REST所有指导原则的系统也是不多见的。
至于使用范围,REST与RPC作为主流的两种远程调用方式,在使用上是确有重合的,但重合的区域有多大就见仁见智了。上一节提到了当前的RPC协议框架都各有侧重点,并且列举了RPC一些发展方向,如分布式对象、提升调用效率、简化调用复杂性等等。这里面分布式对象这一条线的应用与REST可以说是毫无关联;而能够重视远程服务调用效率的应用场景,就基本上已经排除了REST应用得最多的供浏览器端消费的远程服务,因为以浏览器作为前端,对于传输协议、序列化器这两点都不会有什么选择的权力,哪怕想要更高效率也有心无力;而在移动端、桌面端或者分布式服务端的节点之间通讯这一块,REST虽然照样有宽阔的用武之地,只要支持HTTP就可以用于任何语言之间的交互,不过通常都会以网络没有成为性能瓶颈为使用前提,在需要追求传输效率的场景里,REST提升传输效率的潜力有限;对追求简化调用的场景——前面提到的浏览器端就属于这一类的典型,众多 RPC 里也就JSON-RPC有机会与REST竞争,其他RPC协议与框架,但很少见有实际项目把它们真的用到浏览器上的。
REST的英文是REpresentational State Transfer,表征状态转移。
以下为满足REST风格的六大原则:
REST的基本思想是面向资源来抽象问题,它与此前流行的编程思想——面向过程的编程在抽象主体上有本质的差别。在REST提出以前,人们设计分布式系统服务的唯一方案就只有RPC,RPC是将本地的方法调用思路迁移到远程方法调用上,开发者是围绕着“远程方法”去设计两个系统间交互的。这样做的坏处不仅是“如何在异构系统间表示一个方法”、“如何获得接口能够提供的方法清单”都成了需要专门协议去解决的问题(RPC 的三大基本问题),更在于服务的每个方法都是完全独立的,服务使用者必须逐个学习才能正确地使用它们。Google 在《Google API Design Guide 》中曾经写下这样一段话:“以前,人们面向方法去设计RPC API,譬如CORBA和DCOM,随着时间推移,接口与方法越来越多却又各不相同,开发人员必须了解每一个方法才能正确使用它们,这样既耗时又容易出错。”以下为REST的好处:
RMM 成熟度一个衡量“服务有多么REST”的模型。简单来说:
第0级:完全不REST。
类似RPC,也就是面向过程,比如要得到一个时间段内医生的空闲时间:
POST /appointmentService?action=query HTTP/1.1
{date: "2020-03-04", doctor: "mjones"}
第1级:开始引入资源的概念。
依然是得到一个时间段内医生的空闲时间和预约,这里的问题,一是只处理了查询和预约,如果我临时想换个时间,要调整预约,或者我的病忽然好了,想删除预约,这都需要提供新的服务接口。二是处理结果响应时,只能靠着结果中的code、message这些字段做分支判断,每一套服务都要设计可能发生错误的code,这很难考虑全面,而且也不利于对某些通用的错误做统一处理;三是并没有考虑认证授权等安全方面的内容,譬如要求只有登陆用户才允许查询医生档期时间。
POST /doctors/mjones HTTP/1.1
{date: "2020-03-04"}
POST /schedules/1234 HTTP/1.1
{name: icyfenix, age: 30, ……}
第2级:引入统一接口,映射到 HTTP 协议的方法上。
解决了第一级留下的三个问题,REST的做法是把不同业务需求抽象为对资源的增加、修改、删除等操作来解决第一个问题;使用HTTP协议的Status Code(这里应该是指403、200这些已经定义好的,而不像第1级的code是自己定义的),可以涵盖大多数资源操作可能出现的异常,而且Status Code可以自定义扩展,以此解决第二个问题;依靠HTTP Header中携带的额外认证、授权信息来解决第三个问题。
第3级:超媒体控制在本文里面的说法是“超文本驱动”。
第2级是目前绝大多数系统所到达的REST级别,但仍不是完美的,至少还存在一个问题:你是如何知道预约mjones医生的档期是需要访问/schedules/1234这个服务Endpoint的?“超文本驱动”,所希望的是除了第一个请求是有你在浏览器地址栏输入所驱动之外,其他的请求都应该能够自己描述清楚后续可能发生的状态转移,由超文本自身来驱动。所以,当你输入了查询的指令之后:
GET /doctors/mjones/schedule?date=2020-03-04&status=open HTTP/1.1
HTTP/1.1 200 OK
{
schedules:[
{
id: 1234, start:"14:00", end: "14:50", doctor: "mjones",
links: [
{rel: "comfirm schedule", href: "/schedules/1234"}
]
},
{
id: 5678, start:"16:00", end: "16:50", doctor: "mjones",
links: [
{rel: "comfirm schedule", href: "/schedules/5678"}
]
}
],
links: [
{rel: "doctor info", href: "/doctors/mjones/info"}
]
}
如果做到了第3级REST,那服务端的API和客户端也是完全解耦的,你要调整服务数量,或者同一个服务做API升级将会变得非常简单。
面向资源的编程思想只适合做CRUD,面向过程、面向对象编程才能处理真正复杂的业务逻辑
并不是。HTTP 的四个最基础的命令POST、GET、PUT 和 DELETE很容易让人直接联想到CRUD操作,它们涵盖了信息在客户端与服务端之间如何流动的几种主要方式。而针对一些比较抽象的场景,如果真不好把HTTP方法映射为资源的所需操作,REST也并非刻板的教条,用户是可以使用自定义方法的,按Google推荐的REST API风格,自定义方法应该放在资源路径末尾,嵌入冒号加自定义动词的后缀。譬如,可以把删除操作映射到标准DELETE方法上,如果此外还要提供一个恢复删除的API,那它可能会被设计为:“POST /user/user_id/cart/book_id:undelete”。如果你不想使用自定义方法,那就设计一个回收站的资源,在那里保留着还能被恢复的商品,将恢复删除视为对该资源某个状态值的修改,映射到PUT或者PATCH方法上,这也是一种完全可行的设计。
面向资源的编程思想与另外两种主流编程思想只是抽象问题时所处的立场不同,只有选择问题,没有高下之分:
1)面向过程编程时,为什么要以算法和处理过程为中心,输入数据,输出结果?当然是为了符合计算机世界中主流的交互方式。
2)面向对象编程时,为什么要将数据和行为统一起来、封装成对象?当然是为了符合现实世界的主流的交互方式。
3)面向资源编程时,为什么要将资源作为抽象的主体,把行为看作是统一的接口?当然是为了符合网络世界的主流的交互方式。
REST与HTTP完全绑定,不适合应用于要求高性能传输的场景中
作者很大程度上赞同此观点,但并不认为这是REST的缺陷,HTTP并不是传输层协议,它是应用层协议,如果仅将HTTP当作传输的全部是不恰当的。对于需要直接控制传输,如二进制细节、编码形式、报文格式、连接方式等细节的场景中,REST确实不合适。
REST不利于事务支持
如果“事务”指的是数据库那种的狭义的刚性ACID事务,那除非完全不持有状态,否则分布式系统本身与此就是有矛盾的(CAP 不可兼得),这是分布式的问题而不是REST的问题。如果“事务”是指通过服务协议或架构,在分布式服务中,获得对多个数据同时提交的统一协调能力(2PC/3PC),这 REST 确实不支持。如果“事务”只是指希望保障数据的最终一致性,说明你已经放弃刚性事务了,这才是分布式系统中的正常交互方式,使用REST肯定不会有什么阻碍,谈不上“不利于”。当然,对此REST也并没有什么帮助,这完全取决于你系统的事务设计。
REST没有传输可靠性支持
是的,并没有。在HTTP中你发送出去一个请求,通常会收到一个与之相对的响应,譬如HTTP/1.1 200 OK 或者 HTTP/1.1 404 Not Found诸如此类的。但如果你没有收到任何响应,那就无法确定消息到底是没有发送出去,抑或是没有从服务端返回回来,这其中的关键差别是服务端到底是否被触发了某些处理?应对传输可靠性最简单粗暴的做法是把消息再重发一遍。这种简单处理能够成立的前提是服务应具有幂等性,即服务被重复执行多次的效果与执行一次是相等的。HTTP也协议要求GET、PUT和DELETE应具有幂等性。
REST缺乏对资源进行“部分”和“批量”的处理能力
这个观点作者是认同的,这很可能是未来面向资源的思想和API设计风格的发展方向。譬如你仅仅想获得某个用户的姓名,RPC风格中可以设计一个“getUsernameById”的服务,返回一个字符串,尽管这种服务的通用性实在称不上“设计”二字,但确实可以工作;而REST风格中你将向服务端请求整个用户对象,然后丢弃掉返回的结果中该用户除用户名外的其他属性,这便是一种“过度获取”;你准备把某个用户的名字增加一个“VIP”前缀,提交一个PUT请求修改这个用户的名称即可,而你要给1000个用户加VIP时,如果真的去调用1000次PUT,浏览器会回应你HTTP/1.1 429 Too Many Requests,此时,你就不得不先创建一个任务资源,把1000个用户的ID交给这个任务,控制请求的频率,避免因为请求过于频繁而被服务器拒绝。
目前,一种理论上较优秀的可以解决以上这几类问题的方案是GraphQL,这是由Facebook提出的一种面向资源API的数据查询语言,如同SQL一样,挂了个“查询语言”的名字,但其实CRUD都有涉猎。比起依赖HTTP无协议的REST,GraphQL可以说是另一种“有协议”的、更彻底地面向资源的服务方式。然而凡事都有两面,离开了HTTP,它又面临着几乎所有RPC框架所遇到的那个如何推广交互接口的问题。