轉(zhuǎn)存服務(wù)器

參考原文: 轉(zhuǎn)存服務(wù)器


Buffer、Stream、Promise、async await、request、分片上傳

什么是轉(zhuǎn)存服務(wù)器?

即向服務(wù)器發(fā)送一個圖片的url地址,服務(wù)負責(zé)去將圖片下載到服務(wù)器上,之后再將這個圖片上傳到存儲服務(wù),得到一個可以訪問(通常情況都是CDN服務(wù))的圖片地址。

當(dāng)服務(wù)器下在一個大型文件時,需要完全下載完,然后緩存到本地硬盤的緩存文件中,而且 一次性上傳大文件,過程中由于耗時較長,因此存在較高的失敗率,通常采用分片法來解決,如果分片失敗則只需重新上傳該分片即可。

在下載時,如果下載量滿足一個分片大小則上傳。所以第一步就是監(jiān)聽下載內(nèi)容。ReadStream在接收數(shù)據(jù)時會不斷的觸發(fā)data事件,因此只需監(jiān)聽data事件就可以準確捕獲到每一次數(shù)據(jù)傳輸過程,ReadStream分為兩種模式流動模式暫停模式,流動模式下數(shù)據(jù)會源源不斷的流出供需要者使用,而暫停模式只有調(diào)用read()方法才會有數(shù)據(jù)流出。這里我們通過pipe把ReadStream與WriteStream相連,讓數(shù)據(jù)流動起來。

const request = require('request');
const fs      = require('fs');
const path    = require('path');
const url     = 'https://baobao-3d.bj.bcebos.com/16-0-205.shuimian.mp4';


const httpReadStream  = request({method: 'GET', url: url});
const fileWriteStream = fs.createWriteStream(path.join(__dirname, 'download', 'lyf.mp4'));

httpReadStream.pipe(fileWriteStream);

let totalLength = 0;
httpReadStream
    .on('response', res=> {
        console.log('response.headers', res.statusCode);
    })
    .on('data', chunk=> {
        totalLength += chunk.length;
    })
    .on('error', err=> {
        console.log('err', err);
    });

fileWriteStream.on('close', ()=> {
    console.log(`已下載文件大小: ${(totalLength / 1000 / 1024).toFixed(2)} MB`)
});```

每次data事件獲取的chunk大小因網(wǎng)絡(luò)而變化,假設(shè)每次上傳分片大小為2MB,每一次chunk有可能大于2MB也可能小于2MB,所以可在中間設(shè)置一緩沖區(qū),當(dāng)緩沖區(qū)滿足2MB時就取出2MB作為一個分片進行上傳。

于是我們使用Buffer實現(xiàn)一個緩沖區(qū),主要用于分片。
```javascript
class BufferCache {
    constructor(cutSize = 2 * 1024 * 1000) {
        this._cache      = Buffer.alloc(0);
        this._cutSzie    = cutSize;
        this._readyCache = [];
    }

    push(buf) {
        let cacheLength = this._cache.length;
        let bufLength   = buf.length;
        this._cache     = Buffer.concat([this._cache, buf], bufLength + cacheLength)
        this.cut();
    }

    pull() {
        return this._readyCache.shift();
    }


    cut() {
        if (this._cache.length >= this._cutSzie) {
            const totalCacheLength = this._cache.length;
            let cutCount           = Math.floor(totalCacheLength / this._cutSzie);

            for (let i = 0; i < cutCount; i++) {
                let newBuffer = Buffer.alloc(this._cutSzie);
                this._cache.copy(newBuffer, 0, i * this._cutSzie, (i + 1) * this._cutSzie);
                this._readyCache.push(newBuffer);
            }
            this._cache = this._cache.slice(cutCount * this._cutSzie);
        }
    }

    getReadChunks() {
        return this._readyCache;
    }

    getRemainChunks() {
        if (this._cache.length < this._cutSzie)
            return this._cache;
        else {
            this.cut();
            return this.getRemainChunks();
        }
    }
}

exports = module.exports = BufferCache;

為了便于后面的編碼,提高可擴展性和可讀性,我們將下載過程封裝如下。通過四個回調(diào)函數(shù)輕易掌控下載開始、中途、結(jié)束、異常四種狀態(tài)。

const request     = require('request');
const fs          = require('fs');
const path        = require('path');
const BufferCache = require('./bufferCache');
const url         = 'https://baobao-3d.bj.bcebos.com/16-0-205.shuimian.mp4';
const _cutSize    = 1024 * 1000 * 2;
const bufferCache = new BufferCache(_cutSize);
let isFinished    = false;

function getChunks(options, onStart, onData, onFinish, onError) {
    const httpReadStream  = request({method: options.method, url: options.url});
    const fileWriteStream = fs.createWriteStream(path.join(__dirname, 'download', 'lyf.mp4'));

    httpReadStream.pipe(fileWriteStream);

    let downloadedLength = 0;
    httpReadStream
        .on('response', res=>onStart(res))
        .on('data', chunk=> {
            downloadedLength += chunk.length;
            onData(chunk, downloadedLength)
        })
        .on('error', err=>onError(err));
    
    fileWriteStream.on('close', ()=> {
        onFinish(downloadedLength)
    });
}

function onStart(res) {
    console.log('start downloading, statusCode is :', res.statusCode);
}

function onData(chunk, downloadedLength) {
    bufferCache.push(chunk);
}

function onFinished(totalLength) {
    let chunkCount = Math.ceil(totalLength / _cutSize);
    console.log('total chunk count is:' + chunkCount);
}

function onError(err) {
    console.log('err', err)
}

getChunks({method: 'GET', url: url}, onStart, onData, onFinished, onError);

截止目前,我們已經(jīng)完成下載、分片接下來需要考慮如下:

  • 如何連續(xù)獲取準備好的分片?
  • 如何上傳分片?
  • 上傳分片失敗的重傳問題?
  • 上傳完所有分片之后的統(tǒng)一處理接口?
  • 分片的并發(fā)上傳?以及并發(fā)數(shù)的控制
  • 如何連續(xù)獲取準備好的分片?
    在onStart執(zhí)行之后即數(shù)據(jù)開始傳輸時,我們可以使用Node自帶的間隔計時器setInterval,每隔200ms獲取一次分片。一個文件在經(jīng)過多次相同大小的切割之后,總會遺留下小的一塊分片,因此我們還需要對最后一個分片進行特殊處理。當(dāng) readyCache 的長度為0的時候,而且下載已經(jīng)完成,不會再調(diào)用 pushBuf 函數(shù),就是獲取最后一段分片的時機。于是重寫onStart函數(shù)完成以上業(yè)務(wù)
function onStart(res) {
    console.log('start downloading, statusCode is :', res.statusCode);
    let interval = setInterval(function () {
        if (bufferCache.getReadChunks().length > 0) {
            let readCache = bufferCache.pull();
            console.log('recives', readCache.length)
        }
        if (isFinished) {
            clearInterval(interval);
            let lastChunk = bufferCache.getRemainChunks();
            console.log('the last chunk', lastChunk.length);
        }
    }, 200)
}
  • 如何上傳分片?
    使用HTTP進行文件上傳,文件在傳輸過程中為一個byte序列,其 content-type 為 multipart/form-data,我們先通過Promise封裝一個上傳函數(shù)
function upload(url, data) {
    return new Promise((resolve, reject) => {
        request.post({
            url: url,
            formData: data
        }, function (err, response, body) {
            if (!err && response.statusCode === 200) {
                resolve(body);
            }
            else {
                reject(err);
            }
        });
    });
}

我們現(xiàn)在需要從緩存中拿分片,如國還有剩余著繼續(xù),沒有則通知發(fā)送完成,對于這樣的邏輯可以使用遞歸
假設(shè)當(dāng)前網(wǎng)絡(luò)環(huán)境擁堵,會導(dǎo)致上傳一個分片的時間 > 200ms, 200ms之后下一次輪詢開始運行時,原先的分片還沒上傳完畢,由于沒有一個狀態(tài)值進行判斷,依然會調(diào)用上傳函數(shù),又再一次進行分片上傳,就會更加劇的網(wǎng)絡(luò)擁堵環(huán)境,導(dǎo)致分片上傳時間更短。如此反復(fù),時間一長就會導(dǎo)致崩潰,造成分片上傳全部大面積失敗。為了避免這樣的情況,我們就需要一個變量來表示當(dāng)前這個上傳流程的狀態(tài),目前我們只關(guān)心單個流程進行上傳,可以只需要保證最大同時上傳的值為1即可。

function sendChunks() {
    let chunkId     = 0; // 給每個分片劃分ID
    let sending     = 0; // 當(dāng)前并行上傳的數(shù)量
    let MAX_SENDING = 1; // 最大并行上傳數(shù)

    function send(readCaches) {
        if (readCaches.length <= 0)
            return;
        console.log(`發(fā)送第 ${chunkId} 塊分片`)
        const chunk       = readCaches.shift();
        const sendPromise = upload('http://localhost:3000/upload', {
            chunk: {
                value: chunk,
                options: {
                    // 在文件名稱上添加chunkId,可以方便后端服務(wù)進行分片整理
                    filename: 'example.mp4_IDSPLIT_' + chunkId
                }
            }
        });
        sending++;
        sendPromise.then(resBody=> {
            sending--;
            if (resBody.uploadStatus === 0 && readCaches.length > 0)
                send(readCaches);
        });
        chunkId++;
    }

    return new Promise((resolve, reject)=> {
        let readyCaches = bufferCache.getReadChunks();
        let interval    = setInterval(function () {
            if (readyCaches.length >= 0 && sending <= MAX_SENDING) {
                send(readyCaches);
            }
            if (isFinished && readyCaches.length === 0) {
                clearInterval(interval);
                const lastChunk = bufferCache.getRemainChunks();
                readyCaches.push(lastChunk);
                send(readyCaches)
            }
        }, 200)
    })
}

截止此我們已經(jīng)完成下載-分片-連續(xù)上傳分片的簡單實現(xiàn),但如果某一分片上傳失敗又該怎么辦呢?send()函數(shù)可以看作一個發(fā)送單個分片(不考慮遞歸)的控制器,只需在其內(nèi)部捕獲上傳錯誤的分片,保存下來重傳即可。于是我們修改sendChunks函數(shù)如下:在send().cathc(fn)內(nèi)進行重傳控制,在可嘗試次數(shù)之內(nèi)進行重傳,如果失敗則拋出異常。

function sendChunks() {
    let chunkId = 0;
    let sending = 0; // 當(dāng)前并行上傳的數(shù)量
    let MAX_SENDING = 1; // 最大并行上傳數(shù)
    let stopSend = false;

    function send(options) {
        let readyCache = options.readyCache;
        let fresh = options.fresh;
        let retryCount = options.retry;
        let chunkIndex;

        let chunk = null;

        // 新的數(shù)據(jù)
        if (fresh) {
            if (readyCache.length === 0) {
                return;
            }

            chunk = readyCache.shift();
            chunkIndex = chunkId;
            chunkId++;
        }
        // 失敗重試的數(shù)據(jù)
        else {
            chunk = options.data;
            chunkIndex = options.index;
        }

        sending++;
        let sendP = upload('http://localhost:3000', {
            chunk: {
                value: chunk,
                options: {
                    filename: 'example.mp4_IDSPLIT_' + chunkIndex
                }
            }
        }).then((response) => {
            sending--;
            let json = JSON.parse(response);

            if (json.errno === 0 && readyCache.length > 0) {
                return send({
                    retry: RETRY_COUNT,
                    fresh: true,
                    readyCache: readyCache
                });
            }

            // 這里一直返回成功
            return Promise.resolve(json);
        }).catch(err => {
            if (retryCount > 0) {
                // 這里遞歸下去,如果成功的話,就等同于錯誤已經(jīng)處理
                return send({
                    retry: retryCount - 1,
                    index: chunkIndex,
                    fresh: false,
                    data: chunk,
                    readyCache: readyCache
                });
            }
            else {
                console.log(`upload failed of chunkIndex: ${chunkIndex}`);
                // 停止上傳標識,會直接停止上傳
                stopSend = true;
                // 返回reject,異常拋出
                return Promise.reject(err);
            }
        });
    }

    return new Promise((resolve, reject) => {
        let readyCache = bufferCache.getChunks();

        let sendTimer = setInterval(() => {
            if (sending < MAX_SENDING && readyCache.length > 0) {
                // 改用傳入對象
                send({
                    retry: 3, // 最大重試3次
                    fresh: true, // 用這個字段來區(qū)分是新的分片,還是由于失敗重試的
                    readyCache: readyCache
                }).catch(err => {
                    console.log('upload failed, errmsg: ', err);
                });
            }
            else if (isFinished && readyCache.length === 0 || stopSend) {
                clearTimeout(sendTimer);

                // 已經(jīng)成功走到最后一個分片了。
                if (!stopSend) {
                    let lastChunk = bufferCache.getRemainChunks();
                    readyCache.push(lastChunk);

                    send({
                        retry: 3,
                        fresh: true,
                        readyCache: readyCache
                    }).catch(err => {
                        console.log('upload failed, errmsg: ', err);
                    });
                }
            }

            // 到這里是為分片正在下載,同時又正在上傳
            // 或者上傳比下載快,已經(jīng)下載好的分片都傳完了,等待下載完成
        }, 200);
    });
}
  • 上傳完所有分片之后的統(tǒng)一處理接口?
    由于上傳send()在成功上傳一個分片后會返回一個Promise對象,上傳失敗時會拋出異常,所以只需使用Promsie.all()方法捕獲即可。
let readyCache = bufferCache.getChunks();
let sendPromise = [];

let sendTimer = setInterval(() => {
    if (sending < MAX_SENDING && readyCache.length > 0) {
        // 把Promise塞進數(shù)組
        sendPromise.push(send({
            retry: RETRY_COUNT,
            fresh: true,
            readyCache: readyCache
        }));
    }
    else if ((isFinished && readyCache.length === 0) || stopSend) {
        clearTimeout(sendTimer);

        if (!stopSend) {
            console.log('got last chunk');
            let lastChunk = bufferCache.getRemainChunks();
            readyCache.push(lastChunk);
            // 把Promise塞進數(shù)組
            sendPromise.push(send({
                retry: RETRY_COUNT,
                fresh: true,
                readyCache: readyCache
            }));
        }

        // 當(dāng)所有的分片都發(fā)送之后觸發(fā),
        Promise.all(sendPromise).then(() => {
            console.log('send success');
        }).catch(err => {
            console.log('send failed');
        });
    }
    // not ready, wait for next interval
}, 200);

  • 分片的并發(fā)上傳?以及并發(fā)數(shù)的控制?現(xiàn)在還剩最后一個問題,Node本身就是非阻塞IO、事件驅(qū)動的,我們只需使用send()去同步的獲得執(zhí)行,而真正的上傳邏輯upload卻是異步,所以不需要考慮資源競爭、死鎖等問題,只需同步擴展send方法即可。

let readyCache = bufferCache.getChunks();
let threadPool = [];

let sendTimer = setInterval(() => {
    if (sending < MAX_SENDING && readyCache.length > 0) {
        // 這個例子同時開啟4個分片上傳
        for (let i = 0; i < MAX_SENDING; i++) {
            let thread = send({
                retry: RETRY_COUNT,
                fresh: true,
                readyCache: readyCache
            });

            threadPool.push(thread);
        }
    }
    else if ((isFinished && readyCache.length === 0) || stopSend) {
        clearTimeout(sendTimer);

        if (!stopSend) {
            console.log('got last chunk');
            let lastChunk = bufferCache.getRemainChunks();
            readyCache.push(lastChunk);
            threadPool.push(send({
                retry: RETRY_COUNT,
                fresh: true,
                readyCache: readyCache
            }));
        }

        Promise.all(threadPool).then(() => {
            console.log('send success');
        }).catch(err => {
            console.log('send failed');
        });
    }
}, 200);

這里我們通過文件的md5值去判斷是否屬于同一文件。


function toMd5(buffer) {
    let md5 = crypto.createHash('md5');
    md5.update(buffer);
    return md5.digest('hex');
}

存儲服務(wù)器上由于是分片后的文件,所以我們先把目錄中的文件以Buffer的形式讀入內(nèi)存,在求文件的md5值即可。

function filesInDirToBuffer(dirPath) {
    let totalBuffer = Buffer.allocUnsafe(0);
    const dirsInfo  = fs.readdirSync(dirPath);
    dirsInfo.forEach(file=> {
        if (file != '.DS_Store') {
            const currentFileBuffer = fs.readFileSync(path.join(dirPath, file));
            totalBuffer             = Buffer.concat([totalBuffer, currentFileBuffer], totalBuffer.length + currentFileBuffer.length);
        }
    });
    return totalBuffer;
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,702評論 6 534
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,615評論 3 419
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,606評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,044評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 71,826評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,227評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,307評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,447評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,992評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,807評論 3 355
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,001評論 1 370
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,550評論 5 361
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,243評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,667評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,930評論 1 287
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,709評論 3 393
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 47,996評論 2 374

推薦閱讀更多精彩內(nèi)容