javascript

VAX — инструмент для визуального программирования, или как написать SQL мышкой

  • пятница, 21 июля 2017 г. в 03:12:13
https://habrahabr.ru/post/333750/
  • Программирование
  • Анализ и проектирование систем
  • SQL
  • JavaScript




Я хочу рассказать про созданный мною web редактор для «визуального программирования» и его историю создания.

Случилось как-то мне столкнуться с одной очень надоедливой задачей на работе в компании PimPay, в которой я занимаю должность технического директора. Есть у нас отдел, занимающийся претензионной деятельностью с Почтой России. Ребята ищут потерянные/не оплаченные/не доставленные отправления. В сухом остатке для разработчиков эта задача сводилась к написанию очень большого количества разнообразных и огромных (>150 строк) SQL запросов на диалекте PostgreSQL, которые ОЧЕНЬ часто менялись и дополнялись в силу активного появления новых гипотез, а также поступления новых сведений о заказах.

Естественно, нам, как программистам, это дело очень быстро надоело, и сразу захотелось предложить отстать от нас какой-то визуальный инструмент конструирования фильтров, которые в итоге транслируются в SQL. Сначала захотели налепить просто много формочек с кучей «инпутов», но идея быстро провалилась, т.к. было понятно, что все фильтры надо уметь как-то «композиционировать» (compose): объединять в группы, пересекать с разными условиями И, ИЛИ, параметризовать и прочее. И даже простейший реально используемый фильтр начинал выглядеть ужасно. Плюс сбоку закрадывалась мысль, что в принципе данные фильтры являются частным вариантом просто общего сборщика SQL, и вообще, раз уж дошло дело до «визуального программирования», то можно постараться полностью абстрагироваться от предметной области.

И тут, как в фильме «Проблеск гениальности» («Flash of Genius»), у меня перед глазами всплыла картина визуального редактора схем (blueprints) из UE4 (Unreal Engine 4), с помощью которых персонажи запускали файрболы в своих врагов:

image

Прибежав в тот же вечер домой, я взял первую попавшуюся JavaScript библиотеку, умеющую рисовать красивые прямоугольники и сложные линии — ей оказалась Raphaël от нашего соотечественника DmitryBaranovskiy. Нарисовав пару прямоугольников и подёргав их с помощью библиотечных drag-and-drop, я сразу написал автору библиотеки с вопросом поддерживает ли он её. И не дождавшись ответа (до сих пор), я в ту же ночь наплодил более 1000 строк кода на JavaScript, и моя мечта на глазах почти стала явью! Но предстояло ещё много работы.

В итоге, что же захотелось сделать:

  • Красивый и удобный редактор в вебе, который предоставляет средства для манипуляции и связывания «узлов» разных типов, которые описываются в доменной схеме, передающейся редактору при инициализации. Тем самым сделать редактор независимым от предметной области.
  • Возможность сериализации ацикличного графа пользователя в древовидную структуру, которую очень легко разбирать (parse) (например JSON) и интерпретировать на любом языке.
  • Предоставить удобный формат для описания типов и компонентов предметной области.
  • Придумать богатую систему типов и ограничений, которая поможет пользователю создавать графы, корректные с точки зрения предметной области.
  • Дать возможность пользователю создавать свои компоненты из комбинаций существующих.

В итоге вот что получилось:

image

Видно, что чертёж (blueprint) состоит из узлов (nodes), которые являются конкретными экземплярами компонентов (component), описанных в схеме предметной области (schema). Узлы соединяют проводами (wires). Провод всегда идёт от выхода (output) одного узла к входу (input) другого (и наоборот). Из одного выхода может идти много проводов, но на вход можно привязать только один.

Все входы и выходы имеют тип, и соответственно накладывают ограничения на возможные связи. Например, возьмём следующую систему типов:

types:
    # Всегда есть тип Any, который супер-родитель всех типов
    Scalar:
    Numeric:
       extends: Scalar
    String:
       extends: Scalar
    List:
       typeParams: [A]

Таким образом можно выход типа Numeric связать с входом типа Scalar, но не наоборот. Для параметризованных типов вроде List подразумевается ковариативность, т.е. List[String] можно передать в List[Scalar], но не наоборот. Плюс всегда присутствует супер тип Any, наследником которого являются все остальные типы.

У узлов также могу присутствовать атрибуты, которые не настраиваются с помощью проводов, а пользователь сам вводит в них значения. Для этого существуют концепция valuePicker-ов, с помощью которых можно задавать свой интерфейс ввода значений атрибутов. Из коробки есть просто текстовый инпут и возможность выбора из заранее определённого набора констант.

Также узлы бывают параметризованы по типам. Например, дан компонент:

IfThenElse:
  typeParams: [T]
  in:
    C: Boolean  
  out:
    onTrue: @T
    onFalse: @T

При создании узла на базе компонента IfThenElse редактор попросит нас указать тип T и подставит его во все места с T:

image

Типы входов и выходов также помогают пользователю при проектировании. Если вы потянете проводок из выхода с типом Numeric и отпустите мышку, то вылезет окно создания компонентов, отфильтрованных таким образом, что там останутся только те, вход которых совместим (conforms) с типом Numeric. И даже автоматически привяжется проводок.

Заняло всё это примерно чистых тройку человеко-недель, растянутых на добрых 5-6 месяцев. И ещё спустя полгода появились силы что-то задокументировать и заявить об этом миру.

Итак, господа, настало время творить! Возьмём самый нереальный случай, когда вам надо предоставить «не техническому» пользователю возможность визуально программировать процесс складывания чисел. Мы пониманием, что нам нужен всего лишь один тип Numeric и пару компонентов: возможность задать число (Literal) и возможность сложить два таких числа (Plus). Далее приведён пример схемы данной предметной области: (все примеры схем описаны в формате YAML для наглядности, в реальности же вам надо будет передавать нативные javascript объекты):

types:
    # Всегда есть тип Any, который супер-родитель всех типов
    Numeric:
        color: "#fff"
      
components:
  Literal: # Название компонента
    attrs: # Атрибуты
      V: Numeric 
    out: # Исходящие сокеты
      O: Numeric

  Plus:
    in: # Входящие сокеты
      A: Numeric
      B: Numeric
    out:
      O: Numeric

Пример собранного редактора с данной схемой и простым графом можно посмотреть тут.

Поиграйтесь! Нажмите X для создания нового элемента, удалите элемент двойным кликом. Соедините узлы проводками, выделите их все и скопируйте и вставьте через Ctrl+C и Ctrl+V. Потом выделите все Ctrl+A и удалите с помощью Delete. Ведь всегда можно сделать Undo, прибегнув к Ctrl+Z!

Теперь допустим, наш нехитрый пользователь собрал следующий граф:

image

Если мы попросим редактор сохранить наш граф в дерево, то получим:

[
  {
    "id": 8,
    "c": "Plus",
    "links": {
      "A": {
        "id": 2,
        "c": "Literal",
        "a": {
          "V": "2"
        },
        "links": {},
        "out": "O"
      },
      "B": {
        "id": 5,
        "c": "Literal",
        "a": {
          "V": "2"
        },
        "links": {},
        "out": "O"
      }
    }
  }
]

Как видим нам тут пришло дерево, которое очень легко рекурсивно обойти и получить какой-то результат. Допустим, языком нашего бэкэнда является тоже JavaScript (хотя может быть любой).

Пишем тривиальный код:

function walk(node) {
    switch (node.c) {
        case 'Literal':
            return parseFloat(node.a.V);

        case 'Plus':
            return walk(node.links.A) + walk(node.links.B);

        default:
            throw new Error("Unsupported node component: " + node.component);
    }
}

walk(tree);

Если мы прогуляемся такой функцией по вышеуказанному дереву, то получим 2+2=4. Вуаля!

Очень приятным бонусом является возможность у пользователя определять свои «функции», объединяя существующие компоненты.

Например, даже имея такую скудную доменную область, где можно просто складывать числа, пользователь может определить свой компонент, который будет умножать заданное число на три:



Теперь у нас появилась пользовательская функция x3:



Которой можно воспользоваться, как новым компонентом:



При этом на backend можно послать дерево, где все пользовательские функции развёрнуты (inlined), и разработчик даже не будет знать, что некоторые узлы были в составе пользовательской функции. Получается, что пользователи сами могу обогащать свой язык проектирования при должных фантазии и упорстве.

Вернёмся же к нашему первому примитивному примеру складывания чисел. Не смотря на то, что процессор это интегральное устройство, которое только и делает, что складывает — визуально программировать конечному пользователю на уровне сложения чисел будет не очень весело. Нужны более экспрессивные и богатые конструкции!

Возьмём к примеру замечательный язык SQL. Если присмотреться, то любой SQL запрос на самом деле очень легко раскладывается в дерево (этим и занимается первым делом БД, когда получает ваш запрос). Понаписав достаточное количество типов и компонентов можно получить нечто уже более устрашающее:

image

P.S. Если какой-то из примеров не открывается...
Возможно это связано с тем, что вы уже попытались сохранить пользовательскую функцию для одной из схем. А так как по умолчанию (но можно и нужно определять свои обработчики) все пользовательские функции хранятся в localStorage, то может возникнуть ситуация, когда редактор попытается загрузить компоненты или типы, не описанные в текущей схеме.
Для этого просто очистите текущий localStorage с помощью:

localStorage.clear()

К сожалению, демонстрацию превращения данного графа в реальный SQL провести не получится, т.к. она сильно завязана на наш проект. Но в реальности это собирается в следующий SQL:

SELECT 
     COUNT(o.id) AS cnt
   , (o.created_at)::DATE AS "Дата" 
FROM tbl_order AS o 
WHERE o.created_at BETWEEN '2017-1-1'::DATE AND CURRENT_DATE 
GROUP BY (o.created_at)::DATE 
HAVING ( COUNT(o.id) ) > ( 100 ) 
ORDER BY (o.created_at)::DATE ASC

Который сразу же исполняется и отдаёт готовый отчёт в формате Excel. Переведя данный SQL на человечий, получаем:

Показать количество загруженных заказов за каждую день в хронологическом порядке, начинания с 1 января 2017 года, выкидывания дни, где загрузили меньше чем 100 заказов. Вполне себе реальный отчёт для бизнеса!

Схему в формате JSON для данного примера можно посмотреть тут.

Я не буду в статье приводить полное описание системы типов и компонентов, за этим отправляю в соответствующий раздел документации. Но для подогрева интереса лишь немного «вброшу», что можно писать вот так (немного косячит подсветка вычурного синтаксиса YAML):

Plus:
        typeParams: [T]
        typeBounds: {T: {<: Expr}} # Параметризованный тип 'T' ограничен сверху типом 'Expr',
                                   # что означает, что нам надо сюда передать наследник типа 'Expr'
        in:
            A: @T
            B: @T
        out:
            O: @T

Это как если бы вы в Scala объявили функцию:

def Plus[T <: Expr](A: T, B: T): T = A + B

В итоге, подготовив достаточное количество компонентов, и придумав хорошую систему типов (и написав много немного backend кода для обхода деревьев) мы сбагрили дали возможность пользователю составлять свои ad-hoc отчёты на базе SQL. Немного расширив доменную область, мы сильно упростили исходную задачу с фильтрами для поиска проблемных заказов, описанную в начале статьи. А самое главное дали бизнесу возможность самостоятельно тестировать свои гипотезы без привлечения разработчиков. Теперь правда, приходится обучать и писать новые компоненты, но это куда более приятное занятие! Надеюсь, что на SQL и фильтрах дело не остановится, и мы воспользуемся этим инструментом в другим областях проекта.

Самое главное, что я с радостью передаю этот инструмент в общественное достояние на GitHub под лицензией MIT.

Кому интересно, есть идеи/задачи по дальнейшему развитию инструмента:
  • Более удобная навигация по компонентам в схеме + их документация для пользователя
  • Сокеты-атрибуты (как в UE4)
  • Возможность определять атрибуты в пользовательских функциях.
  • Режим read-only для отображения схем
  • Узлы кастомной формы (как в UE4), а не только прямоугольники
  • Ускорение и оптимизация для работы с большим количеством элементов
  • Интернационализация
  • Экспорт картинки в SVG/PNG
  • You name it!

Будет круто, если кому-то захочется воспользоваться этим инструментом на практике и поделиться своим опытом.

P.S. Ещё круче будет, если кто-то присоединится к разработке инструмента!

P.P.S. Я проводил презентацию инструмента в компании с помощью данного документа.