REST设计风格:你写的 RESTful API 到第几层了?

·  阅读 705

理解REST

在理解其真正概念前,我们先来明确: REST它的核心思想是面向资源的抽象(相对于RPC就是面向过程抽象),它是一种设计风格的指导,而非具有较强约束的协议。

REST源于Roy Thomas Fielding在2000年发表的博士论文“Architectural Stylesand the Design of Network-based Software Architectures”[1]提出的一种编程思想,并为这种程序设计风格取了一个很多人难以理解,但是今天已经广为人知的名字——REST(Representational State Transfer,表征状态转移)。

如果拆分成独立单词RepresentationalStateTransfer,我们知道它们分别是表征状态转移的意思。但是合在一起,好像又不明白它想要表达的意思了。我们不妨先去理解什么是HTTP(毕竟REST是建立在HTTP之上的),你会发现REST实际上是“HTT”(Hypertext Transfer)的进一步抽象,两者的关系就如同接口与实现类的关系一般。REST是对资源的抽象,何为资源?

资源(Resource):譬如你现在正在阅读一篇名为《REST设计》的文章,这篇文章的内容本身(你可以将其理解为蕴含的信息、数据)称之为“资源”。无论你是通过阅读购买的图书、浏览器上的网页还是打印出来的文稿,无论是在电脑屏幕上阅读还是在手机上阅读,尽管呈现的样子各不相同,但其中的信息是不变的,你所阅读的仍是同一份“资源”。

然后我们以此文章为资源,来看看表征状态转移在阅读文章过程的中含义:

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

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

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

RESTful系统的六大设计原则

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

将用户界面所关注的逻辑和数据存储所关注的逻辑分离开来,有助于提高用户界面的跨平台的可移植性。相较以往的完全基于服务端控制和渲染(如JSP这类)的模式已甚少,一方面代码仓库的便捷性和易管理性成为了敏捷开发的障碍,另一方面得益于前端技术(从ES规范,到语言实现,再到前端框架等)在近年来的高速发展,造就了现如今的**前后端分离*模式:后端控制数据,前端控制渲染。

2.无状态(Stateless)

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

但现实是骨感的,大型系统的上下文状态数量完全可能膨胀到客户端无法承受的程度,在服务端的内存、会话、数据库或者缓存等地方持有一定的状态成为一种事实上存在,并将长期存在、被广泛使用的主流方案。

3.可缓存(Cacheability)

无状态服务虽然提升了系统的可见性、可靠性和可伸缩性,但降低了系统的网络性。“降低网络性”的通俗解释是某个功能使用有状态的设计时只需要一次(或少量)请求就能完成,使用无状态的设计时则可能会需要多次请求,或者在请求中带有额外冗余的信息。为了缓解这个矛盾,REST希望软件系统能够如同万维网一样,允许客户端和中间的通信传递者(譬如代理)将部分服务端的应答缓存起来。当然,为了缓存能够正确地运作,服务端的应答中必须直接或者间接地表明本身是否可以进行缓存、可以缓存多长时间,以避免客户端在将来进行请求的时候得到过时的数据。运作良好的缓存机制可以减少客户端、服务端之间的交互,甚至有些场景中可以完全避免交互,这就进一步提高了性能。

4.分层系统(Layered System)

这里所指的分层并不是表示层、服务层、持久层这种意义上的分层,而是指客户端一般不需要知道是否直接连接到了最终的服务器,抑或连接到路径上的中间服务器。中间服务器可以通过负载均衡和共享缓存的机制提高系统的可扩展性,这样也便于缓存、伸缩和安全策略的部署。该原则的典型应用是内容分发网络(ContentDistribution Network,CDN)。如果你是通过网站浏览到这篇文章的话,你所发出的请求一般(假设你在中国境内的话)并不是直接访问位于GitHub Pages的源服务器,而是访问了位于国内的CDN服务器,但作为用户,你完全不需要感知到这一点。

5.统一接口(Uniform Interface)

这是REST的另一条核心原则,REST希望开发者面向资源编程,希望软件系统设计的重点放在抽象系统该有哪些资源,而不是抽象系统该有哪些行为(服务)上。这条原则你可以类比计算机中对文件管理的操作来理解,管理文件可能会涉及创建、修改、删除、移动等操作,这些操作数量是可数的,而且对所有文件都是固定、统一的。如果面向资源来设计系统,同样会具有类似的操作特征,由于REST并没有设计新的协议,所以这些操作都借用了HTTP协议中固有的操作命令来完成。

统一接口也是REST最容易陷入争论的地方,基于网络的软件系统,到底是面向资源合适,还是面向服务更合适,这个问题恐怕在很长时间里都不会有定论,也许永远都没有。但是,已经有一个基本清晰的结论是:面向资源编程的抽象程度通常更高。抽象程度高带来的坏处是距离人类的思维方式往往会更远,而好处是通用程度往往会更好。用这样的语言去诠释REST,还是有些抽象,下面以一个例子来说明:譬如,对于几乎每个系统都有的登录和注销功能,如果你理解成登录对应于login()服务,注销对应于logout()服务这样两个独立服务,这是“符合人类思维”的;如果你理解成登录是PUT Session,注销是DELETE Session,这样你只需要设计一种“Session资源”即可满足需求,甚至以后对Session的其他需求,如查询登录用户的信息,就是GET Session而已,其他操作如修改用户信息等也都可以被这同一套设计囊括在内,这便是“抽象程度更高”带来的好处。

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

6.按需代码(Code-On-Demand)

按需代码被Fielding列为一条可选原则。它是指任何按照客户端(譬如浏览器)的请求,将可执行的软件程序从服务端发送到客户端的技术。按需代码赋予了客户端无须事先知道所有来自服务端的信息应该如何处理、如何运行的宽容度。举个具体例子,以前的Java Applet技术,今天的WebAssembly等都属于典型的按需代码,蕴含着具体执行逻辑的代码是存放在服务端,只有当客户端请求了某个JavaApplet之后,代码才会被传输并在客户端机器中运行,结束后通常也会随即在客户端中被销毁。将按需代码列为可选原则的原因并非是它特别难以达到,更多是出于必要性和性价比的实际考虑。

RMM (Richardson MaturityModel)

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

  • 第0级(The Swamp of Plain Old XML):完全不REST。
  • 第1级(Resources):开始引入资源的概念。
  • 第2级(HTTP Verbs):引入统一接口,映射到HTTP协议的方法上。
  • 第3级(Hypermedia Controls):超媒体控制,在本文里面的说法是“超文本驱动”,在Fielding论文里的说法是“Hypertext As The Engine Of ApplicationState,HATEOAS”,其实都是指同一件事情。

下面借用Martin Fowler撰写的关于RMM的文章中的实际例子(原文是XML写的,这里简化为JSON表示),来具体展示一下四种不同程度的REST反映到实际接口中会是怎样的。假设你是那名程序员,你会怎么设计:

医生预约系统

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

第0级

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

POST /appointmentService?query HTTP/1.1

{"data": "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=confirm HTTP/1.1

{
    "appointment": {"date": "2020-03-04", "start":"14:00", "end":"14:50", "doctor": "mjones"},
    "patient": {"name": "zio", "age": 30, ...}
}
复制代码

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

HTTP/1.1 200 OK

{
    "code": 0,
    "message": "Successful confirmation of appiontment"
}
复制代码

如果出现问题,譬如有人在我前面抢先预约了,那么我会在响应中收到某种错误消息:

HTTP/1.1 200 OK

{
    "code": 1,
    "message": "doctor not available"
}
复制代码

至此,整个预约服务宣告完成,直接明了,我们采用的是非常直观的基于RPC风格的服务设计

第1级

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

通往REST的第一步是引入资源的概念,在API中的基本体现是围绕资源而不是过程来设计服务,说得直白一点,可以理解为服务的Endpoint应该是一个名词而不是动词。此外,每次请求中都应包含资源的ID,所有操作均通过资源ID来进行,譬如,获取医生指定时间的空闲档期:

GET /doctors/mjones?date="2020-03-04" HTTP/1.1
复制代码

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

HTTP/1.1 200 OK

[
    {"id": 1, "start":"14:00", "end":"14:50", "doctor": "mjones"},
    {"id": 2, "start":"16:00", "end":"16:50", "doctor": "mjones"}
]
复制代码

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

POST /schedules/1 HTTP/1.1

{"name": "zio", "age":30, ...}
复制代码

后面预约成功或者失败的响应消息在这个级别里面与之前一致,就不重复了。比起第0级,第1级的特征是引入了资源,通过资源ID作为主要线索与服务交互,但第1级至少还有三个问题没有解决:一是只处理了查询和预约,如果临时想换个时间,要调整预约,或者病忽然好了,想删除预约,这都需要提供新的服务接口;二是处理结果响应时,只能依靠结果中的code、message这些字段做分支判断,每一套服务都要设计可能发生错误的code,这很难考虑全面,而且也不利于对某些通用的错误做统一处理;三是没有考虑认证授权等安全方面的内容,譬如要求只有登录用户才允许查询医生档期时间,某些医生可能只对VIP开放,需要特定级别的病人才能预约,等等。

第二级

第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": 1, "start":"14:00", "end":"14:50", "doctor": "mjones"},
    {"id": 2, "start":"16:00", "end":"16:50", "doctor": "mjones"}
]
复制代码

笔者仍然觉得14:00的时间比较合适,于是进行预约确认,并提交了个人基本信息,用以创建预约,这是符合POST的语义的:

POST /schedules/1 HTTP/1.1

{"name": "zio", "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中的超文本控制、Fielding论文中的HATEOAS和现在提的比较多的“超文本驱动”,所希望的是除了第一个请求是由你在浏览器地址栏输入驱动之外,其他的请求都应该能够自己描述清楚后续可能发生的状态转移,由超文本自身来驱动。所以,当你输入了查询的指令之后:

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

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

HTTP/1.1 200 OK

[
    {
        "id": 1, "start":"14:00", "end":"14:50", "doctor": "mjones",
        "links": [
            {"rel": "confirm schedule", "href": "/schedule/1"}
        ]
    },
    {
        "id": 2, "start":"16:00", "end":"16:50", "doctor": "mjones",
        "links": [
            {"rel": "confirm schedule", "href": "/schedule/2"}
        ]
    }
]
复制代码

如果做到了第3级REST,那服务端的API和客户端也是完全解耦的,此时如果你要调整服务数量,或者对同一个服务做API升级时将会变得非常简单。

对于第3级需要明确:如果客户端指的是移动端这类发布升级成本较高的场景,这样的设计确实友谊颇高;但如果是客户端是web前端,它们的发布成本和服务端无差,那么可以case by case的去看待,是否要把这类“href”信息维护在服务端,这样做是否有悖于“前后端分离”的思想。

推荐阅读

《凤凰架构·构建可靠的大型分布式系统》

分类:
后端
标签:
分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改