Возникла у нас на проекте прихоть — рисовать на стороне сервера графики, да не простые, а максимально похожие на уже имеющиеся графики на клиентской стороне.
Да-да, именно так, на клиенте уже были всевозможные красивости, реализованные на d3.js.
Для исследования возможностей был применен комплексный метод анализа «google-driven investigation» и в первой итерации выбор пал на ноду + фантом.
За подробностями прошу в глубины поста.
Скучное введение
Раскажу вкратце о проекте, чтобы обрисовать ситуацию. Наша фирма нашла BigData-стартап, команда выйграла тендер и теперь мы вчетвером пилим аналитику в облаке для тяжеловесных датасетов.
Наш зоопарк состоит из кластеров на AWS с автодеплоем, Scala, Spark, Shark, Mesos, NodeJS и прочих страшных технологий (я надеюсь, такой проект позволит мне и моим коллегам утолить интеллектуальный голод и понаписать пару статей).
Дисклеймер
Наша команда — два матерых джависта и два «полиглота» (java/scala + javascript). Мы считаем себя хорошими инженерами и используем языки как инструменты, хотя и делаем упор в джаву. Поэтому, если материал покажется «неправославным» c точки зрения подходов и практик, прошу тухлые яйца кидать в личку, а конструктивную критику — в комментарии.
У нас недельные итерации и ретроспектива + демо в конце недели. Это накладывает ряд ограничений на исследования и поиск лучших практик.
На момент реализации решения у нас уже были «цифрожевалки» на скале и рест-сервисы на ноде.
Суть
Требования
- Графика должна быть статической
- Графика должна быть максимально похожа на клиентские интерактивные «свистелки»
- Графика должна генериться на сервере
- Интерфейс взаимодействия — REST
- Все дело должно строиться динамически по датасетам из хранилища
Почему нода и фантом?
В ходе беглого изучения проблемы было обнаружено три варианта:
- Использовать js-реализацию дом-дерева и Image Magic для конвертации SVG в PNG (пример был найден).
- Использовать джава-библиотеки для чартов в скале (или скала-аналоги) и максимально стилизовать их под d3
- Заиспользовать фантом в связке со скалой/нодой
Вариант №1 оставлял открытым вопрос о css-стилях и общей целесообразности (не нодовское это призвание процессор рассчетами загружать).
Вариант №2 показался разумным, но гарантирующим продолжительную боль в области седалищного нерва.
Было решено использовать Вариант №3.
Последующие изучение и эксперименты показали, что:
- Scala с фантомом не дружит. И внешний апи никакой фантом не предоставляет.
- Зато фантом дружит с нодой. Причем есть несколько npm-модулей, предоставляющих мост между нодой и родным апи фантома.
Какой такой мост?
Это оказалось интересным. Из-за того, что первоначально был выбран несвежий модуль для работы с фантомом, пришлось нырнуть с головой в дебаг модуля и троллинг сообщества на гитхабе на предмет поддержки самописных модулей.
Оказалось, что внешнего апи у фантома вообще нет. Даже для ноды. Но внутренний апи эмулируется через socket.io и переопределением обработчика alert'а на странице, открытой в фантоме.
Автору уважуха за находчивость!
Алгоритм примерно такой:
Оказалось, что внешнего апи у фантома вообще нет. Даже для ноды. Но внутренний апи эмулируется через socket.io и переопределением обработчика alert'а на странице, открытой в фантоме.
Автору уважуха за находчивость!
Алгоритм примерно такой:
- Создается скрипт, который будет принимать socket.io сообщения внутри фантома
- Создается страница-заглушка с подключенным скриптом.
- Переопределяется слушатель alert-сообщений, которые будут содержать «ответ» страницы на socket.io сообщение
- На ноде поднимается express-сервер, отдающий страницу и обрабатывающий socket.io запросы.
- Запускается процесс фантома и ему скармливается страница-заглушка.
- Модуль экспортирует «отзеркаленный» апи фантома (но все методы становятся асинхронными; в фантоме они почти все синхронны)
Углубившись в вариант «фантом + нода», я выяснил, что можно заиспользовать уже имеющийся javascript-код клиента для построения графиков на стороне сервера.
Фантом — это вебкит с полноценной реализацией дом-дерева, стилей и джаваскрипта. И он позволяет делать снимки отрисованной страницы. Такое решение позволяет вообще не дублировать код построения графики!
Подводные камни
Для работы фантома он должен быть установлен в системе :)
или
После этих волшебных слов мост сможет использовать модуль webpage.
Во время реализации пришлось попотеть с использованием фантома через ноду. Первый модуль оказался плоховат и кривоват (см. предыдущий спойлер), потому выбор пал на node-phantom.
Возникла давняя как мир проблема — отсутствие документации по апи.
Методом научного тыка удалось выяснить, что:
Я использую модуль vow vow для уменьшения «макаронности» кода. Плохо или хорошо использую — отпишите в комментариях!
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
Вопросы, пожелания, конструктив и троллинг — в комментарии.
Ошибки в «великом и могучем? — в личку.
Буду рад услышать ваши отзывы по всем аспектам — качество кода, качество статьи, стиль изложения.
Ошибки в «великом и могучем? — в личку.
Буду рад услышать ваши отзывы по всем аспектам — качество кода, качество статьи, стиль изложения.