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 进程存活、销毁与端口占用
虽然,我们通过多重通信「握手」的机制成功确保了首次通信的稳定性,但是我们仍面临两个问题
- 子进程虽然不会重启了,但是本身缺乏鲁棒性。一旦子进程因为某种原因抛出异常,那么用户也需要手动重启自己的主进程。
- 我们无法预料用户的输入。所以有一个隐性的问题是,当用户因为本身程序的问题进行重复调用 register 函数时,可以预料到会由于重复端口占用导致无法预期的缺陷。
那么我们来解决这两个问题,第一个问题的最简单解决方案是重启,但是我们要知道应该在什么时机重启以及在哪些时机不要🙅重启。
对此,我们需要认识一下这些事件:
重启上,我们只需要关注 unhandledRejection
和 uncaughtException
就行:
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
网页也更方便了。
接下来,我们会继续关注开发者的反馈,不断改进这个工具,让大家用起来更顺手,开发效率更高!希望这次的优化能给大家带来实实在在的帮助,让开发变得更愉快!