
最近在做项目的时候,经常需要发包到测试环境,由于测试环境没有流水线支撑,所以每次拷贝打包到测试服务器很是麻烦,于是就想弄了基于本地的一键部署工具(基于nodejs开发),通过简单配置即可上传本地包到服务器,既然是想将本增效,那么就需要满足以下需求:
- 支持脚本模式,并保留最近三次的部署包(三次之前的包均删除),如:
npm run deploy
; - 支持灵活配置多套环境,通过指定
系统标示
进行部署,如:npm run deploy [系统标示]
、或者通过.env
中Deploy_Server
节点指定系统标示、或者通过配置文件中default
节点指定的系统标示; - 支持检测本地代码的git状态,如果存在未提交代码,则不允许上传部署,通知支持强制模式,如
npm run deploy --force
; - 支持一键回滚,如
npm run deploy --revert
; - 支持通过
npm
全局安装此工具,方便多个工程独立配置部署方案,如npm install -g localdeploy
; - 支持在项目代码的根目录配置配置文件
deploy.config.js
; - 在服务器发布目录,记录发布、回退日志,可跟踪查询;
一、整体效果
deploy.config.js
文件样例:
export default {
localPath: "./dist",
default: "dev1",
servers: {
dev1: {
remotePath: "/home/web/demo"
server: {
host: "127.0.0.1",
port: 22,
username: "test",
password: "0000"
}
},
dev2: {
//...
}
}
}
工程目录
deploy.config.js
配置文件在工程的根目录。
package.json
配置
{
"scripts": {
"deploy": "deploy"
}
}
通过 npm install localdeploy -g
全局注册后(这里我在本地通过verdaccio搭建本地私服,实现npm包的管理),可以直接通过deploy
命令直接调用发布。
备注:也可以通过源码包引入的方式实现,具体操作见附录1。
部署效果
回退效果
二、整体设计
本工具运行环境的nodejs
和npm
版本如下:
C:> node -v
v16.20.2
C:> npm -v
8.19.4
核心组件:ssh2
// 以下为伪代码,只为了标示核心思路
// 引入ssh2
const ssh2 = require("ssh2");
// 初始化连接客户端实例
const Client = ssh2.Client;
// 创建链接
this.conn = new Client();
// 发起链接
this.conn.on("ready", () => {
//连接就绪
stdout(`连接服务器成功,准备就绪....`);
}).on("error", err => {
stdout("ssh 连接异常:", err);
}).on("close", msg => {
stdout("ssh 连接关闭:", msg);
}).connect(this.server);
//释放链接
this.conn.end();
其中 this.server
即为 deploy.config.js
中定义的 server字段
:
server: {
host: "127.0.0.1",
port: 22,
username: "test",
password: "0000"
}
部署流程:
- 解析执行输入的脚本,判断是部署还是回退(
--revert
); - 检查工程目录的git状态,判断是否有未提交的代码、给出提示,可以
--force
跳过检查; - 读取本地配置文件
deploy.config.js
文件,根据服务器标示(sysflag
)读取对应的配置文件; - 根据配置文件中的本地包路径,对打好包的目录进行压缩处理;
ssh2
链接服务器,并备份配置文件deploy.config.js
中指定的目录;- 将压缩包上传至配置文件
deploy.config.js
中指定的目录; - 解压压缩包;
- 清理备份,只保留最近三次的发布记录;
sysflag
的优先级
sysflag
,即deploy.config.js
中servers
节点的key
值,用来标示发布到哪个服务器,工具将读取该服务器的配置进行发布操作。
优先级顺序:process.argv
> process.env
> deploy.config.js
1.process.argv
:最高优先级,取执行脚本的最后一个参数,如 npm run deploy [sysflag]
2.process.env
:优先级次之,取工程根目录中.env
文件定义的值,如:
Deploy_Server=dev1
3.deploy.config.js
: 最低优先级,即 default
节点定义的值,如:
export default {
...
default: "dev1",
servers: {
dev1: {
//...
},
dev2: {
//...
},
...
}
}
三、关键技术点
此工具的流程细节较多,不能一一列举,此处只对其中的几个主要技术点进行分析:
1. 读取npm
执行脚本的上下文信息
读取脚本参数信息,有四种场景:
第一种是通过 --
修饰的,如 npm run deploy --force
、npm run deploy --revert
,npm
将此类参数均定义为 npm_config_[参数名字]
,并可通过 process.env
读取。
// 执行脚本
npm run deploy --force
// 可以通过如下语句解析参数, 此变量结果为布尔类型
process.env.npm_config_force
第二种是直接跟在脚本后面的,如 npm run deploy dev
,此类参数可以通过 process.argv
来获取,该语句的结果如下:
第三种是读取 .env
文件中定义的参数信息,此类参数通过引入 dotenv
组件进行读取;
.env
文件定义如下:
代码中读取方式如下:
const dotenv = require('dotenv');
// 读取本地.env文件,可以扩展为根据不同的环境来读取不同的配置信息
dotenv.config();
// 使用时直接用 process.env.[参数名字]读取
process.env.Deploy_Server
第四种是读取 package.json
文件中定义的信息,此类参数通过引入 process.env
组件进行读取;
// 读取package.json中的name节点值
const sysName = process.env.npm_package_name;
// 读取package.json中的version节点值
const version = process.env.npm_package_version;
2. Git 信息提取
本地工程目录的Git
信息主要通过 child_process
模块来读取,其有两个可以执行git
命令的函数:
const child_process = require('child_process');
// 同步读取
const result = child_process.execSync([cmd],""), {
'cwd': process.cwd(),
'encoding': 'utf-8'
});
// 异步读取
child_process.exec([cmd], {
'encoding': 'utf-8',
'cwd': process.cwd()
}, (error, stdout, stderr) => {
if(error || stderr) {
// TODO
return;
};
if(stdout) {
// TODO
};
});
在工具中,需要判断工作区是否有未提交内容,需要得到当前 git 分支名字、最后一次提交的commitId、用户和邮箱,分别执行以下命令获得:
// 获取当前分支是否有未提交的内容
git status -s
// 获取当前 git 分支名字
git name-rev --name-only HEAD
// 获取最后一次提交的commitId
git rev-parse HEAD
// 获取名字和邮箱
git config --get user.name
git config --get user.email
这些命令可以批量执行,只需要在中间添加 &&
即可,代码如下:
function getGitInfos() {
const cmd = `git name-rev --name-only HEAD &&
git rev-parse HEAD &&
git config --get user.name &&
git config --get user.email
`;
try {
return child_process.execSync(cmd.replace(/\n/gm,""), {
'cwd': process.cwd(),
'encoding': 'utf-8'
}).split("\n");
} catch (e) {
// 静默处理异常
stdout("warn: can't get git information.", true);
return [];
}
}
判断工作区是否有未提交内容,--force
场景下直接跳过。
function checkWorkSpace(isForceMode) {
return new Promise((resolve, reject) => {
isForceMode ? resolve() : child_process.exec("git status -s", { 'encoding': 'utf-8', 'cwd': process.cwd() }, (error, stdout, stderr) => {
if(error || stderr) reject(error || stderr);
if(stdout) reject("error: 当前工作区仍有未提交或者未纳入版本控制的文件,请确保工作干净后再开始部署");
resolve();
});
});
}
3. 读取本地配置文件 deploy.config.js
这是大多数命令行工具包都实现的部分,如 vue-cli
的 vue.config.js
,主要依据 npm
脚本执行的上下文环境进行获取,当我们在工程目录执行 npm run [cmd]
时,当前工程目录就是脚本执行的根目录,可以通过如下方式获取配置文件的路径:
const path = require('path');
const configFilePath = path.resolve(process.cwd(), 'deploy.config.js');
这里有个需要注意的点,当配置 vue.config.js
文件时,我们发现这个配置文件采用的是 commonjs
规范,这是因为nodejs
默认支持的就是 commonjs
规范,不过 nodejs
当前版本已经支持 ESM 模块的加载,不同规范的加载方式如下:
// Commonjs模块加载,通过require的方式
const _ = require('loadsh');
// ESM 模块加载,通过import的方式
import _ from "loadsh";
这要看你打算怎么定义你的配置文件规范,不同的规范,导出的方式不同,CommonJs
规范的导出用的是 module.exports
、exports
,ESM规范的导出用的是 export default
、export
,这一块的资料网上有很多,可以根据自己的喜好进行选择。
Node.js 有两个模块规范:
CommonJS
模块规范 和ECMAScript
模块规范 开发者可以通过.mjs
文件扩展名、package.json
中设置type=module
或node xxx.js --input-type=module
标志告诉 Node.js 使用ECMAScript
规范去执行代码。 如果没这些设置,Node.js 将使用CommonJS
去执行。Node.js Modules: ECMAScript modules
这里我偏向采用ESM
规范来定义我们的配置文件,通过 nodejs
加载 ESM
规范的模块,需要显式指定模块的规范,有三种方式:
- 将文件后缀改为
.mjs
,node.js加载的时候自动会用ESM
规范 - 在项目中
package.json
新增配置项"type":"module"
,那么整个项目中的.js文件都会按照ESM
规范去执行 - 增加执行参数
--input-type
也可以实现相同效果
其中第一种方式比较符合我们的预期,第二、第三两种方式不适用我们当前的场景(我们需要的是动态加载),代码如下:
const path = require('path');
// 将我们的配置文件后缀名修改为 .mjs,告诉
const configFilePath = path.resolve(process.cwd(), 'deploy.config.mjs');
// 定义动态加载函数
const dynamicImport = new Function('file', 'return import(file)');
//实现动态加载
(async function() {
const config = await dynamicImport(configFilePath);
DeployTools.init(config).start();
})();
还有一个问题,如果我不想修改配置文件的后缀名,还想用js
,有没有办法? 有,具体见另外一篇文章,如何在 npm
模块中优雅的加载配置文件
4. 远程执行服务器shell命令
ssh2
有很多很强大的功能,其中执行远程服务器 shell
脚本便是其中之一,封装shell脚本执行函数如下:
/**
* 执行远程linux命令
* @param cmd - 命令正文
* @param callback - 回调函数
*/
exec: function (cmd, callback) {
this.conn.exec(cmd, function (err, stream) {
var data = "";
stream.pipe(through(function onWrite(buf) {
data = data + buf;
}, function onEnd() {
stream.unpipe();
}));
stream.on("close", function () {
//console.log("执行命令:", cmd);
//触发回调
if (callback) callback(null, "" + data);
});
});
},
其中 cmd
命令便是linux shell
脚本,既可以执行单行脚本,也可以通过 sh
命令执行 shell
脚本文件,通过上述函数,远程操控linux
服务器成为了可能,实在是太过强大。
有个地方需要注意,就是服务器端需要开启 ssh服务
,可以通过以下命令查看和开启该服务:
// 查看状态
service ssh status
// 启动
service ssh start
工具中有几个步骤分别用到 exec
的单命令和shell文件的执行:
1) 备份远程服务器发布目录;
backupDir: function(callback) {
return new Promise((resolve, reject) => {
//备份已有的文件
stdout(`开始备份已有的 ${this.remotePath} 目录...`);
const newDir = `${this.remotePath}-${getTimeSlot()}`;
this.exec(`mkdir ${newDir} \n cp -r ${this.remotePath}/* ${newDir}/ \n rm -rf ${this.remotePath}/* \n exit \n`, (err, data) => {
stdout(`备份 ${this.remotePath} 目录成功`);
//开始上传本地文件
stdout("开始上传本地包文件...");
resolve();
});
});
},
2) 清理发布目录,只保留最新发布的三个版本;
清理脚本:clean.sh
#!/bin/bash
dir=$(ls -l ./ |awk '/^d/ {print $NF}'|grep -i $1-)
dirArr=($dir)
dirArrSort=($(
for i in "${dirArr[@]}"
do
echo "$i"
done | sort -r
))
arrLength=${#dirArrSort[*]}
rmDirArr=(${dirArrSort[*]:$2:$((arrLength-$2))})
for i in "${rmDirArr[*]}"
do
rm -rf $i
done
exit
通过 exec
函数 执行脚本:
function cleanBackupDirs(remotePath) {
return new Promise((resolve, reject) => {
// 获取参数
const reservedNum = 3;// 3,保留备份的目录个数,后续可以扩展npm cooked参数,TODO
const shellFileName = "clean.sh";
// 上传sh到服务器
const shRPath = remotePath.substring(0, remotePath.lastIndexOf("/"));
const shLPath = path.join(__dirname, "../", "localdeploy");
// 组装参数
const shParams = {
prefix: remotePath.split("/").pop(),
reservedNum
}
ssh2Tool.cleanBackupDirs(shLPath, shRPath, shellFileName, shParams, err => {
if (err) {
reject()
} else {
resolve();
}
});
});
}
cleanBackupDirs: function(shLPath, shRPath, fileName, {prefix, reservedNum}, callback) {
stdout("正在清理backup...");
//开始上传子目录的命令文件
this.uploadFile(`${shLPath}\\${fileName}`, `${shRPath}/${fileName}`, (err, result) => {
if (err) throw err;
//开始执行
this.exec(`cd ${shRPath}\n chmod u+x ${fileName} \n sh ${fileName} ${prefix} ${reservedNum}\n rm -rf ${fileName}\n exit \n`, (err, data) => {
stdout("清理完成");
stdout("");
callback && callback();
});
});
}
备注:执行 .sh
脚本文件的步骤为,先上传,再执行,执行前需要 chmod u+x [filename]
提升执行权限;
3) 回退上一个发布的版本;
回退脚本 revert.sh
:
#!/bin/bash
dir=$(ls -l ./ |awk '/^d/ {print $NF}'|grep -i $1-)
dirArr=($dir)
dirArrSort=($(
for i in "${dirArr[@]}"
do
echo "$i"
done | sort -r
))
targetDir="${dirArrSort[$2]}"
rm -rf $1/*
cp -r $targetDir/* $1/
rm -rf $targetDir
exit
其执行思路同上述的清理步骤。
5. 上传本地包到远程服务器
ssh2
的上传思路,主要是借助 this.conn.sftp
进行上传, 以单个文件上传为例:
/**
* 上传文件到服务器
* @param localPath - 本地文件路径
* @param remotePath - 远程文件路径
* @param callback - 回调函数
*/
uploadFile: function (localPath, remotePath, callback) {
this.conn.sftp(function (err, sftp) {
if(err){
callback(err);
} else {
sftp.fastPut(localPath, remotePath, function (err, result) {
sftp.end();
callback(err, result);
});
}
});
},
上传本地包有两种方式,第一种是遍历包目录,一个文件一个文件的上传;第二种是将本地包压缩成一个 zip
包,上传后在服务器端进行解压。此工具经过测试后决定用第二种方式,下面分别介绍这两种上传方案的思路:
遍历目录上传
主要思路是,先遍历本地包目录,找到所有的目录和文件,分别记录目录路径形成目录列表 dirs
、文件路径形成文件列表 files
,然后遍历 dirs
生成创建目录的 shell
脚本,命名为 tmp_[timeslot].sh
。遍历 files
,结束上传函数 uploadFile
形成上传单个文件的回调函数列表 rFileCmdArr
。先上传创建目录的 shell
脚本在服务器端执行后,创建目录结构,然后逐一调用单个文件的上传回调函数(rFileCmdArr
存储的),实现文件上传:
/**
* 上传本地文件夹到远程linux服务器
* @param callback - 回调函数
*/
uploadDir: function (callback) {
const dirs = [], files = [];
//获取本地待上传的目录及文件列表
getFileAndDirList(this.localPath, dirs, files);
//创建远程目录
const dirCmdFileName = "tmp_" + (new Date()).getTime() + ".sh";
const fsCmdFile = fs.createWriteStream(dirCmdFileName);
//遍历目录,形成命令文件
dirs.forEach(dir => {
const to = path.join(this.remotePath, dir.substring(this.localPath.length - 1)).replace(/[\\]/g, "/");
const cmd = "mkdir -p \"" + to + "\"\n";
fs.appendFileSync(dirCmdFileName, cmd, "utf8");
});
fsCmdFile.end();
//遍历文件列表,形成执行函数数组
const rFileCmdArr = [];
this.totalFilesCount = files.length;
files.forEach((file, pos) => {
rFileCmdArr.push(done => {
const to = path.join(this.remotePath, file.substring(this.localPath.length - 1)).replace(/[\\]/g, '/');
this.uploadFile(file, to, (err, result) => {
if(!err) {
const progress = Math.round((pos + 1) / this.totalFilesCount * 100) + '%';
stdout(`[${progress}] upload ${file} to ${to}`);
}
done(err, result);
});
});
});
//创建根目录
this.exec("mkdir -p " + this.remotePath + " \n exit \n", (err, data) => {
stdout("在服务器上创建根目录成功。");
//开始上传子目录的命令文件
this.uploadFile(dirCmdFileName, this.remotePath + "/" + dirCmdFileName, (err, result) => {
//删除本地的命令文件
fs.unlinkSync(dirCmdFileName);
if (err) throw err;
stdout("上传目录命令文件成功。");
//开始执行上传
this.exec("cd " + this.remotePath + "\n sh " + dirCmdFileName + "\n rm -rf " + dirCmdFileName + "\n exit \n", (err, data) => {
if (err) throw err;
stdout("创建目录结构成功。");
stdout("开始上传文件...");
event.emit("upload", rFileCmdArr, err => {
if (err) {
throw err;
}
if (callback) callback();
});
});
});
});
}
压缩包上传
压缩包上传比较简单,发布包压缩后就成了单个文件上传的场景,只需要调用单个文件上传函数(uploadFile
)即可,主要思路是,先用 compressing
模块对发布包进行压缩处理:
const compressing = require('compressing');
/**
* 发布启动逻辑
*/
function dealPublish ({ localPath, remotePath, server }) {
const distPath = localPath.replace(/\\/gi, '/');
stdout(`部署目标服务器IP:${server.host}\n待部署的本地目录:${distPath}\n部署到服务器目录:${remotePath}\n启动发布流程...\n\n`, true);
// 创建本地压缩包
stdout(`创建本地压缩包文件`);
const zipFileName = `${distPath.split("/").pop()}.zip`;
const zipLPath = distPath.substring(0, distPath.lastIndexOf("/"));
compressing.zip.compressDir(distPath, `${zipLPath}/${zipFileName}`).then(() => {
stdout(`创建本地压缩包文件成功`);
ssh2Tool.startPublish(zipLPath, remotePath, zipFileName, err => {
if(err) {
stdout(`发布失败!!!`, true);
process.exit();
} else {
// 更新发布日志, 清理备份目录
Promise.all([updateDeployLog(remotePath), cleanBackupDirs(remotePath)]).then(() => {}, () => {}).finally(res => {
stdout(`发布成功!!!`, true);
process.exit();
});
}
});
}).catch(err => {
stdout(`创建本地压缩包文件失败`);
});
};
然后执行单个文件上传:
startPublish: function (LPath, RPath, fileName, callback) {
//创建连接
this.connect(() => {
this.backupDir().then(() => {
//开始上传包文件
this.uploadFile(`${LPath}/${fileName}`, `${RPath}/${fileName}`, (err, result) => {
if (err) throw err;
//删除本地的包文件
fs.unlinkSync(`${LPath}/${fileName}`);
stdout("上传本地包文件成功");
stdout("开始解压包文件...");
const fname = fileName.substr(0,fileName.lastIndexOf("."));
//开始执行
this.exec(`cd ${RPath}\n unzip ${fileName} \n mv ./${fname}/* .\n rm -rf ${fname}*\n exit \n`, (err, data) => {
stdout("解压包文件成功...");
callback && callback();
});
});
});
});
}
6. 记录发布、回退日志
以发包为例,在发包部署前,在本地的包目录中生成部署日志文件 deploy.log
,每次发包将会配套生成一个新的部署日志文件:
deploy.log
文件的信息如下:
该日志文件将一起被压缩到包中,上传到服务器后,在服务器部署目录下汇总一个日志文件,每次新的发布、回退操作日志均追加到该文件中。
生成本地日志文件:
/**
* 创建本地文件
* @param {String} dir 本地目录
* @param {String} content 文件内容
* @param {String} fileName 文件名字
* @return {void}
*/
function createLocalFile(dir, content, fileName) {
const localFile = `${dir}\\${fileName}`;
// 先删除,再创建
if (fs.existsSync(localFile)) {
fs.unlinkSync(localFile);
}
const fsCmdFile = fs.createWriteStream(localFile);
// 写入内容
fsCmdFile.write(content, "utf8");
// 关闭写入流
fsCmdFile.end();
}
function injectDeployInfo(sysName, {localPath}) {
// 获取本地git参数
const gitInfos = getGitInfos();
if(gitInfos.length > 0) {
const [branchName, commitID, user, email] = gitInfos;
const msg = `发布人:${user}(${email})\n分支名称:${branchName}\n最后一次提交的 commitID: ${commitID}\n\n`;
stdout(msg, true);
// 注入版本信息
createLocalFile(localPath, `时间:${getTimeSlot()}\n发布工程:${sysName}\n${msg}`, "deploy.log");
}
}
远程服务器中合并日志文件:
function updateDeployLog(remotePath) {
return new Promise((resolve, reject) => {
const deployLogPath = remotePath.substring(0, remotePath.lastIndexOf("/"));
ssh2Tool.exec(`cat ${remotePath}/deploy.log ${deployLogPath}/deploy.log >> ${deployLogPath}/temp.log \n mv ${deployLogPath}/temp.log -f ${deployLogPath}/deploy.log \n exit \n`, (err, data) => {
if (err) {
reject()
} else {
resolve();
}
});
});
}
7. nodejs
的命令行日志输出格式化(readline
)
nodejs
命令行日志输出,主要用 readline
模块实现,封装函数如下:
const readline = require('readline');
/**
* 控制台,在同一行输出日志信息
* @param {[string]} str [待输出的日志]
* @param {[boolean]} isMutiLineMode [是否换行输出]
*/
function stdout(str, isMutiLineMode) {
// 删除光标所在行
!isMutiLineMode && readline.clearLine(process.stdout);
// 移动光标到行首
!isMutiLineMode && readline.cursorTo(process.stdout, 0);
// 输出到控制台
if(Object.prototype.toString.call(str) === '[object String]') process.stdout.write(str + "\n");
if(Object.prototype.toString.call(str) === '[object Object]') process.stdout.write(JSON.stringify(str) + "\n");
}
使用时根据需要,指定该行日志是否常驻命令窗口,如果 isMutiLineMode
为 false
或者 undefined
,则日志将会在一行覆盖显示:
// 常驻输出
stdout(`部署的工程名:${sysName} (version:${version})\n`, true);
// 覆盖输出
stdout(`创建本地压缩包文件`);
附录
1)本地引入源码实现部署方案
下载localdeploy
源码包,放到项目根目录。
配置 package.json
执行脚本。
{
"scripts": {
"deploy": "node ./localdeploy/bin.js"
}
}
安装依赖包
npm install esbuild compressing ssh2 dotenv
执行发布命令(–force 可选)
npm run deploy [--force]
、npm run deploy --revert
2)完整代码
github地址:https://github.com/zhangyu-xa/node-componets.git