如何优雅地实现网页文件上传与下载

之前公司里面要做视频播放,总结开发过程,写了一篇文章《如何优雅地实现网页播放视频》。近期,项目中又有在网页上传、下载文件的需求,并且要求能够支持大文件和“高并发”,与此前的视频播放在技术上存在一些联系,经过研究和动手实践后,整理如下,方便有需要的人。

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.png
1.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
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,569评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,499评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,271评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,087评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,474评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,670评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,911评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,636评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,397评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,607评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,093评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,418评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,074评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,092评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,865评论 0 196
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,726评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,627评论 2 270

推荐阅读更多精彩内容