谈一谈对原教旨主义 RESTful API 的想法

从上班到现在,在单位边看边写也折腾了一些代码了。作为一个有成百上千个 API 的后端项目,整个项目的 API 风格从十分原教旨主义的 RESTful 到看起来是 RESTful 实际上是 RPC 语义的都有。然而在实现需求的时候我越发发现,凡是那些十分原教旨主义的 RESTful API 我都能快速理解并掌握如何使用,而那些 RPC 语义并且在 URL Path 里充满着五花八门动词的 API 往往理解需要花费相当的难度。

长期以来至少在我的周围似乎都充斥着一种论断:「RESTful API 设计起来很别扭」,更有人会直言「那些不追求原教旨主义 RESTful 的人早就写完需求下班了,而追求原教旨主义的还在加班」。这话乍一听有一些道理,但实际上这么做会无形地为后续维护增加更多成本——尤其是在需求本身就不明确并且还会频繁变更的情况下。

一个例子:维护菜谱

设想我们有一个维护菜谱的需求,一般后端设计者都很容易设计出下面的 API:

GET /dishes # 获取所有菜谱  
GET /dishes/{id} # 获取特定 ID 的菜谱  
POST /dishes # 添加特定菜谱  
PUT /dishes/{id} # 修改特定 ID 的菜谱  
DELETE /dishes/{id} # 删除特定 ID 的菜谱  

当然一些设计风格比较 RPC 的可能会写成这样:

GET /dish/selectAll # 获取所有菜谱  
GET /dish/selectById # 获取特定 ID 的菜谱  
POST /dish/add # 添加特定菜谱  
POST /dish/modifyById # 修改特定 ID 的菜谱  
POST /dish/deleteById # 删除特定 ID 的菜谱  

这两者在理解成本和设计上其实没有什么不一样的,程序员写完需求,开开心心地下班了。

新需求:菜单步骤

现在突然多了两个需求:每个菜谱都会有一个或多个特定的步骤,用户需要添加步骤或者修改某个特定的步骤的名称。

喜欢 RPC 设计风格的稍加思索搞出来了下面的 API:

GET /dish/selectStepsById # 获取特定菜谱的所有步骤  
POST /dish/appendStepById # 为特定菜谱添加步骤  
POST /dish/modifyStepById # 为特定菜谱修改特定步骤的名称  

RESTful API 原教旨主义者想了半天,折腾出来下面的 API:

GET /dishes/{id}/steps # 获取特定菜谱的所有步骤  
POST /dishes/{id}/steps # 为特定菜谱添加步骤  
PUT /dishes/{id}/steps/{step-id}/name # 为特定菜谱修改特定步骤的名称  

等 API 设计出来并实现好的时候,RESTful API 原教旨主义者发现设计 RPC 风格的程序员已经提前跑去把午饭吃了。

新需求:调整步骤

现在突然又多了一个需求:用户需要修改某个特定的步骤的花费时长。

喜欢 RPC 设计风格的发现了问题,modifyStepById 已经拿去用了,他只能从以下两个选择里挑一个:

  1. 把花费时间的 API 叫成 modifyStepDurationById

  2. 之前 modifyStepById 的请求可能是 {"name":"foo"},现在额外规定:

    {"name":"foo"} 代表修改名称,{"duration":"5m30s"} 代表修改花费时长,都存在则都修改。

我们姑且采用第一种方案:

POST /dish/modifyStepDurationById # 为特定菜谱修改特定步骤的花费时长  

RESTful API 原教旨主义者可能会采用这样的设计:

PUT /dishes/{id}/steps/{step-id}/duration # 为特定菜谱修改特定步骤的花费时长  

当然也可以采取偷懒一点的方案:

PUT /dishes/{id}/steps/{step-id} # 为特定菜谱修改特定步骤的全部信息  

当然,上面这个方案的代价是不需要修改的数据都需要原样传回去一次。

新需求:交换步骤顺序

新需求又来了:用户需要可以交换两个步骤的顺序。

喜欢 RPC 设计风格的可能很快就设计好了 API:

POST /dish/exchangeTwoStepsById # 为特定菜谱交换两个步骤的顺序  

RESTful API 原教旨主义者遇到了「怎么设计怎么难受」的困境,他最后设计出来的 API 大概率是这样的:

POST /dishes/{id}/steps/exchanges # 为特定菜谱交换两个步骤的顺序  

不得不说,这个接口设计出来就是「别扭」的,违反直觉的——这自然也是那些不追求原教旨主义的程序员的理由。

数个月后

数个月后,一批新来的员工决定维护一下现有的系统,他们看到的是什么样的呢?

对那些不追求「原教旨主义」的 RPC 设计风格 API,它们现在长这样:

GET /dish/selectAll # 获取所有菜谱  
GET /dish/selectById # 获取特定 ID 的菜谱  
GET /dish/selectStepsById # 获取特定菜谱的所有步骤

POST /dish/add # 添加特定菜谱  
POST /dish/modifyById # 修改特定 ID 的菜谱  
POST /dish/deleteById # 删除特定 ID 的菜谱  
POST /dish/appendStepById # 为特定菜谱添加步骤  
POST /dish/modifyStepById # 为特定菜谱修改特定步骤的名称  
POST /dish/modifyStepDurationById # 为特定菜谱修改特定步骤的花费时长  
POST /dish/exchangeTwoStepsById # 为特定菜谱交换两个步骤的顺序  

而对于 RESTful 原教旨主义 API 则是这样的:

GET /dishes # 获取所有菜谱  
GET /dishes/{id} # 获取特定 ID 的菜谱  
GET /dishes/{id}/steps # 获取特定菜谱的所有步骤

POST /dishes # 添加特定菜谱  
POST /dishes/{id}/steps # 为特定菜谱添加步骤  
POST /dishes/{id}/steps/exchanges # 为特定菜谱交换两个步骤的顺序

PUT /dishes/{id} # 修改特定 ID 的菜谱  
PUT /dishes/{id}/steps/{step-id}/name # 为特定菜谱修改特定步骤的名称  
PUT /dishes/{id}/steps/{step-id}/duration # 为特定菜谱修改特定步骤的花费时长

DELETE /dishes/{id} # 删除特定 ID 的菜谱  

哪个理解起来一目了然,恐怕是不言而喻的。那么问题出在哪儿呢?

  • 在这个示例里,光 RPC 设计风格 API 里就有足足 6 个动词:selectaddmodifyappend`deleteexchange。其中 addappend 本质上是一样的,只是设计的时候选择不一样而已,而 exchange 更是新的动词。原教旨主义把动词限定在了「增删改查」四个,其中唯一反直觉的是 exchange 被设计成了「向 exchanges 增加数据」,但和「重新理解一个 exchange 动词」相比,这点程度的反直觉又算得上什么呢?
  • 在这个示例里同时出现了 modifyStepByIdmodifyStepDurationById 两个接口,而前者由于历史遗留原因其实只负责修改名字——新开发者在看到具体的调用约定(POST 的 JSON 格式等)之前是很难在大脑里形成一个直观的认识的。原教旨主义会在一开始就「逼迫」你在设计 API 的时候把 /name 写上去,哪怕是对整个步骤修改的 API,也在逼着你保证你修改的和你查询的是一个东西。
  • 在这个示例里开发者很难一眼看出哪些是「所有数据」、哪些是「特定数据」、哪些是「和步骤有关的数据」,这些本质上是因为在设计的时候便偷了懒——原教旨主义同样在一开始设计的时候就在「逼迫」你想清楚这个问题——至少所有「和步骤有关的数据」全都在 /steps 下。

在我个人看来,原教旨主义 RESTful API 风格固然设计得很别扭,但是它至少在让你设计的时候便在「逼迫」你理清楚你到底在做什么,而不是通过一开始的偷懒,在后续阶段增加不必要的理解成本。总的来说,原教旨主义 RESTful API 风格带来的设计上的反直觉等代价和后续维护增加的理解成本相比不值一提。

以上都是我的个人观点。如果想要对上面说的这些提出一点批判的话,希望各位就事论事,而不是单纯地用「你写过 xxx 吗」什么的来攻击我。