理解RESTful Api设计

REST

REST(REpresentational State Transfer)是 Roy Fielding 博士于 2000 年在他的博士论文中提出来的一种软件架构风格(一组架构约束条件和原则)。在该论文的 中文译本 中翻译是"表述性状态移交"。

理解RESTful Api设计

原则

  • 网络上的所有事物都被抽象为资源
  • 每个资源都有一个唯一的资源标识符
  • 同一个资源具有多种表现形式(xml,json 等)
  • 对资源的各种操作不会改变资源标识符
  • 所有的操作都是无状态的

资源(Resources)

资源是一种信息实体或者说是一个具体信息,能够被想象出名字。比如多个图书馆,那么便是可使用的图书馆资源,而图书馆内,多个楼层,那么便拥有了多个楼层的资源,各楼层提供了不同服务,那么服务也是资源。在互联网中,可以用一个 URI(统一资源定位符)指向它,每种资源对应一个特定的 URI(如同一本书,按照书页码去定位哪一页,目的是定位资源)。访问这个特定 URI 便获取到了这个对应的资源。

表述(REpresentations)

资源的表述是一段对于资源在某个特定时刻的状态的描述,通过表述捕获资源,并在组件间(客户/服务器)移交该表述。表述有多种格式,如 HTML/XML/JSON/纯文本/图片/视频/音频等。具体的表述格式,可以在 HTTP 请求头信息中用 Accept 和 Content-Type 字段指定,请求/响应方向的表述通常使用不同的格式。

状态移交(State Transfer)

对于组件间而言(客户/服务器),资源的请求是一个互动过程。通过表述捕获资源当前或是预期的状态,相当于获得了资源的状态。通过移交代表资源的表述,来将资源在组件的两者之间进行传递,进而改变应用状态。如当客户端获取了资源后,自身状态处于稳定,当再次获取资源后自身状态再次处于稳定。客户端操作并对服务端发起请求,在资源上执行各种动作而打破资源自身状态,达到客户端操作所期望状态。

RESTful Api

与 REST 相比多了一个 ful,就英语层面来说是一个形容词,RESTful 翻译成中文为“REST 式的”,满足了 REST 架构风格的应用程序设计的 Api 则便是 RESTful Api,即 REST 式的 Api。

以往 Api 设计

在 MVC 项目中,经常都是设计成动宾结构给 ajax 调用

/getCustomers /getCustomersByName /getCustomersByPhone /getNewCustomers /verifyCredit /saveCustomer /updateCustomer /deleteCustomer 

可有时却因为没有统一的规范,多人协作时,对于动词的描述上也没有统一,时长出现了类似如下的各类叫法,不能说这种情况有什么弊端,毕竟这种方式也是正常工作着。

/getCustomers /getAllCustomer /getCustomerList /getPagedCustomer /queryCustomers /queryAllCustomers /queryCustomerList ... 

相比之下,RESTful Api 提供了更为标准化,规范化的 URL 写法。

设计规范

考虑 Api 设计时,URI 中不能有动词,URI 的目的是定位资源,而具体的对资源的操作,是借助 HTTP 的动词完成,与早期 Api 设计相比,本身的思路是不同的,原来更多的是考虑函数式编程或者叫做面向行为的服务建模,比如 RPC,远程调用一个函数,那么 Api 设计便是会考虑为动词名词格式,而对于 REST 风格来讲,是面向资源的服务建模。而对于资源而言,可以是对象、数据或是查询服务。

HTTP 动词

对于一个系统而言,对外提供的功能总体上划分为两类:

  • 获取系统资源,主要包括读取资源和资源描述信息。
  • 对系统资源进行变更,主要包括写入资源,对已有资源状态的变更,删除已有资源。
    理解RESTful Api设计

对于这其中使用到的一些动词,使用 HTTP 的动词描述来承担对资源执行的行为,动词通常使用以下几种。对于 HTTP1.1 规范中的其他几个动词(如 OPTIONS 等)则不再介绍。

  • GET: 获取目标资源。
  • HEAD: 获取(传输)目标资源的元数据信息。该方法与 GET 相同,但是不传递内容体。
  • POST: 创建新资源,对于复杂查询而言,提交查询表单给查询服务也是常用 POST 的(当然其他几个能做的它也能做)。
  • PUT: 替换已有资源(整体)。
  • PATCH: 修改已有资源(局部)。
  • DELETE: 删除资源。

URI

URI 作为统一资源标识符,其本质是标识资源,就像进入图书馆,任一本书都应具有在哪个楼层,哪个区域,哪个书架等等标识信息,来唯一确定这本书,于资源而言,更是如此,对于 URI 的设计,规范是使用名词来定位资源,比如常见的

GET /Api/Users/{id}  

这样便按照 id 值,来唯一定位这一 User
理解RESTful Api设计

对于资源的单复数格式,尽管规范是尽可能使用复数,但并没有说哪条纪律或是约束限制说一定要使用复数,这无需强制约束,按照自身统一即可。毕竟有些不可数名词,没有复数格式,那么还是沿用本身,而对于整体风格为复数下,却又显得格格不入。

面向资源

资源的组织决定着 URI 的展示方式,对于底层数据库而言,也许 Order 模型有若干张表来支撑存储,对外总体是提供着 Order 服务。这样一来,如果按照底层数据库表来考虑 Api 设计,则会陷入无尽的关系处理中,比如 Order 下有 OrderItem,OrderItem 下有 OrderItemAttachment,如果按照这个思路去实现 Api 设计时,那么 URI 的设计上则会存在多级情况。

POST /Api/Orders POST /Api/Orders/{id}/OrderItems POST /Api/Orders/{id}/OrderItems/{itemId}/OrderItemAttachments POST /Api/Orders/{id}/OrderItems/{itemId}/OrderItemAttachments/{}/... ... 

于数据库而言,表与表间构成了一张庞大的网,有时还不好找到定位资源的入口
理解RESTful Api设计

如果按照单表进行 URI 设计,那么则成了面向表服务建模,这又造成了底层的服务细节统统对外暴露,因此需要避免创建仅反映数据库内部结构的 API。

在领域驱动设计中,聚合这一概念,将具有强相关的实体和值对象纳入到一起,形成独立空间、业务逻辑内聚于聚合之中,同生共死。面向聚合进行 Api 设计,多级路由的嵌套结构缓和许多,如需求上考虑 Order 创建时一定需要有 OrderItem 的存在,那么则对于这两者而言是捆绑的关系,而对于 OrderItemAttachment 而言,不是必要的。

理解RESTful Api设计

那么则可以独立设计聚合(此处忽略底层数据库中表是如何设计的,仅考虑聚合),URI 的设计也围绕着聚合这一资源来进行,这样一来,URI 的设计便成了如下结构

POST /Api/Orders  {     "locationId": 1,     "productIds": [         1,         2,         3     ] }  POST /Api/Orders/{id}/OrderItems  {     "productIds": [         4,         5,         6     ] }  POST /Api/OrderItemAttachments  {     "orderItemId": 1,     "fileUrl": "xxx" } 

嵌套层级结构不会太深,因为太深的层级结构往往也意味着这个聚合的设计或许存在一点问题。

约束设计

对于 Post、Put、Patch 和 Delete 这些操作来讲,面向聚合设计 URI 基本可以有路可循。

比如以下一些常见的 URI

POST /Api/Orders POST /Api/Orders/{id}/OrderItems POST /Api/OrderItemAttachment PUT /Api/Orders/{id} PUT /Api/Orders/{id}/OrderItems/{itemId} PUT /Api/OrderItemAttachments/{id} PATCH /Api/Orders/{id}/Address PATCH /Api/Orders/{id}/OrderItems/{id}/Amount PATCH /Api/OrderItemAttachments/{id}/FileUrl DELETE /Api/Orders/{id} DELETE /Api/Orders/Batches DELETE /Api/Orders/{id}/OrderItems/{id} DELETE /Api/OrderItemAttachments/{id} POST /Api/Invites/emailTemplate  PATCH /Api/Invites/{id}/Sendmail //Sendmail 作为邮件服务资源 PATCH /Api/Notifications/{id}/MessageStatus PATCH /Api/Notifications/MessageStatus/batches PATCH /Api/Orders/{id}/OrderItem/{itemId}/PayStatus  POST /Api/Orders/exports //返回导出资源 POST /Api/exportServices //提交给导出服务资源 POST /Api/exportServices/Sendmail POST /Api/InviteParseServices //提交给解析服务资源  ... 

当然也有一些夹杂着动词,习以为常的 Api 设计,如果习惯了,不想改变,仍然可以使用着动词(后续提到该部分违反约束),但若想改变,就得换个思路去考虑设计了

POST /Api/Account/Login POST /Api/Account/Logout POST /Api/Account/Register 

比如,Login/Logout 操作的目标资源是什么?如果把登录的用户当作在系统中存储的资源来看便可以认为已上线的用户信息,取个资源名字,在线用户(onlineUser),然后对其执行行为。
而对于 Register 来讲,则更是容易转换了,注册本身是对 Account 的操作行为,其本质是创建一个没有过的用户。那么直接去掉注册即可了,如认可改变可以按照如下设计,如仍习惯现有,则不改即可,并没有什么约束、纪律限制说一定要遵循。

POST /Api/Accounts POST /Api/OnlineUsers //如下需要结合 Authorization,不直接在 URI 中传递参数 DELETE /Api/OnlineUsers 

主要是对于查询类的操作,设计起来复杂一些,无论是实际开发中还是按照二八原则,大部分操作都是查询操作,并且查询起来天马行空。

先是以下简单的查询

GET /Api/Orders GET /Api/Orders/{id} GET /Api/Orders/{id}/OrderItems GET /Api/Orders/{id}/OrderItems/{id}  // 筛选 GET /Api/Orders?Name=xxx&LocationId=xxx // 分页 GET /Api/Orders?Page=1&Limit=10 // 也可以拆分成如下两个此处资源为 Page GET /Api/Orders/Page?Page=1&Limit=10 GET /Api/Orders/PageCount?Page=1&Limit=10 // 排序 GET /Api/Orders?Sort=Name%20DESC GET /Api/Orders?Sort=Name%20DESC,CreationTime%20ASC 

然后再为一些常见场景下的(对于查询类的,聚合的边界应消失了,更多的应该是将各种资源串联起来)

// UI 上需要知道某个资源是否存在 GET /Api/Orders?name=xxx HEAD /Api/Orders?name=xxx 能够查询到状态码返回 204 找不到状态码返回 404  // 文件下载 GET /Api/OrderFiles/{id}/Url  // 报表分析(将报表分析的结果作为虚拟资源) GET /Api/AnalyseResults  // 返回指定条件下的总数 GET /Api/Locations/{id}/OrderCount?Status[]&Status[]=2&CreationTime=2022-05-01  // UI 上下拉框所需要的基础数据 GET /Api/Locations/Names?page=1&limit=30&search=xxx {   "id": "xxx",   "name": "xxx" }  // 获取最近的循环周期 GET /Api/Plans/{id}/LatestCycleDate  // 获取最近的记录(根据时间,状态过滤后的第一条) GET /Api/Orders/Latest  ... 

实际使用中,算了算也只有百分之八十左右的接口是按照 RESTful Api 的规范使用着的,总是有些接口,不能或是难以用简单的描述就能解决。比如如下几个接口,我便直接违反着约束(不能有动词,只能使用名词)。

PATCH /Api/Invites/{id}/Approval PATCH /Api/Invites/{id}/Decline PATCH /Api/Invites/{id}/Reject ... 

Github中也还是有动词描述
https://docs.github.com/en/rest/codespaces/codespaces#start-a-codespace-for-the-authenticated-user

https://docs.github.com/en/rest/codespaces/codespaces#stop-a-codespace-for-the-authenticated-user

https://docs.github.com/en/rest/checks/runs#rerequest-a-check-run

https://docs.github.com/en/rest/checks/suites#rerequest-a-check-suite

如果按照这几个约束条件来看的话,仅当满足三个约束条件的才能认为是 RESTful Api,而满足一个或是两个约束条件的为 Http Api,那么我们或许是一直在追随 RESTful Api 的路上了。

理解RESTful Api设计

面对这部分难以描述或是无法组织的接口,个人认为直接违反一些约束即可,总归是只有少部分接口仅满足一个到两个约束。

状态码

HTTP 中使用状态码来表示着请求的成功与否,我们可以直接使用它,而无需在返回值中再包裹一层 code/message,尽管在 mvc 中,我也很喜欢这么做。

{   "code":200,   "message":"",   "data":{        } } 

对 HTTP 的状态码接触越多后,越发觉得思路偏了,不应该将请求响应的状态码与业务中行为的成功与否进行隔离开,因为 HTTP 本身是应用层协议(超文本移交协议),是为业务服务的。如何在网络层面上把一个请求发送出去,再接收到响应,这是 TCP 协议来保障的。假设网络层如果请求失败了,那么应用层都无法进行,因此结合状态码与返回内容(当出现异常时仍然返回状态码与错误描述信息)。
如下 HTTP 的状态码覆盖了绝大部分场景。当客户端需要追踪问题时,查看对应请求的状态码,结合其对应的解释说明,便可以去定位相关的问题,当然,前提是真的返回了符合场景下的状态码。

  1. Informational responses (100199)
  2. Successful responses (200299)
  3. Redirection messages (300399)
  4. Client error responses (400499)
  5. Server error responses (500599)

在 Api 中,100 阶段的状态码不会涉及,具体的各响应码参见如下图

理解RESTful Api设计

版本号

对外提供的资源服务地址需要存在版本控制,以便于客户端应用能够访问到对应的资源,版本号的规划有如下几种方式,具体使用哪种得依靠具体的情况而分析:

  1. 不考虑版本,内部使用、短暂的生命周期下不考虑资源的变更或是直接对资源本身进行了换新如此变更到新的 url 上。
  2. 为每个资源的 URI 添加一个版本号。
GET /Api/v2/Orders/{id} 
  1. 作为查询字符串参数来指定资源的版本
GET /Api/Orders/{id}?version=2 
  1. 在 http 的 header 中增加自定标头设置版本号。
GET /Api/Orders/{id} Custom-Header: version=2 

成熟度模型

2008 年,Leonard Richardson 为 Web API 提出了以下 成熟度模型

  • Level 0: 定义一个 URI,所有操作是对此 URI 发出的 POST 请求。
  • Level 1: 为各个资源单独创建 URI。
  • Level 2: 使用 HTTP 方法来定义对资源执行的操作。
  • Level 3: 使用超媒体(HATEOAS: Hypermedia As The Engine Of Application State,参见 HATEOAS - Wikipedia )。
    诚然,对于这个成熟度模型,我一般都只会去达到前三个级别,虽然 Roy Fielding明确表示 ,Level 3 才是真正的 RESTful Api,对于 Level 3 级别,其实并没有理解到其具体奥妙。因为我们面对的是 UI,用 UI 去链接操作,那么对于 Level 3 返回的超媒体而言,又如何表现呢?

参考文档

2022-05-24,望技术有成后能回来看见自己的脚步

发表评论

相关文章