Максимальное управление сбором данных в памяти с помощью Supergroup.js
Опубликовано: 2022-03-11Манипуляции с данными в памяти часто приводят к куче спагетти-кода. Сама манипуляция может быть достаточно простой: группировка, агрегирование, создание иерархий и выполнение вычислений; но как только код обработки данных написан и результаты отправлены в ту часть приложения, где они необходимы, соответствующие потребности продолжают возникать. Аналогичное преобразование данных может потребоваться в другой части приложения или может потребоваться дополнительная информация: метаданные, контекст, родительские или дочерние данные и т. д. при необходимости можно понять, что всплывающие подсказки, синхронизированные выделения или детализация оказывают неожиданное давление на преобразованные данные. Эти требования можно удовлетворить следующим образом:
- Добавляя больше деталей и уровней в преобразованные данные, пока они не станут огромными и неуклюжими, но удовлетворят потребности всех закоулков приложения, которое оно в конечном итоге посещает.
- Написание новых функций преобразования, которые должны присоединить уже обработанный узел к глобальному источнику данных, чтобы получить новые детали.
- Разработка сложных классов объектов, которые каким-то образом знают, как обращаться со всеми контекстами, в которых они оказываются...
После создания ПО, ориентированного на данные, в течение 20 или 30 лет, как я, начинаешь подозревать, что они снова и снова решают один и тот же набор проблем. Мы вводим сложные циклы, списки, аналитические функции базы данных, функции map или groupBy или даже полноценные механизмы отчетности. По мере развития наших навыков мы становимся лучше в умении и лаконичности любого фрагмента кода обработки данных, но спагетти, похоже, все еще множатся.
В этой статье мы рассмотрим библиотеку JavaScript Supergroup.js, оснащенную некоторыми мощными функциями обработки сбора данных в памяти, группировки и агрегирования, и то, как она может помочь вам решить некоторые распространенные проблемы манипулирования ограниченными наборами данных.
Эта проблема
Во время моего первого взаимодействия с Toptal я с самого первого дня был убежден, что API и подпрограммы управления данными кодовой базы, к которой я добавлял, были безнадежно завышены. Это было приложение D3.js для анализа маркетинговых данных. Приложение уже имело привлекательную визуализацию гистограммы с группировкой/стеком и требовало создания визуализации картограммы. Гистограмма позволяла пользователю отображать 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. Создание сложных схем запросов, которые должны понимать и поддерживать клиент и сервер, часто не требуется. Люди написали библиотеки для выполнения SQL-запросов непосредственно к коллекциям записей JSON, потому что большую часть времени вам не нужна вся оптимизация СУБД. Но даже это перебор. Рискуя показаться безумно грандиозным, Supergroup в большинстве случаев проще в использовании и мощнее, чем SQL.
Супергруппа — это в основном d3.nest, underscore.groupBy или underscore.nest на стероидах. Под капотом он использует groupBy lodash для операции группировки. Основная стратегия состоит в том, чтобы превратить каждую часть исходных данных в метаданные и ссылки на остальную часть дерева, немедленно доступные на каждом узле; и каждый узел или список узлов перегружен свадебным пирогом синтаксического сахара, так что почти все, что вы хотите узнать из любого места на дереве, доступно в коротком выражении.
Супергруппа в действии
Чтобы продемонстрировать некоторую синтаксическую прелесть Supergroup, я украл копию 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 ]«Хорошо, это мило», — скажете вы. — А где остальные данные? Строки или числа в списке Supergroup на самом деле являются объектами String или Number, перегруженными дополнительными свойствами и методами. Для узлов выше листового уровня есть свойство 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Агрегируйте на месте, когда вам нужно
В приведенном выше коде всплывающей подсказки также использовался метод «агрегат». «aggregate» вызывается для одного узла и принимает два параметра:
- Агрегирующая функция, которая ожидает массив (обычно из чисел).
- Либо имя поля, которое должно быть извлечено из записей, сгруппированных под этим узлом, либо функция, которая будет применяться к каждой из этих записей.
Существует также удобный метод «агрегирования» для списков (список групп верхнего уровня или дочерние группы любого узла). Он может возвращать список или карту.
_.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(
_.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, основанных на d3.layout.hierarchy, пример кода в галерее D3 обычно начинается с данных в формате дерева (например, этот пример Treemap). Supergroup позволяет легко подготовить табличные данные для визуализации d3.layout.hierarchy (пример). Все, что вам нужно, это корневой узел, возвращаемый .asRootVal(), а затем запустить root.addRecordsAsChildrenToLeafNodes(). d3.layout.hierarchy ожидает, что нижний уровень дочерних узлов будет массивом необработанных записей. addRecordsAsChildrenToLeafNodes берет конечные узлы дерева супергруппы и копирует массив .records в свойство .children. Это не то, как Supergroup обычно любит вещи, но это будет хорошо работать для древовидных карт, кластеров, разделов и т. Д. (Документация d3.layout.hierarchy).
Подобно методу d3.layout.hierarchy.nodes, который возвращает все узлы дерева в виде единого массива, Supergroup предоставляет .descendants() для получения всех узлов, начиная с определенного узла, .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 подсчитывается дважды. Иногда это желаемое поведение.
Превращение иерархических таблиц в деревья
Еще одна вещь, которая может иногда встречаться, — это табличная структура, которая представляет дерево через явные родительские/дочерние отношения между записями. Вот пример крошечной таксономии:
| п | с |
|---|---|
| животное | млекопитающее |
| животное | рептилия |
| животное | рыба |
| животное | птица |
| растение | дерево |
| растение | трава |
| дерево | дуб |
| дерево | клен |
| дуб | штыревой дуб |
| млекопитающее | примат |
| млекопитающее | бычий |
| бычий | корова |
| бычий | бык |
| примат | обезьяна |
| примат | обезьяна |
| обезьяна | шимпанзе |
| обезьяна | горилла |
| обезьяна | меня |
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"]Заключение
Так что у нас это. Я использую Supergroup в каждом проекте Javascript, над которым работал последние три года. Я знаю, что это решает множество проблем, которые постоянно возникают при программировании, ориентированном на данные. API и реализация вовсе не идеальны, и я был бы рад найти соавторов, заинтересованных в совместной работе над этим.
Через пару дней рефакторинга клиентского проекта я получил сообщение от Дейва, программиста, с которым работал:
Дэйв: Должен сказать, что я большой поклонник супергрупп. Это очищает тонну.
Зигфрид: Ура. Я собираюсь попросить свидетельство в какой-то момент :).
Дэйв: Ха, абсолютно.
Если вы попробуете его и возникнут какие-либо вопросы или проблемы, оставьте строку в разделе комментариев или опубликуйте проблему в репозитории GitHub.
