设计一套良好 REST API

设计一套良好 REST API

硅谷的apigee公司给出一份对REST API的设计指导原则,可以说这家公司在api开发,管理的成绩有目共睹。其提供的指导原则,可以说结合了其自身实际开发经验,诸多大型平台的实际运营经验和标准http规范。非常值得一读。

首先,你需要对REST API有一个基本的概念认知,然后再深入阅读:

1. 基于业务领域的数据建模,而非基于功能建模。

例如,取得所有的dog

GET /api/dogs

取得一个特定的dog

GET /api/dogs/{id}

取得特定名字的dogs

GET /api/dogs/?name=xxx

创建一个dog

POST /api/dogs

更改一个dog

PUT /api/dogs/{id}

删除一个dog

DELETE /api/dogs/{id}

标准的HTTP的方法已经提供了一套约定俗成的操作语义。

而一个基于功能建模的api,通常会是下面的样子:

/getAllDogs/getDogsByNames/getAllBabyDogs/createDogs/createThreeDogs/saveDogs

以上这些我经常在新手的代码里看到。这样做的代码,没错,可以运行,但是不‘标准’ why?基于功能建模的api,首先会造成学习曲线的增长,不容易上手,也往往意味着需要记忆大量的url(swagger会解决一部分这个问题,但不是全部)。

使用HTTP的标准方法作为操作数据的基本语义,胜在其标准的普适性。这一点上,大的平台Github,Heroku等,做的是最好的。

2. 设计数据的表现形式

毫无疑问,使用JSON,今天JSON已经是事实上的web数据标准,简单易懂。但是JSON也有其缺点,比如如何表达Date和Timestamp,一个简单的做法是用string来表达,根据上下文来判断如何解释。尽量保持JSON数据的简洁性。

并且使用link去表达资源之间的联系:

例如:

{"id": "12345678","kind": "Dog""name": "Lassie","furColor": "brown","ownerID": "98765432"}

更好的方法:

{"id": "12345678","kind": "Dog""name": "Lassie","furColor": "brown","ownerID": "98765432","ownerLink": "https://dogtracker.com/persons/98765432"}

更简洁的表示:

{"id": "12345678","kind": "Dog""name": "Lassie","furColor": "brown","owner": "https://dogtracker.com/persons/98765432"}

这样做的好处是客户端不需要自己重新构建URL去获取owner,更方便使用。Google Drive API 和 Github 均使用这种方式。但这样做也不是没有坏处,最容易想到的是,如果url改变了怎么办?production,staging,testing用的是不一样的domain哟。一个方法是:使用相对路径,而不是绝对路径。但这也不能解决全部问题。

3. 设计URL的表现形式

两大原则:规范的(regular),可预测的(predictable)。

只使用名词

要有切入点

要合适的选择id形式

要表达资源间的联系

要支持查询

要支持返回部分资源

处理更复杂的计算逻辑

只使用名词:在URL中使用名词,避免动词,一旦使用动词,意味着你是在对功能建模,而非数据。

要有切入点:原则上来讲,一个api应该有一个root path '/', 其返回一个url map,包括了所有的resouces所对应的url。这样客户端更容易去发现和使用api。

要合适的选择id形式:例如api/dogs/{id} 中的{id}如何表达,是类似于‘/dogs/1’,还是‘/dogs/haha’? 一般来说取决于后端的数据库,大多数情况下,使用RDBMS,像mysql之类的主键自增功能,我比较倾向于使用自增主键的整数直接作为entity的id,避免很多问题,如果使用MongoDB的话,不妨试一试用字符串作为entity的id,可读性会提高,但是如何维护一个全局唯一的字符主键,你得三思。

要表达资源间的联系:比如 GET /persons/1/dogs 返回所有属于person 1的狗。

这种模式可以表述为:

/{relationship-name}[/{resource-id}]/…/{relationship-name}[/{resource-id}]

要支持查询:

GET /persons;1/dogs GET /persons;name=blabla/dogs

这种模式可以表述为:

/{relationship-name}[;{selector}]/…/{relationship-name}[;{selector}]

更复杂的查询条件:

GET /dogs?color=red&state=running&location=park

注意这里的三个查询子条件之间的关系是‘与(and)’,如果要表达是‘或(or)’的逻辑,那就得设计更复杂的query解释机制。但其实实际使用中,表达‘或’的查询条件很少使用。

要支持返回部分资源:

/dogs?fields=name,color,location

返回的resource中,只包含name,color,location三种信息。

处理更复杂的计算逻辑: 有很多例子,比如货币转换,大多数开发人员给出的方案是:

/convert/100/EUR/CNY 或者 /convert?quantity=100&unit=EUR&in=CNY

切记:URL是用来表述资源resource,而不是表述计算的过程。在URL中使用名词,避免动词。

改进的方案1:

GET /monetary-amount/100/EUR HTTP/1.1Host: Currency-Converter.comAccept-Currency:CNY //在http的header中添加Accept-Currency来指明货币的种类。

改进的方案2:

POST /currency-converter HTTP/1.1Host: Currency-Converter.comContent-Length: 69{"amount": 100,"inputCurrency": "EUR","outputCurrency": "CNY"}

两个方案都可行,但是方案2有两个注意的地方:POST返回的结果可能无法再server端缓存;你是在构建一个计算的过程,而非资源的表述,如何理解?就像数据库操作中的‘store procedure’,你可以使用,并且功能强大,但是接口变得复杂,逻辑变得耦合。

4. 反思-设计数据的表现形式。

添加self link

集合数据

数据分页

数据格式

添加self link:self link提供了一个上下文环境,客户端可以更容易理解当前的resource的位置

{"user": { "html_url": "octocat (The Octocat)", "type": "User", "url": "https://api.github.com/users/octocat"}}

集合数据:

方案1:集合collection也是一种resource,也具有self和kind属性,这样所有的单独entity和collection都具有更加统一的规范

{"self": "https://dogtracker.com/dogs","kind": "Collection","contents": [{"self": "https://dogtracker.com/dogs/12344","kind": "Dog","name": "Fido","furColor": "white"},{"self": "https://dogtracker.com/dogs/12345","kind": "Dog","name": "Rover","furColor": "brown"}]}

方案2:看起来更加简洁,但是客户端可能需要去添加额外的逻辑去处理collection

[{"self": "https://dogtracker.com/dogs/12344","kind": "Dog","name": "Fido","furColor": "white"},{"self": "https://dogtracker.com/dogs/12345","kind": "Dog","name": "Rover","furColor": "brown"}]

还有一种做法是,针对一个collection,使用自定义的media type header(比如‘Collection+JSON’)这个方法可行,但是会让客户端的处理逻辑复杂。

数据分页:当数据返回的集合变大时,显然不可能一次性把所有数据都返回给客户端,最好能分批的返回,比如:

GET https://dogtracker.com/dogs?limit=25,offset=0返回{"self": "https://dogtracker.com/dogs?limit=25,offset=0","kind": "Page","pageOf": "https://dogtracker.com/dogs","next": "https://dogtracker.com/dogs?limit=25,offset=25","contents": [...】}

在返回的数据中,加入‘pageOf’来指明查询的起点,‘next’指明下一页的url,当返回第二页的时候,还需加入‘previous’来指明上一页。

数据格式:现在大多数的api几乎只支持JOSN格式的数据来作为input和output,如果要支持更多的数据格式,那么应该要支持Http Accept Header。

能否用HTML作为输出的格式?可以,但是这样就丧失的rest API的灵活性。现代的web应用,大多使用REST API + SPA的设计,SPA端使用Angular等框架,自己渲染HTML,REST API只提供数据服务,前端后端通过JSON数据来交流,从而实现了前后端的彻底解耦。

如果选择JSON作为唯一的数据格式,那么最好支持Http的patch方法,现在有两种patch的模式:JSON Patch和JSON Merge Patch,选择一个来用于资源的更新操作。现在也有很多API只提供PUT来更新资源,这意味着每次请求都必须发送整个resource enrity,势必会消耗更多的payload,但是实现起来更容易。

5. 错误处理

总的原则:使用标准http的status code来表示错误的类型。具体的错误内容,也要被返回。

6. 认证和授权

人生苦短,使用OAuth2。最起码也要使用基于token的鉴权模式。

7. SDK

可以推出SDK来作为你的REST API的一个补充,就像AWS那样,针对每一个服务,都有相应的编程语言的SDK。这样更方便第三方的开发人员使用你的api。多见于SaaS平台。但是小型的平台,得考虑维护的成本。

8. Versioning 多版本

REST API的版本控制问题是一个非常有争议的话题,网上的提议有很多,在这里我们不是简单的给定具体的方法,而是提供几种可行的想法,具体的实施还需自己拿捏:

不(显式)支持多版本

使用Http Accept Header

第一种,什么都不做,不支持多版本的api。这个想法的背后依据是,根据调研发现,大多数的中小型规模的平台服务,客户规模都在一个可控的范围,api的升级不会很频繁,你只需通知你的客户,在某个时间点api会更新,然后再server端做一些兼容性的数据迁移,比如增加或删除某个数据库中的表的某个列的名字。大多数情况下,支持多版本api费力不讨好,测试和部署的成本很大,收益却很小。你要做就是保持唯一个可用api服务的兼容性,而不是提供多个版本的api让用户使用。

第二种,如果你一定要支持versioning,那么就在http的accept header中添加version信息,不要在url中使用version信息,千万不要用/api/v1/xxx。

实际的工作中,对于刚入职的小盆友,在介绍REST API的时候,我会推荐他们读这个。

具体的可以参考:Zalando RESTful API and Event Scheme Guidelines其中的规范涵盖了很多细节内容,细节的规范越多,代码风格才越统一,团队沟通效率才越高。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 215,874评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,102评论 3 391
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 161,676评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,911评论 1 290
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,937评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,935评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,860评论 3 416
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,660评论 0 271
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,113评论 1 308
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,363评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,506评论 1 346
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,238评论 5 341
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,861评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,486评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,674评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,513评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,426评论 2 352

推荐阅读更多精彩内容