聊聊RESTful - 接口设计篇(一)

REST

(本文为“聊聊RESTful”系列文章第二篇,其他文章可通过篇尾链接查看)

大家好,欢迎大家来到“就浩这口”,今天我们继续聊聊RESTful。
在我的上一篇文章科普篇中为大家介绍了REST的起源与REST成熟度模型。同时我也指出网上有大量介绍RESTful接口设计的文章,如果我们不理解真正的REST,那么单纯的接口设计的讨论会让我们对REST产生误解。那么在我们了解了什么是真正的REST之后,再来讨论接口设计,就是学以致用。
所以,今天我们就来聊一聊RESTful的接口设计。我计划分以下几部分来介绍:

  • URI命名
  • HTTP方法
  • 消息体格式
  • 异常处理
  • 版本化
  • 鉴权
  • 接口文档

今天先来说说URI命名和HTTP方法。

URI命名

资源与名词

了解RESTful接口的人都知道,URI的命名应该使用名词,为什么呢?因为REST对于信息的核心抽象是资源,而表示资源的词语天然的就是名词,所以这不是什么技术问题,而是常识。

看似简单的接口命名,其背后却是REST当中核心的资源概念。学过面向对象编程的人们都知道这么一句话,万物皆对象。类似的,对于REST则是,万物皆资源。资源这一概念是在Fielding的论文中5.2.1.1小节来介绍的

5.2.1.1 Resources and Resource Identifiers
The key abstraction of information in REST is a resource. Any information that can be named can be a resource: a document or image, a temporal service (e.g. “today’s weather in Los Angeles”), a collection of other resources, a non-virtual object (e.g. a person), and so on. In other words, any concept that might be the target of an author’s hypertext reference must fit within the definition of a resource. A resource is a conceptual mapping to a set of entities, not the entity that corresponds to the mapping at any particular point in time.

更详细的介绍请大家阅读论文。

不过相对于具体实践,论文中对资源的描述还是相当抽象的,很多开发人员不知从何下手。我觉得,所谓资源的设计,在简单项目中与我们传统面向对象编程的设计没有太大的区别。你要对你的需求做详细的需求分析,划分模块,梳理流程,抽象出需求背后的逻辑关系,数据结构等。但是在规模更大,或者业务更复杂的系统中,传统方式便显得力不从心。这里我觉得DDD的理论完全可以作为资源设计的方法论。DDD本身就是为了解决复杂系统而设计的,它提供了多种模式与方法,帮助我们更好的分析需求,以及长期迭代系统。DDD不是本文的重点,我也还在学习中,这里不做更具体的说明了。以后我会做一个DDD的专题跟大家细说。

总之URI的命名,其背后就是对系统的整体设计。大家要做好充足的前期工作,避免生米煮成熟饭时悔之晚矣。

命名规则

命名规则永远是一个具有争议的话题,在RESTful接口中,对于URI一个普遍接受的规则是:全部小写,用中划线连接。

之所以不混用大小写字母,是因为早期的URI一般都是表示服务器上的文件路径,而不同服务器对大小写的敏感性是不同的,为了兼容不同服务器所以才规定不能混用大小写字母,然后用中划线或下划线连接多个单词。

我们如今开发的api,URI一般不再表示服务器上的文件了,而是一种程序使用的路由,而各类开发程序对字符串的判断也都是区分大小写的,所以就算URI混用了大小写一般也不会有问题。但是为了与那些仍旧表示服务器文件路径的URI保持一致,更多的人仍旧愿意沿用全部使用小写字母的规则。至于连接多个单词使用中划线还是下划线,我只能说,中划线使用得更加广泛。

单数复数

在英文中,名词是有单复数之分的,所以在对资源命名时究竟是用单数还是复数,这也成为了一个有争议的问题。我在网上搜寻了很多相关资料,大部分人认为应该统一使用复数,但我在这里有不同的看法,我的使用习惯是,用单数来表示单个资源,用复数来表示集合资源。我们先来看下网上关于使用复数的说法:

1. 阮一峰的网络日志《RESTful 设计指南》

在RESTful架构中,每个网址代表一种资源(resource),所以网址中不能有动词,只能有名词,而且所用的名词往往与数据库的表格名对应。一般来说,数据库中的表都是同种记录的”集合”(collection),所以API中的名词也应该使用复数。

这篇文章写于2014年,这段话中的数据库的表格名,我理解指的是mysql这类的关系型数据库的表名。我在科普篇中也提到过,基于资源的架构设计是不同于基于关系型数据的设计的,关系型数据库的数据结构和资源结构是不能完全对应的,所以作者这里用了往往一词,因为现实中确实有很多人在这么做,但严格来讲,一个良好的资源设计,是很难和关系型数据库一一对应上的。因此我并不赞同作者说的因为数据库中的表都是同种记录的”集合”(collection)而推导出我们的资源命名应该使用复数。另一方面,我们仔细想一下,我们给数据库表命名时可是很少用复数的啊,上网搜下数据库表名命名规则,同样有很多的争论,但我相信,你见过的单数要更多。而面向对象的开发中,对象的命名也大都是单数,集合变量的命名才使用复数。当然,所有这些情况其实都暗含着集合的含义,也许我们都用该用复数,可实际情况并非如此。

2. Best Practices for Designing a Pragmatic RESTful API及其译文

Should the endpoint name be singular or plural? The keep-it-simple rule applies here. Although your inner-grammatician will tell you it’s wrong to describe a single instance of a resource using a plural, the pragmatic answer is to keep the URL format consistent and always use a plural. Not having to deal with odd pluralization (person/people, goose/geese) makes the life of the API consumer better and is easier for the API provider to implement (as most modern frameworks will natively handle /tickets and /tickets/12 under a common controller).

这篇文章提供了两点理由:

  • keep-it-simple,作者说尽管你知道使用复数来表示单个资源是错误的,但是这样更简单,更务实,并且不用处理怪异的复数形式。这点我很不理解,难道符合常识的做法不是更简单更务实的吗?英语中已然有了单复数的区分,可我们却只用复数,我觉得这才是比较怪异的。对于作者提到的怪异的复数形式,虽然存在,但毕竟是少数单词,对于精通英语的外国人来说,应该并不是什么问题。按照DDD的思想,软件的建模应该发生在在真实世界的问题得到解决之后,因此我并不赞同为了开发软件而对语法做不必要的改变。
  • 接口易于实现,作者说大多数现代框架天然地将/tickets和/tickets/12放在同一个控制器下处理,我并不知道这里指的是什么框架,我查了下作者的github,是以Ruby语言为主的,我并不了解这门语言。在Java开发中,我们大都使用SpringMVC或者基于JAX-RS的框架来做web开发,URI与控制器的对应关系都是我们手动维护的,并不存在作者所谓的天然,因此我也并不赞同作者的这个理由。

3. RESTful API Design Tips from Experience译文

Use Plurals
It makes semantic sense when you request many posts from /posts .
And for goodness sake don’t consider /post/all with /post/:id .

// plurals are consistent and make sense
GET /v1/posts/:id/attachments/:id/comments

// don’t consider ambiguity
// is it just one comment? is it a form? etc.
GET /v1/post/:id/attachment/:id/comment

“I like the idea of using plurals for resource names, but sometimes you get names that can’t be pluralised.”

In cases like these you should simply try to get as close to plural as you can!

说实话我并没有理解作者的困惑,如果用comment来表示评论的集合那确实会有歧义,但如果用来表示单条评论则完全没有问题,难道用posts来表示概念上的帖子或者单个帖子时就没有歧义吗?我记得在英语中,表示抽象概念的大都是不可数名词。而且以上这段节选中作者还引用了一段评论,并建议在不能使用复数的时候尽可能使用复数,我觉得这种违背一般认知的方法更容易引起歧义。

网上多数支持使用复数的文章,大都是转载的以上三篇文章,还有一些文章只是支持使用复数,但并没有给出原因,我也便无法给出评价。除此之外,在很多论坛或问答网站中(如Stack Overflow)也找到了一些关于此话题的讨论,他们讨论的内容似乎比一些文章更有价值,因为每个人都在论述自己所支持的观点的原因,而不仅仅是表述自己的观点,比如这个十年前的帖子《终于知道rails单复数的规则了。》

下面我来说下我自己的观点:用单数来表示单个资源,用复数来表示集合资源。在资源的设计中我们比较容易达成的共识是,资源分为两大类,单个资源和集合资源,仍以图书为例,我会将URI设计为:单个资源/book/:id,集合资源/books。原因有以下下几点

  1. 符合英语单复数的使用习惯,多数情况下,符合习惯的才是简单的容易理解的。
  2. 单个资源和集合资源的差异其实是很大的,集合资源中的单个元素的表述,与单个资源的表述是不同的,有时甚至差异很大(集合资源中往往还会包含分页信息)。所以我更愿意将单个资源和集合资源当做两种完全不同的资源来对待,这样也便避免了单复数的混淆。很多文章都主张避免单复数混用,我理解它的原意应该是要避免单复数形式与资源类型的混乱,所以只要我们严格按照单数与单个资源匹配,复数与集合资源匹配,这种混乱也是可以避免的。
    关于两类资源的差异,我们举个例子,单本图书的表述可能包括书名、ISBN、作者、出版社、出版日期、印张数、图书简介、作者简介、目录、样张、封面图等等很多很多信息,而对于图书列表,每个元素中可能只包括书名、作者、价格。也就是说我们在服务端处理时完全可以用两个不同的类来表示单本图书和图书列表中的一个元素,而不是仅仅为了复用一个实体类代码而混用一个类,进而造成两种资源概念的耦合。当然,如果是个简单的资源,复用一个类也是完全没有问题的,但这并不影响我们区分对待单个资源和资源集合。因此,既然我把两种资源区分对待,那么再参考理由1,我觉得用单数来表示单个资源,用复数来表示集合资源就是合理的了。

其实在我的观点中,关注的重点并不是单数与复数的区别,而是单个资源与集合资源的区别

当然,对于单复数的讨论,我并不认为有什么绝对正确的结论,一切都要结合实际情况具体分析,只要你的理由合理充分,就可以大胆使用。以上是我的个人观点,有兴趣讨论的小伙伴可以在下方留言。

HTTP方法

常用的方法有GET, POST, PUT, DELETE,此外有时还会用到HEADPATCHOPTION。前面说我们系统的设计是以资源为核心的,URI的命名要使用名词,那我们要如何来表示一个操作动作呢?就是依靠HTTP方法,也叫HTTP动词。也就是通过这种动名词的组合来表示一次操作,在REST中会被叫做状态转移。

简单的讲GET是查询操作,不会产生状态转移, PUTPATCH是更新操作,会将资源状态转移为客户端期望的新的状态,也有可能是一个从无到有的转移, DELETE表示删除,将资源状态转移为删除,POST表示添加一个资源,即资源状态从无转移到有,另外POST也被认为是一个万能的方法,可以用于所有的操作,包括写操作和复杂的查询操作。

四种常用方法与CRUD

很多人都会认为,四种常用方法就是对应数据库的增删改查,但这只是REST应用于一些简单业务的表象而已。前面说了,REST是以资源为核心的,四种常用方法所操作的对象也是资源,对资源的操作是状态转移,数据的增删改查则是持久层的具体实现,一次状态转移可能会对应多个数据库表的添加修改删除操作,所以他们之间没有严格的一一对应关系。以后讲具体实现时如果有相关例子,再给大家细说。

安全、幂等、可缓存

在2014年最新修订的HTTP1.1规范中HTTP方法有三个通用属性:安全、幂等、可缓存。下表给出了常见方法的安全性和幂等性,可缓存性是根据实际情况决定的。我们在设计接口时,要考虑到这些属性。

HTTP方法 安全性 幂等性
GET
POST
PUT
DELETE
HEAD
PATCH

安全性

RFC7231#section-4.2.1

4.2.1. Safe Methods
Request methods are considered “safe” if their defined semantics are essentially read-only; i.e., the client does not request, and does not expect, any state change on the origin server as a result of applying a safe method to a target resource. Likewise, reasonable use of a safe method is not expected to cause any harm, loss of property, or unusual burden on the origin server.

安全性比较容易理解,只读的就是安全的,所以很多非REST系统中,使用GET方法来提交数据的方式就是违背规范的。也就是说我们在设计实现具有安全性的HTTP方法时(如GET),一定要保证资源状态不变。

幂等性

RFC7231#section-4.2.2

4.2.2. Idempotent Methods
A request method is considered “idempotent” if the intended effect on the server of multiple identical requests with that method is the same as the effect for a single such request. Of the request methods defined by this specification, PUT, DELETE, and safe request methods are idempotent.

幂等性是我想重点介绍的,因为有很多人甚至没听过这个概念。我们可以简单的用一个数学公式来表示幂等性:

f(f(x)) = f(x)

也就是说同一个操作,执行一次和执行多次的效果是一样的。这里的效果指的是我们使用某方法后达到的预期的资源状态。

要说幂等性,就要说下REST的适用范围,Fielding的论文对其讨论范围做了限定,大家可以查看原文译文了解更多详情

2.1 Scope
Architecture is found at multiple levels within software systems. This dissertation examines the highest level of abstraction in software architecture, where the interactions among components are capable of being realized in network communication. We limit our discussion to styles for network-based application architectures in order to reduce the dimensions of variance among the styles studied.

对于网络应用(network-based application),它与集中式应用最大的不同在于,我们要充分考虑网络的不可靠性,我们要把网络异常当做一种常规用例来处理,最常见的处理方式就是重试。而幂等性的意义就在于,如果一个方法是幂等的,那么在出现网络异常而导致请求失败时,我们可以对此请求进行重试,而不必担心引起预期外的结果。而对于不具有幂等性的请求,就不能仅通过重试来处理网络异常了。例如,一个POST请求超时了,我们并不知道服务端是否接收到了请求,如果我们此时进行重试,很有可能会向服务器添加多个资源。

因此,在接口设计中,我们要尽量将接口设计为幂等的,以便调用方可以更方便地处理异常情况。对于规范中定义的幂等的HTTP方法,我们必须将其实现为幂等的,而对于像POST这类非幂等的方法,我们其实也可以尽力设计为对重试友好的。我们还用图书举例,添加一本图书信息时,如果ISBN是全局唯一的,我们可以根据此字段对添加的信息做唯一性校验,这样就可以避免同一请求多次提交时创建多个资源了。当然,调用方在处理请求响应时可能需要考虑201 Created400 Bad Request两种情况。

稍等一下,上面图书的例子中,既然可以重试,那这样的设计是幂等的吗?我们注意到,如果图书信息添加成功则返回201 Created,如果是多次提交导致了唯一约束冲突则返回400 Bad Request,一次提交和多次提交的响应是不同,这算幂等吗?我觉得规范中定义的幂等,关注的是预期效果,它指的应该是资源状态,而非响应内容。例如,DELETE请求,我们的预期效果是将目标资源的状态变为删除,因此不管是一次请求响应204 No Content,还是再次请求响应404 Not Found,资源状态都已经变为删除,也便达到了我们的预期,这就是幂等的。因此响应的不同,并不是判断是否幂等的标准。标准只有一个,就是看执行一次和执行多次的效果是否一样

那么我们再来看上面图书的例子,是幂等的吗?它不是幂等的,因为在这种情况下,客户端无法判断资源状态是否符合预期。比如,当第一次请求超时后,客户端进行重试,然后得到400 Bad Request的响应,提示失败原因是违反ISBN唯一约束,此时客户端无法判断服务端已存在的资源是刚刚自己添加的,还是之前就已经存在的,如果是之前已经存在的,那它的状态可能和自己期望添加后的状态是不同的,客户端可能还需要额外的操作才能判断,或者根本无法判断,我想这样的情况是不能称为幂等的。尽管如此,这样的设计仍能避免多次请求导致重复添加资源的情况,这也是有意义的。有时我可以通过用PUT请求来表示添加资源的方式来解决类似问题,下文中会有详细说明。

可缓存性

RFC7231#section-4.2.3

4.2.3. Cacheable Methods
Request methods can be defined as “cacheable” to indicate that responses to them are allowed to be stored for future reuse; for specific requirements see [RFC7234]. In general, safe methods that do not depend on a current or authoritative response are defined as cacheable; this specification defines GET, HEAD, and POST as cacheable, although the overwhelming majority of cache implementations only support GET and HEAD.

我在科普篇中提到过,REST架构风格是由多个架构风格叠加而成,而缓存风格($)就是其中之一。缓存不是本文重点,这里不做过多解释,只是希望大家了解其在REST架构风格中的地位。参考规范定义,多数情况下,我们在设计接口时要考虑到GET或HEAD甚至POST响应的内容是有可能被客户端缓存的就好。

POST与PUT

幂等性时,我说添加图书信息的例子可以用PUT来解决。也就是说PUT也可以用来添加资源,它和POST的区别就是PUT具有幂等性,所以只有当客户端可以决定资源唯一标识的时候才可以使用PUT添加资源。比如前面添加图书信息的例子中,如果ISBN就是图书信息的唯一标识(现实中往往并非如此,这里仅为了举例),那么客户端完全可以使用PUT方法来添加图书信息,从而获得幂等性带来的好处。现实中,服务端使用关系型数据库来存储图书信息时,往往会用自增主键做唯一标识,但是如果这个自增主键对客户端无意义的话,我们可以不对客户端暴露此属性,仅让客户端以ISBN作为资源唯一标识就好。

PUT与PATCH

PATCH方法在早期的HTTP1.1规范RFC2068出现过,但是在修订版RFC2616中却不见踪影,而是在RFC5789中单独做了定义。其中原委我就不太清楚了,如果谁知道还请多多赐教。

按照规范中的说明,PUT用于资源的整体更新,而PATCH用于局部更新,这个局部更新并不仅仅是局部替换,也包括了追加,所以PATCH既不安全也不幂等。

因为PATCH是在扩展规范中定义的,所以对它的支持并不是很广泛。也有一些人认为应该尽量避免使用PATCH。我在实际工作中也是使用PUT来实现局部更新的。那么这里我们来讨论以下两个问题:

  1. PUT是否可以用于局部更新。
  2. PATCH如何实现局部更新。

PUT是否可以用于局部更新

网上可以找到很多对于此问题的讨论,其中stack overflow的一篇问答帖“Why isn’t HTTP PUT allowed to do partial updates in a REST API?”,我觉得很不错,我们来看一看。

这篇问答帖中被标记为接受的答案直接请出了REST的作者Fielding博士,引用了他在twitter上回复别人的一段话

Who says? The guy who invented REST says:

@mnot Oy, yes, PATCH was something I created for the initial HTTP/1.1 proposal because partial PUT is never RESTful. ;-)

https://twitter.com/fielding/status/275471320685367296

引用的对话中,IETF HTTP和QUIC工作组的联合主席Mark说,有种说法很可笑,是说某些HTTP扩展规范并不RESTful,而这些扩展规范恰恰就是Fielding本人参与制定的,于是就跟Fielding有了一段对话,上面引用的就是Fielding最后的回复。这样看来REST作者本尊直接给PUT用于局部修改判了死刑。但仔细想想,会发现一个问题,Fielding这里说的RESTful指的是什么呢?还记得我在科普篇中提到Fielding发表的”REST must be hypertext”吗?Fielding这里说的RESTful应该是指REST成熟度模型的第3级,超媒体驱动的RESTful。而我们开发的RESTful api远远达不到这个水平。所以,你可以因为Fielding的观点而不使用PUT实现局部更新,完全没问题,但这个理由我觉得并不充分。

除了被接受的答案外,还有一个票数为-4的答案

Partial updates are allowed by PUT (according to RFC 7231 http://tools.ietf.org/html/rfc7231#section-4.3.4).
“,… PUT request is defined as replacing the state of the target resource.” - replacing part of object basically change state of it.
“Partial content updates are possible by targeting a separately identified resource with state that overlaps a portion of the larger resource, …”
According to that RFC next request is valid: PUT /resource/123 {name: ‘new name’} It will change only name for specified resource. Specifying id inside request payload would be incorrect (as PUT not allow partial updates for unspecified resources).

票数-4说明大家并不认可他的观点,因为他对RFC规范理解错了,那么我们就来看一下他引用的HTTP1.1规范中介绍PUT时一段关于局部修改的这段说明:

Partial content updates are possible by targeting a separately identified resource with state that overlaps a portion of the larger resource, or by using a different method that has been specifically defined for partial updates (for example, the PATCH method defined in RFC5789).

这里给出了两个实现局部更新的方法:

  1. 将一个较大的资源中的各部分,分别设计为单独的资源,使用PUT进行更新。这种方式实际上是把主体资源拆解成了更细粒度的资源,然后对细粒度资源做整体更新,以达到局部更新的效果,本质上并未改变PUT的语义。
  2. 使用一个专门为局部更新设计的方法来做更新,比如RFC5789定义的PATCH。

同时,在RFC5789中也有一段对PUT的描述:

The PUT method is already defined to overwrite a resource with a complete new body, and cannot be reused to do partial changes. Otherwise, proxies and caches, and even clients and servers, may get confused as to the result of the operation.

好了,既然HTTP1.1规范以及PATCH扩展规范给出明确的说明,那么看来用PUT来实现局部更新确实是不可取的。尽管如此,我们仍能找到很多赞成使用PUT实现局部更新的观点,比如文章“REST API Best Practices 3: Partial Updates - PATCH vs PUT”的观点:

REST purists insist that PATCH is the only “correct” way to perform partial updates, but it hasn’t reached “best-practice” status just yet, for a number of reasons.
Pragmatists, on the other hand, are concerned with building mobile back-ends and APIs that simply work and are easy to use, even if that means using PUT to perform partial updates.

综上,我来说说我自己的观点。正如前文所说HTTP相关规范已经明确指出PUT不适用于局部更新,那么这个观点我是赞成的。但是考虑到一个实际情况是,我们设计开发的api往往RESTful程度并不高,远远达不到Fielding定义的,或者HTTP规范中所说的那种程度,那么我觉得也就没必要过分追求与规范的严格匹配了,毕竟这种事在行业内太常见了。

不过,就算你不严格按规范设计,那也不能胡来,也要有一些约束。理解REST理念,理解规范要求以及他们所带来的好处,还是很有必要的,比如幂等性。在理解了这些的基础上,就算你结合自身实际决定用PUT来实现局部更新,你也会在设计时考虑很多约束,至少PUT的幂等性是不能破坏的。

如果你最终决定遵守规则,要用PATCH来实现局部更新,那你就要好好了解下PATCH的规范了,确保自己真的知道如何实现。

PATCH如何实现局部更新

我相信,那些使用PATCH来实现局部更新的接口,大都采用了一种类似于PUT的实现方式。客户端请求消息体中带有局部的资源表述,而服务端处理请求时,只会更新这个局部资源表述中涉及到属性。但严格来讲这里也会有一些问题。在RFC5789中,对PATCH的实现有这样的描述:

The difference between the PUT and PATCH requests is reflected in the way the server processes the enclosed entity to modify the resource identified by the Request-URI. In a PUT request, the enclosed entity is considered to be a modified version of the resource stored on the origin server, and the client is requesting that the stored version be replaced. With PATCH, however, the enclosed entity contains a set of instructions describing how a resource currently residing on the origin server should be modified to produce a new version.

它说PATCH的消息体应该包含一组指令,它描述了当前属于远程服务器的资源要如何被修改,以生成一个新的版本。也就是说PATCH请求的消息体中,并不是局部的资源表述,而应该是一组描述如何修改的指令。这样一来,我们直接提交一个局部资源表述的方式就显得不太符合规范了。而这里说的这组指令的媒体类型也跟表示资源的媒体类型应该有所不同。

规范说的比较抽象模糊,实践中RFC7396定义了一种实现方式”JSON Merge Patch”。这个规范基于Json定义了一种新的媒体类型application/merge-patch+json来表示修改指令,而表述的形式则与我们常用方式相似。虽然都是Json格式,但是如果媒体类型只是application/json,严格来讲是不符合规范的。因为单纯的Json格式并没有表示局部修改的语义,我们实现时完全是服务端定制化的逻辑,这显然是非RESTful的。所以这份规范中定义了一种能够表示修改指令的媒体类型,用于支持PATCH更新。这也是REST中统一接口风格的一种体现。

总的来说RFC7396是一个既能满足规范,又很符合我们实际期望的,浅显易懂的实现方式。在它发布之前,学术界建议的实现方式是RFC6902。这个规范就要更加复杂一些了,不过却可以支持某些特殊的操作,比如move,而这类操作RFC7396就有心无力了。

今天就跟大家聊到这里了,欢迎大家扫描下方二维码,关注我的公众号“就浩这口”,收听这篇文章的语音版解说,并持续关注后续内容。下一期我将继续为大家介绍接口设计的后面两点内容,消息体格式及异常处理,我们下期再见。

传送门

陈浩 wechat
欢迎扫描上面二维码,关注我的公众号“就浩这口”
感谢您的支持,我会创作更多更好的内容