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


  1. xGromMx
    22.12.2015 19:04
    +2

    Гляньте на kotlinlang.org


    1. igor_suhorukov
      22.12.2015 19:12

      Спасибо, уже смотрел! Как Kotlin может помочь в проектах с унаследованным кодом при запрете использовать что-либо кроме java?


      1. xGromMx
        22.12.2015 19:28

        Это же JVM) он также работает с Java как и Scala или Groovy


        1. igor_suhorukov
          22.12.2015 19:36

          Groovy/Scala тоже JVM. Ограничение относится к гомогенности разработки — запрету на добавление новых языков в проект.


  1. andrewsch
    22.12.2015 22:00

    А не возникает проблем с каким-нибудь мусором от класс-лоадера в случае, если такие скрипты массово запускаются по расписанию, и соответственно постоянно перекомпилируются?


    1. igor_suhorukov
      22.12.2015 22:13

      С таким вариантом использования не сталкивался. Но если нет утечек классов и класслоадеры не создаются миллиардами в короткий интервал времени, то не вижу проблем с выгрузкой классов при GC


      1. andrewsch
        22.12.2015 22:17

        мы активно используем Groovy скрипты для всяких нестандартных задач (импортов или расчетов), запускаемых по расписанию. Так мы их сознательно запускаем в режиме интерпретатора, без компиляции. Но это сделано на основе теоретических опасений, так сказать, во избежание. Интересно, насколько проблема нами выдумана…


        1. igor_suhorukov
          22.12.2015 22:31

          Из практики все утечки классов в jvm которые я видел были связаны с ошибками в ПО и лишь один раз точно помню Алексей Рагозин на проекте рассказывал, что утечку памяти в тестах, которую я обнаружил в наших тестах на vicluster-coherence (ныне nanocloud), это не утечка — а особенность сборщика мусора при UseConcMarkSweepGC и большой интенсивности создания новых загрузчиков. Тогда поверил на слово и не стал вгрызаться в детали


          1. igor_suhorukov
            22.12.2015 22:40

            Раньше был PermGen, теперь MetaSpace… Но суть утечек классов одна — ошибки в ПО и ссылки на классы или их загрузчики. Вот несколько примеров как их избежать в веб приложении


  1. Morj
    23.12.2015 01:32
    +1

    Если интересно, посмотрите библиотеку Javassist. github.com/jboss-javassist/javassist jboss-javassist.github.io/javassist
    Стабильная, LGPL, существует с 1999. Умеет полноценно компилировать сорцы в памяти. Наследовать существующие классы, с импортами, например.


    1. igor_suhorukov
      23.12.2015 08:48

      Спасибо за совет! Есть разница в удобстве работы между компилятором ecj и javassist, о чем я уже писал «Модификация программы и что лучше менять: исполняемый код или AST программы?». Также есть библиотеки
      ASM, javassist, BCEL, CGLIB


      1. Morj
        23.12.2015 11:22

        Я посмотрел вашу статью, в ней нет прямых указаний, чем ecj удобнее именно для геренации новых классов на рантайме. Одним из очевидных недостатков высокоуровнего API javassist является отсутствие поддержки generics, но насколько это важно?


        1. igor_suhorukov
          23.12.2015 11:37
          -1

          Отредактировать исходный код в любом редакторе удобнее и компиляция на лету, с полной поддержкой конструкций языка удобнее чем, чем манипуляции с API для генерации байткода. У javassist/asm своя ниша, где ими удобнее решать задачи: модификация существующего байткода без исходников


          1. Morj
            23.12.2015 11:54

            Я согласен, но всё-таки для скриптинга скорее пригодится не загрузка класса из файла целиком, а только части. Если речь идёт о замене хотсвапа, то тогда ваше решение, конечно, лучше. Интересно, в вашем проекте вот эти файлы, которые потом динамически грузятся, они лежат под source control?


            1. igor_suhorukov
              23.12.2015 12:03

              Нет, в файловой системе или на веб. Хотя в планах хранить в git скрипты


              1. Morj
                23.12.2015 12:05

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


                1. igor_suhorukov
                  23.12.2015 12:58

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


        1. igor_suhorukov
          23.12.2015 11:43

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


  1. Agb
    23.12.2015 08:18

    Думаю, вам так же будет интересен такой подход habrahabr.ru/company/haulmont/blog/248981


    1. 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


  1. gurinderu
    23.12.2015 10:29

    igor_suhorukov
    Зачем использовать вместо, когда можно использовать вместе ?)


    1. igor_suhorukov
      23.12.2015 10:37
      +1

      Причины в статье и в коментариях. Вместе конечно лучше


  1. tinhol
    23.12.2015 15:06
    +1

    Как автор похожего решения хотел бы задать вам несколько вопросов.

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


    1. 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. Не пробовал


  1. tinhol
    23.12.2015 19:53
    +1

    Спасибо за ответ.

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


    1. igor_suhorukov
      23.12.2015 20:21

      Интересный опыт!


  1. guai
    29.12.2015 11:29

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


    1. igor_suhorukov
      29.12.2015 21:18
      +1

      MVEL2 тоже похож на java, но не java и тоже менее навороченный