REST服务设计风格

REST无论是思想上、概念上、还是应用目标上,它与各种RPC协议只能算是有所关联,有一些重合,但本质上并不是同一类型的东西。所谓思想上的不同,是指面向过程的编程思想与面向资源的编程思想之间的差异,至于什么是面向资源编程,后文中我们再详谈。

而概念上的不同主要是指REST并不是一种远程服务调用协议,甚至可以把定语也去掉,它就不是一种协议。协议都带有一定的规范性和强制性,最起码也该有个规约文档吧,譬如JSON-RPC,它再简单,也要有个《JSON-RPC Specification》来规定它的格式细节、异常、响应码等信息,但REST并没有这些东西,尽管有一些指导原则,实际上并不受任何强制约束。常有人批评某个系统接口“设计得不够RESTful”,其实这句话本身就有争议,RESTful只是风格而不是规范、协议,而且能完全达到REST所有指导原则的系统也是很少见的,这一点我们同样将在稍后详细讨论。

至于应用目标,REST与RPC在范围上是确有一些重合的,不过重合的区域有多大却是见仁见智。上一节提到了当前的RPC协议框架都各有侧重点,并且列举了RPC一些发展方向,这里面分布式对象这一条线的应用与REST可以说是毫无关系;而能够重视”效率“这个方向的应用,基本上就限制了只能是后端服务(前端应用对于网络协议、序列化器这两点都没有选择的余地,想要高效率也有心无力),在分布式服务各个后端节点之间通讯这一块,REST虽然照样可以用于任何语言(只要有个HTTP Client就可以用)之间的调用,而且是微服务中推荐的通讯方式,但在需要追求效率的后端应用场景里,REST提升传输效率的潜力非常有限,为性能而选择REST真算不得是个好决定。我们开发的REST服务,大多数的是提供给前端或效率不处于主要关注点的部分后端场景去消费的。在前端这一块,一众RPC里最多也就是JSON-RPC有机会与REST产生竞争,其他所有RPC协议、框架,哪怕是能够支持HTTP协议,哪怕提供了JavaScript版本的客户端(如gRPC-Web),也只是存在前端使用的理论可行性,很少见有实际项目把它们真应用到浏览器上的。

但尽管有如此多的不同,这两者还是产生了很多的比较与争论,就如同当年面向对象与面向过程一样,非得争出个高低不可。网上许多REST vs RPC的口水仗中说REST不好的,通常也并不是支持哪个RPC框架/协议比它好用,大多都只是不赞成REST的设计风格,心中说的本意其实是“面向资源编程”的思想不好,不如“面向过程编程”来得好用好理解。

理解REST

个人会有好恶偏爱,但计算机科学是务实的,有了RPC,还会提出REST,有了面向过程编程之后,还能产生面向资源编程,并引起广泛的关注、使用和讨论,后者一定是有一些前者没有的闪光点,或者解决、避免了一些面向过程中的缺陷。我们不妨先去理解REST为什么出现、解决什么问题、方法是什么,然后再来讨论评价它。

许多人都知道REST源于Roy Thomas Fielding在2000年发表的博士论文:《Architectural Styles and the Design of Network-based Software Architectures》,此文的确是REST的源头,但我们不能忽略Fielding的身份和之前工作的背景,这对理解REST的设计思想至关重要。

首先,Fielding是一名很优秀的软件工程师,他是Apache服务器的核心开发者,后来成为了著名的Apache软件基金会的联合创始人;同时,Fielding也是HTTP 1.0协议(1996年发布)的专家组成员,后来还成为了HTTP 1.1协议(1999年发布)的负责人。HTTP 1.1协议设计的极为成功,以至于发布之后长达十年的时间里,都没有多少人认为有修订的必要。用来指导HTTP 1.1协议设计的理论和思想,最初是以备忘录的形式在专家组成员之间交流,除了IETF、W3C的专家外,并没有在外界广泛流传。

Roy Thomas Fielding

从时间上看,对HTTP 1.1协议的设计工作贯穿了Fielding的整个博士研究生涯,当起草HTTP 1.1协议的工作完成后,Fielding回到了加州大学欧文分校继续攻读自己的博士学位。第二年,他更为系统、严谨地阐述了这套理论框架,并且以这套理论框架导出了一种新的编程风格,他为这种风格取了一个很多人难以理解,但是今天已经广为人知的名字REST(Representational State Transfer),即“表征状态转移”的缩写。

哪怕对编程和网络都很熟悉的同学,只从标题中也不太可能直接弄明白什么叫“表征”、啥东西的“状态”、从哪“转移”到哪。尽管在论文原文中确有论述这些概念,但写得确实相当晦涩(不想读英文的同学从此处获得中文翻译版本),笔者推荐一种比较容易理解REST思想方式是先理解什么是HTTP,再配合一些实际例子来进行类比,你会发现“REST”实际上是“HTT”(Hyper Text Transfer)的进一步抽象,两者就如同接口与实现类之间的关系一般。

HTTP中使用的“超文本”一词是美国社会学家Theodor Holm Nelson在1967年于《Brief Words on the Hypertext》一文里提出的,下面引用的是他本人在1992年修正后的定义:

Hypertext

By now the word "hypertext" has become generally accepted for branching and responding text, but the corresponding word "hypermedia", meaning complexes of branching and responding graphics, movies and sound – as well as text – is much less used. Instead they use the strange term "interactive multimedia": this is four syllables longer, and does not express the idea of extending hypertext.

—— Theodor Holm Nelson Literary Machines, 1992

以上定义描述的“超文本(或超媒体)”是一种“能够对操作进行判断和响应的文本(或声音、图像等)”,这个概念在上世纪60年代提出时应该还属于科幻的范畴,但是今天大众已经完全接受了它,互联网中一段文字可以点击、可以触发脚本执行、可以调用服务端,这一切已稀松平常,毫不稀奇。那我们继续尝试从“超文本”或者“超媒体”的含义来理解什么是“表征”以及REST中其他关键概念,笔者使用一个具体事例来将其描述如下:

  • 资源(Resource):譬如你现在正在阅读一篇名为《REST服务设计风格》的文章,这篇文章中的内容本身(你将其视作是某种信息、数据)我们称之为“资源”。无论你是在网上看的网页、是打印出来看的文字稿、是在电脑屏幕上阅读抑或是手机上浏览,尽管呈现的样子各不相同,但其中的信息是不变的,你所阅读的仍是同一个“资源”。

  • 表征(Representation):当你通过电脑浏览器阅读此文章时,浏览器向服务端发出请求“我需要这个资源的HTML格式”,服务端向浏览器返回的这个HTML就被称之为“表征”,你可能通过其他方式拿到本文的PDF、Markdown、RSS等其他形式的版本,它们也同样是一个资源的多种表征。可见“表征”这个概念是指信息与用户交互时的表示形式,这与我们应用分层中常说的“表示层”(Presentation Layer)的语义其实是一致的。

  • 状态(State):当你把这篇文章阅读完毕,想看下一篇文章是什么内容的时候,你向服务器请求“给我下一篇文章”,但是“下一篇”是个相对概念,必须依赖“当前你正在阅读的文章是哪一篇”才能正确回应,这类在特定语境中才能产生的上下文信息即被称为“状态”。我们所说的有状态(Stateful)还是无状态(Stateless),都是只相对于服务端来说的,服务器要完成“取下一篇”的请求,要么自己记住用户的状态(这个用户现在阅读的是哪一篇文章,这是有状态),要么客户端来记住状态,在请求的时候明确告诉服务器(我正在阅读某某文章,现在要读下一篇,这是无状态)。

  • 转移(Transfer):无论状态是由服务端还是客户端来提供的,“取下一篇文章”这个行为逻辑必然只能由服务端来提供。服务器通过某种方式,把“用户当前阅读的文章”转变成“下一篇文章”,这就被称为“表征状态转移

借着这个故事的上下文,笔者顺便再介绍几个现在不涉及但稍后要用到的概念名词:

  • 统一接口(Uniform Interface):上面说的“服务器通过某种方式”具体是什么方式?请把本文拉到结尾处,右下角有下一篇文章的URI超链接地址,这是服务端渲染这篇文章时就预置好的,点击它让页面跳转到下一篇,就是一种所谓的“某种方式”。但URI的含义是统一资源标识符,如何能表达出“转移”的含义呢?HTTP协议中提前约定好了一套“统一接口”,包括:GET、HEAD、POST、PUT、DELETE、TRACE、OPTIONS七种操作,任何一个支持HTTP协议的服务器都会遵守这套规定,对特定的URI采取这些操作,服务器自然就会触发相应的表征状态转移。
  • 超文本驱动(Hypertext Driven):尽管表征状态转移是由浏览器主动向服务器发出请求,该请求导致了“在我们浏览器的屏幕上显示出了下一篇文章的内容”这个结果的出现,但浏览器其实根本不知道系统中这套转移逻辑。它根据是用户输入的URI地址请求网站首页,服务器给予的首页超文本内容,我们是通过内部的超链接导航到了这篇文章,阅读结束时再导航到下一篇。浏览器作为所有网站的通用的客户端,任何网站的导航(状态转移)行为都是不可能预置于浏览器之中,而是由服务器每一个请求中的返回信息(超文本)来驱动的。这点大家习以为常,但其实与其他带有客户端的软件有很本质的区别,在那些软件中,业务逻辑往往是预置于客户端之中的,有专门的页面控制器(无论在服务端还是在客户端中)来驱动页面的状态转移。
  • 自描述消息(Self-Descriptive Messages):由于资源的表征可能存在多种不同形态,在消息中应当有明确的信息来告知客户端该消息的类型以及该如何处理这条消息。一种被广泛采用的自描述方法是在名为“Content-Type”的HTTP Header中标识出互联网媒体类型(MIME type),譬如“Content-Type : application/json; charset=utf-8”,则说明该资源会以JSON的格式来返回,请使用UTF-8字符集进行处理。

RESTful的系统

当你理解了上面这些概念之后,我们就可以开始讨论面向资源的编程思想与REST所提出的几个具体的软件架构设计原则了。请注意,Fielding提出REST时所谈论的范围是“架构风格与网络的软件架构设计”( Architectural Styles and Design of Network-based Software Architectures),而不是现在被人们所狭义理解的一种“服务(API)设计风格”,这两者的范围差别就好比本站全站所谈论的话题“现代软件架构探索”与本篇文章谈论的“服务设计风格”一般,前者是后者的一个很大的超集,尽管基于本文的主题和多数人的关注点考虑,后面还是会着重以“服务设计风格”的视角来讨论,但事前我们至少应该知道它们范围上的差别。

Fielding认为,一套理想的、完全满足REST的系统应该满足以下六个原则:

  1. 服务端与客户端分离(Client-Server)
    将用户界面所关注的逻辑和数据存储所关注的逻辑分离开来有助于提高用户界面的跨平台的可移植性,这一点正越来越受到广大开发者所认可,以前完全基于服务端控制和渲染(如JSF这类)框架实际用户已甚少,而在服务端进行界面控制(Controller),通过服务端或者客户端的模版渲染引擎来进行界面渲染的框架(如Struts、SpringMVC这类)也受到了颇大的冲击。这一点主要推动力量与REST可能关系并不大,前端技术(从ES规范,到语言实现,到前端框架等)的近年来的高速发展,使得前端表达能力大幅度加强才是真正的幕后推手。由于前端的日渐强势,现在还流行起由前端代码反过来驱动服务端进行渲染的SSR(Server-Side Rendering)技术,在Serverless、SEO等场景中已经占领了一块领地。
  2. 无状态(Stateless)
    这是REST的一条关键原则,部分开发者在做服务接口规划时,觉得RESTful风格的API怎么设计都别扭,很有可能的一种原因是在服务端持有着比较重的状态。REST希望服务器能不负责维护状态,每一次从客户端发送的请求中,应包括所有的必要的上下文信息,会话信息也由客户端保存维护,服务器端依据客户端传递的状态信息来进行业务处理,并且驱动整个应用的状态变迁。至于客户端承担状态维护职责后的认证、授权等各方面的可信问题,都会有针对性的解决方案(这部分可参见安全架构中的介绍)
    但必须承认的现状是,目前大多数的系统是达不到这个要求的,越复杂、越大型的系统越是如此。服务端无状态可以在分布式环境中获得非常高价值的好处,但大型系统的上下文状态数量完全可能膨胀到让客户端在每次请求时提供变得不切实际的程度,在服务端的内存、会话、数据库或者集中式缓存等地方持有一定的状态成为一种是事实上仍然被广泛使用的主流的方案。
  3. 可缓存(Cacheability)
    无状态服务虽然提升了系统的可见性、可靠性和可伸缩性,但降低了系统的网络性。这句话通俗的解释就是,某个功能使用有状态的架构只需要一次请求就能完成,而无状态的服务则可能会需要多个请求,或者在请求中带有冗余的信息。为了缓解这个矛盾,REST希望软件系统能够如同万维网一样,客户端和中间的通讯传递者(代理)可以将部分服务端的应答缓存起来。当然,应答中必须明确地或者间接地表明本身是否可以进行缓存,以避免客户端在将来进行请求的时候得到过时的数据。运作良好的缓存机制可以减少客户端、服务器之间的交互,甚至有些场景中可以完全避免交互,这就进一步提了高性能。
  4. 分层系统(Layered System)
    这里所指的并不是表示层、服务层、持久层这种意义上的应用分层。而是指客户端一般不需要知道是否直接连接到了最终的服务器,抑或是路径上的中间服务器。中间服务器可以通过负载均衡和共享缓存的机制提高系统的可扩展性,这样可也便于缓存、伸缩和安全策略的部署。譬如,一种典型的应用是内容分发网络(CDN),如你现在访问这个站点,你所发出的请求一般(假设你在中国国境内的话)并不是直接访问位于GitHub Pages的源服务器,而是访问了位于腾讯云的CDN,但你并不需要感知到这一点。我们将在“透明多级分流系统”中讨论如何构建可缓存的分层系统。
  5. 统一接口(Uniform Interface)
    这是REST的另一条关键原则,REST希望开发者面向资源编程,希望设计软件系统的核心放在抽象系统该有哪些资源,而不是抽象系统该有哪些行为(服务)。对资源的操作是可数的、固定的、统一的,由于REST并没有设计新的协议,所以这些操作都借用了HTTP协议中固有的操作命令来完成。
    这一点也是REST最容易陷入争论的地方,基于网络的软件系统,到底是面向资源更好,还是面向服务更好,这事情哪怕到了今天仍然是没有个定论,也许永远都没有。但是,有一个基本清晰的结论是,面向资源编程的抽象程度通常更高,这意味着坏处是往往距离人类的思维方式更远,而好处是往往通用程度会更好。这样诠释REST大概本身就挺抽象的,笔者还是举个例子来说明:譬如几乎每个系统都有的登录和注销功能,如果你理解成登录对应于login()服务,注销对应于logout()服务这样两个服务,这是“符合人类思维”的;如果你理解成登录是CREATE Session,注销是REMOVE Session,这样你只需要设计一种“Session资源”即可满足需求,甚至以后对Session的其他需求,譬如查询或者修改登陆用户的信息,都可以在这一套设计中囊括在内,这便是“抽象程度更高”带来的好处。
    想要在架构设计中合理恰当地利用统一接口,Fielding建议系统应能做到每次请求中都包含资源的ID,所有操作均通过资源ID来进行;建议每个资源都应该是自描述的消息;建议通过超文本来驱动应用状态的转移。
  6. 按需代码Code-On-Demand
    这被Fielding列为一条可选原则。按需代码指任何按照客户端软件(譬如浏览器)的请求,将可执行的软件程序从服务器计算机发送到客户端的技术。这是可选的原因并非是它特别难以达到,而更多是出于必要性和性价比的考虑。举个例子,譬如你使用Element-UI组件库开发一个Web应用,但其实只用了里面一两个组件,却没有仔细配置好babel-plugin-component来做按需引入,一下子把几十个组件都打包进脚本中,这便是没有贯彻好按需代码的原则。这类事情(引入一个类库可能只使用其中很少量的一部分代码)是相当普遍的,我个人并不赞成不考虑实际场景的唯性能论,在关键场景肯定要抠细节,但所有场景都无限度的“精益求精”并无必要。

REST的基本思想是面向资源来抽象问题,它与此前流行的编程思想——面向过程的编程在抽象主体上有本质的差别。在REST提出以前,人们设计分布式系统服务的唯一方案就只有RPC,RPC是将本地的方法调用思路迁移到远程方法调用上,开发者是围绕着“方法”去设计服务的,譬如CORBA、RMI、DCOM,等等。这样做的坏处不仅是“如何在异构系统间表示一个方法”、“如何获得接口能够提供的方法清单”都成了需要专门协议去解决的问题(RPC的三大基本问题之一),更在于服务的每个方法都是不同的,服务使用者必须逐个学习才能正确地使用它们。Google在《Google API Design Guide》中曾经写下这样一段话:

Traditionally, people design RPC APIs in terms of API interfaces and methods, such as CORBA and Windows COM. As time goes by, more and more interfaces and methods are introduced. The end result can be an overwhelming number of interfaces and methods, each of them different from the others. Developers have to learn each one carefully in order to use it correctly, which can be both time consuming and error prone

以前,人们面向方法去设计RPC API,譬如CORBA和DCOM,随着时间推移,接口与方法越来越多却又各不相同,开发人员必须了解每一个方法才能正确使用它们,这样既耗时又容易出错。

REST提出以资源为主体进行服务设计的风格,为它带来不少好处(自然也有坏处,笔者将在最后谈论REST的不足与争议),譬如:

  • 降低的服务接口的学习成本。统一接口(Uniform Interface)是REST的重要标志,将对资源的标准操作都映射到了标准的HTTP方法上去,这些方法对每个资源的语义都是一致的,不需要刻意学习,更不会有什么Interface Description Language之类的协议存在。

  • 资源具有层次结构。以方法为中心抽象的API,由于方法是动词,逻辑上决定了它们都是互相独立的,但以资源为中心抽象的API,由于资源是名词,天然就可以产生集合与层次结构,譬如:用户资源会拥有多个消息资源、一个个人资料资源、一部购物车资源,购物车中有多本书籍资源,很容易在API中构造出这些资源的集合关系、层次关系,而且是符合人们长期在单机、网络中资源管理的直觉的。相信你不需要专门阅读方法说明书,也可以知道获取用户icyfenix的购物车中的第2本书应该表示为:

    GET /users/icyfenix/cart/2
    
  • REST绑定于HTTP协议。面向资源编程不是必须构筑在HTTP之上,但REST是,这是优点,也是缺点。因为HTTP本来就是面向资源而设计的网络协议,纯粹只用HTTP(而不是SOAP over HTTP那样在再构筑协议)带来的好处是RPC中的Wire Protocol问题无需再多考虑了,REST将复用HTTP协议中已经定义的语义和相关基础支持来解决。HTTP协议已经有效运作了30年,其相关的技术基础设施已是千锤百炼,无比成熟。而坏处自然是,当你想去考虑那些HTTP不提供的特性时,将束手无策。

  • ……

以上列举的面向资源的优点,并非要证明它比面向过程、面向对象更优秀。笔者只想说明它们各有所长,只想说明在互利网中,面向资源来进行交互是这30年HTTP培养出来的用户习惯。是否能够选用RESTful的API设计风格,最需要权衡的是你的需求场景、你团队的设计和开发人员是否能够适应面向资源的思想来设计软件,来编写代码。

RMM成熟度

前面我们花费大量篇幅讨论了REST的思想、概念和指导原则等理论方面的内容,在这个小节里,我们把重心放在实践上,同时把目光从整个软件架构设计聚焦到REST服务接口,以切合本节的题目“服务设计风格”,也顺带填了前面埋下的“如何评价服务是否RESTful”的坑。

RESTful Web APIs》和《RESTful Web Services》的作者Leonard Richardson曾提出过一个衡量“服务有多么REST”的Richardson成熟度模型(Richardson Maturity Model),便于那些原本不使用REST的服务,能够逐步地导入REST。Richardson将服务接口“REST的程度”从低到高,分为0至4级:

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

我们借用Martin Fowler撰写的关于RMM成熟度模型的文章中的实际例子(原文是XML写的,我简化了一下),来实际看一下四种不同程度的REST反应到实际API是怎样的。假设你是一名软件工程师,接到需求(也被我尽量简化了)的UserStory是这样的:

医生预约系统

作为一名病人,我想要从系统中得知指定日期内我熟悉的医生是否具有空闲时间,以便于我向该医生预约就诊。

第0级

医院开放了一个/appointmentService的Web API,传入日期、医生姓名作为参数,可以得到该时间段该名医生的空闲时间,该API的一次HTTP调用如下所示:

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级

第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这些字段做分支判断,每一套服务都要设计可能发生错误的code,这很难考虑全面,而且也不利于对某些通用的错误做统一处理;三是并没有考虑认证授权等安全方面的内容,譬如要求只有登陆用户才允许查询医生档期时间,某些医生可能只对VIP开放,需要特定级别的病人才能预约等等。

第2级

第1级遗留三个问题都可以靠引入统一接口来解决。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

第3级

第2级是目前绝大多数系统所到达的REST级别,但仍不是不够完美的,至少还存在一个问题:你是如何知道预约mjones医生的档期是需要访问“/schedules/1234”这个服务Endpoint的?也许你甚至第一时间无法理解为何我会有这样的疑问,这当然是程序代码写的呀!但REST并不认同这种已烙在程序员脑海中许久的想法。RMM中的Hypermedia Controls、Fielding论文中的HATEOAS和现在提的比较多的“超文本驱动”,所希望的是除了第一个请求是有你在浏览器地址栏输入所驱动之外,其他的请求都应该能够自描述清楚后续可能发生的状态转移,由超文本自身来驱动。所以,当你输入了查询的指令之后:

GET /doctors/mjones/schedule?date=2020-03-04&status=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,只有面向过程、面向对象编程才能处理真正复杂的业务逻辑
    这是遇到最多的一个问题。HTTP的四个最基础的命令POST、GET、PUT和DELETE很容易让人直接联想到CRUD操作,以至于在脑海中自然产生了直接的对应。REST所能涵盖的范围当然远不止于此,不过要说POST、GET、PUT和DELETE对应于CRUD其实也没什么不对,只是这个CRUD必须泛化去理解,它们涵盖了信息在客户端与服务端之间如何流动的几种主要方式,所有基于网络的操作逻辑,都可以对应到信息在服务端与客户端之间如何流动来理解,有的场景里比较直观,而另一些场景中可能比较抽象。
    针对那些比较抽象的场景,如果真不好把HTTP方法映射为资源的所需操作,REST也并非刻板的教条,用户是可以使用自定义方法的,按Google推荐的REST API风格,自定义方法应该放在资源路径末尾,嵌入冒号加自定义动词的后缀。譬如,我将删除操作映射到标准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-AtomicTransactionWS-Coordination这样的功能性协议,这REST确实不支持,假如你已经理解了这样做的代价,仍决定要这样做的话,Web Service是比较好的选择。如果“事务”是指希望保障数据的最终一致性,说明你已经放弃刚性事务了,这才是分布式系统中的主流,使用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带来了极大的便捷(不需要额外协议,不需要重复解决一堆基础网络问题,等等),但也是HTTP本身成了束缚REST的无形牢笼。我仍通过具体例子来解释REST这方面的局限性:譬如你仅仅想获得某个用户的姓名,RPC风格中可以设计一个“getUsernameById”的服务,返回一个字符串,尽管这种服务的通用性实在称不上“设计”二字,但确实可以工作;而REST风格中你将向服务端请求整个用户对象,然后丢弃掉返回的结果中该用户的其他属性,这便是一种Overfetching。REST的应对手段是通过位于中间节点或客户端缓存来缓解这种问题,但此缺陷的本质是由于HTTP协议完全没有对请求资源的结构化描述能力(但有非结构化的部分内容获取能力,即今天多用于端点续传的Range Header),所以返回资源的哪些内容、以什么数据类型返回等等,都不可能得到协议层面的支持,要做你就只能自己在GET方法的Endpoint上设计各种参数来实现。而另外一方面于此相对的缺陷是对资源的批量操作的支持,有时候我们不得不为此而专门设计一些抽象的资源才能应对。譬如你准备把某个用户的名字增加一个“VIP”前缀,提交一个PUT请求修改这个用户的名称即可,而你要给1000个用户加VIP时,就不得不先创建一个(如名为“VIP-Modify-Task”)任务资源,把1000个用户的ID交给这个任务,最后驱动任务进入执行状态(真去调用1000次PUT,浏览器会回应你HTTP/1.1 429 Too Many Requests,老板则会揍你一顿)。又譬如你去网店买东西,下单、冻结库存、支付、加积分、扣减库存这一系列步骤会涉及到多个资源的变化,你可能面临不得不创建一种“事务”的抽象资源,或者用某种具体的资源(譬如“结算单”)贯穿这个过程的始终,每次操作其他资源时都带着事务或者结算单的ID。HTTP协议由于本身的无状态性,会相对不适应(并非不能够)处理这类业务场景。
    解决这类问题,目前看起来一种理论上较优秀的解决方案是GraphQL,这是由Facebook提出并开源的一种面向资源API的数据查询语言(如同SQL一样,挂了个“查询语言”的名字,但CRUD都做)。比起依赖HTTP无协议的REST,GraphQL可以说是另一种“有协议”的、更彻底的面向资源的服务方式。然而凡事都有两面,离开了HTTP,它又面临着几乎所有RPC框架所遇到的那个如何推广交互接口的问题。

Kudos to Star
总字数: 12,006 字  最后更新: 10/18/2020, 6:48:19 PM