在视频网站项目中实践 RESTful 架构经验总结

1,187 阅读12分钟

在社区经常看到前端的兄弟萌吐槽后端的年轻人不讲码德,来!骗!来!糊弄!乱改接口,动不动格式就变了,我大意了,字符串没有判空,控制台一片红。要么就是返回的数据嵌套太深,一层包一层,你搁这俄罗斯套娃呢?而如果按照 RESTful 架构来设计接口,就不会存在这种类似的问题。

耗子喂汁.png

众所周知,RESTful API 是一套成熟的 API 设计理论,它不仅有结构清晰、易于理解、方便扩展等诸多优点,而且它的作者 Roy Thomas Fielding 是位巨佬,他是 HTTP 规范的主要作者、Apache 服务器的共同创始人并在 Adobe 担任首席科学家,跟随巨佬的脚步,可以少走很多弯路。

本文我将记录在视频网站项目中实践 RESTful 架构的经验与心得。例如,设计 Laravel 的接口、在 Vue 中做相应的对接工作等,这样妈妈就再也不用担心我的接口问题了,针不戳!

通信协议

服务端使用 HTTPS 作为通信协议,不仅比 HTTP 更加安全,而且现代浏览器对 HTTP 2 的支持已经逐渐成熟,性能方面也有很大提高。所以即便用户以 HTTP 协议访问接口,我们也直接将访问重定向至 HTTPS 协议,很是省心!

Nginx 配置

nginx.conf 中添加如下配置完成重定向的配置:

server {
    listen 80;
    server_name www.lcgod.com lcgod.com;
    access_log  off;
    rewrite ^/(.*)$ https://www.lcgod.com/$1 permanent;
}

以上是我博客的配置,用户不论访问 http://www.lcgod.com/* 还是 http://lcgod.com/*,都将被 Nginx 重定向至 https://www.lcgod.com/*,兄弟萌可以随意访问进行测试。

接着添加如下代码即可配置 HTTPS 并开启 HTTP 2:

server {
    listen 443 ssl http2;
    server_name www.lcgod.com lcgod.com;

    # 301 重定向
    if ($host = lcgod.com) {
        rewrite  ^/(.*)$ https://www.lcgod.com/$1 permanent;
    }

    ssl_certificate /etc/nginx/ssl/www.pem;
    ssl_certificate_key /etc/nginx/ssl/www.key;
    ssl_stapling on;
    ssl_stapling_verify on;
    ssl_trusted_certificate /etc/nginx/ssl/www.pem;
    resolver 8.8.8.8 114.114.114.114 valid=300s;
    resolver_timeout 5s;
    ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
    ssl_ciphers 'EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128'
                ':RSA+AES128:EECDH+AES256:RSA+AES256'
                ':EECDH+3DES:RSA+3DES:!aNULL:!MD5:!RC4:!DHE:!kEDH';
    add_header Strict-Transport-Security "max-age=15768001; preload";
    add_header X-Content-Type-Options nosniff;

    # 设置前端项目根目录
    root   /home/nginx/spa/web;
    index  index.html;

    # 省略了一些网站配置……
}

以上代码中 ssl_certificate /etc/nginx/ssl/www.pemssl_certificate_key /etc/nginx/ssl/www.key 是配置 HTTPS 所需要的 SSL 证书,直接使用 阿里云免费证书 就好,话说起来,我已经白嫖好几年了,嘤嘤嘤~

我也要 给我也弄一个.jpg

域名

专用域名

大型项目一般都会将接口部署在专用域名之下。例如,掘金的接口项目部署在 api.juejin.cn 下,前端 Vue 项目部署在 juejin.cn 下。这样做的优点是方便扩展,缺点是存在跨域问题,浏览器每次发送复杂请求时(例如掘金的点赞接口),都会先发送一个 OPTIONS 预检请求,探测服务端的跨域规则,若服务端允许跨域才会继续发送真正的异步请求。如下图所示:

options.png

可以从上图中发现掘金服务端设置的一些跨域规则,有一条 access-control-max-age: 86400,意为浏览器对点赞接口发送了一次 OPTIONS 预检请求后,会缓存一天的时间,一天内对点赞接口的后续访问都不会再次发送预检请求。此规则很好地避免了浏览器发送过多的预检请求,浪费服务器资源。

其实跨域还会存在一些例如 Cookie 设置之类的坑,跨域相关的坑是非常多的,只有亲自踩坑才会明白其中的痛苦,并在痛苦中成长,所以我就不再赘述。

专用前缀

对于像我独立开发的一个街舞视频网站 唯舞 这种小项目,业务逻辑简单,我将前端 Vue 与接口 Laravel 都部署在同一域名中,接口项目使用 api 前缀进行区分即可。我就比较喜欢使用这种简单的做法,毕竟我不跨域我就永远不会踩坑 (=・ω・=)

nginx.conf 的中添加如下规则,即可完成前缀设置:

server {
    listen 443 ssl http2;
    server_name www.vhiphop.com vhiphop.com;

    # 省略了一些网站配置……

    # 设置前端项目根目录
    root   /home/nginx/spa/web;
    index  index.html;

    location /api {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location / {
        try_files $uri /index.html;
    }

    location ~ \.php(.*)$ {
        # 设置 PHP 项目根目录
        root   /home/nginx/api/web/public;
        index  index.php;

        fastcgi_pass unix:/var/run/php-fpm/php-fpm.sock;
        fastcgi_index index.php;
        fastcgi_split_path_info ^((?U).+\.php)(/?.+)$;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }
}

其中的 location /api {} 配置项代表用户访问以 www.vhiphop.com/api 开头的 URL,优先交给 PHP 的接口项目处理。

其中的 location / {} 配置项则代表非 www.vhiphop.com/api 开头的 URL 都返回 Vue 的单页面项目。

缓存控制

适当地利用缓存策略可以在减缓服务器压力、优化用户体验的同时不影响项目的版本更新。

我在 nginx.conf 中进行了如下设置:

server {
    listen 443 ssl http2;
    server_name www.vhiphop.com vhiphop.com;

    # 省略了一些网站配置……

    #设置 css、js 和图片等静态资源的缓存时间
    location ~ .*\.(css|js|ico|png|gif|jpg|json|mp3|mp4|flv|swf)(.*) {
        expires 60d;
    }
    
    location /index.html {
        add_header cache-control max-age=30;
    }
}

例如,用户第一次访问了我们的 Vue 项目后,浏览器将项目的静态资源(html、css、js、图片等)下载至本地,并缓存。

假设用户在 30 秒内从新窗口中打开本网站,或点击收藏的网站书签刷新本网站,浏览器都不会重新请求服务器下载最新资源,而是直接对 index.html 返回 200 (form disk cache) 状态码,意为直接从硬盘中读取该文件;而其他资源例如图片,则会返回 200 (form memory cache) 状态码,意为直接从内存中读取该图片,如下图:

dishcache.png

假设我们开发人员在距离用户第一次访问 30 秒内在服务端对 Vue 项目的代码进行了更新,用户在 30 秒后使用以上方式再次刷新页面,浏览器则重新请求服务器,根据请求头中的 last-modifiedetagexpires 等规则,判断是否需要下载最新资源,如果资源发生改变,则下载最新资源、更新缓存。

一个小细节

如果用户点击刷新按钮点击地址栏并按回车键,浏览器每次都会重新访问服务器,若服务器资源已发生改变,则重新下载资源,若未改变,则从缓存、硬盘中读取。兄弟萌可以用 Chrome 试试,如果返回 304 状态码,就代表重新访问了服务器,但资源未发生改变,再次从缓存、硬盘中读取资源,如下图:

304.png

版本号

RESTful 架构提倡每个 URI 都代表一种资源,HTTP 的 URL 是对 URI 的一种实现,这种关系类似 JavaScript 是对 ECMAScript 的一种实现。

固定前缀

目前国内大厂的接口设计基本都是将版本号作为固定前缀放入 URL 中,例如掘金的沸点推荐接口 api.juejin.cn/recommend_api/v1,这种做法的优点是清晰、直观,如果想让不同的版本部署在不同的服务器,Nginx 只需要设置简单的 location 规则即可完成转发。

固定 Header

将版本号放入 Header 中其实更符合 RESTful 架构的设计,毕竟资源本身是没有版本概念的,不同版本的接口实际上返回的是同一种资源的不同表现形式。

所以 URL 中应尽量避免出现与资源无关的字符。并且这种做法也很适合中、小型系统,接口开发完成后,版本更新迭代不会很频繁,每次更新版本时只需要修改 Header 中的版本号即可。

我使用 Flutter 开发的 唯舞 APP 的接口就使用了以上做法,相关代码如下:

_dio = Dio()
  ..options.baseUrl = baseUrl
  ..options.headers.addAll({
    HttpHeaders.acceptHeader: 'application/'
        'vnd.vhiphop.v${Constants.apiVersion}+json',
  })

假设当前 APP 接口版本号为 1.0,那么 Dio 每次发送请求时,都会设置 accept 的值为 vnd.vhiphop.v1.0+json

两种做法到底哪种更好

其实这个问题就像问世界上最好的语言是什么一样(别问,问就是 PHP)。一千个开发者,有一千个哈姆雷特,本质上对于我们的区别也就是改一两行代码的事,更有甚者,淘宝、百度的很多接口都是用 JSONP 来发送异步请求,你能说他们架构设计的不够好吗?所以选择一个适合自己系统的就好,Any colour you like~

路径

路径即接口 URL 的后缀部分,例如掘金的热门文章接口 api.juejin.cn/recommend_api/v1/article/recommend_all_feed 其中的 /recommend_api/v1/article/recommend_all_feed 便是路径,但掘金的接口肯定不是按 RESTful 架构设计的,如下图所示:

path.png

使用名词复数形式

还是那句话, RESTful 架构提倡每个 URI 都代表一种资源,因为资源是一种实体,所以应该使用名词,正常情况下资源都能与数据库中的表名对应,并且接口返回的数据都是集合的形式(例如数组、对象),所以 URL 应该使用名词的复数形式。

例如,数据库中有文章表 article 与用户表 user,相关接口的 path 部分设计为如下:

# 获取文章列表
/articles

# 获取用户列表
/users

数据库的表名为什么使用单数

1、直观

你有一个袋子,里面有好多个苹果,你会说这是个苹果袋。但无论里面有 0、1 还是 1000 个苹果,它依然是个袋子。表也是如此,表名需要描述清楚它所包含的对象,而非有多少个数据。

2、便利

单数形式更简单。有一些单词,它的复数形式可能不是常规的,或者就没有复数形式,但是单数不一样,单数形式则没那么多讲究。有些单词的复数,可能会让你想到头大,可能得好好谷歌才能找到。

3、优雅

特别是一些 master_detail 形式的资源名称,统一用单数,读起来更方便,对齐更整齐,从顺序上更有逻辑性。例如:

// 单数:
order

// 复数:
orders

// 单数:
order_detail

// 复数:
order_details

4、简单朴素

设想下,不论是表名、主键、关系还是实例,你都可以统一用单数,看上去非常统一,也不用费心地各种复数单数中转换你的思维。例如:

# 表名
customer

# 主键
customer.customer_id

# 关联表
customer_address

# 方法名
public function getCustomer { }

# 查询语句
SELECT FROM customer WHERE customer_id = 100

一旦你确定将这个对象名称定为 customer,那么所有和数据库相关的交互、编程就都可以使用这个单词。

5、全球化

假设你身处一个全球化的团队,成员中有些人的母语不是英文(说的就是我),对于他们来说,辨认和书写一个单词的复数形式更加困难,会给他们带来麻烦,也给团队合作带来麻烦。

6、效率

可以节省你的拼写时间与硬盘空间,甚至让你的键盘更“长寿”。

综上所述,我推荐在数据库中使用单数表名,而在 URL 中使名词复数。

名词之间加入分隔符

URL 的基本结构为 协议域名路径,由于协议域名 都是不区分大小写的,所以为了保持统一,路径 也要采用小写形式,不要使用驼峰命名法,例如,获取用户隐私协议的接口:

// 错误做法
/userPrivacyPolicies

// 正确做法
/user_privacy_policies

// 更好的做法
/user-privacy-policies

为什么不推荐使用下划线分隔单词?

  • 了解正则表达式的兄弟萌都懂,在正则表达式中 /w 表示单词字符,其范围包括 a-zA-Z0-9 和下划线。例如,hello_world 将被视为一个单词字符,而 hello-world 将被视为两个单词。大部分情况下,前端的路由名称与接口的路径名称保持统一,不仅规范并且利于搜索引擎的关键词收录。

  • 使用分隔符 - 分隔单词,比下划线 _ 看起来更加容易分辨,键盘上也可以少按一个 Shift 键。

综上所述,我推荐使用分隔符 - 对名词进行分隔。

查询字符串

查询字符串是 URL 的最后一部分,一般用于对结果返回结果的过滤。例如,获取文章列表第一页的 20 条记录:

/articles?page=1&size=20

只获取 user_id233 的用户的文章:

/articles?user_id=233

还有一种更好的做法,就是对资源进行分层,下面这种写法更加清晰、直观:

/users/233/articles

如果只获取发布状态为已发布的文章,你可能会这么做:

/users/233/articles/published

我是不推荐使用以上做法的,当层数过多时,URL 已经没有那么直观了,改为以下写法要更好:

/users/233/published-articles

// 更好的写法
/users/233/articles?publish_state=1

数据格式

实际上讲,使用 JSON 作为数据格式进行交互,早已成为主流,毕竟它轻量、易于阅读,最重要的是它是 ECMAScript 的子集,浏览器对它的支持有着天然的优势。

Vue 中的设置

如果使用 axios 进行 HTTP 请求,默认的 Content-Type 就是 application/json,无需进行任何设置。

如果使用 fetch 进行 HTTP 请求,则默认的 Content-Typetext/plain,我们需要进行如下修改:

const response = await fetch(
  'https://www.lcgod.com/api',
  { headers: { 'Content-Type': 'application/json; charset=utf-8' }},
);

Laravel 中的设置

Laravel 从 5.4 版本开始,不再支持在配置文件中定制 PDO 的 fetch mode,取而代之的 PDO::FETCH_OBJ。也就是说,通过查询构造器或模型从数据库中取出的数据不是单纯的数组形式,而是数组与 stdClass Object 的结合体,直接返回给前端,根本无法解析为数组,那还用个 🔨

所以需要将 app/Providers/EventServiceProvier.php 文件中的 boot 方法替换为如下,即可将 fetchMode 改为正常:

public function boot()
{
    parent::boot();
    Event::listen(\Illuminate\Database\Events\StatementPrepared::class, function ($event) {
        $event->statement->setFetchMode(\PDO::FETCH_ASSOC);
    });
}

从数据库取出传统的数组后,在控制器中直接返回 response 全局函数即可输出 JSON 数据,有以下两种用法:

# 手动设置 Content-Type
return response([], 200)->header('Content-Type', 'application/json');

# 框架自动设置 Content-Type
return response()->json([], 200);

HTTP 动词与状态码

客户端使用不同的 HTTP 动词请求服务端,服务端根据动词对资源做出不同类型的操作:

名称动作数据库操作
GET获取资源SELECT
POST新增资源INSERT
PUT更新整体资源UPDATE
PATCH更新部分资源UPDATE
DELETE删除资源DELETE
HEAD获取资源元数据-
OPTIONS获取客户端可以改变的资源信息-

服务端返回不同的状态码表示资源的不同状态:

状态码状态信息
200成功返回数据(返回 JSON 数组或 JSON 对象)
201成功创建或更新数据(返回 JSON 对象)
204成功删除数据(无返回数据)
401用户登录后才能访问(返回 JSON 对象)
403提交的参数不合法(返回 JSON 对象)
404未找到相关的服务(返回 JSON 对象)
405使用了不支持的 HTTP 动词(例如只支持 GET,而你发送 POST)
500服务器内部发生错误(返回 JSON 对象)

客户端发送的请求只要失败了,服务端统一返回以下格式的 JSON 字符串,例如,某个请求地址不正确,服务端没有相关的接口,则返回 404 状态码:

{
    "message": "未找到相关的服务",
    "error_code": 1001
}

手机号格式错误,返回 403 状态码:

{
    "message": "请输入正确的手机号",
    "error_code": 1001
}

短信验证码错误,返回 403 状态码,并给出不同的 error_code

{
    "message": "请输入正确的验证码",
    "error_code": 1002
}

其中的 error_code 由后端决定相关的错误状态,客户端根据 error_code 做出不同的动作。例如,唯舞网的注册组件就是这样做的:

errorcode.png

下面列举我在项目中使用 HTTP 动词的一些例子。

GET

获取用户列表:

/users

服务端返回 200 状态码:

{
  "count": 123456,
  "users": [
    {
      "id": 233,
      "token": "abc123",
      "nickname": "聪聪",
      "avatar": "avatar.jpg",
      "phone": "181****9876"
    },
    {
      "id": 234,
      "token": "abc123",
      "nickname": "聪聪2",
      "avatar": "avatar.jpg",
      "phone": "181****9876"
    },
    {
      "id": 235,
      "token": "abc123",
      "nickname": "聪聪3",
      "avatar": "avatar.jpg",
      "phone": "181****9876"
    }
  ]
}

获取 user_id233 的用户的个人资料:

/users/233

服务端返回 200 状态码:

{
  "id": 233,
  "token": "abc123",
  "nickname": "聪聪",
  "avatar": "avatar.jpg",
  "phone": "181****9876"
}

POST

注册一个新用户:

/users

假设通过手机验证码注册,则提交的数据如下:

{
    "sign_mode": 1,
    "phone": 12345678910,
    "code": 123456,
    "nickname": "聪聪",
    "psw": "abc123456"
}

服务端返回 201 状态码:

{
  "id": 233,
  "token": "abc123",
  "nickname": "聪聪",
  "avatar": "avatar.jpg",
  "phone": "181****9876"
}

PUT

修改 user_id233 的用户个人资料:

/users/233

假设 user 表有以下 4 个字段储存用户个人资料,则将这 4 个字段全部提交:

{
    "nickname": "聪聪",
    "avatar": "avatar.jpg",
    "phone": 12345678910,
    "psw": "abc123456"
}

服务端返回 201 状态码:

{
  "message": "ok",
  "error_code": 0
}

PATCH

修改 user_id233 的用户手机号:

/users/233

提交的数据中只需要包含手机号与验证码即可,后端将不会对其他信息进行更改:

{
    "phone": 12345678910,
    "code": "123456"
}

服务端返回 201 状态码:

{
  "message": "ok",
  "error_code": 0
}

DELETE

注销 user_id233 的用户:

/users/233

服务端返回 204 No Content 状态码

其实所谓的删除,实际项目中都是软删除,例如将字段 is_del 的值从 0 更新为 1,后端不可能使用 DELETE 操作真正对数据进行物理删除,以便用户误操作后找回数据。

HEAD

视频播放页需要先获取视频的大小,做一些初始化操作。获取 video_id233 的视频元数据:

/videos/233

OPTIONS

前面提过该动词,但我在实际项目中也很少主动使用,都是浏览器用于探测跨域规则自动发送的。

Laravel 对返回数据的处理

在生产环境中,服务端一定要关闭 debug 信息提示,避免暴露错误信息给客户端,保证接口的安全性。

错误处理

Laravel 8 的错误由 app/Exceptions/Handler.php 处理,将该文件中的 register 方法替换为如下,即可拦截框架运行出错时的 debug 提示:

public function register() : void
{
    $this->renderable(function (\Throwable $e) {
        $isDebug = (bool) env('APP_DEBUG', false);
        $errorMessage = $isDebug ? $e->getTrace() : ['error_message' => '服务器繁忙', 'error_code' => 1001];
        $statusCode = $isDebug ? $e->getStatusCode() : 500;

        return response()->json($errorMessage, $statusCode);
    });
}

在生产环境中,修改 .env 文件的 debug 配置为 false

APP_DEBUG=false

假设框架运行时发生错误,此时只会返回客户端简单的提示:

{
    "message": "服务器繁忙",
    "error_code": 1001
}

主动返回数据

新建一个 app/Helpers/ApiResponse.php,用于处理接口主动返回数据:

<?php

namespace App\Helpers;

use Illuminate\Http\JsonResponse;

trait ApiResponse {

    protected static function ok(array $data = [], int $statusCode = 200) : JsonResponse
    {
        !$data && $data = ['message' => 'ok', 'error_code' => 0];
        return response()->json($data, $statusCode);
    }

    protected static function created(array $data = []) : JsonResponse
    {
        return self::ok($data, 201);
    }

    protected static function noContent() : void
    {
        abort(204);
    }

    protected static function error(
        $message = '身份已失效, 请尝试重新登录',
        $errorCode = 1001,
        $statusCode = 403,
    ) : JsonResponse
    {
        return self::ok(
            [
                'message' => $message,
                'error_code' => $errorCode,
            ],
            $statusCode
        );
    }

    protected static function notFound($message = '未找到相关数据') : JsonResponse
    {
        return self::error($message, 404);
    }
}

app/Http/Controller.php 中使用 ApiResponse

<?php

namespace App\Http\Controllers;

use App\Helpers\ApiResponse;
use Illuminate\Routing\Controller as BaseController;

class Controller extends BaseController
{
    use ApiResponse;
}

app/Http/UserController.php 中调用 ApiResponse 的方法,直接返回数据给客户端:

<?php
namespace App\Http\Controllers;

use App\Services\UserService;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;

class UserController extends Controller
{
    private UserService $service;

    public function __construct()
    {
        $this->service = new UserService();
    }

    // GET 获取用户列表
    public function index() : JsonResponse
    {
        $response = $this->service->index();
        return self::ok($response);
    }

    // GET 获取某个用户的个人资料
    public function show(int $id) : JsonResponse
    {
        $response = $this->service->show($id);
        return self::ok($response);
    }

    // POST 注册一个新用户
    public function store(Request $request) : JsonResponse
    {
        // 做一些验证参数之类的操作……
        $response = $this->service->store($data);
        return self::created($response);
    }

    // PUT 修改某个用户的个人资料
    public function update(int $id) : JsonResponse
    {
        // 做一些验证参数之类的操作……
        $response = $this->service->update($id, $data);
        return self::created($response);
    }

    // DELETE 注销某个用户
    public function destroy(int $id) : JsonResponse
    {
        $this->service->destroy($id);
        return self::noContent();
    }
}

封装 axios

/src 目录下新建 utils 文件夹,存放项目中所有的工具文件,便于后期的扩展与维护。

utils 文件夹中新建 request.js,用于封装 axios ,发送异步请求。

初始化

request.js 中初始化 axios 实例,设置接口地址,直接使用项目的 .env 文件里的配置:

import axios from 'axios';
import { Message } from 'element-ui';
import store from '@/store';

const service = axios.create({
  baseURL: process.env.VUE_APP_BASE_API,
  timeout: 5000,
});

请求拦截器

设置一些自定义的请求头,并对实际 URL 进行处理,如果项目需要访问第三方的接口,将 baseURL 设置为空即可:

service.interceptors.request.use(
  (config) => {
    if (config.url.includes('http')) {
      config.baseURL = '';
      return config;
    }

    const { getters } = store;
    config.headers['x-user-id'] = getters.userId;
    config.headers['x-user-token'] = getters.userToken;
    return config;
  },
  (error) => Promise.reject(error),
);

响应拦截器

根据 HTTP 状态码进行相关的一些操作,例如 401 状态码需要清空用户信息,退出登录:

service.interceptors.response.use(
  (response) => response.data,
  (error) => {
    let { data } = error.response;
    if (typeof data !== 'object') data = {};
    if (!data.error_code) data.error_code = 1001;

    switch (error.response.status) {
      case 403:
        if (!data.message) data.message = '参数错误';
        break;
      case 404:
        if (!data.message) data.message = '未找到相关服务';
        break;
      case 401:
        if (!data.message) data.message = '登录已失效,请重新登录!';
        store.dispatch('user/logout').catch(() => {});
        break;
      default:
        if (!data.message) data.message = '网络繁忙';
    }

    return Promise.reject(data);
  },
);

异常处理

request 方法用于对异常的处理,根据参数判断是否自动提示错误信息:

async function request({
  url, method, params, isAutoShowErrorTip,
}) {
  let isError = false;
  const data = await service({ url, method, params })
    .catch((error) => { isError = true; return error; });

  if (isError && isAutoShowErrorTip) {
    Message({
      message: data.message,
      type: 'error',
      duration: 5000,
    });
  }

  return { data, isError };
}

导出请求方法

将 HTTP 动词对应的请求方法分别导出,便于项目的 API 文件调用。

export function get({ url, params, isAutoShowErrorTip }) {
  return request({
    method: 'GET',
    url,
    params,
    isAutoShowErrorTip,
  });
}

export function post({ url, params, isAutoShowErrorTip }) {
  return request({
    method: 'POST',
    url,
    params,
    isAutoShowErrorTip,
  });
}

export function put({ url, params, isAutoShowErrorTip }) {
  return request({
    method: 'PUT',
    url,
    params,
    isAutoShowErrorTip,
  });
}

export function patch({ url, params, isAutoShowErrorTip }) {
  return request({
    method: 'PATCH',
    url,
    params,
    isAutoShowErrorTip,
  });
}

export function del({ url, params, isAutoShowErrorTip }) {
  return request({
    method: 'DELETE',
    url,
    params,
    isAutoShowErrorTip,
  });
}

export function head({ url, params, isAutoShowErrorTip }) {
  return request({
    method: 'HEAD',
    url,
    params,
    isAutoShowErrorTip,
  });
}

接口文件的封装

/src 目录下新建 api 文件夹,存放项目中所有的接口文件,便于后期的扩展与维护。

对于用户相关的接口请求,全部存放于 /src/api/user.js,以下是相关示例:

import { get, post, put, del } from '@/utils/request';

const url = 'users';

// 获取用户列表
export function index(params, isAutoShowErrorTip = true) {
  return get({
    url,
    params,
    isAutoShowErrorTip,
  });
}

// 获取某个用户的个人资料
export function show(id, isAutoShowErrorTip = true) {
  return get({
    url: `${url}/${id}`,
    isAutoShowErrorTip,
  });
}

// 注册一个新用户
export function store(params, isAutoShowErrorTip = true) {
  return post({
    url,
    params,
    isAutoShowErrorTip
  });
}

// 修改某个用户的个人资料
export function update(id, params, isAutoShowErrorTip = true) {
  return put({
    url: `${url}/${id}`,
    params,
    isAutoShowErrorTip
  });
}

// 注销某个用户
export function destroy(id, isAutoShowErrorTip = true) {
  return del({
    url: `${url}/${id}`,
    isAutoShowErrorTip
  });
}

页面组件调用

最后在页面组件进行调用,例如 /src/views/user/index.vue 是用户列表页,其 script 内容为如下:

import { index, destroy } from '@/api/user';

export default {
  data: () => ({
    isLoading: false,
    isDeleting: false,
    count: 0,
    users: [],
    queryList: {
      is_asc: 0,
      page: 1,
      size: 8,
    },
  }),
  methods: {
    async load(route, next) {
      if (this.isLoading) return;

      const { queryList } = this;
      const { query } = route;
      const is_asc = query.is_desc ?? 1;
      const size = +(query.size ?? 0);
      const page = +query.page;

      queryList.is_desc = is_asc ? 1 : 0;
      queryList.page = page > 0 ? page : 1;
      queryList.size = (size < 8 || size > 16) ? 8 : size;

      this.isLoading = true;
      const { isError, data } = await index(this.queryList);
      this.isLoading = false;

      if (next) next();
      if (isError) return;

      this.count = data.count;
      this.users = data.users;
    },
    async handleDelete(id) {
      if (this.isDeleting) return;

      this.isDeleting = true;
      const { isError } = await destroy(id);
      this.isDeleting = false;
      if (isError) return;

      this.load();
    },
  },
  beforeRouteUpdate(to, from, next) {
    this.load(to, next);
  },
  beforeMount() {
    this.load(this.$route);
  },
};

封装 Fetch

如果是个人项目,例如我的博客,不注重兼容性,可以直接使用浏览器自带的 fetch 发送请求,对其简单封装即可使用,而不必使用 axios

export default async function({ method, url, params }) {
  const init = {
    method,
    mode: process.env.VUE_APP_CORS_MODE,
    credentials: process.env.VUE_APP_CREDENTIALS,
    headers: { 'Content-Type': 'application/json; charset=utf-8' },
  };
  
  if (params) {
    if (method === 'GET' || method === 'DELETE') {
      const data = [];
      Object.keys(params).forEach((k) => {
        data.push(`${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`);
      });
      url += `?${data.join('&')}`;
    } else {
      init.body = JSON.stringify(params);
    }
  }
  
  url = url.includes('http') ? url : `${process.env.VUE_APP_BASE_API}${url}`;
  const response = await fetch(url, init);
  const { status } = response;

  let data;
  try {
    data = await response.json();
  } catch (e) {
    data = {};
  }

  if (status > 199 && status < 300) return Promise.resolve(data);

  if (typeof data !== 'object') data = {};
  if (!data.error_code) data.error_code = 1001;

  switch (status) {
    case 403:
      if (!data.message) data.message = '参数错误';
      break;
    case 404:
      if (!data.message) data.message = '未找到相关服务';
      break;
    case 401:
      if (!data.message) data.message = '登录已失效,请重新登录!';
      store.dispatch('user/logout').catch(() => {});
      break;
    default:
      if (!data.message) data.message = '网络繁忙';
  }

  return Promise.reject(data);
}

总结

我根据自己独立开发的 唯舞网唯舞 APP 站在全干开发者的角度,从通信协议到具体请求文件的封装,尽可能详细地描述了如何实践 RESTful 架构。而现实中的项目肯定是千变万化的,最终的设计还是要考虑自己系统的架构规模,设计一套适合自己系统的规范,大家好才是真的好,不一定要严格遵循 RESTful 理论。