前端项目开发完成后需要部署到服务器,为了减轻业务服务器的压力,以及为了更快的浏览器初次渲染速度,会做动静分离,也就是静态资源分离到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的工具。
分析下需求,主要有这么几点:
- 能够把指定路径下指定模式(后缀名等)的文件匹配出来
- 能够批量的并发的上传,但并发数量要可控
- 多次上传能够识别出更改的部分,实现增量上传
基于这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')}`);
}
});
网友评论