Node实现静态文件增量上传CDN

前端项目开发完成后需要部署到服务器,为了减轻业务服务器的压力,以及为了更快的浏览器初次渲染速度,会做动静分离,也就是静态资源分离到CDN中去,动态生成的资源(主要是接口)才会部署到自己服务器上。

webpack支持output.publicPath来替换打包出的资源中assets的引用路径,output.publicPath配置为http://xxx.cdn.com/,那么/static/a.jpg 的路径就会被替换为http://xxx.cdn.com/static/a.jpg。这样我们只要把静态文件上传到CDN就好了。

最近的一次会议上,我们分析服务端的统计数据的时候发现服务器30%的流量都被静态资源占去了,这反映出我们急需把静态资源批量上传到CDN上,减轻业务服务器的压力。而我们正缺少这样的一个工具,于是我们就基于node开发了一个静态文件上传CDN的工具。

分析下需求,主要有这么几点:

  1. 能够把指定路径下指定模式(后缀名等)的文件匹配出来
  2. 能够批量的并发的上传,但并发数量要可控
  3. 多次上传能够识别出更改的部分,实现增量上传

基于这3点需求,我们进行了调研和设计,最终方案是这样的:

实现第一点需求(匹配指定模式的文件),可以使用node-dir实现,readFiles方法支持读取一个目录下的文件,根据一些模式来过滤:

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

实现第二点需求(异步上传文件)可以使用p-queue
,支持传入多个异步的promise对象,然后指定并发数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);
        }),
    ),
);

进度条可以使用cli-progress
来实现,结合上面的p-queue来显示进度。

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

const bar = new cliProgress.Bar(
    {
        format: '上传进度 [{bar}] {percentage}% | 预计: {eta}s | {value}/{total}',
    },
    cliProgress.Presets.rect,
);
bar.start(files.length, 0);

bar.increment();//每个文件上传完成时
bar.stop();

第三点需求(增量上传)的方案是这样的,使用node-dir匹配出文件列表之后,生成每个文件的md5,文件路径作为值,生成一个map,叫做toUploadManifest,然后上传完成后,把上传过的文件的内容md5和文件路径生成uploadedManifest。每次上传之前把toUploadManifest 中在uploadedManifest出现过的文件都去掉,这样就实现了增量的上传。

md5的生成使用node的crypto内置模块:

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

过滤掉toUploadManifest中已上传的部分:

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

至此,实现静态文件增量上传CDN的功能就基本可以实现了。当然上传CDN的接口实现需要做一些鉴权之类的,这里因为我们后端实现了这部分功能,我们只需要调用接口就可以了,如果自己实现需要做一些鉴权。可以参看ali-oss的文档

很多情况下上传cdn的脚本都是跑在gitlab ci的,gitlab ci使用不同的runner来执行脚本,runner可以在不同的机器上,所以想要uploadedManifest.json真正做到记录上传过的文件的功能,必须统一放到一个地方,可以结合gitlab ci的cache来实现:

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

总结

动静分离几乎必用的优化手段,主要有两步:webpack配置output.publicPath,然后把静态资源上传CDN。我们开发的工具就是实现了静态资源增量上传CDN,并且可以控制并发数。增量上传的部分可以是基于md5 + 持久化的文件来实现的,在gitlab ci的runner中运行时,要是用gitlab cache来存储清单文件。

完整代码:

// 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, 绝对路径
 * @param {Object} options {
 *     exclude,  通过正则或数组忽略指定的文件名
 *     encoding, 文件编码 (默认 'utf8')
 *     excludeDir, 通过正则或数组忽略指定的目录
 *     match,  通过正则或数组匹配指定的文件名
 *     matchDir 通过正则或数组匹配指定的目录
 * }
 * @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} 个文件`);
                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) => {
        // 其他失败,导致无法继续上传,失败即退出
        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));
    })
}

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

/**
 * @description
 * @date 2019-03-08
 * @param {string} dir 本地项目目录,相对执行命令所在文件
 * @param {object} {
 *      project,    上传OSS所在目录,通常使用项目名
 *      limit = 5,  并发最大数
 *      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: '',
    };

    // 上传后的网络地址
    const targetProject = url.resolve(targetHost, data.path);

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

    const bar = new cliProgress.Bar(
        {
            format: '上传进度 [{bar}] {percentage}% | 预计: {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);
    
                // 添加到队列中
                queue.addAll(
                    files.map((filePath) => () =>
                        uploadFile(targetProject, {
                            ...data,
                            file: filePath,
                            filename: path.relative(uploadDir, filePath).replace(/[\\]/g, '/'),
                        }).then((rs) => {
                            // 更新进度条
                            bar.increment();
                            result.push(rs);
                        }),
                    ),
                );
                return queue.onIdle().then(() => {
                    bar.stop();
                    return result;
                });
            })
        })
        .then((res) => {
            const success = [];
            const fail = [];
            console.log();
            // 全部结束
            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, // 有失败时返回 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);
            // 发生未知错误 process.exit(1);
            return Promise.reject(error);
        });
}

module.exports = upload;

//使用时:
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}个文件:\n${rs.success.join('\n')}`);
    if (rs.status === 1) {
        console.log(`已全部上传完成!`);
    } else {
        console.log(`部分文件上传失败:\n${rs.fail.join('\n')}`);
    }
});
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,761评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,953评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,998评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,248评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,130评论 4 356
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,145评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,550评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,236评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,510评论 1 291
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,601评论 2 310
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,376评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,247评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,613评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,911评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,191评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,532评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,739评论 2 335

推荐阅读更多精彩内容