极简工程--自动化部署

299 阅读3分钟

前言

站在巨人的肩膀上,往往更容易达成目标。
本文是对《从零开始 Node实现前端自动化部署》 的一个完善,推荐比对阅读。

效果图

自动化部署效果图
自动化部署效果图

优化点

  1. 增强命令行交互体验,实现美化打印
  2. 远程数据备份采用时间格式备份和最新备份
  3. 完善错误处理,有报错直接中断后续程序进行,减少脏数据
  4. 自定义发布后文件名称,不局限于dist
  5. 进一步解耦,将相关逻辑再度拆分
  6. 更简洁的语法实现时间格式化

部署流程

  1. 部署文件配置 config
  2. 压缩本地编译后的项目 npm run build =>dist=> dist.zip
  3. 建立与远程服务器的连接 ssh
  4. 上传本地压缩后项目到远程服务器 upload dist.zip
  5. 远程服务器数据备份 根据时间节点备份或只保留最新备份
  6. 远程服务器解压本地上传的压缩包 unzip dist.zip
  7. 修改发布目录 mv dist target
  8. 删除远程的压缩文件 rm -rf dist.zip
  9. 删除本地的压缩文件 rimraf dist.zip
部署流程
部署流程

技术选型

  1. node-ssh 实现远程服务器的连接,远程命令执行
  2. archiver 实现文件压缩
  3. inquirer 命令行交互界面
  4. 依赖安装
npm i node-ssh 
npm i archiver 
npm i inquirer 

目录结构划分

src/main.js                 自动化部署程序入口
src/config.js               部署文件配置
src/utils/compressFile.js   文件压缩
src/utils/execCommand.js    执行远程命令
src/utils/timeFormat.js     时间格式化处理
src/utils/inquirerUI.js     命令行交互界面
src/utils/ssh.js            ssh连接
src/utils/uploadFile.js     文件上传
src/utils/backupFile.js     远程文件备份
src/utils/print.js          美化打印

功能实现

通用工具函数--美化打印print

//src/utils/print
const styles = {
    'red': '\x1B[31m', // 红色--danger
    'danger': '\x1B[31m', // 红色--danger
    'yellow': '\x1B[33m', // 黄色--warnning
    'warnning': '\x1B[33m', // 黄色--warnning
    'blue': '\x1B[34m', // 蓝色--primary
    'primary': '\x1B[34m', // 蓝色--primary
    'bright': '\x1B[1m', // 亮色
    'green': '\x1B[32m', // 绿色
    'magenta': '\x1B[35m', // 品红
    'cyan': '\x1B[36m', // 青色
    'white': '\x1B[37m', // 白色
}

function print(msg = '', color = 'blue') {
    console.log(`${styles[color]}%s\x1B[0m`, msg)
}

//颜色测试
for (const key in styles) {
    if (styles.hasOwnProperty(key)) {
        print('自动化部署',key)
    }
}

module.exports = print
美化打印
美化打印

通用工具函数--时间格式化timeFormat


//src/utils/timeFormat
function getCurrentTime() {
  const date = new Date
  const yyyy = date.getFullYear()
  const MM = (date.getMonth() + 1).toString().padStart(2, '0')
  const dd = date.getDate().toString().padStart(2, '0')
  const HH = date.getHours().toString().padStart(2, '0')
  const mm = date.getMinutes().toString().padStart(2, '0')
  const ss = date.getSeconds().toString().padStart(2, '0')
  return `${yyyy}-${MM}-${dd}#${HH}:${mm}:${ss}`
}

module.exports = getCurrentTime

1. 预检查

//src/utils/inquirerUI
const inquirer = require('inquirer')
const print = require('./print')
const selectTip = 'project name:'
const options = [
  {
    type: 'list',
    name: selectTip,
    message: 'Which project do you want to deploy?',
    choices: []
  }
]

//交互式命令行界面
function showHelper(config) {
  return new Promise((resolve, reject) => {
    initHelper(config) // 初始化helper
    inquirer.prompt(options).then(answers => {
      resolve({ value: findInfoByName(config, answers[selectTip]) }) // 查找所选配置项
    }).catch((err) => {
      reject(print(' helper显示或选择出错!' + err.message, 'danger'))
      process.exit()
    })
  })
}

// 初始化helper
function initHelper(config) {
  for (let item of config) {
    options[0].choices.push(item.name)
  }
  print('正在检查全局配置信息...')
  // 检查是否存在相同name
  if (new Set(options[0].choices).size !== options[0].choices.length) {
    print('请检查配置信息,存在相同name!', 'danger')
    process.exit()
  }
}

// 查找符合条件的配置项
function findInfoByName(config, name) {
  for (let item of config) {
    if (item.name === name) {
      return item
    }
  }
}

module.exports = showHelper


2. 本地项目压缩

//src/utils/compressFile
const fs = require('fs')
const archiver = require('archiver')
const print = require('./print')

//文件压缩
function compressFile(targetDir, localFile, build) {
  return new Promise((resolve, reject) => {

    print('1-正在压缩文件...')
    let output = fs.createWriteStream(localFile) // 创建文件写入流
    const archive = archiver('zip', {
      zlib: { level: 9 } // 设置压缩等级
    })
    output.on('close', () => {
      resolve(
        print('2-压缩完成!共计 ' + (archive.pointer() / 1024 / 1024).toFixed(3) + 'MB')
      )
    }).on('error', (err) => {
      reject(() => {
        print('压缩失败', 'dnager')
        print(err.message, 'dnager')
        process.exit()
      })
    })
    archive.pipe(output) // 管道存档数据到文件
    archive.directory(targetDir, build) // 存储目标文件并重命名
    archive.finalize() // 完成文件追加 确保写入流完成
  })
}

module.exports = compressFile


3. ssh连接


//src/utils/ssh
const NodeSSH = require('node-ssh')
const ssh = new NodeSSH()
const print = require('./print')

// 连接服务器
function connectServe(sshInfo) {
  return new Promise((resolve, reject) => {
    ssh.connect({ ...sshInfo }).then(() => {
      resolve(print('3-' + sshInfo.host + ' 连接成功'))
    }).catch((err) => {
      reject(print('3-' + sshInfo.host + ' 连接失败' + err.message, 'danger'))
    })
  })
}

module.exports = { ssh, connectServe }


4. 文件上传


//src/utils/uploadFile
const handleBackupFile = require('./backupFile')
const print = require('./print')

// 文件上传(ssh对象、配置信息、本地待上传文件)
async function uploadFile(ssh, config, localFile) {
  return new Promise((resolve, reject) => {
    print('4-开始文件上传')
    handleBackupFile(ssh, config)
    ssh.putFile(localFile, config.deployDir + config.targetFile).then(async () => {
      resolve(print('5-文件上传完成'))
    }, (err) => {
      reject(print('5-上传失败!' + err.message, 'danger'))
    })
  })
}

module.exports = uploadFile

5. 远程数据备份

//src/utils/backupFile
const runCommand = require('./execCommand')
const getCurrentTime = require('./timeFormat')
const print = require('./print')

// 处理源文件(ssh对象、配置信息)
async function handleBackupFile(ssh, config) {

    try {
        if (config.openBackUp) {
            if (config.backUpByTime) {
                print('已开启远端备份---时间节点备份');
                await runCommand(
                    ssh,
                    //备份-重命名文件
                    `
            if [ -d ${config.releaseDir} ];
            then mv ${config.releaseDir} ${config.releaseDir}#${getCurrentTime()}
            fi
            `,
                    config.deployDir)
            } else {
                print('已开启远端备份---最新备份')

                //每次删除备份目录,下次创建相当于备份后并重命名
                //始终获取最新数据
                await runCommand(
                    ssh,
                    `
            if [ -d ${config.releaseDir} ];
            then rm -rf ${config.releaseDir}#bak && mv -f ${config.releaseDir} ${config.releaseDir}#bak
            fi
            `,
                    config.deployDir)
            }


        } else {
            print('非法操作:请开启远端备份!', 'danger')
            process.exit()
        }
    } catch (error) {
        print("远程备份出错,请检查!", 'danger')
        print(error.message, 'danger')
        process.exit()
    }

}

module.exports = handleBackupFile


6. 入口文件

//src/main

const config = require('./config')
const helper = require('./utils/inquirerUI')
const compressFile = require('./utils/compressFile')
const sshServer = require('./utils/ssh')
const uploadFile = require('./utils/uploadFile')
const runCommand = require('./utils/execCommand')
const print = require('./utils/print')

// 主程序(可单独执行)
async function main() {
  try {

    const SELECT_CONFIG = (await helper(config)).value // 所选部署项目的配置信息
    const {
      name, targetFile, openCompress, targetDir,
      ssh, deployDir, build, releaseDir
    } = SELECT_CONFIG;

    print(`您选择了部署${name}`)

    const localFile = __dirname + '/' + targetFile // 待上传本地文件
    openCompress && await compressFile(targetDir, localFile, build)  //压缩
    await sshServer.connectServe(ssh) // 连接
    await uploadFile(sshServer.ssh, SELECT_CONFIG, localFile) // 上传
    await runCommand(sshServer.ssh, 'unzip ' + targetFile, deployDir) // 解压
    //此时原项目名已经被重命名,或最新备份,或时间节点备份
    // mv dist target,这条命令相当于将dist重命名为原始目录
    // src->src#bak   dist->src
    await runCommand(sshServer.ssh, `mv ${build}  ${releaseDir}`, deployDir) // 修改发布目录
    await runCommand(sshServer.ssh, 'rm -f ' + targetFile, deployDir) // 删除
  } catch (err) {
    print('部署过程出现错误!', 'danger')
    print(err.message, 'danger')
  } finally {
    process.exit()
  }
}

// run main
main()


7. 配置文件

//src/config
const config = [
  {
    name: 'xxx',
    ssh: {
      host: 'xxx',
      port: 22,
      username: 'xxx',
      password: 'xxx',
      // privateKey: '', // ssh私钥(不使用此方法时请勿填写, 注释即可)
      passphrase: '' // ssh私钥对应解密密码(不存在设为''即可)
    },
    build: 'lib',//目标压缩目录名称
    targetDir: './lib', // 目标压缩目录路径(可使用相对地址)
    targetFile: 'lib.zip', // 目标文件压缩包名称
    openCompress: true, // 是否开启本地压缩
    openBackUp: true, // 是否开启远端备份
    backUpByTime: false, // 是否开启基于时间节点的备份
    deployDir: 'xxx', // 远端目录
    releaseDir: 'www' // 远端发布目录 ,形如 deployDir/releaseDir
  },
  {
    name: 'yyy',
    ssh: {
      host: 'yyy',
      port: 22,
      username: 'yyy',
      password: 'yyy',
      // privateKey: '', // ssh私钥(不使用此方法时请勿填写, 注释即可)
      passphrase: '' // ssh私钥对应解密密码(不存在设为''即可)
    },
    build: 'dist',//目标压缩目录名称
    targetDir: './dist', // 目标压缩目录(可使用相对地址)
    targetFile: 'dist.zip', // 目标文件
    openCompress: true, // 是否开启本地压缩
    openBackUp: true, // 是否开启远端备份
    backUpByTime: true, // 是否开启基于时间节点的备份
    deployDir: 'yyy', // 远端目录
    releaseDir: 'yyy' // 远端发布目录 形如 deployDir/releaseDir
  },
]

module.exports = config

8. 本地项目清理


npm i rimraf -D
//package.json配置
"devDependencies": {
    "rimraf": "^3.0.2"
},
"scripts": {
    "deploy": "node main.js && rimraf ./lib.zip && rimraf ./dist.zip"
},

9. 项目启动

  1. 按需修改config文件
  2. 运行npm run deploy

项目源码

源码 :https://github.com/lengyuexin/auto-deploy