nodejs實現分段加速下載


node如何下載文件?

用 axios 就行啦!

簡單版如下:

const axios = require('axios')
const fs = require('fs')

function formatHeaders (headers) {
    return Object.keys(headers).reduce((header, name) => {
        header[String(name).toLowerCase()] = headers[name]
        return header
    }, {})
}

async function download(url, filePath) {
    let response = await axios({
        timeout: 60000,    
        method: 'get',
        responseType: 'stream',   // 請求文件流
        headers: {
            'Cache-Control': 'no-cache',
            'Connection': 'keep-alive',
            'Pragma': 'no-cache'
        },
        url
    })
    let responseHeaders = formatHeaders(response.headers)
    let fileLength = Number(responseHeaders['content-length'])
    let readerStream = response.data.pipe(fs.createWriteStream(filePath))
    // 監聽 WraiteStream 的 finish 事件
    readerStream.on('finish', () => {  
        if (fileLength === readerStream.bytesWritten) {
            // 下載成功
        }
    })
    readerStream.on('error', (err) => {
        // 下載失敗
    })
}

 

大功告成!

。。。

等下,分段下載怎么搞?

分段下載,需要用到請求的頭信息字段 Range。MDN描述摘抄如下:

Range 是一個請求首部,告知服務器返回文件的哪一部分。在一個  Range 首部中,可以一次性請求多個部分,服務器會以 multipart 文件的形式將其返回。如果服務器返回的是范圍響應,需要使用 206 Partial Content 狀態碼。假如所請求的范圍不合法,那么服務器會返回  416 Range Not Satisfiable 狀態碼,表示客戶端錯誤。服務器允許忽略  Range  首部,從而返回整個文件,狀態碼用 200 。

語法如下:

Range: <unit>=<range-start>-
Range: <unit>=<range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>
Range: <unit>=<range-start>-<range-end>, <range-start>-<range-end>, <range-start>-<range-end>

如果你看視頻的時候注意一下視頻的請求頭,你會發現請求信息是這樣的:

content-range 字段描述如下:

在HTTP協議中,響應首部 Content-Range 顯示的是一個數據片段在整個文件中的位置。

語法

Content-Range: <unit> <range-start>-<range-end>/<size>
Content-Range: <unit> <range-start>-<range-end>/*
Content-Range: <unit> */<size>

so,要實現分段下載,用range字段分割就行啦!

一頓操作猛如虎:

先拿到請求的文件的大小,就是 content-range 字段后面的 size 那一截

async function getResHeader(url) {
    try {
        let response = await axios({
            timeout: 60000,
            method: 'get',
            headers: {
                'Cache-Control': 'no-cache',
                'Connection': 'keep-alive',
                'Pragma': 'no-cache',
                'Range': 'bytes=0-1'
            },
            url
        })
        let headers = formatHeaders(response.headers)
        if (headers && headers['content-range']) {
            // 根據 content-range 獲取文件大小
            return Number(headers['content-range'].split('/').pop())
        }
        return 0
    } catch (e) {
        throw e
    }
}

 

再根據返回的文件大小進行分塊,這里我們就先預設 4M 吧,小於4M的就不分塊了:

// 長度分割方法
function splitBlock(blockSize, fileLength) {
    let blockList = []
    let block = 0
    while (block < fileLength) {
        let end = block + blockSize - 1
        if (end > fileLength) {
            end = fileLength
        }
        blockList.push({start: block, end: end})
        block += blockSize
    }
    return blockList
}

let fileLength = await getResHeader(url)
let fileBuffer = null
// 分塊大小 4M
let blockSize = 1024 * 1024 * 4;

if (fileLength > blockSize) {
    // 如果超過 4M 則分割文件
    fileBuffer = splitBlock(blockSize, fileLength)
}
if (!Array.isArray(fileBuffer) || !fileBuffer.length) {
    // 小於 4M 的文件直接獲取全部長度
    fileBuffer = [{start: 0, end: fileLength}]
}

 

 

然后拿着分段的信息去下載文件:

 

    fileBuffer.forEach(({start, end}) => {
        try {
            let header = Object.assign({}, {
                'etag': headers['etag'],
                'Content-Type': headers['content-type'],
                'Range': 'bytes=' + start + '-' + end
            })
            download(url, filePath, header)
        } catch (e) {
            throw e
        }
    })

 

鍵盤一頓啪啪啪,一看下載報錯了。。。

createWriteStream 寫入失敗?哦,不能同時寫入文件。。。那我就換個方法吧。

先把 download 方法改一改,改成直接返回buffer:

async function download(url, defaultHeaders) {
    let headers = Object.assign({
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive',
        'Pragma': 'no-cache'
    }, defaultHeaders)

    let response = await axios({
        timeout: 60000,
        method: 'get',
        responseType: 'arraybuffer',   // 改成獲取文件 ArrayBuffer
        headers,
        url
    })
    return response.data
}

 

 

再把分段請求回來的數據組裝上:

    Promise.all(fileBuffer.map(({start, end}) => {
        let header = Object.assign({}, {
            'etag': headers['etag'],
            'Content-Type': headers['content-type'],
            'Range': 'bytes=' + start + '-' + end
        })
        return download(url, filePath, header)
    })).then(resultList => {
        resultList.forEach(data => {
            fs.appendFileSync(filePath, data)
        })
    })

 

耶!成功了?

下幾個大文件試試。。。

藍屏了。。。

看來還是只能用 createWriteStream 來寫入文件了,不然我這破電腦內存根本不夠用啊。

可是 stream 誰知道他會按什么順序下載完成啊,還組裝個錘子,寫入的時候還占着文件,沒法搞啊。

既然寫入的時候占着文件,那我每個分段都寫入一個文件不就好了嘛,真是天才想法啊

 

 

先引入  fs-extra ,這樣文件操作會簡單一點,在下載目錄下新加一個緩存目錄用來存放臨時文件,等所有文件下載完成,再組裝起來

先改造download方法,下載 stream流,並且只有在文件片段下載完成后才返回:

async function download(url, tempPath, headers) {
    try {
        let response = await axios({
            timeout: 60000,
            method: 'get',
            responseType: 'stream',
            headers: Object.assign({
                'Cache-Control': 'no-cache',
                'Connection': 'keep-alive',
                'Pragma': 'no-cache'
            }, headers),
            url
        })
        let responseHeaders = formatHeaders(response.headers)
        let fileLength = Number(responseHeaders['content-length'])

        return new Promise((resolve, reject) => {
            let readerStream = response.data.pipe(fs.createWriteStream(tempPath, {start: 0, flags: 'r+', autoClose: true}))

            readerStream.on('finish', () => {
                // 如果下載的片段長度跟分割的長度一致則下載完成
                if (fileLength === readerStream.bytesWritten) {
                    resolve()
                } else {
                    reject(new Error('下載失敗'))
                }
            })
            readerStream.on('error', (err) => {
                reject(err)
            })
        })
    } catch (e) {
        throw e
    }
}

 

並行下載所有片段:

async function multiThreadDownload (fileBuffer, url, fileName, filePath, headers) {
    // 生成臨時文件目錄
    let downloadList = fileBuffer.map(({start, end}) => {
        // 將臨時文件放到下載的同級目錄下的.download_cache 文件夾
        let tempPath = path.join(filePath, '../.download_cache/' + fileName + '/' )
        // 根據每一段文件的長度命名臨時文件
        let tempFilePath = path.join(tempPath, start + '-' + end + '.tmp')

        return {
            start,
            end,
            tempPath,
            tempFilePath
        }
    })
    await Promise.all(fileBuffer.map(async ({start, end, tempFilePath, tempPath}) => {
        // 創建臨時文件
        fse.ensureDirSync(tempPath);
        // 判斷臨時文件是否存在
        if (fs.existsSync(tempFilePath)) {
            let fileLength = await new Promise((resolve, reject) => {
                fs.readFile(tempFilePath, (err, data) => {
                    if (err) {
                        reject(err)
                    }
                    resolve(data.length)
                })
            })
            // 如果臨時文件存在則直接返回,不再進入下載
            if (fileLength >= end - start) {
                return Promise.resolve()
            }
        }
        // 針對每一段文件創建臨時文件
        fs.appendFileSync(tempFilePath, new Uint8Array(0))

        try {
            let header = Object.assign({}, {
                'etag': headers['etag'],
                'Content-Type': headers['content-type'],
                'Range': 'bytes=' + start + '-' + end
            })
            return download(url, tempFilePath, header)
        } catch (e) {
            fse.removeSync(tempFilePath)
            throw e
        }
    }))

    // 所有片段下載完成后開始組裝
    // 創建文件寫入流
    let writeStream = fs.createWriteStream(filePath)

    for (let i = 0; i < downloadList.length; i++) {
        let tempFilePath = downloadList[i].tempFilePath
        await new Promise((resolve, reject) => {
            let readerStream = fs.createReadStream(tempFilePath)
            readerStream.pipe(writeStream, {end: false})

            readerStream.on('end', () => {
                resolve()
            })
            readerStream.on('error', (err) => {
                reject(err)
            })
        })
    }
    writeStream.end('down')
    // 寫入完畢,刪除臨時文件和文件夾
    fse.removeSync(downloadList[0].tempPath)
}

 

 

組裝完成!

 

 

完整demo地址:https://github.com/flicat/fast-bird-download

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM