美文网首页基础前端
断点续传和分片上传

断点续传和分片上传

作者: CondorHero | 来源:发表于2021-01-30 19:30 被阅读0次
    断点续传和分片上传.png

    先来个总结,我之前和将要写到的文件上传用到的技术和场景描述:

    场景描述 使用技术
    图片上传前的预览 FileReader 或 createObjectURL
    限制用户上传的文件格式和大小 通过文件对象 File 的 size 和 type 属性
    强大的原生 Form 表单上传 FileList 对象
    虚拟表单上传 FormData
    ctrl + v 上传 paste 事件
    鼠标拖拽上传 dropover 和 drop 事件, DataTransfer 对象
    大文件 分片上传 Blob 的 slice 方法
    大文件 分片下载 HTTP 的 Range 技术
    体验更好的 断点续传/下载 暂存技术
    秒传 MD5 等摘要算法加密

    有几项涉及到的技术,在之前的博客有提到过,就是下面这两篇文章,链接如下:

    本次重点来写下「分片」和「断点」这两个技术。

    写完发现一篇好文章:NodeJS实现简单的HTTP文件断点续传下载功能

    一、实现分片上传和断点续传

    分片上传又叫切片上传

    我们知道使用 <input type="file" name="file" /> 元素选择一个文件之后,会得到 File 对象,而 File 对象 又天生继承 Blob,正好 Blob 对象有个方法叫 slice。这个方法和数组的 slice 方法使用基本相同,它可以获取待上传文件的某一部分,经过 slice 方法处理之后得到的结果也是一个 Blob。

    我们先来个文件上传案例:

    前端代码:

    <!DOCTYPE html>
    <html lang="en">
    
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>分片上传</title>
        <style>
            html, body {
                display: flex;
                align-items: center;
                justify-content: center;
                height: 100%;
            }
        </style>
    </head>
    
    <body>
        <script src="./axios.min.js"></script>
        <input type="file" name="file" id="ipt" />
        <button onclick="upload()">上传</button>
        <script>
            function upload() {
                const file = ipt.files[0];
                const vform = new FormData();
                vform.append("vform", file, file.name);
                axios.post("/upload", vform).then(res => {
                    console.log(vform, res);
                });
            }
        </script>
    </body>
    
    </html>
    

    后端代码:

    const multiparty = require("multiparty");
    const bodyParser = require("body-parser");
    const path = require("path");
    const express = require("express");
    const app =  express();
    const fs = require("fs");
    function resolvePath (dir) {
        return path.join(__dirname, dir);
    }
    
    
    app.use(express.static(resolvePath("/public")));
    // https://expressjs.com/en/4x/api.html#req.body
    app.use(bodyParser.json({ limit: "50mb" }));
    app.use(bodyParser.urlencoded({ extended: true }));
    
    app.post("/upload", function (req, res) {
        const form = new multiparty.Form({ uploadDir: "public" });
        form.parse(req);
        form.on("file", function(name, file) {
            console.log(name, file)
            const { path, originalFilename } = file;
            fs.renameSync(path, `public/${originalFilename}`);
            res.json({
                url: `http://localhost:48488/${originalFilename}`,
                message: "发送成功"
            });
        })
    });
    
    const port = 48488;
    app.listen(port, function () {
        console.log(`listen port ${port}`);
    });
    

    一个简单的文件上传就完成了,现在开始切片上传功能开发,切片上传就是把一个文件切分成很多小文件,本来上传一个大文件,现在改成上传很多小文件

    如何切文件,这就很哲学了,流行两种思路:

    • 不管上传文件的大小,切成固定的块数,然后上传。
    • 不管上传文件的大小,每次切的块大小相同,然后上传。

    第一种方法的缺点就是,如果文件过小的话,切成固定的块数,明显浪费 HTPP 请求,如果文件过大,切成固定的块数,切的每块可能依然过大,即切片的切片还需要继续切片。所以使用这种方法,需要加上限定条件,假如上传文件的大小为 s,限定条件应该这样写 n <= s <= m

    第二种方法的缺点就是,如何确定每次上传文件的大小,定小了,容易出现 HTTP 请求过多,定大了,容易出现切片效果不理想,切片的大小真是让人头疼。

    所以,我们常常需要这两种办法结合使用,上传文件的代码逻辑应该这样写:

    1. 文件过小,不用切片,可以直接上传。例如 10kb、190kb、200kb、甚至 1M……。
    2. 文件三四十兆的这种,就固定切片大小就好。
    3. 大于一百兆但是小于 1G 的这种,可以分区间,不同的区间给不同固定的分包数量。
    4. 如果文件再大,可以两者方法结合用,先固定分包数量,然后随机包大小。
    5. 文件超大的那种,应该寻求并行上传方法,简单点前端可以直接禁止上传超大文件。
    6. 重复文件上传应该有秒传功能。

    以上是一个非常完善的切片上传逻辑,项目没有要求我当然不会实现的了,毕竟要写好多的判断,不过切片上传的核心功能,我还是得通过代码来实现的,一起来看看。

    前端代码:递归实现切片上传

    <!DOCTYPE html>
    <html lang="en">
    
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>分片上传</title>
        <style>
            html, body {
                display: flex;
                align-items: center;
                justify-content: center;
                height: 100%;
            }
        </style>
    </head>
    
    <body>
        <script src="./axios.min.js"></script>
        <script src="./spark-md5.min.js"></script>
        <input type="file" name="file" id="ipt" />
        <button onclick="upload(0)">上传</button>
        <script>
            const chunkSize = 1024 * 1024; // 默认分片大小为1兆。1kb = 1024byte, 1m = 1024 kb
            let fileFingerprint = undefined;
            const file = ipt.files[0];
            function upload(index) {
                const { name, type, size } = file;
                
                // 生成文件指纹
                const spark = fileFingerprint || new SparkMD5.ArrayBuffer();
                spark.append(file);
                const hexHash = spark.end();
                
                const extName = name.substring(name.lastIndexOf("."));
                const startIndex = chunkSize * index;
    
                // 文件上传完,终止递归同时合并文件
                if ( startIndex > size ) {
                    axios.post("/merge", {
                        fileName: name,
                        hexHash,
                        extName
                    }).then(res => {
                        fileFingerprint = undefined;
                        console.log(res);
                    });
                    return;
                };
                const endIndex = startIndex + chunkSize > size ? size : startIndex + chunkSize;
                const blobPart = file.slice(startIndex, endIndex, type);
                // FormData 直接上传切片后的文件,文件名默认为 blob( filename="blob")
                // 这里通过 File 给个文件名
                const blobFile = new File([blobPart],  `${hexHash}-${index}${extName}`, { type });
    
                // 创建虚拟表单进行文件上传
                const vform = new FormData();
                vform.append("vform", blobFile);
                axios.post("/upload", vform).then(res => {
                    // 分片 => 通过递归实现
                    upload(++index);
                });
            }
        </script>
    </body>
    
    </html>
    

    后端代码:两个重要的路由

    app.post("/upload", function (req, res) {
        const form = new multiparty.Form({ uploadDir: "temp" });
        form.parse(req);
        form.on("file", function(name, file) {
            const { path, originalFilename } = file;
            fs.renameSync(path, `temp/${originalFilename}`);
            res.json({
                code: "200",
                message: "发送成功"
            });
        })
    });
    app.post("/merge", function (req, res) {
        const { fileName, hexHash, extName } = req.body;
        const readDir = fs.readdirSync(resolvePath("./temp"));
        readDir.sort((a, b) => a - b).map(chunkPath => {
            fs.appendFileSync(
                resolvePath(`public/${fileName}`),
                fs.readFileSync(resolvePath(`temp/${chunkPath}`))
            );
            fs.rmSync(resolvePath(`temp/${chunkPath}`));
        });
        // fs.rmdirSync(resolvePath("./temp"));
        res.json({
            url: `http://localhost:48488/${fileName}`,
            message: "发送成功"
        });
    });
    

    一个牛叉而又简单的切片上传就完成了。

    简单切片上传

    你看我们 network 面板里面的 waterfall 你会发现,接口是串行发出的(当然根据前端代码你也能得出来这个结论),聪明的你这时候肯定想到了,这样是不是有点浪费 HTTP 请求,而且速度并没有达到最快,既然串行的方法不太好,我们就并行上传。

    并行实现逻辑:略。

    这时候应该考虑一个用户体验的问题,一如果文件是在太大,文件还没上传完,用户需要暂时离开,关上电子设备。二网络过差,甚至差到断网。这时候我们应该提供 暂停/继续 上传功能。这个功能后端不需要动代码逻辑,只需要前端记住切片上传的位置就行了,这个很简单,简单的加个变量来控制下就行了,如果想体验更好点,甚至要考虑加上取消请求的功能。

    这个暂停/继续上传功能也有局限,就是用户刷新了页面,重新再打开,受浏览器的限制,我们不能用例如 NodeJS 中 fs 模块来主动获取文件,只能用户手动上传,前端来能获取到文件 File,所以暂停/继续上传功能受页面不能刷新影响很大。那问题来了,请问怎么解决?

    自然而然的,我们想到把文件对象直接存储在本地不就行了,好主意,那存在哪里呢?存在 localStorage 怎么样?好像不太行,localStorage 大小就能存约 5M 大小,在如今的网络时代下,这怎么能够用。那就没办法了吗?非也,还有一个终极大杀招,那就 IndexDB,我们通过 MD5 来确认文件的唯一性,然后把没有上传的部分放入 IndexDB 里面,一旦上传完,就立刻删除,最大可能的节省空间。哈哈哈,这下算是彻底的解决问题了。

    但是此时又有一个问题,就是我在 PC 我上传文件,但是只是上传一半就关闭了网页,此时我换设备了,跑到 iPad 或 手机再次打开上传文件页面。我也想要看到未上传的文件。这下麻烦大了,但是也有解决办法。

    首先上传进度,肯定是后端记住,而不是前端了。其次对于设备上没有此文件的上传我们只需要简单的提醒用户,要么使用原设备,要么使用此设备手动重新上传。

    如果用户选择了重新上传,后端需要根据此文件的 MD5 检索出来文件已经上传的部分,前端续传,而不是真正的重新开始。

    断点续传和分片上传,到此完结撒花🎉。

    二、秒传

    上面提到了,续传功能,那就没有理由不支持秒传功能,这个更加简单了,就是根据上传文件的 MD5,在数据库中检索已经上传文件的 MD5,一旦检索到直接上传完成。

    三、分片下载和断点续载

    分片下载又叫切片下载

    分片下载和分片上传的原理那是大大的不同,不过思路都是一致的,就是大化小。与分片上传利用 File 对象 不同,分片下载用到的技术是 HTTP 中的知识。

    你先猜猜用到的是 HTTP 中的什么知识?

    猜不出来吧,那你的去补补 HTTP 的知识了,推荐「图解 HTTP」这本书。答案揭晓其实用到是 Range Requests 的知识,如果你想更加系统的学习,请参考 RFC 7233

    这个技术可能我们前端不经常用到,但是平时我们接触还是非常多的。例如像迅雷这样的多线程下载器,我们平时看视频,进度条随意拖拽只加载部分视频流等等。这么一讲你是不是,有种天灵盖被揭开的柑橘,哦,原来这些功能都是 HTTP 请求的功劳。

    好了,废话不多说,直接上硬菜,先来学学 HTTP 知识的内容。

    Accept-Ranges

    我们知道 HTTP 最早是用来传输文本的,现在想把一个文件切开一部分一部分的传输,那就需要支持更加底层的传输单位,没错就是 字节(byte)。根据规范,在使用 byte 传输的时候,首先验证服务器是否支持这种传输方式。

    我们需要在服务器上通过 Accept-Ranges 头部表示是否支持 Range,Accept-Ranges 的格式为:

    Accept-Ranges = acceptable-ranges
    

    acceptable-ranges 的值有两个:

    • Accept-Ranges: none 不支持 bytes 请求
    • Accept-Ranges = bytes 支持 bytes 请求

    来看下哔哩哔哩网站视频播放时其中一个接口。

    哔哩哔哩
    Range 请求范围的单位

    现在知道服务器支持 byte 请求了,那怎么表示请求范围呢?该是 Range 登场的时候了。来看看 Range 的格式:

     Range = byte-ranges-specifier / other-ranges-specifier
    

    现在假设我们要获取的文件大小为 2000 bytes,那么我们可以按下面步骤获取。

    • 第 1 个 500 字节:bytes=0-499
    • 第 2 个 500 字节 bytes=500-999
    • 第 3 个 500 字节 bytes=500-600,601-999 也可以有重合部分 bytes=500-700,601-999 重合的只会加载一次。注意这种请求叫多重范围请求,它请求头的 Content-Type 比较特殊长成这样Content-Type:multipart/byteranges; boundary=…,有点类似 POST 表单提交的方式。关于 byteranges 请去 MDN 查看教程。
    • 最后 1 个 500 字节:bytes=-500bytes=9500-

    看完只会,考你个问题,如果仅要第 1 个和最后 1 个字节,怎么写?

    bytes=0-0,-1

    OK,这次我们来看看微博上视频播放时 Range 是如何写的。

    看完,不知道你发现没,微博和哔哩哔哩请求头响应头的定义有点不同,微博第一个单词都是大写的,哔哩哔哩都是小写的,本人更喜欢微博的做法,严格遵守了 RFC 规定。

    Content-Range

    服务器收到浏览器的请求了,那么服务器如何返回资源呢?没错就是 Content-Range 了。给个例子来看看它的语法格式:

    Content-Range: bytes start-end/total
    

    上面表示一次 HTTP 请求,服务端返回的结果为 start-end 区间,这次请求的资源总大小为 total。看个例子:

    哔哩哔哩 Content-Range 例子

    还没完,看到我上面标出来的状态码了吧,我们知道一个正常的 HTTP 请求完成之后我们会收到 200 状态码,但是我们用 Range 请求服务器的时候,有所不同,分为以下几种情况:

    • 服务器不支持 Range 请求时,则以 200 返回完整的响应包体
    • 服务器支持 Range 请求的时候,一次正常请求结束返回 206 Partial Content
    • 服务器支持 Range 请求的时候,当请求范围不满足实际资源的大小,返回状态码 416 Range Not Satisfiable ,同时 Content-Range 中的 complete- length 显示完整响应的长度,例如 : Content-Range: bytes */1234
    HTTP 的条件请求

    平时涉及到 HTTP 条件请求的头部有以下五个。

    • If-Match = "*" / 1#entity-tag
    • If-None-Match = "*" / 1#entity-tag
    • If-Modified-Since = HTTP-date
    • If-Unmodified-Since = HTTP-date
    • If-Range = entity-tag / HTTP-date

    If-None-MatchIf-Modified-Since 这两个请求头相信你非常熟悉了,属于协商缓存的内容,相信这时候你马上能想到一个非常经典的前端面试题——说说浏览器的缓存机制。,不懂这个面试题的可以去看看这个博客 图解 HTTP 缓存

    剩下几个我相信你就不太懂了,我们一起来学习下,If-None-MatchIf-Modified-Since 分别取反就是 If-MatchIf-Unmodified-Since。取反的 If-MatchIf-Unmodified-Since 就是给我们前面讲的 Accept-Ranges 使用的,当我们一点点的从服务器获取数据的时候,突然此时已经获取的数据发生了变化,这时我们肯定不能接着获取数据了,而是要从新获取数据。这是我们现在遇到的问题,那怎么解决这个问题呢?

    两个方法:

    1. If-MatchIf-Unmodified-Since

    通过请求头携带的 If-MatchIf-Unmodified-Since 来判断文件是否被修改,如果文件被修改,直接返回 412 (Precondition Failed)来告诉浏览器,正在请求的资源发生了改变,请重新发送请求进行获取。

    使用 NodeJS 来模拟下资源被更新返回 412 状态码这个过程,不过先插播一段知识,关于 NodeJS 获取请求头的方面的,即 express req.headers 大小写问题。

    express 中通过 req.query 来获取客户端 Query 参数,客户端上传的参数是严格遵守大小写的,但是 req.headers 来获取请求头时,接收到的全是小写。搞的人很郁闷,为什么这么奇怪呢,原来是和 HTTP 协议有关。详情见:express request.headers 大小写问题,坑!,我是受不了大小写混乱,所以使用 req.get() 来获取请求头,因为这个 API 是忽略大小写的。

    我们的 NodeJS 后端代码如下:

    app.get("/download", function (req, res) {
        const Range = req.get("Range");
        const clientMatch = req.get("If-Match");
        const clientmodifiedSince = req.get("If-Unmodified-Since");
    
        const readPath = resolvePath("./test.js");
    
        const md5 = crypto.createHash("md5");
        md5.update(fs.readFileSync(readPath));
        const serverMatch = md5.digest("hex");
    
        const { mtime } = fs.statSync(readPath);
        const timeStamp = mtime.getTime();
        const lastModifiedStringDate = new Date(clientmodifiedSince || 0).getTime();
    
        if ((clientMatch && clientMatch !== serverMatch) || (clientmodifiedSince && lastModifiedStringDate !== timeStamp)) {
            res.sendStatus(412);
        } else {
            const rangeBytes = Range.substring(Range.lastIndexOf("=") + 1);
            const [ start, end ] = rangeBytes.split("-");
            res.setHeader("ETag", serverMatch);
            res.setHeader("Last-Modified", timeStamp);
            fs.createReadStream(readPath, { start: +start, end: +end }).pipe(res);
        };
    });
    

    我们要读取的文件 test.js 的内容为:

    CondorHero
    

    然后我们采用 CURL 命令工具进行调试,先看看如何获取一段数据:

    curl http://localhost:48488/download -H "Range: bytes=0-5"  // Condor
    

    再来获取下请求头,为 HTTP 条件请求做准备:

    curl http://localhost:48488/download -H "Range: bytes=0-5" -I
    HTTP/1.1 200 OK
    X-Powered-By: Express
    ETag: 532462711215f93a3206e236e45f894e
    LastModified: 1611933312687
    Date: Sat, 30 Jan 2021 08:01:20 GMT
    Connection: keep-alive
    Keep-Alive: timeout=5
    

    然后利用条件请求 If-MatchIf-Unmodified-Since 来做一个正常请求,这个我选择了 If-Match

    curl http://localhost:48488/download -H "Range: bytes=0-5" -H "If-Match: 532462711215f93a3206e236e45f894e"  // Condor
    

    依然正常输出。随便更改下 If-Match 的值,再次发送请求:

    curl http://localhost:48488/download -H "Range: bytes=0-5" -H "If-Match: 哈哈哈哈"  // 402 Precondition Failed
    

    模拟完成。

    上面这个方法有个缺点,那就是数据一旦被改变,浏览需要先获取 412 状态码,然后浏览器再准备发送请求,我们发现多了一个请求来回,如果服务器对比完,发现资源被改变,能直接完整返回最新资源就完美了,省去了一次 HTTP 请求。没错 If-Range 就是用来干这个的。

    1. If-Range

    稍微修改下后端的代码:

    app.get("/download", function (req, res) {
        const Range = req.get("Range");
        const clientMatch = req.get("If-Range");
        const clientmodifiedSince = req.get("If-Unmodified-Since");
    
        const readPath = resolvePath("./test.js");
    
        const md5 = crypto.createHash("md5");
        md5.update(fs.readFileSync(readPath));
        const serverMatch = md5.digest("hex");
    
        const { mtime } = fs.statSync(readPath);
        const timeStamp = mtime.getTime();
        const lastModifiedStringDate = new Date(clientmodifiedSince || 0).getTime();
    
        if ((clientMatch && clientMatch !== serverMatch) || (clientmodifiedSince && lastModifiedStringDate !== timeStamp)) {
            res.setHeader("ETag", serverMatch);
            res.setHeader("Last-Modified", timeStamp);
            fs.createReadStream(readPath).pipe(res);
        } else {
            const rangeBytes = Range.substring(Range.lastIndexOf("=") + 1);
            const [ start, end ] = rangeBytes.split("-");
            res.setHeader("ETag", serverMatch);
            res.setHeader("Last-Modified", timeStamp);
            fs.createReadStream(readPath, { start: +start, end: +end }).pipe(res);
        };
    });
    

    当我们再次发送请求的时候:

    curl http://localhost:48488/download -H "Range: bytes=0-5" -H "If-Range: 哈哈哈哈哈"
    // 返回结果为 CondorHero
    // 没有返回 412 而是直接返回全部结果
    

    条件请求我们就讲完了,现在直接开始做分片下载的 Demo 好了。

    分片下载

    现在分片实现最重要的两点就是:

    • 前端 => 递归
    • 后端 => createReadStream 的用法

    然后我们后端现在几乎都不要改动什么,简单的加点东西就行了,看下接口。

    app.get("/download", function (req, res) {
        const Range = req.get("Range");
        const clientMatch = req.get("If-Range");
        const clientmodifiedSince = req.get("If-Unmodified-Since");
    
        const readPath = resolvePath("./test.txt");
    
        const md5 = crypto.createHash("md5");
        md5.update(fs.readFileSync(readPath));
        const serverMatch = md5.digest("hex");
    
        const { mtime, blksize } = fs.statSync(readPath);
        const timeStamp = mtime.getTime();
        const lastModifiedStringDate = new Date(clientmodifiedSince || 0).getTime();
    
        if ((clientMatch && clientMatch !== serverMatch) || (clientmodifiedSince && lastModifiedStringDate !== timeStamp)) {
            res.setHeader("ETag", serverMatch);
            res.setHeader("Last-Modified", timeStamp);
            fs.createReadStream(readPath).pipe(res);
        } else {
            const rangeBytes = Range.substring(Range.lastIndexOf("=") + 1);
            const [ start, end ] = rangeBytes.split("-");
            res.setHeader("Accetp-Ranges", "bytes");
            res.setHeader("ETag", serverMatch);
            res.setHeader("Last-Modified", timeStamp);
            res.setHeader("Total-Size", blksize);
            res.setHeader("Content-Range", `bytes ${start}-${end}/${blksize}`);
            res.setHeader("fileName", encodeURIComponent("浣溪沙-晏殊.txt"));
            fs.createReadStream(readPath, { start: +start, end: +end }).pipe(res);
        };
    });
    

    我们要接收的文件它长成这样:

    浣溪沙·小阁重帘有燕过
        晏殊〔宋代〕
    小阁重帘有燕过。晚花红片落庭莎。曲阑干影入凉波。
    一霎好风生翠幕,几回疏雨滴圆荷。酒醒人散得愁多。
    

    前端就很好写了,递归分片:

    <!DOCTYPE html>
    <html lang="en">
    
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>分片下载</title>
    </head>
    
    <body>
        <script src="./axios.min.js"></script>
        <button onclick="download(0, 1000)">下载</button>
        <script>
            let downloadText = "", totalSize = 0, fileName;
            function download(startRang, endRang) {
                // 下载
                if ( totalSize && totalSize <= startRang) {
                    const url = window.URL.createObjectURL(new Blob([downloadText]));
                    const link = document.createElement("a");
                    link.style.display = "none";
                    link.href = url;
                    link.setAttribute("download", decodeURIComponent(fileName));
                    document.body.appendChild(link);
                    link.click();
                    document.body.removeChild(link);
                    totalSize=0;
                    downloadText="";
                    fileName="fileName";
                    return;
                }
                // totalSize 4096
                const ajaxConfig = {
                    headers: {
                        responseType: "arraybuffer",
                        Range: `bytes=${startRang}-${endRang}`
                    }
                };
                const downRes = axios.get("/download", ajaxConfig);
                downRes.then(res => {
                    !totalSize && (totalSize = res.headers["total-size"]);
                    !fileName && (fileName = res.headers["filename"]);
                    const data = res.data
                    downloadText += data;
                    startRang = endRang;
                    if (totalSize - endRang > 1000) {
                        endRang += 1000;
                    } else {
                        endRang = totalSize;
                    };
                    // 分片 => 递归
                    download(startRang, endRang);
                });
    
            }
        </script>
    </body>
    
    </html>
    

    把网络调慢点,我们看下分片下载的演示效果:

    2021-01-30 19-19-50.2021-01-30 19_21_44.gif

    这里送你一张分片请求链接的图:

    断点下载思路,参考上面断点上传。

    完~

    四、最后

    代码我都是很简单的略写实现,并没有太过深入的精心实现。这是因为我们前端遇到大多数的项目,就是一个简单的文件上传,顶多文件过大加点分片上传。像百度云网页那种完美的实现,应该很少有公司有这种业务场景。所以,我们只要大概的了解个原理,妥妥的应付面试就行了。

    当前时间 Saturday, January 30, 2021 19:29:40 北京办公室

    相关文章

      网友评论

        本文标题:断点续传和分片上传

        本文链接:https://www.haomeiwen.com/subject/fzamrktx.html