如何搭建適合本身團隊的構建部署平臺

本文已參與好文召集令活動,點擊查看:後端、大前端雙賽道投稿,2萬元獎池等你挑戰! html

這是第 108 篇不摻水的原創,想獲取更多原創好文,請搜索公衆號關注咱們吧~ 本文首發於政採雲前端博客:如何搭建適合本身團隊的構建部署平臺前端

季節.png

前端業界現有的構建部署方案,經常使用的應該是,Jenkins,Docker,GitHub Actions 這些,而恰巧,咱們公司如今就並存了前兩種方案,既然已經有了穩定的構建部署方式,爲何還要本身作一套前端本身的構建平臺呢?固然不是爲了好玩啊,緣由聽我慢慢分析。node

前端構建使用的時候可能會碰到各類各樣問題,好比:git

  • Eslint 跳過校驗——公司裏面的前端項目,隨着時間的推移,不一樣階段,經過新老腳手架建立出來的項目可能風格各異,而且校驗規則可能也不必定統一,雖然項目自己能夠有着各類的 Eslint,Stylelint 等校驗攔截,但阻止不了開發者跳過這些代碼校驗。
  • npm 版本升級不兼容——對於依賴的 npm 版本必須的一些兼容性校驗,若是某些 npm 插件忽然升級了不兼容的一些版本,代碼上線後就會報錯出錯,典型的就是各種 IE 兼容。
  • 沒法自由添加本身想要的功能——想要優化前端構建的流程,或者方便前端使用的功能優化,但由於依賴運維平臺的構建應用,想加點本身的功能須要等別人排期。

而這些問題,若是有了本身的構建平臺,這都將不是問題,因此也就有了如今的——雲長。web

爲什麼起名叫「雲長「呢,固然是但願這個平臺能像」關雲長「同樣,一夫當關萬夫莫開。那雲長又能給咱們提供什麼樣的一些能力呢?redis

雲長能力

構建部署

這固然是必備的基本能力了,雲長提供了公司不一樣前端項目類型,例如 Pampas、React、Vue、Uniapp 等的構建能力。整個流程其實也並不複雜,開始構建後,雲長的服務端,獲取到要構建的項目名,分支,要部署的環境等信息後,開始進行項目的代碼更新,依賴安裝,以後代碼打包,最後將生成的代碼再打包成鏡像文件,而後將這份鏡像上傳到鏡像倉庫後,而且將項目的一些資源靜態文件均可以上傳 CDN,方便前端以後的調用,最後調用 K8S 的鏡像部署服務,進行鏡像按環境的部署,一個線上構建部署的流程也就完成了。docker

可插拔的構建流程

若是是使用別人的構建平臺, 不少前端本身想加入的腳本功能就依賴別人的服務來實現,而若是走雲長,則能夠提供開放型的接口,讓前端能夠自由定製本身的插件式服務。typescript

好比這個線上構建打包的過程中,就能夠處理一些前文提到過的問題,痛點,例如:shell

  • 代碼的各種 Eslint、Tslint 等合規性校驗,不再怕被人跳過檢驗步驟。
  • 項目構建前還能夠作 npm 包版本的檢測,防止代碼上線後的兼容性報錯等等。
  • 代碼打包後也能作一些全局性質的前端資源注入,例如埋點,錯誤監控,消息推送等等類型。

審覈發佈流程

公司現有的平臺發佈流程管控靠的是運維的名單維護,每一個項目都會管理一個可發佈人的名單,因此基本項目發版都須要發佈人當晚跟隨進行發佈,而云長爲了解決這個問題,提供了一個審覈流的概念。數據庫

也就是當項目在預發環境測試完成以後,代碼開發者能夠提起一個真線的發佈申請單,以後這個項目的可發佈人會經過釘釘收到一個須要審覈的申請單,能夠經過網頁端,或者釘釘消息直接操做,贊成或者拒絕此次發佈申請,在申請通過贊成後,代碼開發者到了可發佈時間後,就能本身部署項目發佈真線,發佈真線後,後續會爲這個項目建立一個代碼的 Merge Request 請求,方便後續代碼的歸檔整理。

這麼作的好處呢,一方面能夠由前端來進行項目構建發佈的權限管控,讓發佈權限能夠進行收攏,另外一方面也能夠解放了項目發佈者,讓開發者能夠更方便的進行代碼上線,而又開放了項目的發佈。

能力對外輸出

雲長能夠對外輸出一些構建更新的能力,也就讓第三方插件接入構建流程成爲了可能,咱們貼心的爲開發者提供了 VsCode 插件,讓你在開發過程當中能夠進行自由的代碼更新,省去打開網頁進行構建的時間,足不出戶,在編輯器中進行代碼的構建更新,經常使用環境更是提供了一鍵更新的快捷方式,進一步省去中間這些操做時間,這個時候多寫兩行代碼不是更開心嗎。

咱們的 VsCode 插件不只僅提供了雲長的一些構建能力,還有小程序構建,路由查找,等等功能,期待這個插件分享的話,請期待咱們後續的文章哦。

雲長架構

上面講過雲長的構建流程,雲長是依賴於 K8S 提供的一個部署鏡像的能力,雲長的客戶端與服務端都是跑在 Docker 中的服務,因此雲長是採用了Docker In Docker 的設計方案,也就是由 Docker 中的服務來進行一個 Docker 鏡像的打包。

針對代碼的構建,雲長服務端部分引入了進程池的處理,每一個在雲長中構建的項目都是進程池中的一個獨立的實例,都有獨立的打包進程,而打包過程的進度跟進則是靠 Redis 的定時任務查詢來進行,也就實現了雲長多實例並行構建的架構。

雲長客戶端與服務端的接口通訊則是正常的 HTTP 請求和 Websocket 請求,客戶端發起請求後,服務端則經過 MySQL 數據存儲一些應用,用戶,構建信息等數據。

外部的資源交互則是,構建的過程當中也會上傳一些靜態資源還有打包的鏡像到 cdn 和鏡像倉庫,最後則是會調用 K8S 的部署接口進行項目的部署操做。

前端構建的 0-1

上面看過了「雲長」的一些功能介紹,以及「雲長」的架構設計,相信不少朋友也想本身作一個相似於「雲長」的前端構建發佈平臺,那須要怎麼作呢,隨我來看看前端構建平臺主要模塊的設計思路吧。

構建流程

前端構建平臺的主要核心模塊確定是構建打包,構建部署流程能夠分爲如下幾個步驟:

  • 每一次構建開始後,須要保存本次構建的一些信息數據,因此須要建立構建發佈記錄,發佈記錄會存儲本次發佈的發佈信息,例如發佈項目的名稱,分支,commitId,commit 信息,操做人數據,須要更新的發佈環境等,這時咱們會須要一張構建發佈記錄表,而若是你須要項目以及操做人的一些數據,你就又須要應用表以及用戶表來存儲相關數據進行關聯。
  • 構建發佈記錄建立之後,開始了前端構建流程,構建流程能夠 pipeline 的流程來進行,流程能夠參考如下例子
// 構建的流程
  async run() {
    const app = this.app;
    const processData = {};
    const pipeline = [{
      handler: context => app.fetchUpdate(context), // Git 更新代碼
      name: 'codeUpdate',
      progress: 10 // 這裏是當前構建的進度
    }, {
      handler: context => app.installDependency(context), // npm install 安裝依賴
      name: 'dependency',
      progress: 30
    }, {
      handler: context => app.check(context), // 構建的前置校驗(非必須):代碼檢測,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), // 構建的後置步驟(非必須):打包後的資源注入
      name: 'injectRes',
      progress: 80
    }, { // docker image build
      handler: context => app.buildImage(context), // 生成 docker 鏡像文件,鏡像上傳倉庫,以及以後調用 K8S 能力進行部署
      name: 'buildImage',
      progress: 90
    }];
    // 循環執行每一步構建流程
    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);
  }
  // 執行構建中的 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;
  }
複製代碼
  • 構建的步驟,上面構建的一些流程,相比你們也想知道在服務端如何跑構建流程當中的一些腳本,其實思路就是經過 nodechild_process 模塊執行 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);
// 執行 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;
    });
  });
};
複製代碼
  • 而例如咱們想在構建前想加入 Eslint 校驗操做,也能夠在構建流程中加入,也就能夠在線上構建的環節中加入攔截型的校驗,控制上線構建代碼質量。
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 ];
};
複製代碼
  • 構建部署完成後,可根據構建狀況,來更新這條構建記錄的更新狀態信息,本次構建生成的 Docker 鏡像,上傳鏡像倉庫後,也須要信息記錄,方便後期可用以前構建的鏡像再次進行更新或者回滾操做,因此須要添加一張鏡像表,下面爲 Docker 鏡像生成的一些實例代碼。
import Docker = require('dockerode');
// 保證服務端中有一個基本的 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);
複製代碼
  • 每一次的構建須要保存一些構建進度,日誌等信息,能夠再加一張日誌表來進行日誌的保存。

多個構建實例的運行

到這裏一個項目的構建流程就已經成功跑通了,但一個構建平臺確定不能每次只能構建更新一個項目啊,因此這時候能夠引入一個進程池,讓你的構建平臺能夠同時構建多個項目。

Node 是單線程模型,當須要執行多個獨立且耗時任務的時候,只能經過 child_process 來分發任務,提升處理速度,因此也須要實現一個進程池,用來控制多構建進程運行的問題,進程池思路是主進程建立任務隊列,控制子進程數量,當子進程完成任務後,經過進程的任務隊列,來繼續添加新的子進程,以此來控制併發進程的運行,流程實現以下。

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; // 最大的實例進程數量
    this.workerPath = options.workerPath;
    this.idlePool = []; // 工做進程 pid 數組
    this.workPool = new Map();  // 工做實例進程池
    this.init();
  }
  /** * @func init 初始化進程, */
  init() {
    while (this.workPool.size < this.depth) {
      this.forkProcess();
    }
  }
  /** * @func forkProcess fork 子進程,建立任務實例 */
  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;
      // 根據 cmd 狀態 返回日誌狀態或者結束後清理掉任務隊列
      if (cmd === 'log') {
      }
      if (cmd === 'finish' || cmd === 'fail') {
        this.killProcess();//結束後清除任務
      }
    });
    worker.on('exit', () => {
      // 結束後,清理實例隊列,開啓下一個任務
      this.workPool.delete(pid);
      worker = null;
      this.forkProcess();
      this.startNextJob();
    });
    return worker;
  }
  // 根據任務隊列,獲取下一個要進行的實例,開始任務
  async startNextJob() {
    this.run();
  }
  /** * @func add 添加構建任務 * @param task 運行的構建程序 */
  async add(task) {
    const inJobQueue = await this.jobQueue.isInQueue(task.appId); // 任務隊列
    const isRunningTask = await this.runningJobMap.has(task.appId); // 正在運行的任務
    const existed = inJobQueue || isRunningTask;
    if (!existed) {
      const len = await this.jobQueue.enqueue(task, task.appId);
      // 執行任務
      const [err] = await to(this.run());
      if (err) {
        return Promise.reject(err);
      }
    } else {
      return Promise.reject(new Error('DuplicateTask'));
    }
  }
  /** * @func initChild 開始構建任務 * @param child 子進程引用 * @param processFile 運行的構建程序文件 */
  initChild(child, processFile) {
    return new Promise(resolve => {
      child.send({ cmd: 'init', value: processFile }, resolve);
    });
  }
  /** * @func startChild 開始構建任務 * @param child 子進程引用 * @param task 構建任務 */
  startChild(child, task) {
    child.send({ cmd: 'start', task });
  }
  /** * @func run 開始隊列任務運行 */
  async run() {
    const jobQueue = this.jobQueue;
    const isEmpty = await jobQueue.isEmpty();
    // 有空閒資源而且任務隊列不爲空
    if (this.idlePool.length > 0 && !isEmpty) {
      // 獲取空閒構建子進程實例
      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 獲取空閒構建子進程 */
  getFreeProcess() {
    if (this.idlePool.length) {
      const pid = this.idlePool.shift();
      return this.workPool.get(pid);
    }
    return null;
  }
  
  /** * @func killProcess 殺死某個子進程,緣由:釋放構建運行時佔用的內存 * @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
});
// 初始化構建文件
packQueue.process(path.join(__dirname, '../../task/func/server-build'));
let key: string;
packQueue.on('message', async data => {
  // 根據項目 id,部署記錄 id,以及用戶 id 來設定 redis 緩存的 key 值,以後進行日誌存儲
  key = `${appId}_${deployId}_${deployer.userId}`;
  const { cmd, value } = data;
  if(cmd === 'log') { // 構建任務日誌
    runningPackTaskLog.set(key,value);
  } else if (cmd === 'finish') { // 構建完成
    runningPackTaskLog.delete(key);
    // 後續日誌能夠進行數據庫存儲
  } else if (cmd === 'fail') { // 構建失敗
    runningPackTaskLog.delete(key);
    // 後續日誌能夠進行數據庫存儲
  }
  // 能夠經過 websocket 將進度同步給前臺展現
});
//添加新的構建任務
let [ err ] = await to(packQueue.add({
  ...appAttrs, // 構建所需信息
}));
複製代碼

有了進程池處理了多進程構建以後,如何記錄每一個進程構建進度呢,我這邊選擇用了 Redis 數據庫進行構建進度狀態的緩存,同時經過Websocket 同步前臺的進度展現,在構建完成後,進行日誌的本地存儲。 上面代碼簡單介紹了進程池的實現以及使用,固然具體的應用還要看本身設計思路了,有了進程池的幫助下,剩下的思路其實就是具體代碼實現了。

前端構建的將來

最後來聊聊咱們對於前端構建將來的一些想法吧,首先前端構建必須保證的是更加穩定的構建,在穩定的前提下,來達到更快的構建,對於 CI/CD 方向,好比更加完整的構建流暢,在更新完生成線上環境之後,自動處理代碼的歸檔,歸檔後最新的 Master 代碼從新合入各個開發分支,再更新所有的測試環境等等。

而對於服務端性能方面,咱們考慮過能不能將雲端構建的能力來靠每臺開發的電腦來完成,實現本地構建,雲端部署的離岸雲端構建,將服務器壓力分散到各自的電腦上,這樣也能減輕服務端構建的壓力,服務端只作最後的部署服務便可。

還有好比咱們的開發同窗很想要項目按組的維度進行打包發佈的功能,一次發佈的版本中,選定好要一塊兒更新發布的項目以及版本分支,統一發布更新。

小結

因此有了本身的構建發佈平臺,本身想要的功能均可以本身操做起來,能夠作前端本身想要的各種功能,豈不是美滋滋。我猜不少同窗可能會對咱們作的 VsCode 插件感興趣吧,除了構建項目,固然還有一些其餘的功能,好比公司測試帳號的管理,小程序的快速構建等等輔助開發的功能,是否是想進一步瞭解這個插件的功能呢,請期待咱們以後的分享吧。

參考文檔

node child_process 文檔

深刻理解Node.js 進程與線程

淺析 Node 進程與線程

推薦閱讀

最熟悉的陌生人rc-form

Vite 特性和部分源碼解析

我在工做中是如何使用 git 的

Serverless Custom (Container) Runtime

開源做品

  • 政採雲前端小報

開源地址 www.zoo.team/openweekly/ (小報官網首頁有微信交流羣)

招賢納士

政採雲前端團隊(ZooTeam),一個年輕富有激情和創造力的前端團隊,隸屬於政採雲產品研發部,Base 在風景如畫的杭州。團隊現有 40 餘個前端小夥伴,平均年齡 27 歲,近 3 成是全棧工程師,妥妥的青年風暴團。成員構成既有來自於阿里、網易的「老」兵,也有浙大、中科大、杭電等校的應屆新人。團隊在平常的業務對接以外,還在物料體系、工程平臺、搭建平臺、性能體驗、雲端應用、數據分析及可視化等方向進行技術探索和實戰,推進並落地了一系列的內部技術產品,持續探索前端技術體系的新邊界。

若是你想改變一直被事折騰,但願開始能折騰事;若是你想改變一直被告誡須要多些想法,卻無從破局;若是你想改變你有能力去作成那個結果,卻不須要你;若是你想改變你想作成的事須要一個團隊去支撐,但沒你帶人的位置;若是你想改變既定的節奏,將會是「5 年工做時間 3 年工做經驗」;若是你想改變原本悟性不錯,但老是有那一層窗戶紙的模糊… 若是你相信相信的力量,相信平凡人能成就非凡事,相信能遇到更好的本身。若是你但願參與到隨着業務騰飛的過程,親手推進一個有着深刻的業務理解、完善的技術體系、技術創造價值、影響力外溢的前端團隊的成長曆程,我以爲咱們該聊聊。任什麼時候間,等着你寫點什麼,發給 ZooTeam@cai-inc.com

相關文章
相關標籤/搜索