基于 ssh2 的本地包部署方案

基于 ssh2 的本地包部署方案

最近在做项目的时候,经常需要发包到测试环境,由于测试环境没有流水线支撑,所以每次拷贝打包到测试服务器很是麻烦,于是就想弄了基于本地的一键部署工具(基于nodejs开发),通过简单配置即可上传本地包到服务器,既然是想将本增效,那么就需要满足以下需求:

  1. 支持脚本模式,并保留最近三次的部署包(三次之前的包均删除),如:npm run deploy;
  2. 支持灵活配置多套环境,通过指定 系统标示进行部署,如:npm run deploy [系统标示]、或者通过 .envDeploy_Server 节点指定系统标示、或者通过配置文件中 default节点指定的系统标示;
  3. 支持检测本地代码的git状态,如果存在未提交代码,则不允许上传部署,通知支持强制模式,如npm run deploy --force
  4. 支持一键回滚,如npm run deploy --revert
  5. 支持通过npm全局安装此工具,方便多个工程独立配置部署方案,如 npm install -g localdeploy
  6. 支持在项目代码的根目录配置配置文件 deploy.config.js
  7. 在服务器发布目录,记录发布、回退日志,可跟踪查询;

一、整体效果

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: {
            //...
        }
    }
}
工程目录

alt

deploy.config.js配置文件在工程的根目录。

package.json 配置
{
  "scripts": {
    "deploy": "deploy"
  }
}

通过 npm install localdeploy -g全局注册后(这里我在本地通过verdaccio搭建本地私服,实现npm包的管理),可以直接通过deploy命令直接调用发布。

备注:也可以通过源码包引入的方式实现,具体操作见附录1。

部署效果

alt

回退效果

alt

二、整体设计

本工具运行环境的nodejsnpm版本如下:

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"
}

部署流程

  1. 解析执行输入的脚本,判断是部署还是回退(--revert);
  2. 检查工程目录的git状态,判断是否有未提交的代码、给出提示,可以 --force跳过检查;
  3. 读取本地配置文件 deploy.config.js文件,根据服务器标示(sysflag)读取对应的配置文件;
  4. 根据配置文件中的本地包路径,对打好包的目录进行压缩处理;
  5. ssh2链接服务器,并备份配置文件 deploy.config.js中指定的目录;
  6. 将压缩包上传至配置文件 deploy.config.js中指定的目录;
  7. 解压压缩包;
  8. 清理备份,只保留最近三次的发布记录;

sysflag的优先级

sysflag,即deploy.config.jsservers节点的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 --forcenpm run deploy --revertnpm 将此类参数均定义为 npm_config_[参数名字],并可通过 process.env 读取。

// 执行脚本
npm run deploy --force

// 可以通过如下语句解析参数, 此变量结果为布尔类型
process.env.npm_config_force

第二种是直接跟在脚本后面的,如 npm run deploy dev,此类参数可以通过 process.argv来获取,该语句的结果如下:

alt

第三种是读取 .env文件中定义的参数信息,此类参数通过引入 dotenv组件进行读取;

.env文件定义如下:

alt

代码中读取方式如下:

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-clivue.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.exportsexports,ESM规范的导出用的是 export defaultexport,这一块的资料网上有很多,可以根据自己的喜好进行选择。

Node.js 有两个模块规范:CommonJS模块规范 和 ECMAScript模块规范 开发者可以通过 .mjs 文件扩展名、package.json中设置type=modulenode xxx.js --input-type=module 标志告诉 Node.js 使用 ECMAScript规范去执行代码。 如果没这些设置,Node.js 将使用 CommonJS 去执行。Node.js Modules: ECMAScript modules

这里我偏向采用ESM规范来定义我们的配置文件,通过 nodejs加载 ESM规范的模块,需要显式指定模块的规范,有三种方式:

  1. 将文件后缀改为.mjs,node.js加载的时候自动会用ESM规范
  2. 在项目中package.json新增配置项"type":"module",那么整个项目中的.js文件都会按照ESM规范去执行
  3. 增加执行参数--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,每次发包将会配套生成一个新的部署日志文件:

alt

deploy.log文件的信息如下:

alt

该日志文件将一起被压缩到包中,上传到服务器后,在服务器部署目录下汇总一个日志文件,每次新的发布、回退操作日志均追加到该文件中。

生成本地日志文件:

/**
 * 创建本地文件
 * @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");
}

使用时根据需要,指定该行日志是否常驻命令窗口,如果 isMutiLineModefalse或者 undefined,则日志将会在一行覆盖显示:

// 常驻输出
stdout(`部署的工程名:${sysName} (version:${version})\n`, true);
// 覆盖输出
stdout(`创建本地压缩包文件`);

附录

1)本地引入源码实现部署方案

下载localdeploy源码包,放到项目根目录。

alt

配置 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