RESTful API 设计入门指南
背景和概述
在技术的迭代中,前后端分离式开发成为了现代开发的主导方式。联系后端(Back-end)与前端(Front-end)的核心,就是由后端提供给前端进行交互的一系列 API。
在实际的开发中,对于后端服务提供的一套接口,可能要同时给Web、移动APP、桌面等不同的前端用,甚至是给其他程序使用(例如微服务的不同模块互相调用)。
若后端服务提供的接口能具有易于理解、有清晰标准、对扩展友好的特性将会有利于后续开发、降低成本。而按照 REST 风格设计的接口刚好具有这些特点,因而在现代 Web 开发中变得流行起来。
虽然网上关于 REST 的资料很多,但全面准确、易读懂、能与实践关联的却较少,对实际开发中遇到的一些问题帮助有限。故在学习和开发的过程中,重新整理并写出了这篇文章,其中包括了定义解释、与传统API对比、实际开发问题等方面,尽可能从开发者的角度全面的进行了概括,希望能帮助读者能更好理解运用 REST。
备注:如果已经对 REST 有了解,可以直接从 深入 RESTful API 这章节开始阅读。文中附带了一些用 Java 的 SpringMVC/Boot、JS 的 Axios 写的 demo 代码,可按需要选择性阅读或直接跳过。
什么是 REST 和 RESTful API
出于严谨先从定义出发:REST 全称 Representational State Transfer,直译过来是:表现性状态转换,提供了一组设计原则。(参考自百度百科)
这个名字会让人感觉摸不着头脑,因为 REST 中省略了一个主语:“资源”。其中“表现性”(Representational)指的是资源的表现。
那这么看,REST 核心就是:资源、表现、状态转移这三部分。我们用通俗一点的方式来理解一下。
- 资源(Resource):是客户端想请求的内容。服务器上的页面、图片、文件,甚至服务端处理后的数据(例如从数据库查询的数据)等都属于资源。在 REST 中资源这个概念包含了两部分:一是资源自身,另一个是能描述这类资源的标识符(Identifier)。原论文的描述是:资源是对一组实体的概念映射,而不是对应于任何特定时间点的映射的实体。(A resource is a conceptual mapping to a set of entities, not the entity that corresponds to the mapping at any particular point in time.)
- 表现(Representation):指的是资源如何展示出来。一个资源可以有多种“表现”的方式,例如,一个接口返回从数据库查询的数据,既能选择用JSON形式返回,也可以返回XML,这就是同一个资源的不同表现。一般包含两个部分:数据和元数据。对应到 HTTP 响应中,就是响应体与头部中描述数据的字段(例如
Content-Type
)。 - 状态转移:这点可能很难找到严谨的定义上的解释。但基本可以理解为:一个资源的内容发生了变化,就是从一个状态转移到另一个状态。如果是网络的客户端服务端模型上,就是客户端通过发送不同的请求,推动了服务端上的资源发生了变化。
一种对 REST 简化的理解,就是资源以某种方式展示出来,且可以被客户端推动发生变化。
参考 IBM 的文档,REST 提出了六个设计原则(也称为架构约束):
- 统一接口。 无论请求来自何处,对同一资源发出的所有 API 请求都应该看起来相同。 API 应确保同一条数据(例如用户的姓名或电子邮件地址)仅属于一个统一资源标识符 (URI)。 资源不应过大,但应包含客户可能需要的每一条信息。
- 无状态。 API 是无状态的,这意味着每个请求都需要包含处理它所需的全部信息。 换句话说,API 不需要任何服务器端会话。 不允许服务器应用程序存储与客户端请求相关的任何数据。
- 可缓存性。 如果可能,资源应该可以在客户端或服务器端缓存。 服务器响应还需要包含有关是否允许对交付的资源进行缓存的信息。 目标是提高客户端的性能,同时增强服务器端的可扩展性。
- 客户端/服务器解耦。 在 API 设计中,客户端和服务器应用程序必须彼此完全独立。客户端应用程序只需知道所请求资源的 URI 即可; 它不能以任何其他方式与服务器应用程序交互。 同样,除了通过 HTTP将客户端应用程序传递到所请求的数据外,服务器应用程序不应修改客户端应用程序。
- 分层系统架构。 在 API 中,调用和响应都会经过多个不同的层。类似于现在的微服务构架,请求会经过转发(代理)服务器、API 网关等层级,再进入真正的服务端程序。
- 按需编码(可选)。 API 通常发送静态资源,但在某些情况下,响应也可以包含可执行代码(例如 Java 小程序)。 在这些情况下,代码只应按需运行。
按照 REST 风格设计出来的 API,称为 RESTful API(有些地方也直接称为 REST API)。设计 API 时主要关注前三个原则即可,4、5在现在的软件构架中已经满足了。
从传统 API 过渡到 RESTful API
到目前为止,我们都是在定义和理论层面上谈论 REST。现在我们先忽略实现的细枝末节,来看一个传统方式设计 API 的例子,以便于我们将 REST 与 API 的设计联系起来。
假设我们要开发一个 图书管理系统,其中的一个功能是针对 书籍数据 做“增删改查”。交给不同的人设计后,可能会得到以下接口(先忽略请求和响应数据):
甲开发的书籍管理API
查询指定书籍 GET /which?id=1
查询多个书籍 GET /all
新建书籍 POST /create-book
修改书籍 POST /update-book
删除书籍 POST /del-book
乙开发的书籍管理API
查询指定书籍 POST /MyBook?id=1
查询多个书籍 POST /AllBook
新建书籍 PUT /book/addBook
修改书籍 PUT /book/modify
删除书籍 POST /removeBook
看,比如同样是修改的接口,甲和乙分别使用了不同的请求方式和路径结构。这暴露出了传统 API 设计中的一个缺陷:不同接口的 URL、使用方式(HTTP请求方式)根本没有任何标准,也没有规律。
单人开发项目可能无所谓。但对于大型系统、多人合作的项目中,API 没有统一的设计规范会导致接口难以理解和使用,甚至增加维护的难度。
此外还有一个问题是,我们对接口支持的 HTTP 请求方式的也没有明确规范。比如,我们可以选择设计出一个 DELETE /getMyUser/id=1
的用户信息查询接口,然后让响应数据以二进制的形式返回。这当然是一种可行的方案,但是多少有些奇怪。
如果是在团队中,为了更好的进行开发,我们可以尝试约定一些”硬性规范“。例如规定:
- URL 结构都是 动词缩写+名词,不允许多层级
- 强制查询以 get 开头、新建以 add 开头、删除以 del 开头
- 查询用 GET 请求,其他用 POST
对于不同功能的“增删改查”接口,我们都可以设计出相似的 API。例如:
查询书籍 GET /getAllBook
新建书籍 POST /addBooks
删除书籍 POST /delBooks
查询用户 GET /getUser
新建用户 POST /addUser
删除用户 POST /delUser
看起来比之前好一些了,可以将其制定为一种接口设计的规范(或者说风格)。
不过还可以进一步细化吗?比如:能否继续细化 URL 的路径层级?甚至统一单复数?
当然是可以的。以书籍的增删改查 接口为例,假设标识符(即 URI)为 /books
,一种方式是:
查询书籍 POST /books/get
新建书籍 POST /books/add
删除书籍 POST /books/del
或者,干脆在 URL 中省略动作,直接利用 HTTP 请求方法表达对资源进行不同操作。例如:
查询书籍 GET /books
新建书籍 POST /books
删除书籍 DELETE /books
后两种设计思路已经开始向 RESTful API 靠拢了。
深入 RESTful API
如果尝试从资源的角度去看增删改查的 API,可以得到一种理解方式:这些接口操作的目标都是资源,不同的接口对资源执行了不同的操作。
例如,上述例子中,接口操作的资源都是 书籍,不同的接口只是对 书籍 执行了不同的操作。(或者说让书籍这个资源发生了状态转移)
RESTful API 的核心就是利用 HTTP 请求方法作为操作类型(行为),通过标识符(URI)确定被操作的资源(即 REST 原则中的统一接口),同时还要满足 REST 的各项设计原则。
HTTP请求方法作为行为(核心内容)
HTTP中定义的全部请求方法可以在这个页面中找到:HTTP 请求方法
在 RESTful API 中,不同的 HTTP 请求方法表达了明确的含义:
方法 | 作用说明 | 是否幂等 | 对应的后端操作 |
---|---|---|---|
GET | 返回一个对象的值 | True | 查询 |
POST | 创建一个新对象,或者提交一个命令 | False | 增加 |
DELETE | 删除一个对象 | True | 删除 |
PUT | 更新一个对象(全量替换),或者创建一个有标识符的对象 | True | 全量更新 |
PATCH | 更新一个对象(部分更新) | False | 局部更新 |
OPTIONS | 获取一个请求的信息,即获取一个URL所支持的请求方法 | True | 无 |
HEAD | 返回一个对象GET响应中的元数据,相当于没有响应体的 GET | True | 查询/无 |
其中 OPTIONS
和 HEAD
用的相对较少。其他的 HTTP 请求方法几乎不会出现在 RESTful API 中故不做讨论。
一个接口并不需要支持全部的方法。但是对于支持的方法,接口必须符合对应的用法。
另外,如果条件允许建议加上对 OPTIONS
的支持,以便于客户端检索此接口的信息(而不是必须查询 API 文档)。其响应的头部应该至少包含 Allow
,同时推荐加上 Link
指向此接口的说明文档 URL。例如:
Allow: PATCH, DELETE
Link: <https://example.com/doc>; rel="help"
URL 基本结构
RESTful API 的 URL 应该是便于阅读、易于拼写的。参考不同的教程和 Github API,常见的 URL 的结构可以归纳为(括号表示可选):
http(s)://网址或IP(:端口)(/子路径)(/版本号)/资源名称(/路径参数)(?查询参数名=参数值)
例如:
一个结构良好的URL:
https://api.contoso.com/v1.0/people/jdoe@contoso.com/inbox
https://api.contoso.com/v1.0/items?url=https://resources.contoso.com/shoes/fancy
不友好的URL(不推荐):
https://api.contoso.com/EWS/OData/Users('jdoe@microsoft.com')/Folders('AAMkADdiYzI1MjUzLTk4MjQtNDQ1Yy05YjJkLWNlMzMzYmIzNTY0MwAuAAAAAACzMsPHYH6HQoSwfdpDx-2bAQCXhUk6PC1dS7AERFluCgBfAAABo58UAAA=')
一般 路径参数 是用来指定同类资源中的 ID、名称等唯一性参数,查询参数 则用来传过滤条件。
扩展:查询参数和路径参数
查询参数也叫过滤参数,顾名思义一般是用于向后端传参,过滤一些数据。写在URL末尾,以 ?
开头,多个参数用 &
连接。常见于 GET 和 DELETE。
路径参数是写在URL 路径中,多个由 /
分隔。
在 SpringMVC 中分别对应 @RequestParam
和 @PathVariable
。
对于表示有层级关系的数据,其中 资源名称 和 路径参数 可以出现多个(路径参数跟随在资源名称之后)。
Github 的 API 中也有多层级的情况,"OWNER", "REPO"分别是所有者名、仓库名: https://api.github.com/repos/OWNER/REPO/interaction-limits https://api.github.com/repos/OWNER/REPO/actions/workflows/WORKFLOW_ID/disable 例如第一条,表示的是在“仓库(repos)”资源里,属于“OWNER”用户所有的“REPO”仓库之下的“交互限制”
此外还有一些零碎的约定俗成之事:
- URI 不能出现动词(操作)。
因为 RESTful API 已经使用 HTTP 方法来表示对资源执行的操作,URI 中再出现操作就容易引发歧义。例如:GET /books/1
是恰当的,GET /book/get/1
是不恰当的 - 资源名应该用名词的复数形式。
资源是一类资源,复数表达了同一类资源集合的含义。非要用单数也行,但是记得保持全局一致,不要单复数混用。
- URL 中字母应该为小写。词组使用连字符
-
分割,而不是驼峰命名法。
准确的说是URI小写。但如果可以,URL其他部分(例如路径参数)也保持统一尽量小写。
另外关于 URI 中是否可用缩写似乎没有明确的共识。出于易读性考虑,建议少用缩写,且只用常见的缩写,例如:CPU,PC。
关于 RESTful API 几个常见的错误理解:
- 使用了 JSON 就是 RESTful API
- URL 中不能出现URL查询参数(
?
方式传参数)
备注:文中举例的情况都是用于前后端交互,但 RESTful API 的应用场景并不仅限于此,只要是开发 API 都可以考虑使用 REST。
为方便不熟悉 HTTP 的后端开发者理解,这里也给出一个简化的 SpringMVC 代码示例。
客户端请求了这些地址:
GET https://api.gameinfo.com/developers/mihoyo/games/genshin-impact GET https://api.gameinfo.com/games/1044620
对应的 Controller 定义:
@RestController public class GameProductController { @GetMapping("/developers/{developerName}/games/{gameName}") public Object getGameById(@PathVariable String developerName, @PathVariable String gameName) { // 查询名为 developerName 厂家开发的 gameName 游戏 ... } @GetMapping("/games/{gid}") public Object getGameById(@PathVariable Integer gid) { // 查询 ID 为 gid 的游戏... } }
URL 和 URI(扩展)
为了便于理解,这里简单的解释一下 URL 与 URI 的区别。另外也可以看看这篇文章:uri和url的区别与联系。
URL
即 Uniform Resource Locator
:称为 统一资源定位符,表示一个确切的网络资源的地址。
URI
即 Uniform Resource Identifier
:称为 统一资源标识符,用来标识 Web
上可访问的任意类型的资源 (HTML,视频,音频,程序)。URI 是相对的,主要作用就是用于与其他资源区别开来的一个标识符。
从级别上来说,URL 是 URI 的一个子集,URL 比 URI 更详细。例如,对于服务端来说,https://api.example.com/books/1003?param=abc
是URL,其中的 /books/1003
是URI。
REST 设计准则(从 REST 到 RESTful API)
现在我们已经对 REST 和 RESTful API 有了初步的了解了。先稍微暂停一下思考一个问题:我们在设计 API 时谈论 REST 究竟是想讨论什么?一个词:设计准则(Designing Guideline)。
对我们来说,运用 REST 进行设计比仅仅了解 REST 更有意义。这就需要一份进一步细化的准则(某些角度来说相当于项目的开发规范)。
参考 微软 REST API 设计准则 一书(以下简称设计指南),准则至少有以下两个作用:
- 细化实现方案和约束
- 保持设计出的 API 具有一致性
这里借用 设计指南 中的一句话,建立准则是为了一致地(consistently)开发接口。
This document establishes the guidelines Microsoft REST APIs SHOULD follow so RESTful interfaces are developed consistently.
在正式开始进行 RESTful API 设计和开发前,推荐在 REST 基础之上细化一套自己的设计准则,这对于团队开发来说更重要。
与传统 API 的差异点
- 一致性基础(Consistency fundamentals)
REST 风格为 API 设计提供了相对完善的设计标准,有一致性,易于人类阅读和理解。(推荐阅读 设计指南 第7章) - 强调语意
谓语动词(HTTP 请求方法)与执行的动作(增删改查等)一一对应。 - 幂等性
传统 API 不会很重视接口幂等。而 RESTful API 的 HTTP 请求方法同时关联了幂等性,开发者在实现此接口时也应该保证这个特性。 - Versioning(使用版本号)
设计 RESTful API 时推荐在 URL 中加入版本号(注意是URL不是URI)。这在一些准则中是强制的要求(例如 设计指南 第12章)。版本号的格式为{majorVersion}.{minorVersion}
,例如:# 在URL路径添加: https://api.contoso.com/v1.0/products/users # 作为URL过滤参数 https://api.contoso.com/products/users?api-version=1.0
另外 设计指南 中有一个注意点是:Services MUST increment their version number in response to any breaking API change.(参考译文:在发生任何破坏性 API 变更的时候,服务端都应该在响应中增加版本号的数字。)
优点与缺点
遵守 REST 风格能为我们带来以下好处:
- 更统一的接口URL风格,提高API的可读性(Human readable)
这个思想等同于一些公司的开发规范要求要给变量、方法起一个好的名称 - 消除URL设计不当产生的歧义、保证幂等性
- 更优雅的处理新接口(接口定义变更)问题
如果要修改API,一个选择是更新 API 的版本号,而不是在原有基础上做向后兼容。这减少了定义URL冲突的概率,同时也简化了新旧接口的命名问题
可能的好处:
- 无状态的 API 比有状态的 API 更易于开发
- 充分利用 HTTP 本身的特性
但缺点也是有的:
- REST 风格主要是针对对于资源的处理,而不是强调动作,增加了 API 的设计难度
- 需要开发者对HTTP协议、前后端框架(比如SpringMVC、Axios)都有一定了解
- 设计命名时可能需要一点的英语词汇量
请求与响应
HTTP 协议中请求、响应都包含了头部(Header)和载荷(Payload / Body)两部分。RESTful API 也会利用头部传递部分数据,设计时需要留意。
在请求中,常见且重要的头部有这些:
响应头部 | 类型 | 描述 |
---|---|---|
Authorization | 字符串 | 请求的授权数据 |
Date | 日期 | 请求的时间戳,以客户端时间为准且使用RFC 5322日期和时间格式。服务端不应该依赖客户端时间的准确性。此头部是可选的,但如果有,客户端一定要使用格林威治时间 (GMT),例如:Wed, 24 Aug 2016 18:41:30 GMT。此处 GMT 和 UTC 等效。 |
Accept | 内容类型 | 客户端期望的响应体数据类型,比如:application/xml,application/json,text/javascript (JSONP用)。对于服务端来说,此头部是一个提示作用,服务端也可以返回不同的内容类型。 |
Accept-Encoding | Gzip, deflate | 当合适时,REST 端点(API)应当支持 GZIP 和 DEFLATE 编码 |
Accept-Language | "en", "es", etc. | 指定客户端期望的响应使用的语言。服务端并不一定要支持,但提供本地化支持时,需要通过此字段实现。 |
Accept-Charset | 字符集类型,比如"UTF-8" | 默认是 UTF-8,但服务端应该也要能处理 ISO-8859-1 |
Content-Type | 内容类型 | 请求体的 Mime 类型(例如:application/json,PUT/POST/PATCH使用) |
响应中,应该至少包含以下内容:
响应头部 | 描述 |
---|---|
Content-Type | 响应体的内容类型(例如:application/json) |
Content-Encoding | GZIP 或 DEFLATE,事情况而定 |
Date | 处理响应的时间戳,以服务端时间为准且使用RFC 5322日期和时间格式。时间标准为格林威治时间 (GMT),例如:Wed, 24 Aug 2016 18:41:30 GMT。此处 GMT 和 UTC 等效。 |
还有部分头部会在特定场景下出现(例如 Prefer
、If-Match
等),用于告知客户/服务端一些信息,这里仅作提及而不展开了。
同时请记得,HTTP 响应码也是有语意的。换句话说,服务端需要根据具体情况,选择并返回合适的 HTTP 响应码。常用的如下:
- 200:成功
- 201:资源已创建(一般是POST 方法新增成功,且无响应体)
- 204:无内容(一般是 PUT 方法处理成功,且无响应体)
- 400:服务器无法处理的不正确请求
- 404:未找到资源
一般来说,直接根据 HTTP 协议中规定各项状态码含义进行选择即可。更详细的示例也可参考 微软 REST API 设计指南。
如何处理更多的动作?
RESTful API 操作的主体是资源,常用的 HTTP 方法很适合“增删改查”的需求。但对于侧重于表达动作(操作),和除了“增删改查”以外操作的 API ,想按 REST 设计似乎就变困难了。
这就需要我们给业务进行进一步地抽象。这里也给出两种参考。
一种方式是,将 动作形成的结果 视为抽象的资源,然后对这个 结果 进行操作。GitHub 的 API 中就能找到类似的思想,例如封禁、解封用户接口(阻止用户 - GitHub Docs):
- Block a user from an organization:PUT
/orgs/{org}/blocks/{username}
- Unblock a user from an organization:DELETE
/orgs/{org}/blocks/{username}
此时操作的资源不是用户,而是将封禁的用户列表视为一份资源。封禁、解封用户就是对列表内容进行放入和删除。
另一种处理方式是,将 动作的承受者 看作资源,并参数化操作的或操作结果。
再举个例子,设计一个用于 启动或者停止执行指定任务 的接口。此时 任务 可以被视为被操作的资源;因为开始、停止两个动作执行后会改变任务的运行状态,我们就可以把 运行状态 设计为参数。使用时如下:
// 启动任务
PATCH /tasks/{id}
Content-Type: application/json
{ "status": "start" }
// 停止任务
PATCH /tasks/{id}
Content-Type: application/json
{ "status": "stop" }
此类 API 一般只需要响应成功即可,故处理完成后可以只响应 204 不用带上响应体。
另外请留意,这样的情况下很可能会选择 PUT、DELETE 等 HTTP 请求方式,开发时就需要恰当地处理接口的幂等性。
实际运用的问题与难点
到此,我们已经熟悉了 REST 本身和基本的用法。不过一旦开始应用又有新的问题。这一章节就来看看一些常见的问题和难点。
HTTPS 使用和实现方案
一定要 HTTPS 吗?不,但是非常建议使用 HTTPS,因为:
- 更安全
- SSL 连接建立在TCP层和HTTP层之间,URL(路径和参数)始终是加密的
根据安全要求不同,开启 HTTPS 有不同的实现方案:
- 如果是微服务构架,可考虑应用程序(API)层级不做加密,在上层的网关做 HTTPS ,然后由网关对外部暴露 API。
- 对 CDN 启用加密,服务器只允许 CDN 节点访问。
响应体形式的选择(是否使用 JSON?)
先说结论:优先使用客户端指定的响应格式(Accept
头部),如果服务端可自行选择再首选 JSON。
REST 并不强制资源以何种方式返回(即资源的表现形式是多样的),理论上接口强制返回 XML 或其他格式也符合 REST,但这样设计得到的是一个糟糕的 API。更好的设计方式是,让 API 优先返回请求头中的指定的类型(不支持则响应 415 Unsupported Media Type
),而不是无论如何都返回 JSON。
为了简化开发,可以参考以下两种实现思路(以 SpringMVC 为例):
- 在框架层面统一判断
Accept
并转换响应内容(例如自定义拦截器) - 控制器(业务层)只返回 JSON,同时拦截所有
Accept
字段不是application/json
的请求(偷懒的做法)
使用 GET 传输 Body(请求体)
HTTP 协议中既没有明确规定 GET 不能带 Body,也没有说明可以带 Body。但是不同的程序的处理方式不一样,例如 Chrome 在发起 GET 请求时默认会抹掉 Body 部分,但是 curl 不会。
curl请求演示
后端关键代码(SpringMVC 的 Controller):
@RestController
public class TestController {
@GetMapping("test")
public String queryUser(@RequestBody UserParam userParam) {
System.out.println("userParam = " + userParam);
return "{\\"user\\":\\"Steve\\",\\"mail\\":\\"ste118@exmail.com\\"}";
}
}
record UserParam(String name, Integer age) {}
因此考虑到兼容性问题,不建议在 GET 时带上 Body。如果需要传递参数,使用过滤参数(URL参数),DELETE 同理。同时建议客户端转义参数中的特殊字符(做一次 URL Encode)。
例如前端请求 API 时,使用 GET /test?name=steve&age=2
(没有HTTP Body)后端以 SpringBoot 为例,以下两种写法都是可行的:
- 使用
@RequestParam
或@PathVariable
接收参数(适合简单参数)@GetMapping("test") public Object queryUser(@RequestParam String name, @RequestParam Integer age) { // do something... }
- 使用 POJO 接收参数(适合复杂参数)
@GetMapping("test") public Object queryUser(@Validated UserParam userParam) { // do something... } record UserParam(@NotEmpty String name, Integer age) { }
注意这种情况下是由 Spring 组装参数,POJO 内有关 JSON 的注解、处理均不会生效。如果字段为枚举可通过
org.springframework.core.convert.converter.Converter
接口自定义包装类型到枚举的转换类。
在实际开发中,建议在满足以下情况时才考虑在 GET 中使用 Body:
- API 仅在后端与后端之间使用
- API 的 URL 参数难以构造或 URL 过长
- API 的提供者、使用者双方都很熟悉 HTTP 和自己所用的网络框架
- API 提供方、调用者之间不存在会修改传输数据的层级(包括网关和代理等)
URL的长度
省流:建议不超过 2083个字符,否则需要与客户端协商。
HTTP 1.1 协议中(RFC 7230 3.1.1 章)没有规定URL的长度限制。但是考虑到多数的客户端(主要是浏览器)与服务端之间存在限制,按 IE 浏览器 URL 长度限制为 2083 作为 URL 长度上限较好。
是否放弃 GET?
在设计 GET 的查询 API 时,可能会遇到违反上述两条原则的情况(比如使用 GET 请求且用 URL 传参导致长度过长)。这个时候需先考虑一下API参数设计是否合适。
另一种方案是客户端仍然使用 POST 发起请求,同时使用 X-HTTP-Method-Override
头部,告诉服务端应该使用哪一个动词。或者放弃按 RESTful API 来设计。
PUT or PATCH?
两者主要区别:
- PUT 表示全量更新,幂等
- PATCH 表示局部更新,非幂等
PATCH 在2010年加入到 HTTP 方法中,主要目的是解决每次更新时全量传输数据过大的问题。一小部分框架或工具默认情况不支持 PATCH。
另外使用 PATCH 时,传递的 JSON 结构可能和 POST / PUT 的结构有很大差别。因此建议 API 提供者、使用者双方提前进行协商。
综合考虑使用和实现 API 两方面,两者各有优势。例如前端使用 Vue + Element UI 构建编辑页面时,PUT 的接口更易于使用。后端使用 SpringMVC + MyBatisPlus 时,默认设置下更符合 PATCH 的语意。
项目应该按需选择(一般二选一即可)。
可以“打破”无状态性吗?
不能,无状态性是 REST 的设计原则之一。而且对于分布式应用设计为无状态性可以化简开发难度。
绝大部分有“状态性”情况都可以按以下情况处理:
- 将状态转化为请求的参数从而消除状态(即API自身仍然是无状态的,例如分页查询中的页码)
- 借助 cookies 等方式将状态数据保存在客户端,而不是在服务端
统一响应体(Response Data)
实际开发中,很多团队会选择设计使用一个统一响应体,包裹每一个 API 返回的数据。例如以下是一个查询响应:
{
"code": 1, // 业务状态码(一般用于表示成功失败)
"message": "成功", // 状态消息
"data": { // 实际的数据节点
"account": "steve",
"point": 100
}
}
开发中可能会遇到以下三种情况:
- 请求处理成功 ✅ :HTTP 状态码为
200
,业务状态码为成功,data 为实际的数据 - 业务处理异常 🔴 :HTTP 状态码为
200
,业务状态码为失败,data 可能没有任何含义 - 接口请求异常 ❌ :HTTP 状态码为非
200
,可能根本没有进入到业务逻辑
备注:从接收方的语义上来说,这种情况下统一响应体被视为响应的一部分,而不是一层包装。换句话说除了数据的结构不一样外,上述的示例等效于:
{
"code": 1, // 业务状态码
"message": "成功", // 状态消息
"account": "steve",
"point": 100
}
使用统一响应体可以单独表达业务逻辑的成功状态,并不违反 REST 风格。
不过 RESTful API 中,HTTP 响应的状态码已经可以明确地表达调用(请求)的成功状态。所以也可以直接利用HTTP状态码表示成功状态,而非统一响应体。这也是一种常见的用法(例如 GitHub 的 API)。
API 响应成功时直接返回数据;API 响应失败时,再返回错误代码(业务状态代码)、描述。
但是这样做也有明显的缺点:
- 对于浏览器,开发者工具的 Network 和 Console 中会提示非 HTTP 2xx 的响应
- 对于一些后端工具(例如 Spring 的
FeignClient
)无法灵活的处理非 HTTP 2xx 响应
后端(SpringBoot)和前端(Axios)的实现并不复杂
// 控制器 @RestController public class TestController { @GetMapping("user-center/v1.1/user/{id}") public String queryUser(@PathVariable String id) { return "{\\"name\\":\\"Steve\\",\\"point\\":100}"; } } // 全局异常处理 @RestControllerAdvice public class CommonExceptionHandler { @ExceptionHandler(value = BusinessException.class) public ResponseEntityhandleBusinessException(BusinessException e) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new Error(1001,"无法处理该请求")); } @ExceptionHandler(value = RuntimeException.class) public ResponseEntity handleOtherException(RuntimeException e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new Error(1003,"服务器未知异常")); } public static record Error(Integer errorCode, String description) {} }
axios.request({
url: 'user-center/v1.1/user/10001',
method: 'get',
}).then(response => {
const { name, point } = response.data
// 获取服务端的返回的数据
console.log(`用户: ${name} 共有 ${point} 积分`)
}).catch(error => {
if (error.response) {
// 请求成功发出且服务器也响应的状态码超出了 2xx 的范围
console.log(`HTTP ${error.response.status} (${error.response.statusText})`);
// 获取错误描述
const { errorCode, description } = response.data
console.log(`出错了,原因: ${description},错误代码: ${errorCode}`);
} else {
// 发送请求时出了问题,一般是网络异常
console.log('Network Error', error.message)
}
});
认证(权限)
认证方式需要对以下方面友好:跨域、扩展(scaling)/ 负载均衡,所以应该使用 JWT 等无状态性的认证方案,而非 session。实际上大部分 RESTful API 都使用 OAuth 2.0 进行身份认证。
另外在现代构架中(例如微服务),也可考虑在网关处做认证,应用程序不做认证。
CORS(请求跨域)
这不是 REST 本身带来的问题,但是在设计 API 时有一些值得注意的地方。在 微软 REST API 设计准则 中(第8章),对遵从 设计指南 开发出的服务有这么一句话:
Services SHOULD support an allowed origin of CORS * and enforce authorization through valid OAuth tokens. Services SHOULD NOT support user credentials with origin validation.
参考译文:
服务端应当支持所有跨站(CORS *)的来源,且强制进行使用 OAuth 令牌的授权。服务端不应当支持带有来源验证的用户凭证。
结合此章节上下文,这给了我们两点设计上的启发:
- 使用无状态性、可跨域的认证方案
- 服务端直接完全开放 CORS 限制(客户端也不需要单独处理 CORS 问题)
另外如果请求需要 cookies,响应头部应带上 Access-Control-Allow-Credentials
并设为 true
。
@Component
public class CorsFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletResponse res = (HttpServletResponse) response;
// *号表示对所有请求都允许跨域访问
res.addHeader("Access-Control-Allow-Credentials", "true");
res.addHeader("Access-Control-Allow-Origin", "*");
res.addHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT");
res.addHeader("Access-Control-Allow-Headers", "Content-Type,X-CAF-Authorization-Token,sessionToken,X-TOKEN");
if (((HttpServletRequest) request).getMethod().equals(HttpMethod.OPTIONS.name())) {
response.getWriter().println("Success");
return;
}
chain.doFilter(request, response);
}
}
处理 CORS 预检
CORS 策略可能会导致 预检(preflight),客户端可能需要避免这种情况(例如客户端只发送简单HTTP请求)。
此外服务端需要根据请求头部正确的处理预检请求。根据 设计指南 中的内容(章节 8.2)
If the request uses the OPTIONS method and contains the Access-Control-Request-Method header, then it is a preflight request intended to probe for access before the actual request. Otherwise, it is an actual request.
以下情况可以视为预检:
- 请求头部中出现
Origin
- 使用 OPTIONS 方法进行请求
- 头部中出现
Access-Control-Request-Method
对于预检请求,服务端的响应头部需要增加以下内容:
Access-Control-Allow-Headers
客户端允许使用的头部(Header)列表(简单请求的头部Accept
、Accept-Language
等可忽略)。 如果不做限制,也可以直接返回客户端请求中Access-Control-Request-Headers
头部的值。Access-Control-Allow-Methods
允许使用者使用的 HTTP 请求方法,例如 GET,POST,OPTIONS 等
篇幅有限这里,不过多讨论 预检、简单请求 和 复杂请求。
实际开发中,如果为每一个 API 都单独配置预检(即实现较小的细粒度),代码可能会变得复杂。一个替代方案是,对所有 API 在收到预检时都做相同的处理。
例如使用 SpringBoot 开发时,可以配置一个 Interceptor
或 Filter
统一处理,代码见上一小节的示例。
放弃 REST?
(对,没看错标题,这是不少开发者所纠结的问题。)
从前文的内容不难看出,出于各方面原因,有时确实难以遵循 REST 风格设计 API。例如以下情况明显不适合按 REST 设计:
- 服务端必须兼容一些仅支持 GET / POST 的非常旧的客户端
- 需要大规模改造旧的后端服务 API,或为使用旧技术的客户端开发新 API
文中就不展开说和技术无关的内容了。只是说作为一个服务的开发者,有时也应该考虑一点非技术的事,例如开发难度、效率、成本等。
当然其他情况只要没有与 REST 风格有明显矛盾,都可以考虑按 REST 开发。尤其是开发大型项目时有利于统一风格。
推荐阅读
文章的最后,汇总了一些有价值的资料,以供参考。
- 微软 REST API 设计准则(指南):api-guidelines/Guidelines.md(推荐想深入研究的读者阅读)
- GitHub REST API 使用说明文档:GitHub REST API - GitHub Docs(优秀的 REST API 示例)
- RESTful 入门(资料):RESTful API 一种流行的 API 设计风格 / 一文搞懂什么是RESTful API
- REST 构架风格论文(原文):Representational State Transfer (REST)
最后感谢阅读~
https://xyuxf.com/archives/2181
欢迎关注 咸鱼先锋 (微信号公众号:xyuxf),获取干货推送
共有 0 条评论