理解RESTful API 架构设计规范与实践
摘要
本文介绍了 REST 的由来,对 REST 的风格架构设计指导原则做了详细的说明。同时举例了过往开发中若干细节的考虑和实现方案。
文字略长,预计需要10 ~ 20 分钟读完。也可以收藏起来,在需要的时候查阅。
RESTful 架构是目前流行的一种互联网应用架构。如果把网站,移动应用从服务器到前端,从整体上看作是一个软件,它就是一个层次清楚,功能强,扩展方便,适宜通信的架构规范。
· 01 ·
什么是 REST
REST 是 “Representational State Transfer”的缩写,直译过来就是“表述性状态转移”。这是一个很奇怪的名词,刚看到的时候,不知其所以然。这个名词来源于 Roy Thomas Fielding 博士著名的论文《Architectural Styles and the Design of Network-based Software Architectures》(架构风格与基于网络的软件架构设计)。
论文发表于2000年,作者在基于 REST 的约束上设计了 HTTP 协议。设计 REST 的目的,就是为了指导现代 Web 架构的设计与开发。他是 HTTP 协议(1.0 版和 1.1 版)的主要设计者、Apache 服务器软件的作者之一、Apache 基金会的第一任主席。
论文的第六章,作者解释了这个名词的由来:
REST 最初被 称作“HTTP 对象模型”,但是那个名称常常引起误解,使人们误以为它是一个 HTTP 服务 器的实现模型。这个名称“表述性状态转移”是有意唤起人们对于一个良好设计的 Web 应 用如何运转的印象:一个由网页组成的网络(一个虚拟状态机),用户通过选择链接(状态转移)在应用中前进,导致下一个页面(代表应用的下一个状态)被转移给用户,并且呈现给他们,以便他们来使用。
第四章描述了设计 REST 的动机:“为 Web 应该如何运转创建一种架构模型, 使之成为 Web 协议标准的指导框架”。第五章从一个没有约束的空架构开始,不断的添加约束,从而使此架构进化为 Web 所需要的架构。
所以,REST 是一组架构约束。
REST 约束包括:客户-服务器,无状态,缓存,统一接口,分层系统,按需代码。如果一个架构符合 REST 原则,就称它为 RESTful 架构。
为实现统一的接口,REST由四个接口约束来定义:
资源的识别(identification of resources)、
通过表述对资源执行的操作(manipulation of resources through representations)、
自描述的消息(self-descriptive messages)、
应用状态引擎的超媒体(hypermedia as the engine of application state,HEOAS)。
当我们用这个原则来设计服务器端的接口,为前端或者外部提供数据时,就称它为 RESTful API 。目前观察到,业内在实践中有下面这些指导原则:
用统一资源标识符来标识资源
应用状态引擎的超媒体(HEOAS)
使用标准的 HTTP 方法
安全性和幂等性
无状态性
在实际开发过程中,我们还会涉及下面几个方面:
版本
鉴权
常见场景
状态码和错误处理
返回结果
文档
在 REST 出现之前,程序间的网络通信架构采用的是远程过程调用(Remote Procedure Call,RPC),而后又在 RPC 基础上发展出来简单对象访问协议( Simple Object Access Protocol,SOAP),此后,出现了 REST。
REST 是一种面向资源的架构,它能更好的适应分布式下的系统架构设计。对于开发者来说,越来越简单,越来越灵活。
· 02 ·
什么是资源
资源是一种概念上的映射。
任何能够被命名的信息都能够作为一个资源,它是对信息的核心抽象:一份文档、一张图片、一个与时间相关的服务(例如:“我现在城市的天气”)、一个包含其他资源的集合、一个非虚拟的对象(例如:用户)等等。它是到一组实体的概念上的映射,而不是实体本身。
更精确地说,资源 R 是一个随时间变化的成员函数 MR(t),该函数将时间 t 映射到等价的一个实体或值的集合,集合中的值可能是资源的表述和,或资源的标识符。
资源不是存储对象,也不是单个文件,更不是某个文本、音频、视频等具体事物。
在设计具体 API 的时候,资源是业务系统里,抽象出来的一个业务对象。例如:用户(User),订单(Order),令牌(Token)等等。它允许随时间变化,输出不同值。
一个资源具有一个或者多个标识。这里说的标识就是统一资源标识符(Uniform Resource Identifier,URI)。统一资源定位符(Uniform Resource Locator,URL)则是 URI 的一种具体实现,是 URI 的子集。在通常的 API 设计中,直接使用 URL 来标示应用系统中的资源。
例如:
https://www.sample.com/api/shops/10083 - 编号10083店铺的基本信息
https://www.sample.com/api/users/2372/orders - 编号2372用户的所有订单信息
在标准的 REST 规范观点中,通常会要求将资源 shop 加上复数形式 s 表示多个资源。但现实中,很多时候忘记给获取多个资源的接口加上复数形式,在不影响理解的前提下,这种做法也不是不可以接受。
实践中常见的情况有:
移动应用的首页,通常有多种资源:横幅广告、编辑精选的内容、针对当前用户推荐的商品,相应的对应着三个资源:广告(banners),内容(contents),商品(products)。
通常后端的开发会让前端调用这三个资源的接口,获得相应的数据。但是前端开发会要求,后端能不能针对首页单独给一个接口?
为适应这种情况,我们会创建一个首页的资源:index,这个资源包括需要展示的其他子资源。如:
https://www.sample.com/api/indexs。
几乎所有的应用,都会有一个搜索功能。我们很容易习惯性的设计这样的接口:
https://www.sample.com/users/search?key=searchkey。
这样的接口设计就沿袭了 RPC 的设计风格,表示 users 服务提供的 search 方法,不符合 REST 规范,也是规范里建议的,URI中不要有动词。因为"资源"表示一种实体,所以应该是名词,URI不应该有动词,动词应该放在HTTP协议中。
推荐的做法是设计一个资源 search,也就是说把动词变成资源。例如:
https://www.sample.com/search/?key=searchkey&source=user
https://www.sample.com/search/user?key=searchkey
· 03 ·
应用状态引擎的超媒体(HEOAS)
这个名词也非常的拗口。它实际的意思是,在资源的表述中,如果有其他资源的,则会提供相应的链接 URL,使得用户不查文档,也知道如何获得相关的资源。
Fielding 明确表示,系统必须满足 HATEOAS 约束才能称为是符合 REST 风格的。
Github 的 API 就是这种设计,访问 api.github.com 会得到一个所有可用的 API 的信息列表,类似这样:
{
"current_user_url": "https://api.github.com/user",
"current_user_authorizations_html_url": "https://github.com/settings/connections/applications{/client_id}",
"authorizations_url": "https://api.github.com/authorizations",
...
}
所以 HATEOAS 会被称为状态引擎,因为它会引导状态的转移。
在设计的理想状态中,使用 HATEOAS 的 REST 服务中,客户端可以通过服务器提供的资源的表达来智能地发现可以执行的操作。当服务器发生了变化时,客户端并不需要做出修改,因为资源的 URI 和其他信息都是动态发现的。
这样的设计,保证了客户端和服务器的实现之间是松耦合的。客户端需要根据服务器提供的返回信息来了解所暴露的资源和对应的操作。当服务器端发生了变化时,如修改了资源的 URI,客户端不需要进行相应的修改。
在实践中,我没发现哪家国内的公司公布的 API 的接口遵守了这条原则,我们自己开发时,也不实现这条原则。为什么实际中大部分开发不遵守这条原则呢?
因为客户端无法决策!
HTTP 能实现 RESTful,是因为浏览器只是将表述以及对资源的操作选项展示了出来,至于具体该如何操作,是由使用浏览器的人来决定的。也就是说,虽然服务端告诉了客户端操作的可选项,但是客户端没办法知道该选择什么。网页浏览是有人参与的,但是 RESTful API 没有人参与,导致 RESTful API 的客户端难以做出决定,该做什么。
鉴于现实这个尴尬情况,Richardson 提出了「REST成熟度模型」。该模型把 REST 服务按照成熟度划分成 4 个层次:
Level 0:Web 服务只是使用 HTTP 作为传输方式,实际上只是远程方法调用(RPC)的一种具体形式。SOAP 和 XML-RPC 都属于此类。
Level 1:Web 服务引入了资源的概念。每个资源有对应的标识符和表述。
Level 2:Web 服务使用不同的 HTTP 方法来进行不同的操作,并且使用 HTTP 状态码来表示不同的结果。如:HTTP GET 方法来获取资源,HTTP DELETE 方法来删除资源。
Level 3:Web 服务使用 HATEOAS。在资源的表述中包含了链接信息。客户端可以根据链接来发现可以执行的动作。
从成熟度模型中可以看到,使用 HATEOAS 的 REST 服务是成熟度最高的,也是推荐的做法。但实际的落地实现时,多数都是次一级的做法。
· 04 ·
使用标准的 HTTP 方法
RESTful API 使用标准的 HTTP 协议实现前后端的接口调用。对涉及到的资源,常用的操作就是增、删、改、查,类似对数据库记录的CRUD(Create,Read,Update,Delete)。使用的 HTTP 方法规则如下:
查询 GET :GET /users/{userId}
增加 POST:POST /users
全量修改 PUT:PUT /users/{userId} 即提供该用户的所有信息来修改
部分修改 PATCH:PATCH /users/{userId} 只提供需要的修改的信息
删除 DELETE:DELETE /users/{userId}
在修改的时,PUT 和 PATCH 区别在于 PUT 是全量修改,user 资源有多少信息,需要全部提供,而 PATCH 可以只修改手机或者邮箱,昵称,密码等信息。
HTTP 的方法中还有两个涉及到 REST:HEAD 和 OPTIONS。
HEAD 方法用于得到描述目标资源的元数据信息。
例如,腾讯云的对象存储的API:HEAD Bucket 请求可以确认该存储桶是否存在,是否有权限访问。
请求:
HEAD / HTTP/1.1
Host: <BucketName-APPID>.cos.<Region>.myqcloud.com
Date: GMT Date
Authorization: Auth String
服务器响应:
OPTIONS /exampleobject HTTP/1.1
Host: examplebucket-1250000000.cos.ap-beijing.myqcloud.com
Date: Thu, 12 Jan 2017 17:26:53 GMT
Origin: http://www.qq.com
Access-Control-Request-Method: PUT
OPTIONS 请求用来确定对某个资源必须具有怎样的约束。
使用场景:客户端先使用 OPTIONS 询问服务端,应该采用怎样的 HTTP 方法以及自定义的请求报头,然后根据其约束发送真正的请求。
例如,腾讯云的对象存储 OPTIONS Object 接口实现 Object 跨域访问配置的预请求。即在发送跨域请求之前会发送一个 OPTIONS 请求并带上特定的来源域,HTTP 方法和 Header 信息等给 COS,以决定是否可以发送真正的跨域请求。
请求:
OPTIONS /exampleobject HTTP/1.1
Host: examplebucket-1250000000.cos.ap-beijing.myqcloud.com
Date: Thu, 12 Jan 2017 17:26:53 GMT
Origin: http://www.qq.com
Access-Control-Request-Method: PUT
服务器响应:
HTTP/1.1 200 OK
Content-Type: application/xml
Content-Length: 16087
Connection: keep-alive
x-cos-request-id: NTg3NzRiZGRfYmRjMzVfM2Y2OF81N2YzNA==
Date: Thu, 12 Jan 2017 17:26:53 GMT
ETag: \"9a4802d5c99dafe1c04da0a8e7e166bf\"
Access-Control-Allow-Origin: http://www.qq.com
Access-Control-Allow-Methods: PUT
Access-Control-Expose-Headers: x-cos-request-id
完全按 REST 指导原则采用标准 HTTP 方法很美好。但是实践中,各大平台推出了内置小程序,前端在调用小程序的 request 请求时,发现只支持 GET、POST 方法。
这种情况下,导致后端的 API 接口不得不做调整,所有方法在设计之初就只用 GET/POST 方法。
或者在已成型的系统中使用扩展属性:X-HTTP-Method-Override,一个非标准的HTTP协议头,来绕过这个问题。前端统一使用POST,在请求头带上这个非标属性,服务端根据Header:X-HTTP-Method-Override,转换成真正的 METHOD。
· 05 ·
安全性和幂等性
在讨论 RESTful API 接口设计时,会提到两个基本的特性:“安全性”和“幂等性”。
安全性是指调用接口不对资源产生修改。
幂等性是指调用方法1次或N次对资源产生的影响结果都是相同的。
需要特别注意的是:这里幂等性指的是对资源产生的影响结果,而不是调用HTTP方法的返回结果。
常用 HTTP 方法的幂等性和安全性总结:
HTTP 方法的幂等性和安全性从上述表格中可以看出,HTTP 方法的幂等性和安全性并不是同一个概念:
OPTONS、HEAD、GET 既是幂等也是安全的,不修改资源,多次调用对资源的影响是相同的。
POST、PATCH 既不幂等也不安全,修改了资源,同时多次调用时,对资源影响是不同的,PATCH 的影响不同在于,每次的局部更新可能会导致资源不一样。
PUT 是对资源的全量更新,多次更新总是对资源影响是一致的,所以它是幂等,但不安全。
DELETE 用于删除资源,多次调用的情况下,都是删除了资源,所以它是幂等,但不安全。
幂等性原本是数学中的含义,表达式的N次变换与1次变换的结果相同。为什么要在接口设计时,考虑幂等性?
在实际的业务流程场景下,我们可能会碰到下面一些问题:
订单创建接口,前端调用超时了,但服务端已经完成了订单的创建,然后前端显示失败,用户又点了一次。
用户完成了支付,服务端完成了扣钱操作,但前端超时了,用户不知道,又去支付了一次。
用户发起一笔转账业务,服务端已经完成了扣款,接口响应超时,调用方重试了一次。
以上类似的场景,需要在设计接口时考虑幂等性。我们可以借鉴微信支付的接口方案来实现这类场景需要的幂等性。在支付之前,需要调用一个接口生产预支付交易单,获得一个交易单号,随后再针对这个交易单号完成支付。服务端确保一个交易单号只会被支付一次,这样就保证了支付过程的幂等性。
在对幂等性的理解上,有时候我们会有疑惑:服务端一般会有日志、缓存或者数据表上的计数器、最后更新时间等。这样上面说的 GET、PUT、DELETE 符合幂等性的方法就会导致这些数据内容的变化,是不是就不是幂等性的方法呢?
我认为在这个幂等判断问题上,还是要回到什么是资源的定义问题上来。服务端的日志、缓存、计数标志、更新时间等,不属于抽象出来的核心概念,也就是对资源没有本质上的影响,这些方法仍然是幂等的。
· 06 ·
无状态性
客户端与服务端的交互必须是无状态的,并在每一次请求中包含处理该请求所需的一切信息。服务端不需要在请求间保留应用状态,只有在接受到实际请求的时候,服务端才会关注应用状态。
这种无状态通信原则,使得服务端和中介能够理解独立的请求和响应。在多次请求中,同一客户端也不再需要依赖于同一服务器,方便实现高可扩展和高可用性的服务端。
REST 只维护资源的状态,并不维护客户端状态,而且 HTTP 本身是无状态的。那如何在用户登录后,和服务器之间传递用户信息呢?
以前这类场景下的做法是使用 cookie 和 session,两者的区别在于,是存在客户端还是服务端。这种设计,就违反了无状态通信的设计原则。实践中,API 设计成在用户登录之后,服务端会返回客户端一个用户令牌(Token),并带有失效时间,客户端在后续的接口请求都会带上这个令牌。
· 07 ·
版本
API 上线运行后,就会有版本升级的情况。在 API 设计中,如何体现不同的版本?有两种通常的做法:
将版本号放入 URL
将版本号放在 HTTP 信息头
两种方法各有优缺点:放在 URL 中,直观,方便。Github 就是这样的方案。
网上也有另一种说法,就是不同版本的资源,仍然还是资源的不同表现形式,所以 URL 应该是同一个,版本号需要放在 HTTP 信息头的 Accept 字段中。
本人比较推荐第一种方案,将版本号放在 URL 中,对前端的兼容性、普适性更友好。
· 08 ·
鉴权
REST 设计原则并未提到有些需要权限的业务场景下应该怎么做。在实践中,推荐使用 OAuth 2.0 标准来实现 API 的鉴权需要。具体如何实现,篇幅可能需要比较长,不在此赘述。
· 09 ·
常见场景
业务系统总是复杂的,前面举了一些资源的操作的例子,但是距离一个实用的系统还远远不够。
例如需要对列表信息进行约束。应用在初始时只显示开始的若干条记录,等待用户翻页或者下拉操作后,需要服务端从第几页开始,返回一页多少条记录。类似下面这样:
?pageIndex=2&pageSize=20 // 指定返回第2页的20条数据
?start=200&limit=20 // 指定返回从第200条记录开始的20条数据
如果不使用动词,原来用户登录接口 POST /user/login 这样的应该如何设计?
还是使用资源的思维,用户登录就是为了获得身份认证信息,所以这个资源就是“认证”,例如:
GET /authorize?username=xx&password=xx
GET /token
· 10 ·
错误处理和返回码
REST 设计原则推荐使用 HTTP 状态码来返回服务端信息。如下:
200 OK - [GET]:服务器成功返回用户请求的数据。
201 CREATED - [POST/PUT/PATCH]:用户新建或修改数据成功。
202 Accepted - [*]:表示一个请求已经进入后台排队(异步任务)
204 NO CONTENT - [DELETE]:用户删除数据成功。
400 INVALID REQUEST - [POST/PUT/PATCH]:用户发出的请求有错误,服务器没有进行新建或修改数据的操作。
401 Unauthorized - [*]:表示用户没有权限(令牌、用户名、密码错误)。
403 Forbidden - [*] 表示用户得到授权(与401错误相对),但是访问是被禁止的。
404 NOT FOUND - [*]:用户发出的请求针对的是不存在的记录,服务器没有进行操作,该操作是幂等的。
406 Not Acceptable - [GET]:用户请求的格式不可得(比如用户请求JSON格式,但是只有XML格式)。
410 Gone -[GET]:用户请求的资源被永久删除,且不会再得到的。
422 Unprocesable entity - [POST/PUT/PATCH] 当创建一个对象时,发生一个验证错误。
500 INTERNAL SERVER ERROR - [*]:服务器发生错误,用户将无法判断发出的请求是否成功。
实践过程中,不推荐 API 设计采用上面的错误返回码。这样做的结果会把业务系统里面本身的错误状态与 HTTP 协议本身的错误混淆起来,有悖于架构设计中分层的原则。例如:
前端收到一个 404 错误时,不清楚是服务端没有这个接口,还是请求的业务系统中的对应资源不存在。
出现 403 错误时,是因为服务端部署出现了权限错误,还是用户的对请求的资源权限不够。
出现 500 错误时,是不是服务端的服务器问题,还是创建资源时,不满足业务系统的一些限制条件。
所以,在最终的实际运行的系统中,只保留了一个200状态码,表示服务端收到了请求,并做了处理,业务系统本身的错误返回信息在结果中体现。
· 11 ·
返回结果
上一节说了服务端返回时 HTTP 状态码时统一使用 200,表示业务系统正常运行。如果有系统的错误提示信息需要向调用者返回,则在返回结果中统一定义。如下面是一个常见的返回对象:
{
code: 0, //表示接口执行是否成功,0:成功,非0:某一类预先定义的失败错误码。
data: object, // 返回的数据对象
error: 'error descrtion message', // 返回码有错误时,返回对错误描述性的文字,方便调用端处理
}
调用端在处理错误返回信息时,也有两种方案:
直接显示服务端返回的错误信息
根据错误码,前端自行定义和显示更人性化的错误提示信息
第一种方案便于系统修改,可以在不修改调用端的情况下,修改返回的显示信息。
第二种方案对用户更友好,常见用于调用外部接口时,屏蔽掉让用户不明白的错误提示信息,显示遇到错误时,应该如何处理的友好提示。
· 12 ·
接口文档
实际系统开发过程中,接口文档也是非常重要的一环。如果单独专门编辑接口文档,时间一长,就会造成代码和文档的不一致情况。开发团队还需要专门费事费力的在代码版本升级后,同时更新接口文档。所以最好的办法,就是写代码的时候,同时更新文档。
如果使用 Spring boot 开发的朋友,可以使用 swagger 自动生成在线的接口文档,并且支持自动生成调用参数,在线测试开发好的接口。
其他语言的文档生成工具,可以试试 apidoc。也是通过读代码中的约定格式的注释,自动生成 API 文档,也支持在线测试调用。
以上,大致将 RESTful API 架构设计原则做了一个框架性的介绍,附带介绍了具体项目实践中的几种落地的做法。
技术发展到现在,在具体的代码实现上,Java 的 Spring boot 框架已经很成熟,能满足所有 RESTful API 设计原则。