Ultimative In-Memory-Datenerfassungsmanipulation mit Supergroup.js
Veröffentlicht: 2022-03-11Die In-Memory-Manipulation von Daten führt oft zu einem Haufen Spaghetti-Code. Die Manipulation selbst könnte einfach genug sein: Gruppieren, Aggregieren, Erstellen von Hierarchien und Durchführen von Berechnungen; Aber sobald der Datenmunging-Code geschrieben ist und die Ergebnisse an den Teil der Anwendung gesendet werden, wo sie benötigt werden, entstehen weiterhin entsprechende Anforderungen. Eine ähnliche Transformation der Daten kann in einem anderen Teil der Anwendung erforderlich sein, oder es können weitere Details erforderlich sein: Metadaten, Kontext, übergeordnete oder untergeordnete Daten usw. Insbesondere in Visualisierungs- oder komplexen Berichtsanwendungen, nachdem Daten in eine Struktur für a Bei Bedarf stellt man fest, dass Tooltips oder synchronisierte Hervorhebungen oder Drilldowns unerwarteten Druck auf die transformierten Daten ausüben. Man könnte diesen Anforderungen begegnen, indem man:
- Füllen Sie mehr Details und mehr Ebenen in die transformierten Daten, bis sie riesig und plump sind, aber die Anforderungen aller Ecken und Winkel der Anwendung erfüllen, die sie schließlich besuchen.
- Schreiben neuer Transformationsfunktionen, die einige bereits verarbeitete Knoten mit der globalen Datenquelle verbinden müssen, um neue Details einzubringen.
- Komplexe Objektklassen entwerfen, die irgendwie wissen, wie sie mit all den Kontexten umgehen sollen, in denen sie landen.
Nachdem man wie ich 20 oder 30 Jahre lang datenzentrierte Software entwickelt hat, beginnt man zu vermuten, dass sie immer wieder dieselben Probleme lösen. Wir bringen komplexe Schleifen, Listenverständnisse, Datenbankanalysefunktionen, Map- oder GroupBy-Funktionen oder sogar vollwertige Reporting-Engines ein. Während sich unsere Fähigkeiten entwickeln, werden wir besser darin, jeden Teil des Datenmunging-Codes clever und prägnant zu machen, aber Spaghetti scheinen sich immer noch zu vermehren.
In diesem Artikel werfen wir einen Blick auf die JavaScript-Bibliothek Supergroup.js – ausgestattet mit einigen leistungsstarken In-Memory-Datenerfassungs-Manipulations-, Gruppierungs- und Aggregationsfunktionen – und wie sie Ihnen helfen kann, einige häufige Manipulationsherausforderungen bei begrenzten Datensätzen zu lösen.
Das Problem
Während meines ersten Toptal-Engagements war ich vom ersten Tag an davon überzeugt, dass die API- und Datenverwaltungsroutinen der Codebasis, die ich ergänzte, hoffnungslos überspezifiziert waren. Es war eine D3.js-Anwendung zur Analyse von Marketingdaten. Die Anwendung verfügte bereits über eine ansprechende gruppierte/gestapelte Balkendiagramm-Visualisierung und erforderte die Erstellung einer Choroplethen-Kartenvisualisierung. Das Balkendiagramm ermöglichte es dem Benutzer, 2, 3 oder 4 beliebige Dimensionen anzuzeigen, die intern als x0, x1, y0 und y1 bezeichnet wurden, wobei x1 und y1 optional sind.
Bei der Konstruktion von Legenden, Filtern, QuickInfos, Titeln und der Berechnung von Summen oder jährlichen Differenzen wurde im gesamten Code auf x0, x1, y0 und y1 verwiesen, und überall im Code war bedingte Logik zu handhaben das Vorhandensein oder Fehlen optionaler Abmessungen.
Es hätte aber schlimmer kommen können. Der Code könnte sich direkt auf bestimmte zugrunde liegende Datendimensionen bezogen haben (z. B. Jahr, Budget, Ebene, Produktkategorie usw.). Vielmehr wurde er zumindest auf die Anzeigedimensionen dieses gruppierten/gestapelten Balkendiagramms verallgemeinert. Als jedoch ein anderer Diagrammtyp erforderlich wurde, bei dem die Dimensionen x0, x1, y0 und y1 keinen Sinn mehr machten, musste ein erheblicher Teil des Codes komplett neu geschrieben werden – Code, der sich mit Legenden, Filtern, QuickInfos und Titeln befasst , zusammenfassende Berechnungen sowie Diagrammerstellung und -darstellung.
Niemand möchte seinem Kunden sagen: „Ich weiß, es ist erst mein erster Tag hier, aber bevor ich das implementiere, worum Sie gebeten haben, kann ich den gesamten Code mithilfe einer von mir selbst geschriebenen JavaScript-Datenmanipulationsbibliothek umgestalten?“ Durch einen großen Glücksfall wurde ich vor dieser Peinlichkeit bewahrt, als ich einem Client-Programmierer vorgestellt wurde, der sowieso kurz davor war, den Code umzugestalten. Mit ungewöhnlicher Aufgeschlossenheit und Anmut lud mich der Kunde durch eine Reihe von Programmiersitzungen zu zweit in den Refactoring-Prozess ein. Er war bereit, Supergroup.js auszuprobieren, und innerhalb von Minuten begannen wir, große Schwaden knorrigen Codes durch prägnante kleine Aufrufe an Supergroup zu ersetzen.
Was wir im Code gesehen haben, war typisch für die Verwicklungen, die beim Umgang mit hierarchischen oder gruppierten Datenstrukturen entstehen, insbesondere in D3-Anwendungen, sobald sie größer als Demos werden. Diese Probleme treten bei Berichtsanwendungen im Allgemeinen auf, bei CRUD-Anwendungen, die das Filtern oder Drillen zu bestimmten Bildschirmen oder Datensätzen beinhalten, bei Analysetools, Visualisierungstools, praktisch jeder Anwendung, bei der genügend Daten verwendet werden, um eine Datenbank zu erfordern.
In-Memory-Manipulation
Nehmen Sie zum Beispiel eine Rest-API für Facettensuche und CRUD-Vorgänge, Sie könnten am Ende einen oder mehrere API-Aufrufe haben, um den Satz von Feldern und Werten (möglicherweise mit Datensatzanzahl) für alle Suchparameter abzurufen, einen weiteren API-Aufruf zum Abrufen von a bestimmten Datensatz und andere Aufrufe zum Abrufen von Gruppen von Datensätzen für die Berichterstattung oder so etwas. All dies wird wahrscheinlich durch die Notwendigkeit kompliziert, temporäre Filter basierend auf der Benutzerauswahl oder den Berechtigungen einzuführen.
Wenn es unwahrscheinlich ist, dass Ihre Datenbank Zehn- oder Hunderttausende von Datensätzen überschreitet, oder wenn Sie einfache Möglichkeiten haben, das unmittelbar interessierende Universum auf einen Datensatz dieser Größe zu beschränken, könnten Sie wahrscheinlich Ihre gesamte komplizierte Rest-API (mit Ausnahme des Berechtigungsteils) wegwerfen ) und einen einzigen Anruf mit der Aufschrift „Holen Sie mir alle Datensätze“ zu tätigen. Wir leben in einer Welt mit schneller Komprimierung, schnellen Übertragungsgeschwindigkeiten, viel Speicher am Frontend und schnellen Javascript-Engines. Das Erstellen komplexer Abfrageschemata, die von Client und Server verstanden und gepflegt werden müssen, ist oft unnötig. Die Leute haben Bibliotheken geschrieben, um SQL-Abfragen direkt auf Sammlungen von JSON-Datensätzen auszuführen, da Sie die meiste Zeit nicht die gesamte Optimierung eines RDBMS benötigen. Aber selbst das ist übertrieben. Auf die Gefahr hin, wahnsinnig grandios zu klingen, ist Supergroup einfacher zu verwenden und meistens leistungsfähiger als SQL.
Supergroup ist im Grunde d3.nest, underscore.groupBy oder underscore.nest auf Steroiden. Unter der Haube verwendet es lodashs groupBy für die Gruppierungsoperation. Die zentrale Strategie besteht darin, jedes Stück Originaldaten in Metadaten umzuwandeln und Links zum Rest des Baums sofort an jedem Knoten zugänglich zu machen; und jeder Knoten oder jede Liste von Knoten ist mit einer Hochzeitstorte aus syntaktischem Zucker überladen, so dass fast alles, was Sie von jedem Ort im Baum wissen möchten, in einem kurzen Ausdruck verfügbar ist.
Supergruppe in Aktion
Um eine gewisse syntaktische Süße von Supergroup zu demonstrieren, habe ich eine Kopie von Shan Carters Mister Nester gekapert. Eine einfache zweistufige Verschachtelung mit d3.nest sieht so aus:
d3.nest() .key(function(d) { return d.year; }) .key(function(d) { return d.fips; }) .map(data);
Das Äquivalent mit Supergroup wäre:
_.supergroup(data,['year','fips']).d3NestMap();
Der abschließende Aufruf von d3NestMap() bringt die Supergroup-Ausgabe einfach in das gleiche (aber meiner Meinung nach nicht sehr nützliche) Format wie nest.map() von d3:
{ "1970": { "6001": [ { "fips": "6001", "totalpop": "1073180", "pctHispanic": "0.126", "year": "1970" } ], "6003": [ { "fips": "6003", "totalpop": "510", "pctHispanic": "NA", "year": "1970" } ], ... } }
Ich sage „nicht sehr nützlich“, weil D3-Auswahlen an Arrays und nicht an Karten gebunden werden müssen. Was ist ein „Knoten“ in dieser Kartendatenstruktur? „1970“ oder „6001“ sind nur Zeichenfolgen und Schlüssel in einer Top- oder Second-Level-Map. Ein Knoten wäre also das, worauf die Schlüssel zeigen. „1970“ zeigt auf eine Karte der zweiten Ebene, „6001“ zeigt auf eine Reihe von Rohaufzeichnungen. Diese Kartenverschachtelung ist in der Konsole lesbar und zum Nachschlagen von Werten in Ordnung, aber für D3-Aufrufe benötigen Sie Array-Daten, also verwenden Sie nest.entries() anstelle von 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" } ] }, ... ] }, ... ]
Jetzt haben wir verschachtelte Arrays von Schlüssel/Wert-Paaren: Der 1970-Knoten hat einen Schlüssel von „1970“ und einen Wert, der aus einem Array von Schlüssel/Wert-Paaren der zweiten Ebene besteht. 6001 ist ein weiteres Schlüssel/Wert-Paar. Sein Schlüssel ist ebenfalls eine Zeichenfolge, die ihn identifiziert, aber der Wert ist ein Array von Rohdatensätzen. Wir müssen diese Knoten auf zweiter Ebene sowie Knoten auf Blattebene anders behandeln als Knoten weiter oben im Baum. Und die Knoten selbst enthalten keinen Hinweis darauf, dass „1970“ ein Jahr und „6001“ ein Fips-Code ist oder dass 1970 der übergeordnete Knoten dieses bestimmten 6001-Knotens ist. Ich werde demonstrieren, wie Supergroup diese Probleme löst, aber zuerst einen Blick auf den unmittelbaren Rückgabewert eines Supergroup-Aufrufs werfen. Auf den ersten Blick ist es nur eine Reihe von „Schlüsseln“ der obersten Ebene:
_.supergroup(data,['year','fips']); // [ 1970, 1980, 1990, 2000, 2010 ]
„Okay, das ist schön“, sagst du. „Aber wo sind die restlichen Daten?“ Die Strings oder Zahlen in der Supergroup-Liste sind eigentlich String- oder Number-Objekte, die mit mehr Eigenschaften und Methoden überladen sind. Für Knoten oberhalb der Blattebene gibt es eine Eigenschaft children (“children” ist der Standardname, man könnte es auch anders nennen), die eine weitere Supergroup-Liste mit Knoten der zweiten Ebene enthält:
_.supergroup(data,['year','fips'])[0].children; // [ 6001, 6003, 6005, 6007, 6009, 6011, ... ]
Tooltip-Funktion, die funktioniert
Um andere Funktionen zu demonstrieren und wie das Ganze funktioniert, erstellen wir eine einfache verschachtelte Liste mit D3 und sehen, wie wir eine nützliche Tooltip-Funktion erstellen, die auf jedem Knoten in der Liste funktionieren kann.
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'); ... };
Diese Tooltip-Funktion funktioniert für fast jeden Knoten in jeder Tiefe. Da Knoten auf der obersten Ebene keine Eltern haben, können wir dies tun, um dies zu umgehen:

var byYearFips = _.supergroup(data,['year','fips']); var root = byYearFips.asRootVal();
Jetzt haben wir einen Root-Knoten, der allen Year-Knoten übergeordnet ist. Wir müssen nichts damit tun, aber jetzt funktioniert unser Tooltip, weil node.parent etwas hat, auf das er zeigen kann. Und node.path()[0], das auf einen Knoten zeigen sollte, der den gesamten Datensatz darstellt, tut dies tatsächlich.
Falls es aus den obigen Beispielen nicht ersichtlich war, geben namePath, dimPath und path einen Pfad vom Stamm zum aktuellen Knoten an:
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
Aggregieren Sie vor Ort, wenn Sie es brauchen
Der obige Tooltip-Code verwendete auch die „Aggregate“-Methode. „aggregate“ wird von einem einzelnen Knoten aufgerufen und benötigt zwei Parameter:
- Eine aggregierende Funktion, die ein Array (normalerweise aus Zahlen) erwartet.
- Entweder ein Feldname des Feldes, das aus den Datensätzen entnommen werden soll, die unter diesem Knoten gruppiert sind, oder eine Funktion, die auf jeden dieser Datensätze anzuwenden ist.
Es gibt auch eine bequeme „Aggregate“-Methode für Listen (die Liste der Gruppen der obersten Ebene oder die untergeordneten Gruppen eines beliebigen Knotens). Es kann eine Liste oder eine Karte zurückgeben.
_.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}
Arrays, die sich wie Karten verhalten
Bei d3.nest verwenden wir eher .entries() als .map(), wie ich bereits sagte, weil „maps“ Ihnen nicht erlauben, alle D3- (oder Underscore-) Funktionen zu verwenden, die von Arrays abhängen. Aber wenn Sie .entries() verwenden, um Arrays zu generieren, können Sie nicht einfach nach Schlüsselwerten suchen. Natürlich liefert Supergroup den syntaktischen Zucker, den Sie wollen, damit Sie nicht jedes Mal durch ein ganzes Array stapfen müssen, wenn Sie einen einzelnen Wert wollen:
_.supergroup(data,['year','fips']).lookup(1980); // ==> 1980 _.supergroup(data,['year','fips']).lookup([1980,6011]).namePath(); // ==> "1980/6011"
Vergleichen von Knoten über die Zeit
Eine . previous () -Methode für Knoten ermöglicht den Zugriff auf den vorherigen Knoten in einer Supergroup-Liste. Sie können .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)" }, ... }
Tabellendaten zu D3.js-Hierarchielayouts
Supergroup macht viel mehr als das, was ich bisher hier gezeigt habe. Für D3-Visualisierungen, die auf d3.layout.hierarchy basieren, beginnt der Beispielcode in der D3-Galerie im Allgemeinen mit den Daten in einem Baumformat (z. B. dieses Treemap-Beispiel). Mit Supergroup können Sie ganz einfach tabellarische Daten für d3.layout.hierarchy-Visualisierungen vorbereiten (Beispiel). Alles, was Sie brauchen, ist der Root-Knoten, der von .asRootVal() zurückgegeben wird, und dann root.addRecordsAsChildrenToLeafNodes() auszuführen. d3.layout.hierarchy erwartet, dass die unterste Ebene der untergeordneten Knoten ein Array von Rohdatensätzen ist. addRecordsAsChildrenToLeafNodes nimmt Blattknoten eines Supergroup-Baums und kopiert das .records-Array in eine .children-Eigenschaft. So mag Supergroup die Dinge normalerweise nicht, aber es funktioniert gut für Treemaps, Cluster, Partitionen usw. (d3.layout.hierarchy docs).
Wie die Methode d3.layout.hierarchy.nodes, die alle Knoten in einem Baum als einzelnes Array zurückgibt, bietet Supergroup .descendants(), um alle Knoten ab einem bestimmten Knoten zu erhalten, .flattenTree(), um alle Knoten zu starten aus einer regulären Supergroup-Liste und .leafNodes(), um nur ein Array der Blattknoten zu erhalten.
Gruppieren und Aggregieren nach mehrwertigen Feldern
Ohne erschöpfend ins Detail zu gehen, möchte ich erwähnen, dass Supergroup einige Funktionen für den Umgang mit Situationen hat, die weniger häufig vorkommen, aber häufig genug sind, um eine besondere Behandlung zu verdienen.
Manchmal möchten Sie nach einem Feld gruppieren, das mehr als einen Wert haben kann. In relationalen oder tabellarischen Feldern sollten mehrwertige Felder im Allgemeinen nicht vorkommen (sie brechen die erste Normalform), aber sie können nützlich sein. So geht Supergroup mit einem solchen Fall um:
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}
Wie Sie sehen, ist bei multiValuedGroup die Summe aller in der Gruppenliste veröffentlichten Artikel höher als die tatsächliche Gesamtzahl der veröffentlichten Artikel, da der Sigfried-Datensatz doppelt gezählt wird. Manchmal ist dies das gewünschte Verhalten.
Hierarchische Tabellen in Bäume umwandeln
Eine andere Sache, die gelegentlich auftauchen kann, ist eine tabellarische Struktur, die einen Baum durch explizite Eltern-/Kind-Beziehungen zwischen Datensätzen darstellt. Hier ist ein Beispiel für eine winzige Taxonomie:
P | C |
---|---|
Tier | Säugetier |
Tier | Reptil |
Tier | Fisch |
Tier | Vogel |
Pflanze, Anlage | Baum |
Pflanze, Anlage | Gras |
Baum | Eiche |
Baum | Ahorn |
Eiche | Pin Eiche |
Säugetier | Primas |
Säugetier | Rinder- |
Rinder- | Kuh |
Rinder- | Ochse |
Primas | Affe |
Primas | Affe |
Affe | Schimpanse |
Affe | Gorilla |
Affe | mich |
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"]
Fazit
Da haben wir es also. Ich habe Supergroup in den letzten drei Jahren bei jedem Javascript-Projekt verwendet, an dem ich gearbeitet habe. Ich weiß, dass es viele Probleme löst, die bei der datenzentrierten Programmierung ständig auftreten. Die API und die Implementierung sind überhaupt nicht perfekt, und ich würde mich freuen, Mitarbeiter zu finden, die daran interessiert sind, mit mir daran zu arbeiten.
Nach ein paar Tagen des Refaktorisierens dieses Kundenprojekts erhielt ich eine Nachricht von Dave, dem Programmierer, mit dem ich zusammenarbeitete:
Dave: Ich muss sagen, ich bin ein ziemlich großer Fan von Supergroups. Es räumt eine Tonne auf.
Sigfried: Juhu. Irgendwann werde ich um ein Zeugnis bitten :).
Dave: Ha, absolut.
Wenn Sie es ausprobieren und Fragen oder Probleme auftreten, hinterlassen Sie eine Zeile im Kommentarbereich oder posten Sie ein Problem im GitHub-Repository.