命令行工具CLI以及FS API

构建首个应用:一个简单的命令行文件浏览器,其功能是允许用户读取和创建文件。

需求:

  • 程序需要在命令行运行。这就意味着程序要么通过node命令在执行,要么直接执行,然后如通过终端提供交互给用户输入、输出。
  • 程序启动后,需要显示当前目录下列表。
  • 选择某个文件,程序需要显示该文件内容。
  • 选择一个目录时,程序需要显示该目录下的信息。
  • 运行结束后程序退出。

根据上述需求,可以细分几个步骤。

  • 创建模块
  • 决定采用同步的fs还是异步的fs
  • 理解什么事流(Stream)
  • 实现输入输出
  • 重构
  • 使用fs进行文件交互
  • 完成

编写首个Node程序

开始基于上述步骤来编写一个模块。模块由几个文件组成,使用任意文本编辑器。

创建模块

新建目录,命名为:file-explorer

首先定义package.json文件,这样既可以方便 NPM中注册的模块依赖进行管理,将来也能对模块进行发布。
尽管此项目仅仅用到Node.js的核心模块API(因此不会从NPM仓库中获取模块),但是,我们还是需要一个简单的package.json文件。

{
    "name": "file-explorer",
    "version": "0.0.1",
    "description": "A command-file file explorer!"
![1.jpg](https://upload-images.jianshu.io/upload_images/1666407-e791bb177381a46c.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
}

验证package.json文件是否有效,可以运行npm install。
正确不会输出任何内容,否则会抛出JSON异常的错误。

然后创建一个index.js文件。

同步还是异步

由于stido API是全局process对象的一部分,所以,我们的程序唯一的依赖就是fs模块。

/**
 * Module dependencies.
 */

var fs = require("fs");

首先获取当前目录的文件列表。
fs模块是唯一一个同时提供同步和异步的API的模块。举个例子,要想获取当前目录的文件列表,可以这样:

console.log(fs.readdirSync(__dirname));

它会立刻返回内容或者当有错误发生时抛出相应异常。


下面是异步版本:

function async(err,files) {
    console.log(files);
}

require("fs").readdir(".",async);

我们在之前提到过,要在单线程中创建能够处理高并发的高效程序,就得采用异步、事件驱动的程序。

未标题-1.jpg

尽管这个命令行创建并非此类型创建(因为同一时间只会有一个人在读取文件),但是,为了学习node.js中最重要也是最具有挑战的部分,还是保持这种异步的代码风格。


为了获取文件列表,我们需要使用fs.readdir。我们提供的回调函数首个参数是一个错误对象(如果没有错误发生,该对象为Null),另外一个参数是一个files数组:

fs.readdir(".",function(err,files) {
    console.log(files);    
});

到现在,你知道了fs模块同时提供同步和异步的API来操作文件系统,接下来进入另一个基础概念——流。


理解什么是流(stream)

console.log会输出控制台。事实上,console.log内部做了这样的事情:它在指定的字符串后加上\n(换行)字符,并将其写到stdout流中。

process全局对象中包含了三个流对象,分别对应三个UNIX标准流:

  • stdin:标准输入
  • stdout:标准输出
  • stderr:标准错误

第一个stdin是一个可读流,而stdout和stderr都是可写流。

stdin流默认的状态是暂停的(paused)。通常,执行一个程序,程序会做一些处理,然后退出,不过,有些时候,程序需要一直处于运行状态来接收用户输入数据。
当回复那个流时,Node会观察对应的文件描述符(在UNIX下为0),随后保持事件循环的运行,同时保持程序不退出,等待事件触发。除非有IO等待,否则node.js总是会自动退出。

流的另外一个属性是它默认的编码。如果在流上设置了编码,那么会得到编码后的字符串(utf-8、ascii等)而不是原始的Buffer作为事件参数。

Steam对象和EventEmitter很像(事实上,前者继承自后者)。在Node中,你会接触到各种类型流,如TCP套接字、HTTP请求等。简而言之,当涉及持续不断地对数据进行读写时,流就出现了。

输入和输出

既然已经知道运行程序后大概是怎样的一个情形了,我们来尝试写第一部分,列出当前目录下的文件,然后等待用户输入。

var fs = require("fs");

fs.readdir(process.cwd(),function(err,files) {
    console.log("");

    if (!files.length) {
        return console.log("\033[31m No files to show!\033[39m\n");
    }

    console.log("     Select which file or directory you want to see\n'");

    function file(i) {
        var filename = files[i];

        fs.stat(__dirname + "/" + filename, function(err,stat) {
            if (stat.isDirectory()) {
                console.log("         " + i + "    \033[36m" + filename + "/\033[39m");
            } else {
                console.log("        " + "\033[90m]" + filename + "\033[39m");
            }

            i++;
            if (i == files.length) {
                console.log("");
                process.stdout.write("    \033[33mEnte your choick: \033[39m");
                process.stdin.resume();
            } else {
                file(i);
            }
        })
    }

    file(0);
})


为了输出更加友好,我们首先输出一个空行:

console.log("");

如果files数组为空,告知用户当前目录没有文件。文件周围的\033[31m\033[39m是为了让文本呈现为红色。例子中最后一个字符又是换行符\n,也是为了输出可读性更好。

    if (!files.length) {
        return console.log("\033[31m No files to show!\033[39m\n");
    }

下一行则是让用户执行操作:

    console.log("     Select which file or directory you want to see\n'");

紧接着,定义了一个函数,数组中每个元素都会执行该函数。这里也出现了第一种#异步流控制模式:串行执行。

function file (i) {
    // ...
}

然后,先获取文件名,再查看文件名对应路径的情况。fs.stat会给出文件或者目录的元数据:

var filename = files[i];
fs.stat(__dirname + "/" + filename, function(err,stat) {
     // ...
}

回调函数还给出了错误对象(如果有的话)和一个Stat对象。本例中使用到的Stat对象上的方法是isDirectory

            if (stat.isDirectory()) {
                console.log("         " + i + "    \033[36m" + filename + "/\033[39m");
![QQ截图20180824014417.jpg](https://upload-images.jianshu.io/upload_images/1666407-9023f7e60706dd4b.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
            } else {
                console.log("        " + "\033[90m]" + filename + "\033[39m");
            }

如果路径所代表的是目录,我们就用别于文件的颜色标识出来。

接下来就到了流控制中的核心部分了,计数器不断递增,与此同时,检查是否还有未处理的文件:

            i++;
            if (i == files.length) {
                console.log("");
                process.stdout.write("    \033[33mEnte your choick: \033[39m");
                process.stdin.resume();
            } else {
                file(i);
            }

如果所有文件处理完毕,此时提示用户进行选择。注意,这里使用的是process.stdout.write而不是console.log,这样就无须换行,让用户可以直接在提示语后进行输入。

console.log("");
process.stdout.write("    \033[33mEnte your choice: \033[39m");
程序提示用户像stdin进行输入
`process.stdin.resume()`:等待用户输入。
`process.stdin.setEncoding('utf8')`:设置流编码为utf8,这样就能支持特殊字符了。

如果还有未处理的文件,则递归调用函数来进行处理:

file(i);

直到列出所有文件、用户输入完毕后,紧接着进行下一步串行处理。

重构

要做重构,我们从为几个常用的变量(stdin和stdout)创建快捷变量开始:

var fs = require("fs"),
    stdin = process.stdin,
    stdout = process.stdout

由于书写的代码都是异步的,因此,会有这样的问题:随着函数量的增长(特别是流控制层的增加),许多的函数嵌套会让程序的可读性变差。

为了避免此类问题,我们可以为每一个异步操作预先定义一个函数。
首先,我们抽离出一个读取stdin函数:

// called for each file walked in the directory
function file(i) {
    var filename = files[i];
    
    fs.stat(__dirname + "/" + filename, function (err, stat) {
        if (stat.isDirectory()) { // 判定是否为一个目录还是一个文件
            console.log("    " + i + "    \033[36m" + filename + "/\033[39m");
        } else {
            console.log("    " + i + "     \033[90m" + filename + "\033[39m");
        }

        if (++i === files.length) {
            read();
        } else {
            file(i);
        }
    });
}
// read user input when files are shown
function read() {
    console.log("");
    stdout.write("    \033[33mEnte your choice: \033[39m");
    stdin.resume();
    stdin.setEncoding("utf8");
}

注意,上述代码所使用的是新的stdin的引用和stdout的引用。

读取用户输入后,接下来要做的就是根据用户输入做出相应处理。用户需要选择要读取的文件,所以,代码层面,设置了stdin的编码后,就开始监听其data事件:

function read() {
    // ...
    stdin.on("data",option)
}
// called with the option supplied by the user
function option(data) {
    if (!files[Number(data)]) {
        stdout.write("     \033[31mEnter your choice: \033[39m");
    } else {
        stdin.pause();
    }
}

这里检查用户的输入是否匹配files数组的下标。还记得files数组是fs.readdir回调函数中的一部分吧。另外,注意的是,上述代码中,我们将utf-8编码的字符串类型data转化为Number类型来方便做检查。

如果检查通过,我们要确保再次将流暂停(回到默认状态),以便于之后做完fs操作后,程序顺利退出。

现在程序能够与用户进行交互了,将当前目录的文件列表展现给用户,下面来实现读取和显示文件内容。

用fs进行文件操作

定位到文件,读取它:

function option(data) {
    var filename = files[Number(data)];
    
    if (!filename) {
        stdout.write("    \033[31mEnter your choice: \033[39m");
    } else {
        stdin.pause();
        fs.readFile(__dirname + "/" + filename , "utf8" , function(err,data) {
            console.log("");
            console.log(
                "\033[90m" + data.replace(/(.*)/g,'    $1') + "\033[39m"
            );
        });
    }
}

提醒:我们可以事先指定编码,这样得到的数据就是相应的字符串了:

fs.raedFile(__dirname + '/' + filename, "utf8' , function(err,data) {
    // ...
})

接着,可以使用正则表达式添加一些辅助缩进后将文件内容进行输出:

data.replace(/(.*)/g , '    $1')

不过,要是选择的是目录呢?这种情况下,应当将其目录下的文件列表显示出来。

为了避免再次执行fs.stat,我们在file函数中,将Stat对象保存了下来:

// ...
var stats = [];
function file(i) {
    var filename = files[i];

    fs.stat(__dirname + "/" + filename , function(err,stat) {
        stats[i] = stat;
        //...
    })
}

现在可以轻松地在option函数中进行检查操作了。

最终源码:

// 模块依赖
var fs = require('fs'),
    stdin = process.stdin,
    stdout = process.stdout;

// 读取当前目录下的文件内容
// 返回当前进程的目录路径
// console.log(process.cwd());
fs.readdir(process.cwd(), function (err, files) {
    // 将文件保存到 files 数组中
    // console.log(files);
    // 空行
    console.log('');
    // 当没有文件的提示信息
    if (!files.length) {
        return console.log('没有文件');
    }
    // 有文件的提示信息
    console.log('请选择你所看见的文件或者目录');

    // 保存目录
    var stats = {};
    // 遍历文件--目录还是文件
    // i 表示选择文件的索引
    function file(i) {
        var filename = files[i];
        // console.log(filename);
        // 返回文件或者目录的元数据
        fs.stat(__dirname + '/' + filename, function (err, stat) {
            stats[i] = stat;
            // 判断目录或者文件
            if (stat.isDirectory()) {
                console.log('   \033[36m' + i + ' ' + filename + '\033[39m');
            } else {
                console.log('   ' + i + ' ' + filename);
            }
            // 遍历完毕
            // console.log(files.length);
            if (++i == files.length) {
                read();
            } else {
                // 递归
                file(i);
            }
        });
    }

    // 读取用户输入的信息
    function read() {
        // 空行
        console.log('');
        // 提示输入目录名称信息---不换行
        stdout.write('请输入你的选择(数字):');
        // 标准输入流默认是暂停的,我们要恢复它
        // 等待用户输入
        stdin.resume();
        // 编码
        stdin.setEncoding('utf8');
        // 监听用户的输入
        stdin.on('data', option);
    }

    // 用户输入的信息
    function option(data) {
        // 选择文件的名称
        var filename = files[Number(data)];
        // console.log(filename);
        if (!filename) {
            stdout.write('请输入你的选择(数字):');
        } else {
            stdin.pause();
            // 读取文件的内容
            if (stats[Number(data)].isDirectory()) {
                fs.readdir(__dirname + '/' + filename, function (err, files) {
                    console.log('');
                    console.log('(' + files.length + ' 个文件)');
                    // 遍历文件名称
                    files.forEach(function (file) {
                        console.log(' - ' + file);
                    });
                    console.log('');
                })
            } else {
                fs.readFile(__dirname + '/' + filename, 'utf8', function (err, data) {
                    console.log('');
                    // 行缩进
                    console.log(data.replace(/(.*)/g, ' $1'));
                })
            }

        }
    }
    file(0);
})

注意点:

process.cwd()__dirname的区别:

process.cwd():运行当前脚本的工作目录的路径process.cwd()
__dirname:是被执行的js 文件的地址 ——文件所在目录__dirname


argv
process.argv包含了所有Node程序运行时的参数值:


返回一个数组,第一个元素为process.execPath,第二个元素为当前执行的JavaScript文件路径。剩余的元素为其他命令行参数。

--

退出
要让一个应用退出,可以调用process.exit并提供一个退出代码。比如,当发生错误时,要退出程序,这个时候最好使用退出代码。

console.error("An error occurred");
process.exit();

ANSI转义码

要在文本终端下控制格式、颜色以及其他输出选项,可以使用ANSI转义码。
在文本周围添加的明显不用于输出的字符,称为非打印字符

console.log('\033[90m' + data.replace(/(.*)/g , '    $1') + '\033[39m')
  • \033表示转义序列开始。
  • [表示开始颜色设置。
  • 90表示前景色为亮灰色。
  • m表示颜色设置结束。

结尾的39用来将颜色再设置回去。


fs
fs模块允许通过Stream API来对数据进行读写操作。与readFile及writeFile方法不同,它对内存的分配不是一次完成的。

比如,有一个大文件,文件内容上百万行逗号分隔文本组成。要完成的读取该文件进行解析,意味着一次性分配很大的内存。更好的方式应当是一次只读取一块内容,以行尾结束符("\n")来切分,然后再逐块进行解析。

fs.createReadStream方法允许为一个文件创建一个可读的Stream对象。
来看例子:

fs.readFile("my-file.txt" , function(err,contents) {
    // 对文件进行处理
})

上述例子中,回调函数必须要等到整个文件读取完毕、载入到RAM、 可用的情况下才会触发

而下面的例子,每次会读取可变大小的内容块,并且每次读取之后会触发回调函数:

var stream = fs.createReadStream("my-file.txt");
stream.on("data",function(chunk) {
    // 处理文件部分内容
});
stream.on("end",function(chunk) {
    //  文件读取完毕
})    

为什么这种能力很重要呢?假设有个很大的视频文件需要上传到某个Web服务。这时,你无须在读取完整的视频内容后开始上传,使用Stream就可以大大提速上传过程。

这对日志纪录的例子也一样,特别是使用可写stream。假设有个应用需要纪录网站上的访问情况,这时,为了将纪录写到文件中,让操作系统进行打开/关闭文件的操作可能就很低效(每次都要在磁盘上进行查找文件操作)。

所以,这就是一个很好的使用fs.WriteStream的例子。打开文件操作只做一次,然后写入每个日志项时都调用.write方法。

监视
Node允许监视文件或目录的变化。监视意味着当文件系统中文件(或者目录)发生变化时,会分发一个事件,然后触发指定的回调函数。

该功能在Node生态系统中被广泛使用。举例来说,有人喜欢用一种可以编译为CSS的语言来书写CSS样式。这个时候,就可以使用监视功能,当源文件发生改变时,就将其编译为CSS文件。


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,372评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,368评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,415评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,157评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,171评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,125评论 1 297
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,028评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,887评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,310评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,533评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,690评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,411评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,004评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,659评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,812评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,693评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,577评论 2 353

推荐阅读更多精彩内容