Egg.js文件上传及重复校验

2,210 阅读5分钟

这是我参与8月更文挑战的第2天,活动详情查看:8月更文挑战

在日常业务编写中,经常用到文件的上传功能。本文章介绍通过Eggjs Multipart 模块的 file 模式 上传文件,通过文件 md5 校验文件是否存在,不在服务器重复生成文件。适合头像等小文件上传。

上传资源存放在服务器配置的静态资源目录,通过nginx解析静态资源,方便需要的时候可以整体打包上传的各种云oss。代码中默认返回资源不带域名,可以在前端配置静态资源域名拼接,或者在Sequelize获取器中拼接域名。

一、文件上传的流程

  1. 框架会将提交过来的文件在配置中的 tmpdir 目录中创建成为临时文件。
  2. 并且把文件的基本信息和临时文件的绝对路径返回,我们用框架返回的临时文件文件绝对路径就能通过 fs 操作文件。
  3. 将文件保存到配置的 userConfig.file.disk 目录下。通过nginx将 disk 目录解析为静态资源域名,就能够访问到上传的文件。
// 获取到文件上传请求示例
 const files = this.ctx.request.files;
 console.log(files);
// 结果打印出请求中的文件信息
[{
  field: 'file',
  filename: '2.png',
  encoding: '7bit',
  mime: 'image/png',
  fieldname: 'file',
  transferEncoding: '7bit',
  mimeType: 'image/png',
  filepath: '/myproject/tmp/egg-base-api/2021/07/27/15/3a809821-023a-4cbd-98ad-40787ac516d8.png'
}]

二、配置文件

修改配置 multipart

  • mode 文件上传方式,默认为stream形式上传。

  • tmpdir 临时目录路径,建议配置,方便清除上传的临时文件。

  • fileExtensions 上传支持的文件扩展名,如果用 whitelist 就会覆盖框架默认的白名单。

  • cleanSchedule 自动清除临时文件的配置,框架默认为每天凌晨4:30清除今天之前的临时文件。可以配置cron表达式。

新增用户自定义配置:

  • userConfig.file.disk 配置服务器存放上传资源的目录,需要给node写权限。
// config/config.default.js
const path = require('path');
module.exports = appInfo => {
  const config = exports = {};
  // ...
  config.multipart = {
    // 文件上传模式,采用临时文件的模式。
    mode: 'file',
    // 配置临时文件目录,修改成项目根目录下tmp中存放,方便管理
    tmpdir: path.join(__dirname, '..', 'tmp', appInfo.name),
    // 允许上传类型的白名单
    fileExtensions: [ '.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', ],
    // 文件大小限制
    fileSize: '20mb',
    cleanSchedule: { cron: '0 30 4 * * *', },
  };
  // 自定义配置
  const userConfig = {
    file: {
      // 自己静态资源存放的路径
      disk: '/home/www/static/upload',
    },
  };
};

三、数据表结构和模型

  • 表结构

    path 文件相对路径,可以和静态资源域名拼接成完整可访问url;

    name 文件的原始名称;

    ext 文件后缀;

    size 文件大小;

    md5 文件的hash值,用来判断文件的唯一性。

CREATE TABLE `file` (
  `id` int NOT NULL AUTO_INCREMENT,
  `path` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '文件路径',
  `name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '文件原始名称',
  `ext` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '文件后缀',
  `size` int NOT NULL DEFAULT '0' COMMENT '文件大小',
  `md5` char(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '文件md5',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
  `delete_time` datetime DEFAULT NULL COMMENT '是否删除 逻辑删除',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=11 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
  • model

    模型中用到了 逻辑删除获取器 。可以参考上篇文章

// app/model/file.js
const dayjs = require('dayjs');
module.exports = app => {
  const { STRING, INTEGER, DATE } = app.Sequelize;
  return app.model.define('file', {
    id: { type: INTEGER, primaryKey: true, autoIncrement: true, },
    path: STRING,
    name: STRING,
    ext: STRING,
    size: STRING,
    md5: STRING,
    create_time: {
      type: DATE,
      get () {
        const time = this.getDataValue('create_time');
        return time ? dayjs(time).format('YYYY-MM-DD HH:mm:ss') : null;
      },
    },
    update_time: {
      type: DATE,
      get () { // 获取器,获取数据时候格式化时间
        const time = this.getDataValue('update_time');
        return time ? dayjs(time).format('YYYY-MM-DD HH:mm:ss') : null;
      },
    },
    delete_time: DATE,
  }, {
    freezeTableName: false,
    tableName: 'file',
    underscored: false,
    paranoid: true,
    timestamps: true, // 开启自动时间才能使用逻辑删除
    createdAt: false,
    updatedAt: false,
    deletedAt: 'delete_time', // 逻辑删除字段
  });
};

四、路由、控制器和服务

  • router
// app/router.js
module.exports = app => {
  const { router, controller } = app;
  router.post('/admin/file', controller.file.upload);
};
  • controller
// app/controller/file.js
const BaseController = require('./base');
class FileController extends BaseController {
  async upload () {
    // 如果使用多文件上传传入false即可(ctx.service.file.uploadFile(false))
    const data = await this.ctx.service.file.uploadFile(true);
    this.success(data);
  }
}
module.exports = FileController;
  • service

    具体逻辑如下,也可以使用其他语言实现。

    1. 获取到请求中的全部文件,循环。

    2. 获取到文件的基本信息:文件大小、MD5、fileBuffer。

    3. 通过文件MD5在数据库中查询是否存在。

    4. 存在则直接返回文件信息;不存在将文件保存至静态资源目录,文件信息入库,返回文件信息。

// app/service/file.js
const Service = require('egg').Service;
const crypto = require('crypto');
const UUID = require('uuid').v4;
const dayjs = require('dayjs');
const fs = require('fs');
const path = require('path');
// 自定义文件上传失败异常
const FileException = require('../exception/file');

class FileService extends Service {
  // 文件上传方法,single:true单文件 single:false多文件 
  async uploadFile (single = false) {
    if (!this.ctx.request.files || this.ctx.request.files.length === 0) {
      throw new FileException('请选择文件');
    }
    const rets = [];
    // 拿到临时文件循环操作
    const files = this.ctx.request.files;
    for (let i = 0; i < files.length; i++) {
      if (single && i > 0) { // 如果单文件上传只处理第一张,然后结束循环
        break;
      }
      const file = files[i];
      try {
        // 获取文件的基本信息:大小,hash,fileBuffer
        const { size, md5, data } = await this.checkFileInfo(file);
        // 数据库中查询是否存在,如果存在就直接返回文件信息,不保存文件。
        const exists = await this.checkFileExists(md5);
        if (exists) {
          rets.push({ key: i, ...exists });
        } else { // 如果不存在就将文件保存到服务器中,并且数据库中创建记录。
          // 保存文件到硬盘
          const res = await this.putFile(data, file.filename);
          // 保存文件信息到数据库
          await this.app.model.File.create({
            path: res.path,
            name: file.filename,
            ext: res.ext,
            size,
            md5,
            create_time: new Date(),
          });
          rets.push({
            key: i,
            path: res.path,
            url: '/' + res.path,
          });
        }
      } catch (e) {
        throw new FileException('文件上传失败');
      }
    }
    return single ? rets[0] : rets;
  }
  
  // 获取文件信息
  async checkFileInfo (file) {
    return new Promise((resolve, reject) => {
      const fsHash = crypto.createHash('md5');
      fs.readFile(file.filepath, (err, data) => { // 读取文件
        if (err) {
          reject(err);
        } else {
          // 获取文件md5
          const md5 = fsHash.update(data).digest('hex');
          // 获取文件大小
          const size = data.length;
          resolve({ size, md5, data });
        }
      });
    });
  }
  
  // 保存文件到硬盘
  async putFile (flieBuffer, filename) {
    return new Promise((resolve, reject) => {
      const { dir, local } = this.createUploadPath();
      const ext = this.getFileExt(filename);
      // 保存的文件名,使用的UUID
      const targetName = UUID() + '.' + ext;
      // 写文件
      fs.writeFile(dir + '/' + targetName, flieBuffer, err => {
        if (err) {
          reject(err);
        } else {
          resolve({
            path: local + '/' + targetName,
            ext,
          });
        }
      });
    });
  }
  
  // 数据库中用文件的md5查找是否存在
  async checkFileExists (md5) {
    const exists = await this.app.model.File.findOne({ where: { md5 } });
    // 如果文件存在就返回文件信息
    if (exists !== null) {
      return {
        id: exists.id,
        path: exists.path,
        url: '/' + exists.path,
      };
    }
    return null;
  }
  
  // 获取不带.的后缀名
  getFileExt (filename) {
    const ext = path.extname(filename);
    if (this.app._.startsWith(ext, '.')) {
      return ext.substr(1);
    }
    return ext;
  }
  
  // 创建文件保存目录,默认每天一个目录
  createUploadPath () {
    // 获取配置中文件存放位置
    const disk = this.app.config.file.disk;
    const local = dayjs().format('YYYYMMDD');
    const dir = disk + '/' + local;
    if (!fs.existsSync(dir)) { // 目录不存在就创建一个目录
      fs.mkdirSync(dir);
    }
    return { dir, local };
  }
}

module.exports = FileService;

四、上传测试

  • 单文件上传 (service.uploadFile(true))

image-20210727155801617

  • 多文件上传 (service.uploadFile(false))

image-20210727155939253