这是我参与8月更文挑战的第2天,活动详情查看:8月更文挑战
在日常业务编写中,经常用到文件的上传功能。本文章介绍通过Eggjs Multipart 模块的 file 模式 上传文件,通过文件 md5 校验文件是否存在,不在服务器重复生成文件。适合头像等小文件上传。
上传资源存放在服务器配置的静态资源目录,通过nginx解析静态资源,方便需要的时候可以整体打包上传的各种云oss。代码中默认返回资源不带域名,可以在前端配置静态资源域名拼接,或者在Sequelize获取器中拼接域名。
一、文件上传的流程
- 框架会将提交过来的文件在配置中的
tmpdir目录中创建成为临时文件。 - 并且把文件的基本信息和临时文件的绝对路径返回,我们用框架返回的临时文件文件绝对路径就能通过
fs操作文件。 - 将文件保存到配置的
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
具体逻辑如下,也可以使用其他语言实现。
-
获取到请求中的全部文件,循环。
-
获取到文件的基本信息:文件大小、MD5、fileBuffer。
-
通过文件MD5在数据库中查询是否存在。
-
存在则直接返回文件信息;不存在将文件保存至静态资源目录,文件信息入库,返回文件信息。
-
// 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))
- 多文件上传 (service.uploadFile(false))