从App角度看API (RESTful) 设计

要API,不要做外星人 (图片来自网络)
首发:http://www.jianshu.com/p/ace1428888ca

做了10多年的桌面和逻辑模块的开发,两年前才开始接触互联网这一块,说起来对RESTful API是没有太多经验的。
公司app搭建之初,前后端通力合作,期间同不少后端同事就API的设计都有过沟通交流;到现在app上线也要快满一年了,不久前进行了一次大改版,部分API也从v1升级到了v2,觉得有些经验,可以总结一下。

一. API设计的一些基本原则

  1. API是自述的
    大多数我看的资料所写的Intuitive(直观的、直觉可理解的),也有些写Descriptive(描述性的),我将其命名为“自述”。一个API有自述性,也就是说看到API的URL,就知道这个API是要干嘛;且这个API的返回值中的字段,又能很好的解释其返回的内容。
    虽然API文档是不可或缺的,但是如果看到API的URL和API的返回值字段就知道这个API的功能作用,多好!
    (注:下文有实例)
  • API是完备的
    对于一组API,我们会要求其为最小完备集。
    对于一个API,个人觉得其同样有最小完备性。这里我主要是指在各种输入参数情况下,API的返回都应该是合理的、完全的。(实际工作中我发现为了满足各种不同的需求,有时候API的返回值中会插入一下冗余信息,因此我这里只提完备性。)
    (注:下文有实例)

  • API是抽象的
    在软件工程中,一直都有各式各样的Add a Layer of Indirection,即通过一层抽象,屏蔽掉具体的数据/实现/细节。使用领域和表现形式各异,其原理实则相同。
    API的设计同样适用。
    (注:下文有实例)

  • API是兼容及可扩展的
    一个API可能需要同时服务于不同的平台:Web、iOS、Android等,也可能需要服务同一个平台的不同版本。虽然可以通过创建不同的API版本(Versioning)达到相同的目的,但是如果同一个API就能做到,岂不更好?
    (注:下文有实例)

  • 其它
    另有很多重要特性,比如安全性Tracking等,但是我个人觉得和我想表达的主题还是有差异。如需更多了解,请参看文章最后的链接。

二. 我亲历、验证了的API设计Tip

1. 使用 JSON Object (Dictionary/HashMap)

JSON格式简美,是现在流行的通信格式。上一句是废话,其实我想说的是,相对于Array,使用Object (Obj-C/Swift中可与Dictionary互转,Java中可与HashMap互转) 能够让API有更大的腾挪空间。

比如有个搜索视频关键字,返回视频列表的API,要求能够让客户端对视频列表进行分页浏览。最开始设计返回的是JSON Array,在HTTP header中带入总页数供客户端进行分页处理。

这个API的设计简明直白:你需要列表,我返回Array。因此服役了不短的时间。
后来需求变了,要求在用户搜索特定关键字或者出现特定视频的时候,在页面上加入特殊的Label。然后,然后...不得不重新设计了v2版本的API。为了继续服务老版app,后台需要维护两套API。烦!

若最开始这个API就设计成JSON Object,则有好处如下:

  1. 总页数不必带在HTTP header中,整个API的信息都集中在Object内。即是我上面提到的“API的自述性”
  • 对于新的需求,增加一对Key-Value即可,老版app和新版app采用同一个API,不需要额外的逻辑去维护两套API。即是我上面提到的“API的兼容和扩展性”
2. API不要返回后台数据库的index(如自增长ID)

前端对后台资源进行引用时,常需要一个唯一标识,比如xxID之类。当时后台小伙极力说服我使用数据库中的自增长ID,被我否决了。
一般而言,生成一个全局唯一的UUID或者标识性String都是不错的选择。

前些时则发生了另外一件事,很能说明些问题。有个资源文件比较庞大,我采取了如下的使用和更新策略:

  1. app内用本地文件的方式预存一份数据 (version=1)
  • 当app内需要用到这个数据时,先查找缓存,再查找本地文件,这样可以保证以最快的速度获取到数据进行展示
  • 同时,app走API向后台获取这个文件的新版本(带入version=1的参数)
    • 若数据没有更新的版本,后台返回空
    • 若数据有新版本,则下载并缓存

这种方法对数据量大、更新不频繁、后台对数据容忍性大的API非常适用。
可惜上线前突然bug了。最后查找原因,发现是因为之前都在测试服务器上测试,本地文件保存的数据中含有后台数据库的自增长ID。上线前在production服务器上一跑,查无此人。

上面说了一个不使用后台数据库自增长ID的具体例子。也即是前面提到的“API的抽象性”。

3. API获取资源要精准完备

由于业务逻辑的需要,常规的API设计可能会有疏漏时,需要根据情况仔细斟酌。

比如有个网站搜集了过去一年和未来半年全世界所有的公开课、讲座和会议信息,当用户进行浏览时,默认显示当前时间点以后的50条信息;当用户往下翻至第50条时,继续加载后50条;当用户往上翻至第一条并pull整个列表时,前向加载过去的50条信息。

初看起来这个API和前面提到的视频列表很相似,但是存在如下条件:

  • 用户每次刷新时,都有可能有新的公开课、讲座或会议成为过去时,但是用户在连续刷新的过程中,应该尽量看到完整的(无缺失且不重复)的信息
  • 同一时间可能有多个公开课同时开始
  • 后台数据在不停添加,有可能在用户的某两次刷新间隔,就有了新的数据。

因此,传统的分页方式肯定是不行的。中间构思过以第一次浏览时间点作为基准的设计,也被找出了n多问题。

最终我们采用的是以UUID为基准的设计。

  • 当用户第一次浏览时,会根据用户的访问时间点,对所有条目进行时间+自增长ID的二次排序,并返回前50条
  • 当用户向下翻页时,提供最后一个条目的UUID作为参数,后台搜索到其时间和自增长ID,然后同样以时间+自增长ID作为过滤条件进行排序,并返回前50条
  • 当用户向前翻页时,提供第一个条目的UUID作为参数,同上。

举这个例子,主要是想说设计API一定要仔细谨慎,使其具有完备性。但是,让人遗憾的是,这个API其实在某些情况下还是会有遗漏,对照前面的3个条件,你能发现问题吗?

4. API默认值的意义

iOS平台编程中,UIView类提供了hidden属性,用于隐藏此窗口。为何用hidden而不用shown呢?因为窗口默认是显示的,对应着属性的默认值NO(false)。
这样的设定同样适用于API的设计。

比如app中采用统一的广告策略:使用webview加载广告页。但是广告页的展示和动画则可能有两种形式:

  1. 广告页有navigation bar (push进来或者包在navigation bar中被present出来),并显示navigation title。
  2. 广告页被全屏present出来

绝大多数情况下,广告页都采用第一种方案展示,但是特殊的广告页可能会要求采用第二种方案展示(比例较低)。
后台API提供广告URL,以及展示广告页的形式。

这个例子中,显示navigation bar的广告页就是默认情况,因为显示navigation bar的概率大。
在我明了上面这段分析之前,API是这么设计的:

{ URL: "https://xxx", nav_title: "NOT Ads" }  // 糟透了

这里,nav_title承担了双重责任:

  • 如果nav_title不为空字符串,广告页显示navigation bar并设置navigation title。
  • 否则,广告页采用全屏展示;

初看起来这样的设计好像也挺不错。但是由于nav_title的默认值(nil)并不对应广告页的默认展示形式(带navigation bar),可能无法应对新的需求变更。
比如以后因为要加入新的展示形式而需要废弃掉nav_title、替换成别的字段时,会发现nav_title删不得。WHY?因为老版本的用户必须依赖于这个参数的非默认值,简直太糟糕了。
至于如何设计这个API才算好,那就见仁见智了。

5. 全局HTTP header

全局HTTP头很金贵,因为一旦设置,则所有的API都会带上,增加数据量+消耗流量。一般来说,用户信息、平台和版本信息等都是不可缺少的,更多的可以参看我后面给出的链接。
我这里要提醒的是,现在的App设计中,总难保不打开一些web页面,最好记得在webview的HTTP header中做同样的处理哟~

6. 常量Key

上文提到API返回最好是一个JSON Object (Dictionary/HashMap),便于扩展。本节标题里说的Key,就是键值对(key-value)中的key,而常量就是我们通常意义上的const。也就是说,我们用于通信的API接口,其key最好能写成const

比如app请求后台的广告业务数据,参数是placementID,后台根据配置,获取该placementID所对应的广告展示类型adType,然后把数据adData返回给app。

一种意见是用placementID做为key,客户端根据placementID来获取广告,感觉十分直接。结构如下:

{"ads" : {placementID: adData}}

但是app端在这里需要额外的判断adData所隐含的展示类型,认为不妥

另一个种意见是以广告的展示类型adType作为key,app端根据UI的展示方式获取数据,贴近实现。结构如下:

{"ads" : {adType: adData}}

但是这种方法一是丢掉了placementID的信息,扩展性上存在问题(比如一次性请求多个placement的广告时);二是广告的展示类型可能会随业务变化,作为key时同样有兼容性的问题。

但是按照本节的标题所建议的设计方式就不存在这方面的问题了。结构如下:

{"ads" :
 [
  {"ad_placement_id": placementID,
 "ad_type": adType,
 "ad_data": adData},
  ...
 ]
}

在上面最后的实现中,所有的key都是const,即"ads", "ad_placement_id","ad_type""ad_data",整个API都很容易扩展,保持良好的兼容性。

三. 别人总结的经验

网上也有一些经验总结,可以参考:

  1. HTTP API Design Guide (有中文翻译)
  2. Best Practices for Designing a Pragmatic RESTful API
  3. API Design Principles (QT的API设计原则--Restful API和Lib API大同小异)
  4. 虚拟研讨会:如何设计好的RESTful API?(InfoQ的一个总结)

推荐阅读更多精彩内容