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

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

耗子喂汁.png

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

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

通讯协议

服务端使用 HTTPS 做为通讯协议,不只比 HTTP 更加安全,并且现代浏览器对 HTTP 2 的支持已经逐渐成熟,性能方面也有很大提升。因此即使用户以 HTTP 协议访问接口,咱们也直接将访问重定向至 HTTPS 协议,非常省心!前端

Nginx 配置

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

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/*,兄弟萌能够随意访问进行测试。ios

接着添加以下代码便可配置 HTTPS 并开启 HTTP 2:nginx

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 证书,直接使用 阿里云免费证书 就好,话提及来,我已经白嫖好几年了,嘤嘤嘤~web

我也要 给我也弄一个.jpg

域名

专用域名

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

options.png

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

其实跨域还会存在一些例如 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
复制代码

数据库的表名为何使用单数

一、直观

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

二、便利

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

三、优雅

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

// 单数:
order

// 复数:
orders

// 单数:
order_detail

// 复数:
order_details
复制代码

四、简单朴素

设想下,不管是表名、主键、关系仍是实例,你均可以统一用单数,看上去很是统一,也不用费心地各类复数单数中转换你的思惟。例如:

# 表名
customer

# 主键
customer.customer_id

# 关联表
customer_address

# 方法名
public function getCustomer { }

# 查询语句
SELECT FROM customer WHERE customer_id = 100
复制代码

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

五、全球化

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

六、效率

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

综上所述,我推荐在数据库中使用单数表名,而在 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 理论。

相关文章
相关标签/搜索