斷點(diǎn)續(xù)傳和分片上傳

斷點(diǎn)續(xù)傳和分片上傳.png

先來個(gè)總結(jié),我之前和將要寫到的文件上傳用到的技術(shù)和場景描述:

場景描述 使用技術(shù)
圖片上傳前的預(yù)覽 FileReader 或 createObjectURL
限制用戶上傳的文件格式和大小 通過文件對象 File 的 size 和 type 屬性
強(qiáng)大的原生 Form 表單上傳 FileList 對象
虛擬表單上傳 FormData
ctrl + v 上傳 paste 事件
鼠標(biāo)拖拽上傳 dropover 和 drop 事件, DataTransfer 對象
大文件 分片上傳 Blob 的 slice 方法
大文件 分片下載 HTTP 的 Range 技術(shù)
體驗(yàn)更好的 斷點(diǎn)續(xù)傳/下載 暫存技術(shù)
秒傳 MD5 等摘要算法加密

有幾項(xiàng)涉及到的技術(shù),在之前的博客有提到過,就是下面這兩篇文章,鏈接如下:

本次重點(diǎn)來寫下「分片」和「斷點(diǎn)」這兩個(gè)技術(shù)。

寫完發(fā)現(xiàn)一篇好文章:NodeJS實(shí)現(xiàn)簡單的HTTP文件斷點(diǎn)續(xù)傳下載功能

一、實(shí)現(xiàn)分片上傳和斷點(diǎn)續(xù)傳

分片上傳又叫切片上傳

我們知道使用 <input type="file" name="file" /> 元素選擇一個(gè)文件之后,會(huì)得到 File 對象,而 File 對象 又天生繼承 Blob,正好 Blob 對象有個(gè)方法叫 slice。這個(gè)方法和數(shù)組的 slice 方法使用基本相同,它可以獲取待上傳文件的某一部分,經(jīng)過 slice 方法處理之后得到的結(jié)果也是一個(gè) Blob。

我們先來個(gè)文件上傳案例:

前端代碼:

<!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: "發(fā)送成功"
        });
    })
});

const port = 48488;
app.listen(port, function () {
    console.log(`listen port ${port}`);
});

一個(gè)簡單的文件上傳就完成了,現(xiàn)在開始切片上傳功能開發(fā),切片上傳就是把一個(gè)文件切分成很多小文件,本來上傳一個(gè)大文件,現(xiàn)在改成上傳很多小文件。

如何切文件,這就很哲學(xué)了,流行兩種思路:

  • 不管上傳文件的大小,切成固定的塊數(shù),然后上傳。
  • 不管上傳文件的大小,每次切的塊大小相同,然后上傳。

第一種方法的缺點(diǎn)就是,如果文件過小的話,切成固定的塊數(shù),明顯浪費(fèi) HTPP 請求,如果文件過大,切成固定的塊數(shù),切的每塊可能依然過大,即切片的切片還需要繼續(xù)切片。所以使用這種方法,需要加上限定條件,假如上傳文件的大小為 s,限定條件應(yīng)該這樣寫 n <= s <= m。

第二種方法的缺點(diǎn)就是,如何確定每次上傳文件的大小,定小了,容易出現(xiàn) HTTP 請求過多,定大了,容易出現(xiàn)切片效果不理想,切片的大小真是讓人頭疼。

所以,我們常常需要這兩種辦法結(jié)合使用,上傳文件的代碼邏輯應(yīng)該這樣寫:

  1. 文件過小,不用切片,可以直接上傳。例如 10kb、190kb、200kb、甚至 1M……。
  2. 文件三四十兆的這種,就固定切片大小就好。
  3. 大于一百兆但是小于 1G 的這種,可以分區(qū)間,不同的區(qū)間給不同固定的分包數(shù)量。
  4. 如果文件再大,可以兩者方法結(jié)合用,先固定分包數(shù)量,然后隨機(jī)包大小。
  5. 文件超大的那種,應(yīng)該尋求并行上傳方法,簡單點(diǎn)前端可以直接禁止上傳超大文件。
  6. 重復(fù)文件上傳應(yīng)該有秒傳功能。

以上是一個(gè)非常完善的切片上傳邏輯,項(xiàng)目沒有要求我當(dāng)然不會(huì)實(shí)現(xiàn)的了,畢竟要寫好多的判斷,不過切片上傳的核心功能,我還是得通過代碼來實(shí)現(xiàn)的,一起來看看。

前端代碼:遞歸實(shí)現(xiàn)切片上傳

<!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; // 默認(rèn)分片大小為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;

            // 文件上傳完,終止遞歸同時(shí)合并文件
            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 直接上傳切片后的文件,文件名默認(rèn)為 blob( filename="blob")
            // 這里通過 File 給個(gè)文件名
            const blobFile = new File([blobPart],  `${hexHash}-${index}${extName}`, { type });

            // 創(chuàng)建虛擬表單進(jìn)行文件上傳
            const vform = new FormData();
            vform.append("vform", blobFile);
            axios.post("/upload", vform).then(res => {
                // 分片 => 通過遞歸實(shí)現(xiàn)
                upload(++index);
            });
        }
    </script>
</body>

</html>

后端代碼:兩個(gè)重要的路由

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: "發(fā)送成功"
        });
    })
});
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: "發(fā)送成功"
    });
});

一個(gè)牛叉而又簡單的切片上傳就完成了。

簡單切片上傳

你看我們 network 面板里面的 waterfall 你會(huì)發(fā)現(xiàn),接口是串行發(fā)出的(當(dāng)然根據(jù)前端代碼你也能得出來這個(gè)結(jié)論),聰明的你這時(shí)候肯定想到了,這樣是不是有點(diǎn)浪費(fèi) HTTP 請求,而且速度并沒有達(dá)到最快,既然串行的方法不太好,我們就并行上傳。

并行實(shí)現(xiàn)邏輯:略。

這時(shí)候應(yīng)該考慮一個(gè)用戶體驗(yàn)的問題,一如果文件是在太大,文件還沒上傳完,用戶需要暫時(shí)離開,關(guān)上電子設(shè)備。二網(wǎng)絡(luò)過差,甚至差到斷網(wǎng)。這時(shí)候我們應(yīng)該提供 暫停/繼續(xù) 上傳功能。這個(gè)功能后端不需要?jiǎng)哟a邏輯,只需要前端記住切片上傳的位置就行了,這個(gè)很簡單,簡單的加個(gè)變量來控制下就行了,如果想體驗(yàn)更好點(diǎn),甚至要考慮加上取消請求的功能。

這個(gè)暫停/繼續(xù)上傳功能也有局限,就是用戶刷新了頁面,重新再打開,受瀏覽器的限制,我們不能用例如 NodeJS 中 fs 模塊來主動(dòng)獲取文件,只能用戶手動(dòng)上傳,前端來能獲取到文件 File,所以暫停/繼續(xù)上傳功能受頁面不能刷新影響很大。那問題來了,請問怎么解決?

自然而然的,我們想到把文件對象直接存儲(chǔ)在本地不就行了,好主意,那存在哪里呢?存在 localStorage 怎么樣?好像不太行,localStorage 大小就能存約 5M 大小,在如今的網(wǎng)絡(luò)時(shí)代下,這怎么能夠用。那就沒辦法了嗎?非也,還有一個(gè)終極大殺招,那就 IndexDB,我們通過 MD5 來確認(rèn)文件的唯一性,然后把沒有上傳的部分放入 IndexDB 里面,一旦上傳完,就立刻刪除,最大可能的節(jié)省空間。哈哈哈,這下算是徹底的解決問題了。

但是此時(shí)又有一個(gè)問題,就是我在 PC 我上傳文件,但是只是上傳一半就關(guān)閉了網(wǎng)頁,此時(shí)我換設(shè)備了,跑到 iPad 或 手機(jī)再次打開上傳文件頁面。我也想要看到未上傳的文件。這下麻煩大了,但是也有解決辦法。

首先上傳進(jìn)度,肯定是后端記住,而不是前端了。其次對于設(shè)備上沒有此文件的上傳我們只需要簡單的提醒用戶,要么使用原設(shè)備,要么使用此設(shè)備手動(dòng)重新上傳。

如果用戶選擇了重新上傳,后端需要根據(jù)此文件的 MD5 檢索出來文件已經(jīng)上傳的部分,前端續(xù)傳,而不是真正的重新開始。

斷點(diǎn)續(xù)傳和分片上傳,到此完結(jié)撒花??。

二、秒傳

上面提到了,續(xù)傳功能,那就沒有理由不支持秒傳功能,這個(gè)更加簡單了,就是根據(jù)上傳文件的 MD5,在數(shù)據(jù)庫中檢索已經(jīng)上傳文件的 MD5,一旦檢索到直接上傳完成。

三、分片下載和斷點(diǎn)續(xù)載

分片下載又叫切片下載

分片下載和分片上傳的原理那是大大的不同,不過思路都是一致的,就是大化小。與分片上傳利用 File 對象 不同,分片下載用到的技術(shù)是 HTTP 中的知識(shí)。

你先猜猜用到的是 HTTP 中的什么知識(shí)?

猜不出來吧,那你的去補(bǔ)補(bǔ) HTTP 的知識(shí)了,推薦「圖解 HTTP」這本書。答案揭曉其實(shí)用到是 Range Requests 的知識(shí),如果你想更加系統(tǒng)的學(xué)習(xí),請參考 RFC 7233。

這個(gè)技術(shù)可能我們前端不經(jīng)常用到,但是平時(shí)我們接觸還是非常多的。例如像迅雷這樣的多線程下載器,我們平時(shí)看視頻,進(jìn)度條隨意拖拽只加載部分視頻流等等。這么一講你是不是,有種天靈蓋被揭開的柑橘,哦,原來這些功能都是 HTTP 請求的功勞。

好了,廢話不多說,直接上硬菜,先來學(xué)學(xué) HTTP 知識(shí)的內(nèi)容。

Accept-Ranges

我們知道 HTTP 最早是用來傳輸文本的,現(xiàn)在想把一個(gè)文件切開一部分一部分的傳輸,那就需要支持更加底層的傳輸單位,沒錯(cuò)就是 字節(jié)(byte)。根據(jù)規(guī)范,在使用 byte 傳輸?shù)臅r(shí)候,首先驗(yàn)證服務(wù)器是否支持這種傳輸方式。

我們需要在服務(wù)器上通過 Accept-Ranges 頭部表示是否支持 Range,Accept-Ranges 的格式為:

Accept-Ranges = acceptable-ranges

acceptable-ranges 的值有兩個(gè):

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

來看下嗶哩嗶哩網(wǎng)站視頻播放時(shí)其中一個(gè)接口。

嗶哩嗶哩
Range 請求范圍的單位

現(xiàn)在知道服務(wù)器支持 byte 請求了,那怎么表示請求范圍呢?該是 Range 登場的時(shí)候了。來看看 Range 的格式:

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

現(xiàn)在假設(shè)我們要獲取的文件大小為 2000 bytes,那么我們可以按下面步驟獲取。

  • 第 1 個(gè) 500 字節(jié):bytes=0-499
  • 第 2 個(gè) 500 字節(jié) bytes=500-999
  • 第 3 個(gè) 500 字節(jié) bytes=500-600,601-999 也可以有重合部分 bytes=500-700,601-999 重合的只會(huì)加載一次。注意這種請求叫多重范圍請求,它請求頭的 Content-Type 比較特殊長成這樣Content-Type:multipart/byteranges; boundary=…,有點(diǎn)類似 POST 表單提交的方式。關(guān)于 byteranges 請去 MDN 查看教程。
  • 最后 1 個(gè) 500 字節(jié):bytes=-500bytes=9500-

看完只會(huì),考你個(gè)問題,如果僅要第 1 個(gè)和最后 1 個(gè)字節(jié),怎么寫?

bytes=0-0,-1

OK,這次我們來看看微博上視頻播放時(shí) Range 是如何寫的。

看完,不知道你發(fā)現(xiàn)沒,微博和嗶哩嗶哩請求頭響應(yīng)頭的定義有點(diǎn)不同,微博第一個(gè)單詞都是大寫的,嗶哩嗶哩都是小寫的,本人更喜歡微博的做法,嚴(yán)格遵守了 RFC 規(guī)定。

Content-Range

服務(wù)器收到瀏覽器的請求了,那么服務(wù)器如何返回資源呢?沒錯(cuò)就是 Content-Range 了。給個(gè)例子來看看它的語法格式:

Content-Range: bytes start-end/total

上面表示一次 HTTP 請求,服務(wù)端返回的結(jié)果為 start-end 區(qū)間,這次請求的資源總大小為 total??磦€(gè)例子:

嗶哩嗶哩 Content-Range 例子

還沒完,看到我上面標(biāo)出來的狀態(tài)碼了吧,我們知道一個(gè)正常的 HTTP 請求完成之后我們會(huì)收到 200 狀態(tài)碼,但是我們用 Range 請求服務(wù)器的時(shí)候,有所不同,分為以下幾種情況:

  • 服務(wù)器不支持 Range 請求時(shí),則以 200 返回完整的響應(yīng)包體
  • 服務(wù)器支持 Range 請求的時(shí)候,一次正常請求結(jié)束返回 206 Partial Content
  • 服務(wù)器支持 Range 請求的時(shí)候,當(dāng)請求范圍不滿足實(shí)際資源的大小,返回狀態(tài)碼 416 Range Not Satisfiable ,同時(shí) Content-Range 中的 complete- length 顯示完整響應(yīng)的長度,例如 : Content-Range: bytes */1234
HTTP 的條件請求

平時(shí)涉及到 HTTP 條件請求的頭部有以下五個(gè)。

  • 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 這兩個(gè)請求頭相信你非常熟悉了,屬于協(xié)商緩存的內(nèi)容,相信這時(shí)候你馬上能想到一個(gè)非常經(jīng)典的前端面試題——說說瀏覽器的緩存機(jī)制。,不懂這個(gè)面試題的可以去看看這個(gè)博客 圖解 HTTP 緩存。

剩下幾個(gè)我相信你就不太懂了,我們一起來學(xué)習(xí)下,If-None-MatchIf-Modified-Since 分別取反就是 If-MatchIf-Unmodified-Since。取反的 If-MatchIf-Unmodified-Since 就是給我們前面講的 Accept-Ranges 使用的,當(dāng)我們一點(diǎn)點(diǎn)的從服務(wù)器獲取數(shù)據(jù)的時(shí)候,突然此時(shí)已經(jīng)獲取的數(shù)據(jù)發(fā)生了變化,這時(shí)我們肯定不能接著獲取數(shù)據(jù)了,而是要從新獲取數(shù)據(jù)。這是我們現(xiàn)在遇到的問題,那怎么解決這個(gè)問題呢?

兩個(gè)方法:

  1. If-MatchIf-Unmodified-Since

通過請求頭攜帶的 If-MatchIf-Unmodified-Since 來判斷文件是否被修改,如果文件被修改,直接返回 412 (Precondition Failed)來告訴瀏覽器,正在請求的資源發(fā)生了改變,請重新發(fā)送請求進(jìn)行獲取。

使用 NodeJS 來模擬下資源被更新返回 412 狀態(tài)碼這個(gè)過程,不過先插播一段知識(shí),關(guān)于 NodeJS 獲取請求頭的方面的,即 express req.headers 大小寫問題。

express 中通過 req.query 來獲取客戶端 Query 參數(shù),客戶端上傳的參數(shù)是嚴(yán)格遵守大小寫的,但是 req.headers 來獲取請求頭時(shí),接收到的全是小寫。搞的人很郁悶,為什么這么奇怪呢,原來是和 HTTP 協(xié)議有關(guān)。詳情見:express request.headers 大小寫問題,坑!,我是受不了大小寫混亂,所以使用 req.get() 來獲取請求頭,因?yàn)檫@個(gè) 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 的內(nèi)容為:

CondorHero

然后我們采用 CURL 命令工具進(jìn)行調(diào)試,先看看如何獲取一段數(shù)據(jù):

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

再來獲取下請求頭,為 HTTP 條件請求做準(zhǔn)備:

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 來做一個(gè)正常請求,這個(gè)我選擇了 If-Match

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

依然正常輸出。隨便更改下 If-Match 的值,再次發(fā)送請求:

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

模擬完成。

上面這個(gè)方法有個(gè)缺點(diǎn),那就是數(shù)據(jù)一旦被改變,瀏覽需要先獲取 412 狀態(tài)碼,然后瀏覽器再準(zhǔn)備發(fā)送請求,我們發(fā)現(xiàn)多了一個(gè)請求來回,如果服務(wù)器對比完,發(fā)現(xiàn)資源被改變,能直接完整返回最新資源就完美了,省去了一次 HTTP 請求。沒錯(cuò) If-Range 就是用來干這個(gè)的。

  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);
    };
});

當(dāng)我們再次發(fā)送請求的時(shí)候:

curl http://localhost:48488/download -H "Range: bytes=0-5" -H "If-Range: 哈哈哈哈哈"
// 返回結(jié)果為 CondorHero
// 沒有返回 412 而是直接返回全部結(jié)果

條件請求我們就講完了,現(xiàn)在直接開始做分片下載的 Demo 好了。

分片下載

現(xiàn)在分片實(shí)現(xiàn)最重要的兩點(diǎn)就是:

  • 前端 => 遞歸
  • 后端 => createReadStream 的用法

然后我們后端現(xiàn)在幾乎都不要改動(dòng)什么,簡單的加點(diǎn)東西就行了,看下接口。

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);
    };
});

我們要接收的文件它長成這樣:

浣溪沙·小閣重簾有燕過
    晏殊〔宋代〕
小閣重簾有燕過。晚花紅片落庭莎。曲闌干影入涼波。
一霎好風(fēng)生翠幕,幾回疏雨滴圓荷。酒醒人散得愁多。

前端就很好寫了,遞歸分片:

<!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>

把網(wǎng)絡(luò)調(diào)慢點(diǎn),我們看下分片下載的演示效果:

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

這里送你一張分片請求鏈接的圖:

斷點(diǎn)下載思路,參考上面斷點(diǎn)上傳。

完~

四、最后

代碼我都是很簡單的略寫實(shí)現(xiàn),并沒有太過深入的精心實(shí)現(xiàn)。這是因?yàn)槲覀兦岸擞龅酱蠖鄶?shù)的項(xiàng)目,就是一個(gè)簡單的文件上傳,頂多文件過大加點(diǎn)分片上傳。像百度云網(wǎng)頁那種完美的實(shí)現(xiàn),應(yīng)該很少有公司有這種業(yè)務(wù)場景。所以,我們只要大概的了解個(gè)原理,妥妥的應(yīng)付面試就行了。

當(dāng)前時(shí)間 Saturday, January 30, 2021 19:29:40 北京辦公室

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容