返回
首页

RESTful cheatsheet

REST,即Representational State Transfer的缩写——"表现层状态转化"。如果一个架构符合REST原则,就称它为RESTful架构。

URL设计

动词 + 宾语

RESTful的核心思想就是,客户端发出的数据操作指令都是"动词 + 宾语"的结构。比如,GET /articles这个命令,GET是动词,/articles是宾语。

动词操作
GET读取(Read)
POST新建(Create)
PUT更新(Update)
PATCH更新(Update),通常是部分更新
DELETE删除(Delete)

动词的覆盖

有些客户端只能使用GETPOST这两种方法,服务器必须接受POST模拟其他三个方法(PUTPATCHDELETE)。

通过X-HTTP-Method-Override属性,告诉服务器覆盖的是哪种请求。

POST /api/Person/4 HTTP/1.1  
X-HTTP-Method-Override: PUT

宾语必须是名词

宾语显然是名称,它是API的URL,是HTTP动词作用的对象。比如,/articles这个URL就是正确的,而下面的URL是错误的:

  • /getAllCars
  • /createNewCar
  • /deleteAllRedCars

复数 URL

既然URL是名词,那么应该使用复数,还是单数?

这没有统一的规定,但是常见的操作是读取一个集合,比如GET /articles(读取所有文章),这里明显应该是复数。

为了统一起见,建议都使用复数URL,比如GET /articles/2要好于GET /article/2

避免多级 URL

常见的情况是,资源需要多级分类,因此很容易写出多级的 URL,比如获取某个作者的某一类文章。

GET /articles/authors/12/categories/2

这种 URL 不利于扩展,语义也不明确。

更好的做法是,除了第一级,其他级别都用查询字符串表达。

GET /articles?authors=12&categories=2

下面是另一个例子,查询已发布的文章:

GET /articles/published

查询字符串的写法明显更好。

GET /articles?published=true

状态码

状态码必须精确

客户端的每一次请求,服务器都必须给出回应。回应包括 HTTP状态码数据两部分。

HTTP状态码就是一个三位数,分成五个类别。

codedesc
1xx相关信息
2xx操作成功
3xx重定向
4xx客户端错误
5xx服务器错误

API不需要1xx状态码,下面介绍其他四类状态码的精确含义。

2xx 状态码

200状态码表示操作成功,但是不同的方法可以返回更精确的状态码。

HTTP方法响应描述
GET200 OK成功
POST201 Created生成了新的资源
PUT200 OK成功
PATCH200 OK成功
DELETE204 No Content资源已经不存在

此外,202 Accepted表示已经收到请求,会在未来处理,通常用于异步操作

HTTP/1.1 202 Accepted

{
  "task": {
    "href": "/api/company/job-management/jobs/2130040",
    "id": "2130040"
  }
}

3xx 状态码

应用级别,浏览器会直接跳转

codedesc
301永久重定向
302暂时重定向
307暂时重定向

API级别

codedesc
303See Other,示参考另一个 URL

303302307的含义一样,也是暂时重定向。区别在于302307用于GET请求,而303用于POSTPUTDELETE请求。收到303以后,浏览器不会自动跳转,而会让用户自己决定下一步怎么办:

HTTP/1.1 303 See Other
Location: /api/orders/12345

4xx 状态码

4xx状态码表示客户端错误,主要有下面几种。

codedesc
400 Bad Request无法解析,一般是参数错误
401 Unauthorized没有通过身份验证
403 Forbidden通过了身份验证,但没有相关权限
404 Not Found请求的资源不存在,或不可用
405 Method Not Allowed所用的HTTP方法不允许
410 Gone资源已从这个地址转移,不再可用
415 Unsupported Media Type客户端要求的返回格式不支持
422 Unprocessable Entity客户端上传的附件无法处理
429 Too Many Requests客户端的请求次数超过限额

5xx 状态码

5xx表示服务端错误。一般来说,API 不会向用户透露服务器的详细信息。

codedesc
500 Internal Server Error客户端请求有效,服务器发生了错误
503 Service Unavailable服务器无法处理请求,多用于维护状态

服务器响应

响应数据格式

API 返回的数据格式,应该是一个JSON对象,所以:

  • 服务器响应时HTTP头Content-Type属性设为application/json
  • 客户端请求时请求时HTTP头ACCEPT属性设成application/json

错误时,不返回200状态码

有一种不恰当的做法是,即使发生错误,也返回200状态码,把错误信息放在数据体里面:

HTTP/1.1 200 OK
Content-Type: application/json

{
  "status": "failure",
  "data": {
    "error": "Expected at least two items in list."
  }
}

上面代码中,解析数据体以后,才能得知操作失败。

这张做法实际上取消了状态码,这是完全不可取的。正确的做法是,状态码反映发生的错误,具体的错误信息放在数据体里面返回。下面是一个例子。

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "error": "Invalid payoad.",
  "detail": {
     "surname": "This field is required."
  }
}

提供链接

API 的使用者未必知道,URL 是怎么设计的。一个解决方法就是,在回应中,给出相关链接,便于下一步操作。这样的话,用户只要记住一个 URL,就可以发现其他的 URL。这种方法叫做HATEOAS

举例来说,GitHub 的 API 都在 api.github.com 这个域名。访问它,就可以得到其他 URL。

{
  ...
  "feeds_url": "https://api.github.com/feeds",
  "followers_url": "https://api.github.com/user/followers",
  "following_url": "https://api.github.com/user/following{/target}",
  "gists_url": "https://api.github.com/gists{/gist_id}",
  "hub_url": "https://api.github.com/hub",
  ...
}

上面的响应中,挑一个 URL 访问,又可以得到别的 URL。对于用户来说,不需要记住 URL 设计,只要从 api.github.com 一步步查找就可以了。

HATEOAS 的格式没有统一规定,上面例子中,GitHub 将它们与其他属性放在一起。更好的做法应该是,将相关链接与其他属性分开。

HTTP/1.1 200 OK
Content-Type: application/json

{
  "status": "In progress",
   "links": {[
    { "rel":"cancel", "method": "delete", "href":"/api/status/12345" } ,
    { "rel":"edit", "method": "put", "href":"/api/status/12345" }
  ]}
}

RESTful 成熟度

Richardson 成熟度模型

RESTful Web APIsRESTful Web Services的作者伦纳德 · 理查德森(Leonard Richardson),曾提出过一个衡量服务有多么 RESTRichardson 成熟度模型Richardson Maturity Model,RMM)。这个模型的一个用处是,方便那些原本不使用 REST 的服务,能够逐步导入 REST。

Richardson 将服务接口按照“REST 的程度”,从低到高分为 0 至 3 共 4 级:

  • The Swamp of Plain Old XML:完全不 REST。另外,关于 POX 这个说法,SOAP 表示感觉有被冒犯到。
  • Resources:开始引入资源的概念。
  • HTTP Verbs:引入统一接口,映射到 HTTP 协议的方法上。
  • Hypermedia Controls:在咱们课程里面的说法是“超文本驱动”,在 Fielding 论文里的说法是 Hypertext as the Engine of Application State(HATEOAS),都说的是同一件事情。

接下来,我们通过马丁 · 福勒(Martin Fowler)的关于 RMM 的文章中的实际例子(原文是 XML 写的,我简化了一下),来看看四种不同程度的 REST 反应到实际 API 是怎样的。

假设,你是一名软件工程师,接到需求(也被我尽量简化了)的用户故事是这样的:现在要开发一个医生预约系统,病人通过这个系统,可以知道自己熟悉的医生在指定日期是否有空闲时间,以方便预约就诊。

第 0 级成熟度:The Swamp of Plain Old XML

医院开放了一个 /appointmentService 的 Web API,传入日期、医生姓名作为参数,就可以得到该时间段、该医生的空闲时间。

POST /appointmentService?action=query HTTP/1.1
{date: "2020-03-04", doctor: "mjones"}

在接收到请求之后,服务器会传回一个包含所需信息的结果:

HTTP/1.1 200 OK
[
    {start:"14:00", end: "14:50", doctor: "mjones"},
    {start:"16:00", end: "16:50", doctor: "mjones"}
]

得到了医生空闲的结果后,我觉得 14:00 的时间比较合适,于是预约确认,并提交了我的基本信息:

POST /appointmentService?action=comfirm HTTP/1.1
{
    appointment: {date: "2020-03-04", start:"14:00", doctor: "mjones"},
    patient: {name: xx, age: 30, ……}
}

如果预约成功,那我能够收到一个预约成功的响应:

HTTP/1.1 200 OK
{
    code: 0,
    message: "Successful confirmation of appointment"
}

如果发生了问题,比如有人在我前面抢先预约了,那么我会在响应中收到某种错误信息:

HTTP/1.1 200 OK
{
    code: 1
    message: "doctor not available"
}

到此,整个预约服务就完成了,可以说是直接明了。在这个方案里,我们采用的是非常直观的基于 RPC 风格的服务设计,看似是很轻松地解决了所有问题,但真的是这样吗?

第 1 级成熟度:Resources

实际上你可以发现,第 0 级是 RPC 的风格,所以如果需求永远不会变化,也不会增加,那它完全可以良好地工作下去。但是,如果你不想为预约医生之外的其他操作、为获取空闲时间之外的其他信息去编写额外的方法,或者改动现有方法的接口,那就应该考虑一下如何使用 REST 来抽象资源。

通往 REST 的第一步是引入资源的概念,在 API 中最基本的体现,就是它会围绕着资源而不是过程来设计服务。说得直白一点,你可以理解为服务的 Endpoint 应该是一个名词而不是动词。此外,每次请求中都应包含资源 ID,所有操作均通过资源 ID 来进行。

POST /doctors/mjones HTTP/1.1
{date: "2020-03-04"}

然后,服务器传回一个包含了 ID 的信息。注意,ID 是资源的唯一编号,有 ID 即代表“医生的档期”被视为一种资源:

HTTP/1.1 200 OK
[
    {id: 1234, start:"14:00", end: "14:50", doctor: "mjones"},
    {id: 5678, start:"16:00", end: "16:50", doctor: "mjones"}
]

我还是觉得 14:00 的时间比较合适,于是又预约确认,并提交了我的基本信息:

POST /schedules/1234 HTTP/1.1
{name: xx, age: 30, ……}

后面预约成功或者失败的响应消息在这个级别里面与之前一致,就不重复了。

比起第 0 级,第 1 级的服务抽象程度有所提高,但至少还有三个问题并没有解决:

  • 只处理了查询和预约,如果我要调整或取消预约,这都需要提供新的服务接口。
  • 处理结果响应时,只能靠着结果中的 code、message 这些字段做分支判断,多个系统要设计多套。
  • 并没有考虑认证授权等安全方面的内容。

这三个问题,其实都可以通过引入统一接口(Uniform Interface)来解决。接下来,我们就来到了第 2 级。

第 2 级成熟度:HTTP Verbs

前面说到,第 1 级中遗留的这三个问题,都可以靠引入统一接口来解决,而 HTTP 协议的标准方法便是最常接触到的统一接口。

HTTP 协议的标准方法是经过精心设计的,它几乎涵盖了资源可能遇到的所有操作场景(这其实更取决于架构师的抽象能力)。

那么,REST 的做法是:

  • 针对预约变更的问题,把不同业务需求抽象为对资源的增加、修改、删除等操作来解决;
  • 针对响应代码的问题,使用 HTTP 协议的 Status Code,可以涵盖大多数资源操作可能出现的异常(而且也是可以自定义扩展的);
  • 针对安全性的问题,依靠 HTTP Header 中携带的额外认证、授权信息来解决

按这个思路,我们在获取医生档期时,应该使用具有查询语义的 GET 操作来完成:

GET /doctors/mjones/schedule?date=2020-03-04&status=open HTTP/1.1

然后,服务器会传回一个包含了所需信息的结果:

HTTP/1.1 200 OK
[
    {id: 1234, start:"14:00", end: "14:50", doctor: "mjones"},
    {id: 5678, start:"16:00", end: "16:50", doctor: "mjones"}
]

我还是觉得 14:00 的时间比较合适,于是就预约确认,并提交了我的基本信息用来创建预约。这是符合 POST 的语义的:

POST /schedules/1234 HTTP/1.1
{name: xx, age: 30, ……}

如果预约成功,那我能够收到一个预约成功的响应:

HTTP/1.1 201 Created
Successful confirmation of appointment

否则,我会在响应中收到某种错误信息:

HTTP/1.1 409 Conflict
doctor not available

目前绝大多数的系统能够达到的 REST 级别,也就是第 2 级了。不过这种方案还不够完美,最主要的一个问题是:我们如何知道预约 mjones 医生的档期,需要访问/schedules/1234这个服务 Endpoint?

第 3 级成熟度:Hypermedia Controls

或许你第一眼看到这个问题会说,这当然是程序写的啊,我为什么会问这么奇怪的问题。但问题是,REST 并不认同这种已烙在程序员脑海中许久的想法。

RMM 中的第 3 级成熟度 Hypermedia Controls、Fielding 论文中的 HATEOAS 和现在提得比较多的超文本驱动,其实都是希望能达到这样一种效果:除了第一个请求是由你在浏览器地址栏输入的信息所驱动的之外,其他的请求都应该能够自己描述清楚后续可能发生的状态转移,由超文本自身来驱动。

所以,当你输入了查询命令后:

GET /doctors/mjones/schedule?date=2020-03-04&statu s=open HTTP/1.1

服务器传回的响应信息应该包括如何预约档期、如何了解医生信息等可能的后续操作:

HTTP/1.1 200 OK
{
    schedules:[
        {
            id: 1234, start:"14:00", end: "14:50", doctor: "mjones",
            links: [
                {rel: "comfirm schedule", href: "/schedules/1234"}
            ]
        },
        {
            id: 5678, start:"16:00", end: "16:50", doctor: "mjones",
            links: [
                {rel: "comfirm schedule", href: "/schedules/5678"}
            ]
        }
   ],
   links: [
       {rel: "doctor info", href: "/doctors/mjones/info"}
   ]
}

如果做到了第 3 级 REST,那么服务端的 API 和客户端就可以做到完全解耦了。这样一来,你再想要调整服务数量,或者同一个服务做 API 升级,将会变得非常简单。

REST 的不足与争议

争议一 面向资源只适合做 CRUD

面向资源的编程思想只适合做 CRUD,只有面向过程、面向对象编程才能处理真正复杂的业务逻辑。原因也很简单,HTTP 的 4 个最基础的命令 POST、GET、PUT 和 DELETE,很容易让人联想到 CRUD 操作。

REST 涵盖的范围当然远不止于此。不过要说 POST、GET、PUT 和 DELETE 对应于 CRUD,其实也没什么不对,只是我们必须泛化地去理解这个 CRUD:它们涵盖了信息在客户端与服务端之间流动的几种主要方式(比如 POST、GET、PUT 等标准方法),所有基于网络的操作逻辑,都可以通过解决“信息在服务端与客户端之间如何流动”这个问题来理解,有的场景里比较直观,而另一些场景中可能比较抽象。

针对那些比较抽象的场景,如果确实不好把 HTTP 方法映射为资源的所需操作,REST 也并不会刻板地要求一定要做映射。这时,用户可以使用自定义方法,按 Google 推荐的 REST API 风格来拓展 HTTP 标准方法。

自定义方法应该放在资源路径末尾,嵌入冒号加自定义动词的后缀。比如,我将删除操作映射到标准 DELETE 方法上,此外还要提供一个恢复删除的 API,那它可能会被设计为:

POST /user/user_id/cart/book_id:undelete

要实现恢复删除,一个完全可行的设计是:设计一个回收站的资源,在那里保留还能被恢复的商品,我们把恢复删除看作是对这个资源的某个状态值的修改,映射到 PUT 或者 PATCH 方法上。

最后,我要再重复一遍,面向资源的编程思想与另外两种主流编程(面向过程面向对象编程)思想,只是抽象问题时所处的立场不同,只有选择问题,没有高下之分:

  • 面向过程编程时,为什么要以算法和处理过程为中心,输入数据,输出结果?当然是为了符合计算机世界中主流的交互方式。
  • 面向对象编程时,为什么要将数据和行为统一起来、封装成对象?当然是为了符合现实世界的主流交互方式。
  • 面向资源编程时,为什么要将数据(资源)作为抽象的主体,把行为看作是统一的接口?当然是为了符合网络世界的主流的交互方式。

争议二 REST 与 HTTP 完全绑定,不适用于要求高性能传输

其实,我在很大程度上赞同这个观点,但我并不认为这是 REST 的缺陷,因为锤子不能当扳手用,并不是锤子的质量有问题。

面向资源编程与协议无关,但是 REST(特指 Fielding 论文中所定义的 REST,而不是泛指面向资源的思想)的确依赖着 HTTP 协议的标准方法、状态码和协议头等各个方面。

我们也知道,HTTP 是应用层协议,而不是传输层协议,如果我们只是把 HTTP 用作传输是不恰当的(SOAP:再次感觉有被冒犯到)。因此,对于需要直接控制传输(如二进制细节 / 编码形式 / 报文格式 / 连接方式等)细节的场景,REST 确实不合适。这些场景往往存在于服务集群的内部节点之间,虽然 REST 和 RPC 的应用场景的确有所重合,但重合的范围有多大就是见仁见智的事情了。

争议三 REST 不利于事务支持

其实,这个问题首先要看我们怎么去理解事务(Transaction)这个概念了。

  • 如果“事务”指的是数据库那种狭义的刚性 ACID 事务,那分布式系统本身跟它之间就是有矛盾的(CAP 不可兼得)。这是分布式的问题,而不是 REST 的问题。
  • 如果“事务”是指通过服务协议或架构,在分布式服务中,获得对多个数据同时提交的统一协调能力(2PC/3PC),比如WS-AtomicTransaction和WS-Coordination这样的功能性协议,那 REST 确实不支持。假如你已经理解了这样做的代价,仍决定要这样做的话,Web Service 是比较好的选择。
  • 如果“事务”是指希望保证数据的最终一致性,说明你已经放弃刚性事务了。这才是分布式系统中的主流,使用 REST 肯定不会有什么阻碍,更谈不上“不利于”事务支持(当然,对于最终一致性的问题,REST 本身并没有提供什么帮助,而是完全取决于你系统的事务设计)。

争议四 REST 没有传输可靠性支持

是的,REST 并没有提供对传输可靠性的支持。在 HTTP 中,你发送出去一个请求,通常会收到一个与之相对的响应,比如 HTTP/1.1 200 OK 或者 HTTP/1.1 404 Not Found 等。但是,如果你没有收到任何响应,那就无法确定消息到底是没有发送出去,还是没有从服务端返回回来。这其中的关键差别,是服务端到底是否被触发了某些处理?

应对传输可靠性最简单粗暴的做法,就是把消息再重发一遍。这种简单处理能够成立的前提,是服务具有幂等性(Idempotency),也就是说服务被重复执行多次的效果与执行一次是相等的。

HTTP 协议要求 GET、PUT 和 DELETE 操作应该具有幂等性,我们把 REST 服务映射到这些方法时,也应该保证幂等性。

对于 POST 方法,曾经有过一些专门的提案(比如POE、POST Once Exactly),但并未得到 IETF 的通过。对于 POST 的重复提交,浏览器会出现相应警告,比如 Chrome 中会有“确认重新提交表单”的提示。而服务端就应该做预校验,如果发现可能重复,就返回 HTTP/1.1 425 Too Early。

另外,Web Service 中有WS-ReliableMessaging功能协议,用来支持消息可靠投递。类似的,REST 因为没有采用额外的 Wire Protocol,所以除了缺少对事务、可靠传输的支持外,一定还可以在 WS-* 协议中找到很多 REST 不支持的特性。

争议五 REST 缺乏对资源进行“部分”和“批量”的处理能力

这个观点我是认同的,而且我认为这很可能是未来面向资源的思想和 API 设计风格的发展方向。

REST 开创了面向资源的服务风格,却肯定不完美。以 HTTP 协议为基础,虽然给 REST 带来了极大的便捷(不需要额外协议,不需要重复解决一堆基础网络问题,等等),但也成了束缚 REST 的无形牢笼。

关于 HTTP 协议对 REST 的束缚,我会通过具体的例子和你解释。

第一种束缚,就是缺少对资源的“部分”操作的支持。

有些时候,我们只是想获得某个用户的姓名,RPC 风格中可以设计一个getUsernameById的服务,返回一个字符串。尽管这种服务的通用性实在称不上“设计”二字,但确实可以工作。而要是采用 REST 风格的话,你需要向服务端请求整个用户对象,然后丢弃掉返回结果中的其他属性,这就是一种请求冗余(Overfetching)。

REST 的应对手段是,通过位于中间节点或客户端缓存来缓解。但这治标不治本,因为这个问题的根源在于,HTTP 协议对请求资源完全没有结构化的描述能力(但有的是非结构化的部分内容获取能力,也就是今天多用于端点续传的Range Header),所以返回资源的哪些内容、以什么数据类型返回等等,都不可能得到协议层面的支持。如果要实现这种能力,你就只能自己在 GET 方法的 Endpoint 上设计各种参数。

而与此相对的缺陷,也是 HTTP 协议对 REST 的第二种束缚,是对资源的“批量”操作的支持。

有时候,我们不得不为此而专门设计一些抽象的资源才能应对。

比如,我们要把某个用户的昵称增加一个“VIP”前缀,那提交一个 PUT 请求修改这个用户的昵称就可以了。但如果我们要给 1000 个用户的昵称加“VIP”前缀时,就不得不先创建一个(比如名为“VIP-Modify-Task”)任务资源,把 1000 个用户的 ID 交给这个任务,最后驱动任务进入执行状态(如果真去调用 1000 次 PUT,等浏览器回应我们 HTTP/1.1 429 Too Many Requests 的时候,老板就要发飙了)。

又比如,我们在网店买东西的时候,下单、冻结库存、支付、加积分、扣减库存这一系列步骤会涉及多个资源的变化,这时候我们就得创建一种“事务”的抽象资源,或者用某种具体的资源(比如“结算单”),贯穿网购这个过程的始终,每次操作其他资源时都带着事务或者结算单的 ID。对于 HTTP 协议来说,由于它的无状态性,相对来说不适用于(并非不能够)处理这类业务场景。

要解决批量操作这类问题,目前一种从理论上看还比较优秀的解决方案是GraphQL(但实际使用人数并不多)。GraphQL 是由 Facebook 提出并开源的一种面向资源 API 的数据查询语言。它和 SQL 一样,挂了个“查询语言”的名字,但其实 CRUD 都能做。

相对于依赖 HTTP 无协议的 REST 来说,GraphQL 是另一种“有协议”地、更彻底地面向资源的服务方式。但是凡事都有两面,离开了 HTTP,GraphQL 又面临着几乎所有 RPC 框架都会遇到的如何推广交互接口的问题。