如何搭建适合自己团队的构建部署平台

17,605 阅读16分钟

本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

这是第 108 篇不掺水的原创,想获取更多原创好文,请搜索公众号关注我们吧~ 本文首发于政采云前端博客:如何搭建适合自己团队的构建部署平台

季节.png

前端业界现有的构建部署方案,常用的应该是,Jenkins,Docker,GitHub Actions 这些,而恰巧,我们公司现在就并存了前两种方案,既然已经有了稳定的构建部署方式,为什么还要自己做一套前端自己的构建平台呢?当然不是为了好玩啊,原因听我慢慢分析。

前端构建使用的时候可能会碰到各种各样问题,比如:

  • Eslint 跳过校验——公司里面的前端项目,随着时间的推移,不同阶段,通过新老脚手架创建出来的项目可能风格各异,并且校验规则可能也不一定统一,虽然项目本身可以有着各种的 Eslint,Stylelint 等校验拦截,但阻止不了开发者跳过这些代码校验。
  • npm 版本升级不兼容——对于依赖的 npm 版本必须的一些兼容性校验,如果某些 npm 插件突然升级了不兼容的一些版本,代码上线后就会报错出错,典型的就是各类 IE 兼容。
  • 无法自由添加自己想要的功能——想要优化前端构建的流程,或者方便前端使用的功能优化,但因为依赖运维平台的构建应用,想加点自己的功能需要等别人排期。

而这些问题,如果有了自己的构建平台,这都将不是问题,所以也就有了现在的——云长。

为何起名叫“云长“呢,当然是希望这个平台能像”关云长“一样,一夫当关万夫莫开。那云长又能给我们提供什么样的一些能力呢?

云长能力

构建部署

这当然是必备的基本能力了,云长提供了公司不同前端项目类型,例如 Pampas、React、Vue、Uniapp 等的构建能力。整个流程其实也并不复杂,开始构建后,云长的服务端,获取到要构建的项目名,分支,要部署的环境等信息后,开始进行项目的代码更新,依赖安装,之后代码打包,最后将生成的代码再打包成镜像文件,然后将这份镜像上传到镜像仓库后,并且将项目的一些资源静态文件都可以上传 CDN,方便前端之后的调用,最后调用 K8S 的镜像部署服务,进行镜像按环境的部署,一个线上构建部署的流程也就完成了。

可插拔的构建流程

如果是使用别人的构建平台, 很多前端自己想加入的脚本功能就依赖别人的服务来实现,而如果走云长,则可以提供开放型的接口,让前端可以自由定制自己的插件式服务。

比如这个线上构建打包的过程当中,就可以处理一些前文提到过的问题,痛点,例如:

  • 代码的各类 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