手拉手寥寥数行 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 接口进行上传的。
  • [ ] 其他各位官人想要的玩法
    如果想给这个工具加个功能,你最希望加什么?
posted @ 2022-09-05 09:22  程序媛李李李李蕾  阅读(196)  评论(0编辑  收藏  举报