本文概述
内存中的数据处理通常会导致大量的意大利面条式代码。操作本身可能很简单:分组, 聚合, 创建层次结构和执行计算;但是一旦编写了数据处理代码并将结果发送到需要它们的应用程序部分, 相关需求就会继续出现。在应用程序的另一部分可能需要对数据进行类似的转换, 或者可能需要更多详细信息:元数据, 上下文, 父项或子项数据等。在可视化或复杂的报表应用程序中, 尤其是在将数据拔尖成某种结构后, 根据需要, 人们意识到工具提示或同步的突出显示或向下钻取对转换后的数据施加了意外的压力。可以通过以下方式满足这些要求:
- 将更多的细节和更多的层次填充到转换后的数据中, 直到变得庞大而笨拙, 但满足了最终访问的应用程序的所有细节和缺点。
- 编写新的转换函数, 这些函数必须将一些已经处理的节点连接到全局数据源以引入新的细节。
- 设计复杂的对象类, 以某种方式知道如何处理它们最终所处的所有上下文。
在像我一样以数据为中心构建了20或30年之后, 人们开始怀疑它们正在一次又一次地解决相同的问题。我们引入了复杂的循环, 列表推导, 数据库分析功能, map或groupBy函数, 甚至是成熟的报告引擎。随着技能的发展, 我们变得更擅长使任何简单的数据处理代码变得简洁明了, 但意大利面条似乎仍在扩散。
在本文中, 我们将研究JavaScript库Supergroup.js(配备了一些强大的内存中数据收集操作, 分组和聚合功能)以及它如何帮助你解决有限数据集上的一些常见操作难题。
问题
在我第一次接触srcmini的过程中, 从第一天起, 我就确信我要添加的代码库的API和数据管理例程的指定过于严格。这是一个用于分析营销数据的D3.js应用程序。该应用程序已经具有吸引人的分组/堆叠条形图可视化, 并且需要构建一个Choropleth地图可视化。条形图允许用户显示2、3或4个任意内部尺寸, 分别称为x0, x1, y0和y1, 其中x1和y1是可选的。
在图例, 过滤器, 工具提示, 标题以及总计或年度差异的计算中, 在整个代码中均引用x0, x1, y0和y1, 并且在整个代码中普遍使用要处理的条件逻辑是否存在可选尺寸。
不过可能会更糟。该代码可能直接引用了特定的基础数据维度(例如, 年份, 预算, 层级, 产品类别等)。相反, 它至少已推广到此分组/堆叠条形图的显示维度。但是, 当另一种图表类型成为必需项(x0, x1, y0和y1的尺寸没有意义)时, 必须完全重写大部分代码-处理图例, 过滤器, 工具提示, 标题的代码, 摘要计算以及图表构建和渲染。
没有人想告诉他们的客户:”我知道这只是我的第一天, 但是在实现你要求的功能之前, 我可以使用自己编写的Javascript数据操作库来重构所有代码吗?”幸运的是, 当我被介绍给客户端程序员时, 我免于陷入这种尴尬。客户以异常开放的态度和宽容的态度, 通过一系列的配对编程会议邀请我进入重构过程。他愿意尝试一下Supergroup.js, 在几分钟之内, 我们开始用对Supergroup的小小的电话替换大量的粗糙代码。
我们在代码中看到的是处理分层或分组数据结构时出现的纠结的典型现象, 尤其是在D3应用程序变得比演示更大时, 尤其如此。这些问题通常出现在报表应用程序中, 在涉及筛选或钻探特定屏幕或记录的CRUD应用程序中, 在分析工具, 可视化工具中, 实际上是任何使用足够数据来需要数据库的应用程序中。
内存操纵
以Rest API进行多面搜索和CRUD操作为例, 你可能最终会获得一个或多个API调用, 以获取所有搜索参数的一组字段和值(可能带有记录计数), 而另一个API调用则获得一个特定记录, 以及其他要求获取记录组以进行报告或其他操作的调用。然后, 所有这些都可能由于需要基于用户选择或权限而强加临时过滤器而变得复杂。
如果你的数据库不太可能超过成千上万的记录, 或者你有简单的方法将感兴趣的直接范围限制为该大小的数据集, 则可能会丢掉整个复杂的Rest API(权限部分除外) ), 并打一个电话说”给我所有记录”。我们生活在一个拥有快速压缩, 快速传输速度, 前端大量内存以及快速Javascript引擎的世界中。通常不需要建立客户端和服务器必须理解和维护的复杂查询方案。人们已经编写了可以直接在JSON记录集合上运行SQL查询的库, 因为很多时候你不需要对RDBMS进行所有优化。但这甚至是过大的。冒着听起来很夸张的风险, Supergroup在大多数情况下比SQL更易于使用和更强大。
在类固醇上, 超级组基本上是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()的尾随调用只是将超级组的输出置于与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 ]
“好的, 很好。”你说。 “但是其余的数据在哪里?” “超级组”列表中的字符串或数字实际上是String或Number对象, 并带有更多的属性和方法。对于叶级以上的节点, 有一个childs属性(默认名称为” children”, 你可以使用其他名称), 其中包含另一个第二级节点的超组列表:
_.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
需要时就地汇总
上面的工具提示代码也使用了”聚合”方法。在单个节点上调用”聚合”, 它具有两个参数:
- 一个期望一个数组(通常是数字)的聚合函数。
- 从该节点下分组的记录中抽取字段的字段名称, 或者对每个记录应用一个函数。
列表(组的顶级列表或任何节点的子组)上还有一种”聚合”便捷方法。它可以返回列表或地图。
_.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(或Underscore)功能。但是, 当你使用.entries()生成数组时, 就无法通过键值进行简单的查找。当然, Supergroup提供了所需的语法糖, 因此你不必每次都想要一个值时就遍历整个数组:
_.supergroup(data, ['year', 'fips']).lookup(1980); // ==> 1980
_.supergroup(data, ['year', 'fips']).lookup([1980, 6011]).namePath(); // ==> "1980/6011"
跨时间比较节点
节点上的.previous()方法使你可以访问”超级组”列表中的上一个节点。你可以在超级组列表(包括任何给定节点的子级列表)上使用.sort()或.sortBy(), 以确保在调用.previous()之前, 这些节点的顺序正确。以下是一些代码, 用于报告按不同地区划分的人口的逐年变化:
_.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获取超组树的叶节点, 并将.records数组复制到.children属性。这不是Supergroup通常喜欢的方式, 但是它对于Treemaps, Cluster, Partitions等(d3.layout.hierarchy文档)可以正常工作。
就像d3.layout.hierarchy.nodes方法将树中的所有节点作为单个数组返回一样, Supergroup提供.descendants()来获取从某个特定节点开始的所有节点, 而superflat提供了.flattenTree()来获取所有节点的开始从常规的超级组列表和.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 |
---|---|
动物 | 哺乳动物 |
动物 | 爬虫 |
动物 | 鱼 |
动物 | 鸟 |
厂 | 树 |
厂 | 草 |
树 | 橡木 |
树 | 枫 |
橡木 | 橡木别针 |
哺乳动物 | 灵长类动物 |
哺乳动物 | 牛的 |
牛的 | 牛 |
牛的 | ox |
灵长类动物 | 猴 |
灵长类动物 | 猿 |
猿 | 黑猩猩 |
猿 | 大猩猩 |
猿 | 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"]
总结
因此, 我们有它。在过去的三年中, 我一直在每个Javascript项目中使用Supergroup。我知道它解决了许多以数据为中心的编程中不断出现的问题。 API和实现都不是完美的, 我很高兴找到对我感兴趣的合作者。
在对该客户项目进行了几天的重构之后, 我从与之合作的程序员戴夫那里得到了一条消息:
戴夫:我必须说我是超级群体的忠实粉丝。正在清理一吨。西格弗里德:是的。我会在某个时候要求提供证明:)。戴夫:绝对是。
如果你尝试一下, 但出现任何问题, 请在评论部分添加一行, 或在GitHub存储库上发布问题。
评论前必须登录!
注册