/Web-API-The-Good-Parts

《Web API的设计与开发》--知识点整理

Web API的设计与开发

说明

丰富了附录B中的Web API检查清单,给予了一些必要的解释与说明,方便大家快速学习。

另外,以下只是作者的建议,使用需结合实际情况。

检查清单

  • URI是否短小且容易输入
好的例子:http://api.example.com/search
坏的例子:http://api.example.com/service/api/search。

域名已经是api,在URI就没有必要重复一些毫无意义的单词。
  • URI是否能让人一眼看懂
不要轻易使用缩写:http://api.example.com/sv/u
使用更地道的英语表达,比如搜索接口一般用search而不是find,可以多参照一些国外大厂的API。
  • URI是否只有小写字母组成
HTTP协议指定了:URL中除了schema和hostname不区分大小写,其他部分均大小写敏感。
因此,URI应该使用小写,禁止大小写混写。
  • URI是否容易修改
比如获取某个商品信息的URI应该长这样:http://api.example.com/v1/items/12346
从URI直观的即可知道获取56780商品的URI是这样:http://api.example.com/v1/items/56780。

开发者对URI的行为理解是自然的,不费心的。
  • URI是否反映了服务端的架构
不要暴露服务端是哪种开发语言,下面是错误的例子:
http://api.example.com/cgi-bin/get_user.php?user=100
  • URI规则是否统一
URI中的词汇和结构应该保持统一。

下面是一个错误的例子:

获取好友信息:http://api.example.com/friends?id=100
发送信息:http://api.example.com/friend/100/messages

首先,friends和friend的单复数形式不统一,你可以想象API使用者是如何被这种细小的差异坑了半天。
其次,一个通过get参数传参,一个通过URI路径传参,不够统一。

一个正确的例子:
获取好友信息:http://api.example.com/friends/100
发送信息:http://api.example.com/friends/100/messages

该例子遵循REST风格,下面讲解REST URI设计风格。

  • 有没有使用合适的HTTP方法
作者强调REST风格。

HTTP方法表示"进行怎样的操作",URI表示"资源",HTTP和URI一起则表示"对什么资源做什么操作"。

GET:获取资源

获取ID=100的好友信息,
GET http://api.example.com/friends/100

POST:新增资源

添加一位好友,相当于新建一个好友关系:
POST http://api.example.com/friends

PUT:更新已有资源

更新ID=100的好友信息(例如:更新备注信息),
PUT http://api.example.com/friends/100

PATCH:更新部分资源

和PUT类似,只是强调更新资源的部分信息,不常用。

DELETE:删除资源

删除ID=100的好友信息:
DELETE http://api.example.com/friends/100

HEAD:获取资源的元信息

这个作者没有详细去说一个资源的元信息应该是什么样的。

感觉和自己设计的API不太一样吧?这就是REST风格,常见于国外各种大厂。
  • URI里用到的单词所表达的意思是否和大部分API相同
有点重复,还是强调对于非英语母语的开发者,应该注意使用地道准确的单词。
比如,照片应该用photo而不是picture。
  • URI里用到的名词是否采用了复数形式
因为URI表示资源的集合,所以作者是建议总是使用复数形式。

正确的例子:http://api.example.com/friends/100
错误的例子:http://api.example.com/friend/100

另外,因为REST风格强调URI是资源,所以不应该在URI里出现动词,因为动作是HTTP方法表达的。

错误的例子:http://api.example.com/get_friend?id=100
  • URI里有没有空格符以及需要编码的字符
URL是会被urlencode编码的,所以不要在URI里使用空格(会被编码成+)、UTF-8字符、乱七八糟的符号等。

即不要影响URI的可读性。
  • URI里的单词和单词之间有没有使用连接符
因为URL中的hostname不允许使用下划线,所以作者建议URI部分总是使用连字符-来连接多个单词。
  • 分页的设计是否恰当
分页参数分2种风格,可以按情况使用:

第一种,表示第3页,每一页50条:
page=3&per_page=50

第二种:表示从100条开始的50条:
offset=100&limit=50

前者page从1开始增长,后者offset从0开始增长。

前者对用户来说自由度较低,缓存命中率较高。
后者对用户来说自由度较高,缓存命中率较低。

上述翻页风格称为"相对位置",深度翻页性能差(可以自己扩展学习),如果数据集合有更新,则翻页时可能看到重复内容或者错过一些内容。

与之相对的是"绝对位置",即指定last_id之前的N条数据,下次使用新的last_id继续获取。(个人理解瀑布流页面比较适合)

  • 登录有没有使用OAuth2.0
很常见的认证规范,让用户通过大厂的账号系统登录,并授权给第三方获取用户信息的权限。

作者表达的意思是你如果对外提供API,应该提供OAuth2.0认证,这样第三方调用API时携带access_token,我们即可校验其是否有权使用API。

最后,作者描述了一下REST API的几个等级:

REST LEVEL0:使用HTTP
REST LEVEL1: 引入资源的概念
REST LEVEL2:引入HTTP动词(GET/POST/PUT/DELETE等)
REST LEVEL3:引入HATEOAS概念

本书是REST LEVEL2。

LEVEL3中的HATEOAS概念尚未普及,其思路是API返回的数据中应该包括下一步行为对应的URI是什么,客户端请求下一步的URI就可以得到进一步的数据。

比如文章列表的返回值:

{
	"articles": [
		{
			"title": "good",
			"uri": "https://api.example.com/v1/articles/12345",
			"rel": "article/detail"
		}
	
	]

}

每一篇文章的uri告知客户端如何获取文章详情,属于一种高度灵活。


  • 响应数据格式有没有使用JSON作为默认格式
越简单的东西越容易普及,JSON比XML简单的多,也满足需求,另外Javascript天然支持json。

客户端没有明确指定返回值格式的话,JSON应该作为默认的返回值格式。
  • 是否支持通过查询参数来指定数据格式
如果服务端支持多种返回数据格式,那么客户端可以指定。

通过get参数:
https://api.example.com/v1/users?format=xml

通过扩展名:
https://api.example.com/v1/users.xml

通过HTTP头部:
GET /v1/users
Host: api.example.com
Accept: application/xml

作者建议首先使用HTTP头部,因为更符合HTTP协议规范;其次使用查询参数,避免使用扩展名。
  • 是否支持不必要的JSONP
JSONP可以实现跨域HTTP调用,其原理是基于<script>加载一段服务端的javascript代码(自行扩展学习)。

API如果决定支持JSONP,可以在服务端判定客户端是否上传了callback参数,如果上传了就返回jsonp格式。

客户端为了区分不同的JSONP调用,需要为每个JSONP调用生成一个唯一的全局回调函数名,这一点Jquery可以帮我们实现。

出于正确性与安全性考虑,JSONP服务端返回时应该设置Content-Type: application/javascript而不是application/json,因为返回的是一段js函数调用代码。

作者提到,JSONP因为是作为一个script引入的,服务端可以通过2种方式返回错误信息:
1)在json响应体里放置error信息
2)在callback之外支持error_callback传参
  • 响应数据的内容能不能从客户端指定
有时调用方只需要部分信息,比如:用户信息接口只希望获取用户ID,这样可以节约通讯量。

此类接口可以通过类似fields的get参数来指定返回哪些信息:
http://api.example.com/v1/users/12345?fields=name,age

另外也可以提前准备几种返回值的组合,称为响应群(response group),比如:
http://api.example.com/v1/users/12345?group=basic_info
其中,basic_info表示返回用户的基础信息,例如name和age。
  • 响应数据中是否存在不必要的封装
作者出于rest风格原因,建议把错误码和错误信息放在http header里,而不是放在body里。

一个错误的例子:
HTTP/1.1 200 OK
{
	"error_code": 500,
	"error_msg": "参数错误",
	"data": {}
}
作者认为HTTP返回200,但内容却表达了500失败,这样很奇怪,很不rest。

rest风格更建议用下面这种方式:
HTTP/1.1 500 参数错误
{
	"data": {}
}
  • 响应数据的结构有没有尽量做到扁平化
不要在JSON中增加无意义的多余层级,尽可能扁平化。

一个错误的例子:

{
	"id": 12345,
	"name": "hahaha",
	"profile": {
		"birthday": "0203",
		"gender": "male",
		"language": ["zh", "en"]
	}
}

增加profile并没有带来什么价值,不如扁平化:
{
	"id": 12345,
	"name": "hahaha",
	"birthday": "0203",
	"gender": "male",
	"language": ["zh", "en"]
}

不仅访问起来方便,而且传输的内容也少了。
  • 响应数据有没有用对象来描述,而不是用数组
作者建议JSON返回值总是使用{}作为返回值的最外层,而不要直接返回数组[]。

正确的例子:
{
	"articles": [
		{"id": 1},
		{"id": 2}
		...
	]
}
错误的例子:
[
	{"id": 1},
	{"id", 2}
]

这样做有2个次要的理由:
1,因为从字面看,articles能直接表达数据的含义
2,客户端在处理JSON应答时,可以统一将最外层作为对象去解析,不需要为数组做适配。
3,有一些安全性考虑,后面会提到。

  • 响应数据的名称所选用的单词的意思是否和大部分API相同
对非英语母语的人,多模仿大厂使用的常见单词。
  • 响应数据的名称有没有用尽可能少的单词来描述
关于用户注册时间字段,

错误的例子:userRegistraionDataTime
这个单词太长了,很容易打错,也不容易记忆。

正确的例子:registeredAt
  • 响应数据的名称由多个单词连接而成时,连接方法在整个API里是否一致
有几种连接单词的方法:
1,user_id:蛇形法
2,user-id:脊柱法
3,userId:驼峰法

在JSON和Javascript中,都是建议使用驼峰法的,但是保持风格一致是最重要的。
  • 响应数据的名称有没有使用奇怪的缩写形式
尽量避免奇怪的缩写,比如timezone写成tz。

如果出于数据量大小的考虑而采用缩写,属于特殊情况。
  • 响应数据的名称的单复数形式是否和数据内容相一致
只为数组采用复数,比如friends。
其他情况使用单数。

一致的命名风格,API使用者会顺其自然,养成习惯。
  • 出错时响应数据中是否包含有助于客户端剖析原因的信息
出错时,响应信息应该包含2部分:
1,错误码
2,错误原因

作者建议使用http header返回错误码,每种错误码的含义如下:

1xx:消息
2xx:成功
3xx:重定向
4xx:客户端原因引起的错误
5xx:服务端原因引起的错误

而不是在JSON中设置一个error_code字段,因为http code本身就是这个意思。

当http code为4xx或者5xx时,需要进一步告知客户端错误原因,这时候有2种做法:
1,在http header里自定义一些头部信息,保存错误原因,例如:
X-ERROR-MESSAGE: params error
2,在body中返回JSON格式的错误信息:
{	
	"errors": [
		{
			"message": "参数fields错误"
		},
		{
			"message": "参数last_id错误"
		}
	]
}
作者建议采用第2种方式,因为可以描述多个错误原因,并且很多大厂都是这么做的。

  • 出错时有没有返回HTML数据
当服务端发生错误时,很多web框架会打印一个html错误信息页面。

对于API来说,当发生错误时也应该返回一个合法的JSON结构,因为客户端假设服务端返回JSON,返回HTML可能导致异常。
  • 有没有返回合适的状态码
这一条规则有点重复,主要是指返回适当的http code。
  • 服务器端在维护时有没有返回503状态码
当服务器需要停机维护时,按照Google爬虫的建议,应该返回503错误码,并且在header中告知维护的结束时间:

503 Service Temporarily Unavailable
Retry-After: Mon, 2 Dec 2013 03:00:00 GMT

这遵循HTTP1.1规范,客户端需要实现逻辑去识别这个情况,但是至少google爬虫会去理解这些信息。
  • 有没有返回合适的媒体类型
有的HTTP客户端会校验应答中的Content-Type字段,因此服务端如果返回的是JSON,那么就应该返回Content-Type: application/json而不是Content-Type: text/html,这样避免一些严格的客户端出现解析失败。
  • 必要时能不能支持CORS
浏览器有同源策略,禁止跨域Ajax请求。

API可以支持CORS跨域资源共享,比如http://www.example.com请求http://api.example.com的API时应该携带请求的来源:
Origin: http://www.example.com

服务端只允许某些来源的跨域调用,如果Origin合法就在返回中携带:

Access-Control-Allow-Origin: http://www.example.com
或者
Access-Control-Allow-Origin: *

浏览器看到这样的应答,就会把ajax请求正常执行完成,否则会报告ajax调用失败。

对于一些特殊场景,浏览器会采用"事先请求"的方式,先通过一个OPTION方法调用到对应的接口来试探服务端是否返回Access-Control-Allow-Origin,如果没有返回则不发起真正的数据请求。

CORS客户端默认不会传输cookie,我们在发起ajax前设置XHTTPRequest.withCredentials=true,并且服务端必须返回header:Access-Control-Allow-Credentials: true,否则这次ajax调用将报告失败。
  • 有没有返回Cache-Control、ETag、Last-Modified、Vary等首部以便客户端采用合适的缓存策略
缓存模型分2种:
1,过期模型:Expires、Cache-Control
2,验证模型:Last-Modified、ETag

过期模型是指,浏览器在过期之间直接使用本地缓存文件,下面是一个例子:
Expires: Fri, 01 Jan 2016 00:00:00 GMT
Cache-Control: max-age=3600

Cache-Control是HTTP1.1协议出现的,Expires是HTTP1.0,前者优先级更高。
并且HTTP1.1协议也规定,缓存时间不应超过1年,但实际上客户端可能没有遵循这个约束。

验证模型是指,客户端照常发起请求,但在header中携带附加条件,服务器根据附加条件判断若数据没有修改则返回304,客户端直接使用本地缓存即可,否则返回200并携带内容。

下面是个例子,

请求:
GET /v1/users/12345
If-Modified-Since: Tue, 01 Jul 2014 00:00:00 GMT
If-None-Match: "ff39b31e285573ee373af0d492aca581"
应答:
HTTP/1.1 304 Not Modified
Last-Modified: Tue, 01 Jul 2014 00:00:00 GMT
ETag: "ff39b31e285573ee373af0d492aca581"

需要注意ETag分为强验证和弱验证:
强验证是指资源的真实内容完全不能变,弱验证是指逻辑上资源没有改变即可。
  • 不想缓存的数据有没有添加Cache-Control: no-cache首部信息
如果不希望被客户端缓存,可以指定Cache-Control: no-cache。

如果你的API前面存在反向代理缓存,可以额外声明Cache-Control: no-store,这样代理服务器也不会缓存数据了。

客户端可能多次请求同一个API,但是请求的http header不同,导致返回的内容结构不同,比如:

客户端携带 Accept: application/json,则服务端返回的是JSON。
客户端携带 Accept: application/xml,则服务端返回的是XML。

如果反向代理根据URI缓存,则会导致无法根据客户端的要求返回正确格式,此时我们API应该在返回值里携带Vary: Accept,这样缓存服务器会为不同的Accept分别缓存。
  • 有没有对API进行版本管理
一般API会不断的迭代功能,有时会出现无法向下兼容的情况。

通常老客户端会依旧使用老版本的API,新客户端使用新版本的API,并在合适的时机完全下线掉老版本的API。
  • API版本的命名有没有遵循语义化版本控制规范
作者介绍了语义化版本控制,通常版本号是a.b.c这样的,分别表示主版本号,次版本号,补丁版本号。

1,如果软件API没有变更,只是修复服务端BUG,那么就增加补丁版本号
2,对软件API实施了向下兼容的变更,增加次版本号
3,对软件API实施了不向下兼容的变更时,增加主版本号

  • 有没有在URI里嵌入主版本编号,并且能够让人一目了然
对于Web API来说,作者建议在URI中嵌入主版本号即可,例如:
http://api.example.com/v1/users

整体原则是,尽量保持向下兼容,这样URI不会改变,老用户不需要迁移。

还有一个问题是,如果不带版本号访问应该套用哪个版本的接口?谷歌的做法是使用最老版本,这样就不会影响那些老用户了。
  • 有没有考虑API终止提供时的相关事项
停止API时应该让API返回410错误码,它代表接口不再对外公开。

如果客户端是公司的产品,则可以强制客户端升级,避免停止API导致用户无法使用。
  • 有没有在文档里明确注明API的最低提供期限
错误的例子:

该API 2018-06-01下线,请注意迁移。

正确的例子:

该API将继续维护12个月,请您尽快迁移。

错误的例子把期限说的太死了,而正确的例子则留了余地(比如再维护额外的12个月),使用者的感受会好很多。

  • 有没有使用HTTPS来提供API
HTTP是明文传输,可以被任意劫持。

HTTPS采用SSL通讯,保障数据安全。但是HTTPS要求客户端严格验证证书的真伪,否则中间人可以伪造证书实施攻击。

另外,作者强调HTTPS会导致请求变慢,但相比安全性仍然是值得做的。
  • 有没有认真执行JSON转义
一个这样的JSON,如果按照Content-Type: text/html被浏览器解析,其中的js就会被执行:
{"username": "<script>alert(1)</script>"}

好在大多数JSON库默认会在编码时会进行适当的转义,因此最终得不到执行:
{"username":"<script>alert(1)<\/script>"}

所以API应该返回完整转义过的JSON串,为了稳妥也应该设置Content-Type: application/json,避免浏览器将JSON当做html解析,导致攻击者得以实施XSS攻击。
  • 能不能识别X-Request-With首部,让浏览器无法通过SCRIPT元素读取JSON数据
假设https://api.example.com/v1/users/me是获取当前登录用户信息的接口。

攻击者在自己的网站通过<script src="https://api.example.com/v1/users/me" language="vbscript"></script>可以实施攻击。
因为接口返回的是JSON,而加载时指定了vb语言肯定是无法解析成功的,因此攻击者通过设置window.onerror = function(err) {}即可被浏览器回调,从而从错误信息中获取到用户信息。

解决这个问题的方法是禁止通过script标签调用API,判定方法就是服务端判断请求中是否有Header X-Requested-With,因为Ajax请求默认会携带这个header而script不会。
  • 通过浏览器访问的API有没有使用XSRF token
XSRF称为跨站点请求伪造。

攻击者在自己的网站做一个form表单,提交地址写为目标网站的表单提交地址。当受害者访问攻击者网站时,攻击者通过javascript自动提交form表单(form.submit),即可完成向目标网站的提交(想象这是一个转账表单)。

form表单提交不受同源策略(跨域)影响,因此可以达成上述攻击手段。

解决方法就是在表单里生成一次性的CSRF token放在隐藏字段中,并把token种植在用户cookie中,在用户提交表单到API时可以检查表单token和cookie中的token一致,则允许提交。
  • API在接收参数时有没有仔细检查非法的参数(负数等)
作者以减少用户积分的API为例,如果传入一个负数积分,会导致减法变成加法,导致用户积分越来越多。

所以API需要严格校验参数是否合法。
  • 有没有做到即使请求重复发送,数据也不会多次更新
作者其实就是想表达幂等性,举了一个支付系统的例子,就不详细描述了。
  • 有没有在响应消息里添加各种增强安全性的首部
有很多header是作者建议总是加在API响应头里的,可以给浏览器很多建议,提升安全等级,就不一一描述了。

比较重要的一点是set-cookie时的安全问题:
1)Secure属性:表示cookie只能在访问https链接时才能被发送给服务端,这样可以彻底避免cookie被攻击者在网络中嗅探到。
2)HttpOnly属性:cookie仅能供HTTP调用时使用,而不允许javascript直接获取cookie,这样可以避免网站出现XSS漏洞的时候,攻击者通过JS代码把用户的会话cookie盗走。
  • 有没有实施访问限速
限速是为了保护API服务,避免超过负载。

限速一般是针对每个用户的,限速的单位是多少分钟内最多访问多少次。

从实际存储上可以采用Redis,key的数量大概是"API的数量 * 用户数量"。

API超出限速应该返回429 Too Many Requests的http code,最好还能给出Retry-After告知多久后可以继续使用。

  • 对预想的用例来说限速的次数有没有设置得过少
一般来说,应该为开放的API开发一套dashboard管理后台,从而可以灵活的为不同的用户设置不同的限速值,以及查看实时速率以及剩余调用次数等信息。

购买链接

Web API设计与开发