RESETful API接口设计规范

开发工作中,我们有时需要提供API接口给客户端或者第三方使用,那么如何构建一个能让使用者快速理解的API是一项重要的工作。如何我们在设计API时就严格遵守一些规范,那么在后面的开发过程中沟通成本和效率就会大大改善,我们今天来说说RESETful API的设计规范。

RESTful API 设计的定义

以下是我将贯穿在整个文档中的几个重要的术语:

  • 资源:一个对象的单个实例。比如,一个动物。
  • 集合:一个同类型对象的集合。比如,动物们。
  • HTTP:网络通信协议。
  • 消费者:能够发生 HTTP 请求的客户端应用程序。
  • 第三方开发者: 不属于你项目组成员但是希望使用你数据的开发者。
  • 服务:一个 HTTP 服务/应用能够通过网络被消费者访问。
  • 节点:服务器上的 API URL ,它能代表资源或整个集合。
  • 幂等性:无副作用,可以多次发生而没有副作用。
  • URL 段:URL 中由斜杠(“/”)分隔的信息段。

数据抽象与设计

我们需要确定数据库设计以及提供的web服务功能,有了功能设计,我们就可以很好的进行API设计,API应该尽可能抽象业务逻辑和数据,让使用者轻松上手。

请求方法

显然你肯定知道 GET 和 POST 请求,这是最常见的两种请求。POST 如此受欢迎以至于它融入进了其他常见的语言,那些不知道互联网如何运作的人也清楚他们可以在朋友圈『post』一些内容。

这里有四个半非常重要的 HTTP 动词需要你去了解,我说 “半个”,是因为 PATCH 请求和 PUT 请求非常接近。这两个经常被开发人员结合在一起使用。

  • GET (SELECT):从服务器或者资源列表中检索特定的资源。
  • POST (CREATE): 在服务器上创建新的资源。
  • PUT (UPDATE):更新服务器上的资源,提供整个资源。
  • PATCH (UPDATE): 更新服务器上的资源,仅提供改动的属性。
  • DELETE (DELETE): 从服务器上移除一个资源。

下面是两个不常见的 HTTP 请求方法:

  • HEAD -- 检索资源的元数据,比如数据的 Hash 值或者最后一次更新的时间。
  • OPTIONS -- 检索消费者可以对资源进行的操作信息。

一个好的 RESTful API 会利用这四个半 HTTP 请求方法来运行第三方和它提供的数据进行交互,并且绝对不会在 URL 段里包含任何动作。

通常,GET 请求可以被浏览器缓存,例如,浏览器将缓存 GET 请求( 取决于缓存头 ),如果用户试图发起第二次请求,浏览器将尽可能的提示用户。HEAD 请求基本是一个不带返回主体的 GET 请求,它同样也会被缓存。

版本控制

不论你构建什么应用,无论你提前准备了多少,你的核心应用都一直在变,你的数据关联关系也会变化,属性总是不断的从你的资源里新增或者删除。这就是软件开发的工作方式,尤其是当你的项目依然存在并且被很多人在使用时(如果你正在构建一个 API ,很可能就是这个样子)。

记住 API 是服务方与消费者之间的契约。如果你修改了你的服务 API 并且导致向后无法兼容,那么你就打破了服务方与消费者之间的契约,这些 API 使用者也会因此而怨恨于你。更严重的情况是,他们将不再使用你的 API 。为了保证你的应用继续发展并且让使用方满意,你需要在偶尔引入新版本的同时,保证老版本的正常访问。

顺便说一下,如果你只是简单的给你的 API 增加新特性,比如资源的新属性(不是必须的,并且没有这些属性也能正常使用),或者为你的 API 新增一个节点,你并不需要升级你的 API 版本号,因为这些改变没有打破向后兼容性。当然,你需要更新你的 API文档(你的契约)。

经过一段时间,你可以不再支持旧版本的 API 。不再支持旧版本API 的特性并不意味着立即关闭或者降低它的质量,而是告诉消费者旧版本的API 将会在指定的日期移除,他们应该升级到新的 API 版本。

良好的 RESTful API 将会在 URL 中跟踪版本。另外一种常见的解决方案是在请求头中放置版本号信息,但是经过在与许多不同的第三方开发人员合作后,我可以告诉你,将版本好信息放在请求头中没有直接添加到 URL 段中容易。

分析

所谓 API 分析就是持续跟踪那些客户端正在使用的 API 的版本和端点信息。如每次请求都往数据库自增一个计数器。很多原因显示跟踪分析 API 是一个好主意,如保证常用 API 的调用有效性。

构建第三方开发者所喜欢的 API,最重要的是当您决定弃用某个版本的 API 时,您实际上可以使用已弃用的 API 功能与开发人员联系。这是在你关闭旧版本 API 之前提醒他们升级的完美方式。

联系及通知第三方开发者过程可以是自动的,例如,例如每当一个过时的特性上发生 10000 次请求时就发邮件通知开发者。

API Root URL

无论你信不信,API 的根地址很重要。当一个开发者接手了一个旧项目。而这个项目正在使用你的 API,同时开发者还想构建一个新的功能,但他们可能完全不知道你提供的哪些服务。幸运的是他们知道客户端对外调用的那些 URL 列表。让你的 API 根入口点保持尽可能的简单是很重要的,因为一个漫长的复杂 URL 设计将令人望而生畏,并且可能会让开发者无法接受。

这里有两个常见的 URL 根例子:

https://example.org/api/v1/*
https://api.example.com/v1/*
知识兔

如果你的应用很庞大或者你预期它将会变的很庞大,那么将 API 放到子域下(如 api.example.com)通常是一个好选择。这种做法可以保持某些规模化上的灵活性。

但如果你觉得你的 API 不会变的很庞大,或是你只是想让应用安装更简单些(如你想用相同的框架来支持站点和 API),将你的 API 放到根域名下(如 example.com/api)也是可以的。

API 根拥有一些内容通常也是个好主意。GithubAPI 根就是一个典型的例子。从个人角度来说我是一个通过根 URL 发布信息的粉丝,这对很多人来说是有用的,例如如何获取 API 相关的开发文档。

同样也请注意 HTTPS 前缀,一个好的 RESTful API 总是基于HTTPS 来发布的。

端点

一个端点就是指向特定资源或资源集合的 URL

如你正在构建一个虚构的 API 来展现几个不同的动物园,每一个动物园又包含很多动物,员工和每个动物的物种,你可能会有如下的端点信息:

  • https://api.example.com/v1/**zoos**
  • https://api.example.com/v1/**animals**
  • https://api.example.com/v1/**animal_types*...
  • https://api.example.com/v1/**employees**

针对每一个端点来说,您需要列出有效的 HTTP 动词和端点组合。以下是我们的虚构 API 可以执行的半全面(semi-comprehensive)的操作列表。请注意我把 HTTP 动词都放在了虚构的 API 之前,正如将同样的注解放在每一个 HTTP 请求头里一样。

  • GET /zoos: 列出所有动物园 (ID、NAME等信息不要太详细)
  • POST /zoos: 创建一个新的动物园
  • GET /zoos/ZID: 获取一个动物园详情
  • PUT /zoos/ZID: 更新指定动物园
  • PATCH /zoos/ZID: 更新指定动物园(局部数据)
  • DELETE /zoos/ZID: 删除指定动物园
  • GET /zoos/ZID/animals: 检索指定动物园下动物列表(ID、NAME 不要太详细)
  • GET /animals: 列出所有动物(ID、NAME 不要太详细)
  • POST /animals: 创建一个新的动物
  • GET /animals/AID: 获取指定动物详情
  • PUT /animals/AID: 更新指定动物
  • PATCH /animals/AID: 更新指定动物(局部数据)
  • GET /animal_types: 检索动物类型列表(ID、NAME 不要太详细)
  • GET /animal_types/ATID: 检索指定的动物类型
  • GET /employees: 获取雇员列表(ID、NAME 不要太详细)
  • GET /employees/EID: 获取指定雇员详情
  • GET /zoos/ZID/employees: 获取指定动物园下的雇员列表
  • POST /employees: 创建一个新的雇员
  • POST /zoos/ZID/employees: 为指定动物园聘请一个雇员
  • DELETE /zoos/ZID/employees/EID: 删除指定动物园下的指定雇员

在上面的列表里,ZID 表示动物园的 IDAID 表示动物的 IDEID 表示雇员的 ID,还有 ATID 表示物种的 ID。让文档里所有的东西都有一个关键字是一个好主意。

为简洁起见,我在上面的示例中省略了公共 API URL前缀。作为沟通方式这没什么问题,但是如果你真要写到API文档中,那就必须包含完整的路径(如,GET http://api.example.com/v1/animal_type/ATID)。

请注意如何展示数据之间的关系,特别是雇员与动物园之间的多对多关系。通过添加一个额外的 URL 段就可以实现更多的交互能力。当然没有一个 HTTP 动词能表示正在解雇一个人,但是你可以使用 DELETE 一个动物园里的雇员来达到相同的效果。

过滤器

当客户端创建了一个请求来获取一个对象列表时,很重要一点就是你要返回给他们一个符合查询条件的所有对象的列表。这个列表可能会很大。但你不能随意给返回数据的数量做限制。因为这些无谓的限制会导致第三方开发者不知道发生了什么。如果他们请求一个确切的集合并且要遍历结果,然而他们发现只拿到了 100 条数据。接下来他们就不得不去查找这个限制条件的出处。到底是ORM的bug导致的,还是因为网络截断了大数据包?

尽可能减少那些会影响到第三方开发者的无谓限制

这点很重要,但你可以让客户端自己对结果做一些具体的过滤或限制。这么做最重要的一个原因是可以最小化网络传输,并让客户端尽可能快的得到查询结果。其次是客户端可能比较懒,如果这时服务器能对结果做一些过滤或分页,对大家都是好事。另外一个不那么重要的原因是(从客户端角度来说),对服务器来说响应请求的负载越少越好。

过滤器是最有效的方式去处理那些获取资源集合的请求。所以只要出现 GET 的请求,就应该通过 URL 来过滤信息。以下有一些过滤器的例子,可能是你想要填加到 API 中的:

  • ?limit=10: 减少返回给客户端的结果数量(用于分页)
  • ?offset=10: 发送一堆信息给客户端(用于分页)
  • ?animal_type_id=1: 使用条件匹配来过滤记录
  • ?sortby=name&order=asc: 对结果按特定属性进行排序

有些过滤器可能会与端点 URL 的效果重复。例如我之前提到的 GET /zoo/ZID/animals。它也同样可以通过 GET /animals?zoo_id=ZID 来实现。独立的端点会让客户端更好过一些,因为他们的需求往往超出你的预期。本文中提到这种冗余差异可能对第三方开发者并不可见。

无论怎么说,当你准备过滤或排序数据时,你必须明确的将那些客户端可以过滤或排序的列放到白名单中,因为我们不想将任何的数据库错误发送给客户端!

状态码

对于一个良好的 RESTful API 来说,使用合适的 HTTP 状态码是非常重要的;毕竟这是一个标准!各种网络社保都能识别这些状态码,例如:可以配置负载均衡,避免向 Web 服务器发生过多的 50x 的错误。这里有 大量的 HTTP 状态码 供你选择,但是一些这些将会是一个好的起点 :

  • 200 OK -- [GET]

    消费者向服务请求数据,并且服务找到了对应的数据(幂等)

  • 201 CREATED -- [POST/PUT/PATCH]

    消费者给服务发送数据,并且服务创建了资源

  • 204 NO CONTENT -- [DELETE]

    消费者请求服务删除资源,并且服务成功删除了资源

  • 400 INVALID REQUEST -- [POST/PUT/PATCH]

    消费者给服务发送了错误的数据,服务没有进行任何处理(幂等)

  • 404 NOT FOUND -- [*]

    消费者请求了一个不存在的资源或者集合,服务没有进行任何处理(幂等)

  • 500 INTERNAL SERVER ERROR -- [*]

    服务器发生错误,消费者不清楚请求是否成功

状态码范围

1xx 范围的状态码是保留给底层 HTTP 功能使用的,并且估计在你的职业生涯里面也用不着手动发送这样一个状态码出来。

2xx 范围的状态码是保留给成功消息使用的,你尽可能的确保服务器总发送这些状态码给用户。

3xx 范围的状态码是保留给重定向用的。大多数的 API 不会太常使用这类状态码,但是在新的超媒体样式的 API 中会使用更多一些。

4xx 范围的状态码是保留给客户端错误用的。例如,客户端提供了一些错误的数据或请求了不存在的内容。这些请求应该是幂等的,不会改变任何服务器的状态。

5xx 范围的状态码是保留给服务器端错误用的。这些错误常常是从底层的函数抛出来的,并且开发人员也通常没法处理。发送这类状态码的目的是确保客户端能得到一些响应。收到 5xx 响应后,客户端没办法知道服务器端的状态,所以这类状态码是要尽可能的避免。

预期返回文档

当使用不同的 HTTP 动词在服务节点执行动作时,消费者需要在返回只中获取某些信息。以下的典型的 RESTful APIs:

  • GET /collection: 返回一个资源对象的列表(数组)
  • GET /collection/resource: 返回单个资源对象
  • POST /collection: 返回最新创建的资源对象
  • PUT /collection/resource: 返回完整的资源对象
  • PATCH /collection/resource: 返回完整的资源对象
  • DELETE /collection/resource: 返回一个空文档

注意当消费者创建一个资源,他们通常不知道资源被创建时的 ID (或者其他属性比如创建或者修改时间,如果存在的话)。这些额外的属性将会在后续的请求中返回,当然在第一次 POST 时也会返回。

认证

大多数情况下,服务器需要确切知道谁在进行哪些请求。当然,有些 API 是可以公开(匿名)访问的,但是大部分时间里我们都需要确定 API 访问者的身份。

OAuth2.0 提供了一个非常好的方法来做到这一点。在每一个请求里,你可以明确知道哪个客户端创建了请求,哪个用户提交了请求,并且提供了一种标准的访问过期机制或允许用户从客户端注销,所有这些都不需要第三方的客户端知道用户的登陆认证信息。

还有 OAuth 1.0xAuth 同样适用这样的场景。无论你选择哪个方法,请确保它为多种不同语言/平台上的库提供了一些通用的并且设计良好文档,因为你的用户可能会使用这些语言和平台来编写客户端。

我可以诚实地告诉你,尽管 OAuth 1.0a 是最安全的选项,但实施起来却是一个巨大的痛苦。大量的第三方开发者不得不为他们使用的语言开发一个适配的包。我花了足够的时间来调试神秘的 无效签名 错误,所以推荐您选择替代方案。

Content Type

目前,大多数「令人称赞」的 API 都为 RESTful 接口提供 JSON 数据支持。诸如 FacebookTwitterGithub 等等你所知的。XML 的方法现在已经很少有人在使用(大型企业环境除外)。很幸运的是 SOAP 已几乎没人在使用了,并且现在我们很少看到 APIHTML 作为结果返回给客户端(除非你在构建一个爬虫程序)。

只要你返回给他们有效的数据格式,开发者就可以使用流行的语言和框架进行解析。如果你正在构建一个通用的响应对象,通过使用一个不同的序列化器,你也可以很容易的提供之前所提到的那些数据格式(不包括 SOAP)。而你所要做的就是把使用方式放在响应数据的接收头里面。

有些 API 的创建者会推荐把 .json, .xml, .html 等文件的扩展名放在 URL 里面来指示返回内容类型,但我个人并不习惯这么做。我依然喜欢通过 Accept header 来指示返回内容类型(这也是 HTTP 标准的一部分),并且我觉得这么做也比较适当一些。

超媒体API

超媒体 API 很可能就是 RESTful API 设计的将来。超媒体是一个非常棒的概念,它回归到了 HTTPHTML 如何运作的「本质」。

在非超媒体 RESTful API 的情景中,URL 端点是服务器与客户端契约的一部分。这些端点必须让客户端事先知道,并且修改它们也意味着客户端可能再也无法与服务器通信了。你可以先假定这是一个限制。

时至今日,英特网上的 API 客户端已经不仅仅只有那些创建 HTTP 请求的用户代理了。大多数 HTTP 请求是由人们通过浏览器产生的。人们不会被哪些预先定义好的 RESTful API 端点 URL 所束缚。是什么让人们变的如此与众不同?因为人们可以阅读内容,可以点击他们感兴趣的链接,并浏览一下网站,然后跳到他们关注的内容那里。即使一个 URL 改变了,人们也不会受到影响(除非他们事先给某个页面做了书签,这时他们回到主页并发现原来有一条新的路径可以去往之前的页面)。

超媒体 API 概念的运作跟人们的行为类似。通过请求 API 的根来获得一个 URL 的列表,这个列表里面的每一个 URL 都指向一个集合,并且提供了客户端可以理解的信息来描述每一个集合。是否为每一个资源提供 ID 并不重要(或者不是必须的),只要提供 URL 即可。

一个超媒体 API 一旦具有了客户端,那么它就可以爬行链接并收集信息,而 URL 总是在响应中被更新,并且不需要如契约的一部分那样事先被知晓。如果一个 URL 曾经被缓存过,并且在随后的请求中返回 404 错误,那么客户端可以很简单的回退到根URL并重新发现内容。

在获取集合中的一个资源列表时会返回一个属性,这个属性包含了各个资源的完整 URL。当实施一个 POST/PATCH/PUT 请求后,响应可以被一个 3xx 的状态码重定向到完整的资源上。

JSON 不仅告诉了我们需要定义哪些属性作为 URL,也告诉了我们如何将 URL 与当前文档关联的语义。正如你猜的那样,HTML 就提供了这样的信息。我们可能很乐意看到我们的 API 走完了完整的周期,并回到了处理 HTML 上来。想一下我们与 CSS 一起前行了多远,有一天我们可能再次看到它变成了一个通用实践让 API 和网站可以去使用相同的 URL 和内容。

文档

老实说,即使你达不到这篇指南的百分之百标注,你的 API 也不一定会很糟糕。 但是如果你没有用正确的方式来书写你的 API 文档,没有人会知道怎样使用它,这样就会变成一个可怕的 API 。

文档应该面向所有开发者。

不要使用文档自动生成器,如果非要用,请确保你对其处理并被显示。

不要只列出部分示例请求和响应主体; 它们应该被完整的显示出来。 在你的文档中使用语法高亮显示器。

文档应该为每个接口提供预期的响应和可能出现的错误提示,以及这些错误是怎样造成的。

如果你有足够的时间,你可以考虑为开发人员构建一个 API 控制台(如Postman),以便于他们可以马上试验。它并没有你想的那么难,如果你这样做,开发人员(内部人员和第三方开发者)都会爱死你!

一定要保证你的文档可以被打印; CSS 足够丰富; 在打印文档时记得隐藏侧边栏。即使没有人打印过物理的副本,你也会惊讶于有多少开发人员喜欢打印成 pdf 来离线阅读。

勘误:原始的 HTTP 封包

由于我们所做的一切都是基 HTTP 进行的,因此我将向您展示一个 HTTP 数据包的剖析。我经常很惊讶的发现有很多人都不知道 HTTP 的请求数据格式。当客户端向服务器发送请求时,他们会提供一个键值对集,先是一个头,紧跟着是两个回车换行符,然后才是请求体。所有这些都是在一个封包里被发送。

服务器响应也是同样的键值对集,带两个回车换行符,然后是响应体。HTTP 就是一个请求/响应协议;它不支持 推送 模式(服务器直接发送数据给客户端),除非你采用其他协议,如 Websockets

当你设计 API 时,你应该能够使用工具去查看原始的 HTTP 封包。Wireshark 是个不错的选择。此外,请确保您使用的框架/Web服务器允许您阅读和更改尽可能多的这些字段。

HTTP 请求示例

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

{
  "name": "Gir",
  "animal_type": 12
}
知识兔

HTTP 响应示例

HTTP/1.1 200 OK
Date: Wed, 18 Dec 2013 06:08:22 GMT
Content-Type: application/json
Access-Control-Max-Age: 1728000
Cache-Control: no-cache

{
  "id": 12,
  "created": 1386363036,
  "modified": 1386363036,
  "name": "Gir",
  "animal_type": 12
}
知识兔
计算机