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

栏目: 编程工具 · 发布时间: 5年前

内容简介:前端项目开发完成后需要部署到服务器,为了减轻业务服务器的压力,以及为了更快的浏览器初次渲染速度,会做动静分离,也就是静态资源分离到CDN中去,动态生成的资源(主要是接口)才会部署到自己服务器上。webpack支持output.publicPath来替换打包出的资源中assets的引用路径,output.publicPath配置为http://xxx.cdn.com/,那么/static/a.jpg 的路径就会被替换为http://xxx.cdn.com/static/a.jpg。这样我们只要把静态文件上传到

前端项目开发完成后需要部署到服务器,为了减轻业务服务器的压力,以及为了更快的浏览器初次渲染速度,会做动静分离,也就是静态资源分离到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')}`);
    }
});
复制代码

以上所述就是小编给大家介绍的《Node实现静态文件增量上传CDN》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

Mathematica Cookbook

Mathematica Cookbook

Sal Mangano / O'Reilly Media / 2009 / GBP 51.99

As the leading software application for symbolic mathematics, Mathematica is standard in many environments that rely on math, such as science, engineering, financial analysis, software development, an......一起来看看 《Mathematica Cookbook》 这本书的介绍吧!

RGB转16进制工具
RGB转16进制工具

RGB HEX 互转工具

Base64 编码/解码
Base64 编码/解码

Base64 编码/解码

XML 在线格式化
XML 在线格式化

在线 XML 格式化压缩工具