如何搭建適合自己團隊的構(gòu)建部署平臺
本文首發(fā)于政采云前端團隊博客:如何搭建適合自己團隊的構(gòu)建部署平臺
https://www.zoo.team/article/webbuild

前端業(yè)界現(xiàn)有的構(gòu)建部署方案,常用的應(yīng)該是 Jenkins,Docker,GitHub Actions 這些,而恰巧,我們公司現(xiàn)在就并存了前兩種方案。既然已經(jīng)有了穩(wěn)定的構(gòu)建部署方式,為什么還要自己做一套前端自己的構(gòu)建平臺呢?當然不是為了好玩啊,原因聽我慢慢分析。
前端構(gòu)建使用的時候可能會碰到各種各樣問題,比如:
Eslint 跳過校驗——公司里面的前端項目,隨著時間的推移,不同階段,通過新老腳手架創(chuàng)建出來的項目可能風格各異,并且校驗規(guī)則可能也不一定統(tǒng)一,雖然項目本身可以有著各種的 Eslint,Stylelint 等校驗攔截,但阻止不了開發(fā)者跳過這些代碼校驗。 npm 版本升級不兼容——對于依賴的 npm 版本必須的一些兼容性校驗,如果某些 npm 插件突然升級了不兼容的一些版本,代碼上線后就會報錯出錯,典型的就是各類 IE 兼容。 無法自由添加自己想要的功能——想要優(yōu)化前端構(gòu)建的流程,或者方便前端使用的功能優(yōu)化,但因為依賴運維平臺的構(gòu)建應(yīng)用,想加點自己的功能需要等別人排期。
而這些問題,如果有了自己的構(gòu)建平臺,這都將不是問題,所以也就有了現(xiàn)在的——云長。
為何起名叫“云長“呢,當然是希望這個平臺能像”關(guān)云長“一樣,一夫當關(guān)萬夫莫開。那云長又能給我們提供什么樣的一些能力呢?
云長能力
構(gòu)建部署
這當然是必備的基本能力了,云長提供了公司不同前端項目類型,例如 Pampas、React、Vue、Uniapp 等的構(gòu)建能力。整個流程其實也并不復(fù)雜,開始構(gòu)建后,云長的服務(wù)端,獲取到要構(gòu)建的項目名,分支,要部署的環(huán)境等信息后,開始進行項目的代碼更新,依賴安裝,之后代碼打包,最后將生成的代碼再打包成鏡像文件,然后將這份鏡像上傳到鏡像倉庫后,并且將項目的一些資源靜態(tài)文件都可以上傳 CDN,方便前端之后的調(diào)用,最后調(diào)用 K8S 的鏡像部署服務(wù),進行鏡像按環(huán)境的部署,一個線上構(gòu)建部署的流程也就完成了。

可插拔的構(gòu)建流程
如果是使用別人的構(gòu)建平臺, 很多前端自己想加入的腳本功能就依賴別人的服務(wù)來實現(xiàn),而如果走云長,則可以提供開放型的接口,讓前端可以自由定制自己的插件式服務(wù)。
比如這個線上構(gòu)建打包的過程當中,就可以處理一些前文提到過的問題,痛點,例如:
代碼的各類 Eslint、Tslint 等合規(guī)性校驗,再也不怕被人跳過檢驗步驟。 項目構(gòu)建前還可以做 npm 包版本的檢測,防止代碼上線后的兼容性報錯等等。 代碼打包后也能做一些全局性質(zhì)的前端資源注入,例如埋點,錯誤監(jiān)控,消息推送等等類型。

審核發(fā)布流程
公司現(xiàn)有的平臺發(fā)布流程管控靠的是運維的名單維護,每個項目都會管理一個可發(fā)布人的名單,所以基本項目發(fā)版都需要發(fā)布人當晚跟隨進行發(fā)布,而云長為了解決這個問題,提供了一個審核流的概念。
也就是當項目在預(yù)發(fā)環(huán)境測試完成之后,代碼開發(fā)者可以提起一個真線的發(fā)布申請單,之后這個項目的可發(fā)布人會通過釘釘收到一個需要審核的申請單,可以通過網(wǎng)頁端,或者釘釘消息直接操作,同意或者拒絕這次發(fā)布申請,在申請經(jīng)過同意后,代碼開發(fā)者到了可發(fā)布時間后,就能自己部署項目發(fā)布真線,發(fā)布真線后,后續(xù)會為這個項目創(chuàng)建一個代碼的 Merge Request 請求,方便后續(xù)代碼的歸檔整理。

這么做的好處呢,一方面可以由前端來進行項目構(gòu)建發(fā)布的權(quán)限管控,讓發(fā)布權(quán)限可以進行收攏,另一方面也可以解放了項目發(fā)布者,讓開發(fā)者可以更方便的進行代碼上線,而又開放了項目的發(fā)布。
能力對外輸出
云長可以對外輸出一些構(gòu)建更新的能力,也就讓第三方插件接入構(gòu)建流程成為了可能,我們貼心的為開發(fā)者提供了 VsCode 插件,讓你在開發(fā)過程中可以進行自由的代碼更新,省去打開網(wǎng)頁進行構(gòu)建的時間,足不出戶,在編輯器中進行代碼的構(gòu)建更新,常用環(huán)境更是提供了一鍵更新的快捷方式,進一步省去中間這些操作時間,這個時候多寫兩行代碼不是更開心嗎。


我們的 VsCode 插件不僅僅提供了云長的一些構(gòu)建能力,還有小程序構(gòu)建,路由查找,等等功能,期待這個插件分享的話,請期待我們后續(xù)的文章哦。
云長架構(gòu)
上面講過云長的構(gòu)建流程,云長是依賴于 K8S 提供的一個部署鏡像的能力,云長的客戶端與服務(wù)端都是跑在 Docker 中的服務(wù),所以云長是采用了Docker In Docker 的設(shè)計方案,也就是由 Docker 中的服務(wù)來進行一個 Docker 鏡像的打包。
針對代碼的構(gòu)建,云長服務(wù)端部分引入了進程池的處理,每個在云長中構(gòu)建的項目都是進程池中的一個獨立的實例,都有獨立的打包進程,而打包過程的進度跟進則是靠 Redis 的定時任務(wù)查詢來進行,也就實現(xiàn)了云長多實例并行構(gòu)建的架構(gòu)。
云長客戶端與服務(wù)端的接口通信則是正常的 HTTP 請求和 Websocket 請求,客戶端發(fā)起請求后,服務(wù)端則通過 MySQL 數(shù)據(jù)存儲一些應(yīng)用,用戶,構(gòu)建信息等數(shù)據(jù)。
外部的資源交互則是,構(gòu)建的過程中也會上傳一些靜態(tài)資源還有打包的鏡像到 cdn 和鏡像倉庫,最后則是會調(diào)用 K8S 的部署接口進行項目的部署操作。

前端構(gòu)建的 0-1
上面看過了“云長”的一些功能介紹,以及“云長”的架構(gòu)設(shè)計,相信很多朋友也想自己做一個類似于“云長”的前端構(gòu)建發(fā)布平臺,那需要怎么做呢,隨我來看看前端構(gòu)建平臺主要模塊的設(shè)計思路吧。

構(gòu)建流程
前端構(gòu)建平臺的主要核心模塊肯定是構(gòu)建打包,構(gòu)建部署流程可以分為以下幾個步驟:
每一次構(gòu)建開始后,需要保存本次構(gòu)建的一些信息數(shù)據(jù),所以需要創(chuàng)建構(gòu)建發(fā)布記錄,發(fā)布記錄會存儲本次發(fā)布的發(fā)布信息,例如發(fā)布項目的名稱,分支,commitId,commit 信息,操作人數(shù)據(jù),需要更新的發(fā)布環(huán)境等,這時我們會需要一張構(gòu)建發(fā)布記錄表,而如果你需要項目以及操作人的一些數(shù)據(jù),你就又需要應(yīng)用表以及用戶表來存儲相關(guān)數(shù)據(jù)進行關(guān)聯(lián)。 構(gòu)建發(fā)布記錄創(chuàng)建以后,開始了前端構(gòu)建流程,構(gòu)建流程可以 pipeline的流程來進行,流程可以參考以下例子
// 構(gòu)建的流程
async run() {
const app = this.app;
const processData = {};
const pipeline = [{
handler: context => app.fetchUpdate(context), // Git 更新代碼
name: 'codeUpdate',
progress: 10 // 這里是當前構(gòu)建的進度
}, {
handler: context => app.installDependency(context), // npm install 安裝依賴
name: 'dependency',
progress: 30
}, {
handler: context => app.check(context), // 構(gòu)建的前置校驗(非必須):代碼檢測,eslint,package.json 版本等
name: 'check',
progress: 40
}, {
handler: context => app.pack(context), // npm run build 的打包邏輯,如果有其他的項目類型,例如 gulp 之類,也可以在這一步進行處理
name: 'pack',
progress: 70
}, {
handler: context => app.injectScript(context), // 構(gòu)建的后置步驟(非必須):打包后的資源注入
name: 'injectRes',
progress: 80
}, { // docker image build
handler: context => app.buildImage(context), // 生成 docker 鏡像文件,鏡像上傳倉庫,以及之后調(diào)用 K8S 能力進行部署
name: 'buildImage',
progress: 90
}];
// 循環(huán)執(zhí)行每一步構(gòu)建流程
for (let i = 0; i < pipeline.length; i++) {
const task = pipeline[i];
const [ err, response ] = await to(this.execProcess({
...task,
step: i
}));
if (response) {
processData[task.name] = response;
}
}
return Promise.resolve(processData);
}
// 執(zhí)行構(gòu)建中的 handler 操作
async execProcess(task) {
this.step(task.name, { status: 'start' });
const result = await task.handler(this.buildContext);
this.progress(task.progress);
this.step(task.name, { status: 'end', taskMeta: result });
return result;
}
構(gòu)建的步驟,上面構(gòu)建的一些流程,相比大家也想知道在服務(wù)端如何跑構(gòu)建流程當中的一些腳本,其實思路就是通過 node的child_process模塊執(zhí)行 shell 腳本,下面是代碼的一些示例:
import { spawn } from 'child_process';
// git clone
execCmd(`git clone ${url} ${dir}`, {
cwd: this.root,
verbose: this.verbose
});
// npm run build
const cmd = ['npm run build', cmdOption].filter(Boolean).join(' ');
execCmd(cmd, options);
// 執(zhí)行 shell 命令
function execCmd(cmd: string, options:any = {}): Promise<any> {
const [ shell, ...args ] = cmd.split(' ').filter(Boolean);
const { verbose, ...others } = options;
return new Promise((resolve, reject) => {
let child: any = spawn(shell, args, others);
let stdout = '';
let stderr = '';
child.stdout && child.stdout.on('data', (buf: Buffer) => {
stdout = `${stdout}${buf}`;
if (verbose) {
logger.info(`${buf}`);
}
});
child.stderr && child.stderr.on('data', (buf: Buffer) => {
stderr = `${stderr}${buf}`;
if (verbose) {
logger.error(`${buf}`);
}
});
child.on('exit', (code: number) => {
if (code !== 0) {
const reason = stderr || 'some unknown error';
reject(`exited with code ${code} due to ${reason}`);
} else {
resolve({stdout, stderr});
}
child.kill();
child = null;
});
child.on('error', err => {
reject(err.message);
child.kill();
child = null;
});
});
};
而例如我們想在構(gòu)建前想加入 Eslint校驗操作,也可以在構(gòu)建流程中加入,也就可以在線上構(gòu)建的環(huán)節(jié)中加入攔截型的校驗,控制上線構(gòu)建代碼質(zhì)量。
import { CLIEngine } from 'eslint';
export function lintOnFiles(context) {
const { root } = context;
const [ err ] = createPluginSymLink(root);
if (err) {
return [ err ];
}
const linter = new CLIEngine({
envs: [ 'browser' ],
useEslintrc: true,
cwd: root,
configFile: path.join(__dirname, 'LintConfig.js'),
ignorePattern: ['**/router-config.js']
});
let report = linter.executeOnFiles(['src']);
const errorReport = CLIEngine.getErrorResults(report.results);
const errorList = errorReport.map(item => {
const file = path.relative(root, item.filePath);
return {
file,
errorCount: item.errorCount,
warningCount: item.warningCount,
messages: item.messages
};
});
const result = {
errorList,
errorCount: report.errorCount,
warningCount: report.warningCount
}
return [ null, result ];
};
構(gòu)建部署完成后,可根據(jù)構(gòu)建情況,來更新這條構(gòu)建記錄的更新狀態(tài)信息,本次構(gòu)建生成的 Docker鏡像,上傳鏡像倉庫后,也需要信息記錄,方便后期可用之前構(gòu)建的鏡像再次進行更新或者回滾操作,所以需要添加一張鏡像表,下面為Docker鏡像生成的一些實例代碼。
import Docker = require('dockerode');
// 保證服務(wù)端中有一個基本的 dockerfile 鏡像文件
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
const image = '鏡像打包名稱'
let buildStream;
[ err, buildStream ] = await to(
docker.buildImage({
context: outputDir
}, { t: image })
);
let pushStream;
// authconfig 鏡像倉庫的一些驗證信息
const authconfig = {
serveraddress: "鏡像倉庫地址"
};
// 向遠端私有倉庫推送鏡像
const dockerImage = docker.getImage(image);
[ err, pushStream ] = await to(dockerImage.push({
authconfig,
tag
}));
// 3s 打印一次進度信息
const progressLog = _.throttle((msg) => logger.info(msg), 3000);
const pushPromise = new Promise((resolve, reject) => {
docker.modem.followProgress(pushStream, (err, res) => {
err ? reject(err) : resolve(res);
}, e => {
if (e.error) {
reject(e.error);
} else {
const { id, status, progressDetail } = e;
if (progressDetail && !_.isEmpty(progressDetail)) {
const { current, total } = progressDetail;
const percent = Math.floor(current / total * 100);
progressLog(`${id} : pushing progress ${percent}%`);
if (percent === 100) { // 進度完成
progressLog.flush();
}
} else if (id && status) {
logger.info(`${id} : ${status}`);
}
}
});
});
await to(pushPromise);
每一次的構(gòu)建需要保存一些構(gòu)建進度,日志等信息,可以再加一張日志表來進行日志的保存。
多個構(gòu)建實例的運行
到這里一個項目的構(gòu)建流程就已經(jīng)成功跑通了,但一個構(gòu)建平臺肯定不能每次只能構(gòu)建更新一個項目啊,所以這時候可以引入一個進程池,讓你的構(gòu)建平臺可以同時構(gòu)建多個項目。
Node 是單線程模型,當需要執(zhí)行多個獨立且耗時任務(wù)的時候,只能通過 child_process 來分發(fā)任務(wù),提高處理速度,所以也需要實現(xiàn)一個進程池,用來控制多構(gòu)建進程運行的問題,進程池思路是主進程創(chuàng)建任務(wù)隊列,控制子進程數(shù)量,當子進程完成任務(wù)后,通過進程的任務(wù)隊列,來繼續(xù)添加新的子進程,以此來控制并發(fā)進程的運行,流程實現(xiàn)如下。

ProcessPool.ts 以下是進程池的部分代碼,主要展示思路。
import * as child_process from 'child_process';
import { cpus } from 'os';
import { EventEmitter } from 'events';
import TaskQueue from './TaskQueue';
import TaskMap from './TaskMap';
import { to } from '../util/tool';
export default class ProcessPool extends EventEmitter {
private jobQueue: TaskQueue;
private depth: number;
private processorFile: string;
private workerPath: string;
private runningJobMap: TaskMap;
private idlePool: Array<number>;
private workPool: Map<any, any>;
constructor(options: any = {}) {
super();
this.jobQueue = new TaskQueue('fap_pack_task_queue');
this.runningJobMap = new TaskMap('fap_running_pack_task');
this.depth = options.depth || cpus().length; // 最大的實例進程數(shù)量
this.workerPath = options.workerPath;
this.idlePool = []; // 工作進程 pid 數(shù)組
this.workPool = new Map(); // 工作實例進程池
this.init();
}
/**
* @func init 初始化進程,
*/
init() {
while (this.workPool.size < this.depth) {
this.forkProcess();
}
}
/**
* @func forkProcess fork 子進程,創(chuàng)建任務(wù)實例
*/
forkProcess() {
let worker: any = child_process.fork(this.workerPath);
const pid = worker.pid;
this.workPool.set(pid, worker);
worker.on('message', async (data) => {
const { cmd } = data;
// 根據(jù) cmd 狀態(tài) 返回日志狀態(tài)或者結(jié)束后清理掉任務(wù)隊列
if (cmd === 'log') {
}
if (cmd === 'finish' || cmd === 'fail') {
this.killProcess();//結(jié)束后清除任務(wù)
}
});
worker.on('exit', () => {
// 結(jié)束后,清理實例隊列,開啟下一個任務(wù)
this.workPool.delete(pid);
worker = null;
this.forkProcess();
this.startNextJob();
});
return worker;
}
// 根據(jù)任務(wù)隊列,獲取下一個要進行的實例,開始任務(wù)
async startNextJob() {
this.run();
}
/**
* @func add 添加構(gòu)建任務(wù)
* @param task 運行的構(gòu)建程序
*/
async add(task) {
const inJobQueue = await this.jobQueue.isInQueue(task.appId); // 任務(wù)隊列
const isRunningTask = await this.runningJobMap.has(task.appId); // 正在運行的任務(wù)
const existed = inJobQueue || isRunningTask;
if (!existed) {
const len = await this.jobQueue.enqueue(task, task.appId);
// 執(zhí)行任務(wù)
const [err] = await to(this.run());
if (err) {
return Promise.reject(err);
}
} else {
return Promise.reject(new Error('DuplicateTask'));
}
}
/**
* @func initChild 開始構(gòu)建任務(wù)
* @param child 子進程引用
* @param processFile 運行的構(gòu)建程序文件
*/
initChild(child, processFile) {
return new Promise(resolve => {
child.send({ cmd: 'init', value: processFile }, resolve);
});
}
/**
* @func startChild 開始構(gòu)建任務(wù)
* @param child 子進程引用
* @param task 構(gòu)建任務(wù)
*/
startChild(child, task) {
child.send({ cmd: 'start', task });
}
/**
* @func run 開始隊列任務(wù)運行
*/
async run() {
const jobQueue = this.jobQueue;
const isEmpty = await jobQueue.isEmpty();
// 有空閑資源并且任務(wù)隊列不為空
if (this.idlePool.length > 0 && !isEmpty) {
// 獲取空閑構(gòu)建子進程實例
const taskProcess = this.getFreeProcess();
await this.initChild(taskProcess, this.processorFile);
const task = await jobQueue.dequeue();
if (task) {
await this.runningJobMap.set(task.appId, task);
this.startChild(taskProcess, task);
return task;
}
} else {
return Promise.reject(new Error('NoIdleResource'));
}
}
/**
* @func getFreeProcess 獲取空閑構(gòu)建子進程
*/
getFreeProcess() {
if (this.idlePool.length) {
const pid = this.idlePool.shift();
return this.workPool.get(pid);
}
return null;
}
/**
* @func killProcess 殺死某個子進程,原因:釋放構(gòu)建運行時占用的內(nèi)存
* @param pid 進程 pid
*/
killProcess(pid) {
let child = this.workPool.get(pid);
child.disconnect();
child && child.kill();
this.workPool.delete(pid);
child = null;
}
}
Build.ts
import ProcessPool from './ProcessPool';
import TaskMap from './TaskMap';
import * as path from 'path';
// 日志存儲
const runningPackTaskLog = new TaskMap('fap_running_pack_task_log');
//初始化進程池
const packQueue = new ProcessPool({
workerPath: path.join(__dirname, '../../task/func/worker'),
depth: 3
});
// 初始化構(gòu)建文件
packQueue.process(path.join(__dirname, '../../task/func/server-build'));
let key: string;
packQueue.on('message', async data => {
// 根據(jù)項目 id,部署記錄 id,以及用戶 id 來設(shè)定 redis 緩存的 key 值,之后進行日志存儲
key = `${appId}_${deployId}_${deployer.userId}`;
const { cmd, value } = data;
if(cmd === 'log') { // 構(gòu)建任務(wù)日志
runningPackTaskLog.set(key,value);
} else if (cmd === 'finish') { // 構(gòu)建完成
runningPackTaskLog.delete(key);
// 后續(xù)日志可以進行數(shù)據(jù)庫存儲
} else if (cmd === 'fail') { // 構(gòu)建失敗
runningPackTaskLog.delete(key);
// 后續(xù)日志可以進行數(shù)據(jù)庫存儲
}
// 可以通過 websocket 將進度同步給前臺展示
});
//添加新的構(gòu)建任務(wù)
let [ err ] = await to(packQueue.add({
...appAttrs, // 構(gòu)建所需信息
}));
有了進程池處理了多進程構(gòu)建之后,如何記錄每個進程構(gòu)建進度呢,我這邊選擇用了 Redis 數(shù)據(jù)庫進行構(gòu)建進度狀態(tài)的緩存,同時通過Websocket 同步前臺的進度展示,在構(gòu)建完成后,進行日志的本地存儲。上面代碼簡單介紹了進程池的實現(xiàn)以及使用,當然具體的應(yīng)用還要看自己設(shè)計思路了,有了進程池的幫助下,剩下的思路其實就是具體代碼實現(xiàn)了。
前端構(gòu)建的未來
最后來聊聊我們對于前端構(gòu)建未來的一些想法吧,首先前端構(gòu)建必須保證的是更加穩(wěn)定的構(gòu)建,在穩(wěn)定的前提下,來達到更快的構(gòu)建,對于 CI/CD 方向,比如更加完整的構(gòu)建流程,在更新完生成線上環(huán)境以后,自動處理代碼的歸檔,歸檔后最新的 Master 代碼重新合入各個開發(fā)分支,再更新全部的測試環(huán)境等等。
而對于服務(wù)端性能方面,我們考慮過能不能將云端構(gòu)建的能力來靠每臺開發(fā)的電腦來完成,實現(xiàn)本地構(gòu)建,云端部署的離岸云端構(gòu)建,將服務(wù)器壓力分散到各自的電腦上,這樣也能減輕服務(wù)端構(gòu)建的壓力,服務(wù)端只做最后的部署服務(wù)即可。
還有比如我們的開發(fā)同學很想要項目按組的維度進行打包發(fā)布的功能,一次發(fā)布的版本中,選定好要一起更新發(fā)布的項目以及版本分支,統(tǒng)一發(fā)布更新。
小結(jié)
所以有了自己的構(gòu)建發(fā)布平臺,自己想要的功能都可以自己操作起來,可以做前端自己想要的各類功能,豈不是美滋滋。我猜很多同學可能會對我們做的 VsCode 插件感興趣吧,除了構(gòu)建項目,當然還有一些其他的功能,比如公司測試賬號的管理,小程序的快速構(gòu)建等等輔助開發(fā)的功能,是不是想進一步了解這個插件的功能呢,請期待我們之后的分享吧。
參考文檔
node child_process 文檔 (http://nodejs.cn/api/child_process.html#child_process_child_process_fork_modulepath_args_options)
深入理解Node.js 進程與線程 (https://blog.csdn.net/xgangzai/article/details/98919412)
淺析 Node 進程與線程 (https://juejin.cn/post/6844904033640169486?utm_source=weibo&utm_campaign=admin)
內(nèi)推社群
我組建了一個氛圍特別好的騰訊內(nèi)推社群,如果你對加入騰訊感興趣的話(后續(xù)有計劃也可以),我們可以一起進行面試相關(guān)的答疑、聊聊面試的故事、并且在你準備好的時候隨時幫你內(nèi)推。下方加 winty 好友回復(fù)「面試」即可。
