Rails 聊聊 Web 接口设计和接口行为

run99 · 2019年10月07日 · 最后由 run99 回复于 2019年10月08日 · 5700 次阅读

关于 Web 接口的设计,现在网络上聊得最多得是 RESTful. 但很多人仅仅认为 RESTful 是个接口得设计规范,而忽略了它本质上是一个软件设计架构。

在写这篇文章之前,我在网络上查阅了一些文档资料,并从中筛选出我认为写得不错的博客文档。它们是:

阮一峰的两篇博文:

来自于就浩这口的四篇博文:

既然已经有这么多的轮子在那里了,我不需要再重复去扯关于 Restful 的各种细节了。所以,我就当下的各种资料,做一些总结性的梳理。是的,也许你不用去读我推荐给你的文章,而直接进入我的总结性的梳理。但你一定得有相关的基础储备,有的是 HTTP 的,有的是 RESTful 的。你的基础储备得有,哪怕是错误的,也是可以的。

理解 RESTful

你应该面向的是 HTTP 而不是 RESTful

当我们将 RESTful 挂在嘴边的时候,可能已经陷入了一个误区。我们忽略了一个事实:HTTP 本身就是 RESTful 的。例如,在 HTTP 里,URI(统一资源定位符)就是用来定位各种资源的。而资源本身可以展示成不同的格式,如 HTML 的、XML 的、JSON 的等等,使用Content-Type这个响应头来标明响应体的格式。另外,GET、POST、PUT、DELETE 就是来表示对资源的操作的。HTTP 本质上就是 RESTful 的,这是因为,当初在设计 HTTP 的时候,潜意识中就是用的 RESTful 的指导思想。某种程度上说,RESTful 是从 HTTP 的规范中归纳出来的。而不是说,它是后来想出的接口设计规范,用于指导如何设计 HTTP 接口。

所以说,当你使用 HTTP 定义你的接口的时候,它天生就应该是 RESTful. 你不应该去强调你的接口如何 RESTful,而应该说明,你的接口哪些部分没有采纳 RESTful.

至于为什么我们现在看到的接口很多都不是那么的 RESTful 的。一个可能的原因是,HTTP 的设计是个逐步演化的过程。它最初可能就只有 GET、POST 这两个方法,至于其他的方法可能是后来再加上去的。还有一点,人都是懒惰的,一个开发者可能并不想去理解那么多的 HTTP 的概念,而造成了某种误用。

用最简单的话来理解 RESTful

REST,即是 Representational State Transfer 的缩写。它的翻译是”表现层状态转化“。我们就不去理解这个词组的意思了,仅仅需要知道的是,在 REST 世界里,你面向的是资源。

资源,可以理解为一切的实体。这个实体,可以是具体的,例如一本书、一张图片,一首歌曲,一个人。也可以是抽象的,例如一种服务、一个行为。我们需要将转账、关注这些行为都要理解成一种资源。

当我们能够定义和定位资源的时候,针对资源的操作大体只有四种,即 GET、POST、PUT、DELETE,即查看、创建、更新、删除这四种操作。这有点像数据库,面对表的数据只能有增删查改这四种一样。一个局限就是,除了这四种,你不能针对资源有其他的操作,其他的操作必须抽象成另一个资源。这就是面向资源的设计。

资源的操作带来的结果是资源状态的改变。HTTP 是无状态的应用层传输协议,资源的状态只保存在服务器,要想对它做任何改变,只能通过操作改变它。

最后再插入一点。我们用 URI(统一资源定位符)来定位资源,用方法(GET、POST、PUT、DELETE)来表示对资源的操作。另外,我们还会使用大量的头部表示各种元信息。元信息是相当重要的,却是最容易被忽视的部分。在响应体中,还会有一个状态码,顾名思义,它表示这次请求的状态转移的结果。

我所觉得的 HTTP 的最佳实践

我不要你觉得,我要我觉得。

在这一篇里,我直接抛出在设计 HTTP 接口时的最佳实践。当然每个人所理解的最佳是不同的,我个人接受反驳,但未必回应。

URI

使用名词定义资源。另外,名词倾向于使用复数。例如用/books表示一个集合,用/books/:id表示单个的资源。唯一的使用单数的情况是,它特指某个单个资源。如/system表示单例资源,/user特指当前用户。

格式

最简单的是一律只使用 JSON. 当然你也可以同时支持其他的格式,如 XML、formData 等,但务必要清晰。

如果只支持 JSON,那么请求体的Accept必须注明application/json,而响应体的Content-Type也必须是application/json.

状态码

务必使用状态码表示状态,如 4xx 是客户端的错误,5xx 是服务端的错误。但 HTTP 的状态码只是一般性的错误表示,不应该作为开发者处理的字段。我一般会在错误的响应体中定义如下格式:

{
    "code": "...",
    "message": "...",
    "details": {}
}

数据格式

数据格式都要用一个字段包装起来,无论是请求体还是响应体。这样做是为了保持一致性和方便以后的扩展。

例如单个 book 资源:

{
    "book": {
        // ...
    }
}

和 books 资源集合:

{
    "books": [
        { // book 1 },
        { // book 2 }
    ],
    "total": "总数"
}

以上是严格按照 RESTful 所说的而产生的最佳实践的标准。但在我的实际开发中,我往往会做一些妥协。(就像写这篇文章一样,我会随性很多,并做出了很多的妥协)

版本号

使用一个特殊的 Header 表示。

鉴权

我习惯使用一个自定义的X-Token的头部,使用 jwt 加密。据说 OAuth 2.0 很流行,但我对这一块并不懂。

可以接受的调整

首先,有些超出”增删查改“范畴的操作并没有抽象成资源,而是作为一个动作附加在资源后面。更确切地说,这一部分没有使用名词,而是作为动词体现在 URI 中。比如,对于关注,我可能是这样设计:

POST /books/:id/star

这里的 star 应理解为动词而不是名词。如果是名词,正如我前面所说的,它应该用复数。

这是我作为 RESTful 的践行者做得不够彻底的地方。

关于 PUT 和 PATCH 的语义,我想在聊聊 RESTful - 接口设计篇(一)里已经解释得很清楚了。在实际的实践中,我往往是把 PUT 作为局部更新来处理的,而 PATCH 和 PUT 一样,并且也不是按照文章说的那样去处理。

关于版本号,我接受在 URI 中加入。这样会更简单。其实,我在实际开发过程中从没有声明过版本号,接口一但有调整都是直接调整,从没有维护过两个版本的情况。这与我只开发公司内部产品,而不是开放 API 有莫大的关系吧。

面向失败的设计

我们听过面向对象的设计,刚刚也接触过面向资源的接口设计。这里我想提的一点,是面向失败的设计。我们现在所接触的系统,其规模大大超过了以往。我们在上手大部分项目的时候,从一开始,就要考虑到它的完备性,以应对方方面面可能出现的失败。那么,从接口设计开始,我们就要考虑全面一些吧。

我们设想一下客户端调用接口的完整过程,它往往是:

  1. 客户端发送请求
  2. 服务端接收到请求的数据
  3. 服务端对请求的数据做验证,希望它符合一定的格式,并且不会对系统的约束造成破坏
  4. 如果包含鉴权系统,服务端需要验证鉴权信息,并且需要验证客户端的身份信息是否有对该资源有操作的权限
  5. 如果 3、4 的检查都通过,服务端处理数据和请求
  6. 服务端将处理的结果返还给客户端

从以上两点看到,服务器需要考虑到一个请求的主要在两个方面:

  1. 请求的数据是否符合预期
  2. 用户的身份是否对该资源有操作的权限

这分别对应了,在面向失败的设计里,对系统行为预期的最低要求

  1. 接口的调用不会破坏系统的约束
  2. 最起码的不该被非法用户入侵

一切更优化的最低要求和行为,都是建立在这两点的基础上的。

检验请求数据

第一件事情就是要检验请求的数据。首先通用的检验我们就不展开说了,如你的数据是合法的 JSON 格式,这应该是在一开始就限制好的。

针对一个合法的 JSON 数据本身,通常需要考虑的问题是:

  1. 一个字段是可选的,还是必须的。如果是必须的,在缺乏该字段的时候,系统应该报错。如果是可选的,就需要考虑好它的默认值问题了。
  2. 该字段的类型。如标量格式的类型:int、string、boolean 等;又如矢量格式的类型,如:array、object 等。
  3. 预期的格式。如一个 string 的格式,它可能需要是一个合法的 email,也可能需要是一个合法的时间字符串,又或者它的长度需要限制在某个范围内。又如一个 int 的值,它的值需要限制在某个范围内。
  4. 一定的约束。这种约束可能需要查询数据库才能知晓,但不能破坏。最常见的是唯一性约束。

验证操作权限

第二件事情就是验证操作权限,以防止没有权限的用户对它管辖外的资源造成的破坏。鉴权系统首先需要验证客户端是否提供了有效的身份信息,可选的方案如 jwt. 然后,它需要在两个维度下验证权限:

  1. 操作的维度。这个维度是指,用户是否有对该资源有该操作的权限。
  2. 字段的维度。这个维度是指,用户对哪些字段拥有操作的权限。

暴露给客户端的数据

在安全的接口设计里,我们需要考虑哪些数据是允许暴露给客户端的。这也包括两个维度:

  1. 资源的维度。例如一个管理员只能查看某一个城市下的人口数据,则接口返回中需要筛选出只包含该城市的数据。
  2. 字段的维度。一个例子是,在没有付费的普通用户下,需要限制查看某些字段的内容。

这一节提到了三点常见的失败可能。失败的可能错综复杂,将这三点单独拎出来说的原因是,它们是最常见的。所以,在框架选型和项目构建中,一开始就要将这三点囊括进来。现代的 WEB 框架往往能够采用一套通用的方式处理,包括用户代码和测试代码,算是为我接下来讲框架的内容埋下铺垫。

不错,个人感觉做 API 设计的可以翻翻这本书。 Web API: The Good Parts

“这里的 star 应理解为动词而不是名词。如果是动词,正如我前面所说的,它应该用复数”

如果是动词 -> 名词

如果用 RESTful 的风格和你说的这种是不是冲突

{
    "code": "...",
    "message": "...",
    "details": {}
}
w7938940 回复

不会有冲突。因为开发者需要有一个更具体的 code 来处理错误,例如not_authorizedparameter_missing等这些枚举值。如果仅仅给出的是 HTTP status 可能会觉得不够用,而且这些数字还特别难记。

已默默地改掉了

run99 Rails API 的整体实践之面向测试的开发 提及了此话题。 10月20日 16:22
需要 登录 后方可回复, 如果你还没有账号请 注册新账号