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》,希望对大家有所帮助,如果大家有任何疑问请给我留言,小编会及时回复大家的。在此也非常感谢大家对 码农网 的支持!

查看所有标签

猜你喜欢:

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

The Photoshop Anthology

The Photoshop Anthology

Corrie Haffly / SitePoint Pty. Ltd. / 2006 / USD 39.95

The Photoshop Anthology is full-color, question-and-answer book for Web Designers who want to use Photoshop to build Websites and create better looking web graphics more effectively. The book covers: ......一起来看看 《The Photoshop Anthology》 这本书的介绍吧!

XML、JSON 在线转换
XML、JSON 在线转换

在线XML、JSON转换工具

Markdown 在线编辑器
Markdown 在线编辑器

Markdown 在线编辑器

HEX CMYK 转换工具
HEX CMYK 转换工具

HEX CMYK 互转工具