2.2.3 RMM
前面我们花费大量篇幅讨论了REST的思想、概念和指导原则等理论方面的内容,在本节中,我们将把重心放在实践上,把目光从整个软件架构设计进一步聚焦到REST接口设计上,以切合2.2节的标题,也顺带填了前面埋下的“如何评价服务是否RESTful”的坑。
RESTful Web APIs和RESTful Web Services的作者Leonard Richardson曾提出一个衡量“服务有多么REST”的Richardson成熟度模型(Richardson Maturity Model,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 Application State,HATEOAS”,其实都是指同一件事情。
下面笔者借用Martin Fowler撰写的关于RMM的文章中的实际例子(原文是XML写的,这里简化为JSON表示),来具体展示一下四种不同程度的REST反映到实际接口中会是怎样的。假设你是一名软件工程师,接到的需求(原文中的需求复杂一些,这里简化了)描述是这样的:
医生预约系统
作为一名病人,我想要从系统中得知指定日期内我熟悉的医生是否具有空闲时间,以便于我向该医生预约就诊。
第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=confirm HTTP/1.1 { appointment: {date: "2020-03-04", start:"14:00", doctor: "mjones"}, patient: {name: icyfenix, 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: icyfenix, age: 30, ……}
后面预约成功或者失败的响应消息在这个级别里面与之前一致,就不重复了。比起第0级,第1级的特征是引入了资源,通过资源ID作为主要线索与服务交互,但第1级至少还有三个问题没有解决:一是只处理了查询和预约,如果临时想换个时间,要调整预约,或者病忽然好了,想删除预约,这都需要提供新的服务接口;二是处理结果响应时,只能依靠结果中的code、message这些字段做分支判断,每一套服务都要设计可能发生错误的code,这很难考虑全面,而且也不利于对某些通用的错误做统一处理;三是没有考虑认证授权等安全方面的内容,譬如要求只有登录用户才允许查询医生档期时间,某些医生可能只对VIP开放,需要特定级别的病人才能预约,等等。
第2级
第1级遗留的三个问题都可以通过引入统一接口来解决。HTTP协议的七个标准方法是经过精心设计的,只要架构师的抽象能力够用,它们几乎能涵盖资源可能遇到的所有操作场景。REST的具体做法是:把不同业务需求抽象为对资源的增加、修改、删除等操作来解决第一个问题;使用HTTP协议的Status Code,它可以涵盖大多数资源操作可能出现的异常,也可以自定义扩展,以此解决第二个问题;依靠HTTP Header中携带的额外认证、授权信息来解决第三个问题,这个在实战中并没有体现,后文会在5.3节中介绍相关内容。
按这个思路,获取医生档期,应采用具有查询语义的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: icyfenix, 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 { 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升级时将会变得非常简单。