一文了解如何传输大文件
最近在做一个文件服务器,本来一开始只是上传一些图片和word
文档等文本文件;到了后面还是希望把常用的一些exe,zip
等大文件也上传到这个服务上面;那就需要更改传输的代码了,因为之前上传文本类型的方法行不通
Why Not
小文件我们使用form-data
就解决了,但是大文件这样却不行;这有几个原因:
Timeout
大文件不能直接上传最根本的原因还是因为超时的问题,这有多种超时,如下
Connection Timeout
连接超时是指客户端和服务器之间建立连接(establish connection
)时会有一个超时时间,一旦超过这个时间,客户端就会放弃连接了
比如,你在浏览器打开一个网站时,如果超过了60s
(每个浏览器不太一样)没有响应时,就会放弃等到;这样是为了防止浏览器一直卡在那
Request Timeout
客户端和服务建立连接时,可以通过一个Header:Connection
来指定此次连接完成之后是端口连接还是继续保持连接;在HTTP/1.0
中默认的Connection:close
,就是请求完成之后关闭连接
在HTTP/1.1
中默认Connection:keep-alive
,就是完成此次连接之后,还会继续保持连接,以便之后的请求可以重用这个连接,减少了建立TCP
连接的时间;请求头如下:
HTTP/1.1 200 OK
Connection: Keep-Alive
Content-Encoding: gzip
Content-Type: text/html; charset=utf-8
Date: Thu, 11 Aug 2016 15:23:13 GMT
Keep-Alive: timeout=5, max=1000
Last-Modified: Mon, 25 Jul 2016 04:32:39 GMT
Server: Apache
当然,也不会因为设置了keep-alive
,这个连接就会一直存在;因为服务器也不会缓存一个长时间不用的连接,不然连接多了就会占用服务器的大部分资源了;
所以一定时间内没有请求,服务器就会关闭这个连接,而此时客户端如果再用之前的这个连接请求时就会出现Request Timeout
了
同样地,连接也有可能是客户端去关闭的;比如:发送大文件时,如果客户端长时间收不到响应,就会认为是服务端异常了就不会再等待请求,而主动关闭连接了;(在开发中一些比较耗时的请求也会出现这种问题,此时后端服务并没有停止处理,客户端已经关闭了)
TimeToLive(TTL)
TTL
表面理解为存活时间,这个存活时间是指的发送的包(IP Packet
)的传输时间,指的就是这个IP
包在网络中能够存活多长时间(这个时间是指的经过的路由器的个数,一般为255),每经过一个路由器都会减一,一旦减为0,那么这个包就会被丢弃了
这也是为了防止出现循环包和网络中出现大量的数据,因为不能及时到达就会一直在网络中,占据网络资源
Take Too Much Memory
在使用form-data
传输数据时一般都是将整个文件加载到内存,再传输到服务器;大文件要是一次将2GB
的文件放入到内存中,很可能直接就卡死了
同样的,虽然文件服务器类的服务部署时一般会分配比较大的内存,但是如果有多个人同时传输,那么也会导致服务器OOM
How To Fix
那这如何解决呢?显然是没有一个直接的办法,那就只能将大文件切成小文件来传输了,切成小文件之后也会有问题;比如:客户端如何切这些小文件?服务端如何能够存储这些小文件?如何将小文件合并成大文件?或者是要不要合并呢?一般常见的有三种方式
Chunk And Merge
chunk And Merge
指的就是在客户端将文件切块,然后在服务器端收到全部分块之后再将这些文件合并起来
首先,前端对文件进行分片,分片之后需要告诉服务器如下信息:
partNum
:当前分片的序号filename
:文件名称size
:文件大小totalNum
:总分片数量chunkhash
:分片的hash
chunkname
:分片的名称filehash
:文件hash
前端代码如下:
function UploadFile(file, i) {
var name = file.name,
size = file.size, //总大小
chunkSize = 2 * 1024 * 1024, //以2MB为一个分片,每个分片的大小
chunkCount = Math.ceil(size / chunkSize); //总片数
if (i > chunkCount) {
console.log("all chunks upload successfully.");
return;
}
var start = (i - 1) * chunkSize;
var end = (start + chunkSize) > size ? size : (start + chunkSize);
var currentSize = (start + chunkSize) > size ? (size - start) : chunkSize;
var chunkSlice = file.slice(start, end); //将文件进行切片
hashBlob(chunkSlice).then(function (data) {
var chunkSha256 = data;
console.log("chunkfile sha256 is " + data);
//构建form表单进行提交
var form = new FormData();
form.append("data", chunkSlice); //slice方法用于切出文件的一部分
form.append("lastModified", file.lastModified); //最后的额修改时间
form.append("filename", name);
form.append("size", size);
form.append("totalNum", chunkCount); //总片数
form.append("partNum", i); //当前是第几片
form.append("chunkhash", chunkSha256);
form.append("chunkname",chunkname)
console.log("uploading chunk " + i + "/" + chunkCount);
sendUploadReq(file, form, i);
});
}
function sendUploadReq(file, form, i) {
var url = "fileUrl"
$.ajax({
url: url,
type: "POST",
data: form,
async: true, //异步
dataType: "json",
processData: false, //很重要,告诉jquery不要对form进行处理
contentType: false, //很重要,指定为false才能形成正确的Content-Type
success: (data) => {
console.log(data);
/* 表示上一块文件上传成功,继续下一次 */
if (data.code === "201") {
if (data.url && data.url !== "") {
return
}
if (!data.uploadId || data.uploadId === "") {
return
}
if (form.get("partNum") == i) {
sendMergeFileReq(form.get("filename"));
} else {
i++;
PostFile(file, i);
}
} else if (data.code === "500") {
/* 失败后,每2秒继续传一次分片文件 */
setInterval(function () {
PostFile(file, i)
}, 2000);
} else {
console.log('未知错误');
}
if (data.error) {
appendUploadOutput(data.error);
}
},
error: (e) => {
appendUploadOutput("upload error :" + e.responseText);
console.log(e);
}
})
}
// get filehash
function getFileHash(fiename){
let fileInput = document.querySelector('#fileInput'); // 获取文件输入框
let chunkSize = 1024 * 1024; // 每个分片的大小,这里使用 1MB
let totalChunks = Math.ceil(fileInput.files[0].size / chunkSize); // 总分片数
let currentChunk = 0; // 当前分片数
let hash = new Promise(function(resolve, reject) {
let hash = new window.CryptoJS.SHA256(); // 创建哈希对象
let fileReader = new window.FileReader();
fileReader.onload = function (e) {
// 在每个分片上更新哈希
hash.update(window.CryptoJS.lib.WordArray.create(e.target.result));
currentChunk++;
if (currentChunk < totalChunks) {
loadNext();
} else {
// 所有分片处理完成时返回哈希值
let finalHash = hash.finalize();
resolve(finalHash.toString());
}
};
fileReader.onerror = function () {
reject();
};
// 加载下一分片
function loadNext() {
let start = currentChunk * chunkSize;
let end = ((start + chunkSize) >= fileInput.files[0].size) ? fileInput.files[0].size - 1 : start + chunkSize - 1;
fileReader.readAsArrayBuffer(fileInput.files[0].slice(start, end + 1));
}
loadNext();
});
hash.then(function (value) {
console.log(value); // 输出哈希值
});
}
文件太大所以不能一次直接计算出整个文件的hash
,这很容易把浏览器整奔溃了;所以可以计算分片hash
,等到传输最后一个分片的时候,也就能计算出整个文件的hash
值了
后端服务器收到每个分片,首先在数据库保存文件名和每个分片的关系,以及分片的序号,分片的名称等信息,然后将所收到的分片临时存储在本地,分片的名称就是chunkname
(分片的名称可以是分片的hash
值,这样可以唯一的识别一个分片)
等到所有的分片全部上传完成之后,发送合并请求(MergeRequest
),将所有分片进行合并,合并之后就形成了最终的文件
优点:前端可以使用并发的方式加快上传的速度,因为这并不会影响合并文件的操作(注:如果某一片传输失败要进行重传,否则也不能合并)
缺点:使用这种方式上传,首先就是得维护所有文件和所有分片的关系和顺序;而且分片会在一定时间内占用大量的存储
Chunk Not Merge
chunkNotMerge
是指在将要上传的文件分片上传,但是不合并所有的分片;这种方式就是需要保证在数据库能够维护好文件和各个分片的关系,已经分片之间的顺序
优点:内容相同的分片(比如:有相同的hash
值的)就不需要重复上传了,这能够节省不少的存储空间;减少合并的步骤,并且可以并发上传,这样能够提高传输的速度
缺点:整个文件在后端是分片存储的,如果丢失了其中的一片,就会导致整个文件不可用
Chunk And Append
Chunk And Append
是指分片上传追加到文件中;也就是首次需要在服务器创建一个文件,之后将分片一个个的追加到这个文件即可;具体流程如下:
-
首先客户端发送一个
POST
请求到服务端初始化上传,这是一个上传创建请求,告诉服务器上传的一些基本信息,例如:size,filename
等服务器收到这个请求之后,如果成功,会在
header
中返回一个成功上传的 URL,上传的 URL 是用于唯一表示一个上传资源的标识;请求如下:[Request] POST /files?path= HTTP/1.1 Host: tus.example.org Content-Length: 5 Upload-Length: 100 // 整个文件的大小 Content-Type: application/offset+octet-stream // 上传文件的类型,大文件传输只能是二进制 [Response] HTTP/1.1 201 Created Location: https://tus.example.org/files/24e533e02ec3bc40c387f1a0e460e216 // 上传文件的位置 Upload-Offset: 0 // 上传文件的偏移量
-
接下来就通过
PATCH
请求发送实际要上传的数据了,该请求的 URL 就是之前在POST
请求的HEADER
中返回的那个 URL理想情况下,上传的 Body 中应该包含尽可能多的的数据,减少上传的次数;同时,PATCH 请求必须包含
Upload-Offset
header,告诉服务器应该将上传的数据写到文件的那个offset
,当offset+Content-Length=size
时表示整个文件传输完成[Request] PATCH /files?path= HTTP/1.1 Host: https://tus.example.org Content-Type: application/offset+octet-stream // 指定上传文件的类型,只能是二进制 Content-Length: 30 // 上传的内容大小 Upload-Offset: 70 // 上传文件的偏移量,也就是已传输的大小 Upload-Length: 100 // 上传的文件的总大小 Chunk-Checksum: sda123wqe // 此次上传内容的摘要 [remaining 30 bytes] [Response] HTTP/1.1 200 OK Upload-Offset: 100`
-
如果
PATCH
请求上传失败,客户端可以尝试重传,对于重传,客户端必须知道服务端接收了多少个字节;这时需要一个HEAD
请求发送到上传的 URL 中,然后服务端返回该文件的偏移量一旦知道了偏移量就可以继续上传直至传输全部完成[Request] HEAD /files?path= HTTP/1.1 Host: https://tus.example.org [Response] HTTP/1.1 200 OK Cache-Control: no-store Upload-Offset: 100
优点
- 服务器端不需要维护上传文件的各个分片
- 上传之后的文件是一个完整的文件,下载更加方便
- 能更好的支持断点续传,暂停等功能
缺点:
- 每上传一个分片都要打开文件追加,多次
I/O
降低了速度 - 一些云存储,像
oss,blob
好像对追加上传有大小限制 - 前端不能并发,需要一个个的传输,可能对传输速率有影响
注:docker registry
使用的就是类似的方式来传输镜像的
End
上面更多的只是提供了一种思路大文件传输的思路,每种传输方式可能应用的场景有所不同;比如:分片上传不合并海量的文本数据就比较合适
参考:
转载自:https://juejin.cn/post/7223635374740881467