一种RESTful接口的约定

1 概述

1.1 撰写目的

本文用于定义一种统一的RESTful接口设计方案,希望具有参考价值。本文所描述的方案比较学院派(死板),在上一家公司提出没有被采纳,在所了解到的有限的若干家声称采用了RESTful风格的公司里,发现他们也偏离甚远,而在书本以及网上大部分介绍RESTful的资料里,却都是这样的方案(URL命名风格、请求与响应的设计)。当然,他们这么做是有理由的,我也理解,这只是取舍问题。这篇文章其实是旧文了,2016年年底就已经写好,但是一直躺在电脑的硬盘里,不想白费了当时的功夫,因此在此公开。

1.2 为什么采用REST

目的是为了服务端与客户端的解耦。SOA仅仅是从结构上将前后端分离,但是实际上数据逻辑还是没有实现解耦,服务端接口升级往往会影响客户端,两者的行为需要严格约定。而REST采用HTTP协议进行约定,客户端仅仅需要按照HTTP协议来理解服务端返回的数据,虽然与业务相关的数据结构还是需要约定,但是这确实进一步解耦了服务端与客户端。

另外,由于严格遵照HTTP协议进行数据返回,对于安全的接口,可以在返回的Header里设置缓存策略(接口安全性的概念在下文会解释)。

1.3 文档结构

第二部分将阐述关于RESTful的若干个关键的概念,明确第二部分阐述的几个概念有利于设计、实现优雅规范的接口。

第三部分就URL命名的问题进行约定。

第四部分对消息实体进行约定。

第五部分对『向RESTful接口发起请求』进行阐述,约定要实现的方法,约定请求的头部和body的格式。

第六部分对接口的响应格式进行约定,包括响应消息的头部、状态码、JSON实体。

第七部分对版本控制的问题进行约定。

第八部分对RESTful接口的实现提出了实现工具的建议。

2 关键概念

明确一些关键的概念是很重要的,虽然RESTful风格的API设计方案并没有统一的标准,但是还是需要符合一定的原则进行设计,否则就不能称为RESTful风格的API。因为许多人并没有对REST进行充分的了解就宣称自己的API是RESTful风格的API,以至于RESTful的提出者Fielding博士本人无法忍受,在2008年为此专门写了一篇博客『REST APIs must be hypertext-driven』,hypertext-driven与HATEOAS是同一个概念的不同表述,在下文会进行阐述。

2.1 RESTful

REST不是一种协议,也不是一种文件格式,更不是一种开发框架。它是一系列的设计约束的集合:无状态性、将超媒体作为应用状态的引擎等。REST是Representation State Transfer的缩写,中文是『表述性状态转移』,这里就涉及到资源的表述与状态两个概念。

简单地说,资源可以看作是服务器上存储的所有数据,资源的表述则是服务器对外提供的指向这些资源的方式,使用JSON、XML等均可,一个资源可以有多种表述;资源的状态则是服务器的数据存储状态,例如在t时刻,服务器中存储了m条数据,这时候客户端向服务端提交了一个创建数据的请求,服务器处理了此请求并创建了一条数据,那么在t+1时刻,服务器中就存储了m+1条数据,这两个时刻的资源状态就是不一样的,t时刻发生的请求导致了资源状态的改变。

2.2 HATEOAS

Hypermedia As The Engine Of Application State,超媒体作为应用程序状态的引擎。这是REST区别于其他SOA风格的主要特点。客户端与服务端进行互动的时候,完全是通过服务端动态提供的超媒体进行的。除了对超媒体的一般理解,客户端不需要知道其他额外的知识。相反,在一些SOA接口的设计中,客户端与服务端的通信是要事先进行约定的,例如通过文档或者接口描述语言(Interface Description Language, IDL)。而基于HTTP协议的REST设计里,一般采用的就是请求与响应的Header来体现HATEOAS原则(具体请参考:https://en.wikipedia.org/wiki/HATEOAS)。这里也隐含这样一层含义:REST应尽可能地利用HTTP标准中现有的东西,例如Header、标准方法与状态码。

从标准的角度看,HTTP标准是一项RFC标准,世界认可;而其他自定义的SOA标准则可能是一项个人标准或者公司标准,最多是一项互联网草案(这对大部分公司来说都不可能),而一项标准越是被广为认可接受,其实现的通用性就越强。个人标准和公司标准都五花八门,这样对每一个标准都要参照其相关文档实现相应的行为逻辑是很麻烦的。

2.3 安全性

一个方法被调用1次与被调用0次是一样的,此方法就是安全的,否则就是不安全的。例如,一个方法A仅仅是读取数据,并不创建或者修改数据,不论A方法被调用多少次,都不对数据记录产生任何影响,A方法是安全的。而假如有另一个方法B对数据进行删除,B方法被调用1次后,数据会被删除(或者标识位被修改),系统里的数据发生了变化,那么B方法是不安全的。

2.4 幂等性

一个方法被同样地调用1次与被调用多次是一样的,即同样的输入会得到同样的输出,此方法就是幂等的,否则就不是幂等的。

2.3节中A方法与B方法都是幂等的,一个安全的方法一定是幂等的,一个幂等的方法不一定是安全的。

假设一个方法C对某个全局计数器执行自增操作并写入数据库,每次调用C方法都会对系统数据产生影响,那么C方法就不是幂等的。

3 URL命名

URL用于标识资源,因此URL应该以名词进行命名,例如/users, /users/children等。

一般URL会内嵌参数,例如要获取id为313的user的信息,那么URL应该为/users/313,前面的user采用复数,如果要列出其所有后代,则URL应为/users/313/children,children为复数形式,如果要获取其id为499的后代,则URL应为/users/313/children/499

4 消息实体

消息实体,就是请求和响应消息中的entity-body(也称为body),消息实体采用JSON字符串格式。

5 请求

5.1 方法

使用HTTP标准定义的请求方法。

5.1.1 get

获取资源,单个参数一般写在URL上,多个参数则作为query parameter附在URL后面,例如:

  • 单个参数:/user/123, 表示id为123的user
  • 多个参数:/user?name=tom&phone=13787890987&gender=male

get方法应为幂等的,并且不对数据记录产生影响。对于汉字与特殊字符,应该进行urlencode。

5.1.2 post

创建资源,请求的headers里设置Content-typeapplication/json,参数为json类型。

根据约定,在创建成功之后,返回的状态码应该是201(Created),并且在response的Header里设置Location为新创建的资源的URL,例如,创建了一个新的user,该user创建后id为888,那么Header里应该设置Location/users/888,当然,这应该是一个完整的URL,这里只是给出了一个相对路径的URI以作为说明。返回了这些数据后,客户端可以自定义后续行为,或者查看创建后的user,或者刷新当前的user列表,这些行为服务端并不关心。

如果重复提交了相同的数据,第一次应该返回201,以后则应返回409(Conflict),并且在response的Header里设置Location指向已经存在的资源,说明冲突的来源。

5.1.3 put

更新资源,对现有资源进行修改,请求的headers与post一样,参数也是。此方法应该是幂等的。

5.1.4 delete

删除资源。此方法应是幂等的。

5.2 Header

Content-type应设为application/json。

另外应设置一个version,指明所使用的接口版本。这不属于HTTP协议中的一部分,是自定义的,出于版本控制的考量,具体见第七章。

5.3 body

采用JSON字符串,具体的结构有待商定,这不属于HTTP协议的一部分,是自定义的。

这里主要放置业务相关的数据。

借用一篇10年前的文章的一张图:

rest

6 响应

6.1 Header

根据响应的状态码不同,相应地设置头部,具体见下一节。

但是在我所了解的公司里,做法都是统一返回200,然后在返回的JSON字符串里设置消息码。我是不能理解的。据一位前端同学说,前端代码接收到了请求以后,不方便获取Http状态码。其实我也写过前端,不深入,但是一些基本的知识还是有的,我觉得这并不难做到,估计是他的代码封装的时候没有考虑到这一点,现在要改比较麻烦,所以不想大动干戈、伤筋动骨。

6.2 状态码

状态码 语义 使用场景
200 OK 正常返回消息,什么问题也没有
201 Created 创建资源成功,Header里应设置Location指向新创建的资源
202 Accepted 请求已被接收,但是处理过程较长,不能马上返回结果
304 Not Modified 没有任何修改发生
401 Unauthorized 缺乏权限,指已经登录但是缺乏请求这个资源的权限
403 Forbidden 拒绝访问,可用于未登录时拦截返回的状态码,此时Header里应设置Location为登录页面的URL
404 Not Found 不存在所请求的资源
406 Not Acceptable 请求没有被接收,参数约束校验不通过,或者其他业务类型的错误都可以返回这个状态码,response的body里应有表示错误信息的JSON实体。
409 Conflict 请求的资源有冲突,例如多次提交一样的创建请求,response的Header里应设置Location为产生冲突的资源的URL
500 Internal Server Error 服务器的非业务类错误,response的body里应有表示错误信息的JSON实体

6.3 body采用JSON字符串。

JSON的结构分为两种:成功、失败。

一般而言,只有返回200的时候才需要读取成功的JSON,只有返回406和500的时候才需要读取失败的JSON,对于其他的状态码,客户端不需要服务器提供额外的消息。

对于成功的JSON,里面应该只包含一个result对象,而失败的JSON应该使用这样的结构:

1
2
3
4
5
6
7
{
error: {
code: xxx,
message: "xxx",
data: {...}
}
}

失败的JSON只有一个error对象,包含错误码、消息及相关数据,message应该是直接可读的消息,客户端毋需理解发生了什么错误,客户端只需将消息展示出来即可。在收到406的时候,客户端只需知道发生的错误是由客户端造成的即可,具体是什么类型并不需要知道,将消息直接展示出来,让使用的人知道是什么即可,所以message应该是人类可以理解的文本。同理,收到500的时候,只需知道这个错误是服务端的问题即可,客户端也毋需知道具体的错误类型,最多就将错误码和消息展示出来,让使用者有反馈的依据即可。

7 版本控制

考虑到接口有可能升级,升级的类型有几种:

  1. 新增功能接口
  2. 原有接口返回数据增加字段
  3. 现有接口返回数据变更现有字段格式或删除现有字段
  4. 现有接口变更业务逻辑
  5. 删除接口

其中,前两种升级并不会影响客户端,因此毋需处理。而后面三种会导致使用旧接口的客户端不能正常工作。

一般服务端升级与客户端升级都不是同步的,客户端升级往往会滞后,因此在服务端升级后应该保留旧版本的接口继续运行一段时间,让未升级的客户端可以继续工作一段时间,同时可以上线新版本的客户端。过一段时间后再将旧版本的接口下线。

而版本控制应该是向下兼容的,即假设当前版本是1.2,如果客户端请求1.3版本的服务,应当用当前版本提供服务。如果没有注明请求的版本号,应当提供当前版本的服务。

一般情况下,客户端请求需要带版本号,但是服务端并不需要对此进行处理,除非是同时运行新旧版本的同一个接口,才需要做差异处理。

8 实现工具

8.1 Spring HATEOAS

Spring HATEOAS可以很方便地与Spring MVC结合来开发RESTful接口。具体参照其文档:
http://docs.spring.io/spring-hateoas/docs/0.20.0.RELEASE/reference/html/#fundamentals.jaxb-json

9 缺陷

其实这个方案基本就是网上一些被大部分人认可的做法的汇总,但是缺乏细节,例如分页,但是其实这些可以灵活变通,例如在查询字符串里加上分页参数。《一次无后端的供应链系统开发实践 (上篇): 前后端分离的 Restful 接口设计》这篇文章的设计就比较全面,但是两者考量的问题范畴不同,他这个是serverless的设计,将业务逻辑都压在前端,后端仅仅作为前端与数据源之间的代理(数据源、客户端都在自己控制范围内的话其实没必要这样搞),这样的话,接口要表达的逻辑就比较复杂,而本文还是从传统的抽象思想去考虑,逻辑都在后端封装,因此接口并不需要表达多么复杂的逻辑。

10 参考文献

当时写的时候没记下来,所以就不列出来了,在此不保证来源的权威性,请读者自行鉴别。^_^