使用 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 存储库上发布问题。