云构建架构设计
为什么需要云构建
- 减少发布过程中的重复劳动
- 打包构建
- 上传静态资源服务器
- 上传静态CDN
- 避免不同环境造成的差异性、保障依赖版本的一致性
- 提升构建性能
- 对构建过程进行统一管理
整体流程还是非常清晰、在这之前需要了解一些前置知识
- egg 基本使用
- websocket
- redis
- simple-git 使用
实现过程
egg 集成 socket 和 redis
1、安装依赖
yarn add egg-redis egg-socket.io
2、更新配置文件
然后分别在 config 目录下的 config.default 和 plugin 进行配置
// config.default.js
config.io = {
namespace: {
'/': {
connectionMiddleware: ['auth'],
packetMiddleware: ['filter'],
},
},
};
config.redis = {
client: {
port: REDIS_PORT,
host: REDIS_HOST,
password: REDIS_PWD,
db: 0,
},
};
// plugin.js
exports.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/build
const 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 * 60
const DISCONNECT_TIME_OUT = 5 * 1000
const FAILED_CODE = ["prepare failed", "download failed", "install failed"]
function parseMsg(msg) {
const action = msg.data.action
const message = msg.data.paylod.message
return {
action,
message
}
}
class Cloudbuild {
constructor(git, options) {
this.git = git
this.buildCmd = options.buildCmd
this.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 } = socket
log.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/auth
const { 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 实例
```javascript
const 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.1
await 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 install
if (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形式