На прошлом JPoint пообещал написать статью про использование GraalVM для смешивания Java и JS. Вот она.
В чем проблема? В повседневной практике часто встречаются приложения, состоящие из двух частей: JavaScript-фронтенд и Java-бэкенд. Организация интеропа между ними требует усилий. Как правило, делают их люди с разных сторон баррикад, и при попытке залезть в чужую область они начинают страдать. Еще есть фуллстек веб-разработчики, но про них всё понятно: они должны страдать всегда.
В этой статье мы рассмотрим новую технологию, которая может сделать процесс немного менее болезненным. Точнее, способ существует довольно давно, но как-то прошел мимо внимания широких народных масс.
Если кто-то из джавистов еще не писал на React, то здесь будет туториал, позволяющий это сделать. Если кто-то из джаваскриптеров не пробовал писать на Java, то в этом же туториале получится к ней прикоснуться (правда, всего одной строчкой и сквозь JS-биндинги).
JS->Java. Тряхнем стариной: Nashorn
Если хочется интероп Java->JS, такая технология в JDK давным-давно была, и называется она Nashorn (читается: «Насхорн»).
Давайте возьмем какую-нибудь реальную ситуацию. Люди из раза в раз, из года в год, продолжают писать «серверные» валидаторы на Java и «клиентские» валидаторы на JS. Особый цинизм тут в том, что проверки зачастую совпадают на 80%, и вся эта активность, по сути, — особая форма бездарно потерянного времени.
Представим, что у нас есть очень тупой валидатор:
var validate = function(target) {
if (target > 0) {
return "success";
} else {
return "fail";
}
};
Запустить мы его можем на всех трех платформах:
- Браузер
- Node.js
- Java
В браузере это тривиально. Просто встраиваем этот кусок кода куда угодно, и оно работает.
В Node.js надо либо уважать их феншуй по использованию require, либо хакнуть его к чертям вот таким простым кодом:
var fs = require('fs');
var vm = require('vm');
var includeInThisContext = function(path) {
var code = fs.readFileSync(path);
vm.runInThisContext(code, path); }.bind(this); includeInThisContext(__dirname + "/" + filename);
Готовый пример есть у меня на GitHub.
Готовьтесь к тому, что если вы пользуетесь такими приемами, то довольно скоро коллеги могут начать считать вас чучелом. Нам, джавистам — не привыкать, а вот профессиональные джаваскриптеры могут и оконфузиться.
Теперь долбанем всё то же самое, но под Насхорном в Java.
public class JSExecutor {
private static final Logger logger = LoggerFactory.getLogger(JSExecutor.class);
ScriptEngine engine = new ScriptEngineManager().getEngineByName("nashorn");
Invocable invoker = (Invocable) engine;
public JSExecutor() {
try {
File bootstrapFile = new ClassPathResource("validator.js").getFile();
String bootstrapString = new String(Files.readAllBytes(bootstrapFile.toPath()));
engine.eval(bootstrapString);
} catch (Exception e) {
logger.error("Can't load bootstrap JS!", e);
}
}
public Object execute(String code) {
Object result = null;
try {
result = engine.eval(code);
} catch (Exception e) {
logger.error("Can't run JS!", e);
}
return result;
}
public Object executeFunction(String name, Object... args) {
Object result = null;
try {
result = invoker.invokeFunction(name, args);
} catch (Exception e) {
logger.error("Can't run JS!", e);
}
return result;
}
}
Этот пример тоже есть у меня на GitHub.
Как видите, можно дернуть как произвольный код, так и отдельную функцию по ее имени.
Есть, конечно, такие проблемы, которые можно решить только в ручном порядке. Например, можно состряпать полифилл типа такого:
var global = this;
var window = this;
var process = {env:{}};
var console = {};
console.debug = print;
console.log = print;
console.warn = print;
console.error = print;
Если хочется, чтобы код валидатора был идеально идентичным и на сервере, и на клиенте, придется для «серверных» методов написать заглушки, которые подпихиваются только в браузер. Это уже детали конкретной реализации.
Кстати, ab
на моем ноутбуке (ab -k -c 10 -n 100 http://localhost:3000/?id=2
) на такой код показывает 6-7 тысяч запросов в секунду, и не важно, на чем он запущен — на Nashorn или Node.js. Но в этом ничего интересного: во-первых, ab
на локалхосте измеряет погоду на Марсе, во-вторых, мы и так верим, что явных ляпов в этих движках нет, они конкуренты.
Понятно, что, если вы живете в «красной зоне» кривой имени Ш., использовать Nashorn без включения
Java->JS. Проблема
Попробуем пропихнуть данные в обратном направлении, из Java в JS.
Зачем это может быть нужно?
Во-первых, что самое главное, во многих компаниях существует необсуждаемая аксиома: мы используем Java. В каких-нибудь банках. Во-вторых, по ходу решения повседневных проблем такие задачи возникают постоянно.
Рассмотрим игрушечный случай из реальной жизни. Представьте: нужно сгенерить фронт вебпаком, и хочется вписать в правый верхний угол веб-странички текущую версию приложения. Вполне вероятно, что версию бэкенда можно нормальным способом вытащить только вызвав какой-то джавовый код (легаси же). Значит, нужно создать такой Maven-проект, который будет работать в два прохода: прибить к какой-нибудь фазе Maven Build Lifecycle сборку пары классов и их запуск, которые сгенерят properties-файл с номером версии, который на следующей фазе подхватит вручную вызванный npm.
Приводить пример такого pom.xml я здесь не буду, потому что это мерзко :)
Более глобально проблема заключается в том, что современная культура поддерживает и поощряет программистов-полиглотов и проекты, написанные на множестве языков. Из этого возникают следующие моменты:
- Разработчики хотят использовать тот язык, который более всего подходит к решаемой задаче. Очень больно писать на Java веб-интерфейс (по крайней мере до тех пор, пока JVM и OpenJDK не стабилизируются на WebAssembly), а на JS он делается просто и удобно.
- Часто хочется параллельно развивать несколько кодовых баз. Например, есть одна база на JS — фронт, и другая база на Java — бэк. Хочется развивать проекты, потихоньку переписывая всё приложение на Node.JS, включая серверный код — в тех местах, где Java не нужна по смыслу. Не должно быть «дня номер ноль», когда весь Java-бэкенд или JS-фронтенд отправляется на свалку, и пусть весь мир подождет, пока мы напишем новый.
- При пересечении границы языка приходится вручную писать множество мусорного кода, обеспечивающего интероп.
Иногда есть готовые решения — например, переход границы Java/С делается с помощью JNI.
Использование такой интеграции еще и тем хорошо, что, как любят говорить программисты-функционалы, «не сломается то, чего нет». Если мы в своем коде поддерживаем адовейшие pom.xml, properties и xml-файлы и другой ручной интероп, то они имеют свойство ломаться в самых неприятных моментах. Если же эту прослойку написали какие-нибудь реальные боевые ботаны, типа Oracle или Microsoft, оно почти не ломается, а когда ломается — чинить это не нам.
Возвращаясь к предыдущему примеру: зачем нам вставать два раза и делать чудеса с Насхорном, если можно не вставать вообще и писать весь UI только на Ноде?
Но как это сделать, учитывая, что нужно прозрачно посасывать данные из Java?
Первая мысль, которая приходит в голову — продолжать использовать Nashorn. Засосать в него все нужные библиотеки, подпилить напильником, и, может быть, они даже запустятся. Если среди них не будет таких, которым нужны нативные расширения. И вручную сэмулировать всю инфраструктуру Ноды. И еще что-то. Кажется, это проблема. Вообще, такой проект уже был, назывался Project Avatar, и, к сожалению, он загнулся. Если разработчики из Oracle не смогли его довести до конца, то какой шанс, что получится сделать это самостоятельно?
Java->JS. Graal
К счастью, у нас есть еще один довольно новый и интересный проект — Graal.js. То есть часть Graal, ответственная за запуск JavaScript.
Инновационные проекты из мира JDK зачастую воспринимаются чем-то далеким и нереальным. Graal в этом плане отличается — очень внезапно он вышел на сцену как зрелый конкурент.
Graal — это не часть OpenJDK, а отдельный продукт. Он известен тем, что в свежих версиях OpenJDK можно переключить JIT-компилятор из C2 на тот, что идет в составе Graal. Кроме того, в составе Graal поставляется фреймворк Truffle, с помощью которого можно реализовывать разные новые языки. В данном случае разработчики из Oracle Labs реализовали поддержку JavaScript.
Чтобы прочувствовать, насколько это просто и удобно, давайте рассмотрим игрушечный проект-пример.
Представим, что мы делаем рубку НЛО на Хабре.
В первой версии Рубки, НЛО сможет банить рандомных людей, и кнопка будет называться «Забанить кого-нибудь!». Во второй версии кнопка будет банить или троллей, или спамеров, и кого именно мы сейчас баним — будет подгружаться из Java. В целях минимализации примера меняться будет только надпись на кнопке, бизнес-логику прокидывать не будем.
Чтобы сделать реактовое приложение, нужно выполнить много действий, поэтому они аккуратно разбиты на шаги. В конце получается работающее приложение, я проверял.
Часть 1. Заготовка приложения
1. Качаем «энтерпрайзную» GraalVM (по ссылке) и прописываем обычные для Java переменные окружения.
Энтерпрайзная версия нужна потому, что только в ней есть GraalJS.
Можно, например, в .bash_profile записать вот такое:
graalvm () {
export LABSJDK=/Users/olegchir/opt/graalvm-0.33/Contents/Home
export LABSJRE=/Users/olegchir/opt/graalvm-0.33/Contents/Home/jre
export JDK_HOME=$LABSJDK
export JRE_HOME=$LABSJRE
export JAVA_HOME=$JDK_HOME
export PATH=$JDK_HOME/bin:$JRE_HOME/bin:$PATH
}
И потом после перезагрузки шелла вызвать эту функцию: graalvm
.
Почему я предлагаю сделать отдельную баш-функцию и вызывать ее по мере необходимости, а не сразу? Тут всё очень просто: после того, как GraalVM попадет в PATH, ваш нормальный системный npm (например, /usr/local/bin/npm
в macOS) будет подменён нашей особой джавовой версией ($JDK_HOME/bin/npm
). Если вы JS-разработчик, такая подмена на постоянку — не самая лучшая идея.
2. Делаем директорию для проекта
mkdir -p ~/git/habrotest
cd ~/git/habrotest
3. npm init
(заполнить с умом, но можно и просто прощелкать кнопку enter)
4. Устанавливаем нужные модули: Webpack, Babel, React
npm i --save-dev webpack webpack-cli webpack-dev-server
npm i --save-dev babel-core babel-loader babel-preset-es2015 babel-preset-react
npm i --save react react-dom
Заметьте, что npm может оказаться слегка устаревшей версии (относительно «настоящего») и попросит обновиться. Обновляться не стоит.
5. Создаем директории, в которых будет происходить работа:
mkdir -p src/client/app
mkdir -p src/client/public
mkdir -p loaders
6. Учим Babel нашим языкам:
./.babelrc
:
{
"presets" : ["es2015", "react"]
}
7. Настраиваем вебпак:
./webpack.config.js
:
var p = require('path');
var webpack = require('webpack');
var BUILD_DIR = p.resolve(__dirname, 'src/client/public');
var APP_DIR = p.resolve(__dirname, 'src/client/app');
var config = {
output: {
path: BUILD_DIR,
filename: 'bundle.js'
},
entry: APP_DIR + '/index.jsx',
module : {
rules : [
{
test : /.jsx?/,
include : APP_DIR,
loader : 'babel-loader'
}
]
}
};
module.exports = config;
8. Создаем страничку для нашего приложения:
./src/client/index.html
<html>
<head>
<meta charset="utf-8">
<title>Добро пожаловать в рубку НЛО</title>
</head>
<body>
<div id="app" />
<script src="public/bundle.js" type="text/javascript"></script>
</body>
</html>
9. Создаем индекс (чтобы потом пихать в него демонстрационный компонент):
./src/client/app/index.jsx
import React from 'react';
import {render} from 'react-dom';
import NLOComponent from './NLOComponent.jsx';
class App extends React.Component {
render () {
return (
<div>
<p>Добро пожаловать в рубку, НЛО</p>
<NLOComponent />
</div>
);
}
}
render(<App/>, document.getElementById('app'));
10. Создаем компонент!
./src/client/app/NLOComponent.jsx
import React from 'react';
class NLOComponent extends React.Component {
constructor(props) {
super(props);
this.state = {banned : 0};
this.onBan = this.onBan.bind(this);
}
onBan () {
let newBanned = this.state.banned + 10;
this.setState({banned: newBanned});
}
render() {
return (<div>
Количество забаненных : <span>{this.state.banned}</span>
<div><button onClick={this.onBan}>Забанить кого-нибудь!</button></div>
</div>
);
}
}
export default NLOComponent;
11. Запускаем сборку: webpack -d
Всё должно успешно собраться и вывести нечто вроде:
joker:habrotest olegchir$ webpack -d
Hash: b19d6529d6e3f70baba6
Version: webpack 4.5.0
Time: 19358ms
Built at: 2018-04-16 05:12:49
Asset Size Chunks Chunk Names
bundle.js 1.69 MiB main [emitted] main
Entrypoint main = bundle.js
[./src/client/app/NLOComponent.jsx] 3.03 KiB {main} [built]
[./src/client/app/index.jsx] 2.61 KiB {main} [built]
+ 21 hidden modules
12. Теперь можно открыть в браузере ./src/client/index.html
и насладиться следующим видом:
Первая часть туториала пройдена, теперь нужно научиться менять надпись на кнопке.
Часть 2. Подсовываем переменные
13. Попробуем внедрить в наш компонент переменную «название кнопки» (buttonCaption
) и «список вариантов» (buttonVariants
), о которых ничего не известно в JS. В дальнейшем они будут подтягиваться из Java, но сейчас просто проверяем, что их использование приводит к ошибке:
import React from 'react';
class NLOComponent extends React.Component {
constructor(props) {
super(props);
this.state = {banned : 0, button: buttonCaption};
this.onBan = this.onBan.bind(this);
}
onBan () {
let newBanned = this.state.banned + 10;
this.setState({banned: newBanned,
button: buttonVariants[Math.round(Math.random())]});
}
render() {
return (<div>
Количество забаненных : <span>{this.state.banned}</span>
<div><button onClick={this.onBan}>{this.state.button}</button></div>
</div>
);
}
}
export default NLOComponent;
Наблюдаем честную ошибку:
NLOComponent.jsx?8e83:7 Uncaught ReferenceError: buttonCaption is not defined
at new NLOComponent (NLOComponent.jsx?8e83:7)
at constructClassInstance (react-dom.development.js?61bb:6789)
at updateClassComponent (react-dom.development.js?61bb:8324)
at beginWork (react-dom.development.js?61bb:8966)
at performUnitOfWork (react-dom.development.js?61bb:11798)
at workLoop (react-dom.development.js?61bb:11827)
at HTMLUnknownElement.callCallback (react-dom.development.js?61bb:104)
at Object.invokeGuardedCallbackDev (react-dom.development.js?61bb:142)
at invokeGuardedCallback (react-dom.development.js?61bb:191)
at replayUnitOfWork (react-dom.development.js?61bb:11302)
(anonymous) @ bundle.js:72
react-dom.development.js?61bb:9627 The above error occurred in the <NLOComponent> component:
in NLOComponent (created by App)
in div (created by App)
in App
14. Теперь давайте познакомимся с легальным способом подсовывать переменные в Вебпаке. Это лоадеры.
Во-первых, нужно немного переписать конфиг вебпака, чтобы удобно грузить кастомные лоадеры:
var p = require('path');
var webpack = require('webpack');
var BUILD_DIR = p.resolve(__dirname, 'src/client/public');
var APP_DIR = p.resolve(__dirname, 'src/client/app');
let defaults = {
output: { path: BUILD_DIR, filename: 'bundle.js' },
entry: APP_DIR + '/index.jsx',
module : { rules : [ { test : /.jsx?/, include : APP_DIR, loader : 'babel-loader' } ] },
resolveLoader: { modules: ['node_modules', p.resolve(__dirname, 'loaders')] }
};
module.exports = function (content) {
let dd = defaults;
dd.module.rules.push({ test : /index.jsx/, loader: "preload", options: {} });
return dd;
};
(Заметьте, что в options
лоадеру можно подсунуть любые данные и потом считать с помощью loaderUtils.getOptions(this)
из модуля loader-utils
)
Ну и теперь, собственно, пишем лоадер. Лоадер устроен тупо: на вход в параметр source нам приходит изначальный код, мы его изменяем по своему желанию (можем и не изменять) и потом возвращаем назад.
./loaders/preload.js
:
const loaderUtils = require("loader-utils"),
schemaUtils = require("schema-utils");
module.exports = function main(source) {
this.cacheable();
console.log("applying loader");
var initial = "Забанить тролля!";
var variants = JSON.stringify(["Забанить тролля!", "Забанить спамера!"]);
return `window.buttonCaption="${initial}";`
+ `window.buttonVariants=${variants};`
+ `${source}`;
};
Выполняем пересборку с помощью webpack -d
.
Всё отлично работает, нет никаких ошибок.
Часть 3. Добавляем Java-код
15. Теперь вы спросите: хорошо, мы выучили один маленький грязный хак Вебпака, но при чем здесь Java?
Интересно здесь то, что наш лоадер выполняется не просто так, а под Граалем. Значит, можно с помощью API, похожего на Nashorn'овский, работать из JS с джавовыми типами.
const loaderUtils = require("loader-utils"),
schemaUtils = require("schema-utils");
module.exports = function main(source) {
this.cacheable();
console.log("applying loader");
//Мы можем получать джавовые типы и содзавать объекты этого типа
var JavaString = Java.type("java.lang.String");
var initial = new JavaString("Забанить тролля!");
//Мы можем конвертить данные туда, сюда, и обратно
var jsVariants = ["Забанить тролля!", "Забанить спамера!"];
var javaVariants = Java.to(jsVariants, "java.lang.String[]");
var variants = JSON.stringify(javaVariants);
//Но интероп не всегда хорош, и тогда приходится городить костыли
return `window.buttonCaption="${initial}";`
+ `window.buttonVariants=${variants};`
+ `${source}`;
};
Ну и конечно, webpack -d
.
16. При попытке собрать вебпаком видим ошибку:
ERROR in ./src/client/app/index.jsx
Module build failed: ReferenceError: Java is not defined
at Object.main (/Users/olegchir/git/habrotest/loaders/preload.js:9:19)
Она возникает потому, что джавовые типы недоступны по умолчанию и включаются специальным флагом --jvm
, который имеется только в GraalJS, но не в «обычной» Ноде.
Поэтому собирать надо специальной командой:
node --jvm node_modules/.bin/webpack -d
Так как набирать всё это достаточно муторно, я использую алиас в баше. Например, в .bash_profile
можно вставить следующую строчку:
alias graal_webpack_build="node --jvm node_modules/.bin/webpack -d"
Или как-нибудь еще короче, чтобы набирать было приятно.
17. PROFIT!
Результат можно посмотреть в моем репозитории на GitHub. Собранные файлы закоммичены прямо в репозиторий, чтобы посмотреть можно было даже не проходя туториал до конца.
Заключение
Вот таким простым и удобным способом мы теперь можем интегрировать Java и JS. Всё это — далеко не единичный случай, способов применения можно придумать множество.
Напоследок, каплю дегтя в бочку меда. В чем же подвох?
- GraalJS — пока не Open Source, хотя, по слухам, опенсорснуть его хотят;
- Джавовый npm пока что подтормаживает. Почему — надо изучать. Тормозит именно npm, а не сам JS-движок;
- Под капотом у всего этого находится лютая магия, и при попытке туда влезть придется изучать много всего дополнительно;
- Всё это собрано относительно JDK8. Новых фишек из Java 11 придется дожидаться достаточно долго;
- Graal — экспериментальный проект. Нужно учитывать это при попытке интегрировать его в совсем уж кровавый энтерпрайз без права на ошибку.
Минутка рекламы. Как вы, наверное, знаете, мы делаем конференции. Ближайшая конференция про JavaScript — HolyJS 2018 Piter, которая пройдет 19-20 мая 2018 года в Санкт-Петербурге. Можно туда прийти, послушать доклады (какие доклады там бывают — описано в программе конференции), вживую пообщаться с практикующими экспертами JavaScript и фронтенда, разработчиками разных моднейших технологий. Короче, заходите, мы вас ждём!
Автор: Олег Чирухин