之前的文章《前端持续集成》中有介绍过采用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 init
、auto-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.js
、build.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)
}
}
命令注册完成后,我们在终端执行帮助命令(默认自带帮助命令)看看:
可以看到我们已经成功注册了init
及build
命令,终端依次执行一下:
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)
}
}
}
}
运行下我们的构建命令看看
一个基本的自动部署方案实现了,实际运用时可以根据相应的需求进行一定的扩展。