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

в 17:30, , рубрики: charts, javascript, node.js, nodejs, phantomjs, метки: , , ,

Возникла у нас на проекте прихоть — рисовать на стороне сервера графики, да не простые, а максимально похожие на уже имеющиеся графики на клиентской стороне.
Да-да, именно так, на клиенте уже были всевозможные красивости, реализованные на d3.js.
Node + Phantom
Для исследования возможностей был применен комплексный метод анализа «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-код клиента для построения графиков на стороне сервера.
Фантом — это вебкит с полноценной реализацией дом-дерева, стилей и джаваскрипта. И он позволяет делать снимки отрисованной страницы. Такое решение позволяет вообще не дублировать код построения графики!

Подводные камни

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

Методом научного тыка удалось выяснить, что:

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

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

Я использую модуль 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

Вопросы, пожелания, конструктив и троллинг — в комментарии.
Ошибки в «великом и могучем? — в личку.
Буду рад услышать ваш» отзывы по всем аспектам — качество кода, качество статьи, стиль изложения.

Автор: MrMig

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js