Manipolazione definitiva della raccolta dei dati in memoria con Supergroup.js
Pubblicato: 2022-03-11La manipolazione in memoria dei dati spesso si traduce in una pila di codici di spaghetti. La manipolazione stessa potrebbe essere abbastanza semplice: raggruppare, aggregare, creare gerarchie ed eseguire calcoli; ma una volta che il codice di scambio di dati è stato scritto e i risultati sono stati inviati alla parte dell'applicazione in cui sono necessari, le esigenze correlate continuano a sorgere. Una trasformazione simile dei dati potrebbe essere richiesta in un'altra parte dell'applicazione, o potrebbero essere necessari maggiori dettagli: metadati, contesto, dati padre o figlio, ecc. In particolare nelle applicazioni di visualizzazione o di reporting complesse, dopo aver inserito i dati in una struttura per un data la necessità, ci si rende conto che i suggerimenti o le evidenziazioni sincronizzate o le analisi dettagliate esercitano pressioni inaspettate sui dati trasformati. Si potrebbe rispondere a questi requisiti:
- Riempire più dettagli e più livelli nei dati trasformati fino a quando non sono enormi e sgraziati ma soddisfano le esigenze di tutti gli angoli e le fessure dell'applicazione che alla fine visita.
- Scrivere nuove funzioni di trasformazione che devono unire alcuni nodi già elaborati all'origine dati globale per inserire nuovi dettagli.
- Progettare classi di oggetti complesse che in qualche modo sappiano come gestire tutti i contesti in cui finiscono..
Dopo aver creato un software incentrato sui dati per 20 o 30 anni come me, si inizia a sospettare che stiano risolvendo la stessa serie di problemi più e più volte. Introduciamo cicli complessi, comprensioni di elenchi, funzioni analitiche di database, funzioni map o groupBy o persino motori di reporting a tutti gli effetti. Man mano che le nostre capacità si sviluppano, miglioriamo nel rendere intelligente e conciso qualsiasi pezzo di codice di munging di dati, ma gli spaghetti sembrano ancora proliferare.
In questo articolo, daremo un'occhiata alla libreria JavaScript Supergroup.js - dotata di alcune potenti funzioni di manipolazione, raggruppamento e aggregazione dei dati in memoria - e come può aiutarti a risolvere alcune comuni sfide di manipolazione su set di dati limitati.
Il problema
Durante il mio primo impegno con Toptal, ero convinto fin dal primo giorno che l'API e le routine di gestione dei dati della base di codice a cui stavo aggiungendo erano state irrimediabilmente sovraspecificate. Era un'applicazione D3.js per l'analisi dei dati di marketing. L'applicazione disponeva già di un'attraente visualizzazione del grafico a barre raggruppate/impilate e richiedeva la creazione di una visualizzazione della mappa coropletica. Il grafico a barre consentiva all'utente di visualizzare 2, 3 o 4 dimensioni arbitrarie denominate internamente x0, x1, y0 e y1, con x1 e y1 opzionali.
Nella costruzione di legende, filtri, descrizioni comandi, titoli e nel calcolo di totali o differenze da un anno all'altro, x0, x1, y0 e y1 sono stati indicati in tutto il codice e in tutto il codice c'era una logica condizionale da gestire. la presenza o meno di dimensioni opzionali.
Poteva essere peggio però. Il codice potrebbe fare riferimento direttamente a specifiche dimensioni dei dati sottostanti (ad es. anno, budget, livello, categoria di prodotto e così via). Piuttosto, è stato almeno generalizzato alle dimensioni di visualizzazione di questo grafico a barre raggruppato/impilato. Ma quando un altro tipo di grafico è diventato un requisito, uno in cui le dimensioni di x0, x1, y0 e y1 non avrebbero avuto senso, una parte significativa del codice ha dovuto essere riscritta interamente: codice che riguarda legende, filtri, descrizioni comandi, titoli , calcoli di riepilogo e costruzione e rendering di grafici.
Nessuno vuole dire al proprio cliente: "So che è solo il mio primo giorno qui, ma prima di implementare ciò che hai chiesto, posso refactoring di tutto il codice utilizzando una libreria di manipolazione dei dati Javascript che ho scritto io stesso?" Per un colpo di fortuna, sono stato salvato da questo imbarazzo quando sono stato presentato a un programmatore client che era comunque sul punto di refactoring del codice. Con insolita apertura mentale e grazia, il cliente mi ha invitato nel processo di refactoring attraverso una serie di sessioni di programmazione di coppia. Era disposto a provare Supergroup.js e in pochi minuti stavamo iniziando a sostituire grandi porzioni di codice nodoso con piccole chiamate concise a Supergroup.
Quello che abbiamo visto nel codice era tipico dei grovigli che sorgono quando si tratta di strutture di dati gerarchiche o raggruppate, in particolare nelle applicazioni D3 una volta che diventano più grandi delle demo. Questi problemi sorgono con le applicazioni di reporting in generale, nelle applicazioni CRUD che implicano il filtraggio o il drilling su schermate o record specifici, negli strumenti di analisi, negli strumenti di visualizzazione, praticamente in qualsiasi applicazione in cui vengono utilizzati dati sufficienti per richiedere un database.
Manipolazione in memoria
Prendi un'API Rest per la ricerca sfaccettata e le operazioni CRUD, ad esempio, potresti finire con una o più chiamate API per ottenere l'insieme di campi e valori (magari con conteggi di record) per tutti i parametri di ricerca, un'altra chiamata API per ottenere un record specifico e altre chiamate per ottenere gruppi di record da segnalare o qualcosa del genere. Quindi è probabile che tutti questi siano complicati dalla necessità di imporre filtri temporanei basati sulla selezione o sulle autorizzazioni dell'utente.
Se è improbabile che il tuo database superi le decine o centinaia di migliaia di record, o se hai modi semplici per limitare l'universo di interesse immediato a un set di dati di quelle dimensioni, potresti probabilmente buttare via l'intera complicata API Rest (tranne la parte delle autorizzazioni ), e fai una sola chiamata che dice "dammi tutti i record". Viviamo in un mondo con compressione veloce, velocità di trasferimento elevate, molta memoria nel front-end e veloci motori Javascript. Spesso non è necessario stabilire schemi di query complessi che devono essere compresi e gestiti da client e server. Le persone hanno scritto librerie per eseguire query SQL direttamente su raccolte di record JSON, perché la maggior parte delle volte non è necessaria l'ottimizzazione di un RDBMS. Ma anche questo è eccessivo. A rischio di sembrare follemente grandioso, Supergroup è più facile da usare e più potente di SQL per la maggior parte del tempo.
Supergroup è fondamentalmente d3.nest, underscore.groupBy o underscore.nest con steroidi. Sotto il cofano utilizza groupBy di lodash per l'operazione di raggruppamento. La strategia centrale consiste nel trasformare ogni dato originale in metadati e i collegamenti al resto dell'albero sono immediatamente accessibili in ogni nodo; e ogni nodo o elenco di nodi è sovraccarico di una torta nuziale di zucchero sintattico in modo che la maggior parte di tutto ciò che vorresti sapere da qualsiasi punto dell'albero sia disponibile in una breve espressione.
Supergruppo in azione
Per dimostrare una certa dolcezza sintattica di Supergroup, ho rubato una copia di Mister Nester di Shan Carter. Un semplice annidamento a due livelli che utilizza d3.nest è simile a:
d3.nest() .key(function(d) { return d.year; }) .key(function(d) { return d.fips; }) .map(data);
L'equivalente con Supergroup sarebbe:
_.supergroup(data,['year','fips']).d3NestMap();
La chiamata finale a d3NestMap() inserisce semplicemente l'output del Supergroup nello stesso formato (ma non molto utile secondo me) di nest.map() di d3:
{ "1970": { "6001": [ { "fips": "6001", "totalpop": "1073180", "pctHispanic": "0.126", "year": "1970" } ], "6003": [ { "fips": "6003", "totalpop": "510", "pctHispanic": "NA", "year": "1970" } ], ... } }
Dico "non molto utile" perché le selezioni D3 devono essere legate agli array, non alle mappe. Che cos'è un "nodo" in questa struttura di dati della mappa? "1970" o "6001", sono solo stringhe e chiavi in una mappa di livello superiore o di secondo livello. Quindi, un nodo sarebbe ciò a cui puntano le chiavi. "1970" punta a una mappa di secondo livello, "6001" punta a una serie di record grezzi. Questa nidificazione della mappa è leggibile nella console e va bene per cercare i valori, ma per le chiamate D3 hai bisogno di dati array, quindi usi nest.entries() invece di 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" } ] }, ... ] }, ... ]
Ora abbiamo array nidificati di coppie chiave/valore: il nodo 1970 ha una chiave di "1970" e un valore costituito da un array di coppie chiave/valore di secondo livello. 6001 è un'altra coppia chiave/valore. La sua chiave è anche una stringa che la identifica, ma il valore è una matrice di record non elaborati. Dobbiamo trattare questi nodi dal secondo al livello foglia così come i nodi a livello foglia in modo diverso dai nodi più in alto nell'albero. E i nodi stessi non contengono prove che "1970" sia un anno e "6001" sia un codice fips, o che 1970 sia il genitore di questo particolare nodo 6001. Dimostrerò come Supergroup risolve questi problemi, ma prima uno sguardo al valore di ritorno immediato di una chiamata Supergroup. A prima vista è solo una serie di "chiavi" di primo livello:
_.supergroup(data,['year','fips']); // [ 1970, 1980, 1990, 2000, 2010 ]
“Ok, è carino,” dici. "Ma dov'è il resto dei dati?" Le stringhe oi numeri nell'elenco Supergroup sono in realtà oggetti String o Number, sovraccaricati con più proprietà e metodi. Per i nodi al di sopra del livello foglia, c'è una proprietà figli ("bambini" è il nome predefinito, potresti chiamarlo qualcos'altro) che contiene un altro elenco di Supergruppi di nodi di secondo livello:
_.supergroup(data,['year','fips'])[0].children; // [ 6001, 6003, 6005, 6007, 6009, 6011, ... ]
Funzione tooltip che funziona
Per dimostrare altre caratteristiche e come funziona l'intera cosa, creiamo un semplice elenco nidificato usando D3 e vediamo come creiamo un'utile funzione di descrizione comando che può funzionare su qualsiasi nodo nell'elenco.
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'); ... };
Questa funzione di descrizione comando funzionerà per quasi tutti i nodi a qualsiasi profondità. Poiché i nodi al livello superiore non hanno genitori, possiamo farlo per ovviare al problema:

var byYearFips = _.supergroup(data,['year','fips']); var root = byYearFips.asRootVal();
Ora abbiamo un nodo radice che è padre di tutti i nodi Anno. Non dobbiamo farci nulla, ma ora il nostro suggerimento funzionerà perché node.parent ha qualcosa a cui puntare. E node.path()[0] che avrebbe dovuto puntare a un nodo che rappresenta l'intero set di dati lo fa effettivamente.
Nel caso non fosse ovvio dagli esempi precedenti, namePath, dimPath e path forniscono un percorso dalla radice al nodo corrente:
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
Aggrega sul posto quando è necessario
Il codice del suggerimento sopra riportato utilizzava anche il metodo "aggregato". “aggregate” viene chiamato su un singolo nodo e richiede due parametri:
- Una funzione di aggregazione che prevede un array (solitamente di numeri).
- Un nome di campo del campo da estrarre dai record raggruppati in quel nodo o una funzione da applicare a ciascuno di quei record.
C'è anche un metodo di convenienza "aggregati" sugli elenchi (l'elenco di gruppi di livello superiore o i gruppi figlio di qualsiasi nodo). Può restituire un elenco o una mappa.
_.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}
Array che agiscono come mappe
Con d3.nest tendiamo a usare .entries() piuttosto che .map(), come ho detto prima, perché "maps" non ti consente di utilizzare tutte le funzionalità D3 (o Underscore) che dipendono dagli array. Ma quando usi .entries() per generare array, non puoi eseguire una semplice ricerca per valore chiave. Ovviamente, Supergroup fornisce lo zucchero sintattico che desideri in modo da non dover arrancare attraverso un intero array ogni volta che desideri un singolo valore:
_.supergroup(data,['year','fips']).lookup(1980); // ==> 1980 _.supergroup(data,['year','fips']).lookup([1980,6011]).namePath(); // ==> "1980/6011"
Confronto di nodi nel tempo
Un metodo .previous() sui nodi ti consente di accedere al nodo precedente in un elenco di supergruppi. Puoi usare .sort(
_.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)" }, ... }
Dati tabulari ai layout della gerarchia D3.js
Supergroup fa molto di più di quello che ho mostrato qui finora. Per le visualizzazioni D3 basate su d3.layout.hierarchy, il codice di esempio nella galleria D3 inizia generalmente con i dati in un formato ad albero (ad esempio questo esempio di Treemap). Supergroup ti consente di ottenere facilmente dati tabulari pronti per le visualizzazioni d3.layout.hierarchy (esempio). Tutto ciò che serve è il nodo radice restituito da .asRootVal(), quindi eseguire root.addRecordsAsChildrenToLeafNodes(). d3.layout.hierarchy prevede che il livello inferiore dei nodi figlio sia una matrice di record grezzi. addRecordsAsChildrenToLeafNodes prende i nodi foglia di un albero Supergroup e copia l'array .records in una proprietà .children. Non è il modo in cui a Supergroup piacciono di solito le cose, ma funzionerà bene per Treemap, Cluster, Partizioni, ecc. (d3.layout.hierarchy docs).
Come il metodo d3.layout.hierarchy.nodes che restituisce tutti i nodi di un albero come un unico array, Supergroup fornisce .descendants() per ottenere tutti i nodi a partire da un nodo specifico, .flattenTree() per far partire tutti i nodi da un normale elenco di Supergruppi e .leafNodes() per ottenere solo un array di nodi foglia.
Raggruppamento e aggregazione per campi multivalore
Senza entrare nei dettagli esaurienti, menzionerò che Supergroup ha alcune funzionalità per gestire situazioni che si verificano meno comunemente ma abbastanza comunemente da meritare un trattamento speciale.
A volte si desidera raggruppare per un campo che può avere più di un valore. Nei campi relazionali o tabulari, generalmente non dovrebbero verificarsi campi multivalore (interrompono la prima forma normale), ma possono essere utili. Ecco come Supergroup gestisce un caso del genere:
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}
Come puoi vedere, con multiValuedGroup, la somma di tutti gli articoliPubblicati nell'elenco dei gruppi è maggiore del numero totale effettivo di articoliPubblicati perché il record Sigfried viene conteggiato due volte. A volte questo è il comportamento desiderato.
Trasformare le tabelle gerarchiche in alberi
Un'altra cosa che può emergere occasionalmente è una struttura tabulare che rappresenta un albero attraverso relazioni genitore/figlio esplicite tra i record. Ecco un esempio di una piccola tassonomia:
P | C |
---|---|
animale | mammifero |
animale | rettile |
animale | pescare |
animale | uccello |
pianta | albero |
pianta | erba |
albero | Quercia |
albero | acero |
Quercia | perno di quercia |
mammifero | primate |
mammifero | bovino |
bovino | mucca |
bovino | bue |
primate | scimmia |
primate | scimmia |
scimmia | scimpanzé |
scimmia | gorilla |
scimmia | me |
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"]
Conclusione
Quindi, ecco qua. Ho usato Supergroup su ogni progetto Javascript su cui ho lavorato negli ultimi tre anni. So che risolve molti problemi che emergono costantemente nella programmazione incentrata sui dati. L'API e l'implementazione non sono affatto perfette e sarei felice di trovare collaboratori interessati a lavorarci con me.
Dopo un paio di giorni di refactoring su quel progetto client, ho ricevuto un messaggio da Dave, il programmatore con cui stavo lavorando:
Dave: Devo dire che sono un grande fan dei supergruppi. Sta ripulendo un sacco.
Sigfrido: Sì. A un certo punto chiederò una testimonianza :).
Dave: Ah assolutamente.
Se ci provi e sorgono domande o problemi, lascia una riga nella sezione commenti o pubblica un problema nel repository GitHub.