在 Linux/Uinx 系统上,有一个 tail
命令,它可以用来显示一个文件尾部的内容,比如执行 tail large_file.txt
仅仅显示该文件最后的 10 行内容(通过 -n
参数可以指定显示的行数)。
tail
命令还有一个 -f
选项,可以监听文件内容的变化,当有新增的内容时会继续打印到屏幕上,因此在处理日志文件时常常会使用到它来跟踪文件变化。
前段时间在研究 Node.js 上的日志文件的处理时,偶然得知 tail -f
命令(下文简称 tailf
)的用法,因此十分好奇, 是否有一种 API 可以实时监听文件内容的变化? ,当然答案是否定的。后来经过网上查找资料,发现其实原理很简单,无非是不断地尝试 read()
文件的内容,如果能读取到就输出,仅此而已。
首先从网上找到一段 使用 Java 实现 tail -f 的代码 :
BufferedReader br = new BufferedReader(...); String line; while (keepReading) { line = reader.readLine(); if (line == null) { //wait until there is more of the file for us to read Thread.sleep(1000); } else { //do something interesting with the line } }
从上面的程序逻辑可以看出,在 while
循环里面,不断地尝试 readLine()
来读取一行内容,如果读取成功就继续,不成功则先 sleep(1000)
等待 1 秒钟。
因此我们可以使用以下 Node.js 代码实现相似的功能:
'use strict'; const fs = require('fs'); /** * tailf * * @param {String} filename 文件名 * @param {Number} delay 读取不到内容时等待的时间,ms */ function tailf(filename, delay) { // 每次读取文件块大小,16K const CHUNK_SIZE = 16 * 1024; // 打开文件,获取文件句柄 const fd = fs.openSync(filename, 'r'); // 文件开始位置 let position = 0; // 循环读取 const loop = () => { const buf = new Buffer(CHUNK_SIZE); const bytesRead = fs.readSync(fd, buf, 0, CHUNK_SIZE, position); // 实际读取的内容长度以 bytesRead 为准,并且更新 position 位置 position += bytesRead; process.stdout.write(buf.slice(0, bytesRead)); if (bytesRead < CHUNK_SIZE) { // 如果当前已到达文件末尾,则先等待一段时间再继续 setTimeout(loop, delay); } else { loop(); } }; loop(); }
说明:
fs.openSync()
来打开文件,得到文件句柄之后,再通过 fs.readSync()
读取文件内容 sleep()
方法,我们只能使用 setTimeout()
来模拟,不能直接使用 while
死循环,否则程序会占满整个 CPU 资源 将以上的代码保存为文件 tailf.js
,并且在文件末尾增加以下代码:
const filename = process.argv[2]; if (filename) { tailf(filename, 100); } else { console.log('使用方法: node tailf <文件名>'); }
现在我们来测试一下。首先执行以下命令新建一个日志文件:
$ echo "hello" > test.log
然后再开始监听文件的变化:
$ node tailf test.log
执行以上命令后,可以看到屏幕上打印出内容 hello
,但是程序还没有结束。再尝试在另一个控制台窗口下执行以下命令:
$ echo "$(date) hello, world" >> test.log
如果能看到 tailf
屏幕上打印出 Sat Jul 23 01:46:05 CST 2016 hello, world
这样的内容,说明我们实现的这个 tailf
命令已经基本上能用了。我们也不妨多执行几次上面的命令,还可以把 hello, world
改成其他的内容,好好感受一下,有木有一股很强的成就感迎面吹来呢……
上面的程序有一个小问题:每次执行 tailf
时都会先从头读取一遍文件,然后才开始监听,假如我们是用来处理很大的日志文件,每次都重头读取一遍似乎不太好,也对不起 tail
这个单词。所以呢,我们机智地修改一行代码解决它吧:
// 文件开始位置 let position = fs.fstatSync(fd).size;
说明:通过 fs.fstatSync()
读取文件的属性,然后得到当前文件的尺寸,直接把 position
设置到文件最末尾就行啦。
为了使得程序简单清晰,上文的程序用的都是 Sync
后缀的方法,这在只处理一个任务的 tailf
命令是最简单直接的。假如我们要实现一个 tailf
函数,将它嵌入到我们编写的项目里面处理多个监听文件内容的任务,那就得使用非阻塞的方法来操作文件了:
'use strict'; const fs = require('fs'); /** * tailf * * @param {String} filename 文件名 * @param {Number} delay 读取不到内容时等待的时间,ms * @param {Function} onError 操作出错时的回调函数,onError(err) * @param {Function} onData 读取到文件内容时的回调函数,onData(data) */ function tailf(filename, delay, onError, onData) { // 每次读取文件块大小,16K const CHUNK_SIZE = 16 * 1024; // 打开文件,获取文件句柄 fs.open(filename, 'r', (err, fd) => { if (err) return onError(err); // 文件开始位置 fs.fstat(fd, (err, stats) => { if (err) return onError(err); // 文件开始位置 let position = stats.size; // 循环读取 const loop = () => { const buf = new Buffer(CHUNK_SIZE); fs.read(fd, buf, 0, CHUNK_SIZE, position, (err, bytesRead, buf) => { if (err) return onError(err); // 实际读取的内容长度以 bytesRead 为准 // 并且更新 position 位置 position += bytesRead; onData(buf.slice(0, bytesRead)); if (bytesRead < CHUNK_SIZE) { // 如果当前已到达文件末尾,则先等待一段时间再继续 setTimeout(loop, delay); } else { loop(); } }); }; loop(); }); }); }
说明:
Sync
后缀,改用回调函数获取结果 tailf
新增了两个参数 onError
和 onData
,分别用来回调操作时发生错误和检测到文件内容更新,其中 onData
会被执行多次 现在可以这样使用 tailf()
:
const filename = process.argv[2]; if (filename) { tailf(filename, 100, err => { if (err) console.error(err); }, data => { process.stdout.write(data); }); } else { console.log('使用方法: node tailf <文件名>'); }
测试方法还是跟上文的一样,当然这么简单的场景根本看不出区别啦。