[中英文 JSON 合并工具] Node 的文件操作能力 - fs

  1. 本节目标:【实现一个中英文 JSON 合并工具】 - 一切皆文件,文件即数据,服务器作为数据的批发市场,文件读写能力成就了调度奇迹。

使用 File System 的时候,就像其他的模块一样,我们把它导进来即可:

  1. const fs = require('fs)

下文的示例代码均省略此句,以节省小册字数。

Node 的搬砖专业户

工程师经常自嘲是个搬砖的,在我的印象里面,Node 里面的搬砖伙计就是 fs,也就是 File System 这货,一切跟文件相关的操作,都跟它脱不了关系,比如拷贝一个文件,在 Node 里面非常简单:

  1. fs.copyFileSync('./1.txt', './2.txt')

无论是 Windows 还是 Linux 系统,它里面的视频、语音、图片、JSON 文件、二进制的程序压缩包…,都是文件,文件散布在各个我们称之为文件夹的地方,而文件夹在本质上也是一种文件,所以一切皆文件,而文件本质上是带特殊属性的数据,所以我们往往提到跟操作系统交互,跟系统底层交互,这里面有相当一部分其实是在跟文件交互,跟数据交互,本质上是在操作系统里面,对于数据的阅读能力、输入输出能力,跟数据库的增删改查行为差不了太多。我们人作为一种物种,不也是如此么,被创建,被销毁,每天都在更新,还能被打上身份标签拎出来识别,只不过多了所谓灵魂、思想和情感这些很难量化的数据而已。

在 Node 里面有很多核心的能力,但最原始最基础最通用的能力,其实就是文件系统交互能力 - fs,以及网络请求的处理能力 - http,正是这两块能力彻底打破了前后端的边界,从前用 JS 是没办法操纵磁盘文件的,通过 JS 也没办法处理网络请求与返回,现在有了 Node 这个混搭各种底层的运行框架,搬砖就变得可能了。

那我们就进入这个能力里面一窥究竟吧,首先看看它的 File System API,扳扳指头算,密密麻麻百来个方法挂在 fs 上面,大部分都分为同步的和异步两个版本,比如创建文件夹有这样的写法:

  1. fs.mkdir(path[, options], callback)
  2. fs.mkdirSync(path[, options])

其实不光 mkdir,其他大部分的文件方法,都有 Sync 的版本,所以这百十个方法除去一半,其实也就剩下了几十个而已,无非一个是 callback 回调函数来异步获取结果,一个则是同步执行,阻滞它后面的代码执行,大家不用怕他们太多记不住,用的时候来查 API 就行,那到底同步和异步在底层执行上有什么区别呢,我们先挑选几个常用的 API,熟悉下它们的用法后,自然就有答案了。

读文件能力

把数据从文件里面读出来,是一个刚需,比如一个项目在启动时候,需要把配置文件读进来,可以这样做:

  1. fs.readFile('./config.json', function (err, data) {
  2. if (err) throw err
  3. loadServer(data)
  4. })
  5. // 或者换同步的方式
  6. const data = fs.readFileSync('./config.json')
  7. loadServer(data)

既然活都一样干,那什么时候该用同步什么时候该用异步呢,我的建议是:

  • 对于一次性加载进来,不会二次加载的,比如项目启动之初,或者过程中只读取一次配置文件的,可以用 Sync/同步的方法
  • 对于体积较大的,可能需要多次读写的,有可能影响到业务流程的响应速度的,改用异步的方式做

特殊情况就特殊处理,如果实在不知道选哪种,那就保险起见选异步的方式做。如果采用同步的方式做,需要注意捕获错误,避免程序崩溃,比如这样写:

  1. let data
  2. try {
  3. data = fs.readFileSync('./config.json')
  4. } catch (err) {
  5. console.log('配置文件读取出错,及时检查服务')
  6. // 其他的一些通知 log(err) 处理等等
  7. }

我们后面的示例代码都选用异步的方式来写,同步的不再赘述。

一旦我们用异步的方式来写代码,就会面临 Node 社区早些年一直头疼的问题,就是 callback hell - 回调地狱,表现就是这样的:

  1. fs.readFile('./cfg1.json', function (err, data1) {
  2. fs.readFile(data1.cfgPath, function (err, data2) {
  3. fs.readFile(data2.cfgPath, function (err, data3) {
  4. fs.readFile(data3.cfgPath, function (err, data4) {
  5. fs.readFile(data4.cfgPath, function (err, data5) {
  6. // ...
  7. })
  8. })
  9. })
  10. })
  11. })

当然现实中不会这么变态(其实也不排除比这还要变态),这些异步操作有先后依赖关系,按照顺序执行,这种代码写起来维护起来都很恶心,除了用同步(Sync)的方式做,社区也衍生了很多解决方案,甚至直接推动了整个 Javascript 语言特性的发展,比如 GeneratorFunction 迭代执行的函数,比如 Async 异步函数,比如 Promise 规范以及实现了 Promise 规范的一系列库等等,而 Node 里面,新版里面 Promise 已经挂载到了 Global 下面可以直接使用。

那我们用 Promise 改写一下:

  1. // 先把 readFile 封装到一个 Prmoise 里面
  2. const readFile = filePath => new Promise((resolve, reject) => {
  3. fs.readFile(filePath, (err, data) => resolve(data))
  4. }
  5. // 然后通过 Promise..then 链式调用
  6. readFile('./cfg1.json')
  7. .then(data => readFile(data.cfgPath))
  8. .then(data => readFile(data.cfgPath))
  9. .then(data => readFile(data.cfgPath))
  10. .then(data => {
  11. console.log('data4.cfgPath:', data)
  12. })

改成了 Promise 的链式调用后,代码嵌套好多了,但是一个冗长的链条,依然看着不太舒服,想要获取链路头部的一些数据也需要额外的包装,不是很方便。干脆通过 Async function 再来重构下:

  1. const readFile = filePath => new Promise((resolve, reject) => {
  2. fs.readFile(filePath, (err, data) => resolve(data))
  3. }
  4. // 放到 async function 里面逐个调用
  5. async function readCfg () {
  6. const data1 = await readFile('./cfg1.json)
  7. const data2 = await readFile(data1.cfgPath)
  8. const data3 = await readFile(data2.cfgPath)
  9. const data4 = await readFile(data3.cfgPath)
  10. console.log(data4)
  11. }

这样写,就看着自然多了,所有的异步 callback 都可以用同步的方式来写,不光 readFile,fs 里面任何的异步方法,都可以用这种方式包装。

  1. fs.readFile(path[, options], callback)

这是 readFile 的 API,其实我最早最早接触 path[, options], callback 这种参数形式的时候,我是真心看不懂的,不明觉厉,直到后来才意识到,原来这里的 [, options] 意思是这个参数可以省略不写,所以 readFile 接收三个参数,文件路径,配置和回调,在 options 这里,可以直接用 utf8 来替代,通过指定编码,就可以拿到的 指定编码解析后的字符串,也就是:

  1. fs.readFile('./a.txt', 'utf8', function(err, data) {})

那如果是第二个参数留空,也不指定 encoding,那么返回的 data 就是一个 Buffer 形式表示的二进制数据了,如果指定的话,就是这样来写:

  1. fs.readFile('./a.txt', {
  2. flag: 'r+',
  3. encoding: 'utf8'
  4. }, function(err, data) {})

这个 options 可以是一个对象,除了 encoding,还可以配置 flag 的值,flag 按我的理解就是标志位,表示文件的打开模式,标识是否具有某个权限。

熟悉 UNIX 系统的童鞋就会知道,UNIX 的文件权限标志位用 9 个码位标识,3 个码位一组,共 3 组。3 个码位依次标识可读,可写,可执行,1 表示有权限,0 表示没权限。3 组依次表示登录用户,同组用户,其他用户的权限。比如 r 是对打开的文件进行只读, 带上加号表示的是对打开的文件进行读写,如果该文件不存在,会抛出错误,如果是 w+ 的话,同样是表示读写,但是文件不存在的话,会创建一个,并不会抛出错误,这些标志位可以参考如下列表:

  • r 打开文本文件进行读取,数据流位置在文件起始处
  • r+ 打开文本文件进行读写,数据流位置在文件起始处
  • w 如果文件存在,将其清零,不存在创建写入文件。数据流位置在文件起始处
  • w+ 打开文件进行读写,如果文件存在,将其清零,不存在创建写入文件。数据流位置在文件起始处
  • a 打开文件写入数据,如果文件存在,将其清零,不存在创建写入文件。数据流位置在文件结尾处,此后的写操作都将数据追加到文件后面
  • a+ 打开文件进行文件读写,如果文件存在,将其清零,不存在创建写入文件。数据流位置在文件结尾处,此后的写操作都将数据追加到文件后面

这些在官方的文档里都有介绍,其实大家只要理解了 read write add 这些模式的意义,等到具体使用的时候,根据场景来设置这个 flag 就可以了。

打开文件

读文件的时候,文件并不一定存在,我们有时候会直接来打开这个文件,可以通过 fs 的 open 方法对文件进行打开操作:

  1. fs.open('./a.txt', 'w', function(err, fd) {
  2. console.log(fd)
  3. // fs.open 打开了文件,当然使用后应该关闭文件,通过 fs.close 方法可以关闭打开的文件
  4. fs.close(fd, callback)
  5. })

打开文件后,就可以通过文件描述符对文件进行读写操作了,那么 open 接收的参数,第一个是文件名,第二个是标志位,最后一个参数就是回调函数,回调函数中第二个参数,fd 表示打开文件的文件描述符,我们要的就是这货。

等到文件打开后,就可以使用 fs.read() 方法进行更加精细的读取,所谓精细的控制,就是说 read 可以传入一大堆参数:

  1. fs.read(fd, buffer, offset, length, position, callback)

fd 参数,文件描述符,通过 fs.open 拿到,buffer 参数,是把读取的数据写入这个对象,是个 Buffer 对象,offset 参数,写入 buffer 的起始位置,length 参数,写入 buffer 的长度,position 参数,从文件的什么位置开始读。然后 callback(err, bytesRead, buffer) 回调方法传入三个参数,第一个参数是出现异常抛出的 err,第二个参数是读取了多少 bytes,最后一个是 buffer 读取到的数据。

在读取前需要创建一个用于保存文件数据的缓冲区,缓冲区数据最终会被传递到回调函数中:

  1. fs.open('./a.txt', 'r', function (err, fd) {
  2. var buf = new Buffer(1024)
  3. var offset = 0
  4. var len = buf.length
  5. // var pos = 2000
  6. var pos = 101
  7. // 这里我定义了参数,文件打开后,会从第 100 个字节开始,读取其后的 1024 个字节的数据。读取完成后,回调方法中可以处理读取到的的缓冲的数据了
  8. fs.read(fd, buf, offset, len, pos, function(err, bytes, buffer) {
  9. console.log('读取了' + bytes + ' bytes')
  10. //数据已被填充到 buf 中
  11. console.log(buf.slice(0, bytes).toString())
  12. })
  13. })

写文件

对应读文件,就是写文件,第一个参数是文件名,第二个是要写入的 buffer 数据,第三个是可省略的参数,跟 readFile 方法一样,配置读写权限和编码格式,设置 flag 为 w, 如果文件不存在会创建一个文件,这种写入方式会全部删除旧有的数据,然后再写入数据,最后一个是回调方法

  1. var data = new Buffer('Hi Juejin!')
  2. fs.writeFile('./b.txt', data, {
  3. flag: 'w',
  4. encoding: 'utf8'
  5. }, function(err) {})

写文件还可以更加精细控制,通过 write 方法,类似 read 方法,write 方法也接受许多参数。

  1. fs.write(fd, buffer, offset, length, position, callback)

首先第一个参数是 fd,通过 fs.open 可以拿到文件描述符, write 方法通过这个找到文件的所在位置, 第二个参数是 buffer 缓冲区,也就是即将被写入到这个文件的二进制数据,buffer 尺寸的大小设置最好是 8 的倍数,这样效率比较高, 第三个参数是 offset,也就是 buffer 写入的偏移量,一般默认从 0 开始写,每写一次,就修改一下这个偏移量,从而保证数据的连续和完整性,第四个参数是 length,也就是要写入的 buffer 的字节数长度,通过 offset 和 length 的结合,来指定哪些数据应该被写入到文件中,第五个参数是 position,用来指定写入到文件的什么位置,如果是 null,将会从当前文件指针的位置开始写入,最后跟着的参数就是一个 回调函数 callback,里面传递了三个参数,第一个毫无疑问是错误对象 err,第二个是写入了多少 bytes,第三个是缓冲区写入的数据。

  1. fs.open('./c.txt', 'a', function (err, fd) {
  2. var buf = new Buffer('I Love Juejin')
  3. var offset = 0
  4. var len = buf.length
  5. var pos = 100
  6. fs.write(fd, buf, offset, len, pos, function(err, bytes, buffer) {
  7. console.log('写入了 ' + bytes + ' bytes')
  8. //数据已被填充到 buf 中
  9. console.log(buf.slice(0, bytes).toString())
  10. fs.close(fd, function(err) {})
  11. })
  12. })

这样竟然可以把 buffer 数据给存储到了文件中,read 还有第二种用法,就是不直接写入 buffer 数据,而是写入字符串,怎么做呢?

  1. fs.open('./c.txt', 'a', function (err, fd) {
  2. var data = 'I Love Juejin'
  3. // 第一个参数依然是文件描述符,第二个是写入的字符串,第三个是写入文件的位置,第四个是编码格式,最后一个是回调函数,回调函数第一个参数是异常,第二个是 指定多少字符数将被写入到文件,最后一个是返回的字符串
  4. fs.write(fd, data, 0, 'utf-8', function(err, written, string) {
  5. console.log(written)
  6. console.log(string)
  7. fs.close(fd, function(err) {})
  8. })
  9. })

学会了读写的这些方法,我们就可以来实现一个小工具了,来检查一张图片是不是 PNG 格式,因为 PNG 头部 8 bytes是固定的,所以拿到文件前 8 bytes就可以作为判断的条件。

  1. // png.js
  2. fs.open('11.png', 'r', function(err, fd) {
  3. var header = new Buffer([137, 80, 78, 71, 13, 10, 26, 10])
  4. var buf = new Buffer(8)
  5. fs.read(fd, buf, 0, buf.length, 0, function(err, bytes, buffer) {
  6. if (header.toString() === buffer.toString()){
  7. console.log('是 PNG 图片')
  8. }
  9. else {
  10. console.log('不是 PNG 图片')
  11. }
  12. })
  13. })

先是用 fs.open 打开 png 文件,然后 header 数据是 PNG 图片标识数据,位于 PNG 图片前 8 个 bytes,只要读取文件前 8 bytes 数据,然后对比一下数据是否一致就可以了。

那么关于写,有时候我们就想在文件结尾追加一些内容,比如每次服务器访问,我们就对日志文件增加一行记录,这时候,fs 的 appendFile 方法就非常顺手:

  1. fs.appendFile('./c.txt', 'Hello Juejin', {
  2. encoding: 'utf8'
  3. }, function(err) {
  4. console.log('done!')
  5. })

目录读写能力

除了文件,文件夹也可以读写,也即目录可以创建,删除,支持遍历,创建非常简单,命令行里面我们通过是 makeDir 来创建,fs 模块也有一个 makedir 方法,传入目录的完整路径和路径名,就可以了,默认的权限是 0777,这个就不演示了,我们直接看如何读取目录,也是直接上一个代码,实现一个小功能,把某个目录下的 js 文件都给遍历出来,然后放到一个数组里。

  1. // 使用 fs.readdir 读取目录,重点其回调函数中files对象
  2. // fs.readdir(path, callback);
  3. /**
  4. * path, 要读取目录的完整路径及目录名;
  5. * [callback(err, files)], 读完目录回调函数;err错误对象,files数组,存放读取到的目录中的所有文件名
  6. */
  7. const walk = function(path) {
  8. fs
  9. .readdirSync(path)
  10. .forEach(function(file) {
  11. const newPath = path + '/' + file
  12. const stat = fs.statSync(newPath)
  13. if (stat.isFile()) {
  14. if (/\.js/.test(file)) {
  15. files.push(file)
  16. }
  17. } else if (stat.isDirectory()) {
  18. walk(newPath)
  19. }
  20. })
  21. }
  22. walk(filesPath)
  23. console.log(files.join('\r\n'))

编程练习 - 中英文 JSON 合并工具

当我们开发一个国际网站时候,有时候需要处理 i18n 的内容,而页面比较多的时候,我们不方便用一个大而全的 JSON 文件来囊括所有的翻译内容,我们可能会把文案按照页面区分后,最终再把它们拼成一份,比如有一个 pagea.json:

  1. {
  2. "signup": "注册"
  3. }

有一个 pageb.json:

  1. {
  2. "menu": "菜单"
  3. }

希望把它们合并成 data.json:

  1. {
  2. "signup": "注册",
  3. "menu": "菜单"
  4. }

代码可以这样写:

  1. const fs = require('fs')
  2. const path = require('path')
  3. // 判断目标路径的文件存在与否
  4. const exists = filePath => fs.existsSync(filePath)
  5. const jsonPath = process.argv[2]
  6. if (!jsonPath) {
  7. console.log('没有传 JSON 目录参数哦!')
  8. process.exit(1)
  9. }
  10. const rootPath = path.join(process.cwd(), jsonPath)
  11. // 遍历所有文件
  12. const walk = (path) => fs
  13. .readdirSync(path)
  14. .reduce((files, file) => {
  15. const filePath = path + '/' + file
  16. const stat = fs.statSync(filePath)
  17. if (stat.isFile()) {
  18. if (/(.*)\.(json)/.test(file)) {
  19. return files.concat(filePath)
  20. }
  21. }
  22. return files
  23. }, [])
  24. // 合并文件内容
  25. const mergeFileData = () => {
  26. const files = walk(rootPath)
  27. if (!files.length) process.exit(2)
  28. const data = files
  29. .filter(exists)
  30. .reduce((total, file) => {
  31. const fileData = fs.readFileSync(file)
  32. const basename = path.basename(file, '.json')
  33. let fileJson
  34. try {
  35. fileJson = JSON.parse(fileData)
  36. } catch (err) {
  37. console.log('读出出错', file)
  38. console.log(err)
  39. }
  40. total[basename] = fileJson
  41. return total
  42. }, {})
  43. fs.writeFileSync('./data.json', JSON.stringify(data, null, 2))
  44. }
  45. mergeFileData()

总结

这一节的内容略显枯燥,我们对读写总结一下,无论读写,都有两种方式,一种粗矿的,一种精细化的,精细化的控制,需要先 open 一个文件,然后操作读写,但需要手工调用 close 方法关闭文件,这种方式适合于多次写入或读取。粗狂的读写是一次性服务的,直接调用 writeFile/appendFile/readFile 方法,只会写入或读取一次,在它的内部自动调用了 close 方法,另外呢,对于 write 方法,因为多次对同一文件进行 write 并不安全,必须等到 callback 调用才可以,官方推荐是使用 stream 方式替代,也就是 createWriteStream,关于 Stream 我们就放到后面的小节中学习。