Перейти к основному содержимому

Интеграция Groovy в приложения Java

· 14 мин. чтения

1. Введение

В этом руководстве мы рассмотрим новейшие методы интеграции Groovy в приложение Java.

2. Несколько слов о Groovy

Язык программирования Groovy — это мощный динамический язык с опциональной типизацией . Он поддерживается Apache Software Foundation и сообществом Groovy при участии более 200 разработчиков.

Его можно использовать для создания всего приложения, для создания модуля или дополнительной библиотеки, взаимодействующей с нашим кодом Java, или для запуска оцениваемых и компилируемых скриптов на лету.

Дополнительные сведения см. в статье Introduction to Groovy Language или в официальной документации .

3. Зависимости Maven

На момент написания последнего стабильного выпуска была версия 2.5.7, а Groovy 2.6 и 3.0 (оба выпущены осенью 2017 года) все еще находятся в стадии альфа-тестирования.

Как и в Spring Boot, нам просто нужно включить groovy-all pom, чтобы добавить все зависимости , которые могут нам понадобиться, не беспокоясь об их версиях:

<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>
<version>${groovy.version}</version>
<type>pom</type>
</dependency>

4. Совместная компиляция

Прежде чем вдаваться в подробности настройки Maven, нам нужно понять, с чем мы имеем дело.

Наш код будет содержать файлы Java и Groovy . У Groovy не возникнет проблем с поиском классов Java, но что, если мы хотим, чтобы Java нашла классы и методы Groovy?

На помощь приходит совместная компиляция!

Совместная компиляция — это процесс, предназначенный для компиляции файлов Java и Groovy в одном проекте с помощью одной команды Maven.

При совместной компиляции компилятор Groovy:

  • разобрать исходные файлы
  • в зависимости от реализации создавать заглушки, совместимые с компилятором Java
  • вызвать компилятор Java для компиляции заглушек вместе с исходными кодами Java — таким образом классы Java могут найти зависимости Groovy
  • скомпилируйте исходники Groovy — теперь наши исходники Groovy могут найти свои Java-зависимости

В зависимости от реализующего его плагина нам может потребоваться разделить файлы на определенные папки или сообщить компилятору, где их найти.

Без совместной компиляции исходные файлы Java будут скомпилированы, как если бы они были исходными кодами Groovy. Иногда это может работать, поскольку большая часть синтаксиса Java 1.7 совместима с Groovy, но семантика будет другой.

5. Плагины компилятора Maven

Доступно несколько подключаемых модулей компилятора, поддерживающих совместную компиляцию , каждый из которых имеет свои сильные и слабые стороны.

Двумя наиболее часто используемыми с Maven являются Groovy-Eclipse Maven и GMaven+.

5.1. Плагин Groovy-Eclipse Maven

Плагин Groovy-Eclipse Maven упрощает совместную компиляцию, избегая генерации заглушек , что по-прежнему является обязательным шагом для других компиляторов, таких как GMaven + , но представляет некоторые особенности конфигурации.

Чтобы включить поиск новейших артефактов компилятора, мы должны добавить репозиторий Maven Bintray:

<pluginRepositories>
<pluginRepository>
<id>bintray</id>
<name>Groovy Bintray</name>
<url>https://dl.bintray.com/groovy/maven</url>
<releases>
<!-- avoid automatic updates -->
<updatePolicy>never</updatePolicy>
</releases>
<snapshots>
<enabled>false</enabled>
</snapshots>
</pluginRepository>
</pluginRepositories>

Затем в разделе плагинов мы сообщаем компилятору Maven, какую версию компилятора Groovy он должен использовать.

На самом деле плагин, который мы будем использовать — плагин компилятора Maven — на самом деле не компилирует, а вместо этого делегирует задание артефакту groovy-eclipse- batch :

<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.0</version>
<configuration>
<compilerId>groovy-eclipse-compiler</compilerId>
<source>${java.version}</source>
<target>${java.version}</target>
</configuration>
<dependencies>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-eclipse-compiler</artifactId>
<version>3.3.0-01</version>
</dependency>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-eclipse-batch</artifactId>
<version>${groovy.version}-01</version>
</dependency>
</dependencies>
</plugin>

Версия зависимости groovy-all должна совпадать с версией компилятора.

Наконец, нам нужно настроить автообнаружение нашего исходного кода: по умолчанию компилятор будет искать в таких папках, как src/main/java и src/main/groovy, но если наша папка java пуста, компилятор не будет искать наш groovy. источники .

Тот же механизм действует и для наших тестов.

Чтобы принудительно обнаружить файл, мы могли бы добавить любой файл в src/main/java и src/test/java или просто добавить плагин groovy-eclipse-compiler :

<plugin>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-eclipse-compiler</artifactId>
<version>3.3.0-01</version>
<extensions>true</extensions>
</plugin>

Раздел <extension> является обязательным, чтобы подключаемый модуль мог добавить дополнительную фазу сборки и цели, содержащие две исходные папки Groovy.

5.2. Плагин GMavenPlus

Плагин GMavenPlus может иметь название, похожее на название старого плагина GMaven, но вместо создания простого патча автор приложил усилия, чтобы упростить компилятор и отделить его от конкретной версии Groovy .

Для этого плагин отделяется от стандартных рекомендаций для плагинов компилятора.

Компилятор GMavenPlus добавляет поддержку функций, которых в то время еще не было в других компиляторах , например, invokedynamic , консоль интерактивной оболочки и Android.

С другой стороны, это представляет некоторые сложности:

  • он изменяет исходные каталоги Maven, чтобы они содержали как исходники Java, так и исходники Groovy, но не заглушки Java.
  • это требует от нас управления заглушками , если мы не удаляем их с правильными целями

Для настройки нашего проекта нам нужно добавить gmavenplus-plugin :

<plugin>
<groupId>org.codehaus.gmavenplus</groupId>
<artifactId>gmavenplus-plugin</artifactId>
<version>1.7.0</version>
<executions>
<execution>
<goals>
<goal>execute</goal>
<goal>addSources</goal>
<goal>addTestSources</goal>
<goal>generateStubs</goal>
<goal>compile</goal>
<goal>generateTestStubs</goal>
<goal>compileTests</goal>
<goal>removeStubs</goal>
<goal>removeTestStubs</goal>
</goals>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>
<!-- any version of Groovy \>= 1.5.0 should work here -->
<version>2.5.6</version>
<scope>runtime</scope>
<type>pom</type>
</dependency>
</dependencies>
</plugin>

Чтобы можно было протестировать этот подключаемый модуль, мы создали в образце второй файл pom с именем gmavenplus-pom.xml .

5.3. Компиляция с помощью плагина Eclipse-Maven

Теперь, когда все настроено, мы можем, наконец, построить наши классы.

В приведенном нами примере мы создали простое приложение Java в исходной папке src/main/java и несколько сценариев Groovy в папке src/ main/groovy , где мы можем создавать классы и сценарии Groovy.

Давайте соберем все с помощью плагина Eclipse-Maven:

$ mvn clean compile
...
[INFO] --- maven-compiler-plugin:3.8.0:compile (default-compile) @ core-groovy-2 ---
[INFO] Changes detected - recompiling the module!
[INFO] Using Groovy-Eclipse compiler to compile both Java and Groovy files
...

Здесь мы видим, что Groovy все компилирует .

5.4. Компиляция с помощью GMavenPlus

GMavenPlus показывает некоторые отличия:

$ mvn -f gmavenplus-pom.xml clean compile
...
[INFO] --- gmavenplus-plugin:1.7.0:generateStubs (default) @ core-groovy-2 ---
[INFO] Using Groovy 2.5.7 to perform generateStubs.
[INFO] Generated 2 stubs.
[INFO]
...
[INFO] --- maven-compiler-plugin:3.8.1:compile (default-compile) @ core-groovy-2 ---
[INFO] Changes detected - recompiling the module!
[INFO] Compiling 3 source files to XXX\ForEach\TutorialsRepo\core-groovy-2\target\classes
[INFO]
...
[INFO] --- gmavenplus-plugin:1.7.0:compile (default) @ core-groovy-2 ---
[INFO] Using Groovy 2.5.7 to perform compile.
[INFO] Compiled 2 files.
[INFO]
...
[INFO] --- gmavenplus-plugin:1.7.0:removeStubs (default) @ core-groovy-2 ---
[INFO]
...

Сразу замечаем, что GMavenPlus проходит дополнительные этапы:

  1. Генерация заглушек, по одной для каждого groovy-файла
  2. Компиляция файлов Java — как заглушек, так и кода Java
  3. Компиляция файлов Groovy

Генерируя заглушки, GMavenPlus наследует слабость, доставлявшую много головной боли разработчикам в последние годы при работе с совместной компиляцией.

В идеальном сценарии все будет работать просто отлично, но если добавить больше шагов, у нас будет больше точек отказа: например, сборка может завершиться ошибкой до того, как удастся очистить заглушки.

Если это произойдет, оставшиеся старые заглушки могут запутать нашу IDE, что затем покажет ошибки компиляции, хотя мы знаем, что все должно быть правильно.

Только чистая сборка позволит избежать мучительной и долгой охоты на ведьм.

5.5. Упаковка зависимостей в файле JAR

Чтобы запустить программу как jar из командной строки , мы добавили maven-assembly-plugin , `` который будет включать все зависимости Groovy в «толстую банку» с именем с постфиксом, определенным в свойстве descriptorRef:

<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.1.0</version>
<configuration>
<!-- get all project dependencies -->
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<!-- MainClass in mainfest make a executable jar -->
<archive>
<manifest>
<mainClass>com.foreach.MyJointCompilationApp</mainClass>
</manifest>
</archive>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<!-- bind to the packaging phase -->
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>

После завершения компиляции мы можем запустить наш код с помощью этой команды:

$ java -jar target/core-groovy-2-1.0-SNAPSHOT-jar-with-dependencies.jar com.foreach.MyJointCompilationApp

6. Загрузка кода Groovy на лету

Компиляция Maven позволяет нам включать файлы Groovy в наш проект и ссылаться на их классы и методы из Java.

Хотя этого недостаточно, если мы хотим изменить логику во время выполнения: компиляция выполняется за пределами этапа выполнения, поэтому нам все равно нужно перезапустить наше приложение, чтобы увидеть наши изменения .

Чтобы воспользоваться динамической мощью (и рисками) Groovy, нам нужно изучить методы, доступные для загрузки наших файлов, когда наше приложение уже запущено.

6.1. GroovyClassLoader

Для этого нам нужен GroovyClassLoader, который может анализировать исходный код в текстовом или файловом формате и генерировать результирующие объекты класса.

Когда источником является файл, результат компиляции также кэшируется , чтобы избежать накладных расходов, когда мы запрашиваем у загрузчика несколько экземпляров одного и того же класса.

Вместо этого скрипт, поступающий непосредственно из объекта String , не будет кэшироваться , поэтому многократный вызов одного и того же скрипта все равно может привести к утечке памяти.

GroovyClassLoader — это основа, на которой строятся другие системы интеграции.

Реализация относительно проста:

private final GroovyClassLoader loader;

private Double addWithGroovyClassLoader(int x, int y)
throws IllegalAccessException, InstantiationException, IOException {
Class calcClass = loader.parseClass(
new File("src/main/groovy/com/foreach/", "CalcMath.groovy"));
GroovyObject calc = (GroovyObject) calcClass.newInstance();
return (Double) calc.invokeMethod("calcSum", new Object[] { x, y });
}

public MyJointCompilationApp() {
loader = new GroovyClassLoader(this.getClass().getClassLoader());
// ...
}

6.2. GroovyShell

Метод parse() загрузчика сценариев оболочки принимает исходные тексты в текстовом или файловом формате и создает экземпляр класса Script .

Этот экземпляр наследует метод run() от Script , который выполняет весь файл сверху донизу и возвращает результат, заданный последней выполненной строкой.

Если мы хотим, мы также можем расширить Script в нашем коде и переопределить реализацию по умолчанию, чтобы напрямую вызывать нашу внутреннюю логику.

Реализация вызова Script.run() выглядит следующим образом:

private Double addWithGroovyShellRun(int x, int y) throws IOException {
Script script = shell.parse(new File("src/main/groovy/com/foreach/", "CalcScript.groovy"));
return (Double) script.run();
}

public MyJointCompilationApp() {
// ...
shell = new GroovyShell(loader, new Binding());
// ...
}

Обратите внимание, что run() не принимает параметры, поэтому нам нужно будет добавить в наш файл некоторые глобальные переменные, чтобы инициализировать их через объект Binding .

Поскольку этот объект передается при инициализации GroovyShell , переменные совместно используются всеми экземплярами сценария .

Если мы предпочитаем более детальное управление, мы можем использовать invokeMethod() , который может обращаться к нашим собственным методам через отражение и напрямую передавать аргументы.

Давайте посмотрим на эту реализацию:

private final GroovyShell shell;

private Double addWithGroovyShell(int x, int y) throws IOException {
Script script = shell.parse(new File("src/main/groovy/com/foreach/", "CalcScript.groovy"));
return (Double) script.invokeMethod("calcSum", new Object[] { x, y });
}

public MyJointCompilationApp() {
// ...
shell = new GroovyShell(loader, new Binding());
// ...
}

Под прикрытием GroovyShell полагается на GroovyClassLoader для компиляции и кэширования результирующих классов, поэтому те же правила, которые объяснялись ранее, применяются таким же образом.

6.3. GroovyScriptEngine

Класс GroovyScriptEngine особенно подходит для тех приложений, которые полагаются на повторную загрузку скрипта и его зависимостей .

Хотя у нас есть эти дополнительные функции, реализация имеет лишь несколько небольших отличий:

private final GroovyScriptEngine engine;

private void addWithGroovyScriptEngine(int x, int y) throws IllegalAccessException,
InstantiationException, ResourceException, ScriptException {
Class<GroovyObject> calcClass = engine.loadScriptByName("CalcMath.groovy");
GroovyObject calc = calcClass.newInstance();
Object result = calc.invokeMethod("calcSum", new Object[] { x, y });
LOG.info("Result of CalcMath.calcSum() method is {}", result);
}

public MyJointCompilationApp() {
...
URL url = null;
try {
url = new File("src/main/groovy/com/foreach/").toURI().toURL();
} catch (MalformedURLException e) {
LOG.error("Exception while creating url", e);
}
engine = new GroovyScriptEngine(new URL[] {url}, this.getClass().getClassLoader());
engineFromFactory = new GroovyScriptEngineFactory().getScriptEngine();
}

На этот раз нам нужно настроить исходные корни, и мы ссылаемся на скрипт только по его имени, что немного чище.

Заглянув внутрь метода loadScriptByName , мы сразу видим проверку isSourceNewer, где движок проверяет, действителен ли источник, находящийся в данный момент в кеше.

Каждый раз, когда наш файл изменяется, GroovyScriptEngine автоматически перезагружает этот конкретный файл и все классы, зависящие от него.

Хотя это удобная и мощная функция, она может вызвать очень опасный побочный эффект: многократная перезагрузка огромного количества файлов приведет к перегрузке процессора без предупреждения.

Если это произойдет, нам может потребоваться реализовать собственный механизм кэширования для решения этой проблемы.

6.4. GroovyScriptEngineFactory (JSR-223)

JSR-223 предоставляет стандартный API для вызова фреймворков сценариев, начиная с Java 6.

Реализация выглядит аналогично, хотя мы возвращаемся к загрузке через полные пути к файлам:

private final ScriptEngine engineFromFactory;

private void addWithEngineFactory(int x, int y) throws IllegalAccessException,
InstantiationException, javax.script.ScriptException, FileNotFoundException {
Class calcClas = (Class) engineFromFactory.eval(
new FileReader(new File("src/main/groovy/com/foreach/", "CalcMath.groovy")));
GroovyObject calc = (GroovyObject) calcClas.newInstance();
Object result = calc.invokeMethod("calcSum", new Object[] { x, y });
LOG.info("Result of CalcMath.calcSum() method is {}", result);
}

public MyJointCompilationApp() {
// ...
engineFromFactory = new GroovyScriptEngineFactory().getScriptEngine();
}

Это здорово, если мы интегрируем наше приложение с несколькими языками сценариев, но его набор функций более ограничен. Например, он не поддерживает перезагрузку класса . Таким образом, если мы интегрируем только с Groovy, возможно, лучше придерживаться более ранних подходов.

7. Подводные камни динамической компиляции

Используя любой из вышеперечисленных методов, мы могли бы создать приложение, которое считывает скрипты или классы из определенной папки вне нашего jar-файла .

Это дало бы нам возможность добавлять новые функции во время работы системы (если только нам не требуется новый код в части Java), тем самым достигая своего рода разработки с непрерывной доставкой.

Но остерегайтесь этого обоюдоострого меча: теперь нам нужно очень тщательно защищать себя от сбоев, которые могут произойти как во время компиляции, так и во время выполнения , де-факто гарантируя безопасный сбой нашего кода.

8. Подводные камни при запуске Groovy в Java-проекте

8.1. Производительность

Все мы знаем, что когда системе требуется высокая производительность, необходимо следовать некоторым золотым правилам.

Два из них могут иметь большее значение для нашего проекта:

  • избегать отражения
  • минимизировать количество инструкций байт-кода

Отражение, в частности, является дорогостоящей операцией из-за процесса проверки класса, полей, методов, параметров метода и так далее.

Если мы проанализируем вызовы методов из Java в Groovy, например, при запуске примера addWithCompiledClasses , стек операций между .calcSum и первой строкой фактического метода Groovy выглядит так:

calcSum:4, CalcScript (com.foreach)
addWithCompiledClasses:43, MyJointCompilationApp (com.foreach)
addWithStaticCompiledClasses:95, MyJointCompilationApp (com.foreach)
main:117, App (com.foreach)

Что соответствует Java. То же самое происходит, когда мы приводим объект, возвращенный загрузчиком, и вызываем его метод.

Однако вот что делает вызов invokeMethod :

calcSum:4, CalcScript (com.foreach)
invoke0:-1, NativeMethodAccessorImpl (sun.reflect)
invoke:62, NativeMethodAccessorImpl (sun.reflect)
invoke:43, DelegatingMethodAccessorImpl (sun.reflect)
invoke:498, Method (java.lang.reflect)
invoke:101, CachedMethod (org.codehaus.groovy.reflection)
doMethodInvoke:323, MetaMethod (groovy.lang)
invokeMethod:1217, MetaClassImpl (groovy.lang)
invokeMethod:1041, MetaClassImpl (groovy.lang)
invokeMethod:821, MetaClassImpl (groovy.lang)
invokeMethod:44, GroovyObjectSupport (groovy.lang)
invokeMethod:77, Script (groovy.lang)
addWithGroovyShell:52, MyJointCompilationApp (com.foreach)
addWithDynamicCompiledClasses:99, MyJointCompilationApp (com.foreach)
main:118, MyJointCompilationApp (com.foreach)

В этом случае мы можем оценить, что на самом деле стоит за мощью Groovy: метакласс .

Метакласс определяет поведение любого заданного класса Groovy или Java, поэтому Groovy просматривает его всякий раз, когда требуется выполнить динамическую операцию , чтобы найти целевой метод или поле. После обнаружения стандартный поток отражения выполняет его.

Два золотых правила нарушены одним методом вызова!

Если нам нужно работать с сотнями динамических файлов Groovy, то, как мы вызываем наши методы, будет иметь огромное значение в производительности нашей системы.

8.2. Метод или свойство не найдено

Как упоминалось ранее, если мы хотим развернуть новые версии файлов Groovy в жизненном цикле компакт-диска, нам нужно обращаться с ними так, как будто они являются API, отдельным от нашей базовой системы.

Это означает введение нескольких отказоустойчивых проверок и ограничений на дизайн кода, чтобы наш недавно присоединившийся разработчик не взорвал производственную систему неправильным толчком.

Примеры каждого из них: наличие конвейера CI и использование устаревшего метода вместо удаления.

Что произойдет, если мы этого не сделаем? Мы получаем ужасные исключения из-за отсутствующих методов и неправильного количества и типов аргументов.

И если мы думаем, что компиляция нас спасет, давайте посмотрим на метод calcSum2() наших скриптов Groovy:

// this method will fail in runtime
def calcSum2(x, y) {
// DANGER! The variable "log" may be undefined
log.info "Executing $x + $y"
// DANGER! This method doesn't exist!
calcSum3()
// DANGER! The logged variable "z" is undefined!
log.info("Logging an undefined variable: $z")
}

Просматривая весь файл, мы сразу видим две проблемы: метод calcSum3() и переменная z нигде не определены.

Тем не менее, скрипт компилируется успешно, без единого предупреждения, как статически в Maven, так и динамически в GroovyClassLoader.

Это не удастся, только когда мы попытаемся вызвать его.

Статическая компиляция Maven покажет ошибку только в том случае, если наш Java-код ссылается непосредственно на calcSum3() после приведения GroovyObject , как мы делаем в методе addWithCompiledClasses() , но это все еще неэффективно, если вместо этого мы используем отражение.

9. Заключение

В этой статье мы рассмотрели, как мы можем интегрировать Groovy в наше приложение Java, рассмотрели различные методы интеграции и некоторые проблемы, с которыми мы можем столкнуться при использовании смешанных языков.

Как обычно, исходный код, использованный в примерах, можно найти на GitHub .