考拉有不少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]的節點,由此依次執行下去,直到所有節點執行完畢爲止