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