凤凰架构:构建可靠的大型分布式系统
上QQ阅读APP看书,第一时间看更新

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升级时将会变得非常简单。