凤凰架构-Reading3-访问远程服务

访问远程服务

远程服务将计算机程序的工作范围从单机扩展到网络,从本地延伸至远程,是构建分布式系统的首要基础。

1. 远程服务调用(Remote Rrocedure Call, RPC)

1.1. 进程间通信

RPC出现的最初目的是为了让计算机能够跟调用本地方法一样去调用远程方法。

  • 首先明确几个后续可能会用到的概念
1
2
3
4
5
6
7
8
// Caller    :  调用者,代码里的main()
// Callee : 被调用者,代码里的println()
// Call Site : 调用点,即发生方法调用的指令流位置
// Parameter : 参数,由Caller传递给Callee的数据,即“hello world”
// Retval : 返回值,由Callee传递给Caller的数据。以下代码中如果方法能够正常结束,它是void,如果方法异常完成,它是对应的异常
public static void main(String[] args) {
System.out.println(“hello world”);
}
  • 在不考虑编译器优化的前提下,程序运行至调用printIn()方法输出hello world行时,会完成以下几项工作。
    1. 传递方法参数:将字符串的地址压栈
    2. 确定方法版本:根据某些语言规范中明确定义原则,找到明确的Callee
    3. 执行被调方法:从栈中弹出Parameter的值或引用,以此作为输入,执行Callee内部的逻辑。
    4. 返回执行结果:将Callee的执行结果压栈,并将程序的指令流恢复到Call Site的下一条指令,继续向下执行。
  • 那么如果println()方法不再当前进程的地址空间,会面临两个直接的障碍:
    • 第一步和第四步的传递参数、传回参数本身依赖于同一个进程的栈空间传递,如果CallerCallee是不同进程,将毫无意义。
    • 第二步的方法版本选择依赖于语言规则的定义,如果CallerCallee不是同一种语言实现的程序,那么这种选择讲师模糊不可知的行为。
  • 为了简化讨论,我们暂时忽略第二个障碍,假设CallerCallee使用同一种语言实现,那么如果解决进程间通信问题(Inter-Process Communication, IPC)
    • 管道(Pipe)和具名管道(Named Pipe)
      • 普通管道只用于有亲缘关系进程(一个进程启动另一个进程)间的通信
      • 具名管道允许亲缘关系进程间的通信
      • 典型应用就是命令行中的|操作符
    • 信号(Signal):信号用于通知目标进程有某种时间发生,不仅仅可以用于进程间通信,还可以发送信号给进程自身,典型应用就是kill命令
    • 信号量(Semaphore):用于两个进程之间同步协作的手段,相当于系统提供的一个特殊变量,程序可以在上面进行wait()notify()操作
    • 消息队列(Message Queue):上面的三种方式只适合传递少量信息,POISX标准定义了消息队列用于进程间数据量较多的通信。
      • 进程可以像队列添加信息,具有读权限的进程可以从队列中消费信息。
      • 消息队列克服了信号承载信息少等缺点,但是实时性受限。
    • 共享内存(Shared Memory):允许多个进程共同访问同一块公共的内存空间。
    • 套接字接口(Socket):套接字接口可以完成更为普适的进程间通信,可用于不同机器的进程通信(本机的进程间通信,套接字接口被优化过)

1.2. 通信的成本

  • 通过网络进行分布式计算的八宗罪(以下的八条反话被认为是程序员在网络编程中经常被忽略的八大问题):
    1. 网络是可靠的
    2. 延迟是不存在的
    3. 带宽是无线的
    4. 网络是安全的
    5. 拓扑结构是一成不变的
    6. 总会有一个管理员
    7. 不必考虑传输成本
    8. 网络是同质化

远程服务调用是指位于互不重合的内存地址空间中的两个程序,在语言层面上,以同步的方式使用带宽有限的信道来传输程序控制信息。

1.3. 三个基本问题

近几十年所有流行过的RPC协议,无外乎是变着花样来解决以下三个基本问题

  1. 如何表示数据
    1. 包括传递给方法的参数,以及方法执行后的返回值。
    2. 有效的做法是将交互双方所设计的数据转换为某种实现约定好的中立数据流格式进行传输,将数据流转换会不同语言中对应的数据类型来进行使用。也就是序列化和反序列化。
  2. 如何传递数据
    1. 如何通过网络,在两个服务的Endpoint之间相互操作、交换数据。
    2. 实际传输一般是基于标准的TCP、UDP等标准的传输层协议来完成,不仅仅包含参数和结果,还包含异常、超时、安全等,这种行为被称为"Wire Protocol"
  3. 如何确定方法
    1. “如何表示同一个方法”、"如何找到对应的方法"需要有一个跨语言的统一的标准。
    2. 比如唯一的绝不重复的编码方案UUID(Universally Unique Identifier)被保留并广为流传。

1.4. 统一与分裂的RPC

  • 统一的RPC:从CORBA,到XML,再到Web Service,这些RPC协议都有各自的弊端。
    • CORBA设计过于繁琐
    • XML性能奇差
    • Web Service系列协议学习成本极高
  • 分裂的RPC
    • 目前已经相继出现了RMI、Thrift等诸多协议和框架。今时今日,任何一款具有生命力的RPC框架,都不再追求大而全的"完美",而是有自己的针对性特点作为主要的发展方向。
      • 朝着面向对象发展:希望分布式系统中也能够进行跨进程的面向对象编程,代表为RMI,这条线又叫分布式对象
      • 朝着性能发展:代表是gRPC和Thrift。
        • 决定RPC性能的两个主要因素:序列化效率和信息密度
        • gRPC和Thrift都有自己优秀的专有序列化器;传输协议方面,gRPC是基于HTTP/2的,Thrift则是直接基于传输层的TCP协议协议。
      • 朝着简化发展:代表是JSON-RPC,虽然协议较为简单轻便,但是相对功能较弱、速度较慢。
    • 近些年,RPC框架有明显地朝着更高层次(不仅仅负责调用远程服务,还管理远程服务)与插件化的发展的趋势。
      • 不再追求独立地解决RPC的全部三个问题(表示数据、传递数据、表示方法),将部分的功能设计成扩展点,让用户自己去选择。
      • 框架聚焦于提供核心的、更高层次的能力,譬如提供负载均衡、服务注册等。

2. REST设计风格

  • REST与RPC在思想上差异的核心是抽象的目标是不一样的,即面向资源的编程思想与面向过程的编程思想两者之间的区别。
    • REST并不是一种远程服务调用协议,而是一种风格,是面向资源来抽象问题。
    • REST与RPC作为主流的两种远程调用方式,在使用上是确有重合的。

2.1. 理解REST(Representational State Transfer, 表征状态转移)

REST的源头:Architectural Styles and the Design of Network-based Software Architectures

  • 超文本(或超媒体,Hypermedia)是一种能够对操作进行判断和相应的文本(或声音、图像等),以"查看下一篇文章"为例
    • 资源(Resource):蕴涵的信息、数据,比如一篇文章中所蕴含的内容本身。
    • 表征(Representation):指信息与用户交互时的表示形式,比如浏览器请求某个资源的HTML格式,服务端向浏览器返回的这个HTML就是表征。
    • 状态(State):在特定语境中才能产生的上下文信息。
      • 有状态:由服务端记录状态
      • 无状态:由客户端记录状态,在请求的时候明确告诉服务器:我正在阅读xx文章,现在要阅读下一篇。
    • 转移(Transfer):无论状态是由服务端还是客户端来提供,服务器通过某种方式,将"当前阅读的文章"转变成"下一篇文章",这就是"表征状态转移"
  • 其他几个概念名词:
    • 统一接口(Uniform Interface):HTTP协议中已经提前约定好了一套"统一接口",包括:GET、HEAD、POST、PUT、DELETE、TRACE、OPTIONS七种基本操作,任何一个支持HTTP协议的服务器都会遵守这套规定,对特定的URI采取这些操作,服务器就会触发相应的表征状态转移。
    • 超文本驱动(Hypertext Driven):任何网站的导航(状态转移)行为都不可能是预置于浏览器代码之中,而是由服务器发出的请求相应信息(超文本)来驱动的
    • 自描述消息(Self-Descriptive Messages):由于资源的表征可能存在多种不同形式,在消息中应当有明确的信息来告知客户端该消息的类型以及如何处理。一种方式是通过"Content-Type"的HTTP Header中标识出互联网媒体类型(MIME type)

2.2. RESTful的系统

Fielding认为,一套理想的、完全满足REST风格的系统应该满足以下六大原则。

  1. 服务端与客户端分离(Client-Server):将用户界面所关注的逻辑和数据存储锁关注的逻辑分离开,有助于提高用户界面的跨平台可移植性。
  2. 无状态(Stateless):无状态是REST的一条核心原则,REST希望服务端不要去维护状态,而这也会有一些新的问题,比如身份认证、授权等可信问题,他们都有针对性的解决方案。
  3. 可缓存(Cacheability):允许客户端和中间的通讯传递者将部分服务端的应答缓存起来。
  4. 分层系统(Layered System)
    1. 客户端一般不需要知道是否直接连接到了最终的服务器,抑或连接到路径上的中间服务器。
    2. 中间服务器可以通过负载均衡和共享缓存的机制来提高系统的可扩展性。
    3. 该原则的典型应用:内容分发网络(Content Distribution Network, CDN)
  5. 统一接口(Uniform Interface):希望开发者面向资源编程,将设计重点放在抽象系统该有哪些资源上,而不是抽象系统该有哪些行为上,但是这种设计的抽象程度比较高。
  6. 按需代码(Code On Demand, 可选原则):任何客户端(比如浏览器)的请求,将可执行的软件程序从服务器发送到客户端的技术,按序代码赋予了客户端无需事先知道所有来自服务端的信息应该如何处理、如何运行的宽容度。

2.3. REST的优点

  1. 降低的服务接口的学习成本:统一接口。
  2. 资源天然具有集合与层次结构,符合人们长期在单机或网络环境中管理数据的直觉。
  3. REST绑定于HTTP协议

2.4. RMM成熟度

  • Richardson将服务接口"REST的程度"从低到高,分为0至3级,更多细节
    1. 等级0:完全不REST
    2. 等级1:Resources,开始引入资源的概念。
    3. 等级2:HTTP Verbs,引入统一接口,映射到HTTP协议的方法上。
    4. 等级3:Hypermedia Controls:超文本驱动

2.5. 不足和争议

  1. 面向资源的编程思想只适合做CRUD,面向过程、面向对象编程才能处理真正复杂的业务逻辑:用户可以使用自定义方法,按Google推荐的REST API风格,自定义方法应该放在资源路径末尾,嵌入冒号加自定义动词的后缀:比如/user/user_id/cart/book_id:undelete
    1. 面向过程编程时,为什么要以算法和处理过程为中心,输入数据,输出结果?当然是为了符合计算机世界中主流的交互方式。
    2. 面向对象编程时,为什么要将数据和行为统一起来、封装成对象?当然是为了符合现实世界的主流的交互方式。
    3. 面向资源编程时,为什么要将数据(资源)作为抽象的主体,把行为看作是统一的接口?当然是为了符合网络世界的主流的交互方式。
  2. REST与HTTP完全绑定,不适合应用于要求高性能传输的场景中:很大程度上认同,但不认为这是REST的缺点,重点是其应用场景。
  3. REST不利于事务支持:完全取决于事务设计
    1. 如果事务理解为数据库的ACID事务,那除非完全不持有状态,否则分布式系统本身就是与此矛盾的。
    2. 如果事务理解为服务协议或架构,在分布式服务中,获得对多个数据同时提交的统一协调能力(2PC/3PC),REST确实不支持。
  4. REST没有传输可靠性支持
    1. 是的,没有
    2. 如果没有收到相应数据,那么最简单的处理就是重发请求,但是这需要保证服务具有幂等性。
    3. 对于POST请求的重复提交,浏览器会出现相应警告,比如"确认重复提交表单"的提示。
  5. REST缺乏对资源进行"部分"和"批量"的处理能力
    1. HTTP协议本身也称为了束缚REST的无形牢笼:比如断点续传等。
    2. 如果需要执行批量操作,那么则需要提供一个批量的接口,而不是一个一个去请求。

凤凰架构-Reading3-访问远程服务
https://spricoder.github.io/2022/02/19/Phoenix-Architecture-Reading/Phoenix-Architecture-Reading3-%E8%AE%BF%E9%97%AE%E8%BF%9C%E7%A8%8B%E6%9C%8D%E5%8A%A1/
作者
SpriCoder
发布于
2022年2月19日
许可协议