vue3 + egg 实现媒体资源直传七牛云工具(CDN刷新、上传、资源预览)

1,120 阅读11分钟

为什么需要这样一个工具?

前端的日常开发中,经常有一些图片需要放在cdn上,否则会导致项目包太大,严重降低项目的构建部署效率。尤其是小程序开发,超过2M的大小都没法启动项目。如果把账号分发出去,又不安全,那开发这样一个工具就非常有必要了!

解决痛点

  1. 方便快捷上传资源
  2. 避免分发七牛云账号造成不必要的麻烦
  3. 常用功能提取到工具降低使用成本

技术栈

前端:vue3 + element-plus

后端:egg + qiniu + Jwt + Docker + mongodb + mongoose

服务端功能实现

这里就直接开始将功能实现,不懂egg这个nodejs框架的可以提前学习学习,也可以参考这篇文章juejin.cn/post/707498…

七牛云 Nodejs Sdk

jwt简介

JWT(Json Web Token)是实现token技术的一种解决方案,JWT由三部分组成: header(头)payload(载体)signature(签名)

  1. 头 HS384 HS512 RS256 RS384 RS512 ES256 ES384 ES512 PS256 PS384有这几种
  2. 载体
iss:Issuer,发行者
sub:Subject,主题
aud:Audience,观众
exp:Expiration time,过期时间
nbf:Not before
iat:Issued at,发行时间
jti:JWT ID
  1. 签名
HMACSHA256(
  base64UrlEncode(header) + "." +
  base64UrlEncode(payload),
  "your-256-bit-secret"
)

egg介绍

Egg.js 为企业级框架和应用而生,我们希望由 Egg.js 孕育出更多上层框架,帮助开发团队和开发人员降低开发和维护成本。

# 项目初始化
mkdir egg-example && cd egg-example
npm init egg --type=simple
npm i
目录结构

以下是初始化生成的项目目录结构,我们主要关注app/

┌┈ github                  github相关的配置
├┈ app                     业务源码
    ├┈ controller          控制器
    ├┈ extend              扩展文件
    ├┈ middleware          中间件
    ├┈ model               数据模型
    ├┈ public              静态服务器目录
    ├┈ service             主要逻辑
    ├┈ router.js           路由文件
├┈ config                  全局配置文件
    ├┈ config.default.js   默认的配置文件
├┈ logs                    日志文件
├┈ run
├┈ test
├┈ typings
├┈ .autod.conf.js
├┈ .dockerignore
├┈ .eslintignore
├┈ .eslintrc.js
├┈ .gitignore
├┈ .travis.yml
├┈ package.json
└┈ README.md
controller

需要注意的是这里无需引入router、controller控制器文件,是因为框架事先的约定自动动将路由、控制器文件挂载到了app上,极大的提高了编码效率,但前提是得按照框架的约束来

module.exports = (app) => {
  const { router, controller } = app;
  // 通过app引用中间件方法
  const auth = app.middleware.auth();
  // 路径前缀,类似 baseUrl
  router.prefix("/image-upload-server");
  
  // 路由  当路由匹配到时会先通过 auth 鉴权中间,成功后才会进入对应引入的控制器文件
  router.get("/getInfo", auth, controller.user.index);
}
extend

框架提供了一种快速扩展的方式,只需在 app/extend 目录下提供扩展脚本即可

exports.md5 = str => {
  return crypto.createHash('md5').update(str).digest('hex')
}
// 使用
this.ctx.helper.md5(data)
middleware

路由鉴权中间件,当用户触及的操作需要用户权限时就需要这么一个中间件来鉴权

// middleware/auth.js
module.exports = (options = { required: true }) => {
  // 返回一个中间件处理函数
  return async (ctx, next) => {
    // 获取请求头 token
    let token = ctx.headers["authorization"]; // Bearer + ' ' + token
    token = token ? token.split("Bearer ")[1] : null;

    if (token) {
      // next
      try {
        // 通过jwt校验解析出用户参数
        const data = await ctx.service.user.verifyToken(token);
        // 利用jwt解析出来的参数去数据库查是否存在此人
        const user = await ctx.model.User.findById(data.userId);
        // 存在则 将当前用户的信息回调出去
        ctx.user = user;
      } catch (error) {
        ctx.throw(401);
      }
    } else if (options.required) {
      ctx.throw(401);
    }

    await next();
  };
};
// 使用
const auth = app.middleware.auth();

router.get("/getInfo", auth, controller.user.index);

错误处理中间件,封装一个统一的错误处理中间件是非常必要的,否则每个接口都得单独写,增加大量的维护成本

// middleware/error_handler.js
module.exports = () => {
  // 返回一个中间件处理函数
  return async function errorHandler(ctx, next) {
    try {
      await next();
    } catch (err) {
      // 所有的异常都在 app 上触发一个 error 事件,框架会记录一条错误日志
      ctx.app.emit("error", err, ctx);

      const status = err.status || 500;
      // 生产环境时 500 错误的详细错误内容不返回给客户端,因为可能包含敏感信息
      const error =
        status === 500 && ctx.app.config.env === "prod"
          ? "Internal Server Error"
          : err.message;
      // 从 error 对象上读出各个属性,设置到响应中
      ctx.body = { error };
      if (status === 422) {
        ctx.body.detail = err.errors;
      }
      ctx.status = status;
    }
  };
};
// config/config.default.js

// add your middleware config here
config.middleware = ["errorHandler"];
config

全局配置文件

/**
 * @param {Egg.EggAppInfo} appInfo app info
 */
const path = require('path')

module.exports = appInfo => {
  /**
   * built-in config
   * @type {Egg.EggAppConfig}
   **/
  const config = (exports = {});

  // use for cookie sign key, should change to your own and keep security
  config.keys = appInfo.name + "_1646547622833_8013";

  // add your middleware config here
  config.middleware = ["errorHandler"];

  // 配置数据库
  config.mongoose = {
    client: {
      url: "mongodb://xxxxxxxxxxxxxxxxxxx",
      options: {
        useUnifiedTopology: true,
      },
      // mongoose global plugins, expected a function or an array of function and options
      plugins: [],
    },
  };
  // 防止网络攻击功能  关闭
  config.security = {
    csrf: {
      headerName: 'x-csrf-token', // csrf 安全
      enable: true,
    },
  };
  // 自定义的全局参数
  config.jwt = {
    secret: "1cd3f51c-d342-4168-88fd-ee535d083c2a",
    expiresIn: "30d",
  };
  // 配置 cors 跨域
  config.cors = {
    origin: '*'
  }
  // 静态资源服务器配置
  config.static = {
    prefix: '/public/',
    dir: path.join(appInfo.baseDir, 'app/public')
  }
  // 上传 body multipart
  config.multipart = {
    // will append to whilelist
    // egg 内置了 egg-multipart插件,有一套默认的文件扩展名白名单,当不在白名单的文件上传时会被拦截
    // fileExtensions 这个就是追加操作,了解更多:https://github.com/eggjs/egg-multipart#upload-multiple-files
    fileExtensions: [".docx", ".doc", ".xlsx", ".woff", ".ttf", ".eot"],
  };

  // add your user config here
  const userConfig = {
    // myAppName: 'egg',
  };

  return {
    ...config,
    ...userConfig,
  };
};

以上就是egg框架的大致介绍,也可以参考官方文档:www.eggjs.org/zh-CN/intro… ,下面正式来实现具体的业务逻辑!

1.用户创建

// 创建用户 controller
  async create() {
    const { ctx, service } = this;
    const body = ctx.request.body;
    const userService = service.user;
    // 参数校验
    ctx.validate({
      username: { type: "string" },
      email: { type: "email" },
      password: { type: "string" },
    });
    // 判断用户是否存在
    if (await userService.findByUsername(body.username)) {
      ctx.throw(422, "用户已存在");
    }
    if (await userService.findByEmail(body.email)) {
      ctx.throw(422, "邮箱已存在");
    }
    // 保存用户
    const user = await userService.creteUser(body);
    // 生成token
    const token = await userService.createToken({ userId: user._id });
    // 发送响应
    ctx.body = {
      data: {
        user: {
          ...ctx.helper._.pick(user, [
            "email",
            "username",
            "channelDescription",
            "avatar",
          ]),
          token,
        },
      },
      msg: "创建用户成功",
      code: 0,
    };
  }
// user.js service
  async creteUser(data) {
    data.password = this.ctx.helper.md5(data.password);
    const user = new this.User(data);
    await user.save();
    return user;
  }

  async createToken(data) {
    return jwt.sign(data, this.app.config.jwt.secret, {
      expiresIn: this.app.config.jwt.expiresIn,
    });
  }

2.登录实现

利用 jwt 实现登录,返回token存储在前端作为凭证

// user.js   controller
const Controller = require('egg').Controller;
// 登录
class UserController extends Controller {
    async login() {
        const { ctx, service } = this;
        const body = ctx.request.body;
        const userService = service.user;
        // 数据验证
        ctx.validate({
          email: { type: "email" },
          password: { type: "string" },
        });
        // 校验邮箱是否存在
        const user = await userService.findByEmail(body.email);
        if (!user) {
          ctx.throw(422, "用户邮箱不存在");
        }
        // 校验密码是否正确
        if (this.ctx.helper.md5(body.password) !== user.password) {
          ctx.throw(422, "用户密码错误");
        }
        // 生成token
        const token = await userService.createToken({ userId: user._id });
        // 发送数据
        ctx.body = {
          data: {
            user: {
              ...ctx.helper._.pick(user, [
                "email",
                "username",
                "channelDescription",
                "avatar",
              ]),
              token,
            },
          },
          msg: "登录成功",
          code: 0,
        };
    }
}
module.exports = UserController;
// user.js  service 
const Service = require('egg').Service
const jwt = require('jsonwebtoken')
class UserService extends Service {
  get User() {
    return this.app.model.User;
  }

  findByUsername(username) {
    return this.User.findOne({
      username,
    });
  }

  findByEmail(email) {
    return this.User.findOne({
      email,
    }).select("+password");
  }

  async createToken(data) {
    return jwt.sign(data, this.app.config.jwt.secret, {
      expiresIn: this.app.config.jwt.expiresIn,
    });
  }

  async verifyToken(token) {
    return jwt.verify(token, this.app.config.jwt.secret);
  }
}

module.exports = UserService;

3.资源上传(重点)

实现的方案是:先将前端本地资源通过接口上传到服务器本地,然后再从服务器本地上传到七牛云,最后把服务器本地的资源删除

upload 接口controller

class UploadController extends Controller {
  async uploadFiles() {
    const { ctx } = this;
    // el-upload 本地图片上传到服务器本地
    const data = await ctx.service.upload.index();
    console.log('=======================', data);
    // 上传到服务器的资源往七牛云空间传输
    // data.params 为夹杂在form-data内的 上传参数
    // data.target 为本地上传到服务器资源的地址
    const res = await ctx.service.upload.uploadQiniu(data.target, data.fileName, data.params);
    // 等到服务器上的资源传输到了七牛云后,删除服务器上传资源,以免占用内存
    await ctx.service.upload.deleteFile(data.target)
    if (res) {
      ctx.body = {
        code: 0,
        msg: "上传成功",
        link: res
      }
    } else {
      ctx.body = {
        message: '上传失败',
      };
    }
  }
}

upload 接口service

// form-data数据流写进服务器本地
async index() {
    const { ctx } = this;
    // 获取form-data数据流
    const stream = await ctx.getFileStream();
    const fileName = stream.filename;
    // 定制资源上传到服务器的存储地址
    const target = path.join(
      this.config.baseDir,
      `app/public/comfiles/${stream.filename}`
    );

    const result = await new Promise((resolve, reject) => {
      // 创建文件 写流
      const remoteFileStream = fs.createWriteStream(target);
      // 将获取到的form-data数据流通过管道写进target地址内
      stream.pipe(remoteFileStream);
      let errFlag;
      // 监控 写 发生错误
      remoteFileStream.on("error", (err) => {
        errFlag = true;
        this.deleteFile(target);
        remoteFileStream.destroy();
        reject(err);
      });
      // 监控 写 结束
      remoteFileStream.on("finish", async () => {
        if (errFlag) return;
        // stream.fields 夹在form-data内的参数
        resolve({ fileName, target, params: stream.fields });
      });
    });
    return result;
}
// 七牛云上传的相关配置初始化
mac() {
    return new Promise((resolve) => {
      const { accessKey, secretKey } = this.config.qiniu;
      var mac = new qiniu.auth.digest.Mac(accessKey, secretKey);
      resolve(mac);
    });
}
// 七牛云上传的token生成
uptoken(bucket, key, params) {
    const { isCoverUpload, prefix } = params;
    return new Promise(async (resolve) => {
      const putPolicy =
        String(isCoverUpload) === "true"
          ? new qiniu.rs.PutPolicy({ scope: `${bucket}:${prefix}${key}` })
          : new qiniu.rs.PutPolicy({ scope: `${bucket}` });
      const mac = await this.mac();
      const token = putPolicy.uploadToken(mac);
      resolve(token);
    });
}
// 服务器本地的资源上传到七牛云
async uploadQiniu(localFile, key, params) {
    const { prefix } = params;
    const { scope, baseUrl } = this.config.qiniu;
    // 生成上传 Token
    const token = await this.uptoken(scope, key, params);
    const config = new qiniu.conf.Config();
    const formUploader = new qiniu.form_up.FormUploader(config);
    const extra = new qiniu.form_up.PutExtra();
    // 创建可读流

    const readerStream = fs.createReadStream(localFile);

    return new Promise((resolve, reject) => {
      const loadTime = Date.now();
      formUploader.putStream(
        token,
        `${prefix}${key}`,
        readerStream,
        extra,
        (err, ret) => {
          if (!err) {
            console.log("上传耗时:", `${Date.now() - loadTime}ms`);
            console.log("操作人", this.ctx.user.username, this.ctx.user.email)
            console.log(
              "操作时间",
              this.ctx.helper.formatTime("YY-mm-dd HH:MM:SS")
            );
            resolve(`${baseUrl}${ret.key}`);
          } else {
            console.log("七牛云上传失败", err);
            reject("七牛云上传失败");
          }
        }
      );
    });
}
// 删除上传到服务器本地的资源
deleteFile(path) {
    fs.unlink(path, (err) => {
      if (err) {
        console.log(err);
      }
    });
}

4.资源刷新

CDN的刷新这就比较简单了,就是调用七牛云的sdk实现就OK

// 文件刷新 service
async refresh() {
    // 刷新参数
    const urlsToRefresh = this.ctx.request.body.urls;
    // 七牛云相关的配置初始化
    const mac = await this.mac();
    const cdnManager = new qiniu.cdn.CdnManager(mac);
    //刷新链接,单次请求链接不可以超过100个,如果超过,请分批发送请求
    return new Promise((resolve, reject) => {
      cdnManager.refreshUrls(urlsToRefresh, function (err, respBody, respInfo) {
        if (err) {
          throw err;
        }
        console.log("statusCode==============", respInfo.statusCode);
        if (respInfo.statusCode == 200) {
          resolve(respInfo)
        } else {
          reject(respInfo)
        }
      });
    })
}
// 目录刷新 service
async refreshDir() {
    const dirsToRefresh = this.ctx.request.body.urls;
    const mac = await this.mac();
    const cdnManager = new qiniu.cdn.CdnManager(mac);
    //刷新链接,单次请求链接不可以超过100个,如果超过,请分批发送请求
    return new Promise((resolve, reject) => {
      cdnManager.refreshDirs(dirsToRefresh, function (err, respBody, respInfo) {
        if (err) {
          throw err;
        }
        console.log("statusCode==============", respInfo);
        if (respInfo.statusCode == 200) {
          resolve(respInfo);
        } else {
          reject(respInfo);
        }
      });
    });
}

3.七牛云资源预览

async getFileList() {
    const query = this.ctx.request.body
    const mac = await this.mac();
    var config = new qiniu.conf.Config();
    // 对应机房
    config.zone = qiniu.zone.Zone_z0;
    var bucketManager = new qiniu.rs.BucketManager(mac, config);
    // bucket空间
    var bucket = this.config.qiniu.scope;
    // @param options 列举操作的可选参数
    // prefix    列举的文件前缀
    // marker    上一次列举返回的位置标记,作为本次列举的起点信息
    // limit     每次返回的最大列举文件数量
    // delimiter 指定目录分隔符
    return new Promise((resolve, reject) => {
      bucketManager.listPrefix(
        bucket,
        query,
        function (err, respBody, respInfo) {
          if (err) {
            console.log(err);
            throw err;
          }
          if (respInfo.statusCode == 200) {
            //如果这个nextMarker不为空,那么还有未列举完毕的文件列表,下次调用listPrefix的时候,
            //指定options里面的marker为这个值
            var nextMarker = respBody.marker;
            var commonPrefixes = respBody.commonPrefixes;
            console.log(nextMarker);
            console.log(commonPrefixes);
            resolve(respBody)
          } else {
            console.log(respInfo.statusCode);
            console.log(respBody);
            reject(respBody)
          }
        }
      );
    })
}

前端页面功能实现

1. 资源上传、CDN刷新

资源上传可以指定上传路径,如果不指定,默认是上传到对应bucket的根目录,当我们不想改变资源在七牛云的路径但资源内容又改变了的时候,就需要用到覆盖上传,这样可以达到路径不边资源被替换成了新资源。使用覆盖上传需要注意原来资源会继续缓存一段时间,导致没法立即看到新上传的资源,怎么解决呢?那就需要用到CDN刷新!CDN刷新分为文件刷新和目录刷新,使用时把需要刷新的链接填入进行刷新即可。

截屏2022-04-06 下午4.23.27.png

2.资源预览

资源预览功能就是提供给用户去搜索对应路径下资源并展示出来,用户也可以自行输入想要查询的路径。展示资源的每一项都有两个按钮,可以单独的去刷新某个资源,复制某个资源的链接,当遇到图片时可以点击预览大图

截屏2022-04-06 下午4.32.27.png

截屏2022-04-06 下午4.43.24.png

服务部署

服务部署走了不少弯路,想着是和前端静态包一样,直接上云效部署,后面一直没部署成功,都不知道是构建docker镜像出了问题还是k8s集群编排除了问题。本着复杂的问题简单化原则,将构建和部署分开,一步一步推进

1. 本地安装docker客户端

2. 构建镜像

  • 编写Dockerfile
FROM node

RUN mkdir -p /home/egg

WORKDIR /home/egg

COPY package.json /home/egg/package.json

RUN npm install --registry=https://registry.npm.taobao.org

COPY . /home/egg

EXPOSE 7001

CMD npm run start
  • 构建镜像
$ docker image build -t koa-demo .
# 或者
$ docker image build -t koa-demo:0.0.1 .
  • 生成容器
$ docker container run -p 8000:7001 -it koa-demo /bin/bash
# 或者
$ docker container run -p 8000:7001 -it koa-demo:0.0.1 /bin/bash
  • -p参数:容器的 7001 端口映射到本机的 8000 端口。
  • -it参数:容器的 Shell 映射到当前的 Shell,然后你在本机窗口输入的命令,就会传入容器。
  • koa-demo:0.0.1:image 文件的名字(如果有标签,还需要提供标签,默认是 latest 标签)。
  • /bin/bash:容器启动以后,内部第一个执行的命令。这里是启动 Bash,保证用户可以使用 Shell。

上面操作一切正常后显示这样

root@66d80f4aaf1e:/app# 

3. 启动服务

可以 Dockerfile CMD命令,也可以手动输入命令启动,当然如果你下载了docker客户端,也可以在客户上启动

点击镜像后的启动按钮启动 image.png 已经启动的在运行的镜像 image.png 推荐阮一峰大佬的docker入门文章

最后浏览器访问 localhost:8000

在本地运行成功后才可证明构建镜像是OK的

4. 上云效流水线

我就不详细的分享云效流水线相关的配置,juejin.cn/post/703441…

截屏2022-03-14 下午11.16.17.png

注意:

截屏2022-03-14 下午11.22.30.png

遇到的坑

当项目开发完后测试阶段,上传内存较大的视屏文件,一直报 413 size too large ,上网查一直说是 egg 配置问题,后面果断禁用这个 bodyParser,还是无济于事,后面经排查才知道是nginx有限制,加上这条就OK了client_max_body_size 1024M;

location ^~/api {
    client_max_body_size 1024M;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_buffering off;
    rewrite ^/api/(.*)$ /$1 break;
    proxy_pass http://gateway.caibeitv.com/;
}

最后

试着通过nodejs和前端技术结合去解决一些问题,你会得意想不到的收货。也能在团队中刷刷存在感。