对 REST 的理解

整理一下目前为止我对 REST 的理解

假的 REST 接口

很多人看到 REST 反应就是,利用 http 动词,处理资源, 随便看看就明白了。

这种人很容易就写出这样一种接口,所有的请求统一返回 200,body 中有,success, message, data, 大家的实现不同,但是大致就是这么个意思。

随处也都能看到这样的讨论,比如这里

理解 REST

我们以一个简单的例子开始,假设我们需要写一套api,功能包括了用户,商品,订单,供应商。

资源

写 REST 接口,首先需要明白资源这个概念,所有的东西都是资源,我们始终是在操作资源,当然资源需要是个名词。

于是我们定义出这样一些资源,users, products, orders, vendors。注意这里是复数,因为既然是资源,肯定是一堆,是个集合。单复数的概念还是很重要的。

状态码

状态码是很重要的,是有意义的,客户端是需要根据状态码做判断的。比如201表示资源被创建了;204 表示请求成功了,但是并没有什么信息需要返回,body 当然是空;202 表示服务器接受了请求,但是还未处理,响应中应该包含相应的指示信息,告诉客户端该去哪里查询这次处理是否真正完成了等等。

你可能会遇到一些同事说它判断不了状态码,一定要解析body,body中需要一个字段表示是否成功...等等,那么你需要做的是跟他讲道理,让他看看 http 协议。

有用的链接 https://httpstatuses.com/

处理资源

有了资源,我们就会需要对资源进行增删改查,对应到 http 的动词,就是 post,delete,put/patch,get。

以一个商品资源为例

http 动词 url 返回状态码 描述
post /api/products 201 创建一个商品
get /api/products 200 获取商品列表
get /api/products/{id} 200 获取某个商品信息
put /api/products/{id} 200 / 204 完整的替换某个商品
patch /api/products/{id} 200 / 204 部分更新某个商品
delete /api/products/{id} 204 删除某个商品

可参考 rfc7231

第一个问题

用户,商品,订单,供应商 每个都是独立的资源,那么如果我有一个订单详情页面,显示哪个用户买的什么商品,买的谁的商品,什么时间买的。也就是同时获取了4个资源的信息,难道要请求4次吗?

比如订单id为10,用户id为1,商品id为5,供应商id为2。

  • get /api/orders/10
  • get /api/products/5
  • get /api/vendors/2
  • get /api/users/1

或者是另一种解决办法,订单详情默认包含了它的商品,用户,供应商信息?

  • get /api/orders/10

这样是请求了一次,但是如果客户端只需要订单信息,我为什么要进行额外的查询,返回额外的信息。

完美的方案

首先,接口的设计应该站在资源的角度,关心的不是页面如何显示,而是客户端需要什么资源,而需要什么当然只能是客户端自己决定。其次资源之间是有关联的,我们要利用资源之间的关系,于是可能是下面这样:

get /api/orders/10?include=user,vendor,product

意思就是我需要10号订单的数据,同时需要订单相关的用户,供应商,商品信息。注意这里是单数,表示其相关的单个资源。

数据格式

有了上面完美的方案,但是资源的数据到底是什么样的,又要怎么嵌套呢?先参考一下这里

对于资源来说,肯定需要一个统一的结构,也方便我们嵌套。我们先理解一个简单好用的json结构。

{
    "data": {...}
    "meta": {...}
}

data 中是这个资源的数据,meta 可选,是资源之外,其他的一些信息,比如分页。对于嵌套的资源同样也是这样。那么对于上面的请求,响应应该是下面这样。

get /api/orders/10?include=user,vendor,product

{
    "data": {
        "id": 10,
        "title": 一个订单,
        ...
        "product": {
            "data": {
                "id": 5,
                "price": 15
                ...
            }
        }
        "user": {
            "data": {
                "id": 1,
                "name": "foo"
                ...
            }
        }
        "vendor": {
            "data": {
                "id": 2,
                "name": "bar"
                ...
            }
        }
    }
}

再举个例子

我的订单列表,
get /api/user/orders?include=product,vendor

{
    "data": [
        {
            "id": 1,
            "title": 一个订单,
            ...
            "product": {
                "data": {
                    "id": 5,
                    "price": 15
                    ...
                }
            }
            "vendor": {
                "data": {
                    "id": 2,
                    "name": "bar"
                    ...
                }
            }
        },
        ...
    ],
    "meta": {
        "pagination": {
            "total": 60,
            "count": 15,
            "per_page": 15,
            "current_page": 1,
            "total_pages": 4,
            "links": {
                "next": "http://foobar/api/user/orders?page=2"
            }
        }
    }
}

利用好资源的关系和嵌套我们再补充几个接口

动词 url 描述 includes
get /api/vendors/{id}/products 获取某个供应商的所有商品 vendor
get /api/vendors/{id}/products/{id} 获取某个供应商的某个商品 vendor
get /api/vendors 获取供应商列表 products
get /api/user/orders 我的订单列表 vendor,products
get /api/users/{id}/orders 某个用户的订单列表 vendor,products

注意最后两个,这里是参考了 github,用单数的 user 表示当前用户,因为我们如果有token,服务器就知道我们是谁。

当然这里只是个例子,真实业务我们可能并不能查看别人下的订单,

第二个问题

我们下了一个订单,可能会伴随很多状态,待付款,已付款,已发货,已收货,已取消等等,如何设计api呢?

其实一开始大家很可能会这么写

patch /api/orders/{id}/pay    对某个订单付款
patch /api/orders/{id}/cancel 取消某个订单

这样或许可以,但是我们引入了动词,有没有更好的方法呢?

其实我们始终是在更新订单状态

patch /api/orders/{id}
body  status: paid,canceled

或许可以这样,对于资源来说我们就是要把订单的状态改为paid或者canceled。但是对于每个状态,提交的参数和要处理的数据可能有很大的不同,难道都写在一个方法里?

$status = $request->get('status');
$method = camel_case('patch_'.$status);
return $this->$method($order);

接口是一个,但是我们接受到请求只有依然是可以进行接口分发的,类似上面这样。

put 和 patch 的关系

两个方法都是更新资源,而且幂等的,但是 put 是整个替换资源,首先需要判断必填项,然后根据请求替换这个资源。patch 是提交什么更新什么。

第二 put 是可以创建资源的,但是一般只存在于客户端可以指定资源id的情况下

put /api/orders/100

更新资源id为100的资源,如果不存在则创建。创建的话返回201,更新的话返回200。这种情况很少见,因为现在基本上都是服务器生成id。所以对于我们平时处理的业务,其实大部分是 patch。

版本区分

有了 api 当然需要区分版本,因为使用时需要更新的。

那么用什么来区分版本其实大体上有两种

/api/v1/orders
/api/v2/orders

或者利用 header

/api/orders

Accept: application/vnd.foobar.v1+json
Accept: application/vnd.foobar.v2+json

一些教程里面觉得放在url上更直观,比如阮一峰的教程,很多人也用 github 作为例子。

但是其实你看看 github api 的第一页,https://developer.github.com/v3/, github 及 其他一些的 rest 教程都是推荐第二种的。

总结

上面是我的个人理解,目前基本是按照这个思路实现的接口,但是依然也有很多地方觉得不完美,不规范。欢迎指正和讨论

本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由 Summer 于 7年前 加精
liyu001989
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 28

某公司前端:我不管,我只判断返回的数据里data.code=0表示成功,其他我不管

7年前 评论
Summer

Route::resource('photos', 'PhotoController');

Laravel 的资源路由器生成:

file

7年前 评论
monkey

file

这里的描述应该是:获取某个供应商的指定商品

7年前 评论
liyu001989

@monkey ok 已修正

7年前 评论

某公司前端:我不管,我只判断返回的数据里data.code=0表示成功,其他我不管

7年前 评论
liyu001989

@keer 我只能说,愿天下多一些讲道理的同事

7年前 评论
咖啡是個軟件猴

看完发现我是个不讲道理的coder, 每次都是判断 data.length>1 ...shame on myself

7年前 评论

很棒!感谢分享!

7年前 评论

Restful 批量操作的理解也跟大家分享下啊

7年前 评论
liyu001989

@Addison 批量操作,我理解的不一定对,有一些讨论,比如这里,和这里

首先批量创建 post /users 因为post应该可以创建一批资源的,可能response返回201,但是location返回什么,stackoverflow , 其实很多人基本也不用这个location,body是空还是创建的资源的集合?如果返回集合那么同一个接口就可能有两种格式的数据返回? 又好像不太好。

   If one or more resources has been created on the origin server as a
   result of successfully processing a POST request, the origin server
   SHOULD send a 201 (Created) response containing a Location header
   field that provides an identifier for the primary resource created
   (Section 7.1.2) and a representation that describes the status of the
   request while referring to the new resource(s).

批量更新 patch /users

批量删除 delete /users

这两个可能就是这样吧,具体怎么更新和删除靠请求参数决定

7年前 评论

其实都是约定....无非就是标准,还是事实标准,还是双方约定的自定义标准....

7年前 评论
liyu001989

Rest 是一种风格,这种风格首先是好的,是有理论依据的,解决了我很多疑惑,也帮助我快速的完成了接口。我认为这里并没有双方,我就是在实现一套通用的,合理的接口,一旦有了双方,就很有可能实现各种奇奇怪怪的接口。所以遇到问题了应该尽量去找到合适的解决方案,而不是跟某几个同事约定。

7年前 评论

遵循REST格式的同时还要遵循JsonApi的格式

7年前 评论
nff93

其实更喜欢Graphql

6年前 评论
LDL1023

请教一下,如果要返回业务逻辑的错误,并且有多种错误情况,客户端要根据不同的错误码做不同的跳转,
http status code 一般用什么呢?

5年前 评论
liyu001989

@LDL1023 这样一般就是在错误中再返回一个自定义的 code,比如 error_code=1001/1002 客户端先判断状态码,知道自己要做什么,然后判断自定义错误码,跳转到不同的地方

5年前 评论
LDL1023

@liyu001989 那 http status 用哪个呢? 用 403 还是其他?

5年前 评论
liyu001989

@LDL1023 该用什么就用什么啊,看每个状态码的意义啊。账号相关的就是401 ,参数错误的422,服务器不允许的403

5年前 评论
LDL1023

@liyu001989 明白,谢谢~

5年前 评论

@liyu001989

有个疑问 “如果一个资源同时属于不同的父资源”,那这个资源的控制器要怎么去定义

  • user/comments,
  • posts/{post_id}/comments
  • comments

像这样的资源,应该怎么去定义它呢?除了写三个控制器 UserComment, PostCommnet, Comment,还有没有其他更好的方法

5年前 评论
liyu001989

@17608777930 我一般都放在 comment 里面,userIndex, postIndex , index 方法,都是查询这个资源的列表,只不过提供了接口简化了查询参数。

定一个规范,怎么实现都行

5年前 评论

受教了,感谢分享 :relieved:

4年前 评论

请问一下:

如果接口是 获取最新的一篇文章 url该怎么写呢。

  • 文章列表 /api/articles

  • 文章详情 /api/articles/{activity}

  • 最新的一篇文章 /api/articles/last ( ???该这样写吗?)

还是说有更规范的写法

4年前 评论
working 2年前

get /api/orders/10?include=user,vendor,product

这样的路由include部分控制器是怎么写的呢,希望分享一下,谢谢

4年前 评论

@Liuzhipeng_laravel
建议查一下Dingo的Transformers文档(即转换层类库), 诸如:

<?php
namespace App\Transformers;

use App\Models\Topic;
use League\Fractal\TransformerAbstract;

class TopicTransformer extends TransformerAbstract
{
    protected $availableIncludes = ['user', 'category'];

    public function transform(Topic $topic)
    {
        return [
            'id' => $topic->id,
            'title' => $topic->title,
            'body' => $topic->body,
            'user_id' => $topic->user_id,
            'category_id' => $topic->category_id,
            'reply_count' => $topic->reply_count,
            'view_count' => $topic->view_count,
            'last_reply_user_id' => $topic->last_reply_user_id,
            'excerpt' => $topic->excerpt,
            'slug' => $topic->slug,
            'created_at' => (string) $topic->created_at,
            'updated_at' => (string) $topic->updated_at,
        ];
    }

    public function includeUser(Topic $topic)
    {
        return $this->item($topic->user, new UserTransformer());
    }

    public function includeCategory(Topic $topic)
    {
        return $this->item($topic->category, new CategoryTransformer());
    }
}
4年前 评论
Liuzhipeng_laravel 4年前

camel_case 是什么鬼

3年前 评论

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