考拉有不少node工程,其中客户端代码client/
和基于egg的服务端server/
混合在一块儿。因为历史遗留问题,大部分client
下都会有多套构建脚本。好比我负责的工程就包含:
1. client/pc
(webpack2)
2. client/wap
(webpack2)
3. client/wap-vue
(webpack4)
4. ssrClient
(vue-cli)html
居然有四套构建 (キ`゚Д゚´) 再加上对应的npm install
,以及服务端的server/
也须要install
,最终致使构建时间会很长vue
为了解决这个问题,提高构建效率,我作了@kaola/buildflow
,经过多线程并行构建,按需构建(缓存npm install
和npm run build
的构建结果),大幅提高了构建速度node
本文主要介绍了buildflow
的解决方案、实现思路,以及一些有趣的技术细节webpack
↓流程示意图↓git
将构建的各类命令合理分配到不一样线程中,将整体打包效率最大化es6
建立一个可执行任务github
flow.exec('install wap packages', {
cmd: 'npm i --prefix=./client/wap',
cwd: './'
})
复制代码
如何串行执行web
flow.exec('install wap packages', { cmd: 'npm i --prefix=./client/wap' })
.exec('build wap packages', { cmd: 'npm run build --prefix=./client/wap' })
复制代码
如何多线程并行执行vue-cli
flow.fork([
flow.exec(...), // 任务1
flow.exec(...), // 任务2
])
复制代码
并行任务之间能够进行嵌套npm
flow.fork([
flow.fork([
flow.exec(...), // 任务1
flow.exec(...), // 任务2
])
.exec(...), // 任务3,
flow.exec(...), // 任务4
.exec(...), // 任务5
])
复制代码
如上伪代码执行顺序,就是1/2/4同时开始执行,当1/2执行结束后3再开始执行,当4执行结束后5开始执行
在运行时,若是不限制线程数,可能会并发执行大量任务,致使机器卡顿,反而会拖累总体构建速度。通常在构建机上执行还好(几十个cpu内核起步),但在本地执行时容易出这种问题
所以,使用线程池来控制最大线程数,数量由cpu虚拟内核数来决定更加合理
// CPU逻辑内核,决定最大并发任务数量
const cpus = require('os').cpus();
const cpuNumber = cpus.length;
复制代码
一个例子,部分代码以下
完整版见:
const CpuPool = require("./cpu-pool");
const sleep = require("./sleep");
const pool = new CpuPool();
// 一个任务
async function exec() {
await pool.occupy(); // 占用1个cpu逻辑内核
await sleep(1000 * 10 * Math.random()); // 休眠0-10s,模拟执行效果
await pool.release(); // 释放占用
}
let count = 0,
max = 20;
while (count <= max) {
exec();
count++;
}
/*
假设 cpu 虚拟内核为 12
并行执行20个任务
并行执行12个任务(执行时间0-10s),剩余8个任务队列中等待
先执行完毕的任务会释放cpu,队列的等待的任务进入执行
*/
复制代码
执行使用node的child_process模块
const spawn = require('child_process').spawn;
function run(first, params, options = {}) {
return new Promise((resolve) => {
const p = spawn(first, params, options);
let stdout = '', stderr = '', output = '';
p.stdout.on('data', (data) => {
const str = data.toString();
stdout += str;
output += str;
});
p.stderr.on('data', (data) => {
const str = data.toString();
stderr += str;
output += str;
});
p.on('close', (code) => {
resolve({ stdout, stderr, output, code });
});
});
}
// 执行
run('npm', ['run', 'build'], { cwd: './client/wap' })
.then({ stdout } => stdout);
复制代码
使用debug包对日志进行分级,如:error、warn、info、debug
经过设置process.env.DEBUG
参数,能够选择输出的日志类型
在webpack的一次构建中,若是耗时太长,能够考虑拆分构建的页面:
buildflow
的缓存能力能够将构建结果与上一次构建的结果合并测试环境可能会频繁构建。若是只修改了服务端代码,却仍然要总体构建,显然是比较浪费的
所以,思路就是将前一次构建的结果进行缓存,并在下一次构建时,只对变更的部分执行构建命令,最后将两次的构建结果合并
将上一次构建的 commitId
与 HEAD
作 git diff
操做。我使用了 simple-git
const { root, joinRoot } = require('../lib/path');
const git = require('simple-git/promise')(rootPath);
async function getChangedFiles(lastCommitId, currCommitId = 'HEAD') { // 获取指定commit id之间变动的文件,默认对比HEAD
assert.ok(typeof lastCommitId === 'string');
const changeFiles = (await git.diffSummary([`${lastCommitId}...${currCommitId}`]))
.files.map(f => f.file)
.filter(p => p)
.map(filepath => joinRoot(filepath));
return changeFiles;
}
复制代码
肯定修改的文件后,将文件路径与npm run build
的路径进行比对,若是文件在该路径下,则执行对应的构建操做。
缓存放在什么地方?
最先在网易的时候,跟运维商量以后,决定将缓存文件放在构建机的特定目录,貌似会进行按期清理
而如今由于构建机有不少台,这种方案显然再也不适用,改成上传到oss远程存储的方式存放缓存数据。
下载解压+压缩上传一个20M+的一个压缩包,大概总共须要10~15s,这个要算到使用缓存功能的时间成本中
另外须要考虑到:版本管理、按期清理、安全问题。
尤为是安全方面须要特别留意,oss bucket不能让无关的用户访问到,Bucket ACL须要设置为私有(上传,下载均须要AK鉴权)。
每次构建结束,将缓存的内容tar+gzip压缩后,上传到oss私有桶,oss中的路径包含:工程名、分支、时间,便于维护和按期清理,到下一次构建开始时,先尝试将缓存的构建结果拉倒本地进行解压
为了更快速的压缩&上传、下载&解压文件,须要用到stream流来辅助操做
好比下载&解压文件,你须要先下载,而后再解压。而若是使用流,就能作到一边下载,一边解压,效率提高了不少
我使用了 compressing 与 pump,再结合 oss node客户端 进行的流操做,文件被压缩为*.tgz
格式
为了应用缓存能力,我使用了中间件的设计思路。上面提到 flow.exec
会生成一个安装或构件任务,那么中间件就会加载到这一任务中,在这个任务开始前和结束后执行。当有多个中间件同时生效时,使用koa洋葱模型嵌套执行
中间件的实现方式很简单,基本原理以下
// 第一个中间件
const mid1 = async (next, props = {}) => {
console.log(`mid1 before, name: ${props.name}`);
await next();
console.log(`mid1 after, name: ${props.name}`);
};
// 另外一个中间件
const mid2 = async (next, props = {}) => {
console.log(`mid2 before, name: ${props.name}`);
await next();
console.log(`mid2 after, name: ${props.name}`);
};
// 任务函数
const exec = async (props = {}) => {
console.log(`run exec, name: ${props.name}`);
};
// 添加了中间件的任务函数
const execNew = async (props = {}) => {
const ms = [
mid1, // 将中间件放到数组的前两位,执行时会符合koa洋葱模型
mid2,
async(next, props = {}) => { // 封装后的任务函数
await exec(props);
},
];
const next = async() => {
const m = ms.shift(); // 每次执行next,都会从头部导出数组的一项
await m(next, { name });
};
await next(); // 开始执行
};
/*
执行结果:
> mid1 before, name: tian
> mid2 before, name: tian
> run exec, name: tian
> mid2 after, name: tian
> mid1 after, name: tian
*/
复制代码
oss相关参数很好处理
flow
.cache({
// oss缓存参数
oss: {...}
})
});
复制代码
中间件的定义,以及哪些exec
任务须要加载中间件
方法一:全局中间件
// 全局应用中间件(具体节点也能够单独设置,会合并)
// - 只针对: flow.exec、flow.check
// - 写在开头,能够应用到全局
flow.use({
'git-change': { // 中间件名称
// 筛选须要应用中间件的节点,名字以 build 开头的任务
test: ({ name }) => /^\s*build\s*$/.test(name),
// 传入中间件的参数
option: ({ cwd = '' }) => {
return { watch: cwd }; // watch 目录的代码修改后,须要从新构建,不然使用缓存
}
}
});
复制代码
方法二:针对某个任务使用中间件
flow.exec('build wap', {
cmd: 'npm run build --prefix=./client/wap',
use: [ 'git-change?watch=./client/wap' ] //
});
复制代码
npm i
操做也会消耗必定的时间,使用缓存后也有必定提高空间
具体策略是将package.json
与package-lock.json
文件 md5 转换为字符串,并与对应的node_modules/
一块儿缓存。
在新一次构建中,若是新计算的md5值未发生修改,则直接使用上一次安装的node_modules/
md5操做使用了crypto
删除文件/目录,通常用于在开始构建前,剔除不参与构建(缓存)的内容
flow.clean([
'./compressed',
'./server/app/public'
])
复制代码
对打包结果进行校验,提早避免一部分问题,好比es6语法、xss风险(目前支持了art-template模板)等
flow // 构建结果检查
.check('check bundle file', {
es5check: { // 检查1 - 指定目录不该该包含es6的语法
path: require.resolve('@kaola/buildflow.check.es6'), // 外部导入
pattern: ['./server/app/public/**/*.js'], // 须要检查的列表
notCheckEval: false, // 是否检查eval语法中的代码
},
xss: {/* ... */} // 检查2 - 模板中是否存在xss风险
});
复制代码
输出构建结果到指定目录,使用 globby
compress
// 将哪些数据输出
.from({
cwd: './',
include: ['@(dist)', 'server/*'],
exclude: []
})
// 输出到的目录
.to('./compressed');
复制代码
如何将易于阅读的api,解析为适合工程运行的数据结构?
假设用户输入的api以下
flow.node('total') // 仅用于打日志,用于分割和标记时间
.clean(['./server/app/public']) // 删除目录
.fork([ // 启用并发
flow.exec('install wap', { cwd: 'npm i --prefix=./client/wap' }) // 任务1
.exec('build wap', { cwd: 'npm run build --prefix=./client/wap' }), // 任务2(依赖任务1)
flow.exec('install server', { cwd: 'npm i --prefix=./server' }), // 任务3,与任务1并发执行
])
.check('check es6 bundle', { // 对构建结果进行校验
checkES6Bundle: {
path: require.resolve('@kaola/buildflow.check.es6'), // 引用外部模块
pattern: ['./server/app/public/**/*.js'], // 须要检查的文件
}
})
复制代码
为了便于程序运行,api最终会被解析为一个数组以下:
[
{ "id": 0, "name": "total", "type": "node", "parent": [] },
{
"id": 1, "name": "clean", "type": "clean", "parent": [0],
"dirs": ["./server/app/public"]
},
{
"id": 2, "name": "install wap", "type": "exec", "parent": [1],
"use": [],
"exec": { "cmd": "npm i", "options": { "cwd": "./client/wap" } }
},
{
"id": 3, "name": "build wap", "type": "exec", "parent": [2],
"use": [],
"exec": { "cmd": "npm run build", "options": { "cwd": "./client/wap" } }
},
{
"id": 4, "name": "install server", "type": "exec", "parent": [1],
"use": [],
"exec": { "cmd": "npm i", "options": { "cwd": "./server" } }
},
{
"id":5, "name":"check es6 bundle", "type":"check", "parent":[3, 4],
"rules":[
{
"path":"./node_modules/@kaola/buildflow.check.es6/src/index.js",
"pattern":["./server/app/public/**/*.js"],
"type":"es5check"
}
]
}
]
复制代码
除了表示并发的flow
以外,其他的API(node
,clean
,exec
,check
)都会被解析为数组中的一项,而且每一种类型都会有对应的处理函数(onNode
,onClean
,onExec
,onCheck
)负责执行
参数id
,parent
用来标记各个节点之间的关系,其中parent
为数组,由于一个节点可能同时依赖多个节点,好比最后的check
节点,它依赖于并行执行的2个任务(任务二、任务3)
真正执行的顺序是这样的,先取数组的第一个节点(id为0),执行第一个节点(onNode
函数)完毕后,将执行完毕的节点的id放到一个数组中(如 dealIds
为 [0]),再检查有哪些节点的parent
已经执行完毕(包含在dealIds
中),此时获得id为1的节点clean
,继续执行onClean
,执行完毕后将对的id放到dealIds
中(此时为[0, 1]),继续检查parent
包含[0, 1]的节点,由此依次执行下去,直到所有节点执行完毕为止