Но даже попытка внедрить груви может провалиться в легаси проекте с консервативным коллективом. И руководство может найти еще десяток причин не пропустить груви в проект. Хоть groovy гораздо проще и ближе программисту знающему java, чем та же scala.
Но даже в этом случае можно использовать динамически компилируемые скрипты в проекте. Научимся компилировать java код динамически в памяти и запускать его в jvm, использовать в нем динамически загружаемыме библиотеки из maven. Хотелось бы написать как можно меньше кода для этого и чтобы процесс использования был максимально прост. Да и еще бы не хотелось надеяться на доступность tools.jar нашей пограмме.
Предупреждая негодование со стороны Groovy специалистов, признаюсь что я и сам люблю и использую этот динамический язык программирования и внес свою скромную лепту в Groovy Grape. Не умаляя достоинств Groovy, все же попробуем применить java в той области где груви на jvm вне конкуренции — динамической компиляции, взаимодействии с существующим java кодом и динамическим импортом зависимостей (то что делает Grape).
О компиляции в Java. JSR 199
Стандарт JSR 199 — java Compiler API, существует довольно давно. Интерфейсы API присутствуют в java пакетах javax.tools.*. Но чтобы компилировать java код из памяти в память и потом запустить его, надо изрядно написать кода и побить в бубен. Реализация компилятора не идет в составе JRE и tools.jar нет в maven репозитариях.
Как писать меньше с JSR 199
Хотелось бы что-нибудь готовое, не велосипедить каждый раз и коллега подсказал проект Janino. Сам janino содержит свой компилятор подмножества java и хорошо подходит лишь для вычисления выраженией. Есть org.codehaus.janino:commons-compiler-jdk который использует JSR 199, но вот только сильно зависит от oracle/openjdk tools.jar. После вечера работы напильником на свет появился janino-commons-compiler-ecj (2,3 МБ) который включает в себя eclipse java compiler и доработанный под него commons-compiler-jdk. Он самодостаточен и позволяет компилировать и загружать код даже в JRE. Если же добавить к нему mvn-classloader, то в скриптах можно делать такую же магию с динамическими зависимостями, как и в Groovy Grape.
Для сравнения библиотека для динамического языка mvel2 (989 КБ) занимает всего в пару раз меньше места, но не позволяет делать такие простые вещи как реализация интерфейса, определение внутреннего и инстанцирование анонимного класса, отсутствует подобие конструкции try/catch/finally да и отладка скриптов на нем может показаться адом.
Пример
Для компиляции скрипта на java нужна только зависимость com.github.igor-suhorukov:janino-commons-compiler-ecj:1.0 и лишь 3 строчки кода:
SimpleClassPathCompiler simpleCompiler = new SimpleClassPathCompiler(dependenciesUrls);
simpleCompiler.cook(SCRIPT_NAME+".java", scriptSourceText);
Class<?> clazz = simpleCompiler.getClassLoader().loadClass(SCRIPT_NAME);
Чтобы не быть голословным, есть пример всего про что рассказываю на github. Для его запуска нам потребуется JVM которая поддерживает java8, так как в примере скрипта будет Stream API.
Итак, начнем:
git clone https://github.com/igor-suhorukov/janino-commons-compiler-ecj-example.git
cd janino-commons-compiler-ecj-example
mvn test
Для того чтобы импортировать класс PhantomJsDowloader из maven зависимости com.github.igor-suhorukov:phantomjs-runner:1.1 вызовем MavenClassLoader и создадим classpath компилятору на основе этого maven артефакта:
List<URL> urlsCollection = MavenClassLoader.usingCentralRepo().getArtifactUrlsCollection("com.github.igor-suhorukov:phantomjs-runner:1.1", null);
new SimpleClassPathCompiler(urlsCollection);
Далее привожу текст основной java программы, которая загружает зависимости из репозитария, компилирует скрипт и выполняет его.
janino-commons-compiler-ecj-example/src/test/java/org.codehaus.commons.compiler.jdk/SimpleClassPathCompilerTest.java
package org.codehaus.commons.compiler.jdk;
import com.github.igorsuhorukov.codehaus.plexus.util.IOUtil;
import com.github.igorsuhorukov.smreed.dropship.MavenClassLoader;
import org.junit.Test;
import java.lang.reflect.Method;
import java.net.URL;
import java.util.List;
public class SimpleClassPathCompilerTest {
@Test
public void testClassloader() throws Exception {
final String SCRIPT_NAME = "MyScript";
List<URL> urlsCollection = MavenClassLoader.usingCentralRepo().getArtifactUrlsCollection("com.github.igor-suhorukov:phantomjs-runner:1.1", null);
SimpleClassPathCompiler simpleCompiler = new SimpleClassPathCompiler(urlsCollection);
simpleCompiler.setCompilerOptions("-8");
simpleCompiler.setDebuggingInformation(true,true,true);
String src = IOUtil.toString(getClass().getResourceAsStream(String.format("/%s.java", SCRIPT_NAME)));
simpleCompiler.cook(SCRIPT_NAME+".java", src);
Class<?> clazz = simpleCompiler.getClassLoader().loadClass(SCRIPT_NAME);
Method main = clazz.getMethod("main", String[].class);
main.invoke(null, (Object) null);
}
public static void runIt(){
System.out.println("DONE!");
}
}
А это сам скрипт, который использует внешнюю библиотеку из maven и также вызывает метод runIt класса, который его скомпилировал.
janino-commons-compiler-ecj-example/src/test/resources/MyScript.java
import com.github.igorsuhorukov.phantomjs.PhantomJsDowloader;
import com.github.igorsuhorukov.smreed.dropship.MavenClassLoader;
import org.codehaus.commons.compiler.jdk.SimpleClassPathCompilerTest;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class MyScript{
public static void main(String[] args) throws Exception{
class Wrapper{
private String value;
public Wrapper(String value) { this.value = value; }
public String getValue() { return value; }
}
SimpleClassPathCompilerTest.runIt();
List<String> res = Arrays.asList(new Wrapper("do"), new Wrapper("something"), new Wrapper("wrong")).stream().
map(Wrapper::getValue).collect(Collectors.toList());
System.out.println(String.join(" ",res));
System.out.println("Classes from project classpath. For example "+MavenClassLoader.class.getName());
System.out.println(PhantomJsDowloader.getPhantomJsPath());
}
}
Для работы примера нужны следующие зависимости
- com.github.igor-suhorukov:janino-commons-compiler-ecj:1.0 для компиляции java кода и динамической загрузки его.
- com.github.igor-suhorukov:mvn-classloader:1.3 для динамической загрузки библиотек из maven и формирования classpath компилятора.
- junit для теста.
pom.xml
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.github.igor-suhorukov</groupId>
<artifactId>janino-commons-compiler-ecj-example</artifactId>
<packaging>jar</packaging>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>com.github.igor-suhorukov</groupId>
<artifactId>janino-commons-compiler-ecj</artifactId>
<version>1.0</version>
<exclusions><exclusion><groupId>*</groupId><artifactId>*</artifactId></exclusion></exclusions>
</dependency>
<dependency>
<groupId>com.github.igor-suhorukov</groupId>
<artifactId>mvn-classloader</artifactId>
<version>1.3</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Debug скрипта на java
Отладка скрипта, как обычная отладка java программы. Расставляем точки останова и не забываем включить отладочную информацию при компиляции скрипта:
simpleCompiler.setDebuggingInformation(true,true,true);
Выводы
Мы научились компилировать java код из java программы, добавлением всего нескольких строчек. Также мы умеем включать в этот скрипт зависимости из maven репозитариев и проводить отладку кода в IDE.
Подход из статьи может заменить groovy для скриптов в проекте, если требования не позволяют использовать ничего кроме java или коллеги враждебно воспринимают груви и с этим ничего не получается сделать. Вы можете возразить про AST/метапрограммирование, что groovy впереди и будете правы, в java с этим не все просто. Про работу с AST java программы расказывал в статье "Разбор Java программы с помощью java программы.". Ситуацию с метропрограммированием попробуем решить в следующих публикациях.
Несмотря на то что статья описывает подход на «чистой» java и на выбор такого подхода в проекте могли повлиять политические мотивы, я считаю что лучше Java вместе с Groovy, чем «Java вместо Groovy».
Комментарии (28)
andrewsch
22.12.2015 22:00А не возникает проблем с каким-нибудь мусором от класс-лоадера в случае, если такие скрипты массово запускаются по расписанию, и соответственно постоянно перекомпилируются?
igor_suhorukov
22.12.2015 22:13С таким вариантом использования не сталкивался. Но если нет утечек классов и класслоадеры не создаются миллиардами в короткий интервал времени, то не вижу проблем с выгрузкой классов при GC
andrewsch
22.12.2015 22:17мы активно используем Groovy скрипты для всяких нестандартных задач (импортов или расчетов), запускаемых по расписанию. Так мы их сознательно запускаем в режиме интерпретатора, без компиляции. Но это сделано на основе теоретических опасений, так сказать, во избежание. Интересно, насколько проблема нами выдумана…
igor_suhorukov
22.12.2015 22:31Из практики все утечки классов в jvm которые я видел были связаны с ошибками в ПО и лишь один раз точно помню Алексей Рагозин на проекте рассказывал, что утечку памяти в тестах, которую я обнаружил в наших тестах на vicluster-coherence (ныне nanocloud), это не утечка — а особенность сборщика мусора при UseConcMarkSweepGC и большой интенсивности создания новых загрузчиков. Тогда поверил на слово и не стал вгрызаться в детали
igor_suhorukov
22.12.2015 22:40Раньше был PermGen, теперь MetaSpace… Но суть утечек классов одна — ошибки в ПО и ссылки на классы или их загрузчики. Вот несколько примеров как их избежать в веб приложении
Morj
23.12.2015 01:32+1Если интересно, посмотрите библиотеку Javassist. github.com/jboss-javassist/javassist jboss-javassist.github.io/javassist
Стабильная, LGPL, существует с 1999. Умеет полноценно компилировать сорцы в памяти. Наследовать существующие классы, с импортами, например.igor_suhorukov
23.12.2015 08:48Спасибо за совет! Есть разница в удобстве работы между компилятором ecj и javassist, о чем я уже писал «Модификация программы и что лучше менять: исполняемый код или AST программы?». Также есть библиотеки
ASM, javassist, BCEL, CGLIBMorj
23.12.2015 11:22Я посмотрел вашу статью, в ней нет прямых указаний, чем ecj удобнее именно для геренации новых классов на рантайме. Одним из очевидных недостатков высокоуровнего API javassist является отсутствие поддержки generics, но насколько это важно?
igor_suhorukov
23.12.2015 11:37-1Отредактировать исходный код в любом редакторе удобнее и компиляция на лету, с полной поддержкой конструкций языка удобнее чем, чем манипуляции с API для генерации байткода. У javassist/asm своя ниша, где ими удобнее решать задачи: модификация существующего байткода без исходников
Morj
23.12.2015 11:54Я согласен, но всё-таки для скриптинга скорее пригодится не загрузка класса из файла целиком, а только части. Если речь идёт о замене хотсвапа, то тогда ваше решение, конечно, лучше. Интересно, в вашем проекте вот эти файлы, которые потом динамически грузятся, они лежат под source control?
igor_suhorukov
23.12.2015 12:03Нет, в файловой системе или на веб. Хотя в планах хранить в git скрипты
Morj
23.12.2015 12:05Просто тогда не совсем понятно, чем это отличается от просто модулей проекта. Возможностью патчинга кода на лету? Почему это называется скриптами?
igor_suhorukov
23.12.2015 12:58В том числе и динамического расширения функциональности. OSGI вполне решил бы большую часть динамизма системы и модулей
igor_suhorukov
23.12.2015 11:43Каждой задаче надо подбирать свой более подходящий инструмент (учитывая все ограничения задачи и инструмента, стоимость эксплуатации)
Agb
23.12.2015 08:18Думаю, вам так же будет интересен такой подход habrahabr.ru/company/haulmont/blog/248981
igor_suhorukov
23.12.2015 09:00+1Спасибо, это тот же JSR 199 (Java 1.6 — ToolProvider.getSystemJavaCompiler ). Без уже установленного tools.jar в этом подходе реализацию компилятора не найти. По хорошему надо носить с собой компилятор и искать его как-то так (CompilerUtil):
ServiceLoader<JavaCompiler> javaCompilers = ServiceLoader.load(JavaCompiler.class); for (JavaCompiler javaCompiler : javaCompilers) { return javaCompiler; }
и только после этого
ToolProvider.getSystemJavaCompiler();
>>Как еще можно быстро доставить изменения на сервер -> «Второй способ — Hot Deploy»
сюда же можно добавить OSGI
gurinderu
23.12.2015 10:29igor_suhorukov
Зачем использовать вместо, когда можно использовать вместе ?)
tinhol
23.12.2015 15:06+1Как автор похожего решения хотел бы задать вам несколько вопросов.
1. Как реализована совместная компиляция и обновление зависимостей? Например я компилирую класс, от которого зависят другие динамически скомпилированные классы?
2. Есть ли кэш классов, и где он хранится (если есть)?
3. Не пробовали ли вы подружить вашу загрузку классов со Spring, или чем-то похожим?
igor_suhorukov
23.12.2015 17:58+1Это ваше решение? Молодцы!
А как вы живете с тем, что компилятор нельзя включать в свое решение и надо надеяться на tools.jar?
1. Не реализовано. Это удел систем типа JRebel или динамических систем типа OSGI. Мое мнение, что пока не будет реализовано в JVM JEP 159: Enhanced Class Redefinition. Каждая реализация такого механизма самостоятельно — очень сложная задача.
2. Классы кешируются в загрузчике org.codehaus.commons.compiler.jdk.JavaFileManagerClassLoader.
3. Не пробовал
tinhol
23.12.2015 19:53+1Спасибо за ответ.
Первый пункт — действительно непростая задача. Конкретно для наших приложений мы решили ее построением дерева зависимостей при компиляции классов. В момент когда какой-то класс меняется мы ищем все классы, зависящие от него (которые его импортят) и компилируем их тоже. Это хорошо работает, особенно если мы перезагружаем сразу целый функциональный модуль. В одном из наших проектов мы весь пользовательский интерфейс делали динамически компилируемым.
guai
29.12.2015 11:29еще можно было предложить вашим ретроградам beanshell — он еще больше похож на яву, чем даже груви. Только, конечно, гораздо менее навороченный
xGromMx
Гляньте на kotlinlang.org
igor_suhorukov
Спасибо, уже смотрел! Как Kotlin может помочь в проектах с унаследованным кодом при запрете использовать что-либо кроме java?
xGromMx
Это же JVM) он также работает с Java как и Scala или Groovy
igor_suhorukov
Groovy/Scala тоже JVM. Ограничение относится к гомогенности разработки — запрету на добавление новых языков в проект.