聊聊RESTful - 接口设计篇(二)

REST

(本文为“聊聊RESTful”系列文章第三篇,其他文章可通过篇尾链接查看)

大家好,欢迎大家来到“就浩这口”,我们继续上一篇的话题,接着跟大家聊聊,RESTful的接口设计。虽然标题是接口设计,但我其实是希望通过每一个接口设计规则来分析它背后的架构设计思想。是葫芦是瓢得捞出来看,没准还能捞到惊喜。
上篇文章发表之后,收到一些反馈,说文章篇幅较长,其实我自己在最后通读时也有这样的感觉,篇幅太长,读起来会有些吃力,所以从本期开始,我会尽量缩减一下篇幅。

今天的话题是消息体格式。

消息体格式

媒体类型与字符编码

媒体类型是一个容易被大家忽略的概念,因在api开发中,往往只有一种媒体类型,而在web开发中,不管是客户端还是服务端似乎都对媒体类型有很好的处理,以至于开发人员可以无视它的存在。

我这里讲媒体类型,并不是因为它会决定我们接口设计的成败,而是因为,我们可以花非常低的成本,就能实现对媒体类型的正确使用(很可能你已经通过某些框架正确的使用它了)。比如,我开发的接口均使用Json格式,那么我会对客户端有两点要求:

  1. 要求客户端请求时必须携带请求头Content-Type: application/json,这样我根据请求头就可以直接拒绝掉非Json的请求而无需解析消息体来判断。
  2. 如果客户端请求中包含请求头Accept,那么它的值也必须包含application/json,这样如果未来有一天我们需要支持XML等其他格式的时候,就不会存在客户端误用媒体类型而导致的兼容问题。

以上这些就算不做也几乎不会对你的接口产生任何影响,但我们只需要花很少的成本就能做到它,何乐而不为呢。对于字符编码也是同样的道理,绝大多数接口都已经限定了字符编码为UTF-8,那就在接口文档与实现中真的这么去做吧。

包装与可见性

其实包装并不是一个独立的问题,而是由其他问题引起的一个错误结果,我们仍旧以图书信息为例,我们来看以下两种格式:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 带包装
{
"data": {
"isbn": "9780321125217",
"name": "Domain-Driven Design"
}
}
# 不带包装
{
"isbn": "9780321125217",
"name": "Domain-Driven Design"
}

如果仅仅是这两种格式的比较,我相信绝大多数人都会选择后者,这完全不是问题。但实际情况带包装的格式往往是以下面这种形式存在的:

1
2
3
4
5
6
7
{
"code": 200,
"data": {
"isbn": "9780321125217",
"name": "Domain-Driven Design"
}
}

这样一来,这个包装结构似乎就非常合理了,就更容易被我们接受了。但其实问题的根源不在于包装结构,而在于我们对REST架构约束的理解。

Fielding的博士论文中指出,基于网络应用的架构中我们要关注的一个架构属性为可见性(Visibility)。

Visibility in this case refers to the ability of a component to monitor or mediate the interaction between two other components.

我们举个简单的例子来说明可见性。我们如今的接口服务可能很少是客户端与服务端直接交互的了,一般都会有些反向代理、网关甚至缓存等。例如缓存层,它并不会缓存所有响应,而只是缓存特定状态码的响应,比如200 OK。也就是说缓存层根据状态码来决定是否缓存响应内容,那么请求结果的状态码信息对缓存层就是可见的

前面那个包装结构的响应,就是典型的弃用状态码的方式,所有请求都会响应200 OK,然后在消息体中体现请求是否成功以及响应内容。如此一来,请求是否成功这一信息对于缓存层就不可见了,就会导致缓存逻辑的错乱,可能会让缓存层违背常理地缓存了错误的信息。我们要记住,在HTTP中,消息体是用来表述资源的,分层系统中的中间组件是不会解析它的,而头部信息才是它们关注的,丰富的明确定义的头部信息正是可见性的体现。

明白了可见性,消息体中也就不会再有资源表述之外的其他信息了,包装问题也就不是问题了。

不过还有一种情况,上次说过资源分为两大类,单个资源和集合资源,而集合资源的表述就是需要包装的。不过,我理解这并不是包装。对于集合资源,分页是一种常规需求,那么我是把分页信息也当做集合资源的一部分,那么集合资源的表述就要包括资源集合表述和分页信息表述两部分。从这个角度看,集合资源的表述并不是一种包装结构,而只是包含了分页信息而已:

1
2
3
4
5
6
7
8
9
10
11
12
{
"books": [
{"isbn": "9780321125217", "name": "Domain-Driven Design"},
......
{"isbn": "9780596805821", "name": "REST in Practice"}
],
"pagination": {
"numPerPage": 10,
"page": 2,
"total": 256
}
}

Json命名规则

看了上面的例子,大家会发现我使用的是驼峰式命名法。关于命名规则是一个永远也讨论不完的话题。每种语言都会有自己主张的命名规范,那么我觉得认准一种用下去就行了。至于Json,很多语言都会用到,可以按照自己语言主张的命名规范,或者直接遵循JavaScript的命名规范就好。

统一格式

在多个接口设计中,常常会遇到不同接口包含了相同或相似属性的情况,这时我们要注意命名与格式的统一。格式规范统一的接口会提高调用方的使用体验,这是我们接口的软实力。举几个例子:

  1. 相同概念相同命名。在同一套接口中,账号的概念可能会存在于多个接口,那么对其属性命名应该一致,比如都叫account,不要有的接口中叫account,有的接口叫accountId。再比如分页,请求的查询参数和响应信息里都会有分页相关信息,命名要尽量一致,响应信息里的分页信息,所有接口都用该使用相同的命名与结构。其实命名的背后暗含着DDD中的通用语言的思想,如果使用了通用语言,我想命名也就不是问题了。
  2. 命名要符合思维定式。比如,我们常会用关系型数据库做持久层,主键往往是自增id,而外键的命名往往以id结尾,那么当我们看到id时自然就会联想的主键或者外键,这是一种思维定式,那么我们设计接口最好就要符合这种思维定式,不要用id或id结尾的词来命名非主键和外键属性,以免引起误会。
  3. 相似概念相同数据类型。不同接口中总有一些相似的属性,比如状态、创建修改时间、各种责任人等,一方面对他们的命名要保持一致,另一方面他们的数据类型往往是相同的。比如,时间类的属性,我们要尽量统一格式,比如都是用日期格式的字符串,而不要有的接口用字符串,有的接口用数值类型的时间戳。这里向大家推荐ISO-8601标准的日期时间格式,因为他包含了时区的定义,因此不会有时差问题,而且各类语言对这个标准都有很好的支持。再比如,有一些属性表示“是否”的概念,那么我们可以统一使用布尔值,而不要有的接口使用布尔值,有的接口使用数字0和1。
  4. 善用枚举。各种状态类的字段,是典型的枚举类型,而大部分开发人员都喜欢使用数值枚举,而我认为这里存在着一些隐患。
    使用数值枚举的起源,我觉得我们大概可以猜到,早期的程序开发受内存容量和cpu性能的影响,要尽量减小程序体积与运算规模,如果一个状态有5种取值,那么用3个bit位就能表示了,就算5种状态可以同时存在,5个bit位也就解决了,这在当时内存稀缺的情况下是最佳选择。
    而随着时间推移,摩尔定律持续发挥作用,内存逐渐变大,人们也就不那么小气了,更多的使用整数来表示状态,早期可能是无符号的短整型,可如今在Java中连Short都很少见了。在这样资源富足的前提下,我更加主张用字符串枚举来表示状态类的属性。因为字符串天然的比数值更能表达每种状态的含义,随着程序规模的不断增大,这种见名知意的特性会越来越重要。当然字符串的计算肯定要比数值慢,在一些高并发的对性能要求极高的场景可能还是数值甚至bit来计算更加合适,但是绝大多数情况下,这种性能的损耗是可以忽略不计的。
    除了字符串枚举有见名知意的好处外,数值枚举的隐患我也来说一下。使用数值枚举是极其简单的,简单到我们甚至无需知晓某种状态的含义,就可以轻易的用一个数值来表示并且开发了。而到了开发后期,随着对系统需求的深入了解,可能才会发现一些状态的不合理,甚至是需求错误,这就是我所说的隐含。而如果使用字符串枚举,我们需要给每个状态命名,在命名的过程中我们自然会思考相关需求,也就更容易发现一些需求流程上的隐患。当然,你也得认真负责的去命名才行。

统一格式的问题可能不会影响接口功能,它更多的是我们接口的软实力。如果是你自己来开发接口,以上这些可能很容易就能做到,但如果是多人团队开发,每个人的差异可能会超乎你的想象,如果前期没有做很好的规范要求,那么等生米煮成熟饭时,你就会发现,你的这套接口变成了一个集各种风格于大成的作品。

今天就跟大家聊到这里了,欢迎大家扫描下方二维码,关注我的公众号“就浩这口”,收听这篇文章的语音版解说,并持续关注后续内容。下一期我将继续为大家介绍接口设计的异常处理问题,我们下期再见。

传送门

陈浩 wechat
欢迎扫描上面二维码,关注我的公众号“就浩这口”
感谢您的支持,我会创作更多更好的内容