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