最近因为公司业务,需要做文件上传功能,说到这,很多人脑袋里会冒出一个想法:这还不简单,一个 file input 控件就搞定了。
事情,当然没有那么简单,因为除了基本的文件上传外,还需要支持切片上传、断点续传、秒传、跨浏览器断点续传等。
因为该项目的技术栈是 vue
,所以在前端侧,我找了一个基于 vue
实现了文件切片上传和文件队列管理的插件 vue-simple-uploader。
关于前端侧的功能可以通过该插件解决,而在编写文件上传功能时,因为后端接口还未提供,所以心血来潮,自己用 nodejs 撸了一个上传服务 vue-uploader-service用于调试,在这里分享一下:
上传流程
- 用户选择需要上传的文件
- 发送请求校验文件的上传状态
- 上传文件切片
- 合并文件切片
用户选择文件及上传交互等逻辑在前端侧解决,所以这里讲讲一下几个方面:
校验文件上传状态
此接口主要做的操作是判断用户将要上传的文件在服务端是否存在,若存在,则返回标识告知用户可以跳过上传操作,直接上传成功,主要步骤和部分代码如下:
- 根据文件唯一标识和文件名生成文件访问路径
const router = require('express').Router()
const sparkMD5 = require('spark-md5')
// 封装的文件操作代码
const { genFilePath } = require('../utils/file')
// GET /upload 接口内部代码
router.get('/', (req, res) => {
const { identifier, filename } = req.query
const name = decodeURIComponent(filename)
const fileHash = sparkMD5.hash(name + identifier)
const filePath = genFilePath(fileHash)
// ... other code
})
复制代码
- 判断文件是否已存在,若存在,返回可以秒传以及文件下载路径
const router = require('express').Router()
const sparkMD5 = require('spark-md5')
// 产品本身 hash 用于生成文件签名
const proHash = sparkMD5.hash('This is your flag')
// 文件签名生成字段间的合并符号
const hashSeparator = '!!!'
// 封装的生成响应对象的方法
const { success } = require('../utils/response')
// GET /upload 接口内部代码
router.get('/', (req, res) => {
// ... 根据文件唯一标识和文件名生成文件访问路径代码
const result = {
isRapidUpload: false,
url: '',
uploadedChunks: []
}
if(fs.existsSync(filePath)) {
// 文件签名
const sig = sparkMD5.hash([proHash, name, fileHash].join(hashSeparator))
return res.json(success(Object.assign(result, {
isRapidUpload: true,
url: [
'http://localhost:3000/upload',
'file', fileHash, filename, sig
].join('/')
}), '可以秒传'))
}
// ... other code
})
复制代码
- 若完整文件不存在,则返回该文件已上传的切片列表
const router = require('express').Router()
const sparkMD5 = require('spark-md5')
// 切片名的前缀
const chunkNamePre = 'chunk'
// 封装的文件操作代码
const { genChunkDir } = require('../utils/file')
// 封装的生成响应对象的方法
const { success } = require('../utils/response')
// GET /upload 接口内部代码
router.get('/', (req, res) => {
// ... 根据文件唯一标识和文件名生成文件访问路径代码
// ... 判断文件是否已存在,若存在,返回可以秒传以及文件下载路径 代码
const chunkDir = genChunkDir(identifier)
const existsChunks = fs.readdirSync(chunkDir)
// 没有上传的切片
if(!existsChunks.length) return res.json(success(result, '文件不存在'))
// 上传了部分切片
const uploadedChunks = existsChunks.map(chunk => parseInt(chunk.slice(chunkNamePre.length)))
res.json(success(Object.assign(result, { uploadedChunks }), '已上传部分切片'))
})
复制代码
以上即为文件上传之前校验文件状态的逻辑,前端侧可以根据不同状态决定是否发起文件上传、哪些切片需要上传。
上传文件切片
若即将上传的文件在服务端不存在,前端侧即开始调用切片上传接口,其逻辑如下:
- 将前端上传的切片存放到对应的临时目录
const router = require('express').Router()
const multer = require('multer')
const upload = multer({ dest: path.join(__dirname, '../tpl/') })
// 切片名的前缀
const chunkNamePre = 'chunk'
// 封装的文件操作代码
const { genChunkDir } = require('../utils/file')
router.post('/', upload.single(fileParamName), async (req, res) => {
const { identifier, chunkNumber, totalSize, chunkSize } = req.body
// 生成切片临时存储目录
const chunksDir = genChunkDir(identifier)
// 根据块索引和文件唯一标识生成存储的每个块临时文件名
const chunkPath = chunksDir + '/' + chunkNamePre + chunkNumber
// 将上传的块文件重命名为上面生成的文件名
fs.renameSync(req.file.path, chunkPath)
// 轮询校验每个块是否都存在,若每个块都存在则响应上传完成
let currChunkNumber = 1
const totalChunks = Math.max(Math.floor(totalSize / chunkSize), 1)
const chunkPathList = []
while(currChunkNumber <= totalChunks) {
const currChunkPath = chunksDir + '/' + chunkNamePre + currChunkNumber
chunkPathList.push(currChunkPath)
currChunkNumber++
}
Promise.all(chunkPathList.map(chunkPath => testChunkExist(chunkPath)))
.then(resultList => {
res.json(success({
isComplete: resultList.every(result => result)
}))
})
})
复制代码
此处用到了一个 nodejs
处理文件上传的插件 multer,有兴趣的同学可以了解一下
- 校验文件切片是否上传完全
const router = require('express').Router()
const multer = require('multer')
const upload = multer({ dest: path.join(__dirname, '../tpl/') })
// 切片名的前缀
const chunkNamePre = 'chunk'
// 封装的文件操作代码
const { testChunkExist } = require('../utils/file')
router.post('/', upload.single(fileParamName), async (req, res) => {
const { identifier, chunkNumber, totalSize, chunkSize } = req.body
// 校验每个切片是否都存在,若每个块都存在则响应上传完成
let currChunkNumber = 1
const totalChunks = Math.max(Math.floor(totalSize / chunkSize), 1)
const chunkPathList = []
while(currChunkNumber <= totalChunks) {
const currChunkPath = chunksDir + '/' + chunkNamePre + currChunkNumber
chunkPathList.push(currChunkPath)
currChunkNumber++
}
// 异步批量校验文件切片是否存在
Promise.all(chunkPathList.map(chunkPath => testChunkExist(chunkPath)))
.then(resultList => {
res.json(success({
isComplete: resultList.every(result => result)
}))
})
})
复制代码
isComplete
为 true
表示文件切片上传完全,可以进行合并操作了
合并文件切片
若文件切片上传完全,此接口对文件所有切片进行合并,并返回文件的下载路径
const router = require('express').Router()
const sparkMD5 = require('spark-md5')
const childProcess = require('child_process')
// 产品本身 hash 用于生成文件签名
const proHash = sparkMD5.hash('This is your flag')
// 文件签名生成字段间的合并符号
const hashSeparator = '!!!'
// 封装的文件操作代码
const { testChunkExist, genFilePath, writeChunks } = require('../utils/file')
const { success, fail } = require('../utils/response')
router.post('/merge', (req, res) => {
const { identifier, fileName } = req.body
const chunkDir = genChunkDir(identifier)
// 文件名+后缀
const name = decodeURIComponent(fileName)
// 生成文件实际存储名称
const fileHash = sparkMD5.hash(name + identifier)
// 文件实际存储路径
const filePath = genFilePath(fileHash)
// 文件签名
const sig = sparkMD5.hash([proHash, name, fileHash].join(hashSeparator))
// 获取切片目录下的所有切片路径并排序
try {
// 将各切片写入最终路径
const writeSuccess = writeChunks(chunkDir, filePath, chunkNamePre)
if(!writeSuccess) return res.json(fail(101, void 0, '该文件分片不存在'))
// 删除缓存切片目录
childProcess.exec(`rm -rf ${chunkDir}`)
// 返回该文件访问 url
res.json(success({
url: [
'http://localhost:3000/upload',
'file', fileHash, fileName, sig
].join('/')
}, '切片合并成功'))
} catch(err) {
childProcess.exec(`rm -rf ${chunkDir}`)
res.json(fail(102, void 0, '切片合并失败'))
}
})
复制代码
在文件切片合并完成后进行删除临时切片目录操作,因为该操作与本次接口响应没有必然联系,所以此处开启了子进程进行操作,以避免影响响应时间
总结
以上即为简单的支持文件切片上传的接口,因为是自己使用的 demo,所以较为简陋,仅用于提供思路交流。有什么好的想法见解,期待大家提出交流。
以上为本次文章所有内容,若有问题,望指正;若需转载,望注明出处