
На прошлом 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 и фронтенда, разработчиками разных моднейших технологий. Короче, заходите, мы вас ждём!

