第 2 章 访问远程服务

20 阅读16分钟

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

2.1 远程服务调用

远程服务调用(Remote Procedure Call,RPC)在计算机科学中已经存在了超过四十年时间,但仍然拥有极高的的关注度。

2.0-1 远程服务调用.png

2.1.1 进程间通信

// 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!");
}

在不考虑编译器优化的前提下,程序运行时要完成以下几项工作:

  1. 传递方法参数:将字符串 Hello World! 的引用地址压栈。
  2. 确定方法版本:根据 println() 方法的签名,确定其执行版本,譬如不同的重载版本。
  3. 执行被调用方法:从栈中弹出 Parameter 的值或引用,以此为输入,执行 Callee 内部的逻辑。
  4. 返回执行结果:将 Callee 的执行结果压栈,并将程序的指令流恢复到 Call Site 的下一条指令,继续向下执行

实现进程间通信,即解决两个进程之间交换数据的问题的常用方式:

  • 管道(Pipe)或者具名管道(Named Pipe):管道能在进程间传递少量的字符流或字节流。普通管道只用于亲缘进程(一个进程启动另外一个进程)间的通信,具名管道则允许无亲缘进程间的通信。典型应用如下,其中 psgrep 都有独立的进程,该命令通过管理操作符 |ps 命令的标准输出连接到 grep 命令的标准输入上。

    ps -ef | grep java
    
  • 信号(Signal):通知目标某种事件发生,除了用于进程间通信,还可以发送信息给自身进程。典型如 kill 命令,由 Shell 进程向指定 PID 的进程发送 SIGKILL 信号。

    kill -9 pid
    
  • 信号量(Semaphore):用于两个进程之间同步协作,相当于操作系统提供的一个特殊变量,程序可以在上面进行 wait()notify() 操作。

  • 消息队列(Message Queue):以上三种方式只适合传递少量信息,POSIX 标准中定义了消息队列用于进程间数据量较多的通信。进程可以向队列添加消息,其他进程则可以从队列消息消息。消息队列克服了信号承载信息量少,管理只能用于无格式字节流以及缓冲区大小受限等缺点,但实时性相对受限。

  • 共享内存(Shared Memory):允许多个进程访问同一块公共的内存空间,是效率最高的进程间通信形式。

  • 套接字接口(Socket):消息队列和共享内存只适用于单机多进程间的通信,套接字接口可用于不同机器之间的进程通信。当仅限于本机进程间通信时,套接字接口是被优化过的,不会经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等操作,只是简单地将应用层数据从一个进程拷贝到另一个进程,这种进程间通信方式有个专名的名称:UNIX Domain Socket,又叫做 IPC Socket。

POSIX 标准,全称为可移植操作系统接口(Portable Operating System Interface)标准,是一套由 IEEE(电气和电子工程师协会)制定的规范,旨在让不同的操作系统在功能和操作上保持一定的一致性,方便软件开发者开发出可以在多种操作系统上运行的软件,就好像是给所有操作系统制定了一个统一的 “游戏规则”。

2.1.2 通信的成本

通过网络进行分布式运算的八宗罪(8 Fallacies of Distributed Computing)

  1. The network is reliable —— 网络是可靠的:假设网络传输不会出现问题,数据一旦发送就肯定能准确无误地到达目的地。
  2. Latency is zero —— 延迟是不存在的:认为数据在网络中的传输是瞬间完成的,不会有任何延迟。
  3. Bandwidth is infinite —— 带宽是无限的:假设网络带宽足够大,可以无限制地传输数据。
  4. The network is secure —— 网络是安全的:认为网络环境是安全的,数据在传输过程中不会被窃取、篡改或受到攻击。
  5. Topology doesn't change —— 拓扑结构是一成不变的:假设网络拓扑结构(即网络中各个节点的连接方式和布局)是固定不变的。在实际的分布式系统中,网络拓扑结构可能会因为各种原因发生变化,如节点的加入或退出、网络设备的故障等。这些变化可能会影响数据的传输路径和系统的性能
  6. There is one administrator ——总会有一个管理员:认为分布式系统只有一个管理员负责管理和维护。在大型分布式系统中,通常需要多个管理员共同管理,不同的管理员可能负责不同的区域或功能。由于不同管理员的操作习惯、管理策略等可能不同,可能会导致系统出现管理混乱的情况。
  7. Transport cost is zero —— 不必考虑传输成本:认为在网络中传输数据不需要任何成本。实际上,数据传输需要消耗网络带宽、能源等资源,这些都会产生成本。
  8. The network is homogeneous —— 网络是同质化的:假设网络中的所有节点和设备都是相同的,具有相同的性能和功能。在实际的分布式系统中,网络中的节点和设备可能来自不同的厂商,具有不同的硬件配置、软件版本和性能特点。这些差异可能会导致数据传输和处理的不一致性。

以上这八条反话被认为是程序员在网络编程中经常被忽略的八大问题,潜台词就是如果远程服务调用要弄透明化的话,就必须为这些罪过买单。

2.1.3 三个基本问题

如何表示数据

  1. 数据指传递给方法的参数和方法执行的返回值

  2. 进程内的方法调用,使用程序语言预置的和程序员自定义的数据类型即可

  3. 远程方法调用,由于程序语言、硬件指令集、操作系统等影响,需要特殊处理

  4. 将交互双方所涉及的数据转换为某种事先约定好的中立数据流格式来进行传输,将数据流转换回不同语言中对应的数据类型来进行使用,即序列化与反序列化

  5. 每种 RPC 协议都有对应的序列化协议

    RPC协议序列化协议
    ONC RPCExternal Data Representation(XDR)
    CORBACommon Data Representation(CDR)
    Java RMIJava Object Serialization Stream Protocol
    gRPCProtocol Buffers
    Web ServiceXML Serialization
    众多轻量级 RPC 支持的JSON Serialization
    ……

如何传递数据

  1. 传递数据一般基于 TCP、UDP 等标准传输怪协议来完成

  2. 计算机科学中使用“Wire Protocol”来表示两个 Endpoint 之间交换数据的行为

  3. 常见的 Wire Protocol

    RPC协议Wire Protocol
    Java RMIJava Remote Message Protocol(JRMP,也支持 RMI-IIOP)
    CORBAInternet Inter ORB Protocol(IIOP,是 GIOP 协议在 IP 协议上的实现版本)
    DDSReal Time Publish Subscribe Protocol(RTPS)
    Web ServiceSimple Object Access Protocol(SOAP)
    双方都是 HTTP EndpointHTTP 协议,如 JSON-RPC
    ……

如何确定方法

  1. 本地方法调用时,编译器或者解释器根据语言规范,将调用的方法签名转换为进程空间中子过程入口位置的指针,从而确定方法

  2. 远程调用时,由于不同语言的方法签名千差万别,需要特殊处理

  3. 使用无关语言的接口描述语言(Interface Description Language, IDL),直接给程序的每个方法都规定一个唯一的,在任何机器上都绝不重复的编号(Universally Unique Identifier,UUID)。调用时直接传编号就能确定方法

  4. 类似表示方法的协议还有

    AndroidAndorid Interface Definition Language(AIDL)
    CORBAOMG Interface Definition Language(OMG IDL)
    Web ServiceWeb Service Description Language(WSDL)
    JSON-RPCJSON Web Service Protocol(JSON-WSP)
    ……

TCP是面向连接的,可靠传输,有三次握手、四次挥手,适用于需要数据完整性的场景,比如网页浏览、文件传输。

UDP是无连接的,速度快,但不保证数据顺序和到达,适合实时应用,如视频通话、在线游戏。

2.1.4 统一的RPC

那些简单的 RPC 协议,如 DCE/RPC、DCOM、Java RMI,要么依赖操作系统,要么依赖于特定语言,总有一些先天约束;那些面向通用的、变适的 RPC 协议,如 CORBA,就无法逃过使用复杂性的困扰,CORBA 烦琐的 OMG IDL、ORB 都是很好的佐证;而那些效果图通过技术手段来屏蔽复杂性的 RPC 协议,如 Web Service,又不免受到性能问题的束缚。简单、普适、高性能这三点,似乎真的难以同时满足

2.1.5 分裂的RPC

由于没有能同时满足以上三点的”完美的 RPC 协议“出现,所以不同的 RPC 框架,有了自己针对的发展方向:

  1. 面向对象方向:又称分布式对象(Distribute Object),代表为 RMI、.NET Remoting。
  2. 性能方向:代表为 gRPC 和 Thrift。从优化序列化效率和信息密度两方面入手提升 RPC 性能。
  3. 简化方向:代表为 JSON-RPC,功能弱,速度慢,但协议简单轻便,格式都更为通用。
  4. ……

2.2 REST设计风格

REST(Representational State Transfer,表征状态转移)与 RPC 同样作为主流的两种远程调用方式中的一种,REST 只是一种设计风格,提供一些指导原则,却并没有强制的约束。

2.2.1 理解REST

REST 的几个关键概念

  • 资源(Resource):同样是《REST 设计风格》这本书,无论是购买的书籍、浏览器看的网页、打印出来的文稿,尽管呈现的样子各不相同,但其中的信息是相同的,你所阅读的仍是同一份资源。
  • 表征(Representation):信息与用户交互时的表现形式,比如当你通过电脑浏览器阅读此文章时,服务器返回的这个 HTML 就称为之表征,其它的形式,如 PDF、Markdown 等,都是同一个资源的多种表征。
  • 状态(State):在特定语境中才能产生的上下文信息,比如“下一篇”这种相对概念,是基于“当前篇”这个特定语境才有意义的。
  • 转移(Transfer):服务器通过某种方式,把“用户当前阅读的文章”转变成“下一篇文章”,这个过程就被称为“表征状态转换”。

与REST 相关的几个概念

  • 统一接口(Uniform Interface):HTTP 协议中约定的一套“统一接口”,包括:GET、HEAD、POST、PUT、DELETE、TRACE、OPTIONS 七种基本操作,对特定的 URI 采取这些操作,服务器就会触发相应的表征状态转移。
  • 超文本驱动(Hypertext Driven):用户通过点击超文本链接,向服务器发送请示。
  • 自描述消息(Self-Descriptive Messages):服务器响应时,在消息中使用明确的信息来告知客户端该消息的类型以及应如何处理这条消息,譬如使用“Content-Type:applicaiton/json; charset=utf-8”,说明该资源会以 JSON 格式返回,请使用 UTF-8 字符集进行处理。

关于有状态和无状态

我们所说的有状态(Stateful)和无状态(Stateless),都是相对于服务器来说的,服务器要完成“取下一篇”的请示,要么自己记住用户的状态——称为有状态;要么客户端来记住状态,在请示时明确告诉服务器——称为无状态。

2.2.2 RESTful的系统

理想的、完全满足 REST 风格的应该满足以下六大原则

  1. 服务端与客户端分离(Clien-Server):将用户界面关注的逻辑和数据存储所关注的逻辑分离开来,有助于提高用户界面的跨平台的可移植性。
  2. 无状态(Stateless):REST 的一条核心原则。REST 希望服务器不要去负责维护状态,每一次从客户端发送的请示中,应包括所有的必要的上下文信息,会话由客户端负责保存维护,服务端依赖客户端传递的状态来执行业务处理逻辑,驱动整个应用的状态变迁。
  3. 可缓存(Cacheability):REST 希望软件系统能够允许客户端和中间的通讯传递者(譬如代理)将部分服务端的应答缓存起来,以提升系统的网络性。
  4. 分层系统(Layered System):客户端一般不需要知道是否直接连接到了最终的服务器,或连接到了路径上的中间服务器。中间服务器可以通过负载均衡和共享缓存的机制提高系统的可扩展性,这样也便于缓存、伸缩和安全策略的部署。
  5. 统一接口(Uniform Interface):REST 的另一条核心原则。REST 希望开发者面向资源编程,将系统设计的重点放到抽象系统该有哪些资源上,而忽略行为。
  6. 按需代码(Code-On-Demand):可选原则。指任何按照客户端(譬如浏览器)的请示,将可执行的软件程序从服务器发送到客户端的技术。具体执行逻辑的代码存放在服务端,只有当客户端的请示到达时,才会被传输并在客户端机器中运行。按需代码赋予了客户端无需事先知道所有来自服务端的信息应该如何处理,如何运行的宽容度。

REST 提出以资源为主体进行服务设计的风格,有不少好处:

  • 降低服务接口的学习成本:统一接口将对资源的标准操作都映射到了标准的 HTTP 方法上,不需要刻意学习。
  • 资源天然具有集合与层次结构:以资源为中心抽象的接口,由于资源是名词,天然就可以产生集合与层次结构
  • REST 绑定 HTTP 协议:纯粹只用 HTTP(而不是 SOAP over HTTP 那样在再构筑协议)带来的好处是 RPC 中的 Wire Protocol 问题就无需再多考虑了,REST 将复用 HTTP 协议中已经定义的概念和相关基础支持来解决问题。

分层系统的典型应用是内容分发网络(Content Distribution Network,CDN)。譬如中国境内访问 github 的请示,并不会直接访问 GitHub Pages 的源服务器,而是访问了位于国内的 CDN 服务器。

2.2.3 RMM

RMM(Richardson Maturity Model),用以衡量“服务有多么 REST”的 Richardson成熟度模型,从低到高分为0至3级:

  1. The Swamp of Plain Old XML:完全不 REST。
  2. Resources:开始引入资源的概念。
  3. HTTP Verbs:引入统一接口,映射到 HTTP 协议的方法上。
  4. Hypermedia Controls:超媒体控制。

作者以医生预约系统为例展示了四种不同程序的 REST 是如何应用到实际接口中的。

2.2.4 不足与争议

  • 面向资源的编程思想只适合做 CRUD,面向过程、面向对象编程才能处理真正复杂的业务逻辑

    • 泛化的 CRUD 可以涵盖绝大多数的场景

    • REST 提供自定义方法:放在资源路径末尾,嵌入冒号加后缀

      POST /user/user_id/cart/book_id:undelete
      
    • 使用新的资源的替代自定义方法,比如回收站资源替代“删除后恢复”方法

  • REST 与 HTTP 完全绑定,不适合应用于要求高性能传输的场景中

    • 很大程度上认同
    • 面向资源编码与协议无关,但是 REST 的确依赖于 HTTP 协议
  • REST 不利于事务支持

    • 如果“事务”是指狭义的刚性 ACID 事务,那分布式本身就是有矛盾的(CAP 不可兼得)
    • 如果事务”是指通过协议或架构,在分布式服务中获取对多个数据同时提交的统一协调能力(2PC/3PC),那么 REST 确实不支持
    • 如果事务”是指保持数据的最终一致性——这也是分布式系统中的正常交互方式,那么使用 REST 不会有任何影响
  • REST 没有传输可靠性支持

    • 认同
    • 当你发出一个 HTTP 请示,而没有收到任何响应,那就无法确定到底是消息没有发出去,还是没有从服务端返回。
    • HTTP 协议要求 GET、PUT 和 DELETE 应具有幂等性
  • REST 缺乏对资源进行“部分”和“批量”的处理能力

    • 认同,这很可能是未来面向资源的 API 设计风格的发展方向
    • 当你只想获取用户姓名时,RPC 可以给你提供一个 “getUsernameById” 的服务,返回一个字符串;而 REST 只能返回整个对象,再丢弃掉其他属性,这无疑是一种“过度获取(Overfetching)”。

关于面向资源编程与另外两种主流编程思想

面向过程编程时:为什么要以算法和处理过程为中心,输入数据,输出结果?当然是为了符合计算机世界中主流的交互方式。

面向对象编程时:为什么要将数据和行为统一起来,封装成对象?当然是为了符合现实世界的主流的交互方式。

面向资源编程时:为什么要将数据(资源)作为抽象的主体,把行为看作是统一的接口?当然是为了符合网络主流的交互方式。