造轮子 - nodejs 实现修改文件后自动重启服务

490 阅读4分钟

很多的nodejs工具都有修改文件后自动重启服务的功能,如vue-cli,pm2,supervisor,本文就想造一个这样的轮子看看

分析

这个功能主要遇到的问题是:

  1. 如何监控文件修改
  2. 怎么重新启动服务

如果上面的问题能解决,我们将直接使用如下代码解决问题

async function watchFile(dir, callback){};
async function restart(){};

async function run(dir) {
    await watchFile(dir, restart);
}

先解决第一个问题

监控文件修改

第一种办法就是对比文件修改时间,定时扫描监控目录树

首先先写一个函数,将nodejs的非promise方法可以转换为promise方法,稍后就可以直接使用async,await来简化代码和增加可读性

// 将error first风格的函数转换为promise风格的函数
function toPromise(fn) {
    return function() {
        let args = Array.prototype.slice.apply(arguments);
        return new Promise(((resolve, reject) => {
            args.push((err, data) => {
                return err?reject(err):resolve(data);
            });
            fn.apply(null, args);
        }));
    }
}

接下来需要建立一个对象来记录所有文件的修改时间,这里直接使用一个对象来记录,如: {'a/b.js': 10101010110}, key为文件路径,value为修改时间

现在来解决获取文件修改时间和遍历子目录的功能, 这里有两个选择,可以选择递归或者使用循环

先用递归实现一个吧

const fs = require('fs');
const path = require('path');

const stat = toPromise(fs.stat);
const readdir = toPromise(fs.readdir);

async function getStatusWithRecursive(dir) {
    let status = {};
    let file = path.resolve(dir);
    let s = await stat(file);
    if (!s.isDirectory()) {
        status[file] = s.mtime.getTime();
    } else {
        let dirs = await readdir(file);
        for (let d of dirs) {
            let subStatus = await getStatusWithRecursive(path.join(file, d));
            for (let k in subStatus) {
                if (subStatus.hasOwnProperty(k)) {
                    status[k] = subStatus[k];
                }
            }
        }
    }
    return status;
}

递归程序可能由于目录层级很深从而导致栈溢出,下面使用循环方式的一种实现

const stat = toPromise(fs.stat);
const readdir = toPromise(fs.readdir);
const path = require('path');

async function getStatusWithLoop(dir) {
    let status = {};
    let queue = [];
    queue.push(path.resolve(dir));
    while (queue.length > 0) {
        let file = queue.shift();
        let s = await stat(file);
        if (!s.isDirectory()) {
            status[file] = s.mtime.getTime();
        } else {
            let dirs = await readdir(file);
            queue.push(...dirs.map(f => path.join(file, f)));
        }
    }
    return status;
}

有了获取整个目录树状态的方法,现在需要一个能对比状态是否有变化的方法

async function isChange(oldStatus, newStatus) {
    // 有文件删除或者新增
    let oldKeys = Object.keys(oldStatus);
    let newKeys = Object.keys(newStatus);
    if (oldKeys.length !== newKeys.length) {
        return true;
    }

    // 老状态中存在新状态中不存在的key (有新文件删除)
    if (oldKeys.some(k => !newStatus.hasOwnProperty(k))) {
        return true;
    }

    // 新状态中存在老状态中不存在的key (有新文件创建)
    if (newKeys.some(k => !oldStatus.hasOwnProperty(k))) {
        return true;
    }

    // 对比文件修改时间
    return oldKeys.some(k => oldStatus[k] !== newStatus[k])
}

完成watchFile方法

async function watchFile(dir, callback) {
    let status = await getStatusWithLoop(dir);
    setInterval(async () => {
        let newStatus = await getStatusWithLoop(dir);
        let change = await isChange(status, newStatus);
        if (change) {
            callback();
        }
        status = newStatus;
    }, 1000);
}

更好的方式实现监控文件修改

写了这么一大堆代码实现文件修改监控, 但是有过linux使用经验的话,可能听过inotify这个东西,在nodejs里也有对应的绑定,就是fs.watch, 现在使用fs模块的代码重新实现watchFile方法

const fs = require('fs');
function watchFile(dir, callback) {
    fs.watch(dir, {recursive: true}, callback);
}

现在已经解决了第一个问题,可以着手解决第二个问题了

如何重新启动服务

由于node启动程序很快,我们不考虑热加载,直接使用多个进程的方式来实现,思路简单来说就是,如果文件有修改,那么就退出老的进程,然后使用新的代码创建一个新的进程

使用child_process模块实现

const child_process = require('child_process');
let pr;
function start() {
    pr = child_process.exec('node index.js');
    pr.on('exit', () => {
        setTimeout(start, 1000);
    });
    console.log('restart program.');
}
watchFile('.', async () => {
    if (pr) {
        pr.kill();
        pr = null;
    }
});
start();

使用上面方式实现需要准确找到node可执行文件的位置,还有index.js所在位置, 可用性不是很好,更好的方式是使用cluster模块实现

使用cluster模块实现, 在master进程中实现文件监听,在worker进程中启动具体需要重启的程序

const cluster = require('cluster');

// 这里引入主程序代码
function serve() {
    require('./index.js');
}

if (cluster.isMaster) {
    let pr;
    function fork() {
        pr = cluster.fork();
        pr.on('exit', () => {
            console.log('process restart.');
            // 如果子进程退出,一秒后重启(防止因为修改代码导致启动不了的时候重试速度过快)
            setTimeout(fork, 1000);
        })
    }
    watchFile('.', () => {
        if (pr) {
            pr.kill();
        }
    });
    fork();
} else {
    serve();
}

完整代码

const fs = require('fs');
const cluster = require('cluster');

async function watchFile(dir, callback) {
    fs.watch(dir, {recursive: true}, callback)
}

function serve() {
    require('./index.js');
}

if (cluster.isMaster) {
    let pr;
    function fork() {
        pr = cluster.fork();
        pr.on('exit', () => {
            console.log('process restart.')
            setTimeout(fork, 1000);
        })
    }
    // 监控文件修改,如果修改了,停止子进程, (子进程有监控退出事件,会自动启动新的进程)
    watchFile('.', () => {
        if (pr) {
            pr.kill();
        }
    });
    
    // 启动子进程
    fork();
} else {
    serve();
}