Manipulation ultime de la collecte de données en mémoire avec Supergroup.js

Publié: 2022-03-11

La manipulation des données en mémoire se traduit souvent par une pile de code spaghetti. La manipulation elle-même peut être assez simple : grouper, agréger, créer des hiérarchies et effectuer des calculs ; mais une fois que le code de gestion des données est écrit et que les résultats sont envoyés à la partie de l'application où ils sont nécessaires, les besoins connexes continuent de survenir. Une transformation similaire des données peut être nécessaire dans une autre partie de l'application, ou plus de détails peuvent être nécessaires : métadonnées, contexte, données parent ou enfant, etc. En fonction des besoins, on se rend compte que les info-bulles ou les surlignages ou les zooms synchronisés exercent des pressions inattendues sur les données transformées. On pourrait répondre à ces exigences en :

  1. Remplir plus de détails et plus de niveaux dans les données transformées jusqu'à ce qu'elles soient énormes et disgracieuses mais satisfassent les besoins de tous les coins et recoins de l'application qu'elle finit par visiter.
  2. Écrire de nouvelles fonctions de transformation qui doivent joindre un nœud déjà traité à la source de données globale pour apporter de nouveaux détails.
  3. Concevoir des classes d'objets complexes qui savent d'une manière ou d'une autre comment gérer tous les contextes dans lesquels elles se retrouvent.

Après avoir construit des logiciels centrés sur les données pendant 20 ou 30 ans comme moi, on commence à soupçonner qu'ils résolvent encore et encore le même ensemble de problèmes. Nous apportons des boucles complexes, des compréhensions de liste, des fonctions d'analyse de base de données, des fonctions de carte ou de groupBy, ou même des moteurs de reporting à part entière. Au fur et à mesure que nos compétences se développent, nous devenons meilleurs pour rendre intelligent et concis n'importe quel morceau de code de munging de données, mais les spaghettis semblent toujours proliférer.

Dans cet article, nous examinerons la bibliothèque JavaScript Supergroup.js - équipée de puissantes fonctions de manipulation, de regroupement et d'agrégation de données en mémoire - et comment elle peut vous aider à résoudre certains problèmes de manipulation courants sur des ensembles de données limités.

Le problème

Lors de mon premier engagement Toptal, j'ai été convaincu dès le premier jour que les routines d'API et de gestion des données de la base de code que j'ajoutais avaient été désespérément sur-spécifiées. Il s'agissait d'une application D3.js pour analyser les données marketing. L'application disposait déjà d'une visualisation graphique à barres groupées/empilées attrayante et nécessitait la création d'une visualisation de carte choroplèthe. Le graphique à barres permettait à l'utilisateur d'afficher 2, 3 ou 4 dimensions arbitraires appelées en interne x0, x1, y0 et y1, x1 et y1 étant facultatifs.

Supergroup.js - Toptal

Dans la construction des légendes, des filtres, des info-bulles, des titres et du calcul des totaux ou des différences d'une année à l'autre, x0, x1, y0 et y1 ont été mentionnés dans tout le code, et omniprésent dans tout le code était une logique conditionnelle à gérer la présence ou l'absence de dimensions facultatives.

Cela aurait pu être pire cependant. Le code aurait pu se référer directement à des dimensions de données sous-jacentes spécifiques (par exemple, année, budget, niveau, catégorie de produit, etc.). Il a plutôt été au moins généralisé aux dimensions d'affichage de ce graphique à barres groupées/empilées. Mais lorsqu'un autre type de graphique est devenu une exigence, un type où les dimensions de x0, x1, y0 et y1 n'auraient pas de sens, une partie importante du code a dû être entièrement réécrite - code qui traite des légendes, des filtres, des info-bulles, des titres , des calculs récapitulatifs, ainsi que la construction et le rendu de graphiques.

Personne ne veut dire à son client : "Je sais que ce n'est que mon premier jour ici, mais avant d'implémenter ce que vous avez demandé, puis-je refactoriser tout le code à l'aide d'une bibliothèque de manipulation de données Javascript que j'ai moi-même écrite ?" Par un coup de chance, j'ai été sauvé de cet embarras lorsque j'ai été présenté à un programmeur client qui était de toute façon sur le point de refactoriser le code. Avec une ouverture d'esprit et une grâce inhabituelles, le client m'a invité dans le processus de refactoring à travers une série de sessions de programmation en binôme. Il était prêt à essayer Supergroup.js, et en quelques minutes, nous commencions à remplacer de gros pans de code noueux par de petits appels concis à Supergroup.

Ce que nous avons vu dans le code était typique des enchevêtrements qui surviennent dans le traitement des structures de données hiérarchiques ou groupées, en particulier dans les applications D3 une fois qu'elles deviennent plus grandes que les démos. Ces problèmes surviennent avec les applications de création de rapports en général, dans les applications CRUD qui impliquent le filtrage ou l'exploration d'écrans ou d'enregistrements spécifiques, dans les outils d'analyse, les outils de visualisation, pratiquement toutes les applications où suffisamment de données sont utilisées pour nécessiter une base de données.

Manipulation en mémoire

Prenez une API Rest pour la recherche à facettes et les opérations CRUD, par exemple, vous pourriez vous retrouver avec un ou plusieurs appels d'API pour obtenir l'ensemble de champs et de valeurs (peut-être avec le nombre d'enregistrements) pour tous les paramètres de recherche, un autre appel d'API pour obtenir un enregistrement spécifique, et d'autres appels pour obtenir des groupes d'enregistrements pour les rapports ou quelque chose. Ensuite, tout cela sera probablement compliqué par la nécessité d'imposer des filtres temporaires basés sur la sélection ou les autorisations de l'utilisateur.

S'il est peu probable que votre base de données dépasse des dizaines ou des centaines de milliers d'enregistrements, ou si vous avez des moyens simples de limiter l'univers immédiat d'intérêt à un ensemble de données de cette taille, vous pourriez probablement jeter toute votre API Rest compliquée (à l'exception de la partie autorisations ), et avoir un seul appel qui dit "obtenez-moi tous les enregistrements". Nous vivons dans un monde avec une compression rapide, des vitesses de transfert rapides, beaucoup de mémoire à l'avant et des moteurs Javascript rapides. L'établissement de schémas de requêtes complexes qui doivent être compris et maintenus par le client et le serveur est souvent inutile. Les gens ont écrit des bibliothèques pour exécuter des requêtes SQL directement sur des collections d'enregistrements JSON, car la plupart du temps, vous n'avez pas besoin de toute l'optimisation d'un SGBDR. Mais même cela est exagéré. Au risque de paraître incroyablement grandiose, Supergroup est plus facile à utiliser et plus puissant que SQL la plupart du temps.

Supergroup est essentiellement d3.nest, underscore.groupBy ou underscore.nest sous stéroïdes. Sous le capot, il utilise groupBy de lodash pour l'opération de regroupement. La stratégie centrale est de transformer chaque élément de données original en métadonnées et des liens vers le reste de l'arborescence immédiatement accessibles à chaque nœud ; et chaque nœud ou liste de nœuds est surchargé d'un gâteau de mariage de sucre syntaxique de sorte que presque tout ce que vous voudriez savoir de n'importe où sur l'arbre est disponible dans une courte expression.

Supergroupe en action

Pour démontrer une certaine douceur syntaxique de Supergroup, j'ai détourné une copie de Mister Nester de Shan Carter. Une simple imbrication à deux niveaux utilisant d3.nest ressemble à :

 d3.nest() .key(function(d) { return d.year; }) .key(function(d) { return d.fips; }) .map(data);

L'équivalent avec Supergroup serait :

 _.supergroup(data,['year','fips']).d3NestMap();

L'appel final à d3NestMap() place simplement la sortie du Supergroup dans le même format (mais pas très utile à mon avis) que nest.map() de d3 :

 { "1970": { "6001": [ { "fips": "6001", "totalpop": "1073180", "pctHispanic": "0.126", "year": "1970" } ], "6003": [ { "fips": "6003", "totalpop": "510", "pctHispanic": "NA", "year": "1970" } ], ... } }

Je dis "pas très utile" car les sélections D3 doivent être liées à des tableaux, pas à des cartes. Qu'est-ce qu'un « nœud » dans cette structure de données cartographiques ? « 1970 » ou « 6001 » ne sont que des chaînes et des clés dans une carte de premier ou de deuxième niveau. Ainsi, un nœud serait ce vers quoi les clés pointent. "1970" pointe vers une carte de second niveau, "6001" pointe vers un tableau d'enregistrements bruts. Cette imbrication de carte est lisible dans la console et convient pour rechercher des valeurs, mais pour les appels D3, vous avez besoin de données de tableau, vous utilisez donc nest.entries() au lieu de nest.map() :

 [ { "key": "1970", "values": [ { "key": "6001", "values": [ { "fips": "6001", "totalpop": "1073180", "pctHispanic": "0.126", "year": "1970" } ] }, { "key": "6003", "values": [ { "fips": "6003", "totalpop": "510", "pctHispanic": "NA", "year": "1970" } ] }, ... ] }, ... ]

Nous avons maintenant des tableaux imbriqués de paires clé/valeur : le nœud 1970 a une clé de « 1970 » et une valeur consistant en un tableau de paires clé/valeur de second niveau. 6001 est une autre paire clé/valeur. Sa clé est également une chaîne l'identifiant, mais la valeur est un tableau d'enregistrements bruts. Nous devons traiter ces nœuds de niveau deuxième à feuille ainsi que les nœuds de niveau feuille différemment des nœuds plus haut dans l'arbre. Et les nœuds eux-mêmes ne contiennent aucune preuve que "1970" est une année et "6001" est un code fips, ou que 1970 est le parent de ce nœud 6001 particulier. Je vais montrer comment Supergroup résout ces problèmes, mais je vais d'abord examiner la valeur de retour immédiate d'un appel Supergroup. À première vue, il ne s'agit que d'un tableau de "clés" de niveau supérieur :

 _.supergroup(data,['year','fips']); // [ 1970, 1980, 1990, 2000, 2010 ]

"Ok, c'est bien," dites-vous. « Mais où sont les autres données ? » Les chaînes ou les nombres de la liste Supergroup sont en fait des objets String ou Number, surchargés avec plus de propriétés et de méthodes. Pour les nœuds situés au-dessus du niveau feuille, il existe une propriété children (« enfants » est le nom par défaut, vous pouvez l'appeler autrement) contenant une autre liste de supergroupes de nœuds de second niveau :

 _.supergroup(data,['year','fips'])[0].children; // [ 6001, 6003, 6005, 6007, 6009, 6011, ... ] 

Fonction d'info-bulle qui fonctionne

Afin de démontrer d'autres fonctionnalités et comment tout cela fonctionne, faisons une simple liste imbriquée en utilisant D3, et voyons comment nous créons une fonction d'info-bulle utile qui peut fonctionner sur n'importe quel nœud de la liste.

 d3.select('body') .selectAll('div.year') .data(_.supergroup(data,['year','fips'])) .enter() .append('div').attr('class','year') .on('mouseover', tooltip) .selectAll('div.fips') .data(function(d) { return d.children; }) .enter() .append('div').attr('class','fips') .on('mouseover', tooltip); function tooltip(node) { // comments show values for a second-level node var typeOfNode = node.dim; // fips var nodeValue = node.toString(); // 6001 var totalPopulation = node.aggregate(d3.sum, 'totalpop'); // 1073180 var pathToRoot = node.namePath(); // 1970/6001 var fieldPath = node.dimPath(); // year/fips var rawRecordCount = node.records.length; var parentPop = node.parent.aggregate(d3.sum, 'totalpop'); var percentOfGroup = 100 * totalPopulation / parentPop; var percentOfAll = 100 * totalPopulation / node.path()[0].aggregate(d3.sum,'totalPop'); ... };

Cette fonction d'info-bulle fonctionnera pour presque tous les nœuds à n'importe quelle profondeur. Étant donné que les nœuds au niveau supérieur n'ont pas de parents, nous pouvons le faire pour contourner le problème :

 var byYearFips = _.supergroup(data,['year','fips']); var root = byYearFips.asRootVal();

Nous avons maintenant un nœud racine qui est parent de tous les nœuds Year. Nous n'avons rien à faire avec, mais maintenant notre info-bulle fonctionnera car node.parent a quelque chose à pointer. Et node.path()[0] qui était censé pointer vers un nœud qui représente l'ensemble de données le fait réellement.

Au cas où ce n'était pas évident d'après les exemples ci-dessus, namePath, dimPath et path donnent un chemin de la racine au nœud actuel :

 var byYearFips = _.supergroup(data,['year','fips']); // BTW, you can give a delimiter string to namePath or dimPath otherwise it defaults to '/': byYearFips[0].children[0].namePath(' --> '); // ==> "1970 --> 6001" byYearFips[0].children[0].dimPath(); // ==> "year/fips" byYearFips[0].children[0].path(); // ==> [1970,6001] // after calling asRootVal, paths go up one more level: var root = byYearFips.asRootVal('Population by Year/Fips'); // you can give the root node a name or it defaults to 'Root' byYearFips[0].children[0].namePath(' --> '); // ==> undefined byYearFips[0].children[0].dimPath(); // ==> "root/year/fips" byYearFips[0].children[0].path(); // ==> ["Population by Year/Fips",1970,6001] // from any node, .path()[0] will point to the root: byYearFips[0].children[0].path()[0] === root; // ==> true

Agréger sur place quand vous en avez besoin

Le code d'info-bulle ci-dessus utilisait également la méthode "agrégation". « aggregate » est appelé sur un seul nœud et prend deux paramètres :

  1. Une fonction d'agrégation qui attend un tableau (généralement de nombres).
  2. Soit un nom de champ du champ à extraire des enregistrements regroupés sous ce nœud, soit une fonction à appliquer à chacun de ces enregistrements.

Il existe également une méthode pratique « agrégats » sur les listes (la liste de groupes de niveau supérieur ou les groupes enfants de n'importe quel nœud). Il peut retourner une liste ou une carte.

 _.supergroup(data,'year').aggregates(d3.sum,'totalpop'); // ==> [19957304,23667902,29760021,33871648,37253956] _.supergroup(data,'year').aggregates(d3.sum,'totalpop','dict'); // ==> {"1970":19957304,"1980":23667902,"1990":29760021,"2000":33871648,"2010":37253956}

Tableaux qui agissent comme des cartes

Avec d3.nest, nous avons tendance à utiliser .entries() plutôt que .map(), comme je l'ai dit plus tôt, car les "cartes" ne vous permettent pas d'utiliser toutes les fonctionnalités D3 (ou Underscore) qui dépendent des tableaux. Mais lorsque vous utilisez .entries() pour générer des tableaux, vous ne pouvez pas effectuer une simple recherche par valeur de clé. Bien sûr, Supergroup fournit le sucre syntaxique que vous voulez pour que vous n'ayez pas à parcourir tout un tableau chaque fois que vous voulez une seule valeur :

 _.supergroup(data,['year','fips']).lookup(1980); // ==> 1980 _.supergroup(data,['year','fips']).lookup([1980,6011]).namePath(); // ==> "1980/6011"

Comparaison des nœuds dans le temps

Une méthode .previous() sur les nœuds vous permet d'accéder au nœud précédent dans une liste de supergroupes. Vous pouvez utiliser .sort( ) ou .sortBy( ) sur une liste Supergroup (y compris une liste des enfants d'un nœud donné) pour s'assurer que les nœuds sont dans le bon ordre avant d'appeler .previous(). Voici un code pour signaler la variation annuelle de la population par région fips :

 _.chain(data) .supergroup(['fips','year']) .map(function(fips) { return [fips, _.chain(fips.children.slice(1)) .map(function(year) { return [year, year.aggregate(d3.sum,'totalpop') + ' (' + Math.round( (year.aggregate(d3.sum, 'totalpop') / year.previous().aggregate(d3.sum,'totalpop') - 1) * 100) + '% change from ' + year.previous() + ')' ]; }).object().value() ] }).object().value(); ==> { "6001": { "1980": "1105379 (3% change from 1970)", "1990": "1279182 (16% change from 1980)", "2000": "1443741 (13% change from 1990)", "2010": "1510271 (5% change from 2000)" }, "6003": { "1980": "1097 (115% change from 1970)", "1990": "1113 (1% change from 1980)", "2000": "1208 (9% change from 1990)", "2010": "1175 (-3% change from 2000)" }, ... }

Données tabulaires vers les dispositions hiérarchiques D3.js

Supergroup fait bien plus que ce que j'ai montré ici jusqu'à présent. Pour les visualisations D3 basées sur d3.layout.hierarchy, l'exemple de code sur la galerie D3 commence généralement par les données dans un format arborescent (cet exemple Treemap par exemple). Supergroup vous permet de préparer facilement des données tabulaires pour les visualisations d3.layout.hierarchy (exemple). Tout ce dont vous avez besoin est le nœud racine renvoyé par .asRootVal(), puis exécutez root.addRecordsAsChildrenToLeafNodes(). d3.layout.hierarchy s'attend à ce que le niveau inférieur des nœuds enfants soit un tableau d'enregistrements bruts. addRecordsAsChildrenToLeafNodes prend les nœuds feuilles d'un arbre de supergroupe et copie le tableau .records dans une propriété .children. Ce n'est pas la façon dont Supergroup aime généralement les choses, mais cela fonctionnera bien pour les Treemaps, les clusters, les partitions, etc. (d3.layout.hierarchy docs).

Comme la méthode d3.layout.hierarchy.nodes qui renvoie tous les nœuds d'un arbre dans un seul tableau, Supergroup fournit .descendants() pour obtenir tous les nœuds à partir d'un nœud spécifique, .flattenTree() pour obtenir tous les nœuds à partir à partir d'une liste de supergroupes régulière, et .leafNodes() pour obtenir juste un tableau des nœuds feuille.

Regroupement et agrégation par champs à valeurs multiples

Sans entrer dans les détails exhaustifs, je mentionnerai que Supergroup possède certaines fonctionnalités pour gérer des situations qui se produisent moins fréquemment mais suffisamment fréquemment pour mériter un traitement spécial.

Parfois, vous souhaitez regrouper par un champ qui peut avoir plusieurs valeurs. Dans les champs relationnels ou tabulaires, les champs à valeurs multiples ne devraient généralement pas apparaître (ils cassent la première forme normale), mais ils peuvent être utiles. Voici comment Supergroup gère un tel cas :

 var bloggers = [ { name:"Ridwan", profession:["Programmer"], articlesPublished:73 }, { name:"Sigfried", profession:["Programmer","Spiritualist"], articlesPublished:2 }, ]; // the regular way _.supergroup(bloggers, 'profession').aggregates(_.sum, 'articlesPublished','dict'); // ==> {"Programmer":73,"Programmer,Spiritualist":2} // with multiValuedGroups _.supergroup(bloggers, 'profession',{multiValuedGroups:true}).aggregates(_.sum, 'articlesPublished','dict'); // ==> {"Programmer":75,"Spiritualist":2}

Comme vous pouvez le voir, avec multiValuedGroup, la somme de tous les articlesPublished dans la liste du groupe est supérieure au nombre total réel d'articlesPublished car l'enregistrement Sigfried est compté deux fois. Parfois, c'est le comportement souhaité.

Transformer des tables hiérarchiques en arborescences

Une autre chose qui peut apparaître occasionnellement est une structure tabulaire qui représente un arbre à travers des relations parent/enfant explicites entre les enregistrements. Voici un exemple d'une petite taxonomie :

p c
animal mammifère
animal reptile
animal poisson
animal oiseau
plante arbre
plante gazon
arbre chêne
arbre érable
chêne pin chêne
mammifère primate
mammifère bovine
bovine vache
bovine bœuf
primate singe
primate singe
singe chimpanzé
singe gorille
singe moi
 tree = _.hierarchicalTableToTree(taxonomy, 'p', 'c'); // top-level nodes ==> ["animal","plant"] _.invoke(tree.flattenTree(), 'namePath'); // call namePath on every node ==> ["animal", "animal/mammal", "animal/mammal/primate", "animal/mammal/primate/monkey", "animal/mammal/primate/ape", "animal/mammal/primate/ape/chimpanzee", "animal/mammal/primate/ape/gorilla", "animal/mammal/primate/ape/me", "animal/mammal/bovine", "animal/mammal/bovine/cow", "animal/mammal/bovine/ox", "animal/reptile", "animal/fish", "animal/bird", "plant", "plant/tree", "plant/tree/oak", "plant/tree/oak/pin oak", "plant/tree/maple", "plant/grass"]

Conclusion

Donc, nous l'avons. J'utilise Supergroup sur tous les projets Javascript sur lesquels j'ai travaillé au cours des trois dernières années. Je sais que cela résout beaucoup de problèmes qui surviennent constamment dans la programmation centrée sur les données. L'API et l'implémentation ne sont pas du tout parfaites, et je serais ravi de trouver des collaborateurs intéressés à travailler dessus avec moi.

Après quelques jours de refactoring sur ce projet client, j'ai reçu un message de Dave, le programmeur avec qui je travaillais :

Dave : Je dois dire que je suis un assez grand fan des supergroupes. C'est nettoyer une tonne.

Sigfried : Oui. Je vais demander un témoignage à un moment donné :).

Dave : Ah, absolument.

Si vous l'essayez et que des questions ou des problèmes surviennent, laissez tomber une ligne dans la section des commentaires ou publiez un problème sur le référentiel GitHub.