nodeJs + vueJs 实现大文件分片上传
在常规的应用场景中,很少需要在浏览器上传几百兆、几千兆的文件,但假如在特殊场景中需要浏览器上传超大文件,那么我们如何上传?如何优化?总体来说,主要还是因为上传时间太长容易引发不可控的意外:
- 网络波动无法控制
- 想暂停,如何续传
- 先来一个简单上传
# 简单上传
前端代码:
<!-- App.vue 页面模板 -->
<template>
<div>
<input type="file" @change="uploadFile">
</div>
</template>
2
3
4
5
6
// axios/index.js axios 请求
import Axios from 'axios'
const Server = Axios.create({
baseURL: '/api'
})
export default Server
// main.js
import Vue from 'vue'
import App from './App.vue'
import Axios from './axios'
Vue.config.productionTip = false
Vue.prototype.$http = Axios
new Vue({
render: h => h(App),
}).$mount('#app')
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// App.vue js
<script>
export default {
methods: {
// input改变事件监听
uploadFile(e) {
const file = e.target.files[0]
this.sendFile(file)
},
// 文件上传方法
sendFile(file) {
let formdata = new FormData()
formdata.append("file", file)
this.$http({
url: "/upload/file",
method: "post",
data: formdata,
headers: { "Content-Type": "multipart/form-data" }
}).then(({ data }) => {
console.log(data, 'upload/file')
})
},
}
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
nodeJs:
const Koa = require('koa')
const router = require('koa-router')() // koa路由模块
const koaBody = require('koa-body') //解析文件上传的插件
const fs = require('fs') // nodeJs内置文件模块
const path = require('path') // nodeJs内置路径模块
const uploadPath = path.join(__dirname, 'public/uploads') // 定义文件上传目录
// 如果初始没有改文件目录,则自动创建
if (!fs.existsSync(uploadPath)) {
fs.mkdirSync(uploadPath)
}
const app = new Koa() // 实例化
// 一些自定义的全局请求处理
app.use(async (ctx, next) => {
console.log(`Process ${ctx.request.method} ${ctx.request.url}...`);
if (ctx.request.method === 'OPTIONS') {
ctx.status = 200
}
try {
await next();
} catch (err) {
ctx.status = err.statusCode || err.status || 500
ctx.body = {
code: 500,
msg: err.message
}
}
})
// 加载文件上传中间件
app.use(koaBody({
multipart: true,
formidable: {
// keepExtensions: true, // 保持文件后缀
uploadDir: uploadPath, // 初始指定文件存放地址,否则将会放入系统临时文件目录
maxFileSize: 10000 * 1024 * 1024 // 设置上传文件大小最大限制,默认20M
}
}))
// 文件上传处理
function uploadFn(ctx) {
return new Promise((resolve, reject) => {
const { name, path: _path } = ctx.request.files.file // 拿到上传的文件信息
const filePath = path.join(uploadPath, name) // 重新组合文件名
// 将临时文件重新设置文件名及地址
fs.rename(_path, filePath, (err) => {
if (err) {
return reject(err)
}
resolve(name)
})
})
}
// 文件上传接口
router.post('/api/upload/file', async function uploadFile(ctx) {
await uploadFn(ctx).then((name) => {
ctx.body = {
code: 0,
url: path.join('http://localhost:3000/uploads', name),
msg: '文件上传成功'
}
}).catch(err => {
ctx.body = {
code: -1,
msg: '文件上传失败'
}
})
})
app.use(router.routes())
// 用端口3000启动服务
app.listen(3000)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
以上为一个简单的文件上传,前端用一个input元素上传文件,然后使用ajax将整个文件传输给后端,后端接收文件并保存在服务器。
# 优化思路
- 既然小文件的上传处理没有问题,而大文件有不可预料的问题和体验,我们不能控制用户的网路状态和用户的想法,那么我们换个思路,可以把整个大的文件在前端分割成一个个小文件,然后再由前端将这些小文件一个个上传,等所有的小文件上传完成之后,再通知后端将这些小文件合并成大文件。如果用户在上传过程中突然断网或者想暂停,当用户再次上传之后,之前已上传的直接跳过,只上传之前没有上传的那些内容。
但是这里又会出现新的问题:
- 如何确定不同的文件上传不会错乱
- 大文件分割成很多个小文件,再由小文件合并成大文件时,顺序需要与分割的一样
- 在上传之前,如何知道哪些有上传完,哪些没有上传
在尝试解决这些问题之前,我们还有一个加速上传的优化:异步并发控制
。
听到并发这个词,大家在脑海里的第一反应是后端服务器在同一时间对大量请求的同时响应。但是在 http 进入 1.1 版本后,浏览器处理请求的时候实现了TCP并发请求,2.0 实现了HTTP并发处理,而且现在的浏览器 http 版本基本都是 1.1 版本后的,所以我们可以利用这个机制,对于前端大量的请求,可以对请求进行并发处理。
正好大文件上传需要分割成几十上百个小文件上传请求,与前端并发控制需要的场景完美嵌合,加上并发处理,达到加速整个文件的上传。
# 异步并发控制
先来熟悉前端对于请求的异步并发控制的代码实现:
/**
* 异步并发控制
* arr {Array} 异步任务队列
* max {Number} 允许同时执行的最大任务数
* callback {Function} 所有任务完成之后的回调函数
*/
function sendRequest(arr, max = 5, callback) {
let i = 0 // 数组下标
let fetchArr = [] // 正在执行的请求
let toFetch = () => {
// 如果异步任务都已开始执行,剩最后一组,则结束并发控制
if (i === arr.length) {
return Promise.resolve()
}
// 执行异步任务
let it = fetch(arr[i++])
// 添加异步事件的完成处理
it.then(() => {
fetchArr.splice(fetchArr.indexOf(it), 1)
})
// 添加新任务
fetchArr.push(it)
let p = Promise.resolve()
// 如果并发数达到最大数,则等其中一个异步任务完成再添加
if (fetchArr.length >= max) {
p = Promise.race(fetchArr)
}
// 执行递归
return p.then(() => toFetch())
}
toFetch().then(() =>
// 最后一组全部执行完再执行回调函数
Promise.all(fetchArr).then(() => {
callback()
})
)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
异步并发控制的原理:先创建一个任务队列,任务队列的容量可以自己设立,然后结合Promise等来监听每一个异步任务。如果队列没满就直接将新的任务放进去,然后设置一下该任务的完成的回调操作:当该任务完成时,将当前任务从任务队列中移除,并继续往任务队列中添加一个待处理的任务,这样我们可以时刻保持满队列执行。直到所有待处理任务都已处理完成,最后再执行所有任务处理完成的回调函数。
# 唯一文件ID
我们需要确定每个文件都有一个唯一的id,这样上传到后端才能确定文件不会错乱,如何为每个文件生成一个唯一的id,我们可以使用一个现有的插件:SparkMD5。 用 SparkMD5 生成文件 id 后,在服务器上以 id 为名称生成一个文件夹来存放所有切分的小文件。
import SparkMD5 from 'spark-md5'
export default {
methods: {
uploadFile(e) {
const file = e.target.files[0]
this.createFileMd5(file).then(fileMd5 => {
// fileMd5 为文件唯一的id,只要文件内容没变,那这id就不会变
console.log(fileMd5, 'md5')
})
},
createFileMd5(file) {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer()
const reader = new FileReader()
reader.readAsArrayBuffer(file)
reader.addEventListener('loadend', () => {
const content = reader.result
spark.append(content)
const hash = spark.end()
resolve(hash, content)
})
reader.addEventListener('error', function _error(err) {
reject(err)
})
})
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 文件切分
第一步就是需要将大文件切分成一个个小文件,然后将这些小文件依次全部上传到后端。
// 文件分割
cutBlob(fileHash, file) {
const chunkArr = [] // 所有切片缓存数组
const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
const chunkNums = Math.ceil(file.size / this.chunkSize) // 切片总数
return new Promise((resolve, reject) => {
let startIndex = ''
let endIndex = ''
let contentItem = ''
for(let i = 0; i < chunkNums; i++) {
startIndex = i * this.chunkSize // 片段起点
endIndex = (i + 1) * this.chunkSize // 片段尾点
endIndex > file.size && (endIndex = file.size)
// 切割文件
contentItem = blobSlice.call(file, startIndex, endIndex)
chunkArr.push({
index: i, // 当前文件片段顺序索引,一并传给后端确定顺序
chunk: contentItem // 当前文件片段内容
})
}
this.fileInfo = {
hash: fileHash,
total: chunkNums,
name: file.name,
size: file.size
}
resolve(chunkArr)
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
# 小文件合并的顺序
前端将大文件切割成小文件后,在上传的时候为小文件的请求加上一个递增的 index 参数,后端接收完数据生成文件的时候将index作为文件名后缀,在合并的时候根据文件名后缀数字的顺序来合并。
// 分片文件上传接口
router.post('/api/upload/snippet', async function snippet(ctx) {
const { index, hash } = ctx.request.body
// 切片上传目录
const chunksPath = path.join(uploadPath, hash, '/')
if(!fs.existsSync(chunksPath)) {
fs.mkdirSync(chunksPath)
}
// 切片文件, index作为文件名后缀用来将来合并的时候确定顺序
const chunksFileName = chunksPath + hash + '-' + index
await uploadFn(ctx, chunksFileName).then(name => {
ctx.body = {
code: 0,
msg: '切片上传完成'
}
}).catch(err => {
console.log(err)
ctx.body = {
code: -1,
msg: '切片上传失败'
}
})
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 续传
当用户在上传过程中主动暂停或者网络断了,再次上传的时候,之前已上传完成的不应该再次上传,所以我们可以在每一次开始上传之前发送一个请求来获取该文件是否有上传过小文件,如果有则返回所有已上传的小文件的 index 前缀或者文件名。这样在前端上传时就可以过滤掉已上传的小文件。
// 前端js
// 请求已上传文件
getUploadedChunks(hash) {
return this.$http({
url: "/upload/checkSnippet",
method: "post",
data: { hash }
})
}
// nodeJs
// 查询分片文件是否上传
router.post('/api/upload/checkSnippet', function snippet(ctx) {
const { hash } = ctx.request.body
// 切片上传目录
const chunksPath = path.join(uploadPath, hash, '/')
let chunksFiles = []
if(fs.existsSync(chunksPath)) {
// 切片文件
chunksFiles = fs.readdirSync(chunksPath)
}
ctx.body = {
code: 0,
data: chunksFiles,
msg: '查询成功'
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 合并文件
当所有文件片段上传完成之后,则需要通知后端在服务器进行合并。
// 前端js
// 请求合并
chunkMerge(data) {
this.$http({
url: "/upload/merge",
method: "post",
data,
}).then(res => {
console.log(res.data)
})
}
// nodeJs
// 删除文件夹及内部所有文件
function deleteFiles(dirpath) {
if (fs.existsSync(dirpath)) {
fs.readdir(dirpath, (err, files) => {
if (err) throw err
while(files.length) {
fs.unlinkSync(dirpath + files.shift())
}
fs.rmdir(dirpath, () => {})
})
}
}
/**
* 文件异步合并
* @param {String} dirPath 分片文件夹
* @param {String} filePath 目标文件
* @param {String} hash 文件hash
* @param {Number} total 分片文件总数
* @returns {Promise}
*/
function mergeFile(dirPath, filePath, hash, total) {
return new Promise((resolve, reject) => {
fs.readdir(dirPath, (err, files) => {
if (err) {
return reject(err)
}
if(files.length !== total || !files.length) {
return reject('上传失败,切片数量不符')
}
const fileWriteStream = fs.createWriteStream(filePath)
function merge(i) {
return new Promise((res, rej) => {
// 合并完成
if (i === files.length) {
fs.rmdir(dirPath, (err) => {
console.log(err, 'rmdir')
})
return res()
}
const chunkpath = dirPath + hash + '-' + i
fs.readFile(chunkpath, (err, data) => {
if (err) return rej(err)
// 将切片追加到存储文件
fs.appendFile(filePath, data, () => {
// 删除切片文件
fs.unlink(chunkpath, () => {
// 递归合并
res(merge(i + 1))
})
})
})
})
}
merge(0).then(() => {
// 默认情况下不需要手动关闭,但是在某些文件的合并并不会自动关闭可写流,比如压缩文件,所以这里在合并完成之后,统一关闭下
resolve(fileWriteStream.close())
})
})
})
}
/**
* 文件合并接口
* 1、判断是否有切片hash文件夹
* 2、判断文件夹内的文件数量是否等于total
* 4、然后合并切片
* 5、删除切片文件信息
*/
router.post('/api/upload/merge', async function uploadFile(ctx) {
const { total, hash, name } = ctx.request.body
const dirPath = path.join(uploadPath, hash, '/')
const filePath = path.join(uploadPath, name) // 合并文件
// 已存在文件,则表示已上传成功
if (fs.existsSync(filePath)) {
deleteFiles(dirPath) // 删除临时文件片段包
ctx.body = {
code: 0,
url: path.join('http://localhost:3000/uploads', name),
msg: '文件上传成功'
}
// 如果没有切片hash文件夹则表明上传失败
} else if (!fs.existsSync(dirPath)) {
ctx.body = {
code: -1,
msg: '文件上传失败'
}
} else {
// 合并文件
await mergeFile(dirPath, filePath, hash, total).then(() => {
ctx.body = {
code: 0,
url: path.join('http://localhost:3000/uploads', name),
msg: '文件上传成功'
}
}).catch(err => {
ctx.body = {
code: -1,
msg: err
}
})
}
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# 进阶优化
对于越大的文件计算 hash 值则越久,这是有点瑕疵的。对于这一块可以继续优化:
利用 webWork 机制,在计算的时候新开一个线程单独做这件事,计算完成之后将结果返回给主线程。
// vue中需要安装 worker-loader
npm install worker-loader -D
// App.vue js
import Worker from './hash.worker.js'
createFileMd5(file) {
return new Promise(() => {
const worker = new Worker()
worker.postMessage({file, chunkSize: this.chunkSize})
worker.onmessage = event => {
resolve(event.data)
}
})
}
// hash.worker.js
import SparkMD5 from 'spark-md5'
onmessage = function(event) {
getFileHash(event.data)
}
function getFileHash({file, chunkSize}) {
console.log(file, chunkSize)
const spark = new SparkMD5.ArrayBuffer()
const reader = new FileReader()
reader.readAsArrayBuffer(file)
reader.addEventListener('loadend', () => {
const content = reader.result
spark.append(content)
const hash = spark.end()
postMessage(hash)
})
reader.addEventListener('error', function _error(err) {
postMessage(err)
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
采用抽样 hash 来计算,但是抽样方式会损失一定的精度,有可能将本不是一个文件生成了一个同样的 hash。
抽样方式的做法是,在大文件切分一个个小片段后,我们可以分别取每个片段的开头10个字符,中间10个字符,末尾10个字符(规则自定)等方式组合生成的 id。这种方式的效率是非常快的。
createFileMd5(file) {
return new Promise((resolve, reject) => {
const spark = new SparkMD5.ArrayBuffer()
const reader = new FileReader()
reader.readAsArrayBuffer(file)
reader.addEventListener('loadend', () => {
console.time('抽样hash计算:')
const content = reader.result
// 抽样hash计算
// 规则:每半个切片大小取前10个
let i = 0
while(this.chunkSize / 2 * (i + 1) + 10 < file.size) {
spark.append(content.slice(this.chunkSize / 2 * i, this.chunkSize / 2 * i + 10))
i++
}
const hash = spark.end()
console.timeEnd('抽样hash计算:')
resolve(hash)
})
reader.addEventListener('error', function _error(err) {
reject(err)
})
})
}
// 这是用一个9M大小的文件,切片大小为100K分别计算得到的时间,
// 全部内容hash计算:: 101.6240234375 ms
// 抽样hash计算:: 2.216796875 ms
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 完整代码
<!-- 页面代码 -->
<template>
<div>
<input type="file" @change="uploadFile">
</div>
</template>
2
3
4
5
6
前端js处理App.vue js
<script>
import Worker from './hash.worker.js'
export default {
data() {
return {
fileInfo: null,
chunkSize: 100 * 1024 // 切片大小
}
},
methods: {
// input改变事件监听
uploadFile(e) {
const file = e.target.files[0]
// 如果文件大小大于文件分片大小的5倍才使用分片上传
if (file.size / this.chunkSize < 5) {
this.sendFile(file)
return
}
this.createFileMd5(file).then(async fileMd5 => {
// 先查询服务器是否已有上传完的文件切片
let {data} = await this.getUploadedChunks(fileMd5)
let uploaded = data.data.length ? data.data.map(v => v.split('-')[1] - 0) : []
// 切割文件
const chunkArr = await this.cutBlob(fileMd5, file, uploaded)
// 开始上传
this.sendRequest(chunkArr, 5, this.chunkMerge)
})
},
createFileMd5(file) {
return new Promise((resolve) => {
const worker = new Worker()
worker.postMessage({file, chunkSize: this.chunkSize})
worker.onmessage = event => {
resolve(event.data)
}
})
},
// 文件分割
cutBlob(fileHash, file, uploaded) {
const chunkArr = [] // 所有切片缓存数组
const blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice
const chunkNums = Math.ceil(file.size / this.chunkSize) // 切片总数
return new Promise(resolve => {
let startIndex = ''
let endIndex = ''
let contentItem = ''
for(let i = 0; i < chunkNums; i++) {
// 如果已上传则跳过
if (uploaded.includes(i)) continue
startIndex = i * this.chunkSize // 片段起点
endIndex = (i + 1) * this.chunkSize // 片段尾点
endIndex > file.size && (endIndex = file.size)
// 切割文件
contentItem = blobSlice.call(file, startIndex, endIndex)
chunkArr.push({
index: i,
chunk: contentItem
})
}
this.fileInfo = {
hash: fileHash,
total: chunkNums,
name: file.name,
size: file.size
}
resolve(chunkArr)
})
},
// 请求并发处理
sendRequest(arr, max = 6, callback) {
let fetchArr = []
let toFetch = () => {
if (!arr.length) {
return Promise.resolve()
}
const chunkItem = arr.shift()
const it = this.sendChunk(chunkItem)
it.then(() => {
// 成功从任务队列中移除
fetchArr.splice(fetchArr.indexOf(it), 1)
}, err => {
// 如果失败则重新放入总队列中
arr.unshift(chunkItem)
console.log(err)
})
fetchArr.push(it)
let p = Promise.resolve()
if (fetchArr.length >= max) {
p = Promise.race(fetchArr)
}
return p.then(() => toFetch())
}
toFetch().then(() => {
Promise.all(fetchArr).then(() => {
callback()
})
}, err => {
console.log(err)
})
},
// 请求已上传文件
getUploadedChunks(hash) {
return this.$http({
url: "/upload/checkSnippet",
method: "post",
data: { hash }
})
},
// 小文件上传
sendChunk(item) {
if (!item) return
let formdata = new FormData()
formdata.append("file", item.chunk)
formdata.append("index", item.index)
formdata.append("hash", this.fileInfo.hash)
// formdata.append("name", this.fileInfo.name)
return this.$http({
url: "/upload/snippet",
method: "post",
data: formdata,
headers: { "Content-Type": "multipart/form-data" }
})
},
// 文件上传方法
sendFile(file) {
let formdata = new FormData()
formdata.append("file", file)
this.$http({
url: "/upload/file",
method: "post",
data: formdata,
headers: { "Content-Type": "multipart/form-data" }
}).then(({ data }) => {
console.log(data, 'upload/file')
})
},
// 请求合并
chunkMerge() {
this.$http({
url: "/upload/merge",
method: "post",
data: this.fileInfo,
}).then(res => {
console.log(res.data)
})
}
}
}
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
// hash.worker.js
import SparkMD5 from 'spark-md5'
onmessage = function(event) {
getFileHash(event.data)
}
function getFileHash({file, chunkSize}) {
const spark = new SparkMD5.ArrayBuffer()
const reader = new FileReader()
reader.readAsArrayBuffer(file)
reader.addEventListener('loadend', () => {
const content = reader.result
// 抽样hash计算
// 规则:每半个切片大小取前10个
let i = 0
while(chunkSize / 2 * (i + 1) + 10 < file.size) {
spark.append(content.slice(chunkSize / 2 * i, chunkSize / 2 * i + 10))
i++
}
const hash = spark.end()
postMessage(hash)
})
reader.addEventListener('error', function _error(err) {
postMessage(err)
})
}
// 或者(推荐)
// import SparkMD5 from 'spark-md5'
self.importScripts("/calculate/spark-md5.min.js"); // 导入脚本
onmessage = function (event) {
// getFileHash(event.data)
let spark = new self.SparkMD5.ArrayBuffer()
let chunkArr = event.data;
let sliceArr = [chunkArr[0]]
if (chunkArr.lenght > 1) {
sliceArr.push(chunkArr[chunkArr.lenght - 1])
}
console.log(sliceArr);
let chunkPromise = sliceArr.map(function (item) {
return getFileReader(item.chunk)
})
Promise.all(chunkPromise).then(function (res) {
res.forEach(content => {
spark.append(content)
});
let hash = spark.end()
console.log(hash);
postMessage(hash)
// Worker 线程
self.close();
}).catch(function (err) {
console.log(err);
postMessage()
self.close();
})
}
function getFileReader(file) {
return new Promise(function (resolve, reject) {
let reader = new FileReader()
reader.readAsArrayBuffer(file)
reader.addEventListener('loadend', function () {
let content = reader.result;
resolve(content)
})
reader.addEventListener('error', function (err) {
reject(err)
})
})
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
NodeJs处理:
const Koa = require('koa')
const router = require('koa-router')() // koa路由模块
const koaBody = require('koa-body') //解析文件上传的插件
const fs = require('fs') // nodeJs内置文件模块
const path = require('path') // nodeJs内置路径模块
const uploadPath = path.join(__dirname, 'public/uploads') // 定义文件上传目录
// 如果初始没有改文件目录,则自动创建
if (!fs.existsSync(uploadPath)) {
fs.mkdirSync(uploadPath)
}
const app = new Koa() // 实例化
// 一些自定义的全局请求处理
app.use(async (ctx, next) => {
console.log(`Process ${ctx.request.method} ${ctx.request.url}...`);
if (ctx.request.method === 'OPTIONS') {
ctx.status = 200
}
try {
await next();
} catch (err) {
ctx.status = err.statusCode || err.status || 500
ctx.body = {
code: 500,
msg: err.message
}
}
})
// 加载文件上传中间件
app.use(koaBody({
multipart: true,
formidable: {
// keepExtensions: true, // 保持文件后缀
uploadDir: uploadPath, // 初始指定文件存放地址,否则将会放入系统临时文件目录
maxFileSize: 10000 * 1024 * 1024 // 设置上传文件大小最大限制,默认20M
}
}))
// 文件上传处理
function uploadFn(ctx, destPath) {
return new Promise((resolve, reject) => {
const { name, path: _path } = ctx.request.files.file // 拿到上传的文件信息
const filePath = destPath || path.join(uploadPath, name) // 重新组合文件名
// 将临时文件重新设置文件名及地址
fs.rename(_path, filePath, (err) => {
if (err) {
return reject(err)
}
resolve(filePath)
})
})
}
// 查询分片文件是否上传
router.post('/api/upload/checkSnippet', function snippet(ctx) {
const { hash } = ctx.request.body
// 切片上传目录
const chunksPath = path.join(uploadPath, hash, '/')
let chunksFiles = []
if(fs.existsSync(chunksPath)) {
// 切片文件
chunksFiles = fs.readdirSync(chunksPath)
}
ctx.body = {
code: 0,
data: chunksFiles,
msg: '查询成功'
}
})
// 分片文件上传接口
router.post('/api/upload/snippet', async function snippet(ctx) {
const { index, hash } = ctx.request.body
// 切片上传目录
const chunksPath = path.join(uploadPath, hash, '/')
if(!fs.existsSync(chunksPath)) {
fs.mkdirSync(chunksPath)
}
// 切片文件
const chunksFileName = chunksPath + hash + '-' + index
await uploadFn(ctx, chunksFileName).then(name => {
ctx.body = {
code: 0,
msg: '切片上传完成',
data: name
}
}).catch(err => {
ctx.body = {
code: -1,
msg: '切片上传失败',
data: err
}
})
})
// 文件上传接口
router.post('/api/upload/file', async function uploadFile(ctx) {
await uploadFn(ctx).then((name) => {
ctx.body = {
code: 0,
url: path.join('http://localhost:3000/uploads', name),
msg: '文件上传成功'
}
}).catch(err => {
ctx.body = {
code: -1,
msg: '文件上传失败'
}
})
})
// 删除文件夹及内部所有文件
function deleteFiles(dirpath) {
if (fs.existsSync(dirpath)) {
fs.readdir(dirpath, (err, files) => {
if (err) throw err
// 删除文件
while(files.length) {
fs.unlinkSync(dirpath + files.shift())
}
// 删除目录
fs.rmdir(dirpath, () => {})
})
}
}
/**
* 文件异步合并
* @param {String} dirPath 分片文件夹
* @param {String} filePath 目标文件
* @param {String} hash 文件hash
* @param {Number} total 分片文件总数
* @returns {Promise}
*/
function mergeFile(dirPath, filePath, hash, total) {
return new Promise((resolve, reject) => {
fs.readdir(dirPath, (err, files) => {
if (err) {
return reject(err)
}
if(files.length !== total || !files.length) {
return reject('上传失败,切片数量不符')
}
// 创建文件写入流
const fileWriteStream = fs.createWriteStream(filePath)
function merge(i) {
return new Promise((res, rej) => {
// 合并完成
if (i === files.length) {
fs.rmdir(dirPath, (err) => {
console.log(err, 'rmdir')
})
return res()
}
const chunkpath = dirPath + hash + '-' + i
fs.readFile(chunkpath, (err, data) => {
if (err) return rej(err)
// 将切片追加到存储文件
fs.appendFile(filePath, data, () => {
// 删除切片文件
fs.unlink(chunkpath, () => {
// 递归合并
res(merge(i + 1))
})
})
})
})
}
merge(0).then(() => {
// 默认情况下不需要手动关闭,但是在某些文件的合并并不会自动关闭可写流,比如压缩文件,所以这里在合并完成之后,统一关闭下
resolve(fileWriteStream.close())
})
})
})
}
/**
* 文件合并接口
* 1、判断是否有切片hash文件夹
* 2、判断文件夹内的文件数量是否等于total
* 4、然后合并切片
* 5、删除切片文件信息
*/
router.post('/api/upload/merge', async function uploadFile(ctx) {
const { total, hash, name } = ctx.request.body
const dirPath = path.join(uploadPath, hash, '/')
const filePath = path.join(uploadPath, name) // 合并文件
// 已存在文件,则表示已上传成功
if (fs.existsSync(filePath)) {
// 删除所有的临时文件
deleteFiles(dirPath)
ctx.body = {
code: 0,
url: path.join('http://localhost:3000/uploads', name),
msg: '文件上传成功'
}
// 如果没有切片hash文件夹则表明上传失败
} else if (!fs.existsSync(dirPath)) {
ctx.body = {
code: -1,
msg: '文件上传失败'
}
} else {
// 开始合并
await mergeFile(dirPath, filePath, hash, total).then(() => {
ctx.body = {
code: 0,
url: path.join('http://localhost:3000/uploads', name),
msg: '文件上传成功'
}
}).catch(err => {
ctx.body = {
code: -1,
msg: err
}
})
}
})
app.use(router.routes())
// 用端口3000启动服务
app.listen(3000)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
# Dome地址
https://gitee.com/zlluGitHub/big-file-upload.git
# 参考文档
- 01
- linux 在没有 sudo 权限下安装 Ollama 框架12-23
- 02
- Express 与 vue3 使用 sse 实现消息推送(长连接)12-20