CDP篇:❌多进程避免重载 & 绕开 Chrome 限制

322 阅读7分钟

1 前言

大家好,我是心锁。

上一章中,我们遗漏了两个比较重要的地方没有讲解:

  • 「我们的程序会跟随主进程的重启而断开 devtool 连接同时数据清除」用户体验极差
  • 我们没有自动打开 devtools://devtools/bundled/inspector.html?ws=localhost:${this.port} ,用户体验不佳

node-network-devtools 的主要使用场景应当是开发环境,这往往伴随着高频率的进程重启,开发者的项目通过 watch 等手段监听文件变化并自动重启。如果在这个过程中,我们的 devtools 也重启、断连,那么这是不合理的。

同时,我们通过内置 devtools 网页来进行 CDP 消息显示固然可行,但是这其实是一个冷门知识,我们需要进一步完成自动打开,不能依赖用户来打开网页。

2 通信方案调研

我们先处理第一部分,因为打开 devtools 的过程和这一部分息息相关。

在思考如何避免我们和 Chrome Devtools 的连接断开方面,我从进程到数据状态保留方面考虑了下边几个方案,并最终选择了通过多进程 + WebSocket 通信的方式来确保我们不跟随主进程重启。

序号标题优势劣势其他
1在主进程进行上下文存储,热重载时重新读取方便在第一版代码的基础上继续开发。同时和主进程共享上下文,没有通信上的问题无法避免 chrome devtool 到程序的 websocket 连接断开,用户体验不佳
2通过 fork 独立出子进程,并通过 process 进行进程间通信可以保证热重载不影响 devtools 相关代码进程间通信机制较麻烦,同时热重载后,主进程想重新和子进程建立连接麻烦
3通过 fork 独立出子进程,通过 websocket 进行进程间通信在具备方案二的优势的同时,主进程更容易建立对子进程的连接,且通信简单额外占用一个端口非 socket,node 的 socket 通信稀烂

解释一下子进程为什么可以避开热重载。

热重载(Hot Reloading)是一种开发技术,它允许在应用运行时对代码进行修改,并立即看到修改后的效果,而无需完全重新启动应用。

这其中的关键原理是「监听文件修改并应用变更」,我们通过 fork 的方式意味着需要独立出子进程的文件,故此用户的修改不会影响到我们子进程的文件,所以首要条件不满足,也就不会触发子进程的重载,,可以保持上下文的继续。

3 要做什么

3.1 多重通信「握手」稳定建立 websocket 连接

对主进程来说,正常来说无非两部分代码。

  • fork 子进程
      const cp = fork(resolve(__dirname, './fork'))
  • 建立 websocket 连接
    this.ws = new Promise<WebSocket>((resolve, reject) => {
      const socket = new WebSocket(`ws://localhost:${props.port}`)
      socket.on('open', () => {
        resolve(socket)
      })
      ...

但是坏就坏在,fork 完毕之后,我们并不能立即建立 websocket 连接,因为进程启动需要时间,子进程再启动 socket 连接也需要时间。

所以我们需要一份额外的进程通信机制,让子进程「告诉」主进程自己准备就绪了:

  • 子进程:我们在启动 socket server 并且进入 listening 阶段时,通过 process.send 发送出 ready 信号。
import { Server } from 'ws'

const READY_MESSAGE = 'ready'
const server = new Server({ port: PORT })
server.on('listening', () => {
  if (process.send) {
    process.send(READY_MESSAGE)
  }
})
  • 主进程:第一部分,即 3.1 的逻辑;第二部分则是 forkProcess 的逻辑,我们在 fork 的实例上监听 message,进而确保在 ready 之后触发回调。
    this.ws = new Promise<WebSocket>((resolve, reject) => {
      const socket = new WebSocket(`ws://localhost:${props.port}`)
      socket.on('open', () => {
        resolve(socket)
      })
      socket.on('error', (e) => {
        this.openProcess(() => {
          const socket = new WebSocket(`ws://localhost:${props.port}`)
          socket.on('open', () => {
            resolve(socket)
          })
          socket.on('error', reject)
        })
      })
    })
  private openProcess(callback?: () => void) {
    const forkProcess = () => {
      // fork a new process with options
      const cp = fork(resolve(__dirname, './fork'), {
        env: {
          ...process.env,
          NETWORK_OPTIONS: JSON.stringify(this.options)
        }
      })
      const handleMsg = (e: any) => {
        if (e === READY_MESSAGE) {
          callback && callback()
          cp.off('message', handleMsg)
        }
      }

      cp.on('message', handleMsg)
    }

    if (IS_DEV_MODE) {
      forkProcess()
      return
    }
    forkProcess()
  }

整体用时序图表示就是:

3.2 Chrome 浏览器限制了打开 devtools 协议网页?!

在确定通过父子进程模型 + WebSocket 的方式来进行通信之后,我们其实就相当于明确了要在子进程去自动打开 devtools。

在上一个版本中,我们没有提到的地方是之前做了简陋版本的自动打开,由于复合了重载自动重启……会导致每次用户主程序更新代码都重新打开一个浏览器窗口。

import open from 'open'

const url = `devtools://devtools/bundled/inspector.html?ws=localhost:${this.port}`
await open(url);

刚开始考虑了各种检测方案来解决,但是发现效果都不好。

现在我们有了主动隔离的子进程,终于可以为所欲为了。

我们选择的是 open 这个库,专门用来打开各种超链接的 npm 库,不过不巧的是,这玩意儿只能成功在 Mac 上打开我们的非标准协议。

在 windows 上不行,而且有相关的 issues 记载,这其实是 Chrome 浏览器的限制:github.com/sindresorhu…

这意味着即便我们切换成 Chrome 的 cli 模式也是没有效果的。

但什么困难都挡不住我们,上一章我们讲过,我们通过 CDP 协议可以操纵浏览器网页,这个时候就非常适合我们出马了。

对比上一章我们启动了 CDP 服务端,这一次我们只要作为客户端进行操作就行。首先打开 chrome 的远程调试模式:

    const pro = await open(url, {
      app: {
        name: apps.chrome,
        arguments: [
          process.platform !== 'darwin' ? `--remote-debugging-port=${REMOTE_DEBUGGER_PORT}` : ''
        ]
      },
      wait: true,
    })

然后通过http://localhost:${REMOTE_DEBUGGER_PORT}/json 这个内置 API,我们可以拿到 websocket 调试地址,再进行 WebSocket 连接到 Chrome 上,我们就建立连接成功了:

    if (process.platform !== 'darwin') {
      const json = await new Promise<{ webSocketDebuggerUrl: string; id: string }[]>((resolve) => {
        let stop = setInterval(async () => {
          try {
            resolve((await fetch(`http://localhost:${REMOTE_DEBUGGER_PORT}/json`)).json())
            clearInterval(stop)
          } catch {
            log('waiting for chrome to open')
          }
        }, 500)
      })
      const { id, webSocketDebuggerUrl } = json[0]
      const debuggerWs = new WebSocket(webSocketDebuggerUrl)
    }

接下来进行操作,由于 CDP 协议模拟的是人工行为,等于绕开了 Chrome cli 的限制,我们成功打开了非标准协议的 devtools 网页。


      debuggerWs.on('open', () => {
        const navigateCommand = {
          id,
          method: 'Page.navigate',
          params: {
            url
          }
        }
        debuggerWs.send(JSON.stringify(navigateCommand))
        debuggerWs.close()
      })

不过我们在设计的时候要注意,Chrome 并非在任何系统都有安装,且浏览器并不一定要在本机开启才能使用我们的应用。

可以理解成,我们本质上是一个前后端分离应用。前端即 devtools,google 帮我们完成了这部分的开发;我们的主要内容都是后端,除了通信之外,和前端有关的工作内容就剩下「自动打开 App」这个能力,但该能力不是必要的,不应该 block 流程。

——所以我们设置 wait 为 true,这样抛出异常后我们可以捕获并截断错误。

3.3 进程存活、销毁与端口占用

虽然,我们通过多重通信「握手」的机制成功确保了首次通信的稳定性,但是我们仍面临两个问题

  1. 子进程虽然不会重启了,但是本身缺乏鲁棒性。一旦子进程因为某种原因抛出异常,那么用户也需要手动重启自己的主进程。
  2. 我们无法预料用户的输入。所以有一个隐性的问题是,当用户因为本身程序的问题进行重复调用 register 函数时,可以预料到会由于重复端口占用导致无法预期的缺陷。

那么我们来解决这两个问题,第一个问题的最简单解决方案是重启,但是我们要知道应该在什么时机重启以及在哪些时机不要🙅重启。

对此,我们需要认识一下这些事件:

重启上,我们只需要关注 unhandledRejectionuncaughtException 就行:

  • uncaughtException :当一个异常在事件循环中未被捕获时触发。也就是说,如果在代码执行过程中抛出了一个错误,而这个错误没有被 try...catch 块捕获,那么 uncaughtException 事件就会被触发。
  • unhandledRejection :当一个 Promise 被拒绝(即调用了 reject),但没有为其提供 catch 处理程序时触发。换句话说,如果一个 Promise 在被拒绝后没有处理拒绝的逻辑,unhandledRejection 事件就会被触发。

但为了防止无限重启,我们设置一个重启上限:

let restartCount = 0
const restartLimit = 5
const cleanRestartCountInterval = 30 * 1000
const restart = () => {
  setTimeout(
    () => {
      restartCount++
      if (restartCount >= restartLimit) {
        console.error('Restart limit reached')
        clean()
        return
      }
      main.close()
      main = loadCenter()
    },
    10 + Math.random() * 100
  )
}

setInterval(() => {
  restartCount = 0
}, cleanRestartCountInterval)

插叙一段:

进一步来说,我们看到一些平时更少见的事件,当进程触发了这些事件,意味着我们的进程很坑要被关闭,我们可以执行一些副作用清理函数。

  • exit: 当 Node.js 进程即将退出时触发。可以用来执行同步的清理操作。
  • SIGINT: 当用户使用 Ctrl+C 终止进程时触发。常用于捕获用户的中断信号并执行一些清理工作。
  • SIGTERM: 当进程接收到终止信号时触发。通常用于请求进程优雅地关闭。
  • beforeExit: 当 Node.js 清空事件循环并且没有其他工作要做时触发。可以用来调度一些异步操作,可能会导致 Node.js 继续运行。

在早期,node-network-devtools 会借助这些事件清理 lock 文件,不过现在由于设计升级,已经不再需要 lock 文件了,但这一部分设计仍保留在这里。

——注意,这些事件能触发,但是我们在其中执行的函数不一定能执行完毕,并不可靠。

我们再回来看到第二个问题,当用户本身的程序输入存在问题,导致了重复触发这种场景下,我们能做什么?

严格来说,这并非我们的问题,因为用户不按照我们推荐的方式使用。但从一个产品的角度,我们应当避免这种情况,至少,当用户这么做的时候,我们是不是可以告诉他:“你不要这样做,不然会出问题”?

我想是的。

我们在「创建子进程」之前增加一个校验步骤,校验是否曾经有主进程启动过一个参数相同的子进程,如果有,则跳过。

其中检测的代码如下,既包含了进程检测,也包含了端口占用检测。

import net from 'net'

export const checkMainProcessAlive = (pid: number | string, port: number) => {
  try {
    if (Number(pid) === process.pid) {
      return Promise.resolve(true)
    }
    process.kill(Number(pid), 0)

    // 检查 port 是否被占用
    return new Promise<boolean>((resolve) => {
      const server = net.createServer()

      server.once('error', (err: any) => {
        if (err.code === 'EADDRINUSE') {
          // 端口被占用
          resolve(false)
        }
        // fallback 其他错误
        resolve(false)
      })

      server.once('listening', () => {
        // 端口未被占用
        server.close(() => resolve(true))
      })

      server.listen(port)
    })
  } catch {
    return Promise.resolve(false)
  }
}

process.kill(Number(pid), 0)是用于检查进程是否存在的方法。它不会实际终止进程,而是通过发送信号 0 来检查指定的进程 ID (pid) 是否存在。如果进程存在且当前用户有权限访问该进程,则不会抛出错误;否则,会抛出一个错误。

而后边的 createServer,是基于 net 这个基础包完成的,通过检测状态是否存在错误,以及错误是否为 EADDRINUSE 来确定是否是端口占用问题。

4 总结

好啦,这次的优化之旅终于走到了尾声,咱们来好好回顾一下都做了些啥,又为啥这么做吧。

在上一版中,遗留了两个挺让人头疼的问题终于是解决了。一个是主进程重启时,咱们和 Chrome Devtools 的连接就断了,数据也没了,这对开发者来说体验太差;另一个就是还得手动打开那个devtools://devtools/bundled/inspector.html?ws=localhost:${this.port},实在是不方便。要知道,node-network-devtools主要就是用在开发环境里的,开发的时候代码经常改,进程动不动就重启,如果每次devtools都跟着折腾,那可真受不了。

经过这一系列的优化,node-network-devtools在开发环境中的体验有了很大提升。它能更稳定地监控网络请求,自动打开devtools网页也更方便了。

接下来,我们会继续关注开发者的反馈,不断改进这个工具,让大家用起来更顺手,开发效率更高!希望这次的优化能给大家带来实实在在的帮助,让开发变得更愉快!