MENU

ts-node-dev 的浅析

November 12, 2022 • Read: 329 • 学习记录

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)

image-20221111140601489.png

三. 遇到的问题

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)

结果截图:

image-20221111191639340.png

从结果可以看出,我们的服务并没有重新启动。原理将在后面部分进行解析

解决方式:

async function shutdownGracefully(signal: string, num: number) {
    // ....
    process.exit(0) // 退出当前服务的进程
}

服务重新启动:

image-20221111192016414.png

正确的优雅退出方式:

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)
}

结果截图:

image-20221111192351925.png

从结果来看,我们自定义的 优雅退出方法 并没有执行,但是服务已经重新启动了。所以,当需要自定义实现优雅退出时,这个参数慎用

四. 源码分析

当运行 ts-node-dev src/index.ts 命令时,其执行流程是如何?接下来将进行分析。

流程图.jpg

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.ts

function 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() 方法

当文件发生改变时,将触发watcherchange 事件(执行 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 的回调函数不会执行。