阅读 248

JavaScript高级 - nodejs+koa2实现文件上传大文件切片上传断点续传(服务器端)

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

前言

最近由于项目需要,研究了一下JS中的文件上传,并做了一下简单的总结和梳理。下面将对该功能分两部分(服务端和客户端)进行分别介绍。本文将只介绍服务端部分。

一、环境准备及第三方库

  • 我们将采用nodejs+koa2来实现文件上传的服务端部分
  • nodejs版本:v12.16.1
  • koa版本:v2.13.1
  • formidable:v1.2.2 用于解析post请求中传递的参数,Content-Type为:application/x-www-form-urlencoded
  • koa-router:v10.0.0 用于配置路由
  • koa-static: v5.0.0 读取静态文件中间件
  • koa2-cors: v2.0.6 用于cors跨域设置
  • multiparty:v4.2.2 用于解析和上传form-data文件
  • spark-md5:v3.0.1 用于根据文件内容生成字符串(相同内容的文件生成的值是一样的)
  • nodemon:v2.0.7 用该命令运行js文件可自动重启服务

二、项目结构

server:根目录

  • node_modules:自动生成,存放所有依赖库
  • upload:用于存放上传后的文件
  • server.js:api接口文件,定义所有的上传接口
  • package.json:项目的配置文件

三、API接口说明(server.js)

1.基本配置

  • 首先我们先将所有要用到的库在文件的头部引入
  • 配置一些全局属性,如服务器地址、端口号、文件保存路径等等
  • 因为是前后端分离,所有可能会涉及到跨域请求,需要配置cors跨域
  • 最后再应用一下koastatic中间件用于访问静态资源文件

整体骨架代码如下:

const Koa = require('koa');
const Router = require('koa-router');
const koastatic = require('koa-static');
const fs = require('fs');
const formidable = require('formidable');
const multiparty = require('multiparty');
const SparkMD5 = require('spark-md5');
const path = require('path');
const app = new Koa();
let router = new Router();

//中间件:设置允许跨域
app.use(async (ctx, next) => {
    ctx.set('Access-Control-Allow-Origin', '*');
    //处理OPTIONS请求
    ctx.request.methods === 'OPTIONS' ? ctx.body = '请求测试成功' : await next();
});

const host = '127.0.0.1',
    port = 3000;
const HOSTNAME = `${host}:${port}`;
const SERVER_PATH = `${__dirname}/upload`;
app.listen(port, function () {
    console.log('======================================================================');
    console.log(`The server started at port: ${port}, you can access it by ${HOSTNAME}`);
    console.log('======================================================================');
});

//.......
//这里添加文件上传的API接口
//.......

app.use(koastatic('./'));
app.use(router.routes());
app.use(router.allowedMethods());
复制代码

2. 公共函数封装

在实现各个接口前难免会涉及到一些重复的代码,这个时候我们就需要进行一些必要的封装,以实现代码的复用。 接下来我们就一起来分析并封装一下常用的方法

  • 检测文件是否存在
    • 本案例中我们采用nodejs内置的fs模块的access函数来检测文件是否已经存在
    • 利用Promise进行管理
    • 关键代码:fs.access(filepath, fs.constants.F_OK, err=>{...})
    • 另:fs模块中有个exists方法也可以用于检测文件是否存在,但官方文档中已经弃用,并推荐使用access方法。
 //检测文件是否已经存在
const exists = function exists(path){
	return new Promise((resolve, reject)=>{
		fs.access(path, fs.constants.F_OK, err=>{
			if(err){
				resolve(false);
				return;
			}
			resolve(true);
		});
	});
}
复制代码
  • 利用multiparty解析请求中的文件信息
    • 利用该插件可实现form-data格式的请求中的文件解析和提取
    • 该模块配置项中有个uploadDir属性,如果指定则会自动上传文件
    • 因为本文还将涉及到自定义文件名上传文件,所以这里我们将该方法封装为可配置的
    • 关键代码:new multiparty.From(config).parse(req,(err, fields, files)=>{})
//利用multiparty插件解析前端传来的form-data格式的数据,并上传至服务器
const multipartyUpload = function multipartyUpload(req, autoUpload){
	let config = {
		maxFieldsSize : 200 * 1024 *1024
	}
	if(autoUpload) config.uploadDir = SERVER_PATH;
	return new Promise((resolve,reject)=>{
		new multiparty.Form(config).parse(req, (err, fields, files)=>{
			if(err){
				reject(err);
				return;
			}
			resolve({
			fields,
			files
			});
		});
	});
}
复制代码
  • 将文件内容写入服务器
    • 该方法中我们将以两种方式进行文件的写入
    • 一种是:对于前端传过来的form-data格式的文件我们将以流的方式进行写入
    • 另一种:则是将内容直接写入到文件,比如BASE64格式的图片文件
    • 这里我们将用到内置模块fs中的createReadStream和createWriteStream用于以流的方式写入,还有fs中的writeFile用于将内容直接写入
    • 关键代码: fs.createReadStream(filepath); fs.createWriteStream(serverpath); readStream.pipe(writeStream); fs.writeFile(serverpath,file,err=>{...})
//将传进来的文件数据写入服务器
//form-data格式的数据将以流的形式写入
//BASE64格式数据则直接将内容写入
const writeFile = function writeFile(serverPath, file, isStream){
	return new Promise((resolve, reject)=>{
		if(isStream){
			try{
				let readStream = fs.createReadStream(file.path);
				let writeStream = fs.createWriteStream(serverPath);
				readStream.pipe(writeStream);
				readStream.on('end',()=>{
					resolve({
						result: true,
						message: '上传成功!'
					});
					fs.unlinkSync(file.path);
				});
			}catch(err){
				resolve({
					result: false,
					message: err
				})
			}
		}else{
			fs.writeFile(serverPath,file, err=>{
				if(err){
					resolve({
						result: false,
						message: err
					})
					return;
				}
				resolve({
					result: true,
					message: '上传成功!'
				});
			});
		}
	});
}
复制代码
  • post请求中application/json或application/x-www-form-urlencoded格式的参数解析
    • 对于from-data形式的文件信息我们可以用multiparty插件进行解析和提取,但是对于其它格式的一些参数,multiparty就无法解析到了
    • 这里我们利用formidable进行解析post参数
    • formidable可以将a=xxx&b=zzz格式的参数解析为json格式,前端请求时需用qs进行相应的格式处理
    • 另外:koa-bodyparser也可以用来解析,但是尝试用了一下都没有成功,所以改用formidable
    • 关键代码:new formidable.IncomingForm().parse(req,(err, fields)=>{...})
//解析post请求参数,content-type为application/x-www-form-urlencoded 或 application/josn
const parsePostParams = function parsePostParams(req){
	return new Promise((resolve, reject)=>{
		let form = new formidable.IncomingForm();
		form.parse(req,(err,fields)=>{
			if(err){
				reject(err);
				return;
			}
			resolve(fields);
		});
	});
}
复制代码
  • 大文件切片上传后再合并
    • 在后面要讲的大文件切片上传,断点续传中,一个大文件会被切成n多个小文件上传
    • 在上传后我们需要再将这些切片文件合并成一个文件,从而实现大文件上传
    • 主要利用内置fs模块中的appendFileSync和readFileSync方法
    • 关键代码:fs.appendFileSync(serverFilePath, fs.readFileSync(filePath))
const mergeFiles = mergeFiles(hash, count){
	return new Promise((resolve, reject)=>{
		const dirPath = `${SERVER_PATH}/${hash}`;
		if(!fs.existsSync(dirPath)){
			reject('还没上传文件,请先上传文件');
			return;
		}
		const filelist = fs.readdirSync(dirPath);
		if(filelist.length < count){
			reject('文件还未上传完成,请稍后再试');
			return;
		}
		let suffix;
		filelist.sort((a,b)=>{
			const reg = /_(\d+)/;
			return reg.exec(a)[1] - reg.exec(b)[1];
		}).forEach(item =>{
		 !suffix ? suffix = /\.([0-9a-zA-Z]+)$/.exec(item)[1]: null;
		 //将每个文件读取出来并append到以hash命名的新文件中
		 fs.appendFileSync(`${SERVER_PATH}/${hash}.${suffix}`, fs.readFileSync(`${dirPath}/${item}`));
		 fs.unlinkSync(`${dirPath}/${item}`);//删除切片文件
		});
		
		await delay(1000);//等待1秒后删除新产生的文件夹
		fs.rmdirSync(dirPath);
		resolve({
			path:`${HOSTNAME}/upload/${hash}.${suffix}`,
			filename: `${hash}.${suffix}`;
		})
	});
}
复制代码
  • 延迟函数
//定义延迟函数
const delay = function delay(interval) {
    typeof interval !== 'number' ? interval = 1000 : null;
    return new Promise((resolve, reject) => {
        setTimeout(function () {
            resolve();
        }, interval);
    });
}
复制代码

3. 文件上传接口

  • 上传form-data格式的单个文件
    • API URL:/upload_single_file
    • 请求方式:post
    • Content-Type:form-data
    • 参数:FormData对象,如:new FormData().append({file: [file]})
    • 返回值:application/json {code:0/1, message:'', filename,filepath}
    • 这个接口比较简单,我们可以利用multiparty插件对request进行文件信息的解析和上传
    • multiparty配置中有个uploadDir属性,如果给这个属性指定了值,那么文件解析完成后会自动上传到该路径下
    • multiparty在自动上传文件时会重新生成文件名后上传
    • 由于前面我们已经利用multiparty封装好了解析和上传的公共方法,所以这里直接调用即可
//上传单个文件(form-data),利用第三方插件multipary解析并上传
router.post('/upload_single_file', async (ctx, next) => {
    try {
        let {
            files
        } = await multipartyUpload(ctx.req, true);
        let file = (files && files.file.length) ? files.file[0] : {};
        ctx.body = {
            code: 0,
            message: '文件上传成功',
            originalFilename: file.originalFilename,
            serverPath: file.path.replace(__dirname, HOSTNAME)
        }
    } catch (err) {
        ctx.body = {
            code: 1,
            message: '文件上传失败'
        }
    }
});
复制代码
  • 上传form-data格式的单个文件,并且由前端定义文件名
    • api:/upload_single_formdata_rename
    • 请求方式:post
    • 参数:FormData对象,如:new FormData().append({file: [file]})
    • Content-Type:form-data
    • 返回值:application/json {code:0/1, message:'', filename,filepath}
    • 这个接口与上面的类似,但不同的是我们需要用前端传给我们的文件名,而不是用multiparty默认生成的
    • 这时就不能用multiparty的自动上传功能了,只需用multiparty解析出文件信息和文件名即可
    • 然后用我们自己封装好的writeFile方法来将文件以流的形式写入到服务器上
//上传单个文件(form-data),利用第三方插件解析但不直接上传,而是将文件重命名后再单独上传
router.post('/upload_single_formdata_rename', async (ctx, next) => {
    try {
        let {
            files,
            fields
        } = await multipartyUpload(ctx.req, false);
        let file = (files && files.file.length) ? files.file[0] : {};
        let filename = (fields && fields.filename.length) ? fields.filename[0] : '';
        const filePath = `${SERVER_PATH}/${filename}`;
        let isExist = await exists(filePath);
        if (isExist) {
            ctx.body = {
                code: 0,
                message: '文件已经存在',
                originalFilename: filename,
                serverPath: file.path.replace(__dirname, HOSTNAME)
            }
            return;
        }
        let obj = await writeFile(filePath, file, true);
        if (obj.result) {
            ctx.body = {
                code: 0,
                message: '文件上传成功',
                originalFilename: filename,
                serverPath: file.path.replace(__dirname, HOSTNAME)
            }
        } else {
            ctx.body = {
                code: 0,
                message: '文件上传失败'
            }
        }
    } catch (ex) {
        ctx.body = {
            code: 0,
            message: ex
        }
    }
});
复制代码
  • 上传BASE64格式的单个图片
    • api:/upload_base64
    • 请求方式:post
    • 参数:file=base64xxxx&filename:xxx
    • Content-Type:application/x-www-form-urlencoded
    • 返回值:application/json {code:0/1, message:'', filename,filepath}
    • 此接口仅用于上传较小的图片文件,图片过大不建议使用该接口,会导致内存消耗程序卡死
    • 需要利用formidable将参数解析出来,可以直接调用我们前面封装好的parsePostParams方法
    • 参数解析出来后,再利用SparkMD5基于文件内容重新生成文件名并上传
    • 调用上面封装的writeFile方法直接将base64格式的内容写入文件
//BASE64上传,该方式只能上传小图片,大图片不建议使用这种方式会造成程序卡死,大图片使用form-data上传
router.post('/upload_base64', async (ctx, next) => {
    try {
        let {
            file,
            filename
        } = await parsePostParams(ctx.req);
        file = decodeURIComponent(file);
        const suffix = /\.([0-9a-zA-Z]+)$/.exec(filename)[1];
        let spark = new SparkMD5.ArrayBuffer();
        file = file.replace(/^data:image\/\w+;base64,/, "");
        file = Buffer.from(file, 'base64');
        spark.append(file);
        let filepath = `${SERVER_PATH}/${spark.end()}.${suffix}`;
        await delay();
        const isExists = await exists(filepath);
        if (isExists) {
            ctx.body = {
                code: 0,
                message: '文件已经存在',
                originalFilename: filename,
                serverPath: file.path.replace(__dirname, HOSTNAME)
            }
            return;
        }
        let obj = await writeFile(filepath, file, false);
        if (obj.result) {
            ctx.body = {
                code: 0,
                message: '文件上传成功',
                originalFilename: filename,
                serverPath: filepath.replace(__dirname, HOSTNAME)
            }
        } else {
            ctx.body = {
                code: 0,
                message: '文件上传失败'
            }
        }
    } catch (err) {
        console.log(err);
        ctx.body = {
            code: 0,
            message: err
        }
    }
});
复制代码
  • 大文件切片上传
    • api:/upload_chunk
    • 请求方式:post
    • 参数:FormData
    • Content-Type:form-data
    • 返回值:application/json {code:0/1, message:'', filename,filepath}
    • 在前端调用接口前会把大文件切成若干个小文件,然后按照“文件名_数字.xxx”的格式编号后分别传给服务端
    • 服务器接收到文件后先解析出文件名(不包含数字部分),并以文件名命名创建一个临时目录用于存放所有的切片文件
    • 由于是form-data个的,同样需要借助multiparty进行文件信息解析
    • 最后调用writeFile方法以流的方式将切片文件保存到临时目录中
//大文件切片上传
router.post('/upload_chunk', async (ctx, next) => {
    try {
        let {
            files,
            fields
        } = await multipartyUpload(ctx.req, false);
        let file = (files && files.file[0]) || {};
        let filename = (fields && fields.filename[0]) || '';
        let [, hash] = /^([^_]+)_(\d+)/.exec(filename);
        const dirPath = `${SERVER_PATH}/${hash}`;
        if (!fs.existsSync(dirPath)) {
            fs.mkdirSync(dirPath);
        }
        const filePath = `${dirPath}/${filename}`;
        const isExists = await exists(filePath);
        if (isExists) {
            ctx.body = {
                code: 0,
                message: '文件已经存在',
                originalFilename: filename,
                serverPath: filePath.replace(__dirname, HOSTNAME)
            }
            return;
        }
        await wirteFile(filePath, file, true);
        ctx.body = {
            code: 0,
            message: '文件上传成功',
            serverPath: filePath.replace(__dirname, HOSTNAME)
        }
    } catch (err) {
        ctx.body = {
            code: 1,
            message: err
        }
    }
});
复制代码
  • 合并所有切片并删除临时目录和切片文件
    • api:/upload_merge
    • 请求方式:post
    • 参数:application/json {hash,count}
    • Content-Type:application/x-www-form-urlencoded
    • 返回值:application/json {code:0/1, message:'', filename,filepath}
    • 在所有的切片文件上传完成后,根据切片的数据和文件的名称(不包含数字部分)将所有切片合并成一个大文件,
    • 切片合并完成后删除所有的切片文件和临时目录
    • 调用parsePostParams解析出文件名(hash)和切片数量(count)参数
    • 调用mergeFiles合并所有切片
//合并切片文件
router.post('/upload_merge', async (ctx, next) => {
    const {
        hash,
        count
    } = await parsePostParams(ctx.req);
    const {
        path,
        filename
    } = await mergeFiles(hash, count);
    ctx.body = {
        code: 0,
        message: '文件上传成功',
        path,
        filename
    }
});
复制代码
  • 获取已上传的切片文件,可用于断点续传
    • api:/uploaded
    • 请求方式:get
    • 参数:application/json {hash}
    • Content-Type:application/x-www-form-urlencoded
    • 返回值:application/json {code:0/1, message:'', filelist}
    • 在上面的切片上传接口中,我们会以文件名(不含数字部分)生成一个临时目录用于保存所有的切片
    • 在每次大文件切片上传前都应先调用一个该接口,查看是否有已经上传的切片,如果已经上传就直接跳过,这样既可实现断点续传
//获取已上传的切片
router.get('/uploaded', async (ctx, next) => {
    try {
        const {
            hash
        } = ctx.request.query;
        const dirPath = `${SERVER_PATH}/${hash}`;
        const filelist = fs.readdirSync(dirPath);
        filelist.sort((a, b) => {
            const reg = /_([\d+])/;
            return reg.exec(a)[1] - reg.exec(b)[1];
        });
        ctx.body = {
            code: 0,
            message: '获取成功',
            filelist
        }
    } catch (err) {
        ctx.body = {
            code: 0,
            message: '获取失败'
        }
    }
});
复制代码

总结

  • 以上全部就是文件上传的接口说明及实现,真实项目中可以根据不同的需求调用不同的接口,下一篇文章中我们将介绍一下前端部分的实现和对接口的调用
文章分类
前端
文章标签