Analyser un gros fichier JSON dans Nodejs

99

J'ai un fichier qui stocke de nombreux objets JavaScript sous forme JSON et je dois lire le fichier, créer chacun des objets et faire quelque chose avec eux (insérez-les dans une base de données dans mon cas). Les objets JavaScript peuvent être représentés par un format:

Format A:

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

ou Format B:

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

Notez que le ...indique un grand nombre d'objets JSON. Je suis conscient que je pourrais lire l'intégralité du fichier en mémoire, puis l'utiliser JSON.parse()comme ceci:

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

Cependant, le fichier peut être très volumineux, je préférerais utiliser un flux pour ce faire. Le problème que je vois avec un flux est que le contenu du fichier peut être divisé en morceaux de données à tout moment, alors comment puis-je l'utiliser JSON.parse()sur de tels objets?

Idéalement, chaque objet serait lu comme un bloc de données distinct, mais je ne sais pas comment faire cela .

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!");
});*/

Remarque, je souhaite empêcher la lecture du fichier entier en mémoire. L'efficacité du temps n'a pas d'importance pour moi. Oui, je pourrais essayer de lire un certain nombre d'objets à la fois et de les insérer tous à la fois, mais c'est un ajustement des performances - j'ai besoin d'un moyen qui garantit de ne pas causer de surcharge de mémoire, quel que soit le nombre d'objets contenus dans le fichier .

Je peux choisir d'utiliser FormatAou FormatBou peut-être autre chose, veuillez simplement le préciser dans votre réponse. Merci!

dgh
la source
Pour le format B, vous pouvez analyser le morceau pour de nouvelles lignes et extraire chaque ligne entière, en concaténant le reste si elle se coupe au milieu. Il peut y avoir une manière plus élégante cependant. Je n'ai pas beaucoup travaillé avec les flux.
travis

Réponses:

82

Pour traiter un fichier ligne par ligne, il vous suffit de découpler la lecture du fichier et le code qui agit sur cette entrée. Vous pouvez accomplir cela en tamponnant votre entrée jusqu'à ce que vous atteigniez une nouvelle ligne. En supposant que nous ayons un objet JSON par ligne (en gros, le format 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!
    }
}

Chaque fois que le flux de fichiers reçoit des données du système de fichiers, il est stocké dans une mémoire tampon, puis pump est appelé.

S'il n'y a pas de nouvelle ligne dans le tampon, pumpretourne simplement sans rien faire. Plus de données (et potentiellement une nouvelle ligne) seront ajoutées au tampon la prochaine fois que le flux obtiendra des données, puis nous aurons un objet complet.

S'il y a une nouvelle ligne, pumpcoupe la mémoire tampon du début à la nouvelle ligne et la transmet à process. Il vérifie ensuite à nouveau s'il y a une autre nouvelle ligne dans le tampon (la whileboucle). De cette façon, nous pouvons traiter toutes les lignes qui ont été lues dans le bloc courant.

Enfin, processest appelé une fois par ligne d'entrée. S'il est présent, il supprime le caractère de retour chariot (pour éviter les problèmes de fin de ligne - LF vs CRLF), puis appelleJSON.parse une ligne. À ce stade, vous pouvez faire tout ce dont vous avez besoin avec votre objet.

Notez que JSON.parsec'est strictement ce qu'il accepte comme entrée; vous devez citer vos identifiants et valeurs de chaîne avec des guillemets doubles . En d'autres termes, {name:'thing1'}jettera une erreur; vous devez utiliser {"name":"thing1"}.

Parce que pas plus d'un morceau de données ne sera jamais en mémoire à la fois, ce sera extrêmement efficace en mémoire. Ce sera également extrêmement rapide. Un test rapide a montré que j'avais traité 10 000 lignes en moins de 15 ms.

josh3736
la source
12
Cette réponse est désormais redondante. Utilisez JSONStream et vous bénéficiez d'un support prêt à l'emploi.
arcseldon
2
Le nom de la fonction «processus» est incorrect. «processus» doit être une variable système. Ce bug m'a dérouté pendant des heures.
Zhigong Li
19
@arcseldon Je ne pense pas que le fait qu'il existe une bibliothèque qui fasse cela rend cette réponse redondante. Il est certainement toujours utile de savoir comment cela peut être fait sans le module.
Kevin B
3
Je ne sais pas si cela fonctionnerait pour un fichier json minifié. Et si tout le fichier était enveloppé sur une seule ligne et que l'utilisation de tels délimiteurs n'était pas possible? Comment résoudre ce problème alors?
SLearner
8
Les bibliothèques tierces ne sont pas faites de magie, vous savez. Ils sont exactement comme cette réponse, des versions élaborées de solutions roulées à la main, mais juste emballées et étiquetées comme un programme. Comprendre comment les choses fonctionnent est beaucoup plus important et pertinent que de jeter aveuglément des données dans une bibliothèque en attendant des résultats. Juste en disant :)
zanona
36

Tout comme je pensais que ce serait amusant d'écrire un analyseur JSON en streaming, j'ai également pensé que je devrais peut-être faire une recherche rapide pour voir s'il y en avait déjà un disponible.

Il s'avère qu'il y en a.

Depuis que je viens de le trouver, je ne l'ai évidemment pas utilisé, donc je ne peux pas faire de commentaire sur sa qualité, mais je serai intéressé de savoir si cela fonctionne.

Cela fonctionne avec le Javascript suivant et _.isString:

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

Cela enregistrera les objets au fur et à mesure qu'ils entrent si le flux est un tableau d'objets. Par conséquent, la seule chose mise en mémoire tampon est un objet à la fois.

user1106925
la source
30

Depuis octobre 2014 , vous pouvez simplement faire quelque chose comme ce qui suit (en utilisant 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
});

Pour démontrer avec un exemple de travail:

npm install JSONStream event-stream

data.json:

{
  "greeting": "hello world"
}

bonjour.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
Arcseldon
la source
2
C'est surtout vrai et utile, mais je pense que vous devez le faire parse('*')ou vous n'obtiendrez aucune donnée.
John Zwinck
@JohnZwinck Merci, d'avoir mis à jour la réponse et ajouté un exemple de travail pour le démontrer pleinement.
arcseldon
dans le premier bloc de code, le premier jeu de parenthèses var getStream() = function () {doit être supprimé.
givemesnacks
1
Cela a échoué avec une erreur de mémoire insuffisante avec un fichier json de 500 Mo.
Keith John Hutchison
18

Je me rends compte que vous voulez éviter de lire tout le fichier JSON en mémoire si possible, mais si vous avez la mémoire disponible, cela peut ne pas être une mauvaise idée en termes de performances. L'utilisation de require () de node.js sur un fichier json charge les données en mémoire très rapidement.

J'ai exécuté deux tests pour voir à quoi ressemblaient les performances lors de l'impression d'un attribut de chaque fonctionnalité à partir d'un fichier geojson de 81 Mo.

Dans le premier test, j'ai lu l'intégralité du fichier geojson en mémoire en utilisant var data = require('./geo.json') . Cela a pris 3330 millisecondes, puis l'impression d'un attribut de chaque entité a pris 804 millisecondes pour un total de 4134 millisecondes. Cependant, il est apparu que node.js utilisait 411 Mo de mémoire.

Dans le deuxième test, j'ai utilisé la réponse de @ arcseldon avec JSONStream + event-stream. J'ai modifié la requête JSONPath pour sélectionner uniquement ce dont j'avais besoin. Cette fois, la mémoire n'a jamais dépassé 82 Mo, cependant, le tout a maintenant pris 70 secondes pour se terminer!

Evan Siroky
la source
18

J'avais une exigence similaire, je dois lire un gros fichier json dans le nœud js et traiter les données en morceaux, appeler une api et enregistrer dans mongodb. inputFile.json est comme:

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

Maintenant, j'ai utilisé JsonStream et EventStream pour y parvenir de manière synchrone.

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();
  });
}
karthick N
la source
Merci beaucoup d'avoir ajouté votre réponse, mon cas nécessitait également un traitement synchrone. Cependant, après le test, il ne m'a pas été possible d'appeler "end ()" comme rappel une fois le tube terminé. Je crois que la seule chose qui pourrait être faite est d'ajouter un événement, ce qui devrait se passer une fois que le flux est "terminé" / "fermé" avec "fileStream.on (" close ", ...)".
nonNumericalFloat
6

J'ai écrit un module qui peut faire cela, appelé BFJ . Plus précisément, la méthode bfj.matchpeut être utilisée pour diviser un gros flux en morceaux discrets de 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
  });

Ici, bfj.matchretourne un flux en mode objet lisible qui recevra les éléments de données analysés et reçoit 3 arguments:

  1. Un flux lisible contenant le JSON d'entrée.

  2. Un prédicat qui indique quels éléments du JSON analysé seront poussés vers le flux de résultats.

  3. Un objet d'options indiquant que l'entrée est JSON délimitée par une nouvelle ligne (il s'agit de traiter le format B de la question, ce n'est pas requis pour le format A).

Une fois appelé, bfj.matchanalysera JSON à partir du flux d'entrée en profondeur en premier, en appelant le prédicat avec chaque valeur pour déterminer s'il faut ou non pousser cet élément vers le flux de résultats. Le prédicat reçoit trois arguments:

  1. La clé de propriété ou l'index du tableau (ce sera undefinedpour les éléments de niveau supérieur).

  2. La valeur elle-même.

  3. La profondeur de l'élément dans la structure JSON (zéro pour les éléments de niveau supérieur).

Bien entendu, un prédicat plus complexe peut également être utilisé selon les besoins. Vous pouvez également transmettre une chaîne ou une expression régulière au lieu d'une fonction de prédicat, si vous souhaitez effectuer des correspondances simples avec des clés de propriété.

Phil Booth
la source
4

J'ai résolu ce problème en utilisant le module split npm . Dirigez votre flux en deux, et il "décomposera un flux et réassemblez-le pour que chaque ligne soit un morceau ".

Exemple de code:

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);           
    // ...
});
Brian Leathem
la source
4

Si vous contrôlez le fichier d'entrée et qu'il s'agit d'un tableau d'objets, vous pouvez résoudre ce problème plus facilement. Arrangez-vous pour sortir le fichier avec chaque enregistrement sur une ligne, comme ceci:

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

C'est toujours du JSON valide.

Ensuite, utilisez le module readline node.js pour les traiter une ligne à la fois.

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! 
}
Steve Hanov
la source
-1

Je pense que vous devez utiliser une base de données. MongoDB est un bon choix dans ce cas car il est compatible JSON.

MISE À JOUR : Vous pouvez utiliser l' outil mongoimport pour importer des données JSON dans MongoDB.

mongoimport --collection collection --file collection.json
Vadim Baryshev
la source
1
Cela ne répond pas à la question. Notez que la deuxième ligne de la question indique qu'il veut faire cela pour obtenir des données dans une base de données .
josh3736
mongoimport n'importera que la taille du fichier jusqu'à 16 Mo.
Haziq Ahmed