手拉手寥寥数行 js 实现一个有温度的前端部署工具
手拉拉寥寥数行 js 实现一个有温度的前端部署工具
以下文档从痛点分析到技术选型,再到实现和优化,以及发布,一条龙服务。
抛开自动化 CI 不谈,大多数公司或场景下,都会需要把文件上传到服务器上的场景。那么让我们来讨论其中需要提升效率的空间在什么地方:
- 可能需要频繁打开 MobaXterm 或 xshell 或 secureCRT 或 FinalShell … 只为上传文件。
- 不方便与项目集成,比如新的开发人员参与时只能手动再次安装,配置,备注。
- 一般大家都使用“破戒”版,下载渠道未知,安全情况未知。
- 可能不同的人有不同的使用习惯,造成沟通成本。
- 不同的系统下需要单独使用不同的安装包,不便于跨平台使用。
- 虽然工具上可以预置命令和密码,但每次需要去寻找本地文件和上传位置,相当繁琐。
- 如果需要涉及备份,通常需要手动在服务器上先复制一份。
- 不方便扩展功能,不方便自动化。
很多朋友都遇到包括但不限于以上问题,十分烦恼。对于我自己而言,我使用的是 MobaXterm,目前在公司主要的应用场景是:
- 前端代码是由前端人员自己发布的
- 前端代码需要发布到很多不同的环境,例如:
- 开发版,功能完成后发布到内网服务器上,供前后端自行查看接口联调成果
- 内测版,当某迭代完成后,发布到测试环境供测试人员测试
- 演示版,内部其他部门人员可以访问到的较为稳定的版本
- 客户版,针对不同客户部署的不同环境,可能像下面这样:
- 客户A
- 客户B
- 客户…
- 每个环境由于一些不可抗因素,产生以下情况:
- 服务器地址不同
- 服务器端口不同
- 服务器用户或密码不同
- 服务器部署位置不同
- 部署后的后置操作不同,例如有的需要部署后重启A程序,有的需要部署后重启B程序
- 有多个项目都存在于上述状况
如果你担任多个项目,每项目又都处于魔改版的敏捷模式
下,然后每个项目都像上面一样我就是我,不一样的烟火
,那估计你可能很想像我一样,犹如中了寒冰烈火掌,一个头两个大(不要想歪🐶),总想着rm -rf / 快刀斩乱麻有没有什么环节可以优化一下。
开始下手
经过低头微微思考,在前情提要中的问题虽然有很多,但他们都有一条共同的命脉,抓住它就可以以小博大,以柔克刚。这些问题主要集中在部署时,环境太多且部署位置这些不同,这些是变化的,不变的是都以 ssh 协议上传文件。
好啦,经过分析,gitlab jenkins githooks 这些需要与项目强依赖或者牵扯到较多的东西的话那是后端管的,那是运维管的,我不懂,我不会就先不考虑了。
另外一种就是命令行形式的,由于这里主要优化的是 ssh 上传文件,常见选择有:
- scp
- rsync — 功能较多,例如同步时能跳过重复的文件
这两个程序在 linux/macos 下一般都有,但在 window 下可能没有 scp,几乎不会有 rsync,虽然有基于 mingw64 提取的 win 版的 rsync,但还得去找,就算找到了,每个平台下的 rsync 还是有那么一些区别的,我可不想因为它去改 bug 出了问题我不知道怎么解决。
好吧假设 rsync 各平台下都有,也没有使用差异,但是写命令脚本这种事,那也不是咱前端干的。主要是要干个像样(标题中的有意思)的脚本,那也很麻烦。假设使用脚本,可能会遇到以下问题:
- 兼容性: window 下常用 cmd/bat,PowerShell 虽然跨平台但也还得单独安装解释程序。而 linux/macos 下使用的是 *.sh 。
- 复杂性: 写几行脚本能完成功能倒没有关系,npm scripts 我们前端天天都在写是不是,但是要在脚本里做一些逻辑和字符串处理啥的,这就很难受了。
- 维护性: 就算兼容性(shelljs)和复杂性(-500发量)我都能解决,但如果其他前端来修改的时候,那不也得继续掉头发?
可以再有一种形式,基于 javascript 封装过的上传程序。基于 anyjs(一切可以用 js 完成的,都会用 js 完成)的信念,果然前端社区已经有了以下工具:
由于 node-scp 是基于 ssh2 实现的上传文件程序,所以我们这里只关注 node-scp 即可。看到这个使用量,就是简单的用法(对,很复合我这种小白),我就放心了。
上传文件到服务器
node-scp 在 readme 上有以下代码, 看起来十分简洁,经测试可用。
const { Client } = require('node-scp')
const client = await Client({
host: 'your host',
port: 22,
username: 'username',
password: 'password',
})
await client.uploadDir('./local/dir', '/server/path')
node-scp 对上传和下载目录、文件提供了直接的 api,那么相当于我们的工作也都完成了。
就这?就这?前情进行了这么久,我才投入状态你就结束了?
等于,我又行了!
实现备份功能
只有上传没有备份那肯定是不行的,这我应该就不用多说了对不啦。
在 node-scp 的开放 api 中提供了 client.exists 用于判断文件是否存在,在上传之前文件之前,如果文件已经存在,则先备份它。
然而并没有直接备份的 api。不过没关系,我们知道 ssh2 可以运行命令,也知道 cp 命令可以复制,这下目标就很明确了。
const { Client } = require('ssh2');
const conn = new Client();
conn.on('ready', () => {
conn.shell((err, stream) => {
stream.on('close', () => {
conn.end();
})
// 备份 dist 目录为 dist_back_20220901
stream.end('cp -a dist dist_back_20220901 \nexit\n');
});
}).connect({
host: '192.168.100.100',
port: 22,
username: 'username',
password: 'password',
});
好家伙,既然能直接在服务器上运行命令,那就好办了rm -rf /* 安排。我们也能运行其他的命令,例如上传完成后重启服务之类的。
上传前或上传后在服务器上运行指定命令
这次奴家想要暴力一点点,你看接口都给你开在下面了,你上来直接干即可!
/**
* 在远程服务器上运行命令
* @param {*} remoteInfo 服务器信息
* @param {*} cmd 待执行命令
* @returns
*/
async function remoteRunCmd(remoteInfo, cmd) {
cmd = `${cmd}\nexit\n`
return new Promise((resolve, reject) => {
let outData = ``
const conn = new Ssh()
conn.on(`ready`, () => {
conn.shell((err, stream) => {
if (err) {
return reject(err)
}
stream.on(`close`, () => {
conn.end()
resolve(outData)
}).on(`data`, (data) => {
outData = outData + data
process.stdout.write(data) // 转换服务器的输出到本地显示
})
stream.end(cmd)
})
}).connect(remoteInfo)
})
}
上传完成前备份文件,以及上传完成后重启 pm2:
const serve = {
host: '192.168.100.100',
port: 22,
username: 'username',
password: 'password',
}
await remoteRunCmd(serve, `cp -a dist dist_back_20220901`) // 备份文件
// await remoteUpload(serve, ...) // 上传文件
await remoteRunCmd(serve, `pm2 restart web`) // 重启 pm2
脱敏隐私信息
通过以上代码,我们基本已经实现了文件的上传、备份、服务重启等一系列的操作,在可预见的未来,我们只需要写好一个列表,写好本地目录、服务器目录、服务器连接信息这些即可。
但是我们在代码里直接写密钥信息,是一个比较危险的事。就像万一你想把你的有些作品上传到P站(一个学习微积分的网站)GITHUB上但又忘记打码,这可能就有点尴尬。
所以马赛克虽然可耻,但有时候为了安全着想,还是要用的。
那么如何给我们的代码打码呢?就用 userkey ,它好我也好。
这个工具包可以把重要信息存储在代码之外的地方,而又可以方便的在代码里或代码外读取他们,有一点像 github action 配置 token 的方式,或者像把密钥放置于环境变量中的方式。
打码之后的代码类似如下:
const store = require(`userkey`)()
const serve = store.get(`vps.test`)
await remoteRunCmd(serve, `cp -a dist dist_back_20220901`) // 备份文件
// ...
边界处理
为了使用体验更好,我们要增加一些容错机制、错误提示。可预见的错误可能会有:
- 要上传的本地目录不存在 — 提示错误
- 连接服务器的账号信息错误 — 提示错误
- 服务器可以连接,但要操作的目录没有权限 — 提示错误
- 服务器可以连接,但还没有创建要父目录 — 自动创建
- 给定的目录含有特殊字符 — 容错处理
- 服务器空间不足 — 提示错误
- …
很显明,有太多可能会出现错误的情况,而我们处理那些常见的即可,之后再是一个根据具体情况不断优化的过程。
题外话:
一个测试工程师走进一家酒吧,要了一杯啤酒;
一个测试工程师走进一家酒吧,要了一杯咖啡;
一个测试工程师走进一家酒吧,要了0.7杯啤酒;
一个测试工程师走进一家酒吧,要了-1杯啤酒;
一个测试工程师走进一家酒吧,要了232杯啤酒;
一个测试工程师走进一家酒吧,要了一杯洗脚水;
一个测试工程师走进一家酒吧,要了一杯蜥蜴;
一个测试工程师走进一家酒吧,要了一份asdfQwer@24dg!&*(@;
一个测试工程师走进一家酒吧,什么也没要;
一个测试工程师走进一家酒吧,又走出去又从窗户进来又从后门出去从下水道钻进来;
一个测试工程师走进一家酒吧,又走出去又进来又出去又进来又出去,最后在外面把老板打了一顿;
一个测试工程师走进一家酒吧,要了一杯烫烫烫的锟斤拷;
一个测试工程师走进一家酒吧,要了NaN杯Null;
一个测试工程师冲进一家酒吧,要了500T啤酒咖啡洗脚水野猫狼牙棒奶茶;
一个测试工程师把酒吧拆了;
一个测试工程师化装成老板走进一家酒吧,要了500杯啤酒并且不付钱;
一万个测试工程师在酒吧门外呼啸而过;
一个测试工程师走进一家酒吧,”< script >alert(“要了一杯酒”);< /script >”
一个测试工程师走进一家酒吧,要了一杯啤酒’;DROP TABLE 酒吧;
测试工程师们满意地离开了酒吧。
然后一名顾客点了一份炒饭,酒吧炸了。
你说如果测试都不能完整覆盖,程序逻辑能完整覆盖吗?所以再说一遍,有太多可能会出现错误的情况,而我们处理那些常见的即可,之后再是一个根据具体情况不断优化的过程有什么事下周再说。
细节优化
首先问个问题,给你一个路径,你觉得它是文件还是目录:
- 路径A
/home/la/www/plugin
- 路径B
/home/la/www/plugin/
很明显,直觉上有斜杠结尾的我们一般认为是目录。很多 cli 程序对两者是有区分的,例如 rsync 复制是含有斜杠是表示要处理目录下的内容,没有斜杠时表示要处理该 plugin 本身。
那么活又来了ps: 你玩法真多,活真好:
/**
* 优化路径
* @param {*} arr
* @returns
*/
function pathHelper(arr = []) {
let [from, to, back] = arr
let backPath = undefined
if(fs.existsSync(from) === false) {
throw new Error(`要处理的 ${from} 不存在`)
}
const {base: fromName, ext} = require(`path`).parse(from)
const fromType = fs.statSync(from).isDirectory() ? `Dir` : `File`
// -- 如果上传的类型是文件, 但目标是目录时, 自动扩展为目标文件地址, 因为 node-scp 只能文件上传到文件, 不能直接文件上传到目录
fromType === `File` && to.endsWith(`/`) === true && (to = `${to}/${fromName}`);
// -- 如果输入的目录结尾没有斜杠时, 表示在需要在服务器上完整保存此目录
fromType === `Dir` && from.endsWith(`/`) === false && (to = `${to}/${fromName}`);
// -- 如果需要备份时, 生成备份目录
back && (backPath = `${to.replace(/\/$/, ``)}${dateFormat(back, new Date())}${ext}`)
let {dir: toDir} = require(`path`).parse(to)
// 去除多于的目录符, 例如 `a\\\\b///c` 转换为 `a/b/c`
const obj = {
from, to, toDir, backPath, fromType
}
Object.entries(obj).forEach(([key, val]) => {
val && (obj[key] = val.replace(/[/\\]+/g, `/`));
})
return obj
}
根据这个区别,我们实现了以下上传方式:
- 目录下的内容到服务器目录中 /local/plugin/ => /server/plugin/
- 整个目录到服务器目录中 /local/plugin => /server/www/
- 文件到服务器目录 /local/plugin/package.json => /server/www/
- 文件到服务器文件 /local/plugin/package.json => /server/plugin/package.new.json
让使用更简单
总结一下我们目前实现的功能:
- 上传
- 备份
- 运行命令
为了让其更容易使用,把以上需求直接提取出来,直接配置必要的参数即可。
直接干的感觉真好,简单粗暴省时间。经过封装,使用方式变成:
const store = require(`userkey`)()
const remoteTool = require('remote-tool')
const projectDir = process.cwd()
const configItem = {
desc: `部署到演示环境`,
key: `show`,
server: store.get(`vps.show`),
run: {
local: `
npm run build:prod
`,
preCmd: `
pm2 stop web
`,
upload: [
[`${projectDir}/dist`, `/home/la/www/web`, `_back_YYYYMMDDhhmmss`],
[`${projectDir}/plugin`, `/home/la/www/plugin`],
],
postCmd: `
pm2 start web
pm2 restart plugin
`,
},
}
await remoteTool(configItem.server, configItem.run)
算是差不多可以了,如果有多个项目,那么我们就把 configItem 作为一个列表即可。
在 configItem 里面
- desc 是配置描述,可以用来输出 console 便于识别
- key 是配置标识,例如通过命令行传入 key=show 则表示运行部署到演示环境的程序
- server 表示服务器连接信息, 我们应避免服务器账号信息泄露
- run.local 在本地运行命令, 例如打包前端代码
- run.preCmd 上传前运行的前置命令, 例如暂停服务访问
- run.upload 上传或备份, 支持批量处理
- 第一项是要上传的本地文件或目录
- 第二项是要上传到服务器的什么地方
- 第三项是表示是否备份, 支持一个时间格式化模板
- run.postCmd 上传后要运行的命令, 例如恢复服务访问
接下来,我们在把它封装成命令行程序,这样的话使用时只需要写配置文件就行,不需要写代码。
# 安装
npm i -g remote-tool
创建配置文件 .remote-tool.js
内容示例:
module.exports = [
{
desc: `部署到演示环境`,
key: `prod`,
server,
run,
},
{
desc: `部署到中国移动客户验收环境`,
key: `uat-cmcc`,
// ...
},
]
# 部署到演示环境
remote-tool key=prod
# 同时部署两个环境
remote-tool key=prod,uat-cmcc
救救我!
有一些想法不知道是否应该实现或怎样实现,希望各位官人帮忙看看,奴家无衣回报。
- [ ] 添加上传进度功能
假设要上传的目录内容比较多或网络比较慢的情况下,运行上传的过程如果没有什么反馈,会让人怀疑程序有没有正在工作。所以这个功能可能很有必要。看了一下 node-scp/ssh2 没有直接提供上传进度监听的事件,各位看官看能不能支个招。实在不行只能搞个虚拟进度条,但这貌似有点那个啥。 - [ ] 支持 ftp 或 http 形式上传,因为某些服务器不直接开放 ssh 登录
由于公司权限分配原因,所以可能是以给定的 ftp 账号或给定的 http 接口进行上传的。 - [ ] 其他各位官人想要的玩法
如果想给这个工具加个功能,你最希望加什么?