云构建架构设计

为什么需要云构建

  • 减少发布过程中的重复劳动
    • 打包构建
    • 上传静态资源服务器
    • 上传静态CDN
  • 避免不同环境造成的差异性、保障依赖版本的一致性
  • 提升构建性能
  • 对构建过程进行统一管理
    • 发布前代码统一规则检查,解决大量安全隐患或者性能瓶颈
    • 封网日统一发布卡口
    • 记录发布次数

      云构建架构图

      脚手架发布架构图.png

整体流程还是非常清晰、在这之前需要了解一些前置知识

  • egg 基本使用
  • websocket
  • redis
  • simple-git 使用

实现过程

egg 集成 socket 和 redis

1、安装依赖

  1. yarn add egg-redis egg-socket.io

2、更新配置文件
然后分别在 config 目录下的 config.default plugin 进行配置

  1. // config.default.js
  2. config.io = {
  3. namespace: {
  4. '/': {
  5. connectionMiddleware: ['auth'],
  6. packetMiddleware: ['filter'],
  7. },
  8. },
  9. };
  10. config.redis = {
  11. client: {
  12. port: REDIS_PORT,
  13. host: REDIS_HOST,
  14. password: REDIS_PWD,
  15. db: 0,
  16. },
  17. };
  1. // plugin.js
  2. exports.io = {
  3. enable: true,
  4. package: 'egg-socket.io',
  5. };
  6. exports.redis = {
  7. enable: true,
  8. package: 'egg-redis',
  9. };

3、修改路由

  1. module.exports = app => {
  2. // app.io.of('/')
  3. app.io.route('build', app.io.controller.build.index);
  4. };

4、开发 middleware

  1. // app/io/middleware/auth.js
  2. 'use strict';
  3. module.exports = () => {
  4. return async (ctx, next) => {
  5. ctx.socket.emit('res', 'auth!');
  6. await next();
  7. console.log('disconnect!');
  8. };
  9. };

5、开发 controller

  1. // app/io/controller/build.js
  2. 'use strict';
  3. module.exports = app => {
  4. class Controller extends app.Controller {
  5. async index() {
  6. const message = this.ctx.args[0];
  7. console.log('chat :', message + ' : ' + process.pid);
  8. this.ctx.socket.emit('res', "server websocket");
  9. }
  10. }
  11. return Controller;
  12. };

ok、然后启动 egg 服务

客户端集成 websocket

客户端需要安装对应的 socket.io

  1. yarn add socket.io-client
  1. // or http://127.0.0.1:7001/build
  2. const socket = require('socket.io-client')('http://127.0.0.1:7001');
  3. socket.on('connect', () => {
  4. console.log('connect!');
  5. socket.emit('chat', 'hello world!');
  6. });
  7. socket.on('res', msg => {
  8. console.log('res from server: %s!', msg);
  9. });

这下基本的通信就算接入了、下面开始进入正式流程。

实例化CloudBuild 类

按照上面架构图、CloudBuild 类 需要做 初始化 websocket 连接和执行云构建命令、代码如下

  1. 'use strict';
  2. const io = require("socket.io-client")
  3. const log = require("@aotu-cli/log")
  4. const WS_SERVER = "ws://127.0.0.1:7001"
  5. const TIME_OUT = 5 * 60
  6. const DISCONNECT_TIME_OUT = 5 * 1000
  7. const FAILED_CODE = ["prepare failed", "download failed", "install failed"]
  8. function parseMsg(msg) {
  9. const action = msg.data.action
  10. const message = msg.data.paylod.message
  11. return {
  12. action,
  13. message
  14. }
  15. }
  16. class Cloudbuild {
  17. constructor(git, options) {
  18. this.git = git
  19. this.buildCmd = options.buildCmd
  20. this.timeout = TIME_OUT
  21. }
  22. doTimeout(fn, timeout) {
  23. this.timer && clearTimeout(this.timer)
  24. log.info(`设置任务超时时间:${timeout / 1000}秒`);
  25. this.timer = setTimeout(fn, timeout);
  26. }
  27. init() {
  28. return new Promise((resolve, reject) => {
  29. const socket = io(WS_SERVER, {
  30. query: {
  31. repo: this.git.remote,
  32. name: this.git.name,
  33. branch: this.git.branch,
  34. version: this.git.version,
  35. buildCmd: this.buildCmd
  36. }
  37. });
  38. this.socket = socket;
  39. socket.on("connect", () => {
  40. clearTimeout(this.timer)
  41. const { id } = socket
  42. log.success(`云构建任务创建成功、任务ID${id}`)
  43. socket.on(id, (msg) => {
  44. const { action, message } = parseMsg(msg);
  45. log.success(action, message)
  46. })
  47. resolve()
  48. })
  49. socket.on("disconnect", () => {
  50. log.success("disconnect", "云构建任务断开");
  51. disconnect();
  52. })
  53. socket.on("error", (err) => {
  54. log.error("error", "云构建出错", err);
  55. disconnect();
  56. reject()
  57. })
  58. const disconnect = () => {
  59. clearTimeout(this.timer);
  60. socket.disconnect();
  61. socket.close();
  62. }
  63. this.doTimeout(() => {
  64. log.error("云构建服务器连接超时、自动终止😭");
  65. disconnect()
  66. }, DISCONNECT_TIME_OUT)
  67. })
  68. }
  69. build() {
  70. return new Promise((resolve, reject) => {
  71. this.socket.emit("build");
  72. this.socket.on("build", (msg) => {
  73. const { action, message } = parseMsg(msg);
  74. if (FAILED_CODE.indexOf(action) >= 0) {
  75. log.error(action, message)
  76. clearTimeout(this.timer);
  77. this.socket.disconnect();
  78. this.socket.close();
  79. } else {
  80. log.success(action, message)
  81. }
  82. });
  83. this.socket.on("building", (msg) => {
  84. console.log(msg);
  85. });
  86. })
  87. }
  88. }
  89. module.exports = Cloudbuild;

对上面代码进行一下分析、我们主要做了 initbuild

init 看代码很简单、主要对 socket 的各种情况进行了监控、并对超时进行了自动断开。其中封装了 parseMsg 方法对日志进行了统一格式处理

注意:日志的统一、清晰方便我们后面排查问题

build 方法则更加简单、主要就是往 socket 服务发送一个请求、告诉服务端开始执行构建命令、下面回到服务端开发

云构建任务写入 redis

上面客户端传来 query 信息、现在我们把他写入到 redis

  1. // app/io/middleware/auth
  2. const { REDIS_PREFIX } = require('../../const');
  3. module.exports = () => {
  4. return async (ctx, next) => {
  5. const { socket, logger, helper, app } = ctx;
  6. const { id } = socket;
  7. const { redis } = app;
  8. const query = socket.handshake.query;
  9. try {
  10. socket.emit(id, helper.parseMsg('connect', {
  11. type: 'connect',
  12. message: '云构建服务连接成功!',
  13. }));
  14. let hasTask = await redis.get(`${REDIS_PREFIX}:${id}`);
  15. if (!hasTask) {
  16. await redis.set(`${REDIS_PREFIX}:${id}`, JSON.stringify(query));
  17. }
  18. hasTask = await redis.get(`${REDIS_PREFIX}:${id}`);
  19. await next();
  20. console.log('disconnect');
  21. } catch (error) {
  22. logger.error('build error', error.message);
  23. }
  24. };
  25. };

我们根据每次 socket 通信产生的唯一 id 把信息写入到 redis

创建 CloudBuildTask

客户端在初始化 init 完后、会发起一起 build 命令、来到 controller、首先实例化 CloudBuildTask

  1. module.exports = app => {
  2. class Controller extends app.Controller {
  3. async index() {
  4. const { ctx, app } = this;
  5. const { socket, helper } = ctx;
  6. const cloudBuildTask = await createCloudBuildTask(ctx, app);
  7. }
  8. }
  9. return Controller;
  10. };
  11. async function createCloudBuildTask(ctx, app) {
  12. const { socket, helper } = ctx;
  13. const { id } = socket;
  14. const redisKey = `${REDIS_PREFIX}:${id}`;
  15. const redisTask = await app.redis.get(redisKey);
  16. const task = JSON.parse(redisTask);
  17. socket.emit('build', helper.parseMsg('create task', {
  18. message: '创建云构建任务',
  19. }));
  20. return new CloudBuildTask(task, ctx);
  21. }

CloudBuildTask 类有以下方法:

  • prepare
  • download
  • install
  • build ```javascript class CloudBuildTask { constructor({ repo, name, branch, version, buildCmd }, ctx) {

    1. this._logger = ctx.logger;
    2. this._socket = ctx.socket;
    3. this._repo = repo; // 源码仓库
    4. this._name = name; // 项目名称
    5. this._branch = branch; // 分支号
    6. this._version = version; // 项目版本号
    7. this._buildCmd = buildCmd; // 构建命令
    8. this._dir = path.resolve(userhome, '.aotu-cli', 'cloudBuild', `${name}@${version}`); // 缓存目录
    9. this._sourceCodeDir = path.resolve(this._dir, name); // 缓存源码目录
    10. this._logger.info('_dir', this._dir);
    11. this._logger.info('_sourceCodeDir', this._sourceCodeDir);

    }

    async prepare() {

    }

    async download() {

    }

    async install() {

    }

    async build() {

    }

    success(message, data) {

    1. return this.response(SUCCESS, message, data);

    }

    failed(message, data) {

    1. return this.response(FAILED, message, data);

    }

    response(code, message, data) {

    1. return {
    2. code,
    3. message,
    4. data,
    5. };

    } }

  1. <a name="WBlTu"></a>
  2. #### prepare
  3. 主要检测代码目录是否存在、并创建 Git 实例
  4. ```javascript
  5. const Git = require('simple-git');
  6. async prepare() {
  7. fse.ensureDirSync(this._dir);
  8. fse.emptyDirSync(this._dir);
  9. this._git = new Git(this._dir);
  10. return this.success();
  11. }

然后在 controller 调用 CloudBuildTask 的 prepare 方法如下:

  1. class Controller extends app.Controller {
  2. async index() {
  3. const { ctx, app } = this;
  4. const { socket, helper } = ctx;
  5. const cloudBuildTask = await createCloudBuildTask(ctx, app);
  6. try {
  7. await prepare(cloudBuildTask, socket, helper);
  8. // ...
  9. } catch (error) {
  10. socket.emit('build', helper.parseMsg('error', {
  11. message: '云构建失败、原因:' + error.message,
  12. }));
  13. socket.disconnect();
  14. }
  15. }
  16. }

prepare 具体方法如下

  1. async function prepare(cloudBuildTask, socket, helper) {
  2. socket.emit('build', helper.parseMsg('prepare', {
  3. message: '开始执行构建前的准备工作',
  4. }));
  5. const prepareRes = await cloudBuildTask.prepare();
  6. if (!prepareRes || prepareRes.code === FAILED) {
  7. socket.emit('build', helper.parseMsg('prepare failed', {
  8. message: '执行构建前准备工作失败,失败原因:' + (prepareRes ? prepareRes.message : '未知'),
  9. }));
  10. return;
  11. }
  12. socket.emit('build', helper.parseMsg('prepare', {
  13. message: '构建前准备工作成功',
  14. }));
  15. }

可能会疑问🤔️、为什么需要再去独立一个方法 prepare、直接去调用 cloudBuildTask.prepare 的方法不就好了吗?还记得上面的那句吗?

注意:日志的统一、清晰方便我们后面排查问题

这样我们给每一步操作都反馈给客户端、那样客户端就能清楚的知道我们目前操作在哪一步、清楚明白。

download

上面检查完后、我们就可以进行源码下载并切换到对应分支、用到我们之前实例化的 git 对象

  1. async download() {
  2. await this._git.clone(this._repo);
  3. // 重置 Git、去到对应的文件目录
  4. this._git = new Git(this._sourceCodeDir);
  5. // git checkout -b dev/1.1.1 origin/dev/1.1.1
  6. await this._git.checkout(['-b', this._branch, `origin/${this._branch}`]);
  7. return fs.existsSync(this._sourceCodeDir) ? this.success() : this.failed();
  8. }

一样、告诉客户端反馈

  1. await download(cloudBuildTask, socket, helper);
  2. async function download(cloudBuildTask, socket, helper) {
  3. socket.emit('build', helper.parseMsg('download repo', {
  4. message: '开始下载源码',
  5. }));
  6. const downloadRes = await cloudBuildTask.download();
  7. if (!downloadRes || downloadRes.code === FAILED) {
  8. socket.emit('build', helper.parseMsg('download failed', {
  9. message: '源码下载失败😭',
  10. }));
  11. return;
  12. }
  13. socket.emit('build', helper.parseMsg('download', {
  14. message: '源码下载成功😄',
  15. }));
  16. }

install

执行安装就简单了

  1. async install() {
  2. const res = await this.execCommand('npm install --registry=https://registry.npm.taobao.org');
  3. return res ? this.success() : this.failed();
  4. }
  5. execCommand(command) {
  6. const commands = command.split(' ');
  7. // npm install
  8. if (commands.length === 0) {
  9. return null;
  10. }
  11. const firstCommand = commands[0];
  12. const leftCommand = commands.slice(1) || [];
  13. return new Promise(resolve => {
  14. const p = exec(firstCommand, leftCommand, {
  15. cwd: this._sourceCodeDir,
  16. }, { stdio: 'pip' });
  17. p.on('error', e => {
  18. this._logger.error('build err', e);
  19. resolve(false);
  20. });
  21. p.on('exit', e => {
  22. this._logger.error('build exit', e);
  23. resolve(true);
  24. });
  25. p.stdout.on('data', data => {
  26. this._socket.emit('building', data.toString());
  27. });
  28. p.stderr.on('data', data => {
  29. this._socket.emit('building', data.toString());
  30. });
  31. });
  32. }
  33. function exec(command, args, options) {
  34. console.log(command, args, options);
  35. const win32 = process.platform === 'win32';
  36. const cmd = win32 ? 'cmd' : command;
  37. const cmdArgs = win32 ? ['/c'].concat(command, args) : args;
  38. return require('child_process').spawn(cmd, cmdArgs, options || {});
  39. }

通过 stdio: ‘pip’ 我们可以看到安装的整个过程、可以把信息输送给客户端进行展示、然后在 controller 进行调用

  1. await install(cloudBuildTask, socket, helper);
  2. async function install(cloudBuildTask, socket, helper) {
  3. socket.emit('build', helper.parseMsg('install repo', {
  4. message: '开始安装依赖',
  5. }));
  6. const installRes = await cloudBuildTask.install();
  7. if (!installRes || installRes.code === FAILED) {
  8. socket.emit('build', helper.parseMsg('install failed', {
  9. message: '安装依赖失败😭',
  10. }));
  11. return;
  12. }
  13. socket.emit('build', helper.parseMsg('install', {
  14. message: '安装依赖成功😄',
  15. }));
  16. }

build

最后执行构建命名

  1. async build() {
  2. let res;
  3. if (checkCommand(this._buildCmd)) {
  4. res = await this.execCommand(this._buildCmd);
  5. } else {
  6. res = false;
  7. }
  8. return res ? this.success() : this.failed();
  9. }
  10. function checkCommand(command) {
  11. const commands = command.split(' ');
  12. // 白名单检测
  13. if (commands.length === 0 || !['npm', 'cnpm', 'yarn'].includes(commands[0])) {
  14. return false;
  15. }
  16. return true;
  17. }

给客户端反馈

  1. await build(cloudBuildTask, socket, helper);
  2. async function build(cloudBuildTask, socket, helper) {
  3. socket.emit('build', helper.parseMsg('build repo', {
  4. message: '开始启动云构建',
  5. }));
  6. const buildRes = await cloudBuildTask.install();
  7. if (!buildRes || buildRes.code === FAILED) {
  8. socket.emit('build', helper.parseMsg('build failed', {
  9. message: '云构建失败😭',
  10. }));
  11. return;
  12. }
  13. socket.emit('build', helper.parseMsg('build', {
  14. message: '云构建成功😄',
  15. }));
  16. }

至此、整个云构建流程完毕。

TODO

  • 记录打包的各种信息、数据量化
  • 是否考虑客户端做成 GUI形式