前言
有些时候,个人或者公司开发服务器并没有jekins持续集成,是手动ssh连接服务器上传项目包,压缩解压备份一套流程下来实在是低效,而且虽然是开发服务器但是有时候还是需要备份包才安心,对旧包备份就更加繁琐了,还容易出错。
如果是小公司没有开发服务器,还在用生产服务器装git更新代码或者手动拷贝的方式,就更需要这种脚本来提高效率啦。
虽然网上有很多类似的文章,但是我相信我的版本是轻量脚本中最完善滴!
话不多说,正文开始
首先写一下我们要实现哪些功能点
1.支持自动打包项目并压缩
2.支持参数可配置方便迁移多项目使用
3.支持密码用户密码+ssh私钥免密码登录服务器
4.支持压缩产物部署并备份旧项目
5.支持项目回滚
6.本地产物部署后自动清理,远程备份文件可按配置自动清理(有效期)
如果恰好你时间很多,可以考虑实现(本人场景用不上):
- 部署日志
- 防止操作冲突
- 回滚支持指定版本
- 多环境配置
使用形态
如图所示,点击命令一键部署,
点击一个按钮或者输入一条命令,实现一键式自动打包-上传-部署/回滚-备份流程
其实本质就是ssh链接服务器,sftp传输文件,相当简单,只不过实际开发完善了许多细节而已。
涉及的库
const fs = require('fs')
const {exists} = require('fs')
const path = require('path')
const archiver = require('archiver') // 压缩插件,其实可以用递归来拷贝文件夹就不用引入了,偷个懒
const { NodeSSH } = require('node-ssh') // 核心库,ssh连接的,就这个是必须的
const sd = require('silly-datetime') // 时间处理的库,其实可以自己处理格式,也是偷懒
const chalk = require('chalk') // 控制台打印带颜色输出,也可以不引入
核心代码实现
先定义一个配置文件config.js,存放服务器ip、项目路径等信息
module.exports = ({
host: 'xxx.xxx.xxx.xx',
username: 'yourName',
password: 'password',
privateKey:'C:\\Users\\kob\\.ssh\\id_rsa',// 本地ssh私钥文件路径,优先级大于密码
port: 22,
backupExpires:3,// 备份时效天为单位,不填或者0默认永不失效,即备份永远不会清理
pathUrl:'/home/bok',// 服务器项目部署路径(一般为dist目录父级)
backupKeyword:'backup',// 备份文件命名关键字(任意字符皆可,默认backup即可)
localPkgPath:'src/dist',
localPkgName:'dist' // 本地打包产物文件夹名(一般为dist,可自定义,不可包含上面backupKeyword字段)
})
核心代码
新建脚本文件
在配置文件同级别新建一个index.js(名字路径随意皆可,引入配置文件注意下就行)
init方法初始化,先对配置文件做一下校验再创建ssh链接(因为很可能配置文件跟脚本是不同的人管理的,校验下比较好,万一有不靠谱的同事把部署路径写错了就完犊子了)
function init() {
checkLocalConfig()
exists(`${config.localPkgName}`, function (exists) {
if (!exists && !isRollback) {
log_break(`config.localPkgName对应产物路径无效`)
}
})
log.gre(`开始执行${isRollback ? '回滚' : '部署'}操作,时间戳:${curTime}\n`)
serverConnect()
}
function checkLocalConfig() {
try {
config = require('./deploy.config_local')
for (let key in config) {
if (!config[key] && key !== 'privateKey') {
log_break(`配置文件不完整,请补充config.${key}\n`)
}
}
log.gre('配置文件校验通过~')
} catch (err) {
log_break(`配置文件缺失或路径有误,请在项目根目录配置deploy.config.js文件\n`)
}
}
校验过后,在init方法中的serverConnect方法发起链接,注意checkServerPakg(),别忘记校验下服务器是否存在目标部署路径
async function serverConnect() {
try {
await ssh.connect({
host: config.host,
username: config.username,
port: config.port,
...config.privateKey ? {privateKey: fs.readFileSync(path.join(config.privateKey), 'utf8')} : {password: config.password}
})
log.gre('ssh连接成功\n')
await checkServerPakg()
// 判断是否要对旧的备份文件做清理做清理
if (config.backupExpires && typeof config.backupExpires === "number") await backupClear()
if (isRollback) {
await remoteFileUpdate()
} else {
compressLocalFile()
}
} catch (err) {
log_break(`SSH连接失败:${err}\n`)
}
}
// 根据配置,对旧的备份文件做清理
const backupClear = async () => {
const configExprires = Math.ceil(config.backupExpires)
const backupArr = await getAllBackup()
// 过滤出备份文件
const arr = backupArr.filter(e => {
return e.includes(config.backupKeyword)
})
// 根据配置计算失效时间,这里以天为粒度
const expriesTime = Number(getTime(-configExprires))
// 找出过期的备份文件名以空格分隔存为字符串
let backupStr = ''
for (const v of arr) {
// 倒序取出当前备份年月日,配置路径变化会导致位置变化不可以
let tempTime = Number(v.slice(-14, -6))
if (tempTime < expriesTime) backupStr += ' ' + v
}
if (backupStr) {
try {
const res = await ssh.execCommand(`rm -f -r ${backupStr}`, {
cwd: `${config.pathUrl}`,
})
if (res.stderr) {
log.red(`${res.stderr}\n`)
} else {
log.yel(`根据配置,以下${configExprires}天前备份已失效被清除:${backupStr}`)
}
} catch (err) {
log_break(err)
}
} else {
log.gre('暂无过期备份需清除')
}
}
回滚是不会走到这里的,这一步属于部署逻辑,这块就是文件包压缩、上传、备份、解压
打包产物压缩
其实也可以递归上传文件夹,可少引入一个压缩库,不过我这里就偷个懒了
const compressLocalFile = () => {
log.gre(`本地打包产物压缩开始\n`)
// 设置本地dist文件绝对路径
const distPath = path.resolve(__dirname, `${config.localPkgName}`)
const outputPath = `${__dirname}/${config.localPkgName}${curTime}.zip`
const output = fs.createWriteStream(outputPath)
const archive = archiver('zip', {
zlib: {level: 9},
}).on('error', (err) => {
throw err
})
output.on('close', (err) => {
if (err) log_break(`文件压缩出错:${JSON.stringify(err)}\n`)
log.gre(`压缩结束,包大小:${(archive.pointer() / 1024 / 1024).toFixed(2)}mb\n`)
uploadZip(outputPath)
})
output.on('end', () => {
log.gre(`数据处理完毕\n`)
})
archive.pipe(output)
archive.directory(distPath, `/dist`)
archive.finalize()
}
压缩包上传完成后,咱们就开始更新部署路径里的文件夹(其实本质就是替换文件而已,so easy~)
这里如果是回滚,就会先获取最新的备份文件,见getLastBackup()方法,然后删除原项目并将最新备份文件命名为正确项目名
-
如果是正常部署,先对原有项目按规则命名备份,然后解压上传的包,再删除服务器压缩包,最好用
const remoteFileUpdate = async () => { let cmd log.gre('执行远程更新命令\n') if (isRollback) { const lastBackup = await getLastBackup() cmd = `rm -r ${config.localPkgName} && mv ${lastBackup} ${config.localPkgName}` log.gre(`回滚目标版本:${lastBackup}\n`) } else { cmd = `mv ${config.localPkgName} ${config.localPkgName}.${config.backupKeyword}${curTime} && unzip ${config.localPkgName}${curTime}.zip && rm -r ${config.localPkgName}${curTime}.zip` } try { const res = await ssh.execCommand(cmd, {cwd: config.pathUrl}) log.gre(`上传信息输出:${res.stdout}\n`) if (!res.stderr) { log.gre(`项目已${isRollback ? '回滚' : '部署'}成功!\n`) // 删除本地压缩包 if (!isRollback) fs.unlinkSync(`${__dirname}\\${config.localPkgName}${curTime}.zip`) log_break() } else { log_break(`远程更新命令出错:${JSON.stringify(res)}\n`) } } catch (err) { log_break(err) } }
功能实现,欢迎提建议