MENU

ts-node-dev 的浅析

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

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 的回调函数不会执行。