Ostateczna manipulacja gromadzeniem danych w pamięci za pomocą Supergroup.js

Opublikowany: 2022-03-11

Manipulowanie danymi w pamięci często powoduje powstanie stosu kodu spaghetti. Sama manipulacja może być dość prosta: grupowanie, agregowanie, tworzenie hierarchii i wykonywanie obliczeń; ale po napisaniu kodu zaśmiecania danych i wysłaniu wyników do tej części aplikacji, w której są potrzebne, związane z tym potrzeby nadal się pojawiają. Podobna transformacja danych może być wymagana w innej części aplikacji lub może być potrzebnych więcej szczegółów: metadanych, kontekstu, danych nadrzędnych lub podrzędnych itp. W szczególności w wizualizacji lub złożonych aplikacjach raportowania, po przekształceniu danych w pewną strukturę dla biorąc pod uwagę potrzebę, uświadamiamy sobie, że podpowiedzi lub zsynchronizowane podświetlenia lub drążenia wywierają nieoczekiwaną presję na przekształcone dane. Te wymagania można rozwiązać poprzez:

  1. Umieszczaj więcej szczegółów i więcej poziomów w przekształconych danych, aż staną się ogromne i niezgrabne, ale zaspokoją potrzeby wszystkich zakamarków aplikacji, którą w końcu odwiedza.
  2. Pisanie nowych funkcji transformacji, które muszą dołączyć jakiś już przetworzony węzeł do globalnego źródła danych, aby wprowadzić nowe szczegóły.
  3. Projektowanie złożonych klas obiektów, które w jakiś sposób wiedzą, jak obsługiwać wszystkie konteksty, w których się znajdują..

Po tworzeniu oprogramowania zorientowanego na dane przez 20 lub 30 lat, tak jak ja, zaczyna się podejrzewać, że rozwiązują ten sam zestaw problemów w kółko. Wprowadzamy złożone pętle, rozumienia list, funkcje analityczne baz danych, funkcje map lub groupBy, a nawet pełnoprawne silniki raportujące. Wraz z rozwojem naszych umiejętności stajemy się coraz lepsi w tworzeniu sprytnych i zwięzłych fragmentów kodu do przetwarzania danych, ale spaghetti wciąż wydaje się rozmnażać.

W tym artykule przyjrzymy się bibliotece JavaScript Supergroup.js — wyposażonej w potężne funkcje manipulacji gromadzeniem danych w pamięci, grupowaniem i agregacją — oraz jak może ona pomóc w rozwiązaniu niektórych typowych problemów związanych z manipulacją na ograniczonych zestawach danych.

Problem

Podczas mojego pierwszego zaangażowania w Toptal od pierwszego dnia byłem przekonany, że API i procedury zarządzania danymi w bazie kodu, do której dodawałem, były beznadziejnie przespecyfikowane. Była to aplikacja D3.js do analizy danych marketingowych. Aplikacja miała już atrakcyjną wizualizację zgrupowanych/ułożonych wykresów słupkowych i wymagała zbudowania wizualizacji kartogramu. Wykres słupkowy pozwalał użytkownikowi wyświetlić 2, 3 lub 4 dowolne wymiary wewnętrznie nazwane x0, x1, y0 i y1, przy czym x1 i y1 są opcjonalne.

Supergroup.js - Toptal

Podczas konstruowania legend, filtrów, podpowiedzi, tytułów i obliczania sum lub różnic rocznych, x0, x1, y0 i y1 były odwoływane w całym kodzie, a wszechobecne w kodzie była logika warunkowa do obsługi obecność lub brak opcjonalnych wymiarów.

Mogło być jednak gorzej. Kod mógł odnosić się bezpośrednio do określonych podstawowych wymiarów danych (np. rok, budżet, warstwa, kategoria produktu itp.). Zamiast tego został przynajmniej uogólniony na wymiary wyświetlania tego pogrupowanego/skumulowanego wykresu słupkowego. Ale kiedy wymagany był inny typ wykresu, taki, w którym wymiary x0, x1, y0 i y1 nie miałyby sensu, znaczna część kodu musiała zostać napisana całkowicie od nowa – kod, który zajmuje się legendami, filtrami, podpowiedziami, tytułami , obliczenia podsumowujące oraz tworzenie i renderowanie wykresów.

Nikt nie chce powiedzieć swojemu klientowi: „Wiem, że to tylko mój pierwszy dzień tutaj, ale zanim zaimplementuję to, o co prosiłeś, czy mogę dokonać refaktoryzacji całego kodu za pomocą biblioteki do manipulacji danymi JavaScript, którą sam napisałem?” Dzięki wielkiemu szczęściu zostałem uratowany od tego zakłopotania, gdy zostałem przedstawiony programiście klienckiemu, który i tak był bliski refaktoryzacji kodu. Z niezwykłą otwartością i gracją klient zaprosił mnie do procesu refaktoryzacji poprzez serię sesji programowania w parach. Chciał wypróbować Supergroup.js i w ciągu kilku minut zaczęliśmy zastępować duże fragmenty gnarly kodu zwięzłymi, małymi telefonami do Supergroup.

To, co widzieliśmy w kodzie, było typowe dla plątaniny, która pojawia się w przypadku hierarchicznych lub pogrupowanych struktur danych, szczególnie w aplikacjach D3, gdy stają się one większe niż wersje demonstracyjne. Problemy te pojawiają się ogólnie w aplikacjach raportowania, w aplikacjach CRUD, które obejmują filtrowanie lub drążenie do określonych ekranów lub rekordów, w narzędziach analitycznych, narzędziach do wizualizacji, praktycznie w każdej aplikacji, w której wykorzystywana jest wystarczająca ilość danych, aby wymagać bazy danych.

Manipulacja w pamięci

Weźmy na przykład interfejs API Rest do wyszukiwania aspektowego i operacji CRUD, na przykład można uzyskać jedno lub więcej wywołań interfejsu API w celu uzyskania zestawu pól i wartości (może z liczbą rekordów) dla wszystkich parametrów wyszukiwania, inne wywołanie interfejsu API w celu uzyskania konkretny rekord i inne wezwania do zebrania grup rekordów do raportowania czy coś takiego. Wtedy wszystko to prawdopodobnie będzie skomplikowane z powodu konieczności narzucenia tymczasowych filtrów opartych na wyborze użytkownika lub uprawnieniach.

Jeśli Twoja baza danych prawdopodobnie nie przekroczy dziesiątek lub setek tysięcy rekordów lub jeśli masz łatwe sposoby na ograniczenie bezpośredniego zainteresowania do zbioru danych o takim rozmiarze, prawdopodobnie możesz wyrzucić cały skomplikowany interfejs Rest API (z wyjątkiem części dotyczącej uprawnień ) i masz jeden telefon, który mówi „zdobądź wszystkie rekordy”. Żyjemy w świecie z szybką kompresją, szybkimi transferami, dużą ilością pamięci w interfejsie użytkownika i szybkimi silnikami JavaScript. Tworzenie złożonych schematów zapytań, które muszą być rozumiane i obsługiwane przez klienta i serwer, jest często niepotrzebne. Ludzie napisali biblioteki do uruchamiania zapytań SQL bezpośrednio w zbiorach rekordów JSON, ponieważ w większości przypadków nie potrzebujesz całej optymalizacji RDBMS. Ale nawet to jest przesadą. Istnieje ryzyko, że Supergroup zabrzmi niesamowicie imponująco, przez większość czasu jest łatwiejsza w użyciu i potężniejsza niż SQL.

Supergrupa to w zasadzie d3.nest, underscore.groupBy lub underscore.nest na sterydach. Pod maską używa groupBy Lodash do operacji grupowania. Główną strategią jest przekształcenie każdego fragmentu oryginalnych danych w metadane i natychmiastowe połączenie z resztą drzewa w każdym węźle; a każdy węzeł lub lista węzłów jest przeładowana weselnym ciastem z cukru składniowego, więc prawie wszystko, co chcesz wiedzieć z dowolnego miejsca na drzewie, jest dostępne w krótkim wyrażeniu.

Supergrupa w akcji

Aby zademonstrować trochę syntaktycznej słodyczy Supergroup, ukradłem kopię Mister Nestera Shan Cartera. Proste dwupoziomowe zagnieżdżanie przy użyciu d3.nest wygląda następująco:

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

Odpowiednikiem Supergrupy byłoby:

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

Końcowe wywołanie d3NestMap() po prostu umieszcza wyjście Supergrupy w tym samym (ale moim zdaniem niezbyt użytecznym) formacie, co nest.map() d3:

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

Mówię „nie bardzo przydatne”, ponieważ selekcje D3 muszą być powiązane z tablicami, a nie mapami. Czym jest „węzeł” w tej strukturze danych mapy? „1970” lub „6001” to tylko ciągi i klucze do mapy najwyższego lub drugiego poziomu. Tak więc węzeł byłby tym, na co wskazują klucze. „1970” wskazuje na mapę drugiego poziomu, „6001” wskazuje na szereg nieprzetworzonych rekordów. To zagnieżdżanie map jest czytelne w konsoli i w porządku do wyszukiwania wartości, ale do wywołań D3 potrzebujesz danych tablicowych, więc używasz nest.entries() zamiast 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" } ] }, ... ] }, ... ]

Teraz mamy zagnieżdżone tablice par klucz/wartość: węzeł 1970 ma klucz „1970” i wartość składającą się z tablicy par klucz/wartość drugiego poziomu. 6001 to kolejna para klucz/wartość. Jego klucz jest również identyfikującym go łańcuchem, ale wartością jest tablica nieprzetworzonych rekordów. Musimy traktować te węzły od drugiego do liścia, a także węzły na poziomie liścia inaczej niż węzły znajdujące się wyżej w drzewie. A same węzły nie zawierają żadnych dowodów na to, że „1970” to rok, a „6001” to kod fips, lub że 1970 jest rodzicem tego konkretnego węzła 6001. Pokażę, jak Supergroup rozwiązuje te problemy, ale najpierw przyjrzyjmy się natychmiastowej wartości zwracanej przez wywołanie Supergroup. Na pierwszy rzut oka to tylko tablica „kluczy” najwyższego poziomu:

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

„Ok, to miłe”, mówisz. „Ale gdzie są pozostałe dane?” Łańcuchy lub liczby na liście Supergrupy są w rzeczywistości obiektami typu String lub Number, przeciążonymi większą liczbą właściwości i metod. W przypadku węzłów powyżej poziomu liścia istnieje właściwość children („children” to nazwa domyślna, można ją nazwać inaczej) zawierająca kolejną listę supergrup węzłów drugiego poziomu:

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

Funkcja podpowiedzi, która działa

Aby zademonstrować inne funkcje i jak to wszystko działa, stwórzmy prostą zagnieżdżoną listę za pomocą D3 i zobaczmy, jak tworzymy użyteczną funkcję podpowiedzi, która może działać na dowolnym węźle na liście.

 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'); ... };

Ta funkcja podpowiedzi będzie działać dla prawie każdego węzła na dowolnej głębokości. Ponieważ węzły na najwyższym poziomie nie mają rodziców, możemy to zrobić, aby to obejść:

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

Teraz mamy węzeł główny, który jest rodzicem wszystkich węzłów Year. Nie musimy nic z tym robić, ale teraz nasza podpowiedź będzie działać, ponieważ node.parent ma na co wskazywać. No i node.path()[0], który miał wskazywać na węzeł, który reprezentuje cały zestaw danych, faktycznie to robi.

W przypadku, gdy nie było to oczywiste z powyższych przykładów, namePath, dimPath i path podają ścieżkę od korzenia do bieżącego węzła:

 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

Agreguj na miejscu, kiedy trzeba

Powyższy kod podpowiedzi również używał metody „agregacji”. „agregat” jest wywoływany na pojedynczym węźle i przyjmuje dwa parametry:

  1. Funkcja agregująca, która oczekuje tablicy (zwykle liczb).
  2. Albo nazwa pola, które ma być wydzielone z rekordów zgrupowanych w tym węźle, albo funkcja, która ma być zastosowana do każdego z tych rekordów.

Istnieje również wygodna metoda „agregatów” na listach (lista grup najwyższego poziomu lub grupy podrzędne dowolnego węzła). Może zwrócić listę lub mapę.

 _.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}

Tablice, które działają jak mapy

W d3.nest zwykle używamy .entries() zamiast .map(), jak powiedziałem wcześniej, ponieważ „mapy” nie pozwalają na użycie całej funkcjonalności D3 (lub podkreślenia), która zależy od tablic. Ale kiedy używasz .entries() do generowania tablic, nie możesz wykonać prostego wyszukiwania według wartości klucza. Oczywiście Supergroup dostarcza cukier składniowy, którego potrzebujesz, więc nie musisz przedzierać się przez całą tablicę za każdym razem, gdy potrzebujesz pojedynczej wartości:

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

Porównywanie węzłów w czasie

Metoda .previous() na węzłach umożliwia dostęp do poprzedniego węzła na liście supergrupy. Możesz użyć .sort( ) lub .sortuj według( ) na liście supergrupy (zawierającej listę dzieci danego węzła), aby upewnić się, że węzły są we właściwej kolejności przed wywołaniem .previous(). Oto kod do raportowania zmiany populacji z roku na rok według regionu 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)" }, ... }

Dane tabelaryczne do układów hierarchii D3.js

Supergrupa robi dużo więcej niż to, co do tej pory tutaj pokazałem. W przypadku wizualizacji D3 opartych na d3.layout.hierarchy przykładowy kod w galerii D3 zazwyczaj zaczyna się od danych w formacie drzewa (na przykład ten przykład mapy drzewa). Supergrupa umożliwia łatwe przygotowanie danych tabelarycznych do wizualizacji d3.layout.hierarchy (przykład). Wszystko czego potrzebujesz to węzeł główny zwrócony przez .asRootVal(), a następnie uruchomienie root.addRecordsAsChildrenToLeafNodes(). d3.layout.hierarchy oczekuje, że najniższy poziom węzłów podrzędnych będzie tablicą nieprzetworzonych rekordów. addRecordsAsChildrenToLeafNodes pobiera węzły liści drzewa Supergrupy i kopiuje tablicę .records do właściwości .children. To nie jest sposób, w jaki Supergrupa zwykle lubi, ale będzie działać dobrze w przypadku map drzew, klastrów, partycji itp. (dokumentacja d3.layout.hierarchy).

Podobnie jak metoda d3.layout.hierarchy.nodes, która zwraca wszystkie węzły w drzewie jako pojedynczą tablicę, Supergroup udostępnia .descendants(), aby wszystkie węzły zaczynały się od określonego węzła, .flattenTree(), aby wszystkie węzły zaczynały ze zwykłej listy Supergrup i .leafNodes(), aby uzyskać tylko tablicę węzłów liści.

Grupowanie i agregowanie według pól wielowartościowych

Nie wdając się w wyczerpujące szczegóły, wspomnę, że Supergrupa ma pewne funkcje do obsługi sytuacji, które występują rzadziej, ale na tyle często, że zasługują na specjalne traktowanie.

Czasami chcesz pogrupować według pola, które może mieć więcej niż jedną wartość. W relacyjnych lub tabelarycznych polach wielowartościowych generalnie nie powinny występować (przełamują pierwszą normalną formę), ale mogą być przydatne. Oto jak Supergroup radzi sobie z takim przypadkiem:

 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}

Jak widać, w przypadku multiValuedGroup suma wszystkich opublikowanych artykułów na liście grup jest wyższa niż rzeczywista łączna liczba opublikowanych artykułów, ponieważ rekord Zygfryda jest liczony dwukrotnie. Czasami jest to pożądane zachowanie.

Przekształcanie tabel hierarchicznych w drzewa

Inną rzeczą, która może pojawić się od czasu do czasu, jest struktura tabelaryczna, która reprezentuje drzewo poprzez jawne relacje rodzic/dziecko między rekordami. Oto przykład małej taksonomii:

P C
zwierzę ssak
zwierzę gad
zwierzę ryba
zwierzę ptak
Zakład drzewo
Zakład trawka
drzewo dąb
drzewo klon
dąb dąb szpilkowy
ssak prymas
ssak wołowy
wołowy krowa
wołowy wół
prymas małpa
prymas małpa
małpa szympans
małpa goryl
małpa ja
 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"]

Wniosek

Więc mamy to. Używam Supergroup w każdym projekcie Javascript, nad którym pracowałem przez ostatnie trzy lata. Wiem, że rozwiązuje wiele problemów, które stale pojawiają się w programowaniu zorientowanym na dane. Interfejs API i implementacja wcale nie są doskonałe i byłbym zachwycony, mogąc znaleźć współpracowników zainteresowanych współpracą ze mną.

Po kilku dniach refaktoryzacji na tym projekcie klienta, otrzymałem wiadomość od Dave'a, programisty, z którym pracowałem:

Dave: Muszę powiedzieć, że jestem całkiem wielkim fanem supergrup. Sprząta tonę.

Zygfryd: Tak. Poproszę kiedyś o referencje :).

Dave: Hah absolutnie.

Jeśli dasz mu spin i pojawią się jakiekolwiek pytania lub problemy, upuść linię w sekcji komentarzy lub opublikuj problem w repozytorium GitHub.