Node實(shí)現(xiàn)靜態(tài)文件增量上傳CDN

前端項(xiàng)目開(kāi)發(fā)完成后需要部署到服務(wù)器载碌,為了減輕業(yè)務(wù)服務(wù)器的壓力猜嘱,以及為了更快的瀏覽器初次渲染速度,會(huì)做動(dòng)靜分離嫁艇,也就是靜態(tài)資源分離到CDN中去朗伶,動(dòng)態(tài)生成的資源(主要是接口)才會(huì)部署到自己服務(wù)器上。

webpack支持output.publicPath來(lái)替換打包出的資源中assets的引用路徑步咪,output.publicPath配置為http://xxx.cdn.com/论皆,那么/static/a.jpg 的路徑就會(huì)被替換為http://xxx.cdn.com/static/a.jpg。這樣我們只要把靜態(tài)文件上傳到CDN就好了猾漫。

最近的一次會(huì)議上点晴,我們分析服務(wù)端的統(tǒng)計(jì)數(shù)據(jù)的時(shí)候發(fā)現(xiàn)服務(wù)器30%的流量都被靜態(tài)資源占去了,這反映出我們急需把靜態(tài)資源批量上傳到CDN上悯周,減輕業(yè)務(wù)服務(wù)器的壓力粒督。而我們正缺少這樣的一個(gè)工具,于是我們就基于node開(kāi)發(fā)了一個(gè)靜態(tài)文件上傳CDN的工具禽翼。

分析下需求屠橄,主要有這么幾點(diǎn):

  1. 能夠把指定路徑下指定模式(后綴名等)的文件匹配出來(lái)
  2. 能夠批量的并發(fā)的上傳族跛,但并發(fā)數(shù)量要可控
  3. 多次上傳能夠識(shí)別出更改的部分,實(shí)現(xiàn)增量上傳

基于這3點(diǎn)需求锐墙,我們進(jìn)行了調(diào)研和設(shè)計(jì)礁哄,最終方案是這樣的:

實(shí)現(xiàn)第一點(diǎn)需求(匹配指定模式的文件),可以使用node-dir實(shí)現(xiàn)溪北,readFiles方法支持讀取一個(gè)目錄下的文件桐绒,根據(jù)一些模式來(lái)過(guò)濾:

dir.readFiles(__dirname, {
    match: /.txt$/,
    exclude: /^\./
    }, function(err, content, next) {
        if (err) throw err;
        console.log('content:', content);
        next();
    },
    function(err, files){
        if (err) throw err;
        console.log('finished reading files:',files);
    });

實(shí)現(xiàn)第二點(diǎn)需求(異步上傳文件)可以使用p-queue
,支持傳入多個(gè)異步的promise對(duì)象之拨,然后指定并發(fā)數(shù)concurrency掏膏。

const queue = new PQueue({ concurrency: limit });
const files = ['/static/a.jpg', '/static/b.jpg'];

queue.addAll(
    files.map((filePath) => () =>
        uploadFile(targetProject, {
            ...data,
            file: filePath,
            filename: path.relative(uploadDir, filePath).replace(/[\\]/g, '/'),
        }).then((rs) => {
            result.push(rs);
        }),
    ),
);

進(jìn)度條可以使用cli-progress
來(lái)實(shí)現(xiàn),結(jié)合上面的p-queue來(lái)顯示進(jìn)度敦锌。

const cliProgress = require('cli-progress');

const bar = new cliProgress.Bar(
    {
        format: '上傳進(jìn)度 [{bar}] {percentage}% | 預(yù)計(jì): {eta}s | {value}/{total}',
    },
    cliProgress.Presets.rect,
);
bar.start(files.length, 0);

bar.increment();//每個(gè)文件上傳完成時(shí)
bar.stop();

第三點(diǎn)需求(增量上傳)的方案是這樣的馒疹,使用node-dir匹配出文件列表之后,生成每個(gè)文件的md5乙墙,文件路徑作為值颖变,生成一個(gè)map,叫做toUploadManifest听想,然后上傳完成后腥刹,把上傳過(guò)的文件的內(nèi)容md5和文件路徑生成uploadedManifest。每次上傳之前把toUploadManifest 中在uploadedManifest出現(xiàn)過(guò)的文件都去掉汉买,這樣就實(shí)現(xiàn)了增量的上傳衔峰。

md5的生成使用node的crypto內(nèi)置模塊:

/**
 * buffer to  md5 str
 * @param {*} buffer 
 */
function bufferToMD5(buffer) {
    const md5 = crypto.createHash('md5');
    md5.update(buffer);
    return md5.digest('base64');
}

生成toUploadManifest:

/**
 * 
 * 生成toUpload清單
 * @param {*} files 待上傳文件 
 */
function generateToUploadManifest(filePaths = []) {
    return Promise.all(filePaths.map(filePath => new Promise((resolve) => {
        fs.readFile(filePath, (err, content) => {
            if (err) {
                console.log(filePath + '讀取失敗');
                return;
            }
            const md5 = bufferToMD5(content);
            resolve({
                [md5]: filePath
            });
        });
    }))).then(manifestItems => manifestItems.length ? Object.assign(...manifestItems) : {})
}

讀取uploadedManifest.json:

/**
 * 獲取uploadedManifest
 */
const UPLOADED_MANIFEST_PATH = path.resolve(process.cwd(), 'uploadedManifest.json');
function getUploadedManifest() {
    try {
        const uploadedManifestStr = fs.readFileSync(UPLOADED_MANIFEST_PATH);
        return JSON.parse(uploadedManifestStr);
    } catch(e) {
        return {}
    }
}

更新uploadedManifest.json:

/**
 * 更新uploadedManifest
 */
function updateUploadedManifest(filePaths) {
    let manifest = {};
    try {
        const uploadedManifestStr = fs.readFileSync(UPLOADED_MANIFEST_PATH);
        manifest = JSON.parse(uploadedManifestStr);
    } catch(e) {
    }
    generateToUploadManifest(filePaths).then(uploadedManifest => {
        manifest = Object.assign(manifest, uploadedManifest);
        fs.writeFileSync(UPLOADED_MANIFEST_PATH, JSON.stringify(manifest));
    })
}

過(guò)濾掉toUploadManifest中已上傳的部分:

/**
 * 過(guò)濾掉toUploadManifest中已上傳的部分
 */
function filterToUploadManifest(toUploadManifest) {
    console.log();
    const uploadedManifest = getUploadedManifest();
    Object.keys(toUploadManifest).filter(item => uploadedManifest[item]).forEach(item => {
        console.log(toUploadManifest[item] + ' 已上傳過(guò)');
        delete toUploadManifest[item]
    });
    console.log();
    return Object.values(toUploadManifest);
}

至此,實(shí)現(xiàn)靜態(tài)文件增量上傳CDN的功能就基本可以實(shí)現(xiàn)了蛙粘。當(dāng)然上傳CDN的接口實(shí)現(xiàn)需要做一些鑒權(quán)之類的垫卤,這里因?yàn)槲覀兒蠖藢?shí)現(xiàn)了這部分功能,我們只需要調(diào)用接口就可以了出牧,如果自己實(shí)現(xiàn)需要做一些鑒權(quán)穴肘。可以參看ali-oss的文檔舔痕。

很多情況下上傳cdn的腳本都是跑在gitlab ci的评抚,gitlab ci使用不同的runner來(lái)執(zhí)行腳本,runner可以在不同的機(jī)器上伯复,所以想要uploadedManifest.json真正做到記錄上傳過(guò)的文件的功能慨代,必須統(tǒng)一放到一個(gè)地方,可以結(jié)合gitlab ci的cache來(lái)實(shí)現(xiàn):

image: hub.pri.xxx.com/frontend/xxx

stages: 
  - test
upload:
  stage: test
  cache:
    paths:
      - node_modules
      - uploadedManifest.json
  before_script:
    - yarn install --slient
  script:
    - node upload.js

總結(jié)

動(dòng)靜分離幾乎必用的優(yōu)化手段啸如,主要有兩步:webpack配置output.publicPath侍匙,然后把靜態(tài)資源上傳CDN。我們開(kāi)發(fā)的工具就是實(shí)現(xiàn)了靜態(tài)資源增量上傳CDN组底,并且可以控制并發(fā)數(shù)丈积。增量上傳的部分可以是基于md5 + 持久化的文件來(lái)實(shí)現(xiàn)的,在gitlab ci的runner中運(yùn)行時(shí)债鸡,要是用gitlab cache來(lái)存儲(chǔ)清單文件江滨。

完整代碼:

// getUploadFiles.js
const dir = require('node-dir');
const readline = require('readline');

function clearWrite(text) {
    readline.clearLine(process.stdout, 0)
    readline.cursorTo(process.stdout, 0)
    process.stdout.write(text);
}

/**
 * @description
 * @param {String} UploadDir, 絕對(duì)路徑
 * @param {Object} options {
 *     exclude,  通過(guò)正則或數(shù)組忽略指定的文件名
 *     encoding, 文件編碼 (默認(rèn) 'utf8')
 *     excludeDir, 通過(guò)正則或數(shù)組忽略指定的目錄
 *     match,  通過(guò)正則或數(shù)組匹配指定的文件名
 *     matchDir 通過(guò)正則或數(shù)組匹配指定的目錄
 * }
 * @return {Promise}
 */
const getUploadFiles = (UploadDir, options) =>
    new Promise((resolve, reject) => {
        let total = 0;
        dir.readFiles(
            UploadDir,
            options,
            function(err, content, next) {
                if (err) throw err;
                clearWrite(`共讀取到 ${++total} 個(gè)文件`);
                next();
            },
            function(err, files) {
                if (err) return reject(err);
                return resolve(files);
            },
        );
    });

module.exports = getUploadFiles;
//uploadFiles.js
const getUploadFiles = require('./getUploadFiles.js');
const fs = require('fs');
const request = require('request');
const url = require('url');
const path = require('path');
const cliProgress = require('cli-progress');
const PQueue = require('p-queue');
const crypto = require('crypto');

const cwd = process.cwd();
const { name: projectName } = require(path.resolve(cwd, 'package.json'));

const uploadUrl = 'http://xxx/xxx';
const targetHost = 'https://xxx.cdn.xxx.com/';

// 上傳文件
function uploadFile (targetProject, data) {
    return new Promise((resolve, reject) => {
        request.post(
            {
                url: uploadUrl,
                formData: {
                    ...data,
                    file: fs.createReadStream(data.file),
                },
            },
            function (err, resp, body) {
                if (err) {
                    return reject(err);
                }
                var result = JSON.parse(body);
                if (result) {
                    const rs = {
                        ...result,
                        url: url.resolve(targetProject, data.filename),
                        localPath: data.file
                    };
                    return resolve(rs);
                }
                return reject(resp);
            },
        );
    }).catch((error) => {
        // 其他失敗,導(dǎo)致無(wú)法繼續(xù)上傳厌均,失敗即退出
        console.log('fail:', data.file);
        error && console.log('Error:', error.msg || error);
        return process.exit(1);
    });
}

/**
 * buffer to  md5 str
 * @param {*} buffer 
 */
function bufferToMD5(buffer) {
    const md5 = crypto.createHash('md5');
    md5.update(buffer);
    return md5.digest('base64');
}

/**
 * 
 * 生成toUpload清單
 * @param {*} files 待上傳文件 
 */
function generateToUploadManifest(filePaths = []) {
    return Promise.all(filePaths.map(filePath => new Promise((resolve) => {
        fs.readFile(filePath, (err, content) => {
            if (err) {
                console.log(filePath + '讀取失敗');
                return;
            }
            const md5 = bufferToMD5(content);
            resolve({
                [md5]: filePath
            });
        });
    }))).then(manifestItems => manifestItems.length ? Object.assign(...manifestItems) : {})
}

/**
 * 獲取uploadedManifest
 */
const UPLOADED_MANIFEST_PATH = path.resolve(process.cwd(), 'node_modules', 'uploadedManifest.json');
function getUploadedManifest() {
    try {
        const uploadedManifestStr = fs.readFileSync(UPLOADED_MANIFEST_PATH);
        console.log(uploadedManifestStr);
        return JSON.parse(uploadedManifestStr);
    } catch(e) {
        console.log('未找到uploadedManifest.json')
        return {}
    }
}
/**
 * 更新uploadedManifest
 */
function updateUploadedManifest(filePaths) {
    let manifest = {};
    try {
        const uploadedManifestStr = fs.readFileSync(UPLOADED_MANIFEST_PATH);
        manifest = JSON.parse(uploadedManifestStr);
    } catch(e) {
    }
    generateToUploadManifest(filePaths).then(uploadedManifest => {
        manifest = Object.assign(manifest, uploadedManifest);
        fs.writeFileSync(UPLOADED_MANIFEST_PATH, JSON.stringify(manifest));
    })
}

/**
 * 過(guò)濾掉toUploadManifest中已上傳的部分
 */
function filterToUploadManifest(toUploadManifest) {
    console.log();
    const uploadedManifest = getUploadedManifest();
    Object.keys(toUploadManifest).filter(item => uploadedManifest[item]).forEach(item => {
        console.log(toUploadManifest[item] + ' 已上傳過(guò)');
        delete toUploadManifest[item]
    });
    console.log();
    return Object.values(toUploadManifest);
}

/**
 * @description
 * @date 2019-03-08
 * @param {string} dir 本地項(xiàng)目目錄唬滑,相對(duì)執(zhí)行命令所在文件
 * @param {object} {
 *      project,    上傳OSS所在目錄,通常使用項(xiàng)目名
 *      limit = 5,  并發(fā)最大數(shù)
 *      region = 'oss-cn-hangzhou',
 *      bucketName = 'xxx,
 *      ...options  傳遞給獲取文件的接口
 * }
 * @param {function} cb
 * @returns Promise
 */
function upload (
    dir,
    { project, limit = 5, region = 'oss-cn-hangzhou', bucketName = 'xxx', ...options },
    cb,
) {
    const data = {
        region,
        path: project || projectName + '/',
        bucket_name: bucketName,
        filename: '',
        file: '',
    };

    // 上傳后的網(wǎng)絡(luò)地址
    const targetProject = url.resolve(targetHost, data.path);

    // 上傳的本地目錄
    const uploadDir = path.resolve(cwd, dir);

    const bar = new cliProgress.Bar(
        {
            format: '上傳進(jìn)度 [{bar}] {percentage}% | 預(yù)計(jì): {eta}s | {value}/{total}',
        },
        cliProgress.Presets.rect,
    );

    const queue = new PQueue({ concurrency: limit });

    return getUploadFiles(uploadDir, options)
        .then((files) => {
            return generateToUploadManifest(files).then( toUploadManifest => {
                files = filterToUploadManifest(toUploadManifest);

                const result = [];
                bar.start(files.length, 0);
    
                // 添加到隊(duì)列中
                queue.addAll(
                    files.map((filePath) => () =>
                        uploadFile(targetProject, {
                            ...data,
                            file: filePath,
                            filename: path.relative(uploadDir, filePath).replace(/[\\]/g, '/'),
                        }).then((rs) => {
                            // 更新進(jìn)度條
                            bar.increment();
                            result.push(rs);
                        }),
                    ),
                );
                return queue.onIdle().then(() => {
                    bar.stop();
                    return result;
                });
            })
        })
        .then((res) => {
            const success = [];
            const fail = [];
            console.log();
            // 全部結(jié)束
            if (Array.isArray(res)) {
                // 更新UploadedManifest
                updateUploadedManifest(res.map(item => item.localPath));
                // 分揀成功和失敗的資源地址
                res.forEach((item) => {            
                    if (item) {
                        if (item.status) {
                            success.push(item.url);
                        } else {
                            fail.push(item.url);
                        }
                    }
                });
                return Promise.resolve({
                    success,
                    fail,
                    status: fail.length > 0 ? 0 : 1, // 有失敗時(shí)返回 0 棺弊,全部成功返回 1
                    isResolve: true,
                });
            }
            return Promise.resolve(res);
        })
        .then((rs) => {
            if (cb) {
                return rs && rs.isResolve ? cb(null, rs) : cb(rs, null);
            }
            if (rs && rs.isResolve) {
                return Promise.resolve(rs);
            }
            return Promise.reject(rs);
        })
        .catch((error) => {
            console.log('Error:', error.msg || error);
            // 發(fā)生未知錯(cuò)誤 process.exit(1);
            return Promise.reject(error);
        });
}

module.exports = upload;

//使用時(shí):
const upload = require('upload');

upload('static', {
    project: 'upload-test/',
    limit: 5,
    match: /\.(jpe?g|png)$/,
    // exclude: /\.png$/,
    // matchDir: ['test']
}).then((rs) => {
    console.log(`共成功上傳${rs.success.length}個(gè)文件:\n${rs.success.join('\n')}`);
    if (rs.status === 1) {
        console.log(`已全部上傳完成!`);
    } else {
        console.log(`部分文件上傳失斁堋:\n${rs.fail.join('\n')}`);
    }
});
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市模她,隨后出現(xiàn)的幾起案子稻艰,更是在濱河造成了極大的恐慌,老刑警劉巖侈净,帶你破解...
    沈念sama閱讀 206,968評(píng)論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件尊勿,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡畜侦,警方通過(guò)查閱死者的電腦和手機(jī)元扔,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,601評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)旋膳,“玉大人澎语,你說(shuō)我怎么就攤上這事⊙榘茫” “怎么了擅羞?”我有些...
    開(kāi)封第一講書(shū)人閱讀 153,220評(píng)論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)义图。 經(jīng)常有香客問(wèn)我祟滴,道長(zhǎng),這世上最難降的妖魔是什么歌溉? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 55,416評(píng)論 1 279
  • 正文 為了忘掉前任垄懂,我火速辦了婚禮,結(jié)果婚禮上痛垛,老公的妹妹穿的比我還像新娘草慧。我一直安慰自己,他們只是感情好匙头,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,425評(píng)論 5 374
  • 文/花漫 我一把揭開(kāi)白布漫谷。 她就那樣靜靜地躺著,像睡著了一般蹂析。 火紅的嫁衣襯著肌膚如雪舔示。 梳的紋絲不亂的頭發(fā)上碟婆,一...
    開(kāi)封第一講書(shū)人閱讀 49,144評(píng)論 1 285
  • 那天,我揣著相機(jī)與錄音惕稻,去河邊找鬼竖共。 笑死,一個(gè)胖子當(dāng)著我的面吹牛俺祠,可吹牛的內(nèi)容都是我干的公给。 我是一名探鬼主播,決...
    沈念sama閱讀 38,432評(píng)論 3 401
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼蜘渣,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼淌铐!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起蔫缸,我...
    開(kāi)封第一講書(shū)人閱讀 37,088評(píng)論 0 261
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤腿准,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后拾碌,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體释涛,經(jīng)...
    沈念sama閱讀 43,586評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,028評(píng)論 2 325
  • 正文 我和宋清朗相戀三年倦沧,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了唇撬。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,137評(píng)論 1 334
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡展融,死狀恐怖窖认,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情告希,我是刑警寧澤扑浸,帶...
    沈念sama閱讀 33,783評(píng)論 4 324
  • 正文 年R本政府宣布,位于F島的核電站燕偶,受9級(jí)特大地震影響喝噪,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜指么,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,343評(píng)論 3 307
  • 文/蒙蒙 一酝惧、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧伯诬,春花似錦晚唇、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 30,333評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至,卻和暖如春悍及,著一層夾襖步出監(jiān)牢的瞬間闽瓢,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 31,559評(píng)論 1 262
  • 我被黑心中介騙來(lái)泰國(guó)打工心赶, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留扣讼,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 45,595評(píng)論 2 355
  • 正文 我出身青樓园担,卻偏偏與公主長(zhǎng)得像届谈,于是被迫代替她去往敵國(guó)和親枯夜。 傳聞我的和親對(duì)象是個(gè)殘疾皇子弯汰,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,901評(píng)論 2 345

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

  • 部署自動(dòng)打包環(huán)境 概述 基于Gitlab-CICD配置的自動(dòng)打包環(huán)境。 在Gitlab上創(chuàng)建Tag時(shí)湖雹,會(huì)執(zhí)行包含D...
    Wurq閱讀 2,077評(píng)論 0 0
  • 背景 aliyun OSS 上部署了靜態(tài)網(wǎng)站咏闪。之前更新項(xiàng)目需要手動(dòng)構(gòu)建,并使用桌面工具將新的包傳到 aliyun ...
    colbrze閱讀 2,249評(píng)論 0 0
  • 最影響用戶體驗(yàn)的是網(wǎng)頁(yè)首次打開(kāi)時(shí)的加載等待摔吏。 導(dǎo)致這個(gè)問(wèn)題的根本是網(wǎng)絡(luò)傳輸過(guò)程耗時(shí)大鸽嫂,CDN的作用就是加速網(wǎng)絡(luò)傳輸...
    oWSQo閱讀 4,206評(píng)論 0 3
  • 時(shí)隔很久的交作業(yè)。征讲。据某。拖延癥晚期,sorry诗箍。癣籽。。 Gitlab Runner打印Hello World 在完成這...
    奚山遇白閱讀 921評(píng)論 0 0
  • Messenger可以翻譯為信使滤祖,顧名思義筷狼,通過(guò)它可以在不同進(jìn)程中傳遞Message對(duì)象,在Message中放入我...
    靜默加載閱讀 583評(píng)論 0 0