利用Node.js流和事件处理文本文件

想用之前爬取的数据文件来给Cassandra做个benchmarking,Cassandra支持CSV格式的数据导入,但是之前爬虫爬下来的数据是JSON格式的,需要做个转换,要是在以前就直接写python脚本了,正好这次尝试下nodejs来做。
现有的数据格式是这样的:

[
{"price":"商品价格","name":"商品名称","comment":"商品说明","id":"商品ID"},
...
]

需要转换成这样的:

商品ID,商品名称,商品说明,商品价格

之前在看《深入浅出Node.js》(朴灵著)的时候就知道,这种读取大文件的场景最好用Buffer来做,因为Buffer所申请的内存空间不在V8的堆内存中(https://nodejs.org/api/buffer.html)

Instances of the Buffer class are similar to arrays of integers but correspond to fixed-sized, raw memory allocations outside the V8 heap. The size of the Buffer is established when it is created and cannot be resized.

而且还要考虑到字符编码的问题,要求是输入输出全是UTF8的。
照着书上的例子,一开始我把代码写成了这个样子:

var fs = require('fs')

var chunks = []
var size = 0
var rs = fs.createReadStream('input.json', {highWaterMark:64 * 1024})
rs.setEncoding('utf8')
rs.on("data", function (chunk) {
    var record = JSON.parse(chunk)
    console.log(record.length)
    record.forEach(function(ele) {
        var result = ele.id + ',' + ele.name + ',' + ele.comment + ',' + ele.price + '\n'
        console.log(result)
        fs.writeFile('output.csv', result, 'utf8')
    })
})
console.log('Finished')

先在一个有几条数据的小文件上测试,执行以后打开output.csv文件,按ASCII打开全是乱码,按UTF8读取失败,不是合法的UTF8格式文件,于是我又仔细看了下官方File SystemStream的解释,才发现上面的代码有两个问题:

  1. 流的data事件可能会被触发很多次
  2. writeFile会覆盖已经存在的文件

这样造成的结果就是每一个JSON Object都会写一次文件,又因为是异步的,多次调用会反复覆盖这个文件,然后在没完成写入时候就结束了,关掉了写入流,造成输出文件格式错误。
(当然里面还有一个重大错误,只不过我当时还没意识到)

然后我又把代码改成了这样:

rs.on("data", function (chunk) {
    var record = JSON.parse(chunk)
    console.log(record.length)
    var header = new Buffer('id,name,comment,price\n', 'utf8')
    size += header.length
    chunks.push(header)
    record.forEach(function(ele, index) {
        console.log(index)
        var result = (new Buffer(ele.id + ',' + ele.name + ',' + ele.comment + ',' + ele.price + '\n', 'utf8'))
        size += result.length
        chunks.push(result)
    })
    var results = Buffer.concat(chunks, size)
    fs.writeFileSync('newmasa.csv', results, 'utf8', (err) => {
        if (err) throw err;
        console.log('It\'s saved!');
})

这样的话小文件的测试能通过了,然后直接换大文件,大概有几十M,运行过程中一个JSON.parse()直接抛了一个uncaught exception,具体找到出错的那一行JSON数据,看了看没啥问题,跟上下几行数据都很相似,单独把这条数据拿出来放到小文件里也可以正确解析,看来不是数据本身的问题。

然后我又陷入了思索,为什么会在这个地方报错,这里大概是175行左右。于是我又回去看书,印象中是说了一个叫highWaterMark的设置,是用来控制每次读取的大小的,好像有哪里不对。
原来是这样的,之前我一直有一个错误的假设,觉得一次data事件就可以把文件完整的读进来,回调函数里面的chunk应该就是完整的json object数组了吧,但其实我想错了,首先data并不只触发了一次,我把代码改成下面这个样子验证了一下:

rs.on("data", function (chunk) {
    console.log(chunk.length)
    console.log(chunk.length)
    chunks.push(chunk)
})

rs.on("end", () => {
    console.log("On end")
    console.log(chunks.length)
})
console.log('Finished')

运行结果是这样的:

Finished
30352
28532
31357
29760
31011
32798
....
On end
1059

按照我之前的写法,只要是分成多次读取的那就不可能成功地按照一个json object array来解析,必然会报错。那么再进一步想,为什么会在一个json的中间报错呢,啊,可能连完整的一行都没读完,再验证一下好了:

var len = chunk.length
console.log(len)
console.log(chunk[len-1].toString('utf8'))

取每一次chunk的最后一个字符,打出来发现不是逗号分隔符,说明不能保证每次都是读到一行的结尾就停止。想想也对,Read Stream把文件按照流来读取,Buffer里存的又是二进制,他们完全不会在意文本里面的换行符。

那么基本就清楚了,应该把整个文件的内容都读取完了再解析,然后写入输出文件。下面代码又改成了这样:

rs.on("data", (chunk) => {
    if(typeof chunk != 'Buffer')
        chunk = new Buffer(chunk)
    chunks.push(chunk)
    size += chunk.length
})

rs.on("end", () => {
    console.log(chunks.length)
    console.log(size)
    fs.writeFileSync('newmasa.csv', 'id,name,comment,price\n', 'utf8')
    var wholeJson = Buffer.concat(chunks, size)
    var records = JSON.parse(wholeJson)
    records.forEach((ele, index) => {
        var record = ele.id + ',' + ele.name + ',' + ele.comment + ',' + ele.price + '\n'
        fs.appendFileSync('newmasa.csv', record, 'utf8')
    })
    console.log(chunks.length)
})

运行,这样应该没问题了吧,谁知道又出现了诡异的错误,还是JSON.parse()抛异常,说有语法错误,有’][‘这样的字符串,我去源文件搜了下comment字段还真是有不少’][‘,这可咋办,又不能全替换,只能开始慢慢排错,开始试了几组数据都通过了,我忽然发现原来有这样的数据:

{},

][
{}

应该是爬虫没进行数据清洗造成格式错误,数据量太大无法手动清理的,果然还是要逐行处理么。
后来我在翻看API的时候无意中发现,居然有Readline这个模块,这下就省事了:

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

const rl = readline.createInterface({
  input: fs.createReadStream('input.json')
})
fs.writeFileSync('newmasa.csv', 'id,name,comment,price\n', 'utf8')

rl.on('line', (line) => {
    if (line && line.trim().length > 0) {
        line = line.trim()
        if (line.startsWith('{')) {
            if (line.endsWith(','))
                line = line.slice(0,-1)
            if (line.endsWith('}')) {
                var record = JSON.parse(line)
                var result = record .id + ',' + record .name + ',' + record .comment + ',' + record .price + '\n'
                fs.appendFileSync('output.csv', result, 'utf8')
            }

        }
    }
})

rl.on('close', () => {
    rl.close()
})

最后运行就成功了,折腾了半天,也算是对流和事件有了更深刻的理解。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值