最近碰到一个数据导入的业务功能,一开始业务人员提供的样本数据不大,图方便我就用了采用了提交整个文件上传的方式处理。随着功能投入使用,开始收到一些文件上传失败的问题反馈。找业务要来数据一看,原来是文件的大小超出了预期。
来看个简单的例子,下面这是一个普通的文件上传选择标签:
<input type="file" @change="handleUpload" />
点击这个标签会弹出文件选择窗口,选择一个文件就会触发 handleUpload
方法开始上传文件。下面是处理文件上传的代码示例:
function uploadTo(url, formData) {
return fetch(url, {
method: 'POST',
body: formData,
}).then(response => {
console.log(response)
})
}
function handleUpload(e) {
const uploadData = new FormData()
uploadData.append('file_data', e.target.files[0])
uploadTo('/files', uploadData)
}
Web 服务使用的是 Nginx,当上传的文件小于 1M 时,以上功能没有任何问题,一旦超过 1M,就会出现 413 Request Entity Too Large
的响应,看这个消息就知道是文件太大了。
最简单的方式是修改 Nginx 配置,添加这样一行配置代码:
client_max_body_size 2M;
这样可以把限制提升到 2M,算是一个不错的快速处理方案。但一旦有更大的文件要上传时,我又得改配置。另外后端使用了 PHP,在超过 2M 后,会触发 PHP 的默认请求限制大小,每次这样改太被动,我想一劳永逸的解决这个上传问题。
当然我也可以考虑一次性把 Nginx 和 PHP 的请求大小限制改到最大,但这不是一个很优雅的解决方式。在了解了一些文件上传方案后,我决定尝试一下基于前端 JavaScript 方式的文件分片上传方案。
这个方案的基础是 File 对象提供的 slice
方法,能对选择上传文件做切片处理。基于这个方法,我写了一个对上传文件做切片处理的自定义函数:
function chunkFile(file, chunkLimit) {
const fileChunks = []
const chunkTotal = Math.ceil(file.size / chunkLimit)
let chunkPos = 0
let chunkStart = 0
while (chunkPos < chunkTotal) {
let chunkEnd = chunkStart+chunkLimit
if (chunkEnd >= file.size) {
chunkEnd = file.size
}
fileChunks.push([chunkStart, chunkEnd])
chunkPos++;
chunkStart = chunkLimit * chunkPos
}
return fileChunks
}
这个函数只需要提供两个参数:
file
: 要上传的文件对象。chunkLimit
: 文件分片大小限制,单位为字节。
在上传方法中使用这个函数的代码如下:
let chunks = chunkFiles(e.target.files[0], 1024*1024)
上面的代码表示把一个上传文件按 1M 大小做切分。函数会返回一个包含切分数量和切分起始偏移值的数组。比如我选了一个 2.3M 的文件应用此函数后返回的结果如下:
[
[ 0, 1048576 ],
[ 1048576, 2097152 ],
[ 2097152, 2258401 ]
]
有了这个数组格式的切片起始数据,就可以水到渠成的开始做分片上传文件的操作:
const now = new Date()
chunks.forEach((offset, i) => {
const uploadData = new FormData()
uploadData.append('file_id', now.getTime()+'.'+now.getMilliseconds())
uploadData.append('file_name', file.name)
uploadData.append('file_data', file.slice(offset[0], offset[1]))
uploadData.append('file_chunk_total', fileChunks.length)
uploadData.append('file_chunk_pos', i+1)
uploadTo('/files', uploadData)
})
由于是分片上传,后端无法像处理正常文件上传那样获取到一些基础文件信息,比如文件名,所以需要在分片上传时补充相关的自定义字段。另外为了方便后端识别文件片段的归属,又专门添加了一个基于时间戳的 file_id
字段。
另外需要注意的是在定义文件切片大小限制值时不能和 Nginx 的限制相同。比如 Nginx 限制 1M,如果文件切片限制也定为 1M,那可能还是会返回 413 响应。这是因为在提交时文件切片大小加上附加字段的大小超出了 1M,所以文件切片应该小于 Nginx 限制,给附加字段留点余量。
另外上面使用 forEach
方法处理文件切片数组有个坑,它不会按顺序提交切片文件,需要后端在收到所有文件片段后重新按序号排序再合并,所以建议通过 Promise 队列的方式来顺序处理,后端会省事很多。比如使用 PHP 按顺序接收合并文件片段的代码:
$file = $_FILES['file_data'];
$extension = explode('.', $_POST['file_name']);
$extension = end($extension);
$filename = $_POST['file_id'].'.'.$extension;
file_put_contents($filename, file_get_contents($file['tmp_name']), FILE_APPEND);
这个分片传输的功能还有一些可以优化的空间,比如使用 localStorage 接口保存分片提交状态,可以实现断点续传功能;或者根据分片数量实现上传进度条功能等……,目前还用不上,也就不研究了。