撸一个文件上传 nodejs 服务

最近因为公司业务,需要做文件上传功能,说到这,很多人脑袋里会冒出一个想法:这还不简单,一个 file input 控件就搞定了。

事情,当然没有那么简单,因为除了基本的文件上传外,还需要支持切片上传、断点续传、秒传、跨浏览器断点续传等。

因为该项目的技术栈是 vue,所以在前端侧,我找了一个基于 vue 实现了文件切片上传和文件队列管理的插件 vue-simple-uploader。

关于前端侧的功能可以通过该插件解决,而在编写文件上传功能时,因为后端接口还未提供,所以心血来潮,自己用 nodejs 撸了一个上传服务 vue-uploader-service用于调试,在这里分享一下:

上传流程

  • 用户选择需要上传的文件
  • 发送请求校验文件的上传状态
  • 上传文件切片
  • 合并文件切片

用户选择文件及上传交互等逻辑在前端侧解决,所以这里讲讲一下几个方面:

校验文件上传状态

此接口主要做的操作是判断用户将要上传的文件在服务端是否存在,若存在,则返回标识告知用户可以跳过上传操作,直接上传成功,主要步骤和部分代码如下:

  1. 根据文件唯一标识和文件名生成文件访问路径
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
})
复制代码
  1. 判断文件是否已存在,若存在,返回可以秒传以及文件下载路径
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
})
复制代码
  1. 若完整文件不存在,则返回该文件已上传的切片列表
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 }), '已上传部分切片'))
})
复制代码

以上即为文件上传之前校验文件状态的逻辑,前端侧可以根据不同状态决定是否发起文件上传、哪些切片需要上传。

上传文件切片

若即将上传的文件在服务端不存在,前端侧即开始调用切片上传接口,其逻辑如下:

  1. 将前端上传的切片存放到对应的临时目录
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,有兴趣的同学可以了解一下

  1. 校验文件切片是否上传完全
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)
            }))
        })
})
复制代码

isCompletetrue 表示文件切片上传完全,可以进行合并操作了

合并文件切片

若文件切片上传完全,此接口对文件所有切片进行合并,并返回文件的下载路径

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,所以较为简陋,仅用于提供思路交流。有什么好的想法见解,期待大家提出交流。

以上为本次文章所有内容,若有问题,望指正;若需转载,望注明出处

转载于:https://juejin.im/post/5cf1fff2f265da1b7b317230

你可能感兴趣的:(前端,json,javascript,ViewUI)