使用webpack & pm2搭建编译服务器
背景
一直被吐槽前端编译速度慢,没办法,文件多了,当然慢啦,而且目前公司使用的Jenkins
作为持续集成工具,其中编译&重启Nodejs服务器命令如下:
npm i;npm run dist-h5;npm run dist-pc; kill -9 `netstat -tlnp|grep 3000|awk '"'"'{print $7}'"'"' | awk -F "/" '"'"'{print $1}'"'"'`; nohup npm run start > /data/home/home.log 2>&1 &
由于历史原因,PC项目和H5项目还有nodejs中间层服务是放在一个项目里面的,因此每次git提交到服务器的时候,都会全量打包一次,所以问题就在于,没有工具可以帮我们精确的编译修改的文件,加上运维也不是很熟nodejs,想来想去,于是自己手动撸一个加快编译的小工具,来逐步代替Jenkins的打包功能。
思路
初步想了想,大体上有以下2种思路
使用语法分析工具来找出修改文件对应的入口文件,使用webpack重新打包指定的入口文件。
在服务器上直接运行一个
webapck -w
实例,主动监听文件变化,然后打包。
但是上面两个方法都有严重的缺陷:
首先我暂时没精力去研究
babel-ast
等分析抽象语法树的工具,其次,在生产模式中,如果使用了CommonsChunkPlugin
,如果入口文件数量变动,很可能会影响最终生成的common.js
,这种方法是不可取的。webapck -w
实例对于package.json
和webapck.config.js
的变动是无法监听到的,而且我在项目中把HtmlWebpackPlugin
的配置抽离出来了,webpack
也没办法监听到,当这些配置文件被修改的时候,需要将整个编译进程重启。
最后解决办法就是用一个进程控制webpack编译进程,并且利用webpack
天生支持的增量编译功能,可以大幅提高后面的构建速度。
尝试
这里利用到了webpack
作为Nodejs模块时的API,文档在这里
const webpack = require("webpack");
const compiler = webpack({
// Configuration Object
});
compiler.run((err, stats) => {
// ...
});
然后我搭建了一个koa server,每次Jenkins执行打包命令就curl我们的server的rest api,server先检测是否需要重启编译进程、安装新的npm包,再执行全量或者增量编译的任务。
第一版
webpack
编译进程代码如下:
/**
* Created by chenchen on 2017/4/20.
*
* 编译进程,被主进程fork运行
*/
const _ = require("lodash");
// =======================================
const gulp = require("gulp");
const fs = require("fs");
const path = require("path");
const chalk = require("chalk");
// ==========================================
const webpack = require("webpack");
let compiler = null;
let isBusy = false;
let currentFullCompileTask = null;
let logCount = 0;
let logArr = new Array(20);
const watchFiles = ['../app/h5/src/webpack.config.js', '../app/pc/src/webpack.config.js', '../app/pc/src/config/dll.config.js', '../package.json'].map(f => path.resolve(__dirname, f));
process.send(`webpack 编译进程启动! pid ==> ${process.pid}`);
watchConfFiles();
watchDog();
fullCompile();
process.on('message', msg => {
let {taskID} = msg;
if (taskID) {
console.log(chalk.gray(`【receive Task】 ${taskID}`))
}
switch (msg.type) {
case 'increment':
compiler.run((err, stats) => {
if (err) {
console.error(err.stack || err);
if (err.details) {
console.error(err.details);
}
return;
}
let retObj = {taskID};
const info = stats.toJson();
if (stats.hasErrors()) {
console.error(info.errors);
retObj.error = info.errors;
}
retObj.result = outputStatsInfo(stats);
process.send(retObj);
});
break;
case 'full':
let p = null;
//
if (isBusy) {
p = currentFullCompileTask;
} else {
p = fullCompile();
}
p.then(stats => {
if (typeof stats === 'string') {
process.send({
taskID,
error: stats,
result: null
});
} else {
process.send({
taskID,
error: null,
result: outputStatsInfo(stats)
});
}
}).catch(e => {
process.send({
taskID,
error: e.message,
result: null
});
});
break;
case 'status':
process.send({
taskID,
error: null,
result: {
isBusy,
resUsage: logArr
}
});
break;
default:
console.log('未知指令');
break;
}
});
function requireWithoutCache(filename) {
delete require.cache[path.resolve(__dirname, filename)];
return require(filename);
}
function outputStatsInfo(stats) {
return stats.toString({
colors: false,
// children: false,
modules: false,
chunk: false,
source: false,
chunkModules: false,
chunkOrigins: false,
})
}
/**
* 全量编译
* @return {Promise}
*/
function fullCompile() {
isBusy = true;
let h5Conf = requireWithoutCache("../app/h5/src/webpack.config.js");
let pcConf = requireWithoutCache("../app/pc/src/webpack.config.js");
console.log('start full compile');
currentFullCompileTask = new Promise((resolve, reject) => {
compiler = webpack([...pcConf, ...h5Conf]);
// compiler = webpack(pcConf);
compiler.run((err, stats) => {
isBusy = false;
console.log('full compile done');
if (err)return resolve(err.message);
console.log(stats.toString("minimal"));
resolve(stats);
});
});
return currentFullCompileTask;
}
// =========================================
function cnpmInstallPackage() {
var {exec} = require("child_process");
return new Promise(function (resolve, reject) {
exec('cnpm i', {maxBuffer: 1024 * 2048}, (err, sto, ster) => {
if (err)return reject(err);
resolve(sto.toString());
})
});
}
function watchConfFiles() {
console.log('监听webpack配置文件。。。');
gulp.watch(watchFiles, e => {
console.log(e);
console.log('config file changed, reRuning...');
if (e.path.match(/package\.json$/)) {
cnpmInstallPackage().catch(e => {
console.log(e);
return -1;
});
}
fullCompile();
});
}
function watchDog() {
function run() {
logArr[logCount % 20] = {
memoryUsage: process.memoryUsage(),
cpuUsage: process.cpuUsage(),
time: Date.now()
};
logCount++;
}
setInterval(run, 3000);
}
这个js文件执行的任务有3个
启动webpack编译任务
监听
webpack.config.js
等文件变动,重启编译任务每隔200ms收集自己进程占用的系统资源
这里有几个要注意的地方
由于require有缓存机制,因此当重新启动编译任务前,需要清除缓存从而拿到最新的配置文件,可以调用下面的函数来require最新的文件内容。
function requireWithoutCache(filename) {
delete require.cache[path.resolve(__dirname, filename)];
return require(filename);
}
编译进程和主进程通过message来通讯,而且大部分是异步任务,因此要构建一套简单的任务收发系统,下面是
控制进程
创建一个任务的代码:
/**
* @description 创建一个任务
* @param {string | null} [id] 任务ID,可以不填
* @param {string} type 任务类型
* @param {number} timeout
* @return {Promise<TaskResult>} taskResult
*/
createBuildTask(id, type = 'increment', timeout = 180000) {
let taskID = id || `${type}-${Date.now() + Math.random() * 1000}`;
return new Promise((resolve, reject) => {
this.taskObj[taskID] = resolve;
setTimeout(reject.bind(null, '编译任务超时'), timeout);
this.webpackProcess.send({taskID, type});
});
}
在koa server端,我们只需要判断querystring,即可执行编译任务,下面是server的部分代码
app.use((ctx, next) => {
let {action} = ctx.query;
switch (action) {
case 'full':
return buildProc.createBuildTask(null, 'full').then(e => {
ctx.body = e;
});
case 'increment':
return buildProc.createBuildTask().then(e => {
ctx.body = e;
});
case 'reset':
buildProc.reset();
ctx.body = 'success';
return next();
case 'sys-info':
return ctx.body = {
uptime: process.uptime(),
version: process.version,
serverPid: process.pid,
webpackProcessPid: buildProc.webpackProcess.pid
};
case 'build-status':
return buildProc.createBuildTask(null, 'status').then(ret => {
return ctx.body = ret.result;
});
default:
ctx.body = fs.readFileSync(path.join(__dirname, './views/index.html')).toString();
return next();
}
});
最后写了一个页面,方便控制
第一个版本我部署在测试服务器后,效果明显,每次打包从10多分钟缩减到了4-5秒,再也不用和测试人员说:改好啦,等编译完成,大概10分钟左右。。。
第二版
后来项目做了比较大的变动,有三个webpack.config.js
并行编译,第一版是将所有的webpack.config.js
合并成一个单独的config,再一起打包,效率比较低,因此第二版做了改变,每个webpack.config.js
被分配到独立的编译进程中去,各自监听文件变动,这样可以更加精确的编译js文件了。
这里和第一版的区别如下
webpack
是以watch模式启动的,也就是说,如果新增了包,或者配置文件修改了,该进程在尝试增量编译的时候会报错,这时候依赖与父进程重启自己。
整体的架构如下:
每个webpack编译进程的代码如下
const path = require("path");
const webpack = require("webpack");
let pcConf = require("../../app/pc/front/webpack.config");
const compiler = webpack(pcConf);
const watching = compiler.watch({
poll: 1000
}, (err, stats) => {
if (err) {
console.error(err);
return process.exit(0);
}
console.log(stats.toString('minimal'))
});
为了方便管理,我使用了pm2
来帮我控制所有的进程,我创建了一个ecosystem.config.js
文件,代码如下
const path = require("path");
function getScript(s) {
return path.join(__dirname, './proc/', s)
}
module.exports = {
/**
* Application configuration section
* http://pm2.keymetrics.io/docs/usage/application-declaration/
*/
apps: [
// First application
{
name: 'pc',
script: getScript('pc.js'),
env: {
NODE_ENV: process.env.NODE_ENV
},
env_production: {
NODE_ENV: 'production'
},
}, {
name: 'merchant-pc',
script: getScript('merchant-pc.js'),
env: {
NODE_ENV: process.env.NODE_ENV
},
env_production: {
NODE_ENV: 'production'
},
},
{
name: 'h5',
script: getScript('h5.js'),
env: {
NODE_ENV: process.env.NODE_ENV
},
env_production: {
NODE_ENV: 'production'
},
}, {
name: 'server',
script: getScript('server.js'),
env: {
NODE_ENV: process.env.NODE_ENV
},
env_production: {
NODE_ENV: 'production'
},
},]
/**
* Deployment section
* http://pm2.keymetrics.io/docs/usage/deployment/
*/
};
这样在controller-server中也使用pm2来启动编译进程,而不用自己fork
了。
function startPm2() {
const cfg = require("./ecosystem.config");
return new Promise(function (resolve, reject) {
pm2.start(cfg, err => {
if (err) {
console.error(err);
return process.exit(0)
}
resolve()
})
});
}
controller-server端核心代码如下
app.listen(PORT, _ => {
console.log(`taskServer is running on ${PORT}`);
pm2.connect(function (err) {
if (err) {
console.error(err);
process.exit(2);
}
startPm2().then(() => {
console.log('pm2 started... init watch dog');
watchDog();
return listProc();
}).then(list => {
list.forEach(proc => {
console.log(proc.name);
})
})
});
});
function cnpmInstallPackage() {
var {exec} = require("child_process");
return new Promise(function (resolve, reject) {
exec('cnpm i', {maxBuffer: 1024 * 2048}, (err, sto, ster) => {
if (err)return reject(err);
resolve(sto.toString());
})
});
}
function watchDog() {
let merchantConf = require.resolve("../app/merchant-pc/front/webpack.config");
let pcConf = require.resolve("../app/pc/front/webpack.config");
let h5Conf = require.resolve("../app/h5/front/webpack.config");
let packageConf = require.resolve("../package.json");
gulp.watch(pcConf, () => {
console.log('pc 前端配置文件修改。。。重启编译进程');
cnpmInstallPackage().then(() => pm2.restart('pc', (err, ret) => {
console.log(ret);
})
)
});
gulp.watch(h5Conf, () => {
console.log('h5 前端配置文件修改。。。重启编译进程');
cnpmInstallPackage().then(() => pm2.restart('h5', (err, ret) => {
console.log(ret);
})
)
});
gulp.watch(merchantConf, () => {
console.log('merchant-pc 前端配置文件修改。。。重启编译进程');
cnpmInstallPackage().then(() => pm2.restart('merchant-pc', (err, ret) => {
console.log(ret);
})
)
});
gulp.watch(packageConf, () => {
console.log('package.json 配置文件修改。。。重启所有编译进程');
cnpmInstallPackage().then(() => pm2.restart('all', (err, ret) => {
console.log(ret);
})
)
});
}
这样,可以直接在shell中控制每个进程了,更加方便
pm2 ls
这里要注意的一点是,如果你是非root用户,记得执行的时候添加sudo;Jenkins的执行任务的用户要和你启动pm2服务是同一个,不然找不到对应的进程实例。
总结
总体来说,代码写的很粗糙,因为开发任务重,实在是没办法抽出太多时间来完善。
代码就不放出来了, 因为代码本来就很简单,这里更重要是记录一下自己一些心得和成果,在平常看似重复而且枯燥的任务中,细心一点,其实可以发现很多可以优化的地方,每天学习和进步一点点,才能从菜鸟成长为大牛。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。