09 _ RESTful服务(上):从面向过程编程到面向资源编程 你好,我是周志明。前面两节课,我们学习了远程方法调用RPC,今天我们接着学习另一种主流的远程服务访问风格:RESTful服务。

REST与RPC的对比

很多人都会拿REST来跟RPC对比优劣,其实,无论是思想上、概念上,还是使用范围上,REST与RPC都不完全一样,它们在本质上并不是同一个类型的东西,充其量只算是有一些相似,在应用中会有一部分功能重合的地方。

REST与RPC在思想上存在差异的核心,是抽象的目标不一样,也就是面向资源的编程思想与面向过程的编程思想之间的区别。

面向过程编程和面向对象编程,想必你应该都听说过,但什么是面向资源编程呢?这个问题等我一会儿介绍完REST的特征之后,再回头细说。

那么,二者在概念上的不同,是指REST并不是一种远程服务调用协议,甚至我们可以把定语也去掉,它就不是一种协议。

因为协议都带有一定的规范性和强制性,最起码也该有个规约文档,比如JSON-RPC,它哪怕再简单,也要有个《JSON-RPC Specification》来规定协议的格式细节、异常、响应码等信息。但是REST并没有定义这些内容,虽然它有一些指导原则,但实际上并不受任何强制的约束。

经常会有人批评说,某个系统接口“设计得不够RESTful”,其实这句话本身就有些争议。因为REST只能说是一种风格,而不是规范、协议,并且能完全达到REST所有指导原则的系统,也是很少见的。这个问题我们会在下一讲中详细讨论。

至于使用范围上,REST与RPC作为主流的两种远程调用方式,在使用上确实有重合之处,但重合的区域有多大就见仁见智了。

上一节课,我提到了当前的RPC协议框架各有侧重点,并且列举了RPC的一些典型发展方向,比如分布式对象、提升调用效率、简化调用复杂性等等。

其中的分布式对象这一条线的应用,对于REST就可以说是毫无关系;而能够重视远程服务调用效率的应用场景,就基本上已经排除了REST应用得最多的供浏览器端消费的远程服务。因为以浏览器作为前端,对于传输协议、序列化器这两点都没有什么选择权,哪怕想要更高效率也有心无力。

而在移动端、桌面端或者分布式服务端的节点之间通讯这一块,REST虽然照样有宽阔的用武之地,只要支持HTTP就可以用于任何语言之间的交互,不过使用REST的前提是网络没有成为性能上的瓶颈。但是在需要追求传输效率的场景里,REST提升传输效率的潜力有限,死磕REST又想要好的网络性能,一般不会有好的效果。

另外,对于追求简化调用的场景,我在前面提到的浏览器端就属于这一类的典型,在众多RPC里,也就JSON-RPC有机会与REST竞争,其他RPC协议与框架,哪怕是能够支持HTTP协议,哪怕提供了JavaScript版本的客户端(如gRPC-Web),也只是具备前端使用的理论可行性,很少能看到有实际项目把它们真的用到浏览器上的。

可是,尽管有着种种不同,REST跟RPC还是产生了很频繁的比较与争论,这两种分别面向资源和面向过程的远程调用方式,就像当年面向对象与面向过程的编程思想一样,非得分出个高低不可。

理解REST

REST概念的提出来自于罗伊·菲尔丁(Roy 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协议的理论和思想,最初是以备忘录的形式,在专家组成员之间交流,这个备忘录其实就是REST的雏形。

那么从时间上看,当起草完HTTP 1.1协议之后,Fielding就回到了加州大学欧文分校,继续攻读博士学位。然后到了第二年,也就是2000年,Fielding更为系统、严谨地阐述了这套理论框架,并且以这套理论框架为基础,导出了一种新的编程风格,他把这种风格命名为了我们今天所熟知的REST,即“表征状态转移”(Representational State Transfer)的缩写。

不过,哪怕是对编程和网络都很熟悉的同学,单从“表征状态转移”这个标题上看,也不太可能直接弄明白,什么叫“表征”、啥东西的“状态”、从哪“转移”到哪。虽然在论文当中,Fielding有论述过这些概念,但他写得确实非常晦涩(不想读英文的话,你可以参考一下中文翻译版本)。

这里呢,我推荐你一种比较容易理解REST思想的方式,就是你先去理解什么是HTTP,再配合一些实际例子来进行类比,你就会发现“REST”实际上是“HTT”(Hyper Text Transfer,超文本传输)的进一步抽象,它们就像是接口与实现类之间的关系。

HTTP中使用的“超文本”一词,是美国社会学家泰德·H·尼尔森(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)

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

好,通过这个“阅读文章”的例子,对资源等概念进行通俗的解释,现在你应该就能理解REST所说的“表征状态转移”的含义了。

那么,借着这个例子的上下文,我再给你介绍几个现在不涉及,但在后面解读REST的6大核心特征时要用到的概念名词:

第一个,统一接口(Uniform Interface)。

在了解这个概念之前,我们先来思考一个问题,前面所说的“服务器通过某种方式”,让表征状态发生转移,具体指的是什么方式呢?

如果你现在正在使用Web端来学习这一讲的内容,你可以看到页面的左半部分有下一讲(或者是下面几讲)的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字符集进行处理。

好,除了以上列出的这些看名字不容易弄懂的概念外,在理解REST的时候,你还要注意一个常见的误区。Fielding在提出REST时,所谈论的范围是“架构风格与网络的软件架构设计”(Architectural Styles and Design of Network-based Software Architectures),而不是现在被人们所狭义理解的一种“远程服务设计风格”。

这两者的范围差别,就好比这门课程所谈论的话题“软件架构”与这个小章节所谈论的话题“访问远程服务”的关系那样,前者是后者的一个很大的超集。尽管考虑到这节课的主题和多数人的关注点,我们确实是会以“远程服务设计风格”作为讨论的重点,但至少我们要知道它们在范围上的差别。

RESTful风格的系统特征

OK,理解了前面解读的这些概念以后,现在我们就可以开始讨论面向资源的编程思想,以及Fielding所提出的具体的软件架构设计特征了。Fielding认为,一套理想的、完全满足REST的系统应该满足以下六个特征。

服务端与客户端分离(Client-Server)

现在,有越来越多的开发者认可,分离开用户界面和数据存储所关注的逻辑,有助于提高用户界面跨平台的可移植性。

以前完全基于服务端控制和渲染(如JSF这类)框架的实际用户,现在已经很少见了。另外,在服务端进行界面控制(Controller),通过服务端或者客户端的模版渲染引擎,来进行界面渲染的框架(如Struts、SpringMVC这类)也受到了颇大的冲击。而推动这个局面发展的主要原因,实际上跟REST的关系并不大,随着前端技术(从ES规范,到语言实现,到前端框架等)近年来的高速发展,前端表达能力的大幅度加强才是真正的幕后推手。

此外,由于前端的日渐强势,现在还流行起由前端代码反过来驱动服务端进行渲染的SSR(Server-Side Rendering)技术,在Serverless、SEO等场景中已经占领了一块领地。

无状态(Stateless)

这是REST的一条关键原则,部分开发者在做服务接口规划时,觉得RESTful风格的API怎么设计都别扭,一个很可能的原因就是服务端持有着比较重的状态。

REST希望服务器能不负责维护状态,每一次从客户端发送的请求中,应该包括所有必要的上下文信息,会话信息也由客户端保存维护,服务器端依据客户端传递的状态信息来进行业务处理,并且驱动整个应用的状态变迁。

至于客户端承担状态维护职责后的认证、授权等各方面的可信问题,都会有针对性的解决方案(这部分内容,我会在后面讲解安全架构时展开介绍)。

但必须承认的现状是,目前大多数的系统是达不到这个要求的,越复杂、越大型的系统越是如此。服务端无状态可以在分布式环境中获得很高价值的好处,但大型系统的上下文状态数量,完全可能膨胀到,客户端在每次发送请求时,根本无法全部囊括系统里所有必要的上下文信息。在服务端的内存、会话、数据库或者缓存等地方,持有一定的状态是一种现实情况,而且会是长期存在、被广泛使用的主流方案。

可缓存(Cacheability)

前面我们提到的无状态服务,虽然提升了系统的可见性、可靠性和可伸缩性,但也降低了系统的网络性。这句话通俗的解释就是,某个功能使用有状态的架构只需要一次请求就能完成,而无状态的服务则可能会需要多个请求,或者在请求中带有冗余的信息。

所以,为了缓解这个矛盾,REST希望软件系统能够像万维网一样,客户端和中间的通讯传递者(代理)可以将部分服务端的应答缓存起来。当然,应答中必须明确或者间接地表明本身是否可以进行缓存,以避免客户端在将来进行请求的时候得到过时的数据。

运作良好的缓存机制可以减少客户端、服务器之间的交互,甚至有些场景中可以完全避免交互,这就进一步提高了性能。

分层系统(Layered System)

这里所指的并不是表示层、服务层、持久层这种意义上的分层,而是指客户端一般不需要知道是否直接连接到了最终的服务器,或者是连接到路径上的中间服务器。中间服务器可以通过负载均衡和共享缓存的机制,提高系统的可扩展性,这样也便于缓存、伸缩和安全策略的部署。

统一接口(Uniform Interface)

REST希望开发者面向资源编程,希望软件系统设计的重点放在抽象系统该有哪些资源上,而不是抽象系统该有哪些行为(服务)上。

这个特征,你可以类比计算机中对文件管理的操作。我们知道,管理文件可能会进行创建、修改、删除、移动等操作,这些操作数量是可数的,而且对所有文件都是固定的、统一的。如果面向资源来设计系统,同样会具有类似的操作特征,由于REST并没有设计新的协议,所以这些操作都借用了HTTP协议中固有的操作命令来完成。

统一接口也是REST最容易陷入争论的地方,基于网络的软件系统,到底是面向资源更好,还是面向服务更合适,这件事情在很长的时间里恐怕都不会有个定论,也许永远都没有。但是,有一个已经基本清晰的结论是:面向资源编程的抽象程度通常更高

抽象程度高有好处但也有坏处。坏处是往往距离人类的思维方式更远,而好处是往往通用程度会更好。

不过这样来诠释REST,大概本身就挺抽象的,你可能不太好理解,我还是举个例子来说明。

几乎每个系统都有登录和注销功能,如果你理解成登录对应于login()、注销对应于logout()这样两个独立服务,这是“符合人类思维”的;如果你理解成登录是PUT Session,注销是DELETE Session,这样你只需要设计一种“Session资源”即可满足需求,甚至以后对Session的其他需求,如查询登录用户的信息,就是GET Session而已,其他操作如修改用户信息等等,都可以被这同一套设计囊括在内,这便是“抽象程度更高”带来的好处。

而如果你想要在架构设计中合理恰当地利用统一接口,Fielding给出了三个建议:第一,系统要能做到每次请求中都包含资源的ID,所有操作均通过资源ID来进行;第二,每个资源都应该是自描述的消息;第三,通过超文本来驱动应用状态的转移。

按需代码(Code-On-Demand

按需代码被Fielding列为了一条可选原则,原因其实并非是它特别难以达到,更多是出于必要性和性价比的实际考虑。按需代码是指任何按照客户端(如浏览器)的请求,将可执行的软件程序从服务器发送到客户端的技术。它赋予了客户端无需事先知道,所有来自服务端的信息应该如何处理、如何运行的宽容度。

举个具体例子,以前的Java Applet技术、今天的WebAssembly等都属于典型的按需代码,蕴含着具体执行逻辑的代码存放在了服务端,只有当客户端请求了某个Java Applet之后,代码才会被传输并在客户端机器中运行,结束后通常也会随即在客户端中被销毁掉。

到这里,REST中的主要概念与思想原则就介绍完了,那么现在,我们再回过头来讨论一下这节课开篇中提出的REST与RPC在思想上的差异。

REST与RPC在思想上的差异

我在前面提到,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,随着时间推移,接口与方法越来越多却又各不相同,开发人员必须了解每一个方法才能正确使用它们,这样既耗时又容易出错。- —— Google API Design Guide, 2017

而REST提出以资源为主体进行服务设计的风格,就为它带来了不少好处。我举几个典型例子。

第一,降低了服务接口的学习成本。

统一接口是REST的重要标志,它把对资源的标准操作都映射到了标准的HTTP方法上去,这些方法对每个资源的语义都是一致的,我们不需要刻意学习,更不会有什么Interface Description Language之类的协议存在。

第二,资源天然具有集合与层次结构。

以方法为中心抽象的接口,由于方法是动词,逻辑上决定了每个接口都是互相独立的;但以资源为中心抽象的接口,由于资源是名词,天然就可以产生集合与层次结构。

我举个例子。你可以想像一个商城用户中心的接口设计:用户资源会拥有多个不同的下级的资源,比如若干条短消息资源、一份用户资料资源、一部购物车资源,而购物车中又会有自己的下级资源,比如多本书籍资源。

这样,你就很容易在程序接口中构造出这些资源的集合关系与层次关系,而且能符合人们长期在单机或网络环境中管理数据的直觉。我相信,你并不需要专门去阅读接口说明书,也能轻易推断出获取用户icyfenix的购物车中,第2本书的REST接口应该表示为: GET /users/icyfenix/cart/2

第三,REST绑定于HTTP协议。

面向资源编程并不是必须构筑在HTTP之上,但对于REST来说,这是优点,也是缺点。

因为HTTP本来就是面向资源而设计的网络协议,纯粹只用HTTP(而不是SOAP over HTTP那样在再构筑协议)带来的好处,是不需要再去考虑RPC中的Wire Protocol问题了,REST可以复用HTTP协议中已经定义的语义和相关基础支持来解决。HTTP协议已经有效运作了30年,与其相关的技术基础设施已是千锤百炼,无比成熟。而它的坏处自然就是,当你想去考虑那些HTTP不提供的特性时,就束手无策了。

小结

在这节课中,虽然我列举了一些面向资源的优点,但我并非要证明它比面向过程、面向对象更优秀。是否选用REST的API设计风格,需要权衡的是你的需求场景、你团队的设计,以及开发人员是否能够适应面向资源的思想来设计软件、来编写代码。

在互联网中,面向资源来进行网络传输,是这三十年来HTTP协议精心培养出来的用户习惯,如果开发者能够适应REST不太符合人类思维习惯的抽象方式,那REST通常能够更好地匹配在HTTP基础上构建的互联网,在效率与扩展性方面也会有可观的收益。

一课一思

与远端服务通讯,除了以资源为中心的REST和以方法为中心的RPC外,还有基于长连接、消息管道等其他方式,想一想你还知道哪些通讯手段,它们能解决什么问题?有什么应用场景?

欢迎给我留言,分享你的答案。如果觉得有收获,也欢迎你把今天的内容分享给更多的朋友。

好,感谢你的阅读,我们下一讲再见。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/%e5%91%a8%e5%bf%97%e6%98%8e%e7%9a%84%e6%9e%b6%e6%9e%84%e8%af%be/09%20_%20RESTful%e6%9c%8d%e5%8a%a1%ef%bc%88%e4%b8%8a%ef%bc%89%ef%bc%9a%e4%bb%8e%e9%9d%a2%e5%90%91%e8%bf%87%e7%a8%8b%e7%bc%96%e7%a8%8b%e5%88%b0%e9%9d%a2%e5%90%91%e8%b5%84%e6%ba%90%e7%bc%96%e7%a8%8b.md