Java вместо Groovy

    Вдруг оказывается, что в проекте нужны скрипты и возникает вопрос что лучше эволюция или революция?
    Но даже попытка внедрить груви может провалиться в легаси проекте с консервативным коллективом. И руководство может найти еще десяток причин не пропустить груви в проект. Хоть 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

      +2
      Гляньте на kotlinlang.org
        0
        Спасибо, уже смотрел! Как Kotlin может помочь в проектах с унаследованным кодом при запрете использовать что-либо кроме java?
          0
          Это же JVM) он также работает с Java как и Scala или Groovy
            0
            Groovy/Scala тоже JVM. Ограничение относится к гомогенности разработки — запрету на добавление новых языков в проект.
        0
        А не возникает проблем с каким-нибудь мусором от класс-лоадера в случае, если такие скрипты массово запускаются по расписанию, и соответственно постоянно перекомпилируются?
          0
          С таким вариантом использования не сталкивался. Но если нет утечек классов и класслоадеры не создаются миллиардами в короткий интервал времени, то не вижу проблем с выгрузкой классов при GC
            0
            мы активно используем Groovy скрипты для всяких нестандартных задач (импортов или расчетов), запускаемых по расписанию. Так мы их сознательно запускаем в режиме интерпретатора, без компиляции. Но это сделано на основе теоретических опасений, так сказать, во избежание. Интересно, насколько проблема нами выдумана…
              0
              Из практики все утечки классов в jvm которые я видел были связаны с ошибками в ПО и лишь один раз точно помню Алексей Рагозин на проекте рассказывал, что утечку памяти в тестах, которую я обнаружил в наших тестах на vicluster-coherence (ныне nanocloud), это не утечка — а особенность сборщика мусора при UseConcMarkSweepGC и большой интенсивности создания новых загрузчиков. Тогда поверил на слово и не стал вгрызаться в детали
                0
                Раньше был PermGen, теперь MetaSpace… Но суть утечек классов одна — ошибки в ПО и ссылки на классы или их загрузчики. Вот несколько примеров как их избежать в веб приложении
          +1
          Если интересно, посмотрите библиотеку Javassist. github.com/jboss-javassist/javassist jboss-javassist.github.io/javassist
          Стабильная, LGPL, существует с 1999. Умеет полноценно компилировать сорцы в памяти. Наследовать существующие классы, с импортами, например.
            0
            Спасибо за совет! Есть разница в удобстве работы между компилятором ecj и javassist, о чем я уже писал «Модификация программы и что лучше менять: исполняемый код или AST программы?». Также есть библиотеки
            ASM, javassist, BCEL, CGLIB
              0
              Я посмотрел вашу статью, в ней нет прямых указаний, чем ecj удобнее именно для геренации новых классов на рантайме. Одним из очевидных недостатков высокоуровнего API javassist является отсутствие поддержки generics, но насколько это важно?
                –1
                Отредактировать исходный код в любом редакторе удобнее и компиляция на лету, с полной поддержкой конструкций языка удобнее чем, чем манипуляции с API для генерации байткода. У javassist/asm своя ниша, где ими удобнее решать задачи: модификация существующего байткода без исходников
                  0
                  Я согласен, но всё-таки для скриптинга скорее пригодится не загрузка класса из файла целиком, а только части. Если речь идёт о замене хотсвапа, то тогда ваше решение, конечно, лучше. Интересно, в вашем проекте вот эти файлы, которые потом динамически грузятся, они лежат под source control?
                    0
                    Нет, в файловой системе или на веб. Хотя в планах хранить в git скрипты
                      0
                      Просто тогда не совсем понятно, чем это отличается от просто модулей проекта. Возможностью патчинга кода на лету? Почему это называется скриптами?
                        0
                        В том числе и динамического расширения функциональности. OSGI вполне решил бы большую часть динамизма системы и модулей
                  0
                  Каждой задаче надо подбирать свой более подходящий инструмент (учитывая все ограничения задачи и инструмента, стоимость эксплуатации)
              0
              Думаю, вам так же будет интересен такой подход habrahabr.ru/company/haulmont/blog/248981
                +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
                0
                igor_suhorukov
                Зачем использовать вместо, когда можно использовать вместе ?)
                  +1
                  Причины в статье и в коментариях. Вместе конечно лучше
                  +1
                  Как автор похожего решения хотел бы задать вам несколько вопросов.

                  1. Как реализована совместная компиляция и обновление зависимостей? Например я компилирую класс, от которого зависят другие динамически скомпилированные классы?
                  2. Есть ли кэш классов, и где он хранится (если есть)?
                  3. Не пробовали ли вы подружить вашу загрузку классов со Spring, или чем-то похожим?

                    +1
                    Это ваше решение? Молодцы!
                    А как вы живете с тем, что компилятор нельзя включать в свое решение и надо надеяться на tools.jar?

                    1. Не реализовано. Это удел систем типа JRebel или динамических систем типа OSGI. Мое мнение, что пока не будет реализовано в JVM JEP 159: Enhanced Class Redefinition. Каждая реализация такого механизма самостоятельно — очень сложная задача.
                    2. Классы кешируются в загрузчике org.codehaus.commons.compiler.jdk.JavaFileManagerClassLoader.
                    3. Не пробовал
                    +1
                    Спасибо за ответ.

                    Первый пункт — действительно непростая задача. Конкретно для наших приложений мы решили ее построением дерева зависимостей при компиляции классов. В момент когда какой-то класс меняется мы ищем все классы, зависящие от него (которые его импортят) и компилируем их тоже. Это хорошо работает, особенно если мы перезагружаем сразу целый функциональный модуль. В одном из наших проектов мы весь пользовательский интерфейс делали динамически компилируемым.

                      0
                      Интересный опыт!
                    • НЛО прилетело и опубликовало эту надпись здесь
                        +1
                        MVEL2 тоже похож на java, но не java и тоже менее навороченный

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

                      Самое читаемое