如何搭建适合本身团队的构建部署平台

本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,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

相关文章
相关标签/搜索