JavaScript, Java, какая теперь разница?

Habrahabr 1

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