记一次用 Node.js 重写工具的过程
之前用 Python 做了一个方便项目开发的脚手架工具,这个工具中用到了 Python 的 C 绑定形式的模块, 于是在安装时就需要机器中有相应的编译环境,比如 OS X 上先装 ports 然后用它装一些库。如果事先没有这套编译环境,第一次安装时还是比较折腾的,虽然我已经尽可能详尽地写文档了。
稳定用了一段时间之后,我现在考虑用 Node.js 来把这个工具重新实现一下,这样在环境方面应该就会友好很多了,毕竟 Node.js 的环境肯定是事先就准备好了的。(不过,我好像记得 Node.js 的某些模块也是需要编译环境的)
1. 要做什么
就是一个 z.py
的文件,它目前的功能有 5 个:
python z.py init
python z.py project PROJECT_NAME
python z.py app PROJECT_NAME APP_NAME
python z.py sass SASS_FILE_ABSOLUTE_PATH
python z.py html HTML_FILE_ABSOLUTE_PATH
init
会创建一个代码库的初始目录结构,当然这个功能几乎不会用到(我所有项目都放一个代码库)。
project
是在代码库中创建一个“项目”的目录结构,大概长这样:
test ├── common │ ├── app.sass │ └── app.sass.css ├── config.js ├── iconfont │ ├── app.sass │ └── app.sass.css ├── index │ ├── app.js │ ├── demo.html │ └── _index.html ├── page-index │ ├── app.html │ ├── app.html.js │ ├── app.js │ ├── app.sass │ ├── app.sass.css │ └── demo.html └── service └── app.js
当然,这个项目名字是 test
,所以里面的一些文件中的名字是跟 test
有关的(一些约定)。
app
跟 project
相似,不过它是创建一个“组件”的目录结构,这是平时使用最多的功能(为此会在 IDE 中专门为它配置“快捷方式”),一个组件大概长这样:
test/first ├── app.html ├── app.html.js ├── app.js ├── app.sass ├── app.sass.css └── demo.html
同理,这个组件是 test
项目中的名为 first
的组件,那么上面文件中的一些内容,是跟 test
和 first
这些词有关的。比如 test/first/app.js
中会有:
var MODULE_NAME = 'test/first'; var DIRECTIVE_NAME = 'testFirst';
这种内容。
sass
的功能是把 app.sass
变成 app.sass.js
,就是普通的 sass 转换行为。
html
的功能是把 app.html
变成 app.html.js
,这个功能简单来说是把 app.html
中的 html 片段(注意只是片段)以一个字符串形式放到 app.html.js
中( app.html.js
中的内容是 AMD 形式的)。
init
project
app
sass
html
5 个功能,对我这个没正经写过 Node.js 的人来说,我预估还是要花些时间的。
2. 先要解决什么问题
我之所以想把这次重写的过程记录下来,是因为我觉得目前面对的这个问题,算是“一个有经验的人如何去学习使用一种新语言”的典型场景。换句话说,即使选择的不是 Node.js 来重写这个工具,而是其它的 A 语言,X 语言,我接下来要做的事也没有什么不同。
要完成这个工具,我认为我首先要学会使用 Node.js 处理以下场景:
- 标准输入输出在 Node.js 中如何处理。
- 字节与字符,编码,等问题在 Node.js 中是怎样的。
- 如何处理命令行参数。
- 文件 IO。
- 找个解析 HTML 的方法。
- 选择一种模板引擎。
当然,在过程中还有一些零碎的小问题,这些都后面一个一个来搞定。
3. 标准输入输出
console.log
似乎就是标准输出,但是,显示这只是一个“别名”才对,使用 console
这个名字作为标准输出太不专业了。
我刚开始为标准输入输出而去翻官方文档时,整个人是懵逼的,找不到 system
, io
这类东西啊,后来通过 Google 才知道标准输入输出是放在 process
这个名字空间下的。
标准输出除了用 console.log
外,它比较“正式”的名字应该是 process.stdout()
:
var stdout = process.stdout; stdout.write('ok'); stdout.write('ok');
这里注意一下,跟其它语言类似, console.log
是加 \n
的。
标准输出有了,随便猜一下,标准错误应该是 process.stderr
和 console.error
吧:
var stderr = process.stderr; stderr.write('error\n'); console.error('console'); console.log('stdout');
直接看看不出区别的,在 bash
中稍处理一下(假设上面几行代码在文件 test.js
中):
$ node test 1>/dev/null error console $ node test 2>/dev/null stdout
看起来没问题。
然后是标准输入, prcess.stdin
,这里就涉及几个读的操作了:
var stdin = process.stdin; var buff = []; stdin.on('readable', function(){ var chunk = stdin.read(); if(chunk !== null){ buff.push(chunk); } }); stdin.on('end', function(){ console.log(buff.join('\n')); });
在终端中:
$ echo "123"|node test.js 123
stdin
的 api 就是 Node.js 的异步IO 那套,这个地方事件倒是比 while 1
的死循环好看些。
4. Node.js 中的字节与字符
我对 Node.js 中的字符中的细节是没有任何概念的,先从这里着手吧(我源文件是 UTF-8 编码):
console.log(('中文').length); // 2
能看到输出的结果是 2
,当然,这个 2
可能真的是表示 '中文'
这个字符串是 2 个字符,但是也不排除 length
这个方法实现上的处理。
暂且认为字符串在 Node.js 中是“字符”的概念,那么接下来,把文件的源码改成 GBK 看看会发生什么:
$ iconv demo.js -f utf8 -t gbk > demo.gbk.js $ node demo.gbk.js 4
输出的是 4
, WTF …… ,这不被坑成猪头才怪。
通过 google 之后,目前我对 Node.js 的概念是,Node.js 中的字符串是“字符”的概念,但是,它的源文件只能是 utf8 ,换句话说,“编译”时就假定“字符串”中的“字节”是 UTF-8 的编码。
要处理“字节”的话,Node.js 有提供 Buffer
这个对象,它初始化时接收的数组可以看作是“字节序列”,通过 toString(encoding)
方法可以转成“字符”,从最简单的 ASCII 开始:
var buff = new Buffer([65]); console.log(buff.toString()); // A
给个“中”字的 UTF-8 的三字节:
var buff = new Buffer([0xe4, 0xb8, 0xad]); console.log(buff.toString()); // 中
看来 toString()
的默认行为就是按 UTF-8 进行“解码”啊。
Buffer
还有 hex
和 base64
这两个比较常用的编码方式:
var buff = new Buffer([0xe4, 0xb8, 0xad]); console.log(buff.toString('hex')); console.log(buff.toString('base64')); console.log(buff.length); // 3
Buffer.length
方法得到的就是字节长度了。
从 api 层面考虑的话, buff.toString('gbk')
应该是按 GBK 编码进行“解码”了,不过,坏消息是目前实现的只有 UTF-8 一种编码。
所以,在 Node.js 中如果涉及到除 UTF-8 之外的其它“字节串”的话,只能借助额外的模块能力来处理,比如 https://github.com/bnoordhuis/node-iconv 。
5. 命令行参数
使用:
process.argv
可以获取当前命令行的所有参数,注意,这里的“所有参数”包括了执行程序 node
与目标文件路径,比如:
[ '/opt/node/bin/node', '/home/zys/temp/demo.js' ]
这个样式,是一个列表。
把 js 文件处理成 Linux 风格的可执行文件对命令行参数没有影响。比如对于文件:
#!/opt/node/bin/node
console.log(process.argv);
通过 chmod +x
之后,直接 ./demo.js
结果还是:
[ '/opt/node/bin/node', '/home/zys/temp/demo.js' ]
6. 文件IO
这个标题本来只是我个人随便起来,没想到在 nodejs 的 API 中,“文件”跟“IO”还真弄在一起了,一般来说“文件”是操作系统级别的一组 API ,而“IO”则是上层的逻辑性操作,混在一起略有些不优雅啊。
从官方的文档上看,文件IO这一组的功能,是放在 fs
这个模块中的,它包括的内容大概有:
- 文件系统的操作,比如创建文件,创建目录,创建链接等。
- 针对“文件”的高层操作,包括同步,异步两套。
- 针对 fd 的低层操作,包括同步,异步两套。
这里,我记得在 Linux 下文件类型的 fd 是不支持异步的,所有针对“文件”的异步操作没意义吧。
6.1. 文件同步读写
fs.readFileSync
和 fs.writeFileSync
。
fs.writeFileSync
的使用方法是:
var fs = require('fs') fs.writeFileSync('/home/zys/temp/demo.txt', 'data here', { encoding: 'utf8', flag: 'w', mode: 0o666 });
参数当中:
encoding
这个应该是指定编码,不过好像 nodejs 目前只支持 utf8 。flag
是模式,常用的有w
和a
,前者是替换旧内容,后者是在旧内容后面追加。mode
是文件的权限,这个只在针对新文件时才有效,已存在文件只是替换内容,原文件的权限不会更改。
fs.readFileSync
的使用方法是:
var fs = require('fs') var content = fs.readFileSync('/home/zys/temp/demo.txt', { encoding: null, flag: 'r'}); console.log(content.toString());
encoding
和 flag
这两个参数在这里等于都是写死的,因为我不知道还能是其它哪些值。
注意一点就是, fs.readFileSync
返回结果的类型不是字符串,而是 Buffer
。
6.2. 完整的模式
就记一下,反正多半用不到。
- r - 打开文件准备读,如果文件不存在会抛出异常。
- r+ - 打开文件,可读可写,如果文件不存在会抛出异常。
- rs - 以同步模式打开文件,准备读,并尝试让操作系统不处理本地文件缓存。
这个模式的主要用于处理 NFS 的挂载,因为它可以避免可能存在的缓存影响。但是它对 IO 性能的影响很大,所以,不要轻易使用它,除非你知道你在干什么。
注意,“同步模式打开”不等于
fs.open()
的调用变成同步形式的了,它跟fs.openSync()
还是不同的。 - rs+ - 同上,只是打开的文件可读可定。
- w - 打开文件准备写,如果文件存在则替换内容,如果不存在则新建。
- wx - 跟 w 一样,只是如果文件存在则会抛出异常。
- w+ - 打开文件,可读可写。无则新建,有则替换内容。
- wx+ - 同上,如果文件存在则会抛出异常。
- a - 打开文件,以“追加”方式写。如果文件不存在会新建。
- ax - 同上,如果文件存在会抛出异常。
- a+ - 打开文件,可读可写。无则新建,有则替换内容。
- ax+ - 同上,如果文件存在会抛出异常。
6.3. fd 读写
首先, fs.open()
和 fs.openSync()
可以打开指定的 path
,并返回一个 fd 用于接下来的操作。但是这里再拿文件 fd 说事就没什么感觉了。用一个命名管道试试吧。
我不知道在 nodejs 中怎么创建命名管道,所以拿 Python 先创建上(“命名管道”在非 Linux 环境下不一定能用):
import os os.mkfifo('/tmp/pipe')
让 nodejs 的程序准备读:
var fs = require('fs') var fd = fs.openSync('/tmp/pipe', 'r'); var buff = new Buffer(3); // 3个字节 var length = fs.readSync(fd, buff, 0, 3, null); // 从buff的0个字节开始写, 读3个字节, 从当前文件位置开始读 console.log(length); // 读了多少个字节 console.log(buff.toString());
运行之后,发看到程序一直未响应,因为管道中还没有内容可读。
我们在终端中直接写入一点东西:
$ echo '123' > /tmp/pipe
这样, nodejs 的标准输出就会有:
3 123
的显示了。
除文件外,其它对 fd 的操作,异步可能还更自然一些:
var fs = require('fs') fs.open('/tmp/pipe', 'r', function(err, fd){ var buff = new Buffer(3); var length = fs.read(fd, buff, 0, 3, null, function(err, length, b){ console.log(length); console.log(b.toString()); }); });
7. 当前主模块判断
这是在写代码中想到的一个问题。因为是动态语言,所以我习惯边写边运行,而最常用的一种代码组织方式,就是在尾部添加一些代码,它的功能是如果当前文件是“主模块”,则运行。而如果当前文件是作为非主模块被其它引用的,则不运行。
就是 Python 中的:
if __name__ == '__main__': run()
nodejs 中的实现方式是:
function run() { console.log('here'); } if(module === require.main){ run() console.log(__filename); }
8. 使用 cheerio 解析 html
找了一圈,没找到即支持 xpath 又能宽容 html 的实现, cheerio 光用来处理 html 很方便,它的 api 是类 jQuery 风格的,直接就可以上手了。
var cheerio = require('cheerio'); var $ = cheerio.load('<a><b>123</b>kk</a>'); console.log($('a > b').text()); console.log($.html('b'));
9. 模板引擎
模板引擎我选择的是 nunjucks , http://mozilla.github.io/nunjucks/ ,因为它是照着 Python 中的 jinja 做的 ,那个双大括号 {{ }}
我非常熟悉。
var nunjucks = require('nunjucks'); var template = '{{ a }}'; var s = nunjucks.renderString(template, {a: '123'}); console.log(s);
10. 多行字符串与占位符功能
多行字符串指的是 Python 中的三引号:
s = ''' a b c '''
占位符功指的是 Python 中的:
print 'create {}/src/{}/{}/app.js'.format(INFO['root'], project, name)
或:
print 'create %s/src/%s/%s/app.js' % (INFO['root'], project, name)
这两个机制, nodejs 中 es6 语法“模板字符串”提供了类似实现。
模板字符串 使用反引号,可以跨行:
var s = ` a b c ${a} `
里面可以使用 ${a}
的形式直接嵌入求值表达式。
但是注意,“模板字符串”不支持“传参”,嵌入的求值表达式里的变量需要事先定义好。
11. 总结
上面各点了解之后,剩下的就可以对着之前的 Python 代码直接翻写一遍了。
- 使用“模板字符串”处理多行文本。
- 使用“模板字符串”作占位符式的模板处理,很方便。
__filename
和__dirname
分别是当前文件的路径与目录。if(module === require.main)
判断当前文件是否为“主模块”。path.resolve()
可以把相对路径转成绝对路径。process.argv
是命令行参数。process.exit(0)
退出当前程序。fs.readFileSync()
和fs.writeFileSync()
以同步方式读写文件(读出的内容是buffer
类型,需要buff.toString()
)。- 判断目录,文件是否存在使用
fs.statSync()
或fs.accessSync()
,但是不存在时,这两个同步形式的方法是抛出异常,这点比较蛋疼了,自己封装一个isFileExists(path)
吧。 cheerio
模块用来解析 HTML 其 API 是 jQuery 风格的。nunjucks
是一个类 jinja 的模板引擎。request
是 HTTP 客户端的上层封装实现。node-sass
是 nodejs 下对 libsass 的封装。