REST 介绍
起源
REST 源于 Roy Thomas Fielding 在 2000 年发表的博士论文:《Architectural Styles and the Design of Network-based Software Architectures》。其中提到了 Representational State Transfer(简称 REST)作为一种面向 Web 的软件架构风格,用于处理分布式系统中的数据交互。
在当时,Web 服务的架构大多是采用 SOAP(Simple Object Access Protocol)和 WSDL(Web Service Description Language)来实现。但是 SOAP 和 WSDL 要求使用 XML 作为数据格式,因此对于移动设备和带宽受限的网络环境并不友好。同时 SOAP 和 WSDL 还需要额外的开销来实现服务的发现和描述,这对于轻量级的 Web 应用来说并不实用。
因此,REST 的提出提供了一种更加简单、轻量级和灵活的 Web 服务架构,通过 HTTP 协议和 URI 来处理数据交互,使得 Web 应用的开发更加方便和快速。
表征状态转移 REST 的含义
REST 这个单词来自:表征状态转移(Representational State Transfer)。为了理解这个拗口的词汇,我们要理解如下概念:
- 资源(Resource):信息、数据都可以称为“资源”。互联网上的文章、视频等内容都算是资源。资源独立于媒介之外,无论一篇文章是在手机上、电脑上还是书本上,它都是同一份资源。
- 表征(Representation):是指信息与用户交互时的表示形式。例如文章可以有多种表征:HTML、PDF、doc 等等。
- 状态(State):当你读完了一篇文章,想看后面是什么内容时,你向服务器发出请求“给我下一篇文章”。但是“下一篇”是个相对概念,必须依赖“当前你正在阅读的文章是哪一篇”才能正确回应,这类在特定语境中才能产生的上下文信息即被称为“状态”。
- 转移(Transfer):无论状态是由服务端还是客户端来提供的,“取下一篇文章”这个行为逻辑必然只能由服务端来提供。服务器通过某种方式,把“用户当前阅读的文章”转变成“下一篇文章”,这就被称为“表征状态转移”。
了解了 REST 的含义,我们自然就清楚:REST 不仅是接口风格,它是一种「面向资源」的编程思想。
RSET 与 RPC
REST 与 RPC 作为主流的两种远程调用方式,在使用上是有重合的。REST 与 RPC 在思想上差异的核心是抽象的目标不一样,即面向资源的编程思想与面向过程的编程思想两者之间的区别。
在 REST 提出以前,人们设计分布式系统服务的唯一方案就只有 RPC,RPC 是将本地的方法调用思路迁移到远程方法调用上,开发者是围绕着“远程方法”去设计两个系统间交互的,譬如 CORBA、RMI、DCOM,等等。这样做的坏处不仅是“如何在异构系统间表示一个方法”、“如何获得接口能够提供的方法清单”都成了需要专门协议去解决的问题(RPC 的三大基本问题之一),更在于服务的每个方法都是完全独立的,服务使用者必须逐个学习才能正确地使用它们。
REST 的出现改变了这个局面,它带来的好处正好可以解决 RPC 的问题。
REST 的好处
- 降低的服务接口的学习成本。将对资源的标准操作都映射到了标准的 HTTP 方法上去,这些方法对于每个资源的用法都是一致的,语义都是类似的,不需要刻意去逐个学习。
- 保证接口的简洁性,易于理解。
- RESTful 通过有限的 HTTP 方法(GET、POST、PUT、DELETE 等)可以很好地限制接口复杂性来实现对资源的访问和操作。这使得 API 设计非常简单、清晰和易于理解。
- 使用 HTTP 方法可以约束系统的「副作用」。当我们调用查询接口的时候,我们总不希望系统调用了某个方法执行了创建操作吧?在大型系统中,逻辑错综复杂,我们可能永远无法理清楚调用某个接口后会发生什么。这个时候,使用 HTTP 方法在一开始就约束好接口可能隐藏的逻辑是很有必要的:GET 方法不应该涉及创建、修改、删除等操作,而其他 HTTP 方法同理。RESTful 可以降低接口理解成本。
- 资源天然具有集合与层次结构。
- 若是以方法为中心抽象的接口,由于方法是动词,逻辑上决定了每个接口都是互相独立的
- 但以资源为中心抽象的接口,由于资源是名词,天然就可以产生集合与层次结构。
- REST 绑定于 HTTP 协议。面向资源编程不是必须构筑在 HTTP 之上,但 REST 是必须得。这是缺点,也是优点。
- 优点:HTTP 及其相关技术千锤百炼,无比成熟,而且应用广泛,学习成本低。HTTP 协议的七个标准方法是经过精心设计的,只要架构师的抽象能力够用,它们几乎能涵盖资源可能遇到的所有操作场景。
- 坏处:难以实现 HTTP 不提供的特性
- 等等
REST 的不足
- 面向资源的编程思想只适合做 CRUD,面向过程、面向对象编程才能处理真正复杂的业务逻辑。
- REST 与 HTTP 完全绑定,不适合应用于要求高性能传输的场景中。
- REST 没有传输可靠性支持。怎么判断一个请求真的发送了?这涉及到幂等性。
- 标准的 REST 缺乏对资源进行“部分”和“批量”的处理能力。
- 想要获取某个资源,就必须全量获取它的所有信息。这会导致“过度获取”
- 若需要对 1000 个用户信息进行修改,则需要将这 1000 个用户信息抽象为一个资源,并专门设计一个接口来修改该资源。要不然,你就得老老实实发送 1000 次 PUT 请求
- 你去网店买东西,下单、冻结库存、支付、扣减库存这一系列步骤会涉及到多个资源的变化,你可能面临不得不创建一种“事务”的抽象资源,或者用某种具体的资源(例如结算单)贯穿这个过程的始终,每次操作其他资源时都带着事务或者结算单的 ID。
RESTful 的系统
RESTful 系统的设计原则
RESTful 系统有如下设计原则,换句话说,一个系统如果声称使用了 RESTful 风格,它就应该具备如下特点。这些原则中有一些也可以属于”RESTful 的好处“,关键在于从哪个角度去理解。
1. 服务端与客户端分离
RESTful 基于 HTTP 协议,这要求系统需要采用服务端与客户端分离的架构。随着前端技术的发展,模板引擎等技术逐渐被淘汰,这为 RESTful 的流行提供了不小的帮助。RESTful 的设计与规范为项目提供了清晰的接口约定,更加有利于服务端与客户端分离后的协作开发,同时也更有利于应用程序的安全性和可扩展性。
2. 无状态
「无状态」是指请求方(调用者)的每次请求必须具备自描述信息,通过这些信息识别请求方身份。服务端不保存任何请求方请求者信息。无状态使得任何客户都可以在任意时刻发起请求,而不必关心服务端的历史状态。
RESTful 与 HTTP 一样是无状态的。REST 希望服务器不要去负责维护状态
- 每一次从客户端发送的请求中,应包括所有的必要的上下文信息,会话信息也由客户端负责保存维护。可以减轻服务端压力
- 服务端依据客户端传递的状态来执行业务处理逻辑,驱动整个应用的状态变迁
服务端无状态可以在分布式计算中获得非常高价值的好处,因为分布式系统中维护状态会带来很大的负担(例如系统间状态的一致性问题)
不过,大型系统的上下文状态其实通常会很多,例如「当前请求的用户」就是一种上下文,这些上下文难以完全让客户端在每次请求时提供。因此,无状态并非是严格要求的,适当采用状态缓存可以减轻开发压力和系统复杂度。
例如,在项目中,我们常会使用 Token 来表示用户信息,虽然 Token 并不包含所有用户状态,但却可以让服务端通过某种途径获取用户状态。这算是一种折中方案。
3. 可缓存
无状态服务虽然提升了系统的可见性、可靠性和可伸缩性,但降低了系统的网络性。
“降低网络性”的通俗解释是某个功能如果使用有状态的设计只需要一次(或少量)请求就能完成,使用无状态的设计则可能会需要多次请求,或者在请求中带有额外冗余的信息。
为了缓存能够正确地运作,服务端的应答中必须明确地或者间接地表明本身是否可以进行缓存、可以缓存多长时间(例如提供超时时间),以避免客户端在将来进行请求的时候得到过时的数据。
4. 分层系统
这里并非指传统意义的分层,而是指客户端一般不需要知道是否直接连接到了最终的服务器,抑或连接到路径上的中间服务器。中间服务器可以通过负载均衡和共享缓存的机制提高系统的可扩展性,这样也便于缓存、伸缩和安全策略的部署。
5. 统一接口
这是 REST 的另一条核心原则。REST 希望开发者面向资源编程,希望软件系统设计的重点放在抽象系统该有哪些资源上,而不是抽象系统该有哪些行为(服务)上。
统一接口也是 REST 最容易陷入争论的地方,基于网络的软件系统,到底是面向资源更好,还是面向服务更合适。已经有一个基本清晰的结论是:面向资源编程的抽象程度通常更高。
几乎每个系统都有的登录和注销功能,如果你理解成登录对应于
login()服务,注销对应于logout()服务这样两个独立服务,这是“符合人类思维”的;如果你理解成登录是 PUT Session,注销是 DELETE Session,这样你只需要设计一种“Session 资源”即可满足需求,甚至以后对 Session 的其他需求,如查询登陆用户的信息,就是 GET Session 而已,其他操作如修改用户信息等都可以被这同一套设计囊括在内,这便是“抽象程度更高”带来的好处。
想要在架构设计中合理恰当地利用统一接口,Fielding 建议系统应能做到每次请求中都包含资源的 ID,所有操作均通过资源 ID 来进行。
RESTful 接口设计
RESTful 接口要求
如何设计 RESTful 的接口?这通常有一下几点。
1. 用 URL 定位资源
RESTful 以「资源」为核心,url 应该呈现资源(而非行为),所以我们应该使用名词来声明 url 接口,例如:使用 /users/1 表示用户 1,使用 /articles/123/comments/456 表示文章 123 下的评论 456。
2. 使用 HTTP 方法描述动作
根据 HTTP 标准,HTTP 请求可以使用多种请求方法。
- HTTP1.0 定义了三种请求方法: GET, POST 和 HEAD 方法。
- HTTP1.1 新增了六种请求方法:OPTIONS、PUT、PATCH、DELETE、TRACE 和 CONNECT 方法。
在 RESTful 接口中,我们通常使用的只有 4 中:
- 使用 GET 查询、获取资源
- 使用 POST 新增资源
- 使用 PUT 更新资源
- 使用 DELETE 删除资源
示例(更详细的实践示例见后文):
| 用例 | HTTP 方法 | url |
|---|---|---|
| 获取单个文章 | GET | /articles/{id} |
| 获取多个文章 | GET | /articles?ids=1&ids=2 |
| 通过参数查询文章 | GET | /articles?title=abc |
| 新增文章 | POST | /articles |
| 修改文章 | PUT | /articles |
| 删除文章 | DELETE | /articles |
| 新增文章评论 | POST | /articles/1/comments |
| 获取文章评论 | GET | /articles/1/comments |
此处可以看出来上文所说的:RESTful 的系统接口会更简单、更易于理解。使用了 HTTP 方法之后,同一个接口
/articles可以应用在多个场景下。而且接口的动作没有歧义,不用担心findById或getById等差别。在更新时,也不用设计updateTitle或updateContent等接口,而是使用统一接口。
值得注意的是,GET、PUT 和 DELETE 应该是「幂等」操作,而 POST 没有这个要求。
所谓幂等,是指任意多次执行所产生的影响均与一次执行的影响相同。设计幂等接口可以降低系统复杂度,因为我们不必担心重复调用接口会带来影响程序运行的麻烦。
RESTful 实践经验
1. 集合、分页等资源的表示
在标准的 RESTful 接口中,并没有特别地要求区分单个实体和多个实体的查询,例如获取某篇文章时使用的是 /articles/1,而获取多篇文章则是 /articles(省略 url 参数),这就导致 url没有对齐。倘若后续有其他接口使用了类似于 /articles/xxx 的接口,那么 xxx 可能会被当作文章 ID 使用,导致访问错误的接口。
为此,我们可以对集合资源做特殊处理:
- 在查询多个文章时,使用
/list/articles - 在分页获取文章时,使用
/page/articles - 在获取所有文章时,使用
/all/articles - 等等
添加了 list、page 等信息不仅可以解决对齐问题,还使得接口含义更加清晰明了。
2. 接口单词命名
标准的 RESTful 接口要求名词一律采用复数,这可以更好地表示资源原本的含义。例如,使用 GET /articles 表示获取多篇文章,那么自然应该使用复数。使用 GET /articles/1 表示在许多文章中获取文章 1,使用复数也很自然。
但是,使用复数也会带来一些麻烦。例如:
- 单复数同形的单词怎么办?有人建议直接使用对应的名词,也有人认为可以统一加 s
- 不可数名词怎么办?有人建议替代为可数名词,例如 news 可以用 news-item 替代。
- 忘记加复数了怎么办?emmm 这个没办法
为了简化开发,我们可以适当抛弃没必要的规矩。如果使用复数形式的 RESTful 对我们而言是一种麻烦,那么统一使用单数形式也是一个不错的选择!
3. 资源过滤
url 只能用来声明资源,所以我们可以使用 url 参数来声明更多的信息:
- limit=10:指定返回记录的数量
- offset=10:指定返回记录的开始位置。
- page=2&size=100:指定第几页,以及每页的记录数。
- sortby=name&order=asc:指定返回结果按照哪个属性排序,以及排序顺序。
- xxx-type=1:其他筛选条件
可以配合
JPA Pageable对象或其他框架的功能来设计 url 参数,使后端在批量查询、分页查询的接口更简洁。
4. 嵌套的 RESTful 资源
让我们想象一个应用场景:现在知乎里有“问题”和“回答”两种,那么回答应该属于问题之下,获取回答时有两种方案:
- 一种是 RESTful 的:
/questions/{questionId}/answers/{answerId} - 另一种就是只有
/answers/{answerId}
你们倾向于哪种?
大多数 RESTful 系统应该会偏向第一种,因为它的业务含义更加丰富和严谨。那么我们再来考虑更细致的场景:文章之下会有评论,获取评论的时候也有几种方案:
- 严谨方案:
/questions/{questionId}/answers/{answerId}/comments/{commentId} - 折中方案:
/answers/{answerId}/comments/{commentId} - 偷懒方案:
/comments/{commentId}
你又会选择哪种?
上面都是查询的接口,如果是新增、删除的接口,你又会怎么选择?
嵌套的弊端
网上有一些相关的讨论,如 服务器端 多层嵌套RESTful API 应该如何设计? - CNode技术社区、REST API Design Best Practices for Sub and Nested Resources | Moesif Blog,其中第二篇讨论得非常详细,其列举了上文严谨方案的种种弊端。如果感兴趣可以仔细阅读。此处我仅介绍几个实际开发中我遇到的问题以及自己的思考:
- url 层级过深,会导致 url 很长,既不美观也不易于理解,还会带来一些开发的不便
- 多层资源嵌套意味着我们需要做额外的检验:例如
/questions/1/answers/2/comments/3中,我们要检查:回答 2 是不是真的属于问题 1?评论 3 又是否属于回答 2?这会导致N+1查询,也会给开发带来极大地不便,更会导致一些逻辑漏洞。 - 真正的“严谨”其实难以实现。我们这里的例子只有三层:问题、回答、评论,但是这些资源是不是应该都属于用户?那么我们应该在前面统一加上
/users/{userId}吗?如果用户属于某一个组织,我们需要再套一层吗??这显然不合常理。 - 嵌套关系不是固定的。在微服务项目的评论服务中,我们可能压根没有问题、回答等概念(毕竟,如果评论服务不需要涉及问题、回答的业务,那为什么要引入它们呢?),那么我就会让评论
comment为 url 的根。这时候你总不能要求我还在前面加上questions和articles吧?
可见,严谨方案的 RSETful 实践意义不大。
折中的方案
在实际项目中,我们通常会采用折中方案。在新建一则评论时,我们通常需要保证其评论的对象(即文章)是存在的,那么新建评论时 url 使用 /answers/{answerId}/comments 就显得很有必要了。此处的 answerId 并不像 questionId 那般累赘,它可以辅助我们进行检查。同时它也符合 RESTful 对于嵌套资源的要求(只是没那么全面、严谨)
最后
希望本文对你有所帮助!如果喜欢还请不吝点赞收藏,也欢迎在评论区交流~