设计易用的RESTFul API

早期互联网很大一部分是静态文件的堆积。因此,我们上网访问的其实都是一个个静态的资源。通过 URL 的定义,可以很方便地找到资源。

随着互联网应用规模的扩大,以及应用复杂度的提高,之前的静态资源已经不能满足需求,网站返回给访问者的内容需要通过动态的方式生成(例如通过获取数据库的内容),同时还支持由访问者控制希望看到的内容。 传统的用于定位资源位置的 URL 的定义变得复杂起来。

本篇内容偏向于 Web API 设计中,有关 URL 部分的讨论。

习以为常的一些URL

在Ajax还未发展起来前,网站应用的复杂度就已经变得相当复杂。因此当我们访问很多网站时,观察地址栏以及网络传输时,不难见到类似这样的请求。

1
http://xxx.com/index.jsp?page=10&catelog=food&create_time=20151001

这样的请求存在一个问题:无法很好地描述一个资源

  1. 无法知晓参数之间的层级关系,从请求串来看它们的关系是并列的
  2. 无法知晓某个参数具体服务于页面中的哪一部分
  3. 由于HTTP是无状态的,因此通过请求也无法准确知晓对于资源状态的影响是什么

以上这些弊端对于描述一个资源是不友好的,特别当应用规模扩大时,会产生较大的对于接口的理解成本与使用成本。

自从Ajax技术兴起,接口的表现形式又有了一些变化。由于页面内容可以实现异步控制,因此通过统一的地址参数与服务器通信的模型改变为,特定的接口指定特定的参数。

1
2
3
4
5
6
http://xxx.com/index.jsp?page=10&catelog=food&create_time=20151001

=>

http://xxx.com/index.jsp?catelog=food
http://xxx.com/items.action?page=10&create_time=20151001

这在一定程度上解决了参数混杂的问题,但是请求资源的层级关系仍然无法做到合理的区分。

API设计原则

一般来说,设计良好的 Web API 会至少满足如下几条原则:

  1. 参数职责单一
  2. 意图清晰,便于开发者调用
  3. 易于访问者输入

REST原则

虽然原则看起来简单易懂,但是现实应用中需要面对各种场景,实际上缺少一个相对统一的规范。

不过在满足基本原则的基础上,还是存在一些事实上的行业标准。业内用得比较多的是 REST

REST是什么

REST 不仅是一份API设计准则,更是一套软件实现架构,用于指导客户端与服务器端之间的交互。 最早由 Roy Thomas Fielding 于2000年在他的博士论文中提出这个概念。

RESTRepresentational State Transfer的缩写。它的理论比较抽象不太具体,理解它主要在于理解这些概念:资源、表现层、状态转换。

对于这些名词的解释,主要参考了 阮一峰 在博客中的描述。

资源

资源表示了存储在网络上的一个特定实体,可以是一段文本、一张图片,也可以一种服务。每个资源由一个特定的URI进行描述。因此我们在浏览器中输入一个特定的URI表示通过浏览器访问某个资源。

例如

1
2
http://xxx.com/index.html 表示访问一个超文本资源
http://xxx.com/music.mp3 表示访问一个多媒体资源
表现层

按照字面意思翻译,表现层表示一个资源的外在表现形式。当然这段话非常学术,不是很好理解。

通俗地理解表现层,大概可以认为它是一个资源以某种文件格式进行包装返回给访问者的形式。

例如一段文本,既可以以普通文本,也可以以超文本的格式承载,在客户端分别以不同的形式进行展示。但是在网络上确实对应的是同一个资源。

一般与表现层关联最大的是应用服务器与Web服务器,通过设置 Content-Type 的HTTP响应给予资源不同的表现形式。

状态转换

客户端对于服务器端的访问可能会引起资源的状态变化。

比如:增加一条记录、修改一条记录。 这些操作都会引起状态的变化,因此需要客户端可以合理地操作。

由于HTTP的无状态特性,所有描述访问者状态的内容都保存在服务器端,因此客户端需要通过某种手段引起服务器端资源的状态变化。

客户端可以使用的手段之一,是HTTP协议。HTTP协议包含几种对于资源操作的描述,REST 对应地可以使用其中四个动词:GETPOSTPUTDELETE。 分别与应用服务器的 CRUD 操作进行对应。

这样对于某次请求的描述就包含了:访问什么资源、以什么样的形式封装资源、资源的操作后状态是什么。所有网络请求的本质都认为是对于资源的操作

RESTFul API

基于REST原则设计的API,一般称为 RESTFul API,需要遵守以下这些原则。

  1. URL描述的是一个特定资源。因此描述需要名词,不能出现动词。因为动词描述的不再是资源本身,而是行为
  2. 利用HTTP请求的动词表示对于资源操作的行为

同时,对于URL的设计一般还有约定俗成的以下补充。

  1. 对于资源的描述的名词应该是层级嵌套的方式,比如 /company/department/projects通过这种对于信息层级描述的方式,更利于实体的抽象,以及增加客户端与服务器端开发人员对于整个系统模块认知的一致性
  2. 路径终点的命名考虑用复数形式,比如 /books一般一个URL路径表示的资源会映射为数据库一系列表的记录的集合,因此使用复数更直观

符合RESTFul API的接口简单示意如下

1
2
3
4
5
6
GET  /books (获取所有书列表)
GET /books/1 (获取编号为1的书)
GET /books/1/summary (获取编号为1的书目的简介信息)
POST /books (增加一本书)
PUT /books/1 (修改编号为1的书)
DELETE /books/2 (删除编号2的书)

RESTFul API设计的折衷

REST 原则可以用于优化系统架构,指导系统设计采用更清晰的实体划分。 但是实际项目的使用场景迥异,一味地照搬原则并不能完全满足所有场景的需求。在某些情况下,应用 REST 需要针对性地进行一些补充。

API版本化

API版本化的应用在实际开发中是有实际意义的。

接口一般情况下是相对稳定的,但是也会随着业务需求的变更而发生变化。 另外还可能是业务需求并未发生变更,为了更好地组合资源,在接口层面进行了抽象和组合。 这种情况下,会要求服务器端实现一套新的接口实现,同时还需要保持原有接口可用。

从本质来看,接口的变化其实反映的是接口使用者感知的“资源表现层”的变化。对于相同的资源,在URL上可以反映为多个不同的名词描述。 但是这样会带来一些使用上的弊端。

  1. 接口的使用者需要感知一个新的实体名称,提高了理解与使用成本
  2. 新的实体名称未必能够直观表达接口更新后的状态(无法区分接口的新旧)

所以比较常见的做法是在接口上增加版本信息。API的版本化也有两种做法,各有利弊。

  1. 请求头增加API信息
  2. 将API体现于URL

使用前者的理由在于,虽然接口版本不同,但是很多时候其实描述的是相同的资源,在描述的资源地址中增加版本号信息会破坏 REST原则。这是偏学术层面的理由。

使用后者的理由在于,版本号信息体现在URL会使接口更加直观与易用。同时版本号作为接口服务器信息的补充方式存在,可以理解为并非对于资源本体的描述。

事实上,在实际项目中出于灵活性考虑,也会将两种方式结合使用的。例如 GithubAPI v3 就同时支持了两种方式

实体别名

在实体层级关系较多的系统中,某些实体对外可访问的接口是通过上层实体关联暴露的。比如

1
GET  /bookstores/1/catelogs/2/saleBooks

saleBook 是作为 bookstore 关联的 catelog 的下层关联实体存在。因此接口使用者感知的是三个实体的关联关系。

出于使用的便捷性考虑,用户也可以只需要理解一个实体名称 saleBook。虽然架构上或者物理上仍然作为下层关联实体存在,但是在接口上可以重新设计为

1
GET  /saleBooks/1/2

这时,我们称 saleBookbookstore/catelog/saleBook 的一个别名。

但是需要注意的是,在一般情况下,除非URL特别长导致难以理解或者调用,不建议过度使用别名。

理由:别名会导致使用者减少了对于实体间关系本质的理解,同时别名还占用了额外的命名空间,可能会和未来出现的某个新的实体的名称重复,或者由于相似命名造成额外的理解成本。

自定义视图

资源是某些特定的数据的聚合,它向外暴露的结构应该是固定不变的。但是在实际应用场景中,同一个接口的不同调用方可能需要用到不同的数据视图。

如果为每个调用方设计一套独立的接口自然能够满足需求,但是缺点同样也很明显。

  1. 每个接口需要实现独立的业务逻辑,增加了开发成本
  2. 增加了接口数量,提高了后期维护成本

因此,允许调用方自定义资源的视图是一种可行的解决方案。 针对同一个描述资源的接口,具体返回的字段由请求参数进行约定。参数来自调用方,服务器端将资源组装成不同的视图返回调用者。

在具体实现上,接口的可指定视图参数更类似于存储资源这方支持数据查询接口的约定。比如接口的查询条件在定义上与关系型数据库的查询条件比较类似。

这种方式应用比较广泛,在各种系统中都可见。比如指定特定的查询条件,以及指定需要返回的特定字段。

1
GET  /books?catelogs=JS&fields=name,price,author,rate&orderBy=rate&order=desc

慎用删除

RESTFul 的设计原则中,对资源的删除操作往往会用 HTTPDELETE 动词来描述。但是实际大部分业务场景下,删除操作并不会被使用到。 一般会出于以下几方面的考虑:

  1. 资源的删除会导致系统逻辑一旦出错缺少完全回滚的机制
  2. 企业越来越注重对于用户的数据挖掘

因此我们一般认为业务型系统架构中对于资源采用非物理性删除更优于物理性删除。例如为资源增加一个状态的描述,调用者期望的删除其实对应的是状态的变更。

1
2
3
4
5
6
DELETE  /books/1

=>

PUT /books/1/status
status=0

这种接口定义的资源操作没有发生真实的删除,而仅仅是某些数值的变更。另外通过应用层的业务逻辑辅助屏蔽不希望调用者查看的数据。最终通过一种“模拟删除”的行为同样满足了对“删除”操作的需求。

REST 设计原则的角度来看,这种方式其实只是利用 PUT 操作替代了 DELETE 操作的效果,资源其实还是真实存在的,并未破坏 HTTP 动词的语义,也没有打破 REST 设计原则。

总结

RESTFul 架构在指导抽象业务实体以及组织系统模块方面有着巨大的优势,目前在流量端系统中也已经得到比较多的应用。

但同时我们也可以看到,部分系统的接口设计也存在一些欠缺,比如

  1. 对部分资源的描述使用了动词
  2. 资源的层级划分不合理
  3. 接口设计时未考虑版本化

其中对于资源层级的合理划分,可以帮助前后端对于系统理解保持一致;

大部分流量端系统目前都是前后端分离部署的,但是接口未实现版本化,意味着

  1. 一旦出现需要修改某个接口定义时,不得不约定新接口,增加了冗余的资源定义
  2. 在不约定新接口的情况下,前后端必须要同时部署
  3. 前后端部署分离时,可能在中间的某个阶段,系统的服务可能是会出现错误的

而接口实现版本化意味着可以实现真正的前后端分离部署。

以上这些都等待我们在之后的迭代开发去逐步优化。

参考文章

  1. Best Practices for Designing a Pragmatic RESTful API
  2. Some REST best practices
  3. 理解RESTFul架构
  4. RESTFul API设计指南
知识共享许可协议