想用之前爬取的数据文件来给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 System和Stream的解释,才发现上面的代码有两个问题:
- 流的data事件可能会被触发很多次
- 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()
})
最后运行就成功了,折腾了半天,也算是对流和事件有了更深刻的理解。