作为架构风格的 REST 到底是什么
很多人搞不明白 REST(Representational State Transfer 表述性状态转移)原因在于一开始就是把它当做设计风格而不是架构风格来理解,因而一上来就大谈特谈什么 RESTful API,结果是只见树木不见森林。
仅从设计的角度去理解 REST(仅把它作为 API 设计原则),最多仅能理解其资源、表述这些概念,却很难理解状态转移到底是怎么回事。
要想搞清楚 REST,必须透彻理解三个关键概念:资源、表述、状态转移。
REST 架构风格提出者和 HTTP 1.1 规范主要设计者都是同一个人 Roy Fielding。事实上,HTTP 1.1 正是 REST 风格的实现,因而认识 REST 最好的方式是从基于 HTTP 的 Web 应用开始。
场景:
我们看一个典型场景。
李小四想在京东上买一部 iPhone。
首先他在浏览器地址栏输入 www.jd.com(当然也可以通过搜索引擎进入),打开京东商城首页,然后在首页搜索栏输入“iPhone”,回车,页面切换到含有 iPhone 关键字的商品列表。
李小四用鼠标点击其中一个商品,进入该商品详情页。
李小四看了看介绍,觉得中意,于是选定颜色、型号、规格、数量,点击“加入购物车”,再点击“去购物车结算“,填写收货人信息、支付方式、开票信息,点击“提交订单”,选择一种支付方式支付并完成订单。
李小四这个人性子比较急,下了单后,每隔一段时间就点开“我的订单”,点开物流信息看看手机到哪了。
终于,手机送到了,李小四从快递员那里签收后,京东立马通过微信给他推送一条货物签收通知,并且附上开票链接。李小四点击进入开票页面,获取一张电子发票。
资源及其表述:
在整个购物过程中,李小四与之交互的是一个叫“京东商城”的 Web 应用——这是 REST 的作用对象。作为架构风格的 REST,其作用对象是一个完整的应用(或者系统)——确切地说是异构的分布式应用——而不是某一两个 API。这样的视角是理解 REST 全貌的关键。
李小四是如何获取到他想要的信息的?跑到卖家仓库去看实体 iPhone?如果这样,就没有 Web 什么事了。李小四在浏览器地址栏输入了一串叫 URL 的东西,然后浏览器就显示出京东商城首页了。
到底什么是资源?
本例中,真正的资源是 iPhone、物流、发票、钱等,但在谈论 Web 的时候,我们说的资源一般不是指这些真正的实物资源,而是指存储在服务器上的特定数据,如这里的 iPhone、订单、物流、发票、账户的数据信息。
对于实体 iPhone,我们可以去专卖店看看摸摸,那 Web 上的 iPhone 数据,我们如何找到它,又如何看如何摸呢?
前辈们设计了个伟大的东西叫 URI,你每台 iPhone 不是有唯一编号嘛,那 Web 上这些虚拟的数据我们也以虚拟物品(资源)的方式给它做唯一编号(标识)。虽然是由资源(虚拟数据)的拥有者来给它做标识,但为了统一、通用,前辈们对资源标识做了一些约束(协议),就形成了统一资源标识符(Uniform Resource Identifier,URI),这样便解决了如何找到资源的问题。比如 URL 通过 schema、域名、端口定位到服务器(资源拥有者),服务器内部再通过 path 和其他参数找到并处理资源。
从 URI (URL 是 URI 的一种实现方案)的定义看,它本身就是用来表达资源的,天生就是名词特性,只是在实际使用过程中不知为啥就跑歪了,各种 /pathto/getuserinfo 动词性的 URL 满天飞(个人认为是成也 HTTP 动词,败也 HTTP 动词,更详细的分析见后面)。
资源是找到了,但我们如何跟它交互呢?
如果是在本机,我们可以通过程序直接操作资源(如通过程序指针直接操作内存数据),但 Web 是个分布式环境,指针没那么长,够不到对方的内存怎么办?
于是我们需要在本地(客户端)拥有一份资源的副本。在 C/S 架构中,只有服务器拥有资源本身,其它客户端拿到的都是副本,而且拥有者(服务器)可以决定提供什么样的副本给客户端(提供哪些信息、以什么样的格式提供信息)。
这种带特定格式的资源副本就是资源的表述。
作为资源拥有者,服务器当然可以决定提供什么样的表述形式,但正如 URI 一样,如果没有大家都认可的、通用的、被广泛支持的格式,服务器们各说自话,互相语言不通,那万维网恐怕就会成为巴比伦塔了。
于是前辈们又定义了一些通用的资源表述格式,官方话叫媒体类型。Web 上用的最广泛的媒体类型应该是 text/html,其他还有 image/jpeg、application/json、text/xml 等。
一个资源可以有多种表述(多种媒体类型),也就是说客户端(如浏览器)通过一个 URI(如 URL )可以获得该资源的多种表述中的一种。那么客户端和服务器端是如何沟通以在表述形式上达成一致呢?
在 HTTP 中,通过头信息协商。HTTP 有一系列 accept 请求头就是用来干这事的,如 accept、accept-encoding、accept-language。比如 accept: image/webp,image/apng,image/* 告知服务器“我能处理这些媒体类型,你给其中任意一种给我就行”。服务器端响应头 content-type 则告知浏览器该资源表述的确切媒体类型,如 content-type: image/jpeg 表示它是一张 jpeg 格式的图片。
另外,和现实世界一样,Web 上的资源具有集合特性,比如 iphone,并不是指某一个 iphone,而是指 iphone 集合。从中我们得出以下推论:
- 用来表示资源的 URI 应该使用名词复数形式;
- 对应集合的包含关系(集合中包含子集合),资源具有层级性;
- 集合中的元素具有集合范围内的唯一标识,通过在 URI 中带入该唯一标识来定位集合中的元素,如 /iphones/123456。
假设有这样一个 url:http://www.jd.com/mobiles/iphones/123456
。
首先这里体现了资源的层级性:手机是一个大资源集合,其下包含了 iphone 这个子集合,而通过资源标识 123456 定位到某一个 iphone。
那么,浏览器访问这个 url 时会返回什么呢?
首先取决于服务器端决定提供哪些媒体类型,我们假设服务器端提供了 text/html、application/json、application/xml 和 image/jpeg 类型。
浏览器会决定请求什么类型呢?
如果我们在地址栏输入该 url,浏览器一般会发送如下头部:accept: text/html,... 要求返回 html 文本。但如果我们在 标签里面写该 url,浏览器会发送诸如 accept:image/* 要求返回图片格式——也就是说,取决于我们在哪里用这个 url,这是浏览器的工作机制,也是 HTML 的魅力所在(后面分析超媒体时再详细分析)。
你可能会发现,现实中我们见到的多数不是这样,更可能是这样:
当要访问 html 类型时:http://www.jd.com/mobiles/iphone.html?id=123456
(或者是编程语言后缀)
当要访问图片时:http://www.jd.com/mobiles/iphones/123456.jpg
现实中,我们不但在 URL 中写入动词来表达要进行的操作,还写入类型后缀来表达要什么样的媒体类型——这两者都违背了 URI 和 REST 设计初衷,让 URI 这个标识符同时承担了操作和媒体类型,对外暴露了设计细节,且该 URI 只能用于极其狭隘的特定场景,违背了可扩展性设计原则(无法给该 URI 扩展更多的操作能力,也无法扩展其表述能力)。
现在我们知道如何定位资源和如何传递(展示)资源,接下来的问题是,客户端如何操作资源呢?客户端无法通过操作表述(资源副本)改变资源状态,必须通过和服务器端交互来实现。
在 HTTP 中是通过几个通用的动词来表达客户端的操作意图的,最典型的 CRUD,对应 HTTP 动词(Method)POST、GET、PUT/PATCH、DELETE
。
状态转移:
通过 URL 定位资源,通过 HTTP 动词操作资源,通过状态码表示操作结果——现在大部分声称 RESTful API 的也都是做到了且仅做到了这些,大部分分析 REST 的文章也是到此便结束了,但实际上这只是开始。
相比于资源表述,REST 中更重要的第二部分是状态转移。Roy Fielding 提出一个术语叫将超媒体作为应用状态的引擎(Hypermedia As The Engine Of Application State)。这句话过于拗口,翻译过来更是难以理解,结果被很多人忽略掉了,但这正是 REST 的精髓。
我们先解释下这个术语。
超媒体:就是我们再熟悉不过的超链接,HTML 标签中的 a、script、img、link等都属于超媒体链接。
应用状态:这里明确指出是应用的状态而不是资源的。比如上面购物场景中的京东商城就是一个 Web 应用,而应用的状态则是该应用在某时刻呈现出来的样子(各个页面)。
引擎:驱动状态改变(迁移)的东西,说得白话一点就是京东商城的一个个超链接(主要是只 a 标签链接)驱动其从一个页面切换到另一个页面。
应用为何要发生状态转移?为了完成一个完整的活动,比如上面的购物。应用本质上是一个有限状态机,其中囊括的一个个活动就是一个个工作流,应用的状态就是工作流中的节点。我们把上面购物过程画出来如下(只画了主流程,实际中会有很多分支流程,比如用户付款后取消订单、签收后退货等):
这里涉及到一次购物活动(一个大的流程图)中的三个子流程:购物(下单-支付)、查看物流、开发票。每个节点对应应用的一个状态(也就是页面,前两个是京东商城的,后一个是微信的)。
回想一下李小四是怎样在这些页面(应用状态)间跳来跳去的?不停地在地址栏输入 URL?如果没有超链接(那个小小的 a)恐怕就只能这样了。如果没有超链接,京东首页就不是现在这个样子了,而是一坨长长的 URL 列表,且附上难看的流程图告诉用户要想买一部 iPhone 得按照顺序依次在地址栏输入哪些 URL——这是多么令人崩溃的事情。
所以超链接是个伟大的发明,它使资源(的表述)之间建立联系,用户能够从应用的一个状态转移到另一个状态,进而完成整个工作流。而且,这种转移是发现式的,即应用的状态切换不是既定的,一个状态的下一个状态可能并不确定,比如李小四打开京东商城首页后,对某款手表感兴趣,于是点击其链接进入手表详情页——结果买了一款手表而不是 iPhone。
那么,资源的表述和应用的状态之间又是什么关系呢?
应用的状态就是资源的表述,或者说应用是通过不同的资源表述来展现自己的。应用状态的转移就是不同的资源表述之间或者同一个资源的不同状态的表述之间的转移。
上面购物流程中,首页是一个特殊的资源;商品列表、商品详情是不同层次的商品资源;添加购物车生成新的购物车资源(或者更新购物车资源),从创建购物车到购物车详情页属于购物车资源的不同状态之间的转移;下单操作创建了新的订单资源,支付则产生支付资源,并且在京东商城应用内部产生了一系列新资源比如物流资源;订单签收后开具发票则产生了发票资源。
至此我们发现,整个 Web 应用的核心仍然是资源,但既不是某一个资源,也不是某几个毫无关联的资源,而是一系列通过超链接建立联系、能够形成工作流来完成一系列活动的有机资源池。
在资源的表述中纳入超链接,让资源的表述带有相关资源的 URI,从而让应用能够自动进行状态转移,这种媒体类型(表述)叫超媒体类型。HTML(XHTML) 是最常见的一种超媒体类型,而且是超媒体文本类型(超文本)。虽然 XHTML 基于 XML,但 XML(以及 JSON)不是超媒体类型,它们的原生语义中不带有超链接,无法从 XML 形式的资源表述进入其它资源表述。
XHTML 之所以是超媒体类型,是它在 XML 基础上做了语义化(标记)处理,HTML(XHTML)处理器知道,a 标签表示超链接,点击可以打开新页面,标签表示需要从其指向的 URI 获取图像格式的资源表述,发起 HTTP 请求时会带上诸如 accept: image/*(而不是 text/html)的请求头。
基于 XML 的另一个广泛使用的超媒体类型是 Atom。
我们也可以基于 XML 和 JSON 来设计自己的超媒体类型吗?当然可以。比如我们可以定义如下 JSON 格式:
{
"id": 123,
"money": 3000.00,
...
"links": [{
"rel": "mydomain/logistics",
"uri": "https://www.domain.com/v1/logistics/47589"
}]
}
其中 links 表示相关资源链接列表,这里给出了本订单相关的物流资源链接。该 JSON 是一个超媒体类型,它不但表述了 123 这个订单资源的信息,还给出了指向相关物流资源的链接。一般地,我们还要编写对应的 JSON Schema,让其它 JSON 解析器能够理解我们定义的类型协议。假如我们将该超媒体类型定义为 application/my.hyperproto+json
,能够处理该媒体类型的客户端发起 HTTP 请求时请求头带上 accept:application/my.hyperproto+json
,我们服务器响应时带上 content-type:application/my.hyperproto+json
,双方便可以自如地你来我往了(这也正是设计 RESTful API 的一个要点,虽然事实上被大部分实现者忽略了)。
现实:
回顾历史,最早人们并没有重视 HTTP 动词和超媒体类型,通过在 URI 中添加动词和类型后缀来表达意图,早期一些浏览器和库甚至不支持除了 GET 和 POST 之外的动词。URI 被动词和类型后缀污染的后果是它不再是“URI”(资源标识),而是操作者意图传输工具,某些角度说,它影响了 URI 的通用性和可扩展性。
还有一种对 HTTP 协议的退化使用是 XML-RPC,通过一个 URL 搞定一切,其他所有的信息都写在 XML 请求体中——在这里,HTTP 仅仅被当做传输协议而不是应用协议来使用,之
所以使用 HTTP 仅仅是因为它被各种库广泛支持,较好地满足了异构系统环境。
后来,可能是一些流行框架的支持,大家赶时髦式地谈论起 RESTful API 起来。这些所谓的 RESTful API 不过是把动词和类型后缀从 URI 中拿走了,给 URI“正了名”,重新用起 HTTP Method。他们并没有用起超媒体特性,HTTP 响应类型仅仅是普通的 XML 或 JSON,资源表述本身不能驱动工作流的行进,使用者仍然需要通过带外方式(文档)获取相关资源 URI。
我想,这可能是 REST 和 HTTP 协议自身特质造成的。
将操作(动作)极度抽象化(通用化)是一项伟大的设计,但“成也萧何败也萧何”。一方面 HTTP 动词高度抽象化(标准化、通用化),迫使开发者需要绞尽脑汁去把现实世界中成百上千的操作映射到那几个动词上——这不是一项简单的思想活动,同时它还要求开发者需合理的定义“资源”,有些可能是极度抽象的。另一方面,和严谨的动词形成鲜明对比的是 URI(URL)的极度灵活性,开发者可以任意书写 URL,只要能定位到正确的服务器,而后便是“我的地盘我做主”,没有任何硬性约束要求 URL 里面只能出现名词。于是为了少死几个脑细胞,开发人员普遍性地忽略掉 HTTP 动词(甚至忽略掉了媒体类型协商),把这些信息一股脑全塞入那个“万能”的 URL 里面。
使用超媒体的一个困惑是,当我们使用自定义的超媒体类型时,客户端需要进行额外的解析工作,还不如直接传递大家都认识的 JSON 或 XML 来得短平快。
另外,通过超媒体驱动,意味着应用(系统)仅需要对外公布少数几个入口 URI,其它 URI 都是通过上游资源表述的超链接获取的。那么,我们到底要暴露哪些入口 URI 呢?这又是一个需要深入思考的问题,而人都是懒惰的。
不过,REST 给我们设计 API 提供了一些启示或原则。
- 在系统的顶层架构上,面向资源而不是操作去规划系统,能站在全局的视角思考系统构架,让系统规划和对外暴露的 API 尽可能趋向稳定。
- URI 仅仅代表资源,通过 HTTP 动词规范化操作,能倒逼我们更合理地划分资源边界,使得系统更模块化、层次化。另外,它能让我们更深层次地思考“资源”,比如登录,好像是个纯动词,但如果进一步思考,登录这个行为是为了创建会话,对应的登出则是销毁会话,因而我们操作的实际上是“会话”(Session)资源。
- 尽可能使用超媒体类型。通过超链接对外暴露 URI 的一个好处是将具体的 URI 细节隐藏起来,比如上面的 JSON 中,客户端仅关心 rel 的值,然后提取相应的 uri 的值,这里 rel 是不变的,但 uri 可能会发生变化(比如我们的某个服务外包给第三方了),当 URI变化时,我们无需广而告之所有的客户端你要改链接哈,否则服务不可用了哈。
总结:
最后我们总结下对 REST 中资源、表述、状态转移的理解:
- 资源是服务器端的原始数据,比如订单数据,它是应用的核心。资源通过 URI 对外暴露自身;
- 在分布式应用中(如 Web),客户端无法直接触达资源本身,能触达的是资源的表述。表述是某种格式的资源副本;
- 客户端无法通过修改表述(资源副本)来改变资源本身。服务器端拥有资源的控制权,它决定可以提供哪些表述给客户端,也能决定提供什么样的操作(动词);
- 客户端通过通用动词来获取资源表述以及修改资源状态;
- 状态是指应用的状态,状态转移体现为应用中工作流程的行进(从一个页面切换到另一个页面);
- 状态转移是通过超链接驱动的。超链接由资源的表述携带,这种携带了超链接的表述称为超媒体;
- 超媒体使得应用能够自我驱动状态转移(而不需要通过带外方式);