前端项目远程部署nodejs脚本实现(支持回滚)

285 阅读5分钟

前言

有些时候,个人或者公司开发服务器并没有jekins持续集成,是手动ssh连接服务器上传项目包,压缩解压备份一套流程下来实在是低效,而且虽然是开发服务器但是有时候还是需要备份包才安心,对旧包备份就更加繁琐了,还容易出错。 如果是小公司没有开发服务器,还在用生产服务器装git更新代码或者手动拷贝的方式,就更需要这种脚本来提高效率啦。 虽然网上有很多类似的文章,但是我相信我的版本是轻量脚本中较为完善的。 话不多说,正文开始

首先写一下我们要实现哪些功能点

1.支持自动打包项目并压缩

2.支持参数可配置方便迁移多项目使用

3.支持密码用户密码+ssh私钥免密码登录服务器

4.支持压缩产物部署并备份旧项目

5.支持项目回滚

6.本地产物部署后自动清理,远程备份文件可按配置自动清理(有效期)

如果恰好你时间很多,可以考虑实现(本人场景用不上):

  1. 部署日志
  2. 防止操作冲突
  3. 回滚支持指定版本
  4. 多环境配置

使用形态

如图所示,点击命令一键部署, 点击一个按钮或者输入一条命令,实现一键式自动打包-上传-部署/回滚-备份流程 2022-09-28T10:55:38.png 其实本质就是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)
     }
    
     }
    

功能实现,欢迎提建议