教你更优雅地写 API 之「路由设计」

RESTful API = Http Method(动词,描述资源操作类型) + URI(名词+属性,描述资源的层级和位置)

写在前面

上篇分享了最近在梳理出如何去「入门」Lumen 开发 API 项目,算是列出了一个清单,简短介绍了「如何统一 API 中的响应结构」,内容相对较少。结合最近在思考「如何使用 Lumen 来合理地架构 API项目,从而提升自身的开发体验」的过程中,发现蛮多不错的文章,想逐一分享讨论一下。

因为内容本身是一些规范约束性的理论,或许不会短时间内就能对日常开发工作有明显的促进作用,生搬硬套一些规则,为了使用而使用,可能反而会给自己的开发过程造成约束,影响效率。

所以,不妨各抒己见,来讨论一番。

特别说明

文章主体内容摘选自:RESTful服务最佳实践,侵删。

REST 是什么?

表现层状态转换(英语:Representational State Transfer,缩写:REST)是Roy Thomas Fielding博士于2000年在他的博士论文[1]中提出来的一种万维网软件架构风格,目的是便于不同软件/程序在网络(例如互联网)中互相传递信息。表现层状态转换是根基于超文本传输协议(HTTP)之上而确定的一组约束和属性,是一种设计提供万维网络服务的软件构建风格。 ——来源于自由的 WIKI 百科『表现层状态转换

Tips:

由此可见,REST 只是一种「软件架构风格」,不是多么玄乎的东西,设计出来的目的是为了方便应用程序之间互相传递信息。通常说的 RESTful API 就是表明应用系统中 API 的架构设计符合 REST 规范,遵守这种规范某种程度上可以说明应用系统的架构设计优秀。

近些年实际上出现了另外一种 API 设计风格 GraphQL 已经趋于成熟,各种编程语言的支持逐渐出现,也可以感受下『为什么 GraphQL 是 API 的未来』(规范的成熟不等同于实际项目中就可以直接落地使用,技术选型前要有自己的判断,预估一下未来能够投入的时间和人力成本,不要受网文推广的影响)

使用HTTP动词表示一些含义

任何API的使用者能够发送GET、POST、PUT和DELETE请求,它们很大程度明确了所给请求的目的。

同时,GET请求不能改变任何潜在的资源数据。测量和跟踪仍可能发生,但只会更新数据而不会更新由URI标识的资源数据。

合理的资源名

合理的资源名称或者路径(如/posts/23而不是/api?type=posts&id=23)可以更明确一个请求的目的。

使用URL查询串来过滤数据是很好的方式,但不应该用于定位资源名称。

适当的资源名称为服务端请求提供上下文,增加服务端API的可理解性。

通过URI名称分层地查看资源,可以给使用者提供一个友好的、容易理解的资源层次,以在他们的应用程序上应用。

资源名称应该是名词,避免为动词。使用HTTP方法来指定请求的动作部分,能让事情更加的清晰。

Tips:相关名词解释和理解

URL:统一资源定位符(英语:Uniform Resource Locator,缩写:URL;或称统一资源定位器、定位地址、URL地址[1],俗称网页地址或简称网址)是因特网上标准的资源的地址(Address),如同在网络上的门牌。——来自维基百科

URI:统一资源标识符(英语:Uniform Resource Identifier,缩写:URI)——来自维基百科

应用到 RESTful API 的路由设计中:

API URL = Http Method(动词,描述对资源操作的类型 CRUD) + URI(Uniform Resource Identifier)(可以类比文件路径,体现资源层级以及描述资源位置)

也就是在 API 的 URL 应该是用来描述去哪个位置找到资源,然后通过 Http Method 描述对资源进行怎样的操作,这样路由设计就清晰了

至于URI如何定义,你可以类比平时是如何在磁盘中进行分类管理文件的,或许就思路清晰了。

相关定义

我们一起简单过一下与 REST 有关的定义。

幂等性

下面是来自维基百科的解释:

在计算机科学中,术语幂等用于更全面地描述一个操作,一次或多次执行该操作产生的结果是一致的。根据应用的上下文,这可能有不同的含义。例如,在方法或者子例程调用具有副作用的情况下,意味着在第一调用之后被修改的状态也保持不变。

从 REST 服务端的角度来看,由于操作(或服务端调用)是幂等的,客户端可以用重复的调用而产生相同的结果。注意,当幂等操作在服务器上产生相同的结果(副作用),响应本身可能是不同的(例如在多个请求之间,资源的状态可能会改变)。

PUT 和 DELETE方 法被定义为是幂等的。GET、HEAD、OPTIO 和 TRACE 方法自从被定义为安全的方法后,也被定义为幂等的。

安全

来自维基百科:

一些方法(例如GET、HEAD、OPTIONS和TRACE)被定义为安全的方法,这意味着它们仅被用于信息检索,而不能更改服务器的状态。换句话说,它们不会有副作用,除了相对来说无害的影响如日志、缓存、横幅广告或计数服务等。任意的GET请求,不考虑应用状态的上下文,都被认为是安全的。

总之,安全意味着调用的方法不会引起副作用。因此,客户端可以反复使用安全的请求而不用担心对服务端产生任何副作用。这意味着服务端必须遵守GET、HEAD、OPTIONS和TRACE操作的安全定义。否则,除了对客户端产生混淆外,它还会导致Web缓存,搜索引擎以及其它自动代理的问题——这将在服务器上产生意想不到的后果。

根据定义,安全操作是幂等的,因为它们在服务器上产生相同的结果。

安全的方法被实现为只读操作。然而,安全并不意味着服务器必须每次都返回相同的响应。

Http 动词/方法

Http动词主要遵循“统一接口”规则,并提供给我们对应的基于名词的资源的动作。

最主要或者最常用的http动词(或者称之为方法,这样称呼可能更恰当些)有POST、GET、PUT和DELETE。这些分别对应于创建、读取、更新和删除(CRUD)操作。

也有许多其它的动词,但是使用频率比较低。在这些使用较少的方法中,OPTIONS和HEAD往往使用得更多。

GET

HTTP的GET方法用于检索(或读取)资源的数据。

在正确的请求路径下,GET方法会返回一个xml或者json格式的数据,以及一个200的HTTP响应代码(表示正确返回结果)。在错误情况下,它通常返回404(不存在)或400(错误的请求)。

例如:

GET http://www.example.com/customers/12345
GET http://www.example.com/customers/12345/orders
GET http://www.example.com/buckets/sample

按照HTTP的设计规范,GET(以及附带的HEAD)请求仅用于读取数据而不改变数据。因此,这种使用方式被认为是安全的。

也就是说,它们的调用没有数据修改或污染的风险——调用1次和调用10次或者没有被调用的效果一样。

此外,GET(以及HEAD)是幂等的,这意味着使用多个相同的请求与使用单个的请求最终都拥有相同的结果。

不要通过GET暴露不安全的操作——它应该永远都不能修改服务器上的任何资源。

PUT

PUT通常被用于更新资源。

通过PUT请求一个已知的资源URI时,需要在请求的body中包含对原始资源的更新数据。

不过,在资源ID是由客户端而非服务端提供的情况下,PUT同样可以被用来创建资源。换句话说,如果PUT请求的URI中包含的资源ID值在服务器上不存在,则用于创建资源。同时请求的body中必须包含要创建的资源的数据。有人觉得这会产生歧义,所以除非真的需要,使用这种方法来创建资源应该被慎用。

或者我们也可以在body中提供由客户端定义的资源ID然后使用POST来创建新的资源——假设请求的URI中不包含要创建的资源ID(参见下面POST的部分)。

例如:

PUT http://www.example.com/customers/12345 
PUT http://www.example.com/customers/12345/orders/98765
PUT http://www.example.com/buckets/secret_stuff

当使用PUT操作更新成功时,会返回200(或者返回204,表示返回的body中不包含任何内容)。如果使用PUT请求创建资源,成功返回的HTTP状态码是201。

响应的body是可选的——如果提供的话将会消耗更多的带宽。在创建资源时没有必要通过头部的位置返回链接,因为客户端已经设置了资源ID。

PUT不是一个安全的操作,因为它会修改(或创建)服务器上的状态,但它是幂等的。换句话说,如果你使用PUT创建或者更新资源,然后重复调用,资源仍然存在并且状态不会发生变化。

但是,如果在资源增量计数器中调用PUT,那么这个调用方法就不再是幂等的。这种情况有时候会发生,且可能足以证明它是非幂等性的。不过,建议保持PUT请求的幂等性。并强烈建议非幂等性的请求使用POST

Tips:为什么 PUT 是幂等?

比如,你第一次请求更新订单状态为配送中,第二次请求如果不加校验,让请求处理成功,订单也是被更新成了配送中的状态。两次请求得到的结果相同,都是将订单更新成了配送中的状态。(要理解结果相同和响应不一定相同这一点,多次请求对资源造成的结果相同就被定义成幂等)

POST

POST请求经常被用于创建新的资源,特别是被用来创建从属资源。从属资源即归属于其它资源(如父资源)的资源。换句话说,当创建一个新资源时,POST请求发送给父资源,服务端负责将新资源与父资源进行关联,并分配一个ID(新资源的URI),等等。

例如:

POST <http://www.example.com/customers
POST <http://www.example.com/customers/12345/orders

当创建成功时,返回HTTP状态码201,并附带一个位置头(Location:xxx)信息,其中带有指向最先创建的资源的链接。

POST请求既不是安全的又不是幂等的,因此它被定义为非幂等性资源请求。

使用两个相同的POST请求很可能会导致创建两个包含相同信息的资源。

Tips:非幂等操作在实际项目中需要考虑的点

在实际项目开发中遇到这种请求需要考虑并发情况,解决思路参考:前端增加校验,比如创建按钮禁用,不允许短时间内连续操作,必须等待后端返回成功后才能继续下一次创建操作;后端增加「业务锁」处理前端发送过来的请求前加锁,等业务处理完以后释放锁。

PUT和POST的创建比较

总之,我们建议使用POST来创建资源。当由客户端来决定新资源具有哪些URI(通过资源名称或ID)时,使用PUT:即如果客户端知道URI(或资源ID)是什么,则对该URI使用PUT请求。否则,当由服务器或服务端来决定创建的资源的URI时则使用POST请求。换句话说,当客户端在创建之前不知道(或无法知道)结果的URI时,使用POST请求来创建新的资源。

Tips:

可以简单点约定,获取/查询资源使用 GET;更新整个资源(相当于替换)使用PUT;更新资源部分的内容使用 PATCH;删除资源使用 DELETE;创建资源使用 POST,以及非幂等性的请求使用 POST(比如更新资源内部的计数器等)。

DELETE

DELETE很容易理解。它被用来根据URI标识删除资源。

例如:

DELETE <http://www.example.com/customers/12345
DELETE <http://www.example.com/customers/12345/orders
DELETE <http://www.example.com/buckets/sample

当删除成功时,返回HTTP状态码200(表示正确),同时会附带一个响应体body,body中可能包含了删除项的数据(这会占用一些网络带宽),或者封装的响应(参见下面的返回值)。也可以返回HTTP状态码204(表示无内容)表示没有响应体。总之,可以返回状态码204表示没有响应体,或者返回状态码200同时附带JSON风格的响应体。

根据HTTP规范,DELETE操作是幂等的。如果你对一个资源进行DELETE操作,资源就被移除了。在资源上反复调用DELETE最终导致的结果都相同:即资源被移除了。

但如果将DELETE的操作用于计数器(资源内部),则DETELE将不再是幂等的。如前面所述,只要数据没有被更新,统计和测量的用法依然可被认为是幂等的。建议非幂等性的资源请求使用POST操作。

然而,这里有一个关于DELETE幂等性的警告。在一个资源上第二次调用DELETE往往会返回404(未找到),因为该资源已经被移除了,所以找不到了。这使得DELETE操作不再是幂等的。如果资源是从数据库中删除而不是被简单地标记为删除,这种情况需要适当妥协。

Tips:如何理解 DELETE 操作被定义为幂等?

上面讨论的也就是「物理删除」和「软删除」的不同场景要不要都使用 DELETE,因为资源的「物理删除」不是幂等操作,第二次请求操作时资源在第一次就没了,对资源造成的结果不同。

物理删除,都没有资源了还怎么操作资源,第一次是有操作结果,第二次没有操作结果(都没资源可以操作,哪来的结果?),两次操作结果不同,所以不是幂等

软删除,第一次删除是更新资源的删除状态为删除,第二次删除即使不加校验,最终也是将资源更新为删除状态。

资源命名(URI)

除了适当地使用HTTP动词,在创建一个可以理解的、易于使用的Web服务API时,资源命名可以说是最具有争议和最重要的概念。一个好的资源命名,它所对应的API看起来更直观并且易于使用。相反,如果命名不好,同样的API会让人感觉很笨拙并且难以理解和使用。当你需要为你的新API创建资源URL时,这里有一些小技巧值得借鉴。

从本质上讲,一个RESTFul API最终都可以被简单地看作是一堆URI的集合,HTTP调用这些URI以及一些用JSON和(或)XML表示的资源,它们中有许多包含了相互关联的链接。RESTful的可寻址能力主要依靠URI。每个资源都有自己的地址或URI——服务器能提供的每一个有用的信息都可以作为资源来公开。统一接口的原则部分地通过URI和HTTP动词的组合来解决,并符合使用标准和约定。

在决定你系统中要使用的资源时,使用名词来命名这些资源,而不是用动词或动作来命名。换句话说,一个RESTful URI应该关联到一个具体的资源而不是关联到一个动作。另外,名词还具有一些动词没有的属性,这也是另一个显著的因素。

一些资源的例子:

  • 系统的用户
  • 学生登记的课程
  • 一个用户帖子的时间轴
  • 关注其他用户的用户
  • 一篇关于骑马的文章

服务套件中的每个资源至少有一个URI来标识。如果这个URI能表示一定的含义并且能够充分描述它所代表的资源,那么它就是一个最好的命名

URI应该具备可预测性分层结构,这将有助于提高它们的可理解性和可用性的:可预测指的是资源应该和名称保持一致;而分层指的是数据具有关系上的结构。这并非REST规则或规范,但是它强化了对API的定义。

RESTful API是提供给消费端(客户端)的,URI的名称和结构应该将它所表达的含义传达给消费者。通常我们很难知道数据的边界是什么,但是从你的数据上你应该很有可能去尝试找到要返回给客户端的数据是什么。API是为客户端而设计的,而不是为你的数据

假设我们现在要描述一个包括客户、订单,列表项,产品等功能的订单系统。考虑一下我们该如何来描述在这个服务中所涉及到的资源的URIs:

准确的案例✅

为了在系统中插入(创建)一个新的用户,我们可以使用:
POST <http://www.example.com/customers

读取编号为33245的用户信息:

GET <http://www.example.com/customers/33245

使用PUT和DELETE来请求相同的URI,可以更新和删除数据。

下面是对产品相关的URI的一些建议:

POST <http://www.example.com/products

用于创建新的产品。

GET|PUT|DELETE <http://www.example.com/products/66432

分别用于读取、更新、删除编号为66432的产品。

那么,如何为用户创建一个新的订单呢?

一种方案是:

POST <http://www.example.com/orders

这种方式可以用来创建订单,但缺少相应的用户数据。

因为我们想为用户创建一个订单(注意之间的关系),这个URI可能不够直观,下面这个URI则更清晰一些:

POST <http://www.example.com/customers/33245/orders

现在我们知道它是为编号33245的用户创建一个订单。(Tips:体现上面提到的 URI 应该具备分层结构的特性)

那下面这个请求返回的是什么呢?(Tips:下面举例体现了 URI 应该具体可预测的特性,从 URI 中就可以推断出即将返回的资源数据)

GET <http://www.example.com/customers/33245/orders

可能是一个编号为33245的用户所创建或拥有的订单列表。注意:我们可以屏蔽对该URI进行DELETE或PUT请求,因为它的操作对象是一个集合。

继续深入,那下面这个URI的请求又代表什么呢?

POST <http://www.example.com/customers/33245/orders/8769/lineitems

可能是(为编号33245的用户)增加一个编号为8769的订单条目。没错!如果使用GET方式请求这个URI,则会返回这个订单的所有条目。但是,如果这些条目与用户信息无关,我们将会提供 POST www.example.com/orders/8769/lineitems这个URI。

从返回的这些条目来看,指定的资源可能会有多个URIs,所以我们可能也需要要提供这样一个URI GET <http://www.example.com/orders/8769,用来在不知道用户ID的情况下根据订单ID来查询订单。>

更进一步:

GET <http://www.example.com/customers/33245/orders/8769/lineitems/1

可能只返回同个订单中的第一个条目。

现在你应该理解什么是分层结构了。它们并不是严格的规则,只是为了确保在你的服务中这些强制的结构能够更容易被用户所理解。与所有软件开发中的技能一样,命名是成功的关键

错误的案例❌

前面我们已经讨论过一些恰当的资源命名的例子,然而有时一些反面的例子也很有教育意义。下面是一些不太具有RESTful风格的资源URIs,看起来比较混乱。这些都是错误的例子!

首先,一些serivices往往使用单一的URI来指定服务接口,然后通过查询参数来指定HTTP请求的动作。例如,要更新编号12345的用户信息,带有JSON body的请求可能是这样:

GET <http://api.example.com/services?op=update_customer&id=12345&format=json

尽管上面URL中的”services”的这个节点是一个名词,但这个URL不是自解释的,因为对于所有的请求而言,该URI的层级结构都是一样的。此外,它使用GET作为HTTP动词来执行一个更新操作,这简直就是反人类(甚至是危险的)。

下面是另外一个更新用户的操作的例子:

GET <http://api.example.com/update_customer/12345

以及它的一个变种:

GET <http://api.example.com/customers/12345/update

你会经常看到在其他开发者的服务套件中有很多这样的用法。可以看出,这些开发者试图去创建RESTful的资源名称,而且已经有了一些进步。但是你仍然能够识别出URL中的动词短语。注意,在这个URL中我们不需要”update”这个词,因为我们可以依靠HTTP动词来完成操作。下面这个URL正好说明了这一点:

PUT <http://api.example.com/customers/12345/update

这个请求同时存在PUT和”update”,这会对消费者产生迷惑!这里的”update”指的是一个资源吗?因此,这里我们费些口舌也是希望你能够明白……

是否需要使用复数形式?

让我们来讨论一下复数和“单数”的争议…还没听说过?但这种争议确实存在,事实上它可以归结为这个问题……

在你的层级结构中URI节点是否需要被命名为单数或复数形式呢?举个例子,你用来检索用户资源的URI的命名是否需要像下面这样:

GET <http://www.example.com/customer/33245

或者:

GET <http://www.example.com/customers/33245

两种方式都没问题,但通常我们都会选择使用复数命名,以使得你的API URI在所有的HTTP方法中保持一致。原因是基于这样一种考虑:customers是服务套件中的一个集合,而ID33245的这个用户则是这个集合中的其中一个。

按照这个规则,一个使用复数形式的多节点的URI会是这样(注意粗体部分):

GET <http://www.example.com/**customers**/33245/**orders**/8769/**lineitems**/1

“customers”、“orders”以及“lineitems”这些URI节点都使用的是复数形式。

这意味着你的每个根资源只需要两个基本的URL就可以了,一个用于创建集合内的资源,另一个用来根据标识符获取、更新和删除资源。

例如,以customers为例,创建资源可以使用下面的URL进行操作:

POST <http://www.example.com/customers

而读取、更新和删除资源,使用下面的URL操作:

GET|PUT|DELETE <http://www.example.com/customers/{id}

正如前面提到的,给定的资源可能有多个URI,但作为一个最小的完整的增删改查功能,利用两个简单的URI来处理就够了。

或许你会问:是否在有些情况下复数没有意义?嗯,事实上是这样的。当没有集合概念的时候(此时复数没有意义)。换句话说,当资源只有一个的情况下,使用单数资源名称也是可以的——即一个单一的资源。

例如,如果有一个单一的总体配置资源,你可以使用一个单数名称来表示:

GET|PUT|DELETE <http://www.example.com/configuration

注意这里缺少configuration的ID以及HTTP动词POST的用法。假设每个用户有一个配置的话,那么这个URL会是这样:

GET|PUT|DELETE <http://www.example.com/customers/12345/configuration

同样注意这里没有指定configuration的ID,以及没有给定POST动词的用法。在这两个例子中,可能也会有人认为使用POST是有效的。好吧…

回顾

  • Http Method 的使用场景

增:post、put(非幂等)

删:delete(幂等,类似修改计数器资源时非幂等)

改:put、patch(幂等,类似修改计数器资源时非幂等)

查:gethead(幂等)

其他:connect、optionstrace(幂等)

  • 使用 PUT 创建/更新资源

创建资源:当由客户端来决定新资源具有哪些URI(通过资源名称或ID)时,使用PUT http://www.example.com/article,请求body 中 id 为 123,用来修改资源名称的 id 为 123

更新资源:PUT http://www.example.com/article/123,用来更新文章 123 的内容

  • 非幂等的请求建议统一使用 POST
  • 使用 Http Method 来描述 API 请求对资源的操作类型(CRUD)
  • 使用 URI 来描述 API 请求处理资源的位置层级,UIR 可以是名词+描述名词的属性,需要具备可预测性和分层结构,能够自解释。

在 lumen-api-starter 中的应用

// routes/web.php
Route::get('/', function () {
    return app()->version();
});

Route::get('author', function () {
    $response = Http::withOptions(['timeout' => 3])->get('<https://api.github.com/users/Jiannei>');
    $response->throw();

    return $response->json();
});

Route::get('configurations', 'ExampleController@configurations');
Route::get('logs', 'ExampleController@logs');

Route::post('users', 'UsersController@store');
Route::get('users/{id}', 'UsersController@show');
Route::get('users', 'UsersController@index');

Route::post('authorization', 'AuthorizationController@store');
Route::delete('authorization', 'AuthorizationController@destroy');
Route::put('authorization', 'AuthorizationController@update');
Route::get('authorization', 'AuthorizationController@show');

扩展阅读

参考

本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由系统于 3年前 自动加精
Jianne
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 3

实在是解决了强迫症的开发者纠结的问题:+1:

3年前 评论
Jianne

@迟早被自己骚死 一起讨论学习 :ok_hand:

3年前 评论

谢谢作者分享 :+1:

1年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!