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

от автора

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

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

Во время реализации пришлось попотеть с использованием фантома через ноду. Первый модуль оказался плоховат и кривоват (см. предыдущий спойлер), потому выбор пал на 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

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

ссылка на оригинал статьи http://habrahabr.ru/post/180927/


Комментарии

Один комментарий на ««Визуализация на сервере: NodeJS + D3.js + PhantomJS»»

  1. Аватар пользователя Дмитрий
    Дмитрий

    Здравствуйте, интересная статья. Но есть несколько интересных вопросов. Ситуация в том что по какой то не понятной причине не все страницы render-ся. Не зависимо от модуля phantom или же node-phantom. Может сборка корявая phantomjs но ставил 1.7.0 и 1.9.2 Та же чепуха. Допустим football.ua рендериться, а вот i.ua уже нет. Что интересно сам render отрабатывается без всякий ероров и екзепшенов. Можнет подскажете в чем тут дело

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *