之前公司里面要做视频播放,总结开发过程,写了一篇文章《如何优雅地实现网页播放视频》。近期,项目中又有在网页上传、下载文件的需求,并且要求能够支持大文件和“高并发”,与此前的视频播放在技术上存在一些联系,经过研究和动手实践后,整理如下,方便有需要的人。
1.文件下载
1.1 简单实现
如果是单纯实现功能,不考虑文件大小、服务器负载等各种情况,几行代码就能实现一个文件下载接口。这里以Go语言为例,下载windows系统D盘下的test.pdf文件。
package main
import (
"net/http"
"os"
)
func main() {
server := http.Server{Addr: ":8080"}
http.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) {
bytes, _ := os.ReadFile("D:\\test.pdf")
header := w.Header()
header.Set("Content-type", "application/octet-stream")
header.Set("Content-Disposition", "attachment;filename=test.pdf")
w.Write(bytes)
})
server.ListenAndServe()
}
以上代码直接读取文件内容,并将内容写入HTTP response,真的是非常简单、粗暴。各种编程语言的web库通常会在此基础上做一些包装,内置一些响应头设置、HTTP Status设置、异常处理等操作,能够更简单地实现文件下载。
Fiber (go)
import "github.com/gofiber/fiber/v2"
func main() {
app := fiber.New()
app.Get("/download", Download)
app.Listen(fmt.Sprintf(":%v", 8080))
}
func Download(c *fiber.Ctx) error {
c.SendFile("D:\\test.pdf")
}
Sanic (python)
from sanic.response import file
@app.route("/")
async def handler(request):
return await file("/path/to/whatever.png")
1.2 问题分析
以上办法实现虽然简单,但同时也存在问题。将文件从磁盘或其它存储(如OSS)写入http response的时,文件内容首先要读取到内存,如果文件特别大,甚至请求频率特别高时,服务器的内存必然会消耗殆尽,最终引发程序崩溃。
1.3 解决方案
为了解决此类问题,计算机领域的大佬们早就想好了解决方案,在制定http协议时,规定了可以在request header中携带Range
(例如Range值为"bytes=0-1023",表示只获取文件起始的1kb内容),告知服务端只获取部分内容,这种办法叫做分片传输(chunked transfer)。将一个体积较大的文件切割成若干体积较小的块,将原来的一次性获取变成多次获取,最终再将获取到的所有内容在客户端组装成一个完整的问题。
1.4 服务端实现
以Go语言为例,在Fiber库的基础上,实现分片下载逻辑,以下代码演示的是本地文件下载,如果是从OSS等其它存储下载,原理大致相同。
func Download(c *fiber.Ctx) error {
const filename = "D:\\test.pdf"
f, err := os.Open(filename)
if err != nil {
return errors.WithStack(err)
}
defer f.Close()
fileinfo, _ := f.Stat()
rangeData, err := c.Range(int(fileinfo.Size()))
if err != nil {
return errors.WithStack(err)
}
// TODO disallow multirange request
if _, err := f.Seek(int64(rangeData.Ranges[0].Start), 0); err != nil {
return errors.WithStack(err)
}
start := rangeData.Ranges[0].Start
end := rangeData.Ranges[0].End
length := end - start + 1
// TODO check chunk size
b := make([]byte, length)
if _, err := f.Read(b); err != nil {
return errors.WithStack(err)
}
// TODO You should read the md5 value from database where the file metadata stored, rather than calculating it every time you download the file.
hash, _ := cryptor.Md5File(filename)
c.Response().Header.Add("x-file-hash", hash)
c.Response().Header.Add("Accept-Ranges", "bytes")
c.Response().Header.Add("Content-Type", "application/octet-stream")
c.Response().Header.Add("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, url.QueryEscape(fileinfo.Name())))
c.Response().Header.Add("Content-Range", fmt.Sprintf("bytes %v-%v/%v", start, end, fileinfo.Size()))
return c.Status(206).SendStream(bytes.NewReader(b), length)
}
其中c.Range()
这个函数根据http request header中的Range值以及文件体积,进行一些校验和修正,以防客户端没有正确传递Range值导致各种异常情况。同时,Range是支持多范围(multirange)请求的,例如"bytes=0-1023,1024-2047"表示获取文件起始的第1kb和第2kb内容,为了保证整个系统简单可控,直接限定不允许多范围请求,杜绝客户端一次请求特别多个chunk的情况。此外,服务端还要检查请求的每一块内容大小是否超过允许的范围,这应该由服务端控制,不能交给客户端自由选择,否则依然会存在前文提及的内存问题。
1.5 客户端实现
服务端的下载接口准备完毕后,客户端还有一些必须的工作要完成,才能完整地实现分片下载。
1.5.1 获取文件大小
为了将文件按照与服务端约定的chunk size分片请求文件内容,首先需要获取文件大小,此时可以提供一个获取文件元信息的接口,也可以与下载文件公用一个接口,只不过先用一个bytes=0-0
或者bytes=0-1
的Range,“试探”性地获取文件内容,从response header中获取Content-Range
值(例如bytes 0-0/1024,其中的1024即文件大小,单位为byte),从而得到文件大小。
1.5.2 下载所有文件块
获取到文件大小后,客户端根据与服务端约定好的chunk size(例如1024 * 1024,即1MB)对文件分割,将原本一次性获取文件内容转变为多次请求,每一次只请求部分内容。
很容易想到的办法是,将文件分片,计算得到每一片的Range范围,依次下载每一块内容,每次下载后如果还有未下载的块,就继续递归
地下载接下来一块内容。
export const download_chunks = (path: string, size: number) => {
const chunk_size = 1024 * 1024; // 1M
const chunks = Math.ceil(size / chunk_size);
let chunk = 0;
const download_chunk = () => {
const start = chunk_size * chunk;
const end = ((start + chunk_size) >= size) ? (size - 1) : (start + chunk_size - 1);
const range = `bytes=${start}-${end}`;
// 省略下载文件部分的代码,用一行log代替
console.log("download chunk:" + (chunk + 1), range);
chunk++;
if (chunk <= chunks - 1) {
download_chunk();
}
};
download_chunk();
};
但由于是递归调用,也很容易想到函数调用栈溢出的情况,这在不同浏览器上也有不同表现。经测试,Firefox上的报错为Uncaught InternalError: too much recursion
,而Chrome上的报错为Uncaught RangeError: Maximum call stack size exceeded
,出现问题的调用深度也不是固定的。
为了解决递归调用的问题,对上面的代码进行一些调整,同时补上了下载每个chunk后的数据存储,这里使用的是LOCALFORAGE
这个库以简单方式操作IndexedDB
。
export const download_chunks = (path: string, params: any, size: number) => {
const chunk_size = 1024 * 1024;
const chunks = Math.ceil(size / chunk_size);
const download_chunk = (current_chunk: number = 0, batch: number=1000) => {
const start = chunk_size * current_chunk;
const end = ((start + chunk_size) >= size) ? (size - 1) : (start + chunk_size - 1);
const range = `bytes=${start}-${end}`;
const url = `/api/${path.indexOf("/") === 0 ? path.substring(1) : path}`;
const config = {
method: "GET",
headers: {
"token": sessionStorage.getItem("token") || "",
"Range": range
}
}
window.fetch(url, config).then((res: any) => res.blob()).then(
(res: any) => {
localforage.setItem(`replace-with-your-file-id-${current_chunk}`, res);
batch--;
if (current_chunk < chunks - 1) {
if (batch > 0) {
download_chunk(current_chunk + 1, batch);
} else {
setTimeout(() => download_chunk(current_chunk + 1, 1000), 0);
}
}
}
).catch(reason => { console.error(reason) });
};
download_chunk(0, 1000);
};
这里用了setTimeout来取消递归函数无限制地增加调用栈深度,但由于setTimeout本身即使在设置延迟为0的时候,还是有微小的延迟,调用次数非常大时,累积的延迟时间就比较明显了。为了尽可能消除这种影响,我们将1000次递归作为一个批次,超过1000次后,使用setTimeout来重置递归。
这里可能不是最佳方案,如果有更好的办法,欢迎赐教!
1.5.3 组装文件
经过上面的步骤,文件会分片下载,最终保存到IndexedDB,以测试代码8.5M的pdf文件为例,按照每个chunk大小为1M进行分割成9个chunk,在IndexedDB中会存储9条记录。
image.png接下来的任务就是要从这些数据中获取Blob,并按顺序合并成一个新的Blob,再将这个Blob转成文件下载。
首先准备一个工具函数,从Blob生成文件,并进行下载。
export const blob_to_file = (blob: Blob, filename?: string) => {
let url = window.URL.createObjectURL(blob);
let a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = filename ? filename : "unnamed";
a.target = "_blank";
document.body.appendChild(a);
a.click();
a.remove();
}
然后根据刚刚存储的文件分片,在已知一共有9个Blob的情况下进行文件合并,实际的场景肯定要进行优化以适应所有情况,这里先演示文件合并及下载过程。
const download_file = () => {
const data: Blob[] = [];
for (let i = 0; i <= 8; i++) {
const key = `replace-with-your-file-id-${i}`;
localforage.getItem(key).then(
(v: any) => {
data.push(v);
if (i === 8) {
// var blob = new Blob([...data], { type: 'application/pdf' });
var blob = new Blob([...data]);
blob_to_file(blob, "测试.pdf");
}
}
);
}
};
运行后,按照给定的文件名下载得到了期望的pdf文件,找到下载位置,能够正常打开,至此分片下载流程已经走通了,但是还遗留一些问题需要解决。
1.5.4 下载优化
刚刚下载文件分片存储到IndexedDB时,使用的Key为replace-with-your-file-id-0,replace-with-your-file-id-1...并且合并文件时也是在已知一共多少个分片的情况下,实际项目中肯定不能这样来读取文件数据,尤其在同时下载多个文件的情况下。
那么怎么以简单的方式来解决这个问题呢?首先为了简单,IndexedDB的操作我们只使用localforage.setItem()
和localforage.getItem()
,实际上除了这两个操作只剩下遍历所有数据了。
为了知道一个文件在IndexedDB中对应哪些数据,我们除了正常的file chunk,再额外存储一条数据,记录文件信息,包括所有的分片在IndexedDB中对应的数据的Key,其结构如下, 这条数据存储的Key可以考虑用文件ID、文件ID+MD5、下载任务ID等等,具体的还要结合业务需求进行选择。
const file_data = {
fileid: "replace-with-your-file-id",
filename: "test.pdf",
type: "application/pdf",
hash: "",
keys: [
"replace-with-your-file-id-0",
"replace-with-your-file-id-1",
"replace-with-your-file-id-2"
]
};
存储了这条额外的数据后,我们就可以根据文件ID或者任务ID等唯一Key从IndexedDB获取file_data,从而找到所有的file chunk。
根据这个思路,将分片下载的代码进一步调整如下
import qs from "qs";
import localforage from 'localforage';
import update from "immutability-helper";
export const download_chunks = (path: string, params: any, size: number) => {
const chunk_size = 1024 * 1024;
const chunks = Math.ceil(size / chunk_size);
const file_data = {
fileid: "replace-with-your-file-id",
filename: "test.pdf",
type: "application/pdf",
hash: "",
keys: new Array<string>()
};
const download_chunk = (current_chunk: number = 0, file_data: object, batch: number = 1000) => {
const start = chunk_size * current_chunk;
const end = ((start + chunk_size) >= size) ? (size - 1) : (start + chunk_size - 1);
const range = `bytes=${start}-${end}`;
console.log("download chunk:" + (current_chunk + 1), range);
const url = `/api/${path.indexOf("/") === 0 ? path.substring(1) : path}`;
if (params) {
path += `?${qs.stringify(params)}`;
}
const config = {
method: "GET",
headers: {
"token": sessionStorage.getItem("token") || "",
"Range": range
}
};
window.fetch(url, config).then((res: any) => res.blob()).then(
(res: any) => {
const file_chunk_key = `replace-with-your-file-id-${current_chunk}`;
localforage.setItem(file_chunk_key, res, () => {
file_data = update(file_data, { keys: { $push: [file_chunk_key] } });
localforage.setItem("task_id/file_id/md5", file_data);
batch--;
if (current_chunk < chunks - 1) {
if (batch > 0) {
download_chunk(current_chunk + 1, file_data, batch);
} else {
setTimeout(() => download_chunk(current_chunk + 1, file_data, 1000), 0);
}
}
});
}
).catch(reason => { console.error(reason) });
};
download_chunk(0, file_data, 1000);
};
调整后,先清空IndexedDB,再尝试下载,再观察IndexedDB中存储的内容,除了之前的9个分片数据,多出了一条数据,记录了文件的相关信息以及所有的file chunk key,后续就可以根据这些key查找具体的分片数据了。
image.png1.5.6 断点续传(breakpoint transmission)
由于使用了分片下载,并且已下载成功的分片数据已经存储到了浏览器的IndexedDB,即使下载中断了,已下载的部分也不会丢失、不需要重新下载,带下次页面重新打开时,继续从未下载的分片开始下载即可。
不过既然有继续下载的概念,那么程序中必然要相应地增加一个下载任务的概念,并且任务有已完成、未完成、暂停、下载中等状态。因此,前文的用户记录文件信息的file_data可能也要做一些调整。
此外,下载中断后,服务器上的文件可能已经发生了变更,如果不管三七二十一,无视这个变化就直接继续下载,得到的文件很可能因为二进制数据错乱导致文件无法正常使用。所以继续下载前,获取文件的md5值与前次的进行比较,如果一致才继续下载,如果不一致就清空已下载的数据并重新下载。
1.5.7 细节处理
优化完IndexedDB相关的操作后,还有些细节内容没有完成,如果想要给用户完美的体验,这些问题还是需要解决的。
- 下载提示
下载完成后,界面上需要给出提示,自动弹出下载完成窗口,提示用户下一步操作。 - Indexed数据清理
也可以说成“缓存清理”,在合适的时机清除文件所有数据,可以结合业务需要处理,下载一次后清理,还是一定时间后清理都可以考虑。
1.5.8 下载进度 progress
同样的,由于是分片下载,每下载完成一个分片后,就可以方便地计算得到已下载的部分占文件总体积的比例,下载进度同样需要按下载任务记录到IndexedDB,并且页面上要提供相应的UI展示。
当然,由于分片一般会固定大小,当文件本身比较小时,下载进度渐进的幅度就会比较大(例如文件大小为4M,分片大小为1M,下载进度只会按0% -> 25% -> 75 -> 100%变化),甚至当文件体积比分片大小还小时,下载进度只会直接从0%到100%变化了。如果为了下载进度看起来更合理,特地根据文件大小动态调整分片大小也可以考虑,但分片分得太小也是不合适的。
2.文件上传
文件上传相对于文件下载,是一个逆向的过程。结合文件下载的经验,如果要实现分片上传文件,并且支持断点续传,那么真正地开始上传动作前,将文件分割存储到IndexedDB中,就成了比较容易想到的思路。
2.1 服务端实现
参考阿里云OSS的分片上传,服务端逻辑分为三步:
- 1.初始化分片上传事件
初始化的目的主要是为了先确定将要上传的文件,并得到一个唯一标识,后续上传分片时,都需要携带这个标识,这样才能判断上传的分片属于哪个文件。 - 2.上传分片
按照约定的分片大小,将一个体积较大的文件,按照确定的分片大小分批将整个文件上传完毕。 - 3.完成分片上传
待所有的分片上传完成后,发送“通知”,将之前上传的分片组装成一个完整的文件。
2.2 客户端实现
2.2.1 初始化 & 完成
照理说,客户端实现应该与服务端一致,首先要进行初始化请求,同时上传完毕后应再发送一个请求告诉服务端上传完成,通知服务端将将之前上传的分片合并成一个完整的文件。但为了简化操作,服务端可以自行检查,如果某个文件首次上传分片,就进行初始化操作,如果是最后一次上传分片,则将这些分片合并,同时删除这些分片。
2.2.2 文件分割
分割文件时,首先从前端页面获取到要上传的File对象,由于File(interface File extends Blob {}
)继承了Blob,使用Blob的slice(start, end, contentType)
方法按照与服务端约定的chunk size对此文件进行分割。需要注意的是,这里的[start, end)
计算范围时使用的是前闭后开的规则,http request header中的Range有所差异。
const blob = new Blob([file], { type: file.type });
2.2.3 文件分片存储到IndexedDB
分片内容存储到IndexedDB的过程与下载文件分片后的操作基本一致,这里不多赘述,文章的最后会放出代码仓库链接。
2.2.4 记录文件上传任务到IndexedDB
同样的,为了知道一个文件被分成了多少块存储在IndexedDB中,我们采用的思路依然是额外存储一条上传文件相关的记录,其中chunks字段记录了这个文件被分割成了哪些块,每一块在IndexedDB中对应的key,以及块的序号、大小、是否已经上传等信息。
2.2.5 从IndexedDB获取文件分片发送至服务端
在所有的文件块在IndexedDB中存储完毕后,接下来就需要将这些文件发送到服务器端。客户端上传分片时,需要传递分片序号,以便服务端按照正确的顺序组装文件。
每上传完成一块后,需要在额外记录文件上传数据的那条记录中更新状态,标记标记哪些块已经上传完成了。一方面方便计算文件上传进度,另外还能在上传任务中断后,下次恢复上传时不至于重新上传之前已经上传过的文件块,只需要继续上传还未上传的部分即可。
2.2.6 上传完成,清理数据
上传完成后,一般不需要考虑特别的场景,将IndexedDB中存储的文件数据清除就行。至于上传任务那条记录,是否要一并删除,问题都不大,页面做好相应的交互及提示,给用户完整的体验就可以。
3.代码实现
后续在编写代码的过程中,重新梳理了思路,尤其在IndexedDB存储上改进了原先的设计,将上传记录、下载记录、文件块分别用一个store
存储。
import localforage from "localforage";
export const db_upload = localforage.createInstance({ name: "myapp", storeName: "upload" });
export const db_download = localforage.createInstance({ name: "myapp", storeName: "download" });
export const db_chunk = localforage.createInstance({ name: "myapp", storeName: "chunk" });
其中upload和download中存储的数据结构也进行了改进,
export interface FileData {
id?: string;
hash: string;
name: string;
type: string;
size: number;
chunks: FileChunk[]; // file chunks stored in IndexedDB
};
export interface FileChunk {
index: number;
key: string;
size: number;
uploaded?: boolean;
}
这样上传记录和下载记录直接遍历upload和download这两个store即可,每个记录相关的chunk,则从chunks字段获取,上传记录的chunk还额外多了一个uploaded字段,表示该分片是否已经上传。
结合上面关于上传和下载的过程和思路分析,这里以上传上传文件到服务端本地为例,提供了完整的前后端代码,供参考。实际项目中需要结合业务场景,将本地存储替换为OSS或其它存储。
Github - file-upload-and-download-example
但是,有一些细节处理是没有实现的,代码中标记了TODO,比如:
- 检查分片是否已经上传过,如果已经上传了则忽略;
- 根据文件md5检查文件是否已经存在,如果存在直接进行链接,无需上传(有些地方将这种做法叫做秒传);
- ...
最后附上两张界面截图
upload download
网友评论