乐趣区

关于前端:如何搭建适合自己团队的构建部署平台

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

前端业界现有的构建部署计划,罕用的应该是,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 代码从新合入各个开发分支,再更新全副的测试环境等等。

而对于服务端性能方面,咱们思考过能不能将云端构建的能力来靠每台开发的电脑来实现,实现本地构建,云端部署的离岸云端构建,将服务器压力扩散到各自的电脑上,这样也能加重服务端构建的压力,服务端只做最初的部署服务即可。

还有比方咱们的开发同学很想要我的项目按组的维度进行打包公布的性能,一次公布的版本中,选定好要一起更新公布的我的项目以及版本分支,对立公布更新。

小结

开源作品

  • 政采云前端小报

开源地址 www.zoo.team/openweekly/ (小报官网首页有微信交换群)

招贤纳士

政采云前端团队(ZooTeam),一个年老富裕激情和创造力的前端团队,隶属于政采云产品研发部,Base 在风景如画的杭州。团队现有 40 余个前端小伙伴,平均年龄 27 岁,近 3 成是全栈工程师,妥妥的青年风暴团。成员形成既有来自于阿里、网易的“老”兵,也有浙大、中科大、杭电等校的应届新人。团队在日常的业务对接之外,还在物料体系、工程平台、搭建平台、性能体验、云端利用、数据分析及可视化等方向进行技术摸索和实战,推动并落地了一系列的外部技术产品,继续摸索前端技术体系的新边界。

如果你想扭转始终被事折腾,心愿开始能折腾事;如果你想扭转始终被告诫须要多些想法,却无从破局;如果你想扭转你有能力去做成那个后果,却不须要你;如果你想扭转你想做成的事须要一个团队去撑持,但没你带人的地位;如果你想扭转既定的节奏,将会是“5 年工作工夫 3 年工作教训”;如果你想扭转原本悟性不错,但总是有那一层窗户纸的含糊… 如果你置信置信的力量,置信平凡人能成就不凡事,置信能遇到更好的本人。如果你心愿参加到随着业务腾飞的过程,亲手推动一个有着深刻的业务了解、欠缺的技术体系、技术发明价值、影响力外溢的前端团队的成长历程,我感觉咱们该聊聊。任何工夫,等着你写点什么,发给 ZooTeam@cai-inc.com

所以有了本人的构建公布平台,本人想要的性能都能够本人操作起来,能够做前端本人想要的各类性能,岂不是美滋滋。我猜很多同学可能会对咱们做的 VsCode 插件感兴趣吧,除了构建我的项目,当然还有一些其余的性能,比方公司测试账号的治理,小程序的疾速构建等等辅助开发的性能,是不是想进一步理解这个插件的性能呢,请期待咱们之后的分享吧。

参考文档

node child_process 文档

深刻了解 Node.js 过程与线程

浅析 Node 过程与线程

退出移动版