Kyle's Notebook

《凤凰架构》阅读笔记(二):访问远程服务

Word count: 3.9kReading time: 14 min
2021/07/21

《凤凰架构》阅读笔记(二):访问远程服务

远程服务调用

远程服务调用(Remote Procedure Call,RPC)历史悠久,其初衷是 让计算机能够跟调用本地方法一样去调用远程方法

进程间通信

在计算机本地调用方法,至少需要完成以下工作(以 Java 程序为例):

  • 传递方法参数:将参数的值或引用地址压栈。

  • 确定方法版本:由于存在多态和重载,需根据方法签名确定执行版本,即经过静态解析或动态分派找到明确的 Callee

  • 执行被调方法:从栈中弹出参数的值或引用,以此为输入执行 Callee 内部逻辑。

  • 返回执行结果:将 Callee 执行结果压栈,将程序的指令流恢复到 Call Site 的下一条指令,继续执行。

跨进程调用时存在问题:参数和执行结果传递都依赖于线程独占的栈内存,如果 CallerCallee 属于不同进程就无法完成传递;当 CallerCallee 不是同一语言时,方法版本选择也无法实现。

考虑 进程间通信(Inter-Process Communication,IPC),有以下方法实现:

少量数据传递:

  • 管道(Pipe)或 具名管道(Named Pipe):用于在进程间传递少量的字符流或字节流。普通管道只用于亲缘关系进程,具名管道则可用于无亲缘关系的两个进程。比如 ps -ef | grep java。只能用于无格式字节流,缓冲区大小受限。
  • 信号(Signal):用于通知目标进程有某种事件发生,可发送给其它进程或自身。比如 kill -9 pid 即向指定 pid 发送 SIGKILL 信号。
  • 信号量(Semaphore):用于两个进程间同步协作,相当于操作系统提供的特殊变量,程序可在上面进行 wait()notify() 操作。

大量数据传递:

  • 消息队列(Message Queue):POSIX 标准中定义了消息队列用于进程间数据量较多的通信。进程向队列添加消息,被赋予读权限的进程可从队列消费消息。实时性相对受限。

  • 共享内存(Shared Memory):允许多个进程访问同一块公共内存空间,效率最高。每个进程的内存地址空间默认相互隔离,操作系统提供了让进程主动创建、映射、分离、控制某一块内存的程序接口。当一块内存被多进程共享时,各个进程往往会与其它通信机制结合使用,达到进程间同步互斥的协调操作。

网络数据传递:

  • 套接字(Socket):可用于相同或不同机器之间的进程通信,主流的操作系统都支持。当只用于本机进程间通信时,套接字接口不会经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等操作,只将应用层数据从一个进程拷贝到另一个进程,被称为 IPC Socket 或 UNIX Domain Socket。

Socket 是每个操作系统都提供的标准接口,可把远程方法调用的通信细节隐藏在操作系统底层,使得本地调用与远程调用在编码上一致。

在网络通信时要考虑各种成本:服务端与客户端的角色、异常处理、线程竞争、数据表示、信息安全、网络利用率和可靠性等问题。透明调用反而增加工作的复杂度(参考 通过网络进行分布式运算的八宗罪)。

因此 IPC 是低层次的或系统层次的通信,RPC 则应该是高层次的或语言层次的通信。

远程服务调用

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

需要考虑和解决三个问题:

  • 如何表示数据:包括传递给方法的参数,和方法执行后的返回值。远程方法调用需要考虑交互双方各自使用不同程序语言,或在不同硬件指令集、操作系统下数据类型表现不一样(数据宽度、字节序等)的问题。因此需要 序列化与反序列化,将交互的数据转换为事先约定好的中立数据流格式再传输,将数据流转换回不同语言中对应的数据类型后使用(比如 gRPC 的 Protocol Buffers)。

  • 如何传递数据:指如何通过网络,在两个服务 Endpoint 之间相互操作,交换数据的行为(即 Wire Protocol,一般基于 TCP、UDP 等传输层协议完成),需要考虑异常、超时、安全、认证、授权、事务等问题。比如 Java RMI 的 JRMP、HTTP 的 JSON-RPC 等。

  • 如何确定方法:即找到跨语言对应方法的标准,比如 JSON-RPC 的 JSON-WSP、Android 的 AIDL 等。

很多协议和框架都是有针对性地解决以上的部分问题:

  • 面向对象:在分布式场景下进行跨进程的面向对象编程,被称为 分布式对象。比如 RMI、.NET Remoting。

  • 性能:追求更高的序列化效率(输出结果的容量越小,速度越快,效率越高)和信息密度(使用传输协议的层次越低,信息密度越高)。比如带专有序列化器的 gRPC 和 Thrift,前者基于 HTTP/2(支持多路复用和 Header 压缩),后者直接基于 TCP。

  • 简化:牺牲了功能和效率换来协议简单轻便,使接口与格式都更为通用。适用于 Web 浏览器等不会有额外协议支持、额外客户端支持的应用场合,比如 JSON-RPC。

近年 RPC 框架有朝更高层次和插件化方向发展的趋势,负责调用和管理远程服务。

其将一部分功能设计成扩展点由用户选择,在框架层面聚焦于提供核心的、更高层次的能力:负载均衡、服务注册、可观察性等支持。

REST 设计风格

REST(Representational State Transfer)即表征状态转移,与 RPC 不是同一类型,在思想上差异的核心是 抽象的目标不同,前者是面向资源,后者是面向过程

REST 只是一种风格,没有规范性和强制性的约束;基于 HTTP,没有(也无法有)分布式对象和高效率上的追求。

相关概念:

  • 统一接口:URI 结合 HTTP 协议中提前约定的统一接口(即各种类型的请求方法,GET、HEAD、POST、PUT、DELETE、TRACE、OPTIONS),使服务器对特定的 URI 采取操作,触发相应的表征状态转移。

  • 超文本驱动:取具体内容必然是由用户操作浏览器发送请求、由服务器发出的请求响应信息驱动,而非预置于代码中的(区别于其它客户端软件内置业务逻辑,有专门的控制器驱动状态转移)。

  • 自描述消息:资源表征可能存在多种不同形态,在消息中应有明确信息来告知客户端该消息类型和处理方式。比如 HTTP Header 中的 Content-Type。

RESTful 的系统

REST 降低服务接口的学习成本、资源天生具有集合与层次的结构、绑定于 HTTP 协议等。遵循 REST 的系统满足以下原则:

  • 服务端与客户端分离(Client-Server):分离用户界面和数据存储各自关注的逻辑,能提高用户界面跨平台可移植性。

  • 无状态(Stateless):会话信息由客户端维护,客户端请求包含必要的上下文信息,服务端依据客户端状态执行业务处理逻辑,驱动应用状态变迁。可提升系统可见性、可靠性和可伸缩性(比如分布式计算),但也因此产生了身份认证授权等可信问题,且目前大多数系统都达不到该要求(比如当上下文信息太大)。

  • 可缓存(Cacheability):允许客户端和中间代理将部分服务端的应答缓存,要明确或间接表明是否可缓存、缓存的时间。可减少两端交互、进一步提高性能。

  • 分层系统(Layered System):客户端不需要知道连接的是最终服务器或中间服务器,即可通过负载均衡和共享缓存提高系统可扩展性,也便于缓存、伸缩和安全策略的部署。典型的应用是 CDN。

  • 统一接口(Uniform Interface):设计重点应放在抽象系统该有的资源上。借用 HTTP 协议中固有的命令来完成操作,抽象程度更高,通用程度更好(比如原本的 login 操作抽象为 PUT session)。要合理利用统一接口,建议每次请求中包含资源 ID,并通过 ID 来操作;资源都应该是自描述消息;通过超文本驱动应用状态的转移。

  • 按需代码Code-On-Demand):客户端无需事先知道所有来自服务端的信息应该如何处理、如何运行。蕴含具体执行逻辑的代码存放在服务端,当客户端请求后代码才会被传输并在客户端机器中运行,结束后通常也会随即在客户端中被销毁(比如 WebAssembly)。出于必要性和性价比的实际考虑,这条原则是可选的。

RMM 成熟度

Richardson Maturity Model,服务接口 REST 的程度,从低到高:

  • 第 0 级:完全不 REST。即 RPC 的风格,当需求发生变化或增加时,都要编写额外的方法或改动现有方法的接口。
1
2
3
4
5
6
7
request:
POST /appointmentService?action=query HTTP/1.1
{date: "2020-03-04", doctor: "mjones"}

response:
HTTP/1.1 200 OK
[{start:"14:00", end: "14:50", doctor: "mjones"},]
1
2
3
4
5
6
7
8
9
10
11
request
POST /appointmentService?action=confirm HTTP/1.1
{appointment: {date: "2020-03-04", start:"14:00", doctor: "mjones"}, patient: {name: icyfenix, age: 30}}

response:
HTTP/1.1 200 OK
{code: 0,message: "Successful confirmation of appointment"}

response:
HTTP/1.1 200 OK
{code: 1, message: "doctor not available"}
  • 第 1 级:引入资源概念。引入了资源,通过资源 ID 作为主要线索与服务交互(理解为服务 Endpoint 是名词而非动词)。虽然已经通过资源 id 与服务进行交互,但存在以下问题:

    • 只处理了查询和预约,要调整时间或删除预约都要提供新的接口。

    • 当处理结果响应超时,只能靠 code、message 做分支判断,对于每套服务都要设计的 code 很难考虑全面,不利于统一处理。

    • 没有考虑认证授权等安全方面的内容,如判断用户是否登录、是否 VIP 等。

1
2
3
4
5
6
7
request:
POST /doctors/mjones HTTP/1.1
{date: "2020-03-04"}

response:
HTTP/1.1 200 OK
[{id: 1234, start:"14:00", end: "14:50", doctor: "mjones"},]
1
2
3
4
5
6
7
request:
POST /schedules/1234 HTTP/1.1
{name: icyfenix, age: 30, ……}

response:
HTTP/1.1 200 OK
{code: 0, message: "Successful confirmation of appointment"}
  • 第 2 级:引入统一接口映射到 HTTP 协议方法上。以上三个问题都可以通过引入统一接口解决:

    • Method 对应资源的 CRUD 操作。

    • Status Code 可涵盖大多数资源操作可能出现的异常。

    • Header 上携带额外认证、授权信息。

1
2
3
4
5
6
7
8
9
request:
GET /doctors/mjones/schedule?date=2020-03-04&status=open HTTP/1.1

response:
HTTP/1.1 200 OK
[
{id: 1234, start:"14:00", end: "14:50", doctor: "mjones"},
{id: 5678, start:"16:00", end: "16:50", doctor: "mjones"}
]
1
2
3
4
5
6
7
8
9
10
11
request:
POST /schedules/1234 HTTP/1.1
{name: icyfenix, age: 30, }

response:
HTTP/1.1 201 Created
Successful confirmation of appointment

response:
HTTP/1.1 409 Conflict
doctor not available
  • 第 3 级:超媒体控制。除了第一个请求要在浏览器地址栏输入所驱动外,其他请求都能自己描述清楚后续可能发生的状态转移,由超文本自身驱动。在一次查询中返回了各种可能的后续操作:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
request:
GET /doctors/mjones/schedule?date=2020-03-04&status=open HTTP/1.1

response:
HTTP/1.1 200 OK
{
schedules:[
{
id: 1234, start:"14:00", end: "14:50", doctor: "mjones",
links: [
{rel: "comfirm schedule", href: "/schedules/1234"}
]
},
],
links: [
{rel: "doctor info", href: "/doctors/mjones/info"}
]
}

不足与争议

只适合做 CRUD

面向过程、面向对象编程才能处理真正复杂的业务逻辑。

针对比较抽象的场景难以把 HTTP 映射为资源操作。可以使用自定义方法,比如 Google 推荐的 REST API 风格,自定义方法应该放在资源路径末尾,嵌入冒号加自定义动词的后缀:POST /user/user_id/cart/book_id:undelete

不适用于高性能传输

REST 依赖于应用层协议,仅将 HTTP 当作传输是不恰当的(SOAP over HTTP 就不好)。

服务集群的内部节点之间往往需要直接控制传输(如二进制细节、编码形式、报文格式、连接方式等细节),更应该使用 RPC。

不利于事务支持

这是分布式本身而非 REST 的问题(尤指 ACID 刚性事务,CAP 不可兼得)。广义上:

  • 当事务指 通过服务协议或架构,在分布式服务中获得对多个数据同时提交的统一协调能力(2PC/3PC,比如 WS-AtomicTransactionWS-Coordination 等功能性协议),REST 是不支持的。

  • 当事务指 希望保障数据的最终一致性,使用 REST 不会有什么障碍。

没有传输可靠性支持

发送 HTTP 请求不一定会有响应,无法确定消息是否发送成功、是否有返回(是否已经服务端处理)。

因此只能重试。重试的前提是服务具有 幂等性(Idempotency),HTTP 协议的 GET、PUT、DELETE 方法就要求满足这点,至于 POST 方法重复提交,也应该做提示和校验。

难以处理部分和批量资源

比如根据 id 返回用户信息,只能获取整个用户对象、然后丢弃不关心的字段,不能单独请求取某个字段。

另一方面是需要创建一个任务资源来描述一批资源,或涉及到多个资源变化时甚至要有针对性地创建一种事务资源(比如电商购物下单减库存等一系列操作),每次

本质是由于 HTTP 协议完全没有对请求资源的结构化描述能力,返回资源的哪些内容、以什么数据类型返回等,都不可能得到协议层面的支持,只能自己在 GET 方法的 Endpoint 上设计参数来实现;而且 HTTP 协议由于本身的无状态性,会相对不适应处理批量和事务场景。

GraphQL 是一种面向资源 API 的数据查询语言。比起依赖 HTTP 无协议的 REST,GraphQL 可以说是另一种有协议的、更彻底地面向资源的服务方式。然而离开了 HTTP,又面临 RPC 框架所遇到的如何推广交互接口的问题。

CATALOG
  1. 1. 《凤凰架构》阅读笔记(二):访问远程服务
    1. 1.1. 远程服务调用
      1. 1.1.1. 进程间通信
      2. 1.1.2. 远程服务调用
    2. 1.2. REST 设计风格
      1. 1.2.1. RESTful 的系统
      2. 1.2.2. RMM 成熟度
      3. 1.2.3. 不足与争议
        1. 1.2.3.1. 只适合做 CRUD
        2. 1.2.3.2. 不适用于高性能传输
        3. 1.2.3.3. 不利于事务支持
        4. 1.2.3.4. 没有传输可靠性支持
        5. 1.2.3.5. 难以处理部分和批量资源