《Web API 的设计与开发》读后总结

前言

此书为[日]水野贵明著,之前已经通读了一遍,收获不少,为了复习与总结,这里便整理一份笔记,以便忘记的时候查找。

第一章:什么是Web API

REST与Web API

REST一词经常以”REST API”的形式出现。一般而言,人们认为它是指”能够通过HTTP协议进行访问,得到XML或JSON格式的返回数据的API”。而REST一词一般是有两种意思:

  • 符合REST架构风格的Web服务系统。
  • 符合RPC风格的XML(or JSON)+HTTP接口的系统(不适用SOAP)。

书中的API设计思想侧重于第2条,旨在API更加亲切易懂,比如搜索操作时,没有在URI中强制使用名词,而是使用search;在URI中添加版本号,而不是Header中。

LSUD vs SSKD

  • LSUD(Large Set of Unknown Developers)
  • SSKD(Small Set of Known Developers),有时使用REST这种基于资源的思维方式不能完全满足需求,还需要引入策略编排层这样的思维方式。

第二章:端点的设计与请求的形式

端点的概念:

在Web API的语境里,端点是指用于访问的API的URI。

端点的设计规范:

  • 短小便于输入

    http://api.example.com/service/api/search不如http://api.example.com/search,不应该出现重复的词,也不应该使用service这种含义过于广泛的词。

  • 人可以读懂

    不要使用缩写,甚至将products缩写为prod,即使是SSKD,也应尽量减少缩写。

    合适的语义,使用合适的单词,拼写错误是不应该出现的。

  • 不能大小写混用

  • 修改方便

    如/items/12346,明显修改12346就可以查看其它商品信息。

  • 不会暴露服务端的架构

    比如,php或jsp结尾。

  • 规则统一

    URI采用一致的风格

HTTP方法和端点

URI和HTTP的关系可以认为是操作对象和操作方法的关系。如果把URI当做API(HTTP)的“操作对象=资源”,HTTP方法则表示“进行怎样的操作”。

  • GET

    表示获取信息,最为常用。一般不会修改已有资源(已读/未读,最后访问日期等属性属于例外)。

  • POST

    一般认为POST方法用于更新信息,其实这样的理解存在一些偏差。POST方法的初衷是用于向服务器注册新建的资源。信息的更新和删除等操作应使用其他HTTP方法。由于HTML4.0的表单中method属性只支持GET和POST两种方法,因此使用表单从浏览器提交信息时,以及更新删除,都使用POST方法了。虽然在HTML5的草案中加入了表单允许使用PUT以及DELETE方法的规范,但最终还是讲该内容删除了。由于Web API基本不涉及表单通过浏览器进行访问,所以一般使用PUT和DELETE更容易理解。

  • PUT

    和POST方法相同,都可用与对服务端的信息进行更新,但二者URI的指定方式有所不同。POST方法发送的数据“附属”于指定的URI,附属表示从属URI之下。以文件系统为例,把文件放入目录后,文件就成了目录的附属部分。虽然HTTP协议定义了当所指定的资源不存在时,可以通过PUT操作发送数据,生成心得资源,但Web API一般只用PUT方法来更新数据,而一般会使用POST来生成新的资源。PUT会用发送的数据完全替换原有的资源信息,如果只是更新资源的某部分数据,可以使用PATCH方法来实现。

  • DELETE

    删除资源。

  • PATCH

    PATCH和PUT相同,用于更新指定的资源。在数据量较高的情况下,会有显著的效果。

X-HTTP-Method-Override

由于HTML的表单规范只支持GET和POST,或是其它开发客户端中只能有GET和POST。所以要利用POST方法将真正想要使用的HTTP方法以元数据信息的形式发送给服务器。有两种方式:

  • 通过名为X-HTTP-Method-Override的Header来实现,推荐使用。

    1
    2
    3
    POST /v1/users/123 HTTP/1.1
    Host: api.example.com
    X-HTTP-Method-Override: DELETE
  • 通过传递表单参数_method来实现。

    user=testuser&_method=PUT使用application/x-www-form-urlencodedMedia Type。

这里看了一下,Django没有支持这种功能,需要自己写中间件或者三方库来实现。django-method-override。而DRF这里的文档说,在3.3.0版本之前是支持的,后来将其从核心功能中移除。DRF文档

动态相关的端点举例

目的 端点 方法
编辑动态信息 http://api.example.com/v1/updates/:id PUT
删除动态信息 http://api.example.com/v1/updates/:id DELETE
发表动态信息 http://api.example.com/v1/updates POST
获取特定用户的动态信息 http://api.example.com/v1/users/:id/updates GET
获取好友的动态列表 http://api.example.com/v1/users/:id/friends/updates GET

访问资源的端点设计注意事项

  • 使用名词的复数形式

    极力避免使用动词,因为HTTP协议原本就是用URI来表示资源,用HTTP方法来表示对资源所进行的操作。

  • 注意所用的单词

    多看大公司的开放API范例,查阅ProgrammableWeb,明白那些具有相近意义的单词哪个更加合适。

  • 不适用空格及需要编码的字符

  • 使用连接符-来连接多个单词

    相对于驼峰发和蛇形法,这种脊柱法对于SEO更加友好。不过应尽量避免使用多个单词,而是使用路径划分,如popular_users不如users/popular,或者将一部分内容作为查询参数,让URI变得更短。

相对位置获取数据大分页时效率低下

使用绝对位置,事先记录下当前已获得的数据里最后一条数据的ID、时间等信息,然后再指定“该ID之前的所有数据”或“该时刻之前的所有数据”。

查询参数还是使用路径?

  • 是否是表示唯一资源所需的信息,比如用户ID。
  • 是否可以省略,如page等分页参数,一般都会设置默认值。

登录与OAuth2.0

什么是OAuth?

假设带有用户注册功能的在线服务A(Facebook)对外公开了API,在线服务B便可以使用这些在线服务A的API提供的各种功能。在这种情况下,当某个已在Facebook里注册的用户需要使用你的在线服务时,你的在线服务就回希望访问Facebook来使用该用户在Facebook中注册的信息。这时,判断是否允许你的在线服务使用该用户在Facebook里注册的信息的机制就是OAuth。

OAuth2.0里的4中类型的交互模式用于访问资源的许可,称为Grant Type。
Grant Type 作用
Authorization Code 适用于在服务端进行大量处理的Web应用
Implicit 适用于只能手机应用及使用JavaScript客户端进行大量处理的应用
Resource Owner Password Credentials 适用于不适用服务端(网站B)的应用
Client Credentials 适用于不以用户为单位来进行认证的应用

Resource 的模式能够应用于公司内部所开发的客户端应用中。

SSKD应用尽量减少API调用次数

如首页包含“人气商品”,“推荐商品”,“用户信息”等信息,这样需要访问不同的API,效率很低,因此可以把应用主页所需要的显示信息归集到一个API中,提高了客户端的便捷性。为了完成一次任务需要多次访问API,这样的API设计叫“Chatty API”,不但会加大网络流量的消耗,还会增加客户端的处理工序。

HETEOAS和REST LEVEL3 API

等级制度

  • LEVEL0:使用HTTP
  • LEVEL1:引入资源概念
  • LEVEL2:引入HTTP动词
  • LEVEL3:引入HATEOAS概念

HETEOAS: (Hypermedia As The Engine Of Application State),超媒体即应用状态引擎。意思就是在API返回的数据中包含下一步要执行的行为、要获取的数据等URI的链接信息,客户端只要看到这些信息,就能知道接下来需要访问什么端点,比如DRF中的下一页链接。

优点:使URI得更改变得容易。

面向SSKDs的API,需要根据实际需求采用。面向LSUDs的API,这个概念还没有得到广泛的普及。

第三章:响应数据的设计

JSONP

概念

JSONP是一种将JSON传递给浏览器的方式,是JSON with Padding的缩写。一般形式如下:

callback({"id": 123, "name": "Saeed"})

留坑…

封装是否必要

比如返回的格式为:

1
2
3
4
5
6
7
8
9
{
"meta": {
"code": 0,
"message": 'OK',
},
"data": {
...
}
}

如果结构形式统一,在客户端就更容易抽象化处理。实际上由于封装的做法会显得冗长,并不值得实现。因为Web API基本上都是用HTTP协议,HTTP已经帮你完成了封装的工作。HTTP协议里引入首部的概念,在首部中可以放入各种数据信息,也可以通过状态码来确认无误地判断是否发生了某种错误等,并将更详细的错误信息放入HTTP首部返回。而如果对API数据进行封装,发生错误时,用户所获得的状态依然是200,这就无法通过状态码来判断处理是成功还是失败。这样的做法没有正确地运用好HTTP协议的机制。这可能会导致客户端的处理发生混乱,这种情况是无论如何都要明令禁止的。而且通用的HTTP客户端程序往往会首先根据状态码来判断请求的处理是否成功,如果出错时返回200,客户端就无法利用通用的错误分类,增加处理负担。

扁平化还是层级?

Google的JSON Style Guide使用了模棱两可的陈述:“虽然要尽可能使用扁平化方式,但在某些情况下使用层级形式反而更容易理解。”

  • 情景一:

    • 层级

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      {
      "id": 3342124,
      "message": "Hi!",
      "sender": {
      "id": 3456,
      "name": "Tom"
      },
      "receiver": {
      "id": 12877,
      "name": "Bob"
      }
      }
    • 扁平化

      1
      2
      3
      4
      5
      6
      7
      8
      {
      "id": 3342124,
      "message": "Hi!",
      "sender_id": 3456,
      "sender_name": "Tom",
      "receiver_id": 12877,
      "receiver_name": "Bob"
      }

    其中”sender”和”receiver”描述了相同的用户这一数据结构,因此层级形式较好,因为客户端可以将各个数据作为用户这一相同的数据来处理。

  • 情景二:

    • 层级

      1
      2
      3
      4
      5
      6
      7
      8
      {
      "id": 2345,
      "name": "Tom",
      "profile": {
      "birthday": 3322,
      "gender": "male"
      }
      }
    • 扁平化

      1
      2
      3
      4
      5
      6
      {
      "id": 2345,
      "name": "Tom",
      "birthday": 3322,
      "gender": "male"
      }

    这种情况,层级导致JSON数据尺寸变大,而且两者看起来没有什么区别,所以应该扁平化。

序列与格式

比如一个好友列表的URI /friends

  • 序列原封不动返回

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    [
    {
    "id": 234,
    "name": "Tom"
    },
    {
    "id": 235,
    "name": "Bob"
    }
    ]
  • 用对象进行封装

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    {
    "friends": [
    {
    "id": 234,
    "name": "Tom"
    },
    {
    "id": 235,
    "name": "Bob"
    }
    ]
    }

看上去第二种方式显得有些冗余,因为URI中已经使用了”friends”来表示好友。而且又显得冗长。但是这样写有三个优点:

  • 更容易理解响应数据表示什么

  • 响应数据通过对象封装实现了结构统一

    顶层数据不会根据API的不同而不同,客户端无需适配序列或是对象,免除麻烦。

  • 可以避免安全方面的风险

    顶层数据中使用JSON序列,可能会导致名为JSON注入的安全隐患,风险很大。

返回数据的个数

返回数据的名称
  • 使用多数API中使用的表示相同含义的单词

  • 通过尽可能少的单词来表示

    比如注册时间registrationDateTime不如registeredAt

  • 使用多个单词时,整个API中连接单词的方法要统一

    一般使用驼峰法

  • 尽可能不用奇怪的缩略语

  • 注意单复数形式

日期的格式
格式名 示例
RFC 822 Sun, 06 Nov 1994 08:49:37 GMT
RFC 850 Sunday, 06-Nov-94 08:49:37 GMT
ANSI C的asctime()格式 Sun Nov 6 08:49:37 1994
RFC 3339 2015-10-12T11:30:22+09:00
Unix 时间戳(epoch)秒 1396821803

一般采用RFC 3339格式。如果考虑国际化产品,则推荐使用”+00:00”。在RFC 3339中使用UTC时,可以通过”Z”来标记。2015-11-02T13:00:12+00:00 == 2015-11-02T13:00:12Z。如果是”-00:00”,则表示时区不明。

如果面向SSKD,也可使用Unix时间戳,因为其易于保存和比较,不过会调试稍微麻烦。

有时需要在HTTP首部添加时间,这个格式不支持RFC 3339,只支持上表中的前三项。

详细的出错信息

状态码属于通用的错误描述,在表示同各个API的内容相关的错误时显得力不从心。所以要返回额外的错误信息。

  • 一种是通过首部返回

    1
    2
    3
    X-MYNAME-ERROR-CODE: 2013
    X-MYNAME-ERROR-MESSAGE: Bad authentication token
    X-MYNAME-ERROR-INFO: http://docs.example.com/api/vi/authentication
  • 将出错信息放入消息体

    1
    2
    3
    4
    5
    6
    7
    {
    "error": {
    "code": 2013,
    "message": "Bad authentication token",
    "info": "http://docs.example.com/api/vi/authentication"
    }
    }

对于客户端来说第二种消息体的方案比较容易处理,所以优先选择后者。错误码一般4位与HTTP状态码区分,1字头表示通用错误,2字头表示用户错误等。有时提示信息里同时包含面向非开发人员和开发人员的信息。

1
2
3
4
5
6
7
8
{
"error": {
"developerMessage": "面向开发人员信息",
"userMessage": "面向用户的信息",
"code": 2013,
"info": "http://docs.example.com/api/vi/authentication"
}
}

有时程序返回500、503或404等错误时,默认会返回HTML或者是ContentType: text/plain格式的信息,会导致客户端崩溃等问题。所以即使这种情况下,服务端应该努力保证发生错误、负载过高、访问的端点不存在等情况下也能以合适的格式返回数据。

如果服务端不得已进行维护返回503状态码的同时,还应该返回Retry-After来告知客户端重试的时间。

1
2
503 Service Temporarily Unavailable
Retry-After Mon, 2 Dec 2013 03:00:00 GMT
需要返回意义不明确的信息时

比如用户在登录时需要输入邮箱和密码,如果登录失败,返回邮箱不存在还是返回邮箱存在但密码错误,还是返回用户已冻结?虽然详细信息对于用户而言显得非常友好,但从另一方面来说,也为非法登录和爬虫提供了友好的信息。所以服务端在这种情况下一般只提供非常少量的信息,让无法正常登陆的用户通过重置密码等手段重新登录。不过这样不太方便调试,所以也可将逻辑分开,在开发时返回详尽信息,生产环境返回不明确的信息。

第四章:最大程度地利用HTTP协议

使用HTTP协议规范的意义

HTTP协议等很多互联网协议都是由名为RFC的规范文档来定义的。

正确使用状态码

首位数字大概含义
状态码 含义
1字头 消息
2字头 成功
3字头 重定向
4字头 客户端原因引起的错误
5字头 服务端原因引起的错误
主要的HTTP状态码
状态码 名称 说明
200 OK 请求成功
201 Created 请求成功,新的资源已创建
202 Accepted 请求成功
204 No Content 没有内容
300 Multiple Choices 存在多个资源
301 Moved Permanently 资源被永久转移
302 Found 请求的资源被暂时转移
303 See Other 引用他出
304 Not Modified 自上一次访问后没有发生更新
307 Temporary Redirect 请求的资源被暂时转移
400 Bad Request 请求不正确
401 Unauthorized 需要认证
403 Forbidden 禁止访问
404 Not Found 没有找到指定的资源
405 Method Not Allowed 无法使用指定的方法
406 Not Acceptable 同Accept相关的首部里含有无法处理的内容
408 Request Timeout 请求在规定时间内没有处理结束
409 Conflict 资源存在冲突
410 Gone 指定的资源已不存在
413 Request Entity Too Large 请求消息体太大
414 Request-URI Too Long 请求的URI太长
415 Unsupported Media Type 不支持所指定的媒体类型
429 Too Many Requests 请求次数过多
500 Internal Server Error 服务器端发生错误
503 Service Unavailable 服务器暂时停止运行
状态码详解及使用场景:
  • 2字头 成功

    • 201 “Created”

      POST请求的场景,表示创建了一个资源,上传了图片,或是添加了用户。

    • 202 “Accepted”

      表示“Accepted”,在异步处理客户端请求时,它用来表示服务器端已接收了来自客户端的请求,但处理尚未结束。

      • 通常的使用场景是文件格式转换、处理远程通知耗时的场景中。
      • 以LinkedIn的参与群组讨论的API为例,我们知道如果成功参与讨论并发表意见,服务器通常会返回201;但如果需要得到 群主确认,所发表的意见就无法立即在页面上显示出来,这时服务器就需要返回202状态码。从广义上来看,也属于异步处理,但是与程序里所说的异步不是一个概念。

      另外,使用Box的API来下载文件时,如果文件尚未准备好,服务器会返回202,还会在首部中添加Retry-After写入处理完成所需时间。

    • 204 “No Content”

      响应消息为空时会返回该状态码。

      • 使用DELETE方法删除数据时。

      关于204有一些争论,这里笔者给出的建议是PUT或PATCH请求时,服务端返回200并将数据同时返回,使用DELETE请求时,返回204。这样,无论在何种情况下,都可以从服务器端返回的数据得知修改操作正确执行。病情也能在PUT/PATCH操作后同时获得ETag信息。

      这里有个疑问,实际业务中有时需要“假删除”,此时应该返回204还是200?

  • 3字头 添加必要的处理

    常用来描述重定向操作。在定义了HTTP1.1的RFC 2068中,用于重定向的状态码只有301和302。301表示请求内容已从当前位置移动到了其他地方,而302则表示请求内容只是临时移动到了别处,而且使用的HTTP方法不会在访问重定向的URI时发生变化(如使用POST方法的话,在重定向后依然会使用POST方法来访问重定向的地址)。不过大部分浏览器却采用了与协议相反的设计,用GET方法来访问重定向后的地址。由于这个原因,在RFC 2616里,人们又新定义了303和307状态码。303定义了无论在重定向之前使用什么HTTP方法访问,都允许在请求完成后用GET方法继续访问。即便如此,现在依然有很多重定向操作会返回302状态码。另外RFC 7238里还定义了308状态码。307和308输入302和301的修正版,定义更加严密。302和301允许访问方法从POST变更为GET,307和308则不允许任何HTTP方法在访问过程中发生变更。

    API中应极力避免返回重定向类状态码。

    • 300 “Multiple Choices”

      当有多个分支可供客户端选择时,服务端会返回该状态码。API使用场景可能性很低,在文件存储类服务里,对于客户端请求的某个键值,如果存在多个数据库,有时会返回该状态码。

    • 304 “Not Modified”

      表示客户端上次获取的数据至今为止没有发生更新。当服务端返回304时,整个响应消息体为空。

  • 4字头 客户端请求发生问题

    服务端无法理解客户端发送的请求,或虽然服务端能理解但请求没有被执行。

    • 400 “Bad Request”

      它表示“其他错误”,无法找到其它满足要求的状态码时返回,比如参数错误。

    • 401 “Unauthorized”

      表示认证(Authentication)类型的错误,“识别前来访问的是谁”。

    • 403 “Forbidden”

      表示授权(Authorization)类型的错误 ,“赋予特定用户执行特定操作的权限”。

    • 404 “Not Found”

      有时不明确是URI不存在还是资源不存在,所以一般会额外返回其它详细的说明。

    • 405 “Method Not Allowed”

      客户端使用的HTTP方法不被服务器端允许。

    • 406 “Not Acceptable”

      API不支持客户端指定的数据格式时服务器端所返回的状态码。

    • 408 “Request Timeout”

      客户端发送请求至服务端所需的时间过长时,触发服务端的超时处理。

    • 409 “Conflict”

      状态码用于表示资源发生冲突时的错误。比如通过指定ID等唯一键值信息来调用注册功能的API,当这样的API创建数据时,倘若已有相同ID的数据存在,就回导致服务端返回409状态码告知客户端该邮箱地址或ID已被使用。

    • 410 “Gone”

      和404相同,表示资源不存在。只是410状态码表示资源曾经存在但目前已经消失了,因此服务端常在访问数据被删除时返回该状态码。但是为了该状态码,服务器必须保存数据已被删除的信息,有些邮箱地址搜索用户信息的API中,从保护个人信息的角度来说,返回410状态的做法会受到质疑。

    • 413 “Request Entity Too Large” 和 414 “Request-URI Too Long”

      413表示请求消息体过长,如上传文件过大。

      414表示请求首部过长而引发的错误,如查询参数过长。

    • 415 “Unsupported Media Type”

      和406类似,表示服务器端不支持客户端请求首部Content-Type里指定的数据格式。区别在于,406一般和GET操作一起使用,415和POST、PUT以及PATCH等方法请求的消息体数据格式不被服务器端支持。例如在只接受JSON格式的请求的API里放入XML格式的数据并向服务器发送,或在Content-Type首部里指定application/xml,都会导致该类型的错误。

    • 429 “Too Many Requests”

      访问次数超过了所允许的范围。

  • 5字头 服务器端发生问题

    • 500 “Internal Server Error”

      服务端代码存在bug,会返回该类型的错误。应该监控错误日志,防止再次发生。

    • 503 “Service Unavailable”

      服务端暂时不可用,服务器维护,或者自身负载过高。

缓存与HTTP协议规范

HTTP协议中,缓存处于可用的状态时称为fresh(新鲜)状态,而处于不可用的状态时则称为stale(不新鲜)状态。

过期模型

预先决定响应数据的保存期限,当到达期限后机会再次访问服务端来重新获得所需的数据。

一个方法是用Cache-Control响应首部,另一个使用Expires响应首部。

1
2
Expires: Fri, 01 Jan 2016 00:00:00 GMT
Cache-Control: max-age=3600

Expires使用RFC 1123中定义的时间格式。HTTP1.0就存在,Cache-Control是HTTP1.1中定义的。有时需要制定一个遥远的日期,使得缓存始终生效,但HTTP1.1规定,不允许设置超过1年以上的时间。两者同时定义时,Cache-Control优先。max-age是根据首部中的Date算的。根据HTTP协议的规定,除了5字头错误等几个特殊情况以外,所有的HTTP消息都必须添加Date首部。描述日期的HTTP首部信息里,只能使用GMT(格林尼治标准时区)作为时区。

验证模型

轮询当前保存的缓存数据是否为最新数据,并只在服务器端进行数据更新时,才重新获取新的数据。

虽然这么做没有减少网络通信的开销,但假设客户端缓存的数据过大,那么此时缓存的作用就体现出来了。这种方式,服务器需要知道“客户端当前保存的信息的状态”,为此需要用到更新日期或实体标签(Entity Tag)作为指标。两者分别填充在Last-ModifiedETag响应消息首部返回给客户端。

1
2
Last-Modified: Tue, 01 Jul 2014 00:00:00 GMT
Etag: "jfkeiwjpii189u98jljdfioj822adf"

ETag如何生成取决于服务器端的实现。

客户端使用最后更新日期执行附带条件的请求时,会用到Modified-Since首部。使用实体标签时,会用到If-None-Match首部。

1
2
GET /v1/users/12345
If-Modified-Since: Tue, 01 Jul 2014 00:00:00 GMT
1
2
GET /v1/users/12345
If-None-Match: "jfkeiwjpii189u98jljdfioj822adf"

服务端检查是否有更新,如果有更新,则返回200和更新后的资源,同时添加最后更新日期或实体标签。未更新,则返回304,响应消息体为空。

如果使用验证模型,并且更新的资源是某个特定的资源,则返回资源自身的最后更新日期;如果是列表信息的多个资源的话,则要使用其中最后更新的资源的最后更新日期。Django中的Etag相关。

ETag有强验证和弱验证两个概念。

  • 强验证ETag: Etag: "jfkeiwjpii189u98jljdfioj822adf",服务端同客户端数据不能有一个字节的差别,必须完全一样。
  • 弱验证ETag:Etag: W/"jfkeiwjpii189u98jljdfioj822adf",只要从资源意义的角度来看没有发生变化,就可以视为相同的数据。例如Web页面的广告信息,虽然每次看到广告的内容会有所改变,但它们依然是相同的资源。
启发式过期

客户端自己寻找服务端资源的规律,所以服务器如果不能返回“将缓存数据保存多久”的信息,那么应该返回Last-Modified等首部信息来告知客户端,努力减少客户端不必要的访问,这一点非常重要。

不希望实施缓存

在实时性要求比较高的场景中,不希望客户端进行缓存。使用Cache-Control首部。

1
Cache-Control: no-cache

除此之外,如果Expires首部里写入的过去的日期或不正确的日期格式,客户端也不会进行缓存操作。但不同浏览器可能发生不同的行为,所以此方法并不建议。

no-cache严格意义来讲,不是“不缓存”的意思,而是表示至少“需要使用验证模型来验证”。如果不希望含有机密信息的数据在代理服务器上保存,就可以在Cache-Control首部里使用no-store并返回。

使用Vary来指定缓存单位

在实施缓存时可能还需要同时指定Vary首部。在实施缓存时,Vary用于指定除URI外使用哪个请求首部项目来确定唯一的数据。因为即使URI相同,获取的数据有时也会因请求首部内容的不同而发生变化。

HTTP里有这样一种机制:根据由Accept开始的一系列请求首部值的不同,响应消息的内容也会发生变化。该机制称为服务器驱动的内容协商。比如API可以通过支持Accept-Language首部指定客户端能接受的自然语言,并据此切换响应数据里包含的语言信息。这时可以使用Vary首部来判断哪个请求首部需要实施缓存操作。

1
Vary: Accept-Language

一般而言,Vary首部用于HTTP经由代理服务器进行交互的场景,特别是在代理服务器拥有缓存功能时。但是有时服务器端无法知晓客户端的访问是否经由代理服务器,这种情况下就需要用到服务器驱动的内容协商机制,Vary首部就成了必选项。比如如果希望在查看用户代理(User Agent)信息后对返回的数据内容进行更新,就需要指定User-Agent首部。

1
Vary: Accept-Language,User-Agent

在API中,返回的数据信息根据用户代理的不同而变化的情况非常少见,但我们仍需要考虑这样的情况:当使用智能手机对普通的Web页面进行访问时,即使URI相同,网站也需要返回和访问终端相匹配的内容。因此当Google的网络爬虫访问服务器端时,如果服务器端会根据URI以外的信息改变返回的内容,则推荐添加Vary首部。

Cache-Control 首部

缓存操作指令及含义

指令名称 含义
public 代理服务器处保存的缓存可以在不同用户之间共享
private 每个用户的缓存数据必须各不相同
no-cache 缓存数据需要通过验证模型来确认
no-store 不需要进行缓存
no-transform 代理服务器不可变更响应数据的媒体类型或其他相关内容
must-revalidate 不管何时都需要向原始服务器进行再次验证
proxy-revalidate 代理服务器需要向原始服务器进行再次验证
max-age 表示缓存数据处于新鲜状态的时间
s-maxage 和max-age一样,但只用于中继服务器

通过stale-while-revalidate=600这样指定秒数,那么即使代理服务器超过了max-age指定的时间,其内部也能异步进行缓存验证,并指定在一定的时间内允许将缓存数据经由响应消息返回。换言之,在指定了max-age=600,stale-while-revalidate=600的情况下,虽然数据维持新鲜状态的时间只有10分钟,但在随后的10分钟内,缓存服务器也能处理来自客户端的请求,并将所保存的缓存数据直接返回给客户端。与此同时,代理服务器还会异步地向原始服务器发起缓存验证的询问。也就是说,客户端最长可以在20分钟内接收到缓存的数据,使得缓存的数据不会因为突然变得到期而不可用。另外,在缓存到期时,这样做还能异步地完成缓存的交互更新,从而更有效率地对客户端的访问做出响应。

由于某种原为无法访问原始服务器时,可以将stale-if-error指令指定为一定的秒数,允许在该段时间内代理服务器直接将所保存的不新鲜缓存返回给客户端。使用该指令的话,万一突发时间导致宕机,直接通过代理服务器和客户端交互,至少还能够在某段时间内不中断客户端的访问。

媒体类型的指定

具有代表性的媒体类型,也就是Content-Type
媒体类型 数据格式
text/plain 纯文本
text/html HTML文件
application/xml XML文件
text/css CSS 文件
application/javascript JavaScript
application/json JSON文件
application/rss+xml RSS域
application/atom+xml Atom域
application/octet-stream 二进制数据
application/zip zip文件
image/jpeg JPEG图像
image/png PNG图像
image/svg+xml SVG图像
multipart/form-data 多个数据组成的Web表单数据
video/mp4 MP4动画文件
application/vnd.ms-excel Excel文件

顶层类型名称中application和text非常容易混淆。比如XML文件的媒体类型由RFC 3023定义,根据该协议,text/xml媒体类型用于表示(没有XML背景知识的用户)能够理解的XML,而API返回的数据中应该不会存在这样的XML文件,因此使用application/xml更加合理。

除了由于历史原因而一直使用text作为顶层类型的text/css和text/html之外,某数据格式即使能够作为文本数据打开,但如果只有知道该数据格式的人才能理解,那么其媒体类型也依然需要用application作为顶层类型名称,这一方式已成为主流。

以x-开头的媒体类型

application/x-msgpack。表示该媒体类型尚未在IANA里注册。IANA(Internet Assigned Numbers Authority)是管理Internet相关编号的组织,还负责域名的管理、IP地址的分配等,在Internet领域承担了非常重要的职责。

没有在IANA里注册的并且以x-开头的媒体类型

媒体类型 数据格式
application/x-msgpack MessagePack
application/x-yaml YAML
application/x-plist 属性列表

但是有些媒体类型已在IANA注册,但在过去刚出现时,使用了x-开头。需要去查一下是否存在不以x-开头的替代类型。

这里有一个例外就是发送HTML表单数据时使用的application/x-www-form-urlencoded类型。该类型在RFC 1866中定义,虽然由于历史原因在命名时加上了x-,但确实IANA中正式注册的媒体类型,虽然有了不加x-的替代,但尚未被IANA采用。

自定义媒体类型

根据不同的前缀区分

树名 前缀
Standards tree(标准树)
Vendor tree(供应商树) vnd
Personal(Vanity) tree(个人树) prs.
Unregistered tree(未注册树) x.

标准树指RFC进行了规范化后的媒体类型,如text/html那样,没有前缀。

供应商树下的数据格式虽然旨在大范围使用,但却由特定的企业、团体来管理。例如Excel文件由微软公司管理,媒体类型为application/vnd.ms-excel。可以这样定义application/vnd.companyname.awesomeformat,Excel由于名气大,所以省略了公司名。

个人树下的数据格式只在实验性质或未公开的产品等中使用。

未注册树下的数据格式一般用于本地环境和私有环境,一般供应商树和个人树基本涵盖未注册树所涉及的用例,因此不推荐这种类型。

请求数据与媒体类型
  • Content-Type

    首部和相应消息首部一样,表示请求消息体是以怎样的数据格式发送给服务端的。如客户端发送POST请求时,如果以JSON的形式发送数据,就应该在首部里指定application/json;如果是从Web页面发送表单数据,就会使用application/x-www-form-urlencoded。进行表单操作时,如果有添加文件的情况,就要指定multipart/form-data

  • Accept

    此首部用于客户端向服务端表明能接受怎样的媒体类型。

    Accept: text/html,application/xml;q=0.9,*/*;q=0.8

    q表示品质因数(Quality Value),指定该媒体类型的优先级。默认为1,表示优先级最高。可以使用*/*表示所有的媒体类型。

    在使用服务器驱动的内容协商确定返回的数据格式时,服务器端会在响应消息的Vary首部里指定Accept,根据Accept的值的不同,响应的详细也可能不同。

同源策略和跨域资源共享

概念

通过XHTTPRequest对不同的域进行访问将无法获取响应数据,这一原则称为同源策略。同源策略主要是出于安全方面的考虑,它只允许从相同的源来获取数据,并通过URI里的schema(http, https等)、主机(api.example.com)、端口号的组合来判断是否同源。由于JSONP有很多安全问题,所以制定了跨域资源共享(Cross-Origin Resource Sharing,CORS)的方式解决跨域访问的问题。

CORS基本的交互

当实施CORS时,客户端要先发送一个名为Origin的请求消息首部。如从http://www.example.com/访问http://api/example.com/

1
Origin: http://www.example.com

服务器保存着允许访问的白名单,请求发送过来,会判断是否在白名单中。如果不在,则返回403;如果在,服务端会在Access-Control-Allow-Origin响应消息首部里放入和请求消息的Origin首部相同的源并返回,表示允许访问。

1
Access-Control-Allow-Origin: http://www.example.com
CORS与用户认证信息

CORS中发送用户认证信息时,必须发布追加的HTTP响应消息首部。例如当客户端使用Cookie首部、Authentication首部发送用户认证信息时,服务器端需要像下面这样将Access-Control-Allow-Credentials首部设为true,来告知客户端“已识别所发送的认证信息”。

1
Access-Control-Allow-Credentials: true

如果不这么做,浏览器会直接拒绝来自服务器的响应消息。

在各个浏览器的XHTTPRequest中,发送cookie等认证信息时,必须把withCredentials属性设为true,否则客户端将无法向服务器发送用户的认证信息。

私有的HTTP首部

定义新的HTTP首部时,一般需要在最前面加X-,接着添加服务、应用、团体等名称。例如GitHub会通过X-GitHub-Request-Id的自定义首部来针对每个请求返回唯一的ID。

RFC 6648中建议不适用X-前缀,即目前的最佳方案,其定义的规则并不具备强制实施的效力。所以,用或不用,统一就好。

第五章:开发方便更改设计的Web API

版本迭代:

  • 在URI路径的开头添加形如v1的版本号,如果新API没有向下兼容,则新增版本号;如果是bug,增加build编号,如果向下兼容的变更或废除某些特定的功能,增加次版本号。

第六章:开发牢固的Web API

中间人攻击(Man-In-The-Middle Attack, MITM攻击)

使用HTTPS加密机制进行通信时,客户端会从服务端获得SSL证书,此时就要求客户端验证该证书的真伪。如果客户端未能执行验证工作,整个通信过程就有可能遭到中间人攻击,导致通信内容被窃取。

有时一些不含机密信息的API,可以直接使用HTTP而不使用HTTPS加密机制。虽然根据不同类别的API采用不同的方式略微有些复杂,但从降低访问延迟、提高响应速度的角度来说,无疑是行之有效的方法。

XSS

XSS接收用户的输入内容并将其嵌入页面的HTML代码,当页面在浏览器里显示时,会自动执行用户输入的JavaScript脚本。一旦页面执行了用户输入的JavaScript脚本,攻击者就能够访问会话、cookie等浏览器里保存的信息,或者篡改页面,还能不受同源策略的限制进行跨域访问,从而完成任意操作。

如服务器返回

1
{"data": "<script>alert('xss');</script>"}

Content-Type首部的值为text/html的情况下,如果浏览器直接访问该JSON数据的URI,该JSON数据会被解释为HTML,导致通过SCRIPT元素加载的JavaScript代码被浏览器执行。所以为了防范,需要让浏览器将JSON格式的数据只识别成JSON,响应首部添加

1
Content-Type: application/json

但是IE会有一个Content Sniffering功能,根据实际的数据内容来推测数据格式。所以在返回JSON类型的数据时还应该添加

1
X-Content-Type-Options: nosniff

这会使IE8以后的浏览器不再使用Content sniffering功能。

XSRF/CSRF(Cross Site Request Forgery)

跨站点请求伪造。通过跨站点发送伪造的请求,让服务器执行用户意愿意外的处理。

常见例子:向公告板任意发帖,攻击站点以造成损失;恶意刷好评或差评。

防范:

  • 禁止通过GET方法来修改服务端的数据,如添加收藏、公告板发帖等。这样一来,就无法用IMG元素等嵌入用于攻击的脚本了。

  • CSRF 令牌。

  • 如果Web API只存在由XMLHTTPRequest或非浏览器客户端发起的访问,就要求客户端使用某种机制在请求附加一个特殊的首部,如果请求消息中不存在这一特殊的首部,就拒绝访问。如X-Request-With首部

JSON劫持

未完待续。