医生预约系统作为一名病人,我想要从系统中得知指定日期内我熟悉的医生是否具有空闲时间,以便于我向该医生预约就诊。
第0级
医院开放了一个/appointmentService的Web API,传入日期、医生姓名等参数,可以得到该时间段内该名医生的空闲时间,该API的一次HTTP调用如下所示:
1 POST /appointmentService?action=query HTTP/1.1
2
3 {date: "2020-03-04", doctor: "mjones"}
然后服务器会传回一个包含了所需信息的回应:
1 HTTP/1.1 200 OK
2
3 [
4 {start:"14:00", end: "14:50", doctor: "mjones"},
5 {start:"16:00", end: "16:50", doctor: "mjones"}
6 ]
得到了医生空闲的结果后,笔者觉得14:00比较合适,于是进行预约确认,并提交了个人基本信息:
1 POST /appointmentService?action=comfirm HTTP/1.1
2
3 {
4 appointment: {date: "2020-03-04", start:"14:00", doctor: "mjones"},
5 patient: {name: icyfenix, age: 30, ……}
6 }
如果预约成功,那我能够收到一个预约成功的响应:
1 HTTP/1.1 200 OK
2
3 {
4 code: 0,
5 message: "Successful confirmation of appointment"
6 }
如果出现问题,譬如有人在我前面抢先预约了,那么我会在响应中收到某种错误消息:
1 HTTP/1.1 200 OK
2
3 {
4 code: 1
5 message: "doctor not available"
6 }
至此,整个预约服务宣告完成,直接明了,我们采用的是非常直观的基于RPC风格的服务设计,似乎很容易就解决了所有问题,但真的是这样吗?
第1级第0级是RPC的风格,如果需求永远不会变化,那它完全可以良好地工作下去。但是,如果你不想为预约医生之外的其他操作、为获取空闲时间之外的其他信息去编写额外的方法,或者改动现有方法的接口,那还是应该考虑一下如何使用REST来抽象资源。通往REST的第一步是引入资源的概念,在API中的基本体现是围绕资源而不是过程来设计服务,说得直白一点,可以理解为服务的Endpoint应该是一个名词而不是动词。此外,每次请求中都应包含资源的ID,所有操作均通过资源ID来进行,譬如,获取医生指定时间的空闲档期:
1 POST /doctors/mjones HTTP/1.1
2
3 {date: "2020-03-04"}
然后服务器传回一组包含了ID信息的档期清单,注意,ID是资源的唯一编号,有ID即代表“医生的档期”被视为一种资源:
1 HTTP/1.1 200 OK
2
3 [
4 {id: 1234, start:"14:00", end: "14:50", doctor: "mjones"},
5 {id: 5678, start:"16:00", end: "16:50", doctor: "mjones"}
6 ]
笔者还是觉得14:00的时间比较合适,于是又进行预约确认,并提交了个人基本信息:
1 POST /schedules/1234 HTTP/1.1
2
3 {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升级时将会变得非常简单。