RESTful API设计最佳实践

原文地址:http://www.vinaysahni.com/best-practices-for-a-pragmatic-restful-api
数据模型日趋稳定后,你可能考虑开放一些API供web应用使用。API一旦发布就很难再做大的改动,所以你一定希望第一版API的质量就很高。如今,尽管互联网上关于API设计的标准很多,但并没有哪种标准是放之四海而皆准的,不管选择哪种标准,你都有一大堆主意要拿:API接受设么格式的参数?采用哪种授权方式?需要版本化你的API吗?
在为SupportFu设计API的过程中,我一直试着从程序角度去回答这些问题。我的目标是SupportFu API要简单易用,还要足够灵活,能适应我们的前端UI的种种变化。
API设计重点
网上关于这个话题大多纠结于一些学术概念,而不是实践。我这篇文章的目的就是在现有的条件下讨论设计API的最佳实践。我也不指望这些原则跟哪种标准严丝合缝。为讨论方便,我列出以下成功API要满足的需求:
--尽可能的使用web标准
--开发者友好,并且应该用浏览器的地址栏能直接调用
--尽量简单直观,并且前后版本保持一定的一致性,让升级过程轻松愉快
--足够灵活,能适应SupportFu UI的种种需求
--在不影响其他需求的前提下应尽量高效
API就像是给开发者用的UI,所以跟其他的UI一样,用户体验是王道,切记三思后行!

设计符合REST标准的URL和操作
RESTful标准现如今大规模的应用网络。这个标准最早是由Roy Felding在他那篇著名的论文里提出来的。
REST的核心概念就是资源,资源是个逻辑概念,API应该按照资源分类。通过常用的HTTP的方法(GET,POST,PUT,PATCH,DELETE)来操作这些资源,这几个方法在HTTP的语境里有着明确的语义。
问题是我怎么定义资源?答案是,资源应该是个名词(而非动词!),而且这些名字应该很容易让API使用者理解。可能你目前的数据模型就很容易跟资源对应起来,但实际操作过程中,并不这俩货不一定要一一对应。一个重要原则就是不要让你的API泄漏了与其无关的底层实现!SupportFu用到的名词包括ticket,user和group.
定义好了资源以后,下一步就是这些资源允许哪些操作,还有怎么把资源+操作翻译成API。RESTful概念提供了一些设计原则,这些原则通过使用HTTP的方法实现CRUD操作:
-- GET /tickets -查询tickets列表
-- GET /tickets/12 -查询某个ticket
-- POST /tickets -新建一个ticket
-- PUT /tickets/12 -更新12号ticket
-- PATCH /tickets/12 -部分更新12号ticket
-- DELETE /tickets/12 -删除12号ticket
REST强大的地方就在于它最大化的利用了现有的HTTP标准,我们只定义了一个/tickets服务,通过HTTP的动词就轻松实现这这么多功能,而且你也不用再纠结于如何命名这些动作,最后一点就是URL的结构干净利索,逻辑清晰。

我应该用单数还是复数定义资源?根据keep-it-simple原则,我们都用复数名词。你心理可能会想,这种设计不对,不应该用复数名字表示单一的资源。不过都用复数之后,API使用者的日子就好过多了,他们再不用纠结于person/people,goose/geese,同时API提供者也轻松多了,目前大多数的框架都能用一个控制器去处理/tickets和/tickets/12。

如何处理资源间的关系? 如果只是从属关系,那么RESTful有着一套实用的原则。举个例子,SupportFu的每个ticket都包含多条message。 这样message和ticket的关系可以如下表示:
-- GET /tickets/12/messages -查询12号ticket下的messages列表
-- GET /tickets/12/messages/5 -查询12号ticket下的5号message
-- POST /tickets/12/messages -在12号ticket下添加一条message
-- PUT /tickets/12/messages/5 -更新12号ticket下的5号message
-- PATCH /tickets/12/messages/5 -部分更新12号ticket下的5号message
-- DELETE /tickets/12/messages/5 -删除12号ticket下的5号message

如果某种关系独立于资源存在,那么你需要在资源的响应里加上这种关系的描述,API使用者号根据这个描述去查找另一个资源。另一种情况是,如果这种关系通常情况都是跟这个资源一块请求的,那么在响应的时候就直接包括这种关系,省得API使用者再发第二个请求。

CRUD不能表达的操作如何处理?
这种情况就比较微妙了,通常有几种方式可以处理:

  1. 当操作不需要参数的时候,可以把这个操作处理成某个资源的一个属性。比如activate操作可以处理成一个activated的boolean类型的属性,然后用PATCH方法更新它。
  2. 把它处理成一个子资源。比如GitHub的API通过这样的方式来stat a gist, PUT /gists/:id/star, 这样来unstar DELETE /gists/:id/star。
  3. 有时候你确实没办法把某个操作处理成一个合理的RESTful结构。比如涉及到多个资源的联合查询就很难归到某个资源下面。这种情况,用/search可能最为合适,尽管你很难把search称作一个资源。这种处理方式也ok,对API使用者来说合理就行,但要记得把文档写清楚。

全站SSL
全都用SSL,没有任何例外。现如今只要有internet的地方就能访问你的web API,比如图书馆、咖啡店、机场啥的。这些地方并不保证网络安全,有些可能根本没有对通信加密,如果用户名密码被劫持了,消息很容易泄漏甚至出现冒名顶替的情况。
用SSL的另一个好处是,通信过程加密了以后,你可以简化认证手段--可以用简单的 access token机制,而不必为每一个API请求加上数字签名这么麻烦。
有一点要注意的地方是,当非SSL的API请求过来的时候,不要直接redirect到SSL服务上去。这种情况应该报错!我们要极力避免一个发往非加密服务器的请求,悄咪咪的的就给redirect到加密的服务器上。

文档
文档的质量决定了API的质量。API文档应该很容易找得到,并且面向大众开放。大多数开发者都是先看API文档,再用API。如果文档写在PDF文件里,或者需要登陆才能看,这样不但难找而且还难搜。
文档里应该包括HTTP请求和响应的实例。如果HTTP请求里面有能复制粘贴的例子就更好了,像URL可以直接粘贴到浏览器里,或者curl命令能粘贴在命令行工具里。Github和Stripe在这方面就做的很到位。
一旦你发布了API,你就等于是承诺了不能悄悄的升级或修改。如果有任何deprecation或者对用户有影响的改动,这些都要明确的写在文档里。应该通过博客(比如changelog)或者邮件组的方式发布更新,两者一块用就更好了。

版本化
永远要记得版本化。版本化会帮助你更快的迭代,而且还能减少旧的请求发到更新过的服务上。还有就是它能让你API大版本升级更容易,你可以在一段时间内同时支持新旧版本API。
关于API版本是应该放在URL里还是HTTP header里,目前仍有很多争议。从学术角度说,或许更应该放在HEADER里。然而,要是想让用户通过浏览器直接就能测试不同版本的API,那就需要放在URL里(还记得我们文章一开始列出的需求不?)
我强烈赞同Stripe采用的方式。把主版本号(v1)放在URL里面,但用户可以在HTTP HEADER里指定一个子版本号,子版本号是根据日期生成的。在这里,通过主版本号保证了每版API的稳定性,同时子版本号用来标识API的小改动(deprecate字段等)。
API不可能一成不变。变化是永恒的。问题时如何管理这些变动。良好的文档加上预留几个月的deprecate的时间是套可行的办法。具体执行的时候要根据API用户数量等因素灵活调整。

对结果过滤,排序和检索
资源的URL最好保持干净,复杂的操作,如结果的过滤排序和搜索(对单一资源的搜索)总是可以用query参数来实现。具体如下:
过滤:对于每个字段都要定一个query参数做过滤用。例如,当通过 /tickets API查询tickets列表时,你可能只想要找处于open状态的tickets。这时候就可以通过类似这种方式查询 GET /tickets?state=open。这里,state就是一个充当过滤作用的query参数。
排序: 跟过滤类似,可以用一个通用的sort参数来描述排序规则。对于复杂的排序,可以通过逗号分隔的多个字段实现,每个字段可以用一个负号来标识倒序。比如:
-- GET /tickets?sort=-priority -按ticket的priority倒序排序
--GET /tickets?sort=-priority,create_at -按按ticket的priority倒序排序,相同的priority,越早建的ticket排的越靠前
检索: 有些情况下简单的过滤并不够用,我们还需要更强大的全文检索功能。或许你已经在用ElasticSearch或者其他基于Lucene的检索技术了。如果某种资源用了全文检索技术,那么可以把检索条件作为API的一个query参数,比如q。检索参数应该直接发给检索引擎,API的输出应该跟其他的返回列表类型结果的API保持一致。
综合以上,我们的查询是这样的:
-- GET /tickets?sort=-updated_at -查询最近更新的tickets
--GET /tickets?state=closed&sort=-updated_at -查询最近关闭的tickets
--GET /tickets?q=return&state=open&sort=-priority,create_at -查询包括"return"关键字,处于open状态并且优先级最高的tickets
为常用查询起别名
为了让普通用户获得更好的API使用体验,我们可以考虑把常用的查询打包成简单易用的RESTful路径。比如,上面提到的最近关闭的tickets查询的API就可以包装成这样 GET /tickets/recently_closed
API返回字段可选
API使用者并不是每次都需要某个资源的全部字段。让用户去选择需要返回的字段有诸多的好处,比如可以减少网络流量,也能提高API的使用效率。
用一个fields的query参数表示需要的字段,多个字段用逗号分隔。比如,下面这个请求就仅仅查询了用于展示处于open状态ticktes的必要信息:
GET /tickets?fields=id,subject,customer_name,update_at&state=open&sort=-updated_at

更新/新建操作应该返回资源的最新状态
PUT,POST或者PATCH方法可能会改变某个资源的字段,而这些字段又不在API的输入参数里,比如create_at,update_at,timestamps。API的响应包括这些信息的话,API使用者就不用再调用一次查询API了。
当用POST方法创建资源的时候,用 HTTP 201 status code当响应头,再加上指向新资源URL的Location header。

应该用HATEOAS吗?
一个很有争议的话题是谁来维护API之间的联系,是API使用者还是由API自己来提供。RESTful定义了HATEOAS,这个标准大致描述了如何让API的输出包含这些联系的元数据,而不是用户通过别的手段自己建立这些联系。
尽管目前互联网就是工作在HATEOAS原则之上的(先是登陆主页,然后能到哪个页面完全取决于页面上提供了哪些链接),但是我认为我们还很难在API级别上应用HATEOAS原则。浏览网页时,决定点哪个链接时运行时的行为。然而,对于API来说,调用哪个API是你写代码时候就应该定下来的,不是在运行时决定的。那么我们能不能也拖到运行时再做决定呢?当然能,不过,这么做好处也极为有限,如果API有了大的改动,我们的代码还是会跑不起来。总之,我认为HATEOAS很有前景,但现在还不实用。要发挥它真正的威力,还需要细化标准,开发相关工具才行。
就目前而言,最好的办法可能是这样,用户通过文档去了解API间的关系,同时在API的输出里包括相关资源的标识符,API用户可以通过这些标识符号来生成关联API的URL。用标志符有几个好处:网络流量最小化,同时API用户需要存储的数据也最小化(因为他们只需要存标志符而不是整个URL)。
此外,考虑到我们提倡将API版本写到URL里,让API用户存储资源标志符比存储URL更加合理。毕竟,在不同的版本里,标志符是一样的,URL却不一样!

只用JSON
在API世界里,XML应该是走到末路了。XML缺点很多:啰嗦,难解析,难读,数据模型跟大多数编程语言的数据模型都不兼容,它最大的优点--可扩展对API又没啥用,因为API的需求只是把内部对象序列化。
毋需赘言,只要看看哪些抛弃XML的服务就行了,比如YouTube,Twitter,Box。
看看Google Trends的图表就一目了然了(XML API vs JSON API):

然而,如果你有大量的其业绩用户,那么你可能仍然需要支持XML。当你要支持XML的时候,你可能又发现了新的问题:
根据什么来决定返回JSON还是XML呢?Accept header还是URL?为了确保用浏览器能直接调用API,应该根据URL来决定。最合理的方式是在URL的最后加上.json或者.xml。

命名,snake_case还是camelCase
如果你用JSON组织数据,那么就是遵循JavaScript命名规范就是最“合理”的选择,也就是camelCase。如果你又接着为各种语言提供客户端类库,那么就按照各个语言的习惯命名--对于C#和Java用camelcase,python和ruby用snake_case。
其实,我一直觉得snake_case比camleCase更易读。之前还只是直觉,没有什么证据来支撑。不过一篇2010年的文章“eye tracking study on camleCase and snake_case”证明,snake_case比camelCase好读20%!这种易读性对于浏览API和文档里的例子很关键。
现在,很多流行的JSON API都用了snake_case。我猜是因为那些序列化的类库按照其实现语言的命名习惯生成的JSON。没准我们需要JSON序列化库提供命名习惯转换功能。

默认用pretty print,同时也支持gzip
当API的结果把空格都压缩掉了,在浏览器上显示出来就特难看。尽管可以通过加个query参数的办法(比如?pretty=true)来控制是否要pretty print,但默认就让API支持pretty print会好得多。多传的数据往往很少,尤其再跟没实现用gzip多传的数据比起来,都可以忽略不计。
考虑下面的case: 当API用户debugg自己的程序,把API的返回内容打印出来,这时候就要求返回内容默认是可读的。又或者用户拿到他代码生成的URL,然后直接用浏览器访问,也需要它默认可读。这些都是小事,不过正是需要把这些小事都做好,才能让API真正好用。
到底pretty print多传了多少数据呢?
找个实际的例子看看这个问题,就拿GitHub的pull data API为例,这个API默认就是用了pretty print。我还会对比用了gzip的差别:
$ curl https://api.github.com/users/veesahni > with-whitespace.txt
$ ruby -r json -e 'puts JSON JSON.parse(STDIN.read)' < with-whitespace.txt > without-whitespace.txt
$ gzip -c with-whitespace.txt > with-whitespace.txt.gz
$ gzip -c without-whitespace.txt > without-whitespace.txt.gz
结果如下:
without-whitespace.txt - 1252 bytes
with-whitespace.txt - 1369 bytes
without-whitespace.txt.gz - 496 bytes
with-whitespace.txt.gz - 509 bytes
在本例里,如果不用gzip空格会让结果大8.5%,如果用了gzip,结果大2.6%。另一个事实是,光用gzip就能节省60%的流量。可以看出来pretty print的影响很小,所以还是默认用pretty print,同时保证支持gzip。
延伸一下,Twitter发现它们的Streaming API某些情况下用gzip能节省80%的流量。更有甚者如Stack Exchange,它们从不返回非压缩的内容!

不要默认对结果进行包装,但同时要支持包装以备不时之需
好多API多这么把结果包装一层:
{
"data" : {
"id" : 123,
"name" : "John"
}
}
有好多理由这么做:想加别的元数据信息的时候就更容易了,有些REST client不好取header参数,像JSONP就根本取不到header参数。不过,随着CROS和Link header from RFC 5988的大规模使用,对结果包装看来已经没必要了。
我们接着还能得出这样的结论:默认情况不要包装结果,只有特殊情况下在这么做。
那些特殊情况需要这么做呢?
有两种情况必须这么做的:API支持用JSONP的跨域请求,或者client端不支持HTTP headers。
JSONP的请求都是多带一个query参数(通常叫callback或者jsonp),这个参数用来传调函数的名字。如果这个参数存在,则API应该对结果进行包装,并且response code应当始终是200,然后把真正的response code放到reponse的JSON里。其他的HTTP headers参数也要在JSON里存一份,比如:
callback_function({
status_code: 200,
next_page: "https://..",
response: {
... actual JSON response body ...
}
})
同样,针对功能不全的HTTP client,也要支持一个query参数?envelop=true,加上这个参数(这种情况没有JSONP的callback参数)就要包装后的结果。

用JSON传POST,PUT和PATCH的body参数
如果你遵循本文的原则,你应该已经接受了用JSON作为API的输出。现在我们看看用JSON作为input参数的问题。
好多API的HTTP body都用URL encoding编码。URL encoding乍看上去很完美,这些body里的键值对用了跟query参数一样的编码方式。它简单,用的广的方法而且适用与大多数的情况。
然而,URL encoding有一些问题让它用起来很麻烦。 它不区分数据类型。这样API就只能把整型和布尔型的都转换成字符型。更严重的是,他不支持层次结构。尽管有些规则能通过键值对构建层次(比如在key后面加上[]来表示array),不过都没法跟天然支持多层次的JSON相提并论.
如果API很简单,没准用URL encoding就够了。不过,对于复杂的API,输入参数用JSON是你不二的选择。不过最后选择那种方式,一旦选定就有始有终,所有API都用这一种方式。
支持JSON作为输入参数的POST,PUT和PATCH的应该要求请求的Content-Type是application/json,不是的话就返回415 Unsupported Media Type。

分页
支持包装结果的开发者的一个主要动力就是可以把分页信息包在里面。之前我也没觉得这样哪不好,毕竟也没别的好办法。如今,我发现分页信息应该用Link header表示,这才是真正的解决之道。
用了Link header的API可以直接返回一些可用的链接,不用API使用者自己组织URL了。尤其是用cursor分页的API,这种方式就更合适了。下面的例子来自于GitHub API,这就是Link header的典型用法:
Link: https://api.github.com/user/repos?page=3&per_page=100; rel="next", https://api.github.com/user/repos?page=50&per_page=100; rel="last"
但这还不是一站式的解决方案,好多API还要返回额外的分页信息,像总条目数。这种情况可以通过自定义的HTTP header来实现,比如X-Total-Count。

自动加载相关资源
很多情况是这样,当请求了一个资源后,还需要请求这个资源相关(或引用)的资源。与其让API使用者反复的调用,如果能有选项让用户决定是否加载相关资源的话,真就方便多了。
不过,这样违反了RESTful原则,我们可以通过家一个embed(或者expand) query参数来最小化这种不一致。
细节上,embed应该用逗号分隔的多个字段,用点来分隔资源和属性。比如:
GET /tickets/12?embed=customer.name,assigned_user
这样结果除了ticket信息,还会包括其他相关信息:
{
"id" : 12,
"subject" : "I have a question!",
"summary" : "Hi, ....",
"customer" : {
"name" : "Bob"
},
assigned_user: {
"id" : 42,
"name" : "Jim",
}
}
当然,是否支持取决于内部实现难度。这种操作很容易引起N+1 select issue。

重写HTTP方法
有些HTTP client只能发GET和POST请求。为了支持这些client,API需要支持HTTP方法重写。 尽管目前还没有通用的标准,不过通常都用header X-HTTP-Method-Override来处理,这个header带一个PUT,PATCH或者DELETE作为参数值。
注意:应该值支持POST请求重写HTTP方法,因为GET请求不能修改服务端数据!

流量限制
为了防止滥用API,标准做法就是增加流量限制机制。RFC 6585定义了一个HTTP status code, 429 Too Many Requests就是为了这个目的。
不过,在达到使用上限前通知使用者会更有用。目前这个领域还没有标准,不过已经有一些常用的习惯用HTTP response header可以起到这个作用。
流量限制最起码要支持这几个header(这里用Twitter的命名规则,通常header的中间字母都不用大写):
X-Rate-Limit-Limit - 当前时间段内允许请求的数量
X-Rate-Limit-Remaining - 当前时间段内剩余请求数量
X-Rate-Limit-Reset - 当前时间段还剩多少秒

X-Rate-Limit-Reset为什么用剩下多少秒而不是时间戳?
时间戳包括很多有用的信息,比如日期,时区等,但这些信息都是多余的。API使用者其实只是想知道他们什么时候才能在此发送请求,他们需要的只是还有多少秒,秒数对API使用者来说还不用做任何额外的处理。同时,这么做还避免了clock skew相关的问题。
有些API用UNIX的时间戳作为X-Rate-Limit-Reset,别这么做!
HTTP协议规定了日期的格式,参见RFC 1123(目前在应用在Data,If-Modified-Since和Last-Modified heaer里)。如果我们的哪个HTTP header想用时间戳,那么应该用RFC 1123定义的格式,而不是UNIX的时间戳。

认证
RESTful API应该是无状态的。也就是说认证不应该依赖cookie或者session,应该是每个请求都带上认证信息。
有了到处SSL的原则,认证信息可以简化成一个随即生成的access token,然后用HTTP Basic Auth的user name字段去发。这么做的好处就是,完全可以用浏览器操作,一旦收到401 Unauthorized的响应,浏览器会弹出输入库让用户输入认证信息。
不过,这种用HTTP Basic Auth发token的方式只适用于API使用者能那得到用户token的情况。在不适用的情况下,可以使用OAuth2协议,它保证token能安全送达第三方开发者。OAuth2使用Bearer tokens,也要求SSL来传输。
支持JSONP的API需要第三种认证方式,因为JSONP既不能发HTTP Basic Auth也不能发Bearer tokens。这时候就需要一个特殊的query参数,access_token。注意:这种方式有个天生的安全问题,因为多数的服务器都会把query参数写到log里。
以上三种方式只是在讨论token如何传输,至于token本身没有特殊要求,这三种方式可以用一样的token。

缓存
HTTP天然支持缓存!你要做的只是加一些header在response里,再有就是简单的校验一下request的相关header。
有两种方式:ETag和Last-Modified
ETag:在生成request的时候,加一个名字为ETag的HTTP header,header的值是请求内容的哈希值或者校验值。这个值应该随着请求内容的变化而变化。当受到了一个包含If-None-Match header的请求,并且ETag的值匹配,则API应该返回304 Not Modified,而不用返回请求内容。
Last-Modified: 基本跟ETag是一个道理,除了用的是时间戳。Response的Last-Modified header里面是一个符合RFC_1123标准的时间戳,用这个时间戳跟If-Modified-Since作比较。注意:HTTP标准定义了三种日期格式,服务端应该都支持。

报错
跟HTML页面有时候会报错一样,API也应该支持一些通用的错误信息。错误的格式应该跟其他API结果格式保持一致,只是字段有所不同。
API应该总是返回合理的status code。通常API错误可以分成两类:4xx系列和5xx系列,前者是客户端问题,后者是服务端问题。最起码,API应该支持标准化的4xx系列错误,并且错误内容用合法的JSON格式表达。如果可能的话(比如负载均衡或者反向代理也能生成错误内容),5xx系列错误也应当如此。
JSON格式的错误应该给开发者提供一些有用的信息--明确的错误内容,一个唯一的错误代码(可以对照这文档查看报错细节),可能还要包括错误的详细描述。比如:
{
"code" : 1234,
"message" : "Something bad happened :(",
"description" : "More details about the error here"
}
PUT,PATCH和POST的校验错误可能涉及到具体的字段。这种情况的最佳做法是,顶层用一个固定的错误代码,在底层的errors字段写上详细的错误信息,像这样:
{
"code" : 1024,
"message" : "Validation Failed",
"errors" : [
{
"code" : 5432,
"field" : "first_name",
"message" : "First name cannot have fancy characters"
},
{
"code" : 5622,
"field" : "password",
"message" : "Password cannot be blank"
}
]
}

HTTP status codes
HTTP定义了一系列有用的status code,你的API应该支持这些。API使用者可以根据这些code来决定每个请求如何处理。下面列举一些我认为绝对需要用到的code:
200 OK - 成功,适用于 GET, PUT, PATCH or DELETE. 同时也适用于没有新建资源的POST方法
201 Created - POST方法成功新建资源。应该结合Locaiton header一起使用,Location指向新建资源的URL
204 No Content - 成功,但没有response body(比如Delete请求)
304 Not Modified - 用于HTTP缓存机制
400 Bad Request - 请求格式不对,比如body无法解析
401 Unauthorized - 没有提供或者提供无效的认证信息。当用浏览器调用API的时候,也可以用来触发认证输入框弹出。
403 Forbidden - 认证通过,但用户没有该权限
404 Not Found - 请求资源不存在
405 Method Not Allowed - 用户无权使用该方法
410 Gone - 资源已不存在。适用于响应老版本API的调用
415 Unsupported Media Type - Request content type错误
422 Unprocessable Entity - 验证错误
429 Too Many Requests - 达到流量上限,被拒绝

总结
API就是开发者使用的界面。我们目标不仅是能用,而且好用。

2014-10-01 17:371308
  • alysejacobos.jimdo.com2017-05-10 20:09

    My developer is trying to persuade me to move to .net from PHP.

    I have always disliked the idea because of the costs.
    But he's tryiong none the less. I've been using WordPress on a variety of websites for about a year and am
    concerned about switching to another platform.

    I have heard very good things about blogengine.net.
    Is there a way I can import all my wordpress posts into it?
    Any help would be greatly appreciated!