深入理解RESTful架构和RESTful API 设计指南

@wanqiuz 2018-04-18 08:51:13发表于 wanqiuz/blog-articles APIRESTfulSpring Boot深入理解

1 RESTful架构解释

Fielding将他对互联网软件的架构原则,定名为REST,即Representational State Transfer的缩写。这个词组的翻译是"表现层状态转化"。
如果一个架构符合REST原则,就称它为RESTful架构。
要理解RESTful架构,最好的方法就是去理解Representational State Transfer这个词组到底是什么意思,它的每一个词代表了什么涵义。如果你把这个名称搞懂了,也就不难体会REST是一种什么样的设计。

1.1 资源(Resources)

REST的名称"表现层状态转化"中,省略了主语。"表现层"其实指的是"资源"(Resources)的"表现层"。
所谓"资源",就是网络上的一个实体,它可以是一段文本、一张图片、一首歌曲、一种服务,总之就是一个具体的实在。用一个URI(统一资源定位符)指向它,每种资源对应一个特定的URI。要获取这个资源,访问它的URI就可以,因此URI就成了每一个资源的地址或独一无二的识别符。所谓"上网",就是与互联网上一系列的"资源"互动,调用它的URI。
URI中的名词表示资源集合,使用复数形式。

1.2 表现层(Representation)

"资源"是一种信息实体,它可以有多种外在表现形式。我们把"资源"具体呈现出来的形式,叫做它的"表现层"(Representation)。
比如,文本可以用txt格式表现,也可以用HTML格式、XML格式、JSON格式表现,甚至可以采用二进制格式;图片可以用JPG格式表现,也可以用PNG格式表现。
URI只代表资源的实体,不代表它的形式。严格地说,有些网址最后的".html"后缀名是不必要的,因为这个后缀名表示格式,属于"表现层"范畴,而URI应该只代表"资源"的位置。它的具体表现形式,应该在HTTP请求的头信息中用Accept和Content-Type字段指定,这两个字段才是对"表现层"的描述。

1.3 状态转化(State Transfer)

访问一个网站,就代表了客户端和服务器的一个互动过程。在这个过程中,势必涉及到数据和状态的变化。
互联网通信协议HTTP协议,是一个无状态协议。这意味着,所有的状态都保存在服务器端。因此,如果客户端想要操作服务器,必须通过某种手段,让服务器端发生"状态转化"(State Transfer)。而这种转化是建立在表现层之上的,所以就是"表现层状态转化"。
客户端用到的手段,只能是HTTP协议。具体来说,就是HTTP协议里面,四个表示操作方式的动词:GET、POST、PUT、DELETE。它们分别对应四种基本操作:GET用来获取资源,POST用来新建资源(也可以用于更新资源),PUT用来更新资源,DELETE用来删除资源。

1.4 综述

综合上面的解释,我们总结一下什么是RESTful架构:
(1) 每一个URI代表一种资源;
(2) 客户端和服务器之间,传递这种资源的某种表现层;
(3) 客户端通过HTTP动词,对服务器端资源进行操作,实现"表现层状态转化"。

2 URI设计

2.1 简单理解URI、URL、URN区别与联系

URI = Universal Resource Identifier
URL = Universal Resource Locator
URN = Universal Resource Name
用面向对象的思想理解,URI就像是一个父类,用一个函数规定了基本的语法,URL和URN就像是URI的子类。URL多了访问方式(Http、ftp等)、资源地址两个成员变量,URN多了资源名字这个成员变量,而这个名字是唯一的,不依赖于时间、空间、访问方式等等。基于多态的原理,我们一般用父类的指针或者引向指向子类的实体,所以在web开发中,URI这个抽象名词用的比较多。
可以参考URL和URI的区别

2.2 URI规范

(1) 不用大写;
(2) 用下杠_不用驼峰;
(3) 参数列表要encode

2.3 资源集合 vs 单个资源

URI表示资源的两种方式:资源集合、单个资源。
资源集合:

/zoos //所有动物园
/zoos/1/animals //id为1的动物园中的所有动物

单个资源:

/zoos/1 //id为1的动物园
/zoos/1;2;3 //id为1,2,3的动物园

2.4 避免层级过深的URI

/在url中表达层级,用于按实体关联关系进行对象导航,一般根据id导航。

过深的导航容易导致url膨胀,不易维护,如 GET /zoos/1/areas/3/animals/4,尽量使用查询参数代替路径中的实体导航,如GET /animals?zoo=1&area=3;

2.5 对Composite资源的访问

服务器端的组合实体必须在uri中通过父实体的id导航访问。

组合实体不是first-class的实体,它的生命周期完全依赖父实体,无法独立存在,在实现上通常是对数据库表中某些列的抽象,不直接对应表,也无id。一个常见的例子是 User — Address,Address是对User表中zipCode/country/city三个字段的简单抽象,无法独立于User存在。必须通过User索引到Address:GET /users/1/addresses

3 在API中加入版本信息

关于设置API的版本信息,常见的有两种方法,一种是将版本号放在 http header 内,另一种是直接放在 URI中。比如:

其中1.1 和v2.8就是API的版本号,这种做法的好处是简单易读,不容易混淆。
为了简单起见,可以省略最新的 API 版本号,假设v3.0是最新版本,调用下面的API应该返回相同的结果:

  • /api/users/1234
  • /api/v3.0/users/1234
  • /v3/users/1234
    如果一个 API 的版本过期了,任何把该请求重定向到最新版本上。比如 user API v1 版本过期了,当有调用/api/v1.0/users/1234的时候,应该被重定向(http 30x)到最新的 /api/v2.0/users/1234 上。

4 Request

4.1 Http方法

通过标准HTTP方法对资源CRUD:
GET:查询

GET /zoos
GET /zoos/1
GET /zoos/1/employees

POST:创建单个资源。POST一般向“资源集合”型URI发起

GET /zoos
GET /zoos/1
GET /zoos/1/employees

PUT:更新单个资源(全部信息),客户端提供完整的更新后的资源。与之对应的是 PATCH,PATCH 负责部分更新,客户端提供要更新的那些字段。PUT/PATCH一般向“单个资源”型URI发起

PUT /animals/1
PUT /zoos/1

DELETE:删除

DELETE /zoos/1/employees/2
DELETE /zoos/1/employees/2;4;5
DELETE /zoos/1/animals  //删除id为1的动物园内的所有动物

PATCH:更新单个资源(部分信息)

PATCH /animals/1
PATCH /zoos/1

HEAD:获取资源的元数据。
OPTIONS:获取信息,关于资源的哪些属性是客户端可以改变的。

4.2 安全性和幂等性

安全性:不会改变资源状态,可以理解为只读的。
幂等性:执行1次和执行N次,对资源状态改变的副作用是等价的。幂等性是系统的接口对外一种承诺(而不是实现), 承诺只要调用接口成功, 外部多次调用对系统的影响是一致的。声明为幂等的接口会认为外部调用失败是常态, 并且失败之后必然会有重试。

方法名 安全性 幂等性
Get
Post
Put
Delete
Options
Head

安全性和幂等性均不保证反复请求能拿到相同的response。以 DELETE 为例,第一次DELETE返回200表示删除成功,第二次返回404提示资源不存在,这是允许的。

4.3 复杂查询

RESTful API 经常有对返回数据过滤和排序的要求,这些输入参数推荐采用 HTTP Query Parameter 的方式实现。
Filtering过滤:
使用唯一的查询参数进行过滤:
GET /cars?color=red 返回红色的cars
GET /cars?seats<=2 返回小于两座位的cars集合
允许一定的URI冗余,如GET /cars/red与GET /cars?color=red
Sorting排序:
允许针对多个字段排序
GET /cars?sort=-money,+year
这是返回根据价格降序和使用年限升序排列的car集合
Field selection
移动端能够显示其中一些字段,它们其实不需要一个资源的所有字段,给API消费者一个选择字段的能力,这会降低网络流量,提高API可用性。
GET /cars?fields=year,money,id,color
Paging分页
使用 limit 和offset.实现分页,缺省limit=20 和offset=0;
GET /cars?offset=10&limit=5
为了将总数发给客户端,使用订制的HTTP头: X-Total-Count.
链接到下一页或上一页可以在HTTP头的link规定,遵循Link规定:
Link: https://blog.mwaysolutions.com/sample/api/v1/cars?offset=15&limit=5; rel="next",
https://blog.mwaysolutions.com/sample/api/v1/cars?offset=50&limit=3; rel="last",
https://blog.mwaysolutions.com/sample/api/v1/cars?offset=0&limit=5; rel="first",
https://blog.mwaysolutions.com/sample/api/v1/cars?offset=5&limit=5; rel="prev",
对于一些常用的条件搜索和过滤,可以考虑映射到一个新的API(相当于快捷方式)比如设计一个用于返回最近登录用户的API:
GET /users/recently_login
这种设计可以简化客户端的调用,否则调用者每次都要根据时间合成 Query Parameter,增加了客户端使用复杂度。

4.4 Bookmarker

经常使用的、复杂的查询标签化,降低维护成本。F
如:

GET /trades?status=closed&sort=created,desc

快捷方式:

GET /trades#recently-closed

或者

GET /trades/recently-closed

4.5 Format

只用以下常见的3种body format:

(1) Content-Type: application/json

POST /v1/animal HTTP/1.1
Host: api.example.org
Accept: application/json
Content-Type: application/json
Content-Length: 24

{   
  "name": "Gir",
  "animalType": "12"
}

(2) Content-Type: application/x-www-form-urlencoded (浏览器POST表单用的格式)

POST /login HTTP/1.1
Host: example.com
Content-Length: 31
Accept: text/html
Content-Type: application/x-www-form-urlencoded

username=root&password=Zion0101

(3) Content-Type: multipart/form-data; boundary=—-RANDOM_jDMUxq4Ot5 (表单有文件上传时的格式)

4.6 Content Negotiation

资源可以有多种表示方式,如json、xml、pdf、excel等等,客户端可以指定自己期望的格式,通常有两种方式:

(1) http header Accept:

Accept:application/xml;q=0.6,application/atom+xml;q=1.0
q为各项格式的偏好程度

(2) URI中加文件后缀:/zoo/1.json

5 Response

GET 操作的返回数据是显而易见,这里不做过多讨论。对于更新和创建操作(PUT POST PATCH),API 在执行相关的操作之后要把更新后的数据也做为返回值的一部分返回给调用者,这样可以避免调用者再次调用 GET API 来获取更新,而浪费一次 HTTP 请求。特别是对于 POST 操作的 API,因为该 API 会创建数据,该数据被创建后的唯一性 ID 往往由服务端生成,如果不返回新创建的 ID,客户端就不能基于这个数据做进一步操作。这个部分理论基础可以参见RESTful Web Service 架构剖析 - 6.2 Resource Identifiers

举个例子来说明这个情况:
假如有个系统提供一个 API 用于上传一张图,这张图上传之后你可以调用另外一个 API 修改这个图片的描述。如果调用上传 API 后,返回数据中没有返回这张图的唯一性 ID,你就无法接着调用其它 API 引用到这个图的资源,从而无法进行修改描述的操作,除非之前额外再次调用查询操作拉取到这张图唯一性 ID。

通常,POST 操作成功以后,我们一般也把新创建的资源的 URI 放在 HTTP header 的 location 字段中,方便客户的拉取。例如上上树图片上传的 API 返回的 header 中可以包含 location: http://api.domain.name/photos/1234

对于返回数据,另一个值得一提的优化是使用gzip,这虽然和 API 设计本身无关,只是服务器配置上的问题,之所以特别提出,是因为 RESTful API 一般都是返回文本数据,启用 gzip 通常可以节省60%-80%以上的带宽(这个数据很好证明,随便使用几个个 json 文件 gzip下就可以看出来,我测试几个 json 文件一般300K左右都能被压缩成50K左右),尤其是在返回的数据比较大情况下,压缩比更高。不过启用gzip 不可避免会增加 CPU 的负担,实际工程项目中需要权衡考量。

##6 根据不同的 API 操作,设置合适的 HTTP 状态码和必要的出错信息
使用合理的状态码有助于提高客户端的易用性,因为这些 HTTP 状态代码本身就有一定的含义,如能在 API 返回信息中合理的利用,可以减少额外的文档描述,让API返回结果“不言自明”。

http status code 的常用应用场景如下:

200 OK 用于返回 GET, PUT, PATCH 或 DELETE 的操作。有使用也用来返回没有创建数据的 POST 操作;
201 Created 用来返回 POST 操作并且成功创建了数据的情况。新创建的数据资源的链接应该放在location中返回,具体参见这里 ;
204 No Content 用来返回一次成功的请求,但是该请求返回的 body 为空的情况,如 DELETE 请求;
304 Not Modified 表示缓存没有失效,和上次的请求相比,没有新的内容;
400 Bad Request 用于返回 API 参数不正确的情况,比如传入的 JSON 格式错误无法解析等;
401 Unauthorized 用于表示请求等 API 缺少身份验证信息;
403 Forbidden 用于表示该资源不允许特定用户访问;
404 Not Found 请求一个不存在的资源;
429 Too Many Requests 请求过于频繁,可以用在客户端调用过于频繁的情况。
对于需要提供额外说明的错误类型,可以在 HTTP Body 中详细描述,便于调用者排查原因。

{
 "error": {
  "message":"Message describing the error",
  "type":"OAuthException",
  "code":190,
  "error_subcode":460,
  "error_user_title":"A title",
  "error_user_msg":"A message",
  "fbtrace_id":"EJplcsCHuLu" 
  }
}

错误信息要容易解析,比如上面的错误信息中,返回的 JSON 数据下有个 error 属性,客户端只要判断属性是否存在即可判断是否有详细的错误信息。
如果你的API比较复杂,最好能有文档按照 error code 分门别类记录这些 error 产生的原因以及如何应对。

7 使用 token 机制设计鉴权和验证系统(Authorization and Authentication)

这个话题可以讨论的内容有很多,这里主要从实用的角度来给出一些解决方案和解决问题的思路。
由于 RESTful API 的无状态的特性,所以我们不能依赖请求前后的上下文来做鉴权和用户验证,那到底该如何区分调用者是谁从而确定它有没有相应的权限调用某个API?

我们先来看个例子,这个例子来自腾讯云微视频MVS API:
你在成功申请腾讯云的微视频服务之后会给你分配 Appid、Secret ID 之类的信息。客户端在调用上传和删除视频之类的 API 时, 需要把一个 token 放在 API 请求的 http header 的 Authorization 字段中。其中 token 是按照某种规则拼接 Appid、Secret ID 生成的。这样服务端在收到这个调用请求时就可以区分这个 API 是哪个用户调用的,该用户是否有相应的权限(其中 Appid 相当于用户名,Secret ID 相当于密码,应当妥善保存)。

这种设计思想很简单,原理就是:针对特定用户生成一个 token,之后每次API的调用请求都带上这个 token。为了防止 token 泄露引发的安全问题,还应该考虑 token 什么时候失效,什么时候需要重新生成。说到这里,可能会有人会问,为什么不实施OAuth 2?答案是适用场景不同,部署 OAuth2 也会将问题复杂化。OAuth 2 适合需要把某一资源暴露给第三方应用的情况,比如新浪微博提供 OAuth 2 验证,如果你使用新浪微博登录豆瓣,在你的同意下(你在微博的登录界面输入用户名密码,并且确认),微博最终会给豆瓣一个具有实效性的 token,豆瓣凭借这个 token 来读取你的昵称和头像信息。想想,如果不使用token,豆瓣只有知道你的用户名密码才能读取昵称和头像信息,这也是OAuth 2 要解决的一个问题。

在实际工程实践中,常见的场景就是用户系统。那么到底如何设计一个 API 能够针对不同的用户做出鉴权和验证?结合 OAuth2,参考上面腾讯云微视频MVS API的例子,这里给出一个实用的解决方案:

用户使用户名密码或者第三方登录,最终请求一个我们设计的登录 API(这个 API 接受用户名密码,或第三方登录验证结果);
服务端认证成功以后,生成一个 token,并将这个 token 和用户信息关联在一起,同时返回这个 token 给调用客户端;
客户端记录并保存下这个 token;
下次客户端发起和用户相关请求 API 都要在 http header 中带上这个 token;
服务端通过这个 token 去区分用户是谁,判断这个用户是否已经登录和有什么样的权限;
服务端也要考虑 token 的失效时间;
客户端在发现 token 失效的时候重新请求新的 token
具体步骤和实现如下图:
2320375-c715914fa54da2a7
细心的读者可能要问:为什么要多一个步骤使用 token 呢?为什么不直接把用户名和密码放在 http header 中直接做授权和验证?原因是调用 API 一般会被频繁调用,这样用户名和密码频繁在网络上传输,增加了泄漏的危险。如果使用token,即使泄漏了也不会暴露用户的密码,何况 token 也被经常被设计成有时间限制的,超时以后当前 token 就会失效,需要客户端重新做验证获得新的 token,暴露之后的影响很快就会过去。
其实获取 token,用 token 做授权和验证和 OAuth 2 如出一辙,手法完全相同的,只是 OAuth 2 有更复杂的标准步骤去换取这个token,并且这个 token 的用途不同。OAuth 2 的 token 用来授权给第三方使用,我们自己设计的系统 token 仅限在自己系统本身 API 使用。

8 如何处理有关联资源的返回数据

考虑这么一个情况:有一个 API,输入一个指定用户 id,返回一个该用户所有评论信息。最终要在 UI 上显示的,除了该用户评论的具体文本内容以外,还有用户名,头像,个人简介之类和该用户相关的详细信息。该API的返回值应该如何设计?

对客户端来说,最直观和容易处理的返回形式如下:

{
  data: [
    {user_id: "1234", avatar: "a.jpg", nick_name:"Jeffrey", comment:"RESTful Service API"}, 
    {user_id: "1234", avatar: "a.jpg", nick_name:"Jeffrey", comment:"J:"}
    ...
  ]
}

你肯定一眼就能看出问题,是的,返回数据中 avatar 和 name 是每条数据都是重复的,所以你也可以这样设计返回数据:
先返回该用户的所有评论 /comments?user=1234

{
  data: [
    {user_id: "1234", comment:"RESTful Service API"}, 
    {user_id: "1234", comment:"J:"},
    ...
  ]
}

再通过请求该用户 API 的相关内容 /users/1234:

{user_id: "1234", avatar: "a.jpg", nickName:"Jeffrey"...}

这种情况下其实可以将依赖资源嵌入返回对象中,避免了客户端需要再一次发起请求来获取这个 user 的详细信息:
/comments?user=1234 直接返回类似这样的信息即可:

{
  data: [
   {comment:"RESTful Service API"},
   {comment:"J:"},
   ...
 ],

 comment_user: {user_id: "1234", avatar: "a.jpg", nickName:"Jeffrey"...}
}

9 限制 API 调用频次(Rate limiting)

出于防止恶意访问和服务器性能压力考虑,限制 API 访问频次是非常有必要的,尤其对于大型系统而言。如果一个客户端请求 API 的频率太快,根据HTTP协议,可以返回429 Too Many Requests。

如果要为客户端提供更加详细的调用频次和访问次数之类的信息,除了提供文档说明以外,还可以在 http header 用自定义字段的形式提供,比如 Twitter API 是这样做的:
X-Rate-Limit-Limit: 该请求的调用上限
X-Rate-Limit-Remaining: 15分钟内还可以调用多少次
X-Rate-Limit-Reset: 还有多少秒之后访问限制会被重置

我们可以根据具体需求在 http header 中使用类似的形式,提供对API调用频率和访问限制的相关信息。当然,文档记录也是一个不错的选择,前提是你能保持文档和代码同步更新。

10 尽可能的使用 HTTPS,涉及用户验证的 API 一定要强制启用 HTTPS

在阅读第六章“使用 token 机制设计鉴权和验证”时,可能已经有读者感到 RESTful API 如果通过 HTTP 明文传递会有很大的安全问题。如果用于鉴权的 app id 和 Secret,甚至是用户名密码通过明文传递,那么它们很容易被截获和保存,完全没有安全性可言。

所以凡是涉及任何和用户特定信息相关内容 API 都要通过 HTTPS 暴露给调用者。事实上,你的 AP I应该全部使用 HTTPS。HTTPS 现在已经是各种网络服务的标配(比如 Xcode 默认不允许请求不安全的 HTTP 信息)。

顺便提下,如果你的WEB Server 是 Nginx,在部署了 HTTPS 的情况下,下面两个选项务必仔细设置,因为这个两个简单的设置可以很大程度上避免一些安全问题:

ssl_prefer_server_ciphers: 表示服务端加密算法优先于客户端加密算法,主要是防止降级攻击 (downgrade attack)。
Strict-Transport-Security(HSTS):告诉浏览器这个域名在指定的时间(max-age)内应该强制使用 HTTPS 访问。

参考并感谢:
HTTP Methods 和 RESTful Service API 设计