RESTful API 设计与工程实践

自言自语 · · 1942 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

声明

本文只是我个人在阅读资料与工程实践中的总结,可能并不是最好的实践。但希望可以给对RESTful API 设计与工程实践有疑惑的读者一些帮助。

前言

RESTful 原则由 Roy Fielding 在他的论文第五章中提出。

RESTful API 之于后端开发者,就像 UI 之于 UI 设计师。RESTful API 与所有 UI 一样,标准、友好、一致的用户体验是极其重要的。为了达到上面的目标,API 需要满足以下要求:

  • 尽可能的遵守有关 WEB 规范和常见约定
  • 调用接口简单明了,可读性强,没有歧义
  • API 风格保持一致,调用规则,传入参数和返回数据有统一的标准
  • 能够为客户端提供简单灵活的数据访问方式
  • 高效、安全、易扩展

安全

使用 HTTPS 保证连接安全

使用 HTTPS 来保证整个 API 调用过程的安全,这可以有效防止窃听和篡改。

另外,由于通信有了安全保障,可以使用更方便的 Token 机制来简化验证流程,而不必再为每个请求签名。

对于非 HTTPS 的 API 调用,不要将其重定向到 HTTPS,而要直接返回调用错误以禁止不安全的调用。

使用 JWT 实现 Authorization 机制

基于 JWT 的认证机制是无状态认证机制中比较好的方案。具体的实现与使用方法,请阅读 JWT

在使用 JWT 时,请务必使用 HTTPS。

API 的设计

版本控制

API 的迭代是必然的。为了保证在迭代的过程中,不会由于 API 频繁迭代而损害开发者的利益,为 API 进行版本控制是很有必要的。

关于版本信息的保存位置有两种观点

  • URL
  • HTTP 头

理论上讲,确实是应该放在 HTTP 头中。但个人觉得更好的实践还是放在 URL 中,这样可以更加直观地看到当前正在使用的 API 的版本。

不过,也有权衡两者的使用方法 —— Strip API versioning

  • URL 中放置主版本号,以标明 API 的总体结构
  • HTTP 头中放置基于时间的次版本号,以标明 API 的微小变化,如参数字段的废弃,Endpoint 的变化等
1
2
3
GET https://api.stripe.com/v1/charges HTTP/1.1
Stripe-Version: 2017-01-27

实现方式:

  • API 调用者在 Request Headers 中添加版本信息以标示自己所请求的 API 版本
  • API 提供者在 Response Headers 中添加与 Request Headers 对应的版本信息以标示所响应的 API 版本

路由

RESTful API 中路由设计的关键在于「将 API 分解成逻辑上的资源,并通过拥有具体含义的 HTTP 方法(GET、POST、PUT、PATCH、DELETE)对资源进行修改」。

比如:

  • GET /posts - 获取 post 列表
  • GET /posts/8 - 获取指定的 post
  • POST /posts - 创建一个新的 post
  • PUT /posts/8 - 更新 ID 为 8 的 post
  • PATCH /posts/8 - 部分更新 ID 为 8 的 post
  • DELETE /posts/8 - 删除 ID 为 8 的 post

其中,有几点有需要注意。

使用名词命名资源,而不是动词

阅读更多

Endpoint 使用复数,而不是单数

即使使用复数形式表示单个资源是错的,但是为了保证 URL 格式的一致,也请始终使用复数形式。另外,不要处理英语中的特殊单词的复数变换,比如 goose/geese。

资源之间的关联关系

如果两种资源之间存在关联关系,比如 posts 与 comments 之间的关联关系,可以通过一下形式将 comment 映射到 post 的路由上:

  • GET /posts/8/comments - 获取 posts #8 的 comment 列表
  • GET /posts/8/comments/5 - 获取 post #8 的 comment #5 的内容
  • POST /posts/8/comments - 为 post #8 创建新的 comment
  • PUT /posts/8/comments/5 - 更新 post #8 的 comment #5 的内容
  • PATCH /posts/8/comments/5 - 部分更新 post #8 的 comment #5 的内容
  • DELETE /posts/8/comments/5 - 删除 post #8 的 comment #5

另外,如果 comment 有自身对应的路由,如 /comments,最好就不要使用上述的 /posts/:post_id/comments/:comment_id 路,而是使用其自身对应的路由。这么做出于两点原因:

  1. 避免重复的 API 设计
  2. 使用更短更方便的路由

条件请求

请阅读 HTTP conditional requests

查询参数

查询参数通过 URL 的 Query Parameters 实现。

资源排序

Query Parameters 以 排序关键字=[-]字段1,...,[-]字段N 的形式拼接:

  • 排序关键字:可自行选择,不与其他字段冲突即可,比如 sort
  • 字段:以 , 分隔的字段列表,如果字段前缀为-表示降序排列。
1
/entrypoint?sort=-age,sex

资源过滤

Query Parameters 以 字段=值 的形式拼接。

1
/entrypoint?age=35&sex=male

资源字段过滤

API 消费者并不都一直需要资源的所有内容。提供按需返回字段的能力对减小流量消耗、加快 API 调用有很大好处。

Querystring 以 字段过滤关键字=字段1,...,字段N 的形式拼接:

  • 字段过滤关键字:可自行选择,不与其他字段冲突即可,比如 fields
  • 字段:以 , 分隔的字段列表。
1
/entrypoint?since=1499410815441&count=10&age=35&fields=age,sex

全文搜索

有时,基本的资源过滤功能是不够的。这时,你可能就会用到 ElasticSearch 或者其他基于 Lucene 的全文搜索工具了。

当使用全文搜索时,全文搜索的参数应该通过资源 API 的查询参数提供。查询完成后所返回的数据,应该和普通列表查询一致。

比如:

1
GET /messages?q=return&state=read&sort=-priority,created_at

为常用查询设置别名

有些查询会经常用到,为它们设置个别名,可以让开发者用得更舒服。比如,查询最近已读的消息:

1
GET /messages/recently_read

请求内容

如果不需要兼容老旧系统,优先使用 JSON。

保证接收到的请求头的 Content-Typeapplication/json ,不然就返回 415 Unsupported Media Type

响应状态码

HTTP 定义了很多有意义的状态码,但也不是所有的都能用到。下面列出了一些常用的状态码:

  • 200 OK - 对成功的 GET、PUT、PATCH 或 DELETE 操作进行响应。也可以被用在不创建新资源的 POST 操作上
  • 201 Created - 对创建新资源的 POST 操作进行响应。应该带着指向新资源地址的 Location 头
  • 204 No Content - 对不会返回响应体的成功请求进行响应(比如 DELETE 请求)
  • 304 Not Modified - HTTP缓存header生效的时候用
  • 400 Bad Request - 请求异常,比如请求中的body无法解析
  • 401 Unauthorized - 没有进行认证或者认证非法。当API通过浏览器访问的时候,可以用来弹出一个认证对话框
  • 403 Forbidden - 当认证成功,但是认证过的用户没有访问资源的权限
  • 404 Not Found - 请求一个不存在的资源
  • 405 Method Not Allowed - 所请求的 HTTP 方法不允许当前认证用户访问
  • 410 Gone - 表示当前请求的资源不再可用。当调用老版本 API 的时候很有用
  • 415 Unsupported Media Type - 如果请求中的内容类型是错误的
  • 422 Unprocessable Entity - 用来表示校验错误
  • 429 Too Many Requests - 由于请求频次达到上限而被拒绝访问

响应内容

  • 如果不需要兼容老旧系统,优先使用 JSON。
  • 创建和修改操作后,返回资源的全部信息。

错误处理

错误一般分为两类:

  • 客户端请求错误
  • 服务端响应错误

关于状态码的使用

客户端请求错误使用 400 系列状态码,服务端响应错误使用 500 系列状态码。

关于响应体

发生错误时,API 返回的响应体应该为开发者提供一些有用的信息:

  • 唯一的错误码
  • 有用的错误信息
  • 可能的话,提供错误细节的描述

用 JSON 格式来表示的话,看起来像这样:

1
2
3
4
5
{
"code" : 1234,
"message" : "Something bad happened :(",
"description" : "More details about the error here"
}

对于 PUT、PATCH、POST 的请求,在发生校验错误时,使用额外的 errors 字段来提供错误细节,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"code" : 1024,
"message" : "Validation Failed",
"errors" : [
{
"code" : 5432,
"field" : "first_name",
"message" : "First name cannot have fancy characters"
},
{
"code" : 5622,
"field" : "password",
"message" : "Password cannot be blank"
}
]
}

更多的元信息

如果需要更多的元信息,可以将其放在 HTTP 头中。比较常见的元数据有:

  • 分页信息
  • 请求频率限制信息(已经提及)
  • 认证信息(已经提及)
  • 版本信息(已经提及)
  • ……

为了避免命名冲突,在设置 HTTP 头时,应该为 HTTP 头内的自定义字段添加前缀,比如 OpenStack 就这么做了:

1
2
3
OpenStack-Identity-Account-ID
OpenStack-Networking-Host-Name
OpenStack-Object-Storage-Policy

在以前,协议的设计者与实现者通过使用 X- 前缀来区分自定义与非自定义的 HTTP 头,但实践证明,这个问题在解决问题的同时,也引入了诸多问题。所以 RFC 6848 已经开始废弃这种做法。

另外,虽然在 HTTP 规范中并没有规定 HTTP 头的大小,但是在某些平台中,它的大小是被限制了的。比如,Node.js 的 Header 大小不能高于 HTTP_MAX_HEADER_SIZE(默认 80KB),这么做的目的是为了防御基于 HTTP 头的 DDOS 攻击

开启 gzip 压缩

记得开启 gzip 压缩。

调用频率限制

服务器的资源是有限的,为了防止有意或无意的高频率请求,对请求频率做限制是很必要的。

RFC 6585 引入了一个 HTTP 状态码 429 Too Many Requests 来解决这个问题。

当然,如果能在达到调用上限之前通知到 API 消费者肯定更好。当前这个领域缺少一些标准,但是很多流行的做法是使用 HTTP 响应头

实现方式:API 提供者维护 API 调用者的频率限制信息,并通过多个 Response Headers 来通知 API 调用者对 API 的调用情况:

  • X-Rate-Limit-Limit:当前时间段内允许的最多请求次数
  • X-Rate-Limit-Remaining:当前时间段内剩余的请求次数
  • X-Rate-Limit-Reset:还有多少秒,请求次数限制会被重置

为什么 X-Rate-Limit-Reset使用的是剩下的秒数而不是时间戳(timestamp)?

时间戳包含了很多有用但是非必需的信息,比如日期和时区。API消费者真正想知道的是他们什么时候可以继续发起请求,使用秒对于消费者来说处理的成本最小。并且使用秒也规避了时钟偏移问题

缓存

HTTP 内置了缓存策略。你只需要在 API 响应中增加几个 Header,在处理请求的时候对一些请求 Header 做点校验。

这里有两个方案:ETagLast-Modified

ETag

当处理一个请求的时候,在响应中包含一个名为 ETag 的 HTTP 头,它的值可以为资源内容的hash 或者 checksum。ETag 的值应该在资源内容发生变化的时候跟着变化。如果 HTTP 请求头中包含 If-None-Match,并且其值与被请求资源的 ETag 值相同,那么 API 则返回 304 Not Modified 状态码,而不再输出资源内容。

Last-Modified

和 ETag 的工作原理差不多,区别在于这个头使用的是时间戳。响应头 Last-Modified 中包含一个 RFC 1123 格式的时间戳,用来对 If-Modified-Since 的值进行校验(HTTP 协议接受三种不同的日期格式,所以对这三种格式服务器应该都能处理)。

API 文档

  • 保持文档与 API 同步更新
  • 让文档可以被公开访问,并且易于查找
  • 文档应该提供完整的调用示例(GitHubStripe 深谙此道)

API 的测试

黑盒测试

黑盒测试是一种不关心应用内部结果和工作原理,而只关心结果是否正确的测试方法。

黑盒测试时,不应该 Mock 任何数据。

另外,写测试时,尽可能不对系统状态做假设,但在某些场景下,需要准确地知道系统当前所处的状态以增加更多的断言来提供测试覆盖率。如果有这种需求的话,可以使用如下两种方法对数据库进行预填充:

  • 选择生产环境数据的子集来运行黑盒测试
  • 运行黑盒测试之前把手动构造的数据填充到数据库

单元测试

除了黑盒测试,单元测试也要老老实实地写。

API 的衍化

API 的衍化是不可避免的。通过文档记录变更,并通过某些途径通知开发者,如 CHANGELOG 、博客、Deprecation Schedules、邮件列表等,之后,逐步废弃老旧的 API。

设计优秀的 API

站在巨人的肩膀上,多多了解并借鉴大站的 API 设计方法,是很不错的学习与实践方法。

RESTFul API 的问题

  1. 缺乏可拓展性。一个刚开始简单的用户接口可能只返回少部分信息,例如用户名、头像等。随着API的不断发展,可能需要返回更多的信息,例如年龄、昵称、签名等。很多时候客户端只是需要其中的部分信息,但是接口依旧传输了所有的信息,这个情况增加了网络传输量,特别对于移动应用来说特别不友好,同时需要客户端自行提取需要的数据。而建立两个功能大致相同只是返回字段有所区别的API则增加了后端实现的复杂度,或者是需要增加业务逻辑判断,或者是增加了维护的难度。当然,这种问题可以通过使用查询参数来解决,但是这无疑增加了代码冗余量。
  2. 复杂的数据需求需要做多次 API 调用。比如,客户端要显示文章的内容,可能要调用文章接口、评论接口、用户信息接口。为构成对一个资源的完整视图,需要做多次单独调用,这样的数据获取方式非常不灵活。

API 的未来

可能是 GraphQLFalcor 吧。

参考


有疑问加站长微信联系(非本文作者)

本文来自:自言自语

感谢作者:自言自语

查看原文:RESTful API 设计与工程实践

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

1942 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传
X
登录和大家一起探讨吧