前端自动化部署

1,416 阅读9分钟

之前的文章《前端持续集成》中有介绍过采用GitLab CI进行自动化的构建,这种方法也有不好的地方,它需要在服务器上进行编译打包,占用服务器资源。另外前段时间在采用electron-vue进行桌面端开发的时候,我们需要把安装包传至服务器上的某个地址方便测试人员进行下载,过程中遇到了这样的问题:linux上无法打mac的安装包,打windows的包也需要额外安装插件,这个时候就只能采用最原始的方法,本地打包,手动上传至服务器。这样还是颇为不便的,网上调研了下,有很多自动化部署的方案,然后根据自身项目的需求,实现的方案如下:

一、整体思路

我们需要实现一个cli工具,当用户在终端输入构建命令时,执行如下步骤
1、本地进行打包并压缩
2、连接服务器
3、将本地压缩包上传至服务器某路径下
4、将服务器上接收到的压缩包进行解压
5、删除本地打包文件
6、断开服务器

以上是完成自动化部署的基本思路,由于我们上传至服务器是供测试人员下载的,每次构建时都需要通知测试下载最新的安装包,所以还增加了发邮件的功能。

二、实现

1、项目搭建

  • 新建一个auto-deploy文件夹,终端执行npm init初始化package.json
  • package.json里新增bin字段,指定auto-deploy命令所对应的可执行文件的位置。
"bin": {
    "auto-deploy": "bin/auto-deploy-cli.js"
 }
  • auto-deploy文件夹下新增一个bin文件夹存放我们的入口文件auto-deploy-cli.js
#!/usr/bin/env node

const Deploy = require('../src/index.js')
// 创建一个新的对象
const deploy = new Deploy()
// 执行对象下的run函数
deploy.run()

行首加入一行#!/usr/bin/env node指定当前脚本由node.js进行解析

  • auto-deploy文件夹下新增一个src文件夹,新建index.js
module.exports = class Deploy {
  constructor() {}
  run() {
    console.log('搭建成功')
  }
}
  • 然后在该项目的终端执行npm link将该npm包链接到全局环境,这样我们就可以使用auto-deploy命令了。

npm link命令可以将一个任意位置的npm包链接到全局执行环境,从而在任意位置使用命令行都可以直接运行该npm包。
简要地讲,这个命令主要做了两件事:
为npm包目录创建软链接,将其链到{prefix}/lib/node_modules/
为可执行文件(bin)创建软链接,将其链到{prefix}/bin/{name}
以上两个路径是官方文档给出的路径,这两个路径是Linux平台上的。在Windows平台中,这两个路径为:
目录: C:\Users{Username}\AppData\Roaming\npm\node_modules<package>
文件: C:\Users{Username}\AppData\Roaming\npm<name>

现在我们在终端执行一下该命令看看

运行auto-deploy命令后成功的执行了我们的项目,到此一个基础的cli项目就搭建好了。

2、方案构思

当用户在终端输入构建命令时,我们需要依次执行整体思路中的步骤,但我们需要根据什么命令进行打包呢,服务器的端口号、用户名以及密码是多少呢,上传至服务器什么路径下呢,所以要有一个配置文件来记录这些信息,需要实现一个命令auto-deploy init来初始化这个配置文件。另外还需要实现一个auto-deploy build命令来依次执行整体思路中的步骤。

方案一

我们可以通过process.argv.slice(2)来获取命令行的参数,从而判断执行什么样的操作,如下

// auto-deploy-cli.js
#!/usr/bin/env node
const Deploy = require('../src/index.js')
const deploy = new Deploy()
const command = process.argv.slice(2)
deploy.run(command)

// index.js
module.exports = class Deploy {
  constructor() {}
  run(command) {
    if (command[0] === 'init') {
      console.log('初始化命令,执行初始化操作')
    } else if (command[0] === 'build') {
      console.log('构建部署命令,执行构建部署操作')
    } 
  }
}

终端依次输入auto-deploy initauto-deploy build命令:

按此思路实现的话是不利于代码后续进行维护及扩展的,命令比较多的时候代码会非常混乱。我们可以采用commander这个第三方包进行命令的添加。

方案二

commander使用方法如下:

const program = require('commander')
program
  .command(commanName) // 添加命令
  .description(command.description) // 命令的描述
  .option('-n, --name <items1> [items2]', 'name description', 'default value') // 定义commander的选项options
  .action((options) => {
    // 命令的回调函数
  })
  • 在src文件夹下新增commands文件夹存放命令的执行文件,在此下面新增文件init.jsbuild.js。 init.js
module.exports = {
    description: '初始化命令',
    run: function () {
        console.log('初始化命令,执行初始化操作')
    }
}

build.js

module.exports = {
    description: '构建部署命令',
    option: '-t, --type <type>',
    optionDescription: 'setup deploy type',
    run: function () {
        console.log('构建部署命令,执行构建部署操作')
    }
}
  • 在src文件下的index.js文件进行遍历commands下面的命令执行文件,注册相应的命令
const program = require('commander')
const fs = require('fs')
module.exports = class Deploy {
    run() {
        // 获取命令执行文件的路径( __dirname: 被执行js文件的绝对路径)
        const commandsPath = `${__dirname}/commands`
        // 对命令执行文件进行遍历,进行命令注册
        fs.readdirSync(`${commandsPath}`).forEach((fileName) =>{
            const command = require(`${commandsPath}/${fileName}`)
            // 截取文件名作为命令名称
            const commandName = fileName.split('.')[0]
            // 进行命令的注册
           if(command.option){
                program
                .command(commandName)
                .description(command.description)
                .option(command.option,command.optionDescription)
                .action((options) => {
                    command.run(options.type)
                })
            }else{
                program
                .command(commandName)
                .description(command.description)
                .action((options) => {
                    command.run(options.type)
                })
            }
        })
        // 解析命令行参数
        program.parse(process.argv)
    }
}

命令注册完成后,我们在终端执行帮助命令(默认自带帮助命令)看看:

可以看到我们已经成功注册了initbuild命令,终端依次执行一下:

3、功能实现

上面我们已经实现了命令的注册,接下来依次完成每个命令要执行的功能

init 命令

首先需要理清思路,明确每个命令要完成的功能,当我们执行init命令时,若项目下已经有配置文件了,则提示用户配置文件已存在,不进行任何操作,如果项目下没有配置文件,我们就需要给用户搭建一个基本的配置文件,配置好我们执行其它命令时所需要用到的信息。上面的整体思路中我们已经知道我们执行构建命令时,需要知道本地打包构建时的构建命令,打包好的文件的存放位置,服务器的ip、端口,用户名及密码,服务器上的存放路径等。所以我们需要在终端依次询问用户这些信息,本文采用的是inquirer来与命令进行交互。

inquirer使用方法如下:

安装

npm install inquirer

基本使用

var inquirer = require('inquirer');
inquirer
  .prompt([
    /* Pass your questions in here */
  ])
  .then(answers => {
    // Use user feedback for... whatever!!
  })
  .catch(error => {
    if(error.isTtyError) {
      // Prompt couldn't be rendered in the current environment
    } else {
      // Something else when wrong
    }
  });

我们还需要在终端输出日志,可以采用最简单的console.log(),但是这样无法区分日志信息的类型,不方面阅读,我们可以采用chalk来优化我们的日志输出。

src文件夹下新增一个util文件夹,新增index.js来放置我们的公用方法

src/utils/index.js

const chalk = require('chalk')

module.exports = {
    // 基本信息
    showInfoText:(message) => {
        console.log(chalk.grey(message))
    },
    // 错误日志
    showErrorText:(message) => {
        console.log(chalk.inverse.red('ERROR') + chalk.red(message))
    },
    // 成功日志类型1
    showSuccessText:(message) => {
        console.log(chalk.green(message + '✔'))
    },
    // 成功日志类型2
    showBigSuccessText:(message) => {
        console.log(chalk.inverse.green('SUCCESS') + ' ' + chalk.green(message))
    }
}

接下来来实现我们的配置初始化

src/commands/init.js

const fs = require('fs')
const path = require('path')
const inquirer = require('inquirer');
const {showErrorText,showSuccessText} = require('../utils/index.js')

// 配置文件存放的地址(process.cwd():当前命令行运行时的工作目录)
const deployConfigPath = `${path.join(process.cwd())}/deploy.config.js`

// 获取用户输入的信息
const getUserInputInfo = function(){
    const prompt = [
        {
            type: 'input',
            name: 'script',
            message: '请输入打包命令'
        },
        {
            type: 'input',
            name: 'host',
            message: '请输入服务器地址'
        },
        {
            type: 'number',
            name: 'port',
            message: '请输入服务器端口号'
        },
        {
            type: 'input',
            name: 'username',
            message: '请输入服务器用户名'
        },
        {
            type: 'input',
            name: 'password',
            message: '请输入服务器密码'
        },
        {
            type: 'input',
            name: 'localPath',
            message: '请输入本地打包目录'
        },
        {
            type: 'input',
            name: 'remotePath',
            message: '请输入服务器部署路径'
        },
        {
            type: 'confirm',
            name: 'needEmail',
            message: '是否需要增加发邮件功能'
        },
        {
            type: 'input',
            name: 'addressee',
            message: '请输入收件人'
        },
        {
            type: 'input',
            name: 'title',
            message: '请输入邮件标题'
        },
        {
            type: 'input',
            name: 'content',
            message: '请输入邮件内容'
        }
    ]
    return inquirer.prompt(prompt)
}

// 创建配置文件
const createConfigFile = function(fileJson){
    const str = `module.exports = ${JSON.stringify({'win':fileJson},null,2)}`
    fs.writeFileSync(deployConfigPath,str)
}

module.exports = {
    description: '初始化命令',
    run: function () {
        if(fs.existsSync(deployConfigPath)){
            showErrorText('deploy.config.js已存在,请勿重复初始化~')
            process.exit(1)
        }else{
            getUserInputInfo().then((configInfo) => {
                createConfigFile(configInfo)
                showSuccessText("配置成功,请检查配置文件是否正确~")
                process.exit(0)
            }).catch(e => {
            	showErrorText('配置失败:' + e)
             	process.exit(1)
            });
        }
    }
}

JSON.stringify() 方法用于将 JavaScript 值转换为 JSON 字符串。
JSON.stringify(value[, replacer[, space]])
参数
value:必需, 要转换的 JavaScript 值(通常为对象或数组)。
replacer:可选。用于转换结果的函数或数组。如果 replacer 为函数,则 JSON.stringify 将调用该函数,并传入每个成员的键和值。使用返回值而不是原始值。如果此函数返回 undefined,则排除成员。根对象的键是一个空字符串:""。如果 replacer 是一个数组,则仅转换该数组中具有键值的成员。成员的转换顺序与键在数组中的顺序一样。
space:可选,文本添加缩进、空格和换行符,如果 space 是一个数字,则返回值文本在每个级别缩进指定数目的空格,如果 space 大于 10,则文本缩进 10 个空格。space 也可以使用非数字,如:\t。

init命令的执行文件创建好了,我们来执行一下:

之后可以发现项目根目录下新增了deploy.config.js文件

当我们再次执行该命令时就会出现报错:

build 命令

构建命令中我们需要一一实现整体步骤中的功能

  • 本地进行打包 我们需要获取配置文件中的打包命令进行本地文件的打包
const childProcess = require('child_process')

const localBuild = async function(config){
    try{
        const {script} = config
        showInfoText('本地打包中...')
        await new Promise((resolve,reject) => {
            childProcess.exec(
                script,
                {cwd:process.cwd(),maxBuffer:5000*1024},
                (e) => {
                    if(e){
                        reject(e)
                    }else{
                        resolve()
                    }
                }
            )
        })
    }catch(e){
        showErrorText('打包失败:' + e)
        process.exit(1)
    }
}

child_process模块是nodejs的一个子进程模块,可以用来创建一个子进程,并执行一些任务。
child_process.exec(command[, options][, callback])
command:要运行的命令,参数使用空格分隔。
options > cwd :子进程的当前工作目录。 默认值: null。
options > maxBuffer:stdout 或 stderr 上允许的最大数据量(以字节为单位)。 如果超过限制,则子进程会被终止,并且输出会被截断。 参见 maxBuffer 和 Unicode 的注意事项。 默认值: 1024 * 1024。
callback:当进程终止时调用并传入输出。

  • 将打包好的文件进行压缩
const fs = require('fs')
const archiver = require('archiver')

// 压缩本地文件
const createZip = async (config) => {
    try {
        const { localPath } = config
        showInfoText('压缩中...')
        await new Promise((resolve, reject) => {
            //  创建最终打包文件的输出流
            const output = fs.createWriteStream(`${process.cwd()}/${localPath}.zip`)
            // 生成archiver对象,打包类型为zip
            const archive = archiver('zip', {
                zlib: { level: 9 } // Sets the compression level.
            });
            output.on('close', (e) => {
                if (e) {
                    showErrorText('压缩失败:' + e)
                    reject(e)
                    process.exit(1)
                } else {
                    showSuccessText(`${localPath}.zip 打包成功`)
                    resolve()
                }
            })
            // 将打包对象与输出流关联
            archive.pipe(output)
            // 从子目录追加文件,将其内容放在archive的根目录
            archive.directory(config.localPath, false)
            // 结束archive
            archive.finalize()
        })
    } catch (e) {
        showErrorText('压缩失败:' + e)
        process.exit(1)
    }
}

archiver是一个在nodejs中能跨平台实现打包功能的模块,可以打zip和tar包,是一个比较好用的三方模块。

  • 连接服务器
const { NodeSSH } = require('node-ssh')
const ssh = new NodeSSH()

const connactSSH = async (config) => {
    try {
        showInfoText('服务器连接中...')
        await ssh.connect({
            host: config.host,
            username: config.username,
            password: config.password,
            port: config.port
        })
        showSuccessText('服务器连接成功')
    } catch (e) {
        showErrorText('服务器连接失败:' + e)
        process.exit(1)
    }
}
  • 上传压缩包到服务器
const uploadFile = async (config) => {
    try {
        showInfoText('本地打包文件上传中...')
        const {
            localPath,
            remotePath
        } = config
        await ssh.putFile(`${process.cwd()}/${localPath}.zip`, `${remotePath}.zip`)
        showSuccessText('本地打包文件上传成功')
    } catch (e) {
        showErrorText('本地打包文件上传失败:' + e)
        process.exit(1)
    }
}
  • 解压远程文件
const unzipFile = async (config) => {
    try {
        showInfoText('远程文件解压中...')
        const {
            remotePath,
            needEmail
        } = config
        const remoteFileName = `${remotePath}.zip`

        await ssh.execCommand(
            `unzip -o ${remoteFileName} -d ${remotePath} && rm -rf ${remoteFileName}`
        )
        // 解压成功后如果有发邮件的需求则发送邮件通知
        if (needEmail) {
            await ssh.execCommand(
                `echo '${config.content}' | mail -s '${config.title}' ${config.addressee}`
            )
        }
        showSuccessText('远程文件解压成功')
    } catch (e) {
        showErrorText('远程文件解压失败:' + e)
        process.exit(1)
    }
}

这里采用的是mail进行邮件的发送,要在服务器上进行安装及配置,配置文件如下(/etc/mail.rc):

set smtp=smtps://smtp.xxx.com:465   # 这里填入smtp地址,这里的xxx为qq或者163等,如果用的云服务器,安全组策略要开放465端口,入站和出站都要开放该端口
set smtp-auth=login                 # 认证方式
set smtp-auth-user=user@xxx.com     # 这里输入邮箱账号
set smtp-auth-password=password     # 这里填入密码,这里是授权码而不是邮箱密码
set ssl-verify=ignore               # 忽略证书警告
set nss-config-dir=/etc/pki/nssdb   # 证书所在目录
set from=user@xxx.com               # 设置发信人邮箱和昵称
  • 删除本地文件
const deleteLocalFile = (config) => {
    try {
        showInfoText('删除本地打包文件中...')
        let {
            localPath
        } = config

        function deleteFn(path) {
            if (fs.existsSync(path)) {
            	// 遍历文件夹,如果里面是文件就直接删掉,是文件夹则递归进行删除
                fs.readdirSync(path).forEach((file) => {
                    var curPath = path + "/" + file;
                    if (fs.statSync(curPath).isDirectory()) {
                        deleteFn(curPath);
                    } else {
                        fs.unlinkSync(curPath);
                    }
                });
                fs.rmdirSync(path);
            }
        }
        deleteFn(localPath)
        fs.unlinkSync(`${localPath}.zip`)
        showSuccessText('删除本地打包文件成功')
    } catch (e) {
        showErrorText('删除本地打包文件失败:' + e)
        process.exit(1)
    }
}

  • 断开与服务器的连接
const disconnectSSH = () => {
    ssh.dispose()
}

最后在我们的入口函数中依次执行这些操作

src/commands/build.js

// 配置文件存放的地址(process.cwd():当前命令行运行时的工作目录)
const path = require('path')
const deployConfigPath = `${path.join(process.cwd())}/deploy.config.js`

module.exports = {
    description: '构建部署命令',
    option: '-t, --type <type>',
    optionDescription: 'setup deploy type',
    run: async function (type) {
        const config = require(deployConfigPath)[type]
        if (!type) {
            showErrorText('请选择要构建的平台')
            process.exit(1)
        } else if (!config) {
            showErrorText(`暂无${type}平台的配置信息,请进行配置`)
            process.exit(1)
        } else {
            if (fs.existsSync(deployConfigPath)) {
                await localBuild(config)
                await createZip(config)
                await connactSSH(config)
                await uploadFile(config)
                await unzipFile(config)
                await deleteLocalFile(config)
                disconnectSSH()
                showBigSuccessText('部署成功')
                process.exit(0)
            } else {
                showErrorText('deploy.config.js配置文件不存在,请先执行auto-deploy init命令创建')
                process.exit(1)
            }
        }
    }
}

运行下我们的构建命令看看

一个基本的自动部署方案实现了,实际运用时可以根据相应的需求进行一定的扩展。