前端項(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):
- 能夠把指定路徑下指定模式(后綴名等)的文件匹配出來(lái)
- 能夠批量的并發(fā)的上傳族跛,但并發(fā)數(shù)量要可控
- 多次上傳能夠識(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')}`);
}
});