RESTful风格的接口设计

Posted by Hsz on March 14, 2019

RESTful风格的接口设计

RESTful架构是目前最流行的一种互联网软件架构.它结构清晰,语义化,易于理解,扩展性好,所以国外知名的网站都早已采用,比如:Github,Google,Facebook,Twitter等,国内也越来越多的公司在做着这方面的尝试,但可能是由于国内环境的问题,不少所谓的RESTful风格的接口其实都是不得精髓的劣化版实现.写这篇文章就是为了避免写的RESTful风格接口也闹这种李逵变李鬼的笑话.

丑话说前面

首先广义上的RESTful是一个并没有严格规范化的东西,它是一个松散的API设计风格协议.因此本文为个人理解,如果有不同意的也不一定会进行修改.

为什么会有RESTful风格

REST这个词是Roy Thomas Fielding在他2000年的博士论文’Architectural Styles and the Design of Network-based Software Architectures‘中提出的

其中有一段话解释了这篇文章的初衷

本文研究计算机科学两大前沿–软件和网络–的交叉点.长期以来,软件研究主要关注软件设计的分类,设计方法的演化,很少客观地评估不同的设计选择对系统行为的影响.而相反地网络研究主要关注系统之间通信行为的细节,如何改进特定通信机制的表现,常常忽视了一个事实那就是改变应用程序的互动风格比改变互动协议对整体表现有更大的影响.我这篇文章的写作目的就是想在符合架构原理的前提下理解和评估以网络为基础的应用软件的架构设计,得到一个功能强,性能好,适宜通信的架构.

This dissertation explores a junction on the frontiers of two research disciplines in computer science: software and networking. Software research has long been concerned with the categorization of software designs and the development of design methodologies, but has rarely been able to objectively evaluate the impact of various design choices on system behavior. Networking research, in contrast, is focused on the details of generic communication behavior between systems and improving the performance of particular communication techniques, often ignoring the fact that changing the interaction style of an application can have more impact on performance than the communication protocols used for that interaction. My work is motivated by the desire to understand and evaluate the architectural design of network-based application software through principled use of architectural constraints, thereby obtaining the functional, performance, and social properties desired of an architecture.

简而言之,他希望将网络服务软件化.让接口有一个规范性接口风格约束,从而让服务之间可以互动.

如何理解RESTful

首先RESTful只是一种接口风格规范,可以拿python下的pep8做类比.它只是规范,他不涉及具体实现,也没有对错一说.

RESTRepresentational State Transfer(表现层状态转化)的缩写.

要理解RESTful架构,最好的方法就是去理解Representational State Transfer这个词组到底是什么意思,它的每一个词代表了什么涵义.如果你把这个名称搞懂了,也就不难体会REST是一种什么样的设计.

下面将解释RESTful语境下的几个名词,以方便后续讨论.

  • 资源

REST的名称’表现层状态转化’中省略了主语.”表现层”其实指的是”资源”(Resources)的”表现层”. 所谓”资源”就是一个信息实体.它可以是一段文本,一张图片,一首歌曲,一种服务,总之就是一个具体的实在的东西.在RESTful架构下,所有的操作都是围绕资源来的.

类比来说资源有点像面向对象编程范式下的对象.

  • 表现层

“资源”是一种信息实体,它可以有多种外在表现形式(描述形式).我们把”资源”具体呈现出来的形式叫做它的”表现层”(Representation)

比如描述一个资源可以用XML格式,JSON格式,protobuff甚至可以直接采用二进制格式.类比来说同一内容的图片可以是png格式,可以是gif格式也可以是jpg格式.他们用什么格式表现都不影响其内容.

  • 状态转化

资源往往不是静止不动的,它是一个有内部状态的实体,如何改变其内部状态也是需要规范的内容.还是那面相对象编程做对比,如果实例没有方法且属性都是只读的话那就不存在状态转化了,但只要属性并非只读,其内部的状态就可以通过方法或者改变字段中的值来改变.

约束

REST是作为互联网自身架构的抽象而出现的,其关键在于所定义的架构上的各种约束.只有满足这些约束才能称之为符合REST架构风格.REST的约束包括:

  • 客户端-服务器结构

    通过一个统一的接口来分开客户端和服务器,使得两者可以独立开发和演化.客户端的实现可以简化,而服务器可以更容易的满足可伸缩性的要求.

  • 无状态

    在不同的客户端请求之间,服务器并不保存客户端相关的上下文状态信息.任何客户端发出的每个请求都包含了服务器处理该请求所需的全部信息.

  • 可缓存

    客户端可以缓存服务器返回的响应结果.服务器可以定义响应结果的缓存设置.

  • 分层的系统

    在分层的系统中,可能有中间服务器来处理安全策略和缓存等相关问题以提高系统的可伸缩性.客户端并不需要了解中间的这些层次的细节.

  • 按需代码(可选)

    服务器可以通过传输可执行代码的方式来扩展或自定义客户端的行为.这是一个可选的约束.

  • 统一接口

    该约束是REST服务的基础,是客户端和服务器之间的桥梁.该约束又包含下面4个子约束.

    • 资源标识符

      每个资源都有各自的标识符.客户端在请求时需要指定该标识符.

    • 通过资源的表达来操纵资源

      客户端根据所得到的资源的表达中包含的信息来了解如何操纵资源,比如对资源进行修改或删除.

    • 自描述的消息

      每条消息都包含足够的信息来描述如何处理该消息.

    • 超媒体作为应用状态的引擎(HATEOAS)

      客户端通过服务器提供的超媒体内容中动态提供的动作来进行状态转换.对于不使用HATEOAS的REST服务,客户端和服务器的实现之间是紧密耦合的.客户端需要根据服务器提供的相关文档来了解所暴露的资源和对应的操作.当服务器发生了变化时,如修改了资源的URI,客户端也需要进行相应的修改.而使HATEOAS的REST服务中,客户端可以通过服务器提供的资源的表达来智能地发现可以执行的操作.当服务器发生了变化时,客户端并不需要做出修改,因为资源的URI和其他信息都是动态发现的.

使用HTTP协议实现RESTful

狭义上讲的RESTful接口指的是使用HTTP/HTTPS(最好是使用HTTPS)来实现的RESTful风格的接口服务.

其特点是:

  1. Web服务只是使用HTTP作为传输方式
  2. Web服务引入了资源的概念,使用路径(Endpoint)作为标识符指代资源集合下的特定资源
  3. Web服务使用json作为表现层协议
  4. Web服务使用不同的HTTP方法来进行不同的操作,并且使用HTTP状态码来表示不同的结果.
  5. Web服务使用HATEOAS.在资源的表达中包含了链接信息.客户端可以根据链接来发现可以执行的动作.

Richardson提出了REST成熟度模型,该模型把REST服务按照成熟度划分成4个层次:

  • Level 0 – 满足上面第一点,本质上只是一种RPC(远程方法调用)而已,XML-RPC,SOAP,JSON-RPC,包括Grpc都属于此类.
  • Level 1 – 进一步满足上面的2,3两点.
  • Level 2 – 进一步满足上面的第4点.这也是多数号称RESTful的接口停留的层次.
  • Level 3 – 进一步满足第5点.

使用Endpoint来定位资源

路径描述资源实体:

https://api.example.com/animal/1

通常路径的的描述顺序代表一个包含关系,如上例中资源animal表示一个动物资源的整体资源,animal/1则表示动物资源下index为1的动物实例资源.

个人对资源的分类

可以看出资源和资源也是不一样的,用户总体可以是一个资源,单一一个用户也是一个资源,他们之间是从属关系.我个人将这种表示总体的资源称为容器资源.

描述动词

RESTful是操作资源的协议,在其语境下用户只能对资源做增删改查,因此是名词性的,很多时候我们可以当它是数据库的一层抽象.因此我们的URI中只会有名词不会有动词.那如果遇到动词怎么办呢?

例: 我们希望有一个RESTful的接口可以为我们实现计算加法的功能

一般来说这种工作更适合rpc做,但RESTful也不是做不了,只是路劲中不能有动词,那我们完全可以将动词转换为名词,参考python中的operator模块我们定义一个资源operator就是了.

GET https://api.example.com/operator/add?params=1,2

这个例子中我们将做加法这个动词转换成了加法这一个名词,而将它挂在了父资源operator下.

面相特定资源的描述动词

例: 我们希望用户资源下可以有follow操作,它可以关注感兴趣的其他用户.

这个例子就很业务,一个社交网站总会有这样的需求,比如github.那该如何实现呢?

我们将这个follow操作转换为两个用户下的子资源

  • follower描述这个用户被其他哪些用户关注

      Get https://api.example.com/user/1/follower
      Put https://api.example.com/user/1/follower
      Del https://api.example.com/user/1/follower
    
  • followed描述这个用户关注了哪些别的用户

      Get https://api.example.com/user/1/followed
      Put https://api.example.com/user/1/followed
      Del https://api.example.com/user/1/followed
    

之后业务上要实现follow这个操作只要修改这两个资源的状态即可.

那一般点击关注是一个原子操作,A关注了B,那A的关注列表里就应该多出B,B的被关注列表中也该多出A,另一种解决方式是和上面的加法一样将这种动作作为服务资源.

```shell
GET https://api.example.com/user/operator/follow?follower=A&followed=B
```

API的版本管理

版本控制本来就是一件糟心的事儿,在RESTful语境下,常见的版本控制方法有两种:

  1. 在请求和响应的HTTP头信息的Accept字段中带上版本信息.

    这种方式的好处是颗粒度细,支持渐进式的服务更新,但缺点也很明显,调用方通常不关心版本,他们关心的是调用服务的稳定性和功能是否按预期实现.服务版本往往也是在接口变动的情况下才会有大版本的更新,因此这种方式我几乎从没见过.但在返回结果的头部加上版本信息也确实是一个比较好的实现,这样有利于收集bug.

     Accept: vnd.example-com.foo+json; version=1.0
    
  2. 在uri的第一段使用版本号.

    这种方式相当于将不同版本的服务也作为一种资源来看待.我看到多数都是这样实现的,比如github.这种方式的好处就是不同的接口可以严格区分,而且维护相对更加简单

     https://api.example.com/v1/user
    

使用HTTP动词定义状态转化

RESTful使用http的默认方法定义状态转化的方法,具体来讲

http方法 说明
GET 从服务器取出资源(一项或多项)
POST 在服务器新建一个资源
PUT 在服务器更新资源(客户端提供改变后的完整资源)
PATCH 在服务器更新资源(客户端提供改变的属性和属性对应的值)
DELETE 从服务器删除资源
HEAD 获取资源的元数据。
OPTIONS 获取信息,关于资源的哪些属性是客户端可以改变的。

在http中实现表现层

表现层本身只是一个协议,服务端和客户端相当于做了一个编码解码的工作,以或许需要的信息.用http实现表现层,我们可以用的东西有:

  • http头信息
  • http的body信息
  • http状态码
  • url参数

使用json作为表现层协议

RESTful发展至今,将json放在http请求的body中作为表现层协议已经是一个默认选项,但我们还是要来在http头部将其申明出来,毕竟默认不代表严格限制.

Accept: application/json
Content-Type: application/json

从容器资源中搜索

通常我们要求容器资源的Get方法有搜索能力,比如https://api.example.com/v1/user要有可以通过url参数搜索出一个或多个https://api.example.com/v1/user/:id的能力,下面是一些常见的参数.

参数 说明
?limit=10 指定返回记录的数量
?offset=10 指定返回记录的开始位置。
?page=2&per_page=100 指定第几页,以及每页的记录数。
?sortby=name&order=asc 指定返回结果按照哪个属性排序,以及排序顺序。
?animal_type_id=1 指定筛选条件

使用HTTP状态码定义异常

接口的异常在表现层中往往没有明确的规范,下面我们来看看几种常见的:

  • Github (use http status)

      {
      "message": "Validation Failed",
      "errors": [
          {
          "resource": "Issue",
          "field": "title",
          "code": "missing_field"
          }
      ]
      }
    
  • Google (use http status)

      {
          "error": {
              "errors": [
              {
                  "domain": "global",
                  "reason": "insufficientFilePermissions",
                  "message": "The user does not have sufficient permissions for file {fileId}."
              }
              ],
              "code": 403,
              "message": "The user does not have sufficient permissions for file {fileId}."
          }
      }
    
  • Facebook (use http status)

      {
          "error": {
              "message": "Message describing the error", 
              "type": "OAuthException",
              "code": 190,
              "error_subcode": 460,
              "error_user_title": "A title",
              "error_user_msg": "A message",
              "fbtrace_id": "EJplcsCHuLu"
          }
      }
    
  • Twitter (use http status)

      {
          "errors": [
              {
              "message": "Sorry, that page does not exist",
              "code": 34
              }
          ]
      }
    
  • Twilio (use http status)

      {
          "code": 21211,
          "message": "The 'To' number 5551234567 is not a valid phone number.",
          "more_info": "https://www.twilio.com/docs/errors/21211",
          "status": 400
      }
    

观察这些结构可以发现它们都有一些共同的地方:

  1. 都利用了Http状态码
  2. 有些返回了业务错误码
  3. 都提供了给用户看的错误提示信息
  4. 有些提供了给开发者看的错误信息

那可以总结出RESTful风格接口对于错误的处理基本原则是复用http状态码和用户优先.而方便开发者的业务错误码其实并不是关键.

这里是全部的http状态码而这些错误类型中,我们最常用的是:

  • 400 Bad Request

    由于包含语法错误,当前请求无法被服务器理解.除非进行修改否则客户端不应该重复提交这个请求.通常在请求参数不合法或格式错误的时候可以返回这个状态码.

  • 401 Unauthorized 当前请求需要用户验证.通常在没有登录的状态下访问一些受保护的API时会用到这个状态码.

  • 403 Forbidden

    服务器已经理解请求,但是拒绝执行它.与401响应不同的是身份验证并不能提供任何帮助.通常在没有权限操作资源时(如修改/删除一个不属于该用户的资源时)会用到这个状态码.

  • 404 Not Found

    请求失败,请求所希望得到的资源未被在服务器上发现.通常在找不到资源时返回这个状态码.

尽管我们可以通过Http状态码来表示错误的类型,但在实际应用中如果仅仅使用Http状态码的话,我们会发现其颗粒度过大,这并不能很好的提示用户应该后续如何操作.我们就需要对错误进行细化,并告知应该如何排查和处理.这种时候我们就会知道如果错误是一种类型就好了,类型本身是信息,还可以已定义其message,甚至可以追踪其错误栈.

我觉得在实际项目中可以参考sanic的异常设计为每个异常定义一个类型.他们可以有继承关系,而一旦出现异常,则可以将异常类型的名字和异常信息都作为返回值传出去.这样可读性更好而且也便于修复问题.

使用超媒体(Hypermedia)描述资源间的关联

按照Richardson提出了REST成熟度模型,这是RESTful实现的最高等级,然而多数企业并没有按这个做,同时即便有做也并没有起到什么根本的变化,更不用说什么最佳实践了.

首先解释下什么是超媒体,所谓超媒体可以简单理解为超链接.比如我们的网页可以通过a标签跳转到相关的页面.超媒体的好处是赋予了资源描述关系的能力.但具体是什么关系却又需要其他规范来支持.这确实可以带来一种编写客户端时的新思路–让客户端根据超媒体信息自己寻找需要的资源.但彻底的这么做要付出的代价也是巨大的

  1. 更多的代码量

    毫无疑问这会加重客户端和服务端两头的工作量.在没有约定的情况下客户端恐怕连如何抽取需要的信息都成问题.而如果借助一些基于语义网技术的工具如JSON-LD,JSONAPI这样的技术,那接口数据将变得相当繁杂,这一样会增加代码量而且可读性会很差.

  2. 更多的数据交互

    由于要让客户端自己寻找需要的数据,那也就意味着客户端无法一次找到想要的数据,而且查找的过程中每次的跳转都是一次数据通信.复杂的话甚至需要客户端在本地建立和同步一张资源间的关系图,通过先在本地遍历找到要用的资源,然后再请求的方式才能减少这种交互.

这就像工人只是要一把锤子,但供应商却认为应该把锤子的制作方法一起告诉工人一样.

但是HATEOAS是不是就完全不可取呢?也不是,毕竟它的表现力更好,描述资源可以多一层关系的维度.就像关系数据库中的外键一样,是很好的补充.

例: 重新构建用户关注清单

上面的例子中我们已经构建过一回用户关注关系,它是从动作这个角度,这次我们使用Hypermedia重新设计这个实现

我们可以定义两个资源:

  • 用户
https://api.example.com/v1/user/1

response:

{
    ...,
    "follower":{
        "source": "https://api.example.com/v1/user-relation/follow",
        "args":{
            "followed-id": 1
        }
    },
    "followed":{
        "source": "https://api.example.com/v1/user-relation/follow",
        "args":{
            "follower-id": 1
        }
    }
}
  • 用户关系

在用户关系资源容器一层提供一个搜索的方法:

Get https://api.example.com/v1/user-relation/follow?followed-id=xx&follower-id=yy

然后每个follow关系像下面这样表示

https://api.example.com/v1/user-relation/follow/:id

response:

{
    ...
    "source": "https://api.example.com/v1/user",
    "follower-id": 1,
    "followed-id": 2,
}

这样我们的接口中利用关系就实现了关注清单,如果一个用户关注了另一个用户,那只要在用户关系资源中加一条就好,用户间的关系就和用户本身解耦了.而像关注列表这种信息也会延后获取.这相当于给出了搜索方法而非搜索结果.