Визуализация на сервере: NodeJS + D3.js + PhantomJS

  • Tutorial
Node + Phantom
Возникла у нас на проекте прихоть — рисовать на стороне сервера графики, да не простые, а максимально похожие на уже имеющиеся графики на клиентской стороне.
Да-да, именно так, на клиенте уже были всевозможные красивости, реализованные на d3.js.
Для исследования возможностей был применен комплексный метод анализа «google-driven investigation» и в первой итерации выбор пал на ноду + фантом.

За подробностями прошу в глубины поста.


Скучное введение


Раскажу вкратце о проекте, чтобы обрисовать ситуацию. Наша фирма нашла BigData-стартап, команда выйграла тендер и теперь мы вчетвером пилим аналитику в облаке для тяжеловесных датасетов.
Наш зоопарк состоит из кластеров на AWS с автодеплоем, Scala, Spark, Shark, Mesos, NodeJS и прочих страшных технологий (я надеюсь, такой проект позволит мне и моим коллегам утолить интеллектуальный голод и понаписать пару статей).

Дисклеймер
Наша команда — два матерых джависта и два «полиглота» (java/scala + javascript). Мы считаем себя хорошими инженерами и используем языки как инструменты, хотя и делаем упор в джаву. Поэтому, если материал покажется «неправославным» c точки зрения подходов и практик, прошу тухлые яйца кидать в личку, а конструктивную критику — в комментарии.

У нас недельные итерации и ретроспектива + демо в конце недели. Это накладывает ряд ограничений на исследования и поиск лучших практик.
На момент реализации решения у нас уже были «цифрожевалки» на скале и рест-сервисы на ноде.

Суть


Требования


  • Графика должна быть статической
  • Графика должна быть максимально похожа на клиентские интерактивные «свистелки»
  • Графика должна генериться на сервере
  • Интерфейс взаимодействия — REST
  • Все дело должно строиться динамически по датасетам из хранилища


Почему нода и фантом?


В ходе беглого изучения проблемы было обнаружено три варианта:
  1. Использовать js-реализацию дом-дерева и Image Magic для конвертации SVG в PNG (пример был найден).
  2. Использовать джава-библиотеки для чартов в скале (или скала-аналоги) и максимально стилизовать их под d3
  3. Заиспользовать фантом в связке со скалой/нодой

Вариант №1 оставлял открытым вопрос о css-стилях и общей целесообразности (не нодовское это призвание процессор рассчетами загружать).
Вариант №2 показался разумным, но гарантирующим продолжительную боль в области седалищного нерва.
Было решено использовать Вариант №3.

Последующие изучение и эксперименты показали, что:
  • Scala с фантомом не дружит. И внешний апи никакой фантом не предоставляет.
  • Зато фантом дружит с нодой. Причем есть несколько npm-модулей, предоставляющих мост между нодой и родным апи фантома.

Какой такой мост?
Это оказалось интересным. Из-за того, что первоначально был выбран несвежий модуль для работы с фантомом, пришлось нырнуть с головой в дебаг модуля и троллинг сообщества на гитхабе на предмет поддержки самописных модулей.

Оказалось, что внешнего апи у фантома вообще нет. Даже для ноды. Но внутренний апи эмулируется через socket.io и переопределением обработчика alert'а на странице, открытой в фантоме.

Автору уважуха за находчивость!

Алгоритм примерно такой:
  1. Создается скрипт, который будет принимать socket.io сообщения внутри фантома
  2. Создается страница-заглушка с подключенным скриптом.
  3. Переопределяется слушатель alert-сообщений, которые будут содержать «ответ» страницы на socket.io сообщение
  4. На ноде поднимается express-сервер, отдающий страницу и обрабатывающий socket.io запросы.
  5. Запускается процесс фантома и ему скармливается страница-заглушка.
  6. Модуль экспортирует «отзеркаленный» апи фантома (но все методы становятся асинхронными; в фантоме они почти все синхронны)



Углубившись в вариант «фантом + нода», я выяснил, что можно заиспользовать уже имеющийся javascript-код клиента для построения графиков на стороне сервера.
Фантом — это вебкит с полноценной реализацией дом-дерева, стилей и джаваскрипта. И он позволяет делать снимки отрисованной страницы. Такое решение позволяет вообще не дублировать код построения графики!

Подводные камни
Для работы фантома он должен быть установлен в системе :)
sudo apt-get install phantomjs
или
brew install phantomjs
После этих волшебных слов мост сможет использовать модуль webpage.

Во время реализации пришлось попотеть с использованием фантома через ноду. Первый модуль оказался плоховат и кривоват (см. предыдущий спойлер), потому выбор пал на node-phantom.
Возникла давняя как мир проблема — отсутствие документации по апи.

Методом научного тыка удалось выяснить, что:
  • Фантом инжектит (page.indectJs) скрипты в страницу только по полному пути на файловой системе.
  • Фантом инклудит (page.includeJs) скрипты в страницу по полному урлу, но в модуле контракт внутреннего API page.includeJs испорчен из-за особенностей реализации.
  • Из-за положения звезд на небе фантом не парсит стили, подключенные динамически через добавление к заголовку страницы.

    Параметры, передаваемые для обработки внутрь страницы фантома, должны быть сериализованы в строку



Долгожданное решение


Я использую модуль vow vow для уменьшения «макаронности» кода. Плохо или хорошо использую — отпишите в комментариях!

// подключаем модуль для работы с фантомом (все зависимости объявлены в package.json)
var phantom = require("node-phantom")
// промисы
  , vow = require("vow")
// конфиг нашего рест-сервера
  , cfg = require("../config")
// родной модуль работы с файловой системой
  , fs = require('fs')
// глобальная ссылка на процесс фантома
  , pi;

// я создаю один процесс фантома сразу при старте приложения
exports.init = function () {
  if (pi) {
    pi.exit();
  }
  phantom.create(function (err, instance) {
    pi = instance;
  });
}

// эта функция дергается в других местах приложения - точка входа
exports.render = function (dataset, opts) {
  var promise = vow.promise();

  // для каждого графика открывается новая страница
  pi.createPage(function (err, page) {
    
    // мы можем определить размер области снимка страницы, если нужно
    page.set("viewportSize", opts.viewport);

    // полный путь к d3 на файловой системе (см. спойлер "подводные камни")
    var d3Path = __dirname + "/../client/scripts/vendor/d3.v3.js";
    // полный путь к клиентскому скрипту, строящему график на d3
    // type - это тип графика (line, bar, pie)
    // каждый файл chart.xxx.js содержит метод рисования конкретного графика
    var chartJs = __dirname + "/../client/scripts/chart." + opts.type + ".js";
    // полный путь к файлу стилей для графика
    var chartCss = __dirname + "/../client/styles/charts.css";
    var innerStyle = "";

    // наша логика
    // как вам такой код? читаем? отзывы в комментарии
    injectLib_(page, d3Path)()
      .then(injectLib_(page, chartJs))
      .then(readCssStyles_(chartCss))
      .then(drawChart_(page, {dataset: dataset, innerCss: innerStyle}, opts))
      .then(function (res) {
        // если все ок, то возвращаем путь к сохраненному графику
        promise.fulfill({filename: res.filename});
      })
      .fail(function (err) {
        promise.reject(err)
      }
    )
  });
  return promise;
}

// считываем стили из файла в буфер (строку)
// зачем так - смотрите в спойлере "подводные камни"
function readCssStyles_(chartCss) {
  return function(){
    var prom = vow.promise();
    fs.readFile(chartCss, 'utf8', function (err,innerCss) {
      if (err) {
        console.log(chartCss + ": read failed, err: " + err);
        prom.reject(chartCss + ": read failed, err: " + err);
      } else {
        console.log(chartCss + " read");
        prom.fulfill(innerCss);
      }
    });
    return prom;
  }
}

function injectLib_(page, path) {
  return function () {
    var prom = vow.promise();

    // этот вызов вставит скрипт в страницу, но не выполнит его до вызова page.evaluate
    page.injectJs(path, function (err) {
      if (err) {
        console.log(path + " injection failed")
        prom.reject(path + " injection failed");
      } else {
        console.log(path + " injected")
        prom.fulfill();
      }
    });
    return prom;
  }
}

function drawChart_(page, data, opts) {
  return function (innerCss) {
    data.innerCss = innerCss;
    var prom = vow.promise();

      // этот метод выполнит все скрипты на странице в фантоме
      // первая функция - это "эвалюатор". Его код будет выполнен в контексте страницы
      // эвалюатор сериализуется, поэтому его можно писать на джаваскрипте, а не строкой
      page.evaluate(function (data) {
        
        // данные передаются только через сериализацию в строку
        // это обратный процесс
        data = JSON.parse(data);

        // так выглядит вызов построения нашего графика
        // этот апи определен в charts.xxx.js
        charts.line("body",data.dataset);

        // нам надо вставить стили, которые мы прочитали из файла стилей и передали строкой
        var style = document.createElement("style");
        style.innerHTML = data.innerCss;
        document.getElementsByTagName("head")[0].appendChild(style);
      }
      , function (err, result) {
        if (err) {
          prom.reject("phantomjs evaluation failed : " + err)
        }
         
        // зададим путь для сохранения отрендеренного графика в файл на локальной файловой системе
        // фантом поддерживает png, pdf, gif и jpeg
        var filename = cfg.server.chartsPath + '/' + opts.type + "_" + Date.now() + ".png";
        var savingPath = "client" + filename;

        //  этот метод непосредственно рендерит и сохраняет страницу
        page.render(savingPath, function (err, res) {
          console.log("Saving image: " + filename);
          page.close();
          prom.fulfill({filename: filename});
        });
      }, JSON.stringify(data));
    return prom;
  }
}


P.S
Вопросы, пожелания, конструктив и троллинг — в комментарии.
Ошибки в «великом и могучем? — в личку.
Буду рад услышать ваши отзывы по всем аспектам — качество кода, качество статьи, стиль изложения.
Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 20

    0
    Попадался на stackoverflow такой же подход.
      0
      А еще вопрос, почему не использовать node-canvas и не генерить картинки с помощью него и d3?
        0
        Отвечу на оба комментария тут.
        1. На стековерфлоу есть куча описаний подобного подхода. В статье я описал уже готовое решение.
        2. Node-canvas не рассматривался потому, что а) времени на инвестигейт было мало и первый же вменяемый вариант был реализован и б) построение графиков в дом уже было на клиенте

        За наводку на node-canvas спасибо.

        UPD: В подходе с node-canvas я вижу проблему в том, что графика будет рендерится самим нодом. Если это так, то по моему мнению это не лучший подход именно для ноды. В случае с фантомом нода получается независимым процессом.
        Если я ошибаюсь, я бы хотел, чтобы знающие люди меня поправили.
          0
          Просто если у вас код на d3 то он останется практически без изменений. А node-canvas позволяет генерить дата-урл, поэтому можно одним запросом весь дашборд стягивать.
            0
            «Практически без изменений» не годится. В подходе с фантомом мы полностью переиспользуем логику с клиента. Т.е. дублирования кода нету вообще.
            Фантом тоже позволяет генерить дата-урл (точнее base64 encoded картинку, что легко превращается в дата-урл).
            Опять же, сохранение картинки — это часть нашей архитектурной модели. Вероятно, на этом стоит акцентировать внимание в статье?
              0
              Возможно, ну или я читал невнимательно. Мне просто кажется, что если можно обойтись без виртуального браузера для решеия этой задачи исользуя только библиотеки ноды — это будет предпочтительнее, к тому же на много легче поддерживать.
                0
                Да, дополнительная подсистема — это дополнительная вероятная точка фейла.
                Но в целом у нас получается отдельный EC2-инстанс со своим образом, содержащим фантом и ноду. С точки зрения devops усложнения практически нет.
                Какие проблемы с поддержкой вы видите в таком решении?
          0
          lol
          Потому что d3 это SVG но никак не canvas
            0
            Вообще то d3 некоторое время поддерживает canvas.
              +1
              Ну хз, язык как то не очень поворачивается назвать это «поддерживает Canvas». Так же можно сказать, что он поддерживает WebSockets например =)
              Но, я уверен, у автора графики были на SVG
                0
                Поддерживает достаточно что бы отренедрить статичный график, что автору и надо в общем.
              0
              Вот пример из галлереи. bl.ocks.org/mbostock/1276463
            +1
            Слова «НАША фирма нашла BigData-стартап, КОМАНДА выйграла тендер и теперь МЫ....» буквально убили мои детские представления… больше ни один стартап не задумаю, а то тоже украдут перепилят и извратят!
              –1
              Я надеюсь, что это шутка :)
              Но на всякий случай уточню. Наш менеджер нашел клиента, которому нужно было запилить стартап. Клиент предоставил набор тестовых заданий и мы их выполнили лучше, чем конкуренты. Теперь мы — удаленная команда, пилящая прототип клиента. Т.е мы аутсорс, который делает проект для американского дяди :)
              0
              1. А зачем SVG конвертировать в bitmap?
              2. Я так понимаю — размеры графики прибиты гвоздями на стороне сервера?
                –1
                1. SCG конвертируется в битмап для того, чтобы на выходе мы имели все же статическую картинку. Эту картинку мы отдаем не веб-клиенту, а потребителю нашего REST API.
                2. Размеры прибиты гвоздями, да. Но размер вьюпорта задается — в коде есть намек на это дело. И можно прокидывать параметры непосредственно в скрипт отрисовки.

                Но в целом да, на выходе мы имеем статическую картинку определенного размера.
                  0
                  "… для всех клиентов" имеется ввиду, видимо.
                  Т.е. вопрос поддержки разных размеров экрана у клиентов, видимо, не стоит?
                    0
                    Вопрос стоит. Этим занимается веб-клиент: в нем такие же графики рендерятся уже в зависимости от вьюпорта и им добавляется интерактивность. Серверный статические графики нам нужны, чтобы отдавать их «роботам».
                    Т.о. человек может получить три вида информации:
                    1. Интерактивная графика через веб-морду
                    2. Статическая графика через REST API
                    3. Голые данные через REST API
                0
                1. IMHO Date.now() маловато для уникальности файла, лучше добавить счетчик.
                2. Как собираетесь зачищать устаревшие файлы?
                  0
                  На эти вопросы нам отвечать, к счастью, не нужно в ближайшее время.
                  Поясню почему: для каждого пользователя в системе будет деплоится отдельный REST-server на собственном EC2 микро инстансе. Таково желание клиента на данной стадии.
                  1. Период запросов на построение графики определенно будет больше сотни миллисекунд, т.к. запросы приходят от одного клиента.
                  2. Устаревшие файлы очищать пока никак не собираемся. У пользователя в рабочей области потенциально будут лежать гигабайты различных данных. На фоне этого хранение избыточной статики не пугает.

                Only users with full accounts can post comments. Log in, please.