使用 Supergroup.js 進行終極內存數據收集操作

已發表: 2022-03-11

內存中的數據操作通常會導致一堆意大利麵條式代碼。 操作本身可能很簡單:分組、聚合、創建層次結構和執行計算; 但是一旦編寫了數據處理代碼並將結果發送到需要它們的應用程序部分,相關的需求就會繼續出現。 應用程序的另一部分可能需要對數據進行類似的轉換,或者可能需要更多詳細信息:元數據、上下文、父或子數據等。特別是在可視化或複雜的報告應用程序中,在將數據硬塞進某種結構之後鑑於需要,人們會意識到工具提示或同步突出顯示或向下鑽取會對轉換後的數據造成意想不到的壓力。 可以通過以下方式解決這些要求:

  1. 將更多細節和更多級別填充到轉換後的數據中,直到它變得龐大且笨拙但滿足它最終訪問的應用程序的所有角落和縫隙的需求。
  2. 編寫新的轉換函數,這些函數必須將一些已處理的節點加入到全局數據源中以引入新的細節。
  3. 設計複雜的對像類,以某種方式知道如何處理它們最終所處的所有上下文。

在像我一樣構建以數據為中心的軟件 20 或 30 年後,人們開始懷疑他們正在一遍又一遍地解決同一組問題。 我們引入了複雜的循環、列表解析、數據庫分析函數、map 或 groupBy 函數,甚至是成熟的報告引擎。 隨著我們技能的發展,我們可以更好地使任何數據處理代碼變得聰明而簡潔,但意大利麵條似乎仍在激增。

在本文中,我們將了解 JavaScript 庫 Supergroup.js - 配備了一些強大的內存數據收集操作、分組和聚合功能 - 以及它如何幫助您解決有限數據集上的一些常見操作挑戰。

問題

在我第一次參與 Toptal 時,我從第一天起就確信我要添加的代碼庫的 API 和數據管理例程被過度指定了。 這是一個用於分析營銷數據的 D3.js 應用程序。 該應用程序已經有一個有吸引力的分組/堆疊條形圖可視化,並且需要構建一個等值線地圖可視化。 條形圖允許用戶顯示 2、3 或 4 個內部稱為 x0、x1、y0 和 y1 的任意維度,其中 x1 和 y1 是可選的。

Supergroup.js - Toptal

在圖例、過濾器、工具提示、標題的構建以及總計或逐年差異的計算中,x0、x1、y0 和 y1 在整個代碼中被引用,並且在整個代碼中無處不在的是要處理的條件邏輯是否存在可選尺寸。

不過情況可能會更糟。 代碼可能直接引用了特定的基礎數據維度(例如,年份、預算、層級、產品類別等)。相反,它至少被推廣到此分組/堆疊條形圖的顯示維度。 但是當需要另一種圖表類型時,其中 x0、x1、y0 和 y1 的維度沒有意義,必須完全重寫大部分代碼 - 處理圖例、過濾器、工具提示、標題的代碼、匯總計算以及圖表構建和渲染。

沒有人願意告訴他們的客戶,“我知道這只是我在這裡的第一天,但​​在我實現你要求的東西之前,我可以使用我自己編寫的 Javascript 數據操作庫重構所有代碼嗎?” 幸運的是,當我被介紹給一位正處於重構代碼邊緣的客戶程序員時,我從這種尷尬中解脫了出來。 客戶以不同尋常的開放和優雅,通過一系列結對編程會議邀請我參與重構過程。 他願意嘗試 Supergroup.js,幾分鐘之內,我們就開始用對 Supergroup 的精練的小調用來替換大量粗糙的代碼。

我們在代碼中看到的是處理分層或分組數據結構時出現的典型纏結,特別是在 D3 應用程序中,一旦它們變得比演示更大。 這些問題通常出現在報告應用程序中,在涉及過濾或鑽取到特定屏幕或記錄的 CRUD 應用程序中,在分析工具、可視化工具中,實際上任何使用足夠數據來需要數據庫的應用程序中。

內存操作

以一個用於分面搜索和 CRUD 操作的 Rest API 為例,您最終可能會使用一個或多個 API 調用來獲取所有搜索參數的一組字段和值(可能帶有記錄計數),另一個 API 調用來獲取特定記錄,以及其他要求獲取記錄組以進行報告或其他內容的調用。 那麼所有這些都可能因為需要根據用戶選擇或權限強加臨時過濾器而變得複雜。

如果您的數據庫不太可能超過數万或數十萬條記錄,或者如果您有簡單的方法將感興趣的直接領域限制為該大小的數據集,您可能會拋棄整個複雜的 Rest API(權限部分除外) ),然後打一個電話說“把所有的記錄都給我”。 我們生活在一個壓縮速度快、傳輸速度快、前端有大量內存和快速 Javascript 引擎的世界中。 建立必須由客戶端和服務器理解和維護的複雜查詢方案通常是不必要的。 人們已經編寫了庫來直接在 JSON 記錄的集合上運行 SQL 查詢,因為很多時候您不需要 RDBMS 的所有優化。 但即使這樣也太過分了。 冒著聽起來非常宏大的風險,Supergroup 在大多數情況下比 SQL 更易於使用且功能更強大。

Supergroup 基本上是 d3.nest、underscore.groupBy 或 underscore.nest 類固醇。 在後台,它使用 lodash 的 groupBy 進行分組操作。 中心策略是將每條原始數據都變成元數據,並在每個節點上立即訪問到樹的其餘部分的鏈接; 並且每個節點或節點列表都被句法糖的婚禮蛋糕重載,因此您想從樹上的任何地方知道的大多數內容都可以在簡短的表達式中獲得。

超群在行動

為了展示 Supergroup 的一些語法甜味,我劫持了 Shan Carter 的 Mister Nester 的副本。 使用 d3.nest 的簡單兩級嵌套如下所示:

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

Supergroup 的等價物是:

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

對 d3NestMap() 的尾隨調用只是將 Supergroup 輸出放入與 d3 的 nest.map() 相同的格式(但在我看來不是很有用):

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

我說“不是很有用”,因為 D3 選擇需要綁定到數組,而不是映射。 這個地圖數據結構中的“節點”是什麼? “1970”或“6001”只是頂級或二級映射的字符串和鍵。 因此,節點將是鍵指向的內容。 “1970”指向二級地圖,“6001”指向原始記錄數組。 此映射嵌套在控制台中是可讀的,並且可以用於查找值,但對於 D3 調用,您需要數組數據,因此您使用 nest.entries() 而不是 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" } ] }, ... ] }, ... ]

現在我們有了嵌套的鍵/值對數組:1970 節點有一個鍵“1970”和一個由二級鍵/值對數組組成的值。 6001 是另一個鍵/值對。 它的鍵也是標識它的字符串,但值是原始記錄的數組。 我們必須將這些第二到葉級節點以及葉級節點與樹上更高的節點區別對待。 並且節點本身不包含任何證據表明“1970”是一年,“6001”是 fips 代碼,或者 1970 是這個特定 6001 節點的父節點。 我將演示 Supergroup 如何解決這些問題,但首先看一下 Supergroup 調用的直接返回值。 乍一看,它只是一組頂級“鍵”:

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

“好吧,那很好,”你說。 “但剩下的數據在哪裡?” Supergroup 列表中的字符串或數字實際上是 String 或 Number 對象,重載了更多的屬性和方法。 對於葉級別以上的節點,有一個 children 屬性(“children”是默認名稱,您可以將其稱為其他名稱)包含另一個二級節點的 Supergroup 列表:

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

有效的工具提示功能

為了演示其他功能以及整個事情是如何工作的,讓我們使用 D3 製作一個簡單的嵌套列表,並看看我們如何製作一個可以在列表中的任何節點上工作的有用的工具提示函數。

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

此工具提示功能幾乎適用於任何深度的任何節點。 由於頂層節點沒有父節點,我們可以這樣做來解決它:

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

現在我們有一個根節點,它是所有 Year 節點的父節點。 我們不需要對它做任何事情,但現在我們的工具提示將起作用,因為 node.parent 有一些東西可以指向。 而 node.path()[0] 應該指向一個代表整個數據集的節點實際上確實如此。

如果從上面的示例中不明顯,namePath、dimPath 和 path 給出了從根到當前節點的路徑:

 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

需要時就地聚合

上面的工具提示代碼也使用了“聚合”方法。 “聚合”在單個節點上調用,它有兩個參數:

  1. 需要一個數組(通常是數字)的聚合函數。
  2. 要從分組在該節點下的記錄中提取的字段的字段名稱或要應用於每個記錄的函數。

列表(組的頂級列表或任何節點的子組)上還有一個“聚合”便利方法。 它可以返回列表或地圖。

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

像地圖一樣工作的數組

對於 d3.nest,我們傾向於使用 .entries() 而不是 .map(),正如我之前所說,因為“映射”不允許您使用依賴於數組的所有 D3(或下劃線)功能。 但是當你使用 .entries() 生成數組時,你不能通過鍵值進行簡單的查找。 當然,Supergroup 提供了您想要的語法糖,因此您不必每次需要單個值時都在整個數組中跋涉:

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

跨時間比較節點

節點上的 .previous() 方法允許您訪問超組列表中的前一個節點。 您可以使用 .sort( ) 或 .sortBy( ) 在 Supergroup 列表(包括任何給定節點的子節點列表)上,以確保在調用 .previous() 之前節點的順序正確。 以下是按 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)" }, ... }

表格數據到 D3.js 層次結構佈局

到目前為止,Supergroup 所做的比我在這裡展示的要多得多。 對於基於 d3.layout.hierarchy 的 D3 可視化,D3 庫上的示例代碼通常以樹格式的數據開頭(例如,此 Treemap 示例)。 Supergroup 讓您可以輕鬆地為 d3.layout.hierarchy 可視化準備表格數據(示例)。 您只需要.asRootVal() 返回的根節點,然後運行root.addRecordsAsChildrenToLeafNodes()。 d3.layout.hierarchy 期望子節點的底層是原始記錄的數組。 addRecordsAsChildrenToLeafNodes 獲取 Supergroup 樹的葉節點並將 .records 數組複製到 .children 屬性。 這不是 Supergroup 通常喜歡的方式,但它適用於 Treemaps、Clusters、Partitions 等(d3.layout.hierarchy 文檔)。

與將樹中的所有節點作為單個數組返回的 d3.layout.hierarchy.nodes 方法一樣,Supergroup 提供 .descendants() 來獲取從某個特定節點開始的所有節點,提供 .flattenTree() 來獲取所有節點開始從一個常規的 Supergroup 列表,和 .leafNodes() 得到一個葉節點數組。

按多值字段分組和聚合

在不詳細介紹的情況下,我將提到 Supergroup 具有一些用於處理不太常見但通常足以值得特殊處理的情況的功能。

有時,您希望按可以具有多個值的字段進行分組。 在關係或表格中,多值字段通常不應該出現(它們打破了第一範式),但它們可能很有用。 以下是 Supergroup 如何處理這種情況:

 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}

如您所見,使用 multiValuedGroup,組列表中發布的所有文章的總和高於實際發布的文章總數,因為 Sigfried 記錄被計算了兩次。 有時這是期望的行為。

將層次表變成樹

偶爾會出現的另一件事是表格結構,它通過記錄之間的顯式父/子關係表示一棵樹。 這是一個小分類的例子:

p C
動物哺乳動物
動物爬蟲
動物
動物
植物
植物
橡木
橡木針橡木
哺乳動物靈長類動物
哺乳動物
奶牛
靈長類動物
靈長類動物
黑猩猩
大猩猩
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"]

結論

因此,我們有它。 在過去三年中,我在每個 Javascript 項目中都使用了 Supergroup。 我知道它解決了以數據為中心的編程中不斷出現的許多問題。 API 和實現並不完美,我很高興找到有興趣與我合作的合作者。

在對該客戶端項目進行了幾天的重構之後,我收到了來自與我一起工作的程序員 Dave 的消息:

戴夫:我必須說我是超級團體的忠實粉絲。 它正在清理一噸。

西格弗里德:是的。 我會在某個時候要求提供推薦信:)。

戴夫:哈絕對。

如果您試一試並出現任何問題或問題,請在評論部分寫一行或在 GitHub 存儲庫上發布問題。