Analizza file JSON di grandi dimensioni in Nodejs


98

Ho un file che memorizza molti oggetti JavaScript in formato JSON e ho bisogno di leggere il file, creare ciascuno degli oggetti e fare qualcosa con loro (inserirli in un db nel mio caso). Gli oggetti JavaScript possono essere rappresentati in un formato:

Formato A:

[{name: 'thing1'},
....
{name: 'thing999999999'}]

o Formato B:

{name: 'thing1'}         // <== My choice.
...
{name: 'thing999999999'}

Si noti che ...indica molti oggetti JSON. Sono consapevole di poter leggere l'intero file in memoria e quindi utilizzare in JSON.parse()questo modo:

fs.readFile(filePath, 'utf-8', function (err, fileContents) {
  if (err) throw err;
  console.log(JSON.parse(fileContents));
});

Tuttavia, il file potrebbe essere molto grande, preferirei utilizzare un flusso per farlo. Il problema che vedo con un flusso è che il contenuto del file potrebbe essere suddiviso in blocchi di dati in qualsiasi momento, quindi come posso usarlo JSON.parse()su tali oggetti?

Idealmente, ogni oggetto verrebbe letto come un blocco di dati separato, ma non sono sicuro di come farlo .

var importStream = fs.createReadStream(filePath, {flags: 'r', encoding: 'utf-8'});
importStream.on('data', function(chunk) {

    var pleaseBeAJSObject = JSON.parse(chunk);           
    // insert pleaseBeAJSObject in a database
});
importStream.on('end', function(item) {
   console.log("Woot, imported objects into the database!");
});*/

Nota, desidero impedire la lettura dell'intero file in memoria. L'efficienza del tempo non mi importa. Sì, potrei provare a leggere più oggetti contemporaneamente e inserirli tutti in una volta, ma è una modifica delle prestazioni: ho bisogno di un modo che sia garantito per non causare un sovraccarico di memoria, non importa quanti oggetti sono contenuti nel file .

Posso scegliere di usare FormatAo FormatBforse qualcos'altro, basta specificare nella risposta. Grazie!


Per il formato B è possibile analizzare il blocco per nuove righe ed estrarre ogni riga intera, concatenando il resto se si interrompe a metà. Potrebbe esserci un modo più elegante però. Non ho lavorato molto con gli stream.
travis

Risposte:


82

Per elaborare un file riga per riga, è sufficiente disaccoppiare la lettura del file e il codice che agisce su quell'input. Puoi farlo caricando il tuo input fino a quando non premi una nuova riga. Supponendo di avere un oggetto JSON per riga (fondamentalmente, formato B):

var stream = fs.createReadStream(filePath, {flags: 'r', encoding: 'utf-8'});
var buf = '';

stream.on('data', function(d) {
    buf += d.toString(); // when data is read, stash it in a string buffer
    pump(); // then process the buffer
});

function pump() {
    var pos;

    while ((pos = buf.indexOf('\n')) >= 0) { // keep going while there's a newline somewhere in the buffer
        if (pos == 0) { // if there's more than one newline in a row, the buffer will now start with a newline
            buf = buf.slice(1); // discard it
            continue; // so that the next iteration will start with data
        }
        processLine(buf.slice(0,pos)); // hand off the line
        buf = buf.slice(pos+1); // and slice the processed data off the buffer
    }
}

function processLine(line) { // here's where we do something with a line

    if (line[line.length-1] == '\r') line=line.substr(0,line.length-1); // discard CR (0x0D)

    if (line.length > 0) { // ignore empty lines
        var obj = JSON.parse(line); // parse the JSON
        console.log(obj); // do something with the data here!
    }
}

Ogni volta che il flusso di file riceve dati dal file system, viene nascosto in un buffer e quindi pumpchiamato.

Se non c'è una nuova riga nel buffer, pumpritorna semplicemente senza fare nulla. Più dati (e potenzialmente una nuova riga) verranno aggiunti al buffer la prossima volta che il flusso riceve dati, e quindi avremo un oggetto completo.

Se è presente una nuova riga, pumptaglia il buffer dall'inizio alla nuova riga e lo passa a process. Quindi controlla di nuovo se c'è un'altra nuova riga nel buffer (il whileciclo). In questo modo, possiamo elaborare tutte le righe che sono state lette nel blocco corrente.

Infine, processviene chiamato una volta per riga di input. Se presente, rimuove il carattere di ritorno a capo (per evitare problemi con le terminazioni di riga - LF vs CRLF), quindi chiama JSON.parseuno la riga. A questo punto, puoi fare tutto ciò di cui hai bisogno con il tuo oggetto.

Nota che JSON.parseè rigoroso su ciò che accetta come input; devi citare i tuoi identificatori e valori di stringa tra virgolette doppie . In altre parole, {name:'thing1'}genererà un errore; devi usare {"name":"thing1"}.

Poiché in memoria non sarà mai presente più di un blocco di dati alla volta, sarà estremamente efficiente in termini di memoria. Sarà anche estremamente veloce. Un rapido test ha mostrato che ho elaborato 10.000 righe in meno di 15 ms.


12
Questa risposta è ora ridondante. Usa JSONStream e avrai un supporto immediato.
arcseldon

2
Il nome della funzione "processo" non è corretto. "processo" dovrebbe essere una variabile di sistema. Questo bug mi ha confuso per ore.
Zhigong Li

17
@arcseldon Non credo che il fatto che ci sia una libreria che fa questo rende questa risposta ridondante. Sicuramente è ancora utile sapere come farlo senza il modulo.
Kevin B

3
Non sono sicuro che funzioni per un file JSON minimizzato. E se l'intero file fosse racchiuso in una singola riga e l'utilizzo di tali delimitatori non fosse possibile? Come risolviamo allora questo problema?
SLearner

7
Le librerie di terze parti non sono fatte di magia, sai. Sono proprio come questa risposta, versioni elaborate di soluzioni arrotolate a mano, ma semplicemente confezionate ed etichettate come un programma. Capire come funzionano le cose è molto più importante e pertinente che lanciare ciecamente dati in una libreria aspettandosi risultati.
Sto

34

Proprio come stavo pensando che sarebbe stato divertente scrivere un parser JSON in streaming, ho anche pensato che forse avrei dovuto fare una ricerca rapida per vedere se ce n'è uno già disponibile.

Si scopre che c'è.

Dato che l'ho appena trovato, ovviamente non l'ho usato, quindi non posso commentare la sua qualità, ma mi interesserà sapere se funziona.

Funziona, considera il seguente Javascript e _.isString:

stream.pipe(JSONStream.parse('*'))
  .on('data', (d) => {
    console.log(typeof d);
    console.log("isString: " + _.isString(d))
  });

Questo registrerà gli oggetti non appena entrano se il flusso è un array di oggetti. Pertanto l'unica cosa che viene bufferizzata è un oggetto alla volta.


29

A partire da ottobre 2014 , puoi semplicemente fare qualcosa come il seguente (usando JSONStream): https://www.npmjs.org/package/JSONStream

var fs = require('fs'),
    JSONStream = require('JSONStream'),

var getStream() = function () {
    var jsonData = 'myData.json',
        stream = fs.createReadStream(jsonData, { encoding: 'utf8' }),
        parser = JSONStream.parse('*');
    return stream.pipe(parser);
}

getStream().pipe(MyTransformToDoWhateverProcessingAsNeeded).on('error', function (err) {
    // handle any errors
});

Per dimostrare con un esempio funzionante:

npm install JSONStream event-stream

data.json:

{
  "greeting": "hello world"
}

ciao.js:

var fs = require('fs'),
    JSONStream = require('JSONStream'),
    es = require('event-stream');

var getStream = function () {
    var jsonData = 'data.json',
        stream = fs.createReadStream(jsonData, { encoding: 'utf8' }),
        parser = JSONStream.parse('*');
    return stream.pipe(parser);
};

getStream()
    .pipe(es.mapSync(function (data) {
        console.log(data);
    }));
$ node hello.js
// hello world

2
Questo è per lo più vero e utile, ma penso che tu debba farlo parse('*')o non otterrai alcun dato.
John Zwinck

@JohnZwinck Grazie, hai aggiornato la risposta e aggiunto un esempio funzionante per dimostrarlo completamente.
arcseldon

nel primo blocco di codice, la prima serie di parentesi var getStream() = function () {dovrebbe essere rimossa.
givemesnacks

1
Questo non è riuscito con un errore di memoria insufficiente con un file json da 500 MB.
Keith John Hutchison

18

Mi rendo conto che vuoi evitare di leggere l'intero file JSON in memoria se possibile, tuttavia se hai la memoria disponibile potrebbe non essere una cattiva idea dal punto di vista delle prestazioni. L'utilizzo di require () di node.js su un file json carica i dati in memoria molto velocemente.

Ho eseguito due test per vedere come apparivano le prestazioni stampando un attributo da ciascuna funzionalità da un file geojson da 81 MB.

Nel primo test, ho letto l'intero file geojson in memoria usando var data = require('./geo.json'). Ci sono voluti 3330 millisecondi e quindi la stampa di un attributo da ciascuna funzione ha richiesto 804 millisecondi per un totale complessivo di 4134 millisecondi. Tuttavia, è emerso che node.js utilizzava 411 MB di memoria.

Nel secondo test, ho usato la risposta di @ arcseldon con JSONStream + event-stream. Ho modificato la query JSONPath per selezionare solo ciò di cui avevo bisogno. Questa volta la memoria non è mai stata superiore a 82 MB, tuttavia, il completamento dell'intera operazione ora richiede 70 secondi!


18

Avevo un requisito simile, ho bisogno di leggere un file json di grandi dimensioni nel nodo js ed elaborare i dati in blocchi e chiamare un'api e salvare in mongodb. inputFile.json è come:

{
 "customers":[
       { /*customer data*/},
       { /*customer data*/},
       { /*customer data*/}....
      ]
}

Ora ho usato JsonStream e EventStream per ottenere questo risultato in modo sincrono.

var JSONStream = require("JSONStream");
var es = require("event-stream");

fileStream = fs.createReadStream(filePath, { encoding: "utf8" });
fileStream.pipe(JSONStream.parse("customers.*")).pipe(
  es.through(function(data) {
    console.log("printing one customer object read from file ::");
    console.log(data);
    this.pause();
    processOneCustomer(data, this);
    return data;
  }),
  function end() {
    console.log("stream reading ended");
    this.emit("end");
  }
);

function processOneCustomer(data, es) {
  DataModel.save(function(err, dataModel) {
    es.resume();
  });
}

Grazie mille per aver aggiunto la tua risposta, anche il mio caso necessitava di una gestione sincrona. Tuttavia dopo il test non è stato possibile chiamare "end ()" come callback dopo che la pipe è terminata. Credo che l'unica cosa che si potrebbe fare sia aggiungere un evento, cosa dovrebbe accadere dopo che lo stream è 'finito' / 'chiuso' con ´fileStream.on ('chiudi', ...) ´.
nonNumericalFloat

6

Ho scritto un modulo che può farlo, chiamato BFJ . In particolare, il metodo bfj.matchpuò essere utilizzato per suddividere un flusso di grandi dimensioni in blocchi discreti di JSON:

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

const stream = fs.createReadStream(filePath);

bfj.match(stream, (key, value, depth) => depth === 0, { ndjson: true })
  .on('data', object => {
    // do whatever you need to do with object
  })
  .on('dataError', error => {
    // a syntax error was found in the JSON
  })
  .on('error', error => {
    // some kind of operational error occurred
  })
  .on('end', error => {
    // finished processing the stream
  });

Qui, bfj.matchrestituisce un flusso leggibile in modalità oggetto che riceverà gli elementi di dati analizzati e gli vengono passati 3 argomenti:

  1. Un flusso leggibile contenente il JSON di input.

  2. Un predicato che indica quali elementi dal JSON analizzato verranno inviati al flusso di risultati.

  3. Un oggetto opzioni che indica che l'input è JSON delimitato da una nuova riga (questo serve per elaborare il formato B dalla domanda, non è richiesto per il formato A).

Dopo essere stato chiamato, bfj.matchanalizzerà prima JSON dal flusso di input in profondità, chiamando il predicato con ogni valore per determinare se inviare o meno quell'elemento al flusso di risultati. Al predicato vengono passati tre argomenti:

  1. La chiave della proprietà o l'indice dell'array (questo sarà undefinedper gli elementi di primo livello).

  2. Il valore stesso.

  3. La profondità dell'elemento nella struttura JSON (zero per gli elementi di primo livello).

Naturalmente, se necessario, può essere utilizzato anche un predicato più complesso a seconda delle esigenze. Puoi anche passare una stringa o un'espressione regolare invece di una funzione predicato, se desideri eseguire corrispondenze semplici con chiavi di proprietà.


4

Ho risolto questo problema utilizzando il modulo split npm . Conduci il flusso in una divisione e " spezzerà un flusso e lo riassemblerà in modo che ogni linea sia un pezzo ".

Codice di esempio:

var fs = require('fs')
  , split = require('split')
  ;

var stream = fs.createReadStream(filePath, {flags: 'r', encoding: 'utf-8'});
var lineStream = stream.pipe(split());
linestream.on('data', function(chunk) {
    var json = JSON.parse(chunk);           
    // ...
});

4

Se hai il controllo sul file di input ed è un array di oggetti, puoi risolverlo più facilmente. Disporre di output del file con ogni record su una riga, in questo modo:

[
   {"key": value},
   {"key": value},
   ...

Questo è ancora JSON valido.

Quindi, usa il modulo readline di node.js per elaborarli una riga alla volta.

var fs = require("fs");

var lineReader = require('readline').createInterface({
    input: fs.createReadStream("input.txt")
});

lineReader.on('line', function (line) {
    line = line.trim();

    if (line.charAt(line.length-1) === ',') {
        line = line.substr(0, line.length-1);
    }

    if (line.charAt(0) === '{') {
        processRecord(JSON.parse(line));
    }
});

function processRecord(record) {
    // Process the records one at a time here! 
}

-1

Penso che tu abbia bisogno di usare un database. MongoDB è una buona scelta in questo caso perché è compatibile con JSON.

AGGIORNAMENTO : puoi utilizzare lo strumento mongoimport per importare dati JSON in MongoDB.

mongoimport --collection collection --file collection.json

1
Questo non risponde alla domanda. Nota che la seconda riga della domanda dice che vuole farlo per ottenere dati in un database .
josh3736

mongoimport importa solo file di dimensioni fino a 16 MB.
Haziq Ahmed
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.