云构建架构设计
为什么需要云构建
- 减少发布过程中的重复劳动
- 打包构建
- 上传静态资源服务器
- 上传静态CDN
- 避免不同环境造成的差异性、保障依赖版本的一致性
- 提升构建性能
- 对构建过程进行统一管理
整体流程还是非常清晰、在这之前需要了解一些前置知识
- egg 基本使用
- websocket
- redis
- simple-git 使用
实现过程
egg 集成 socket 和 redis
1、安装依赖
yarn add egg-redis egg-socket.io
2、更新配置文件
然后分别在 config 目录下的 config.default 和 plugin 进行配置
// config.default.jsconfig.io = {namespace: {'/': {connectionMiddleware: ['auth'],packetMiddleware: ['filter'],},},};config.redis = {client: {port: REDIS_PORT,host: REDIS_HOST,password: REDIS_PWD,db: 0,},};
// plugin.jsexports.io = {enable: true,package: 'egg-socket.io',};exports.redis = {enable: true,package: 'egg-redis',};
3、修改路由
module.exports = app => {// app.io.of('/')app.io.route('build', app.io.controller.build.index);};
4、开发 middleware
// app/io/middleware/auth.js'use strict';module.exports = () => {return async (ctx, next) => {ctx.socket.emit('res', 'auth!');await next();console.log('disconnect!');};};
5、开发 controller
// app/io/controller/build.js'use strict';module.exports = app => {class Controller extends app.Controller {async index() {const message = this.ctx.args[0];console.log('chat :', message + ' : ' + process.pid);this.ctx.socket.emit('res', "server websocket");}}return Controller;};
客户端集成 websocket
客户端需要安装对应的 socket.io 库
yarn add socket.io-client
// or http://127.0.0.1:7001/buildconst socket = require('socket.io-client')('http://127.0.0.1:7001');socket.on('connect', () => {console.log('connect!');socket.emit('chat', 'hello world!');});socket.on('res', msg => {console.log('res from server: %s!', msg);});
实例化CloudBuild 类
按照上面架构图、CloudBuild 类 需要做 初始化 websocket 连接和执行云构建命令、代码如下
'use strict';const io = require("socket.io-client")const log = require("@aotu-cli/log")const WS_SERVER = "ws://127.0.0.1:7001"const TIME_OUT = 5 * 60const DISCONNECT_TIME_OUT = 5 * 1000const FAILED_CODE = ["prepare failed", "download failed", "install failed"]function parseMsg(msg) {const action = msg.data.actionconst message = msg.data.paylod.messagereturn {action,message}}class Cloudbuild {constructor(git, options) {this.git = gitthis.buildCmd = options.buildCmdthis.timeout = TIME_OUT}doTimeout(fn, timeout) {this.timer && clearTimeout(this.timer)log.info(`设置任务超时时间:${timeout / 1000}秒`);this.timer = setTimeout(fn, timeout);}init() {return new Promise((resolve, reject) => {const socket = io(WS_SERVER, {query: {repo: this.git.remote,name: this.git.name,branch: this.git.branch,version: this.git.version,buildCmd: this.buildCmd}});this.socket = socket;socket.on("connect", () => {clearTimeout(this.timer)const { id } = socketlog.success(`云构建任务创建成功、任务ID:${id}`)socket.on(id, (msg) => {const { action, message } = parseMsg(msg);log.success(action, message)})resolve()})socket.on("disconnect", () => {log.success("disconnect", "云构建任务断开");disconnect();})socket.on("error", (err) => {log.error("error", "云构建出错", err);disconnect();reject()})const disconnect = () => {clearTimeout(this.timer);socket.disconnect();socket.close();}this.doTimeout(() => {log.error("云构建服务器连接超时、自动终止😭");disconnect()}, DISCONNECT_TIME_OUT)})}build() {return new Promise((resolve, reject) => {this.socket.emit("build");this.socket.on("build", (msg) => {const { action, message } = parseMsg(msg);if (FAILED_CODE.indexOf(action) >= 0) {log.error(action, message)clearTimeout(this.timer);this.socket.disconnect();this.socket.close();} else {log.success(action, message)}});this.socket.on("building", (msg) => {console.log(msg);});})}}module.exports = Cloudbuild;
对上面代码进行一下分析、我们主要做了 init 和 build
init 看代码很简单、主要对 socket 的各种情况进行了监控、并对超时进行了自动断开。其中封装了 parseMsg 方法对日志进行了统一格式处理
注意:日志的统一、清晰方便我们后面排查问题
build 方法则更加简单、主要就是往 socket 服务发送一个请求、告诉服务端开始执行构建命令、下面回到服务端开发
云构建任务写入 redis
上面客户端传来 query 信息、现在我们把他写入到 redis
// app/io/middleware/authconst { REDIS_PREFIX } = require('../../const');module.exports = () => {return async (ctx, next) => {const { socket, logger, helper, app } = ctx;const { id } = socket;const { redis } = app;const query = socket.handshake.query;try {socket.emit(id, helper.parseMsg('connect', {type: 'connect',message: '云构建服务连接成功!',}));let hasTask = await redis.get(`${REDIS_PREFIX}:${id}`);if (!hasTask) {await redis.set(`${REDIS_PREFIX}:${id}`, JSON.stringify(query));}hasTask = await redis.get(`${REDIS_PREFIX}:${id}`);await next();console.log('disconnect');} catch (error) {logger.error('build error', error.message);}};};
我们根据每次 socket 通信产生的唯一 id 把信息写入到 redis
创建 CloudBuildTask
客户端在初始化 init 完后、会发起一起 build 命令、来到 controller、首先实例化 CloudBuildTask
module.exports = app => {class Controller extends app.Controller {async index() {const { ctx, app } = this;const { socket, helper } = ctx;const cloudBuildTask = await createCloudBuildTask(ctx, app);}}return Controller;};async function createCloudBuildTask(ctx, app) {const { socket, helper } = ctx;const { id } = socket;const redisKey = `${REDIS_PREFIX}:${id}`;const redisTask = await app.redis.get(redisKey);const task = JSON.parse(redisTask);socket.emit('build', helper.parseMsg('create task', {message: '创建云构建任务',}));return new CloudBuildTask(task, ctx);}
CloudBuildTask 类有以下方法:
- prepare
- download
- install
build ```javascript class CloudBuildTask { constructor({ repo, name, branch, version, buildCmd }, ctx) {
this._logger = ctx.logger;this._socket = ctx.socket;this._repo = repo; // 源码仓库this._name = name; // 项目名称this._branch = branch; // 分支号this._version = version; // 项目版本号this._buildCmd = buildCmd; // 构建命令this._dir = path.resolve(userhome, '.aotu-cli', 'cloudBuild', `${name}@${version}`); // 缓存目录this._sourceCodeDir = path.resolve(this._dir, name); // 缓存源码目录this._logger.info('_dir', this._dir);this._logger.info('_sourceCodeDir', this._sourceCodeDir);
}
async prepare() {
}
async download() {
}
async install() {
}
async build() {
}
success(message, data) {
return this.response(SUCCESS, message, data);
}
failed(message, data) {
return this.response(FAILED, message, data);
}
response(code, message, data) {
return {code,message,data,};
} }
<a name="WBlTu"></a>#### prepare主要检测代码目录是否存在、并创建 Git 实例```javascriptconst Git = require('simple-git');async prepare() {fse.ensureDirSync(this._dir);fse.emptyDirSync(this._dir);this._git = new Git(this._dir);return this.success();}
然后在 controller 调用 CloudBuildTask 的 prepare 方法如下:
class Controller extends app.Controller {async index() {const { ctx, app } = this;const { socket, helper } = ctx;const cloudBuildTask = await createCloudBuildTask(ctx, app);try {await prepare(cloudBuildTask, socket, helper);// ...} catch (error) {socket.emit('build', helper.parseMsg('error', {message: '云构建失败、原因:' + error.message,}));socket.disconnect();}}}
prepare 具体方法如下
async function prepare(cloudBuildTask, socket, helper) {socket.emit('build', helper.parseMsg('prepare', {message: '开始执行构建前的准备工作',}));const prepareRes = await cloudBuildTask.prepare();if (!prepareRes || prepareRes.code === FAILED) {socket.emit('build', helper.parseMsg('prepare failed', {message: '执行构建前准备工作失败,失败原因:' + (prepareRes ? prepareRes.message : '未知'),}));return;}socket.emit('build', helper.parseMsg('prepare', {message: '构建前准备工作成功',}));}
可能会疑问🤔️、为什么需要再去独立一个方法 prepare、直接去调用 cloudBuildTask.prepare 的方法不就好了吗?还记得上面的那句吗?
注意:日志的统一、清晰方便我们后面排查问题
这样我们给每一步操作都反馈给客户端、那样客户端就能清楚的知道我们目前操作在哪一步、清楚明白。
download
上面检查完后、我们就可以进行源码下载并切换到对应分支、用到我们之前实例化的 git 对象
async download() {await this._git.clone(this._repo);// 重置 Git、去到对应的文件目录this._git = new Git(this._sourceCodeDir);// git checkout -b dev/1.1.1 origin/dev/1.1.1await this._git.checkout(['-b', this._branch, `origin/${this._branch}`]);return fs.existsSync(this._sourceCodeDir) ? this.success() : this.failed();}
一样、告诉客户端反馈
await download(cloudBuildTask, socket, helper);async function download(cloudBuildTask, socket, helper) {socket.emit('build', helper.parseMsg('download repo', {message: '开始下载源码',}));const downloadRes = await cloudBuildTask.download();if (!downloadRes || downloadRes.code === FAILED) {socket.emit('build', helper.parseMsg('download failed', {message: '源码下载失败😭',}));return;}socket.emit('build', helper.parseMsg('download', {message: '源码下载成功😄',}));}
install
执行安装就简单了
async install() {const res = await this.execCommand('npm install --registry=https://registry.npm.taobao.org');return res ? this.success() : this.failed();}execCommand(command) {const commands = command.split(' ');// npm installif (commands.length === 0) {return null;}const firstCommand = commands[0];const leftCommand = commands.slice(1) || [];return new Promise(resolve => {const p = exec(firstCommand, leftCommand, {cwd: this._sourceCodeDir,}, { stdio: 'pip' });p.on('error', e => {this._logger.error('build err', e);resolve(false);});p.on('exit', e => {this._logger.error('build exit', e);resolve(true);});p.stdout.on('data', data => {this._socket.emit('building', data.toString());});p.stderr.on('data', data => {this._socket.emit('building', data.toString());});});}function exec(command, args, options) {console.log(command, args, options);const win32 = process.platform === 'win32';const cmd = win32 ? 'cmd' : command;const cmdArgs = win32 ? ['/c'].concat(command, args) : args;return require('child_process').spawn(cmd, cmdArgs, options || {});}
通过 stdio: ‘pip’ 我们可以看到安装的整个过程、可以把信息输送给客户端进行展示、然后在 controller 进行调用
await install(cloudBuildTask, socket, helper);async function install(cloudBuildTask, socket, helper) {socket.emit('build', helper.parseMsg('install repo', {message: '开始安装依赖',}));const installRes = await cloudBuildTask.install();if (!installRes || installRes.code === FAILED) {socket.emit('build', helper.parseMsg('install failed', {message: '安装依赖失败😭',}));return;}socket.emit('build', helper.parseMsg('install', {message: '安装依赖成功😄',}));}
build
最后执行构建命名
async build() {let res;if (checkCommand(this._buildCmd)) {res = await this.execCommand(this._buildCmd);} else {res = false;}return res ? this.success() : this.failed();}function checkCommand(command) {const commands = command.split(' ');// 白名单检测if (commands.length === 0 || !['npm', 'cnpm', 'yarn'].includes(commands[0])) {return false;}return true;}
给客户端反馈
await build(cloudBuildTask, socket, helper);async function build(cloudBuildTask, socket, helper) {socket.emit('build', helper.parseMsg('build repo', {message: '开始启动云构建',}));const buildRes = await cloudBuildTask.install();if (!buildRes || buildRes.code === FAILED) {socket.emit('build', helper.parseMsg('build failed', {message: '云构建失败😭',}));return;}socket.emit('build', helper.parseMsg('build', {message: '云构建成功😄',}));}
至此、整个云构建流程完毕。
TODO
- 记录打包的各种信息、数据量化
- 是否考虑客户端做成 GUI形式

