ts-node-dev 的浅析
0. 前言
本文主要是对 ts-node-dev 进行简单介绍、以及作者本人我遇到的一些问题、还有对出现问题地方的 ts-node-dev 的源码原理分析。
一. 简介
ts-node-dev 是基于node-dev 做的一个用于ts-node 服务重启工具。
相较于node-dev -r ts-node/register ...
, nodemon -x ts-node ...
这些同类工具来说,由于其不需要每次重新实例化 ts-node 编译器
,所以拥有更快的重新启动速度。
下面是原文:
Tweaked version of node-dev that uses ts-node under the hood.
It restarts target node process when any of required files changes (as standard node-dev
) but shares Typescript compilation process between restarts. This significantly increases speed of restarting comparing to node-dev -r ts-node/register ...
, nodemon -x ts-node ...
variations because there is no need to instantiate ts-node
compilation each time.
二. 使用
下载:
npm i ts-node-dev --save-dev
ts-node-dev src/index.ts
src/index.ts
import * as http from 'http'
const server = http.createServer(() => {
console.log('server')
}).listen(3001, () => {
console.log('server start')
})
function shutdownGracefully(sin: string) {
server.close()
.on('close', () => {
console.log('server close')
// 最好加上
process.exit()
})
}
process.on('SIGTERM', shutdownGracefully)
三. 遇到的问题
1. 实现 Node 服务
的优雅退出时,ts-node-dev 重启服务失败。
场景:当我们在 k8s
环境时, 进行旧 Pod 关闭时,一般 Node 服务
需要实现优雅退出,来关闭 redis
、数据库
等连接。
一个简单的例子
// 一个简单的例子
async function shutdownGracefully(signal: string, num: number) {
console.log(`shutdownGracefully, Terminating by signal ${signal}(${num}).`)
await Promise.all([
redisClient.quit(),
mysqlClient.close()
])
}
process.on('SIGTERM', shutdownGracefully)
结果截图:
从结果可以看出,我们的服务并没有重新启动。原理将在后面部分进行解析。
解决方式:
async function shutdownGracefully(signal: string, num: number) {
// ....
process.exit(0) // 退出当前服务的进程
}
服务重新启动:
正确的优雅退出方式:
async function shutdownGracefully(signal: string, num: number) {
console.log(`shutdownGracefully, Terminating by signal ${signal}(${num}).`)
server.close()
.on('close', async () => {
try {
await Promise.all([
redisClient.quit(),
mysqlClient.close()
])
} finally {
// 为了保险,尽量开发者自己添加这行代码。虽然 server.close() 也会执行服务进程退出。
process.exit(0)
}
})
}
- 当使用 --exit-child
参数时,自定义的优雅退出方法失效。
async function shutdownGracefully(signal: string, num: number) {
console.log(`shutdownGracefully, Terminating by signal ${signal}(${num}).`)
await Promise.all([
redisClient.quit(),
mysqlClient.close()
])
process.exit(0)
}
结果截图:
从结果来看,我们自定义的 优雅退出方法
并没有执行,但是服务已经重新启动了。所以,当需要自定义实现优雅退出时,这个参数慎用
。
四. 源码分析
当运行 ts-node-dev src/index.ts
命令时,其执行流程是如何?接下来将进行分析。
1. 流程分析
runDev 函数
当运行
ts-node-dev src/index.ts
命令时,将会调用 runDev() 方法/** * 执行 ts-node-dev src/index.ts 时,运行的就是这个函数 * @param script 这个就是 index.ts 文件 */ export const runDev = ( script: string, // ...... 省略 ) => { // ...... 省略 // 加载 wrap.js const wrapper = resolveMain(__dirname + '/wrap.js') function initWatcher() { // ...... 省略 // 当有文件发生改变时,就是直接调用 restart() 方法 watcher.on('change', restart) } // 初始化文件的监听器 let watcher = initWatcher() // ...... 省略 function start() {// ...... 省略 } const killChild = () => {// ...... 省略 } function stop(willTerminate?: boolean) {// ...... 省略} // 当有文件发生改变时,就是调用这个方法来重启服务 function restart(file: string, isManualRestart?: boolean) {// ...... 省略} // ...... 省略 const compiler = makeCompiler(opts, { restart, log: log, }) // 一般情况下,由于只会在开始时,运行 ts-node-dev 命令执行 runDev(),所以只会实例化一个 ts 的编译器对象 compiler.init() // 最后,调用 start() 方法 start() }
start() 方法
start() 方法将会用
子进程
来执行传入的 index.tsfunction start() { // ...... 省略 /** * 将要执行的脚本。script 参数:就是 index.ts * 注意这里 warp = wrap.js */ let cmd = nodeArgs.concat(wrapper, script, scriptArgs) // 创建一个子进程来执行脚本(包含传入的 index.ts),也就是服务是用子进程来执行的。 child = fork(cmd[0], cmd.slice(1), { cwd: process.cwd(), env: process.env, }) // ...... 省略 }
restart() 方法
当文件发生改变时,将触发
watcher
的change
事件(执行 watcher.on('change', restart))// 当有文件发生改变时,就是调用这个方法来重启服务 function restart(file: string, isManualRestart?: boolean) { // ...... 省略 watcher.close() watcher = initWatcher() starting = true if (child) { // 一般走这个分支 /** * 这里是重启服务的关键。这里监听了子进程退出的事件,子进程退出时,重新执行 start() 方法。 * 所以,如果子进程不退出,开发者的服务就不会重新启动,此时子进程就还是运行的旧服务。 */ child.on('exit', start) // 杀掉运行服务的子进程 stop() } // ...... 省略 }
stop() 方法
给运行服务的子进程发送
SIGTERM
信号,结束掉子进程。const killChild = () => { // ...... 省略 } else { /** * 给子进程发送退出的信号。优雅退出也是靠这个 SIGTERM 信号实现。 * 如果子进程触发了 exit 事件后,child.on('exit', start) 这里就会执行, * 然后服务就重新启动了 */ child.kill('SIGTERM') } } function stop(willTerminate?: boolean) { // ...... 省略 if (child.connected === undefined || child.connected === true) { // ...... 省略 // willTerminate 一般为 false if (!willTerminate) { // 执行 killChild() } } }
warp.js
在执行
runDev
方法时,默认加载 wrap.js
,其会和index.ts
一起由子进程来执行。其默认监听
SIGTERM
信号:// Listen SIGTERM and exit unless there is another listener process.on('SIGTERM', function () { /** * 在没有开发者自定义添加对 SIGTERM 的监听函数时,这里就会默认执行“子进程”的退出 * 所以,在自定义 SIGTERM 的处理函数时,一定要执行 process.exit(0),让子进程退出,从而让服务重新启动。 */ if (process.listeners('SIGTERM').length === 1) process.exit(0) })
2. 对第三点遇到问题的原理解答
- ts-node-dev 重启服务失败
原因在于自定义的 SIGTERM 处理函数,没有让 子进程
退出,子进程不退出就会让 start() 方法
无法重新执行,也就导致无法创建新的子进程来重启服务。
// 开发者自定义的
async function shutdownGracefully(signal: string, num: number) {
console.log(`shutdownGracefully, Terminating by signal ${signal}(${num}).`)
await Promise.all([
redisClient.quit(),
mysqlClient.close()
])
}
- 在有 --exit-child 参数的情况:
// child-require-hook.js 会自动注册一个 SIGTERM 回调函数,如下
if (exitChild) {
process.on('SIGTERM', function () {
console.log('Child got SIGTERM, exiting.');
// 程序退出
process.exit();
});
}
// 这个函数是 SIGTERM 信号处理函数集合的第一个,所以函数执行,子进程就退出了,这样就导致”开发者“自己注册 SIGTERM 的回调函数不会执行。