Привет Хабр! Сегодня я хотел бы поговорить про динамическое компилирование и исполнение Java-кода, подобно скриптовым языкам программирования. В этой статье вы найдете пошаговое руководство как скомпилировать Java в Bytecode и загрузить новые классы в ClassLoader на лету.

Зачем?

В разработке все чаще возникают типовые задачи, которые можно было бы закрыть простой генерацией кода. Например, сгенерировать DTO классы по имеющейся спецификации по стандартам OpenAPI или AsyncAPI. В целом, для генерации кода нет необходимости компилировать и выполнять код в runtime, ведь можно сгенерировать исходники классов, а собрать уже вместе с проектом. Однако при написании инструментов для генерации кода, было бы не плохо покрыть это тестами. А при проверке самый очевидный сценарий: сгенерировал-скомпилировал-загрузил-проверил-удалил. И вот тут-то и возникает задача генерации и проверки кода "на лету".

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

Последовательность действий

Для выполнения Java-кода в Runtime нам потребуется:

  1. Динамически создать и сохранить наш код в .java файл.

  2. Скомпилировать исходники в Bytecode (файлы .class).

  3. Загрузить скомпилированные классы в ClassLoader.

  4. Использовать reflection api для получения методов и выполнения их.

Шаг 1. Генерация кода

Вообще для генерации исходников можно конечно просто написать текст через StringBuider в файл и быть довольным. Но мне хотелось бы показать более прикладные решения, поэтому рассмотрим вариант генерации кода с использованием пакета com.sun.codemodel, а вот тут есть неплохой туториал по этому пакету. Так же на его основе есть библиотека jsonschema2pojo для генерации кода на основе jsonschema. Итак к коду:

public void generateTestClass() throws JClassAlreadyExistsException, IOException {
  //создаем модель, это своего рода корень вашего дерева кода      
  JCodeModel codeModel = new JCodeModel();

  //определяем наш класс Habr в пакете hello
  JDefinedClass testClass = codeModel._class("hello.Habr");

  // определяем метод helloHabr
  JMethod method = testClass.method(JMod.PUBLIC + JMod.STATIC, codeModel.VOID, "helloHabr");

  // в теле метода выводим строку "Hello Habr!"
  method.body().directStatement("System.out.println(\"Hello Habr!\");");

  //собираем модель и пишем пакеты в currentDirectory
  codeModel.build(Paths.get(".").toAbsolutePath().toFile());
}

Пример выше сгенерирует класс Habr.java с одним методом:

package hello;


public class Habr {
    public static void helloHabr() {
        System.out.println("Hello Habr!");
    }
}

Шаг 2. Компиляция кода

Для компиляции в Bytecode обычно используется javac и выполняется он простой командой:

javac -sourcepath src -d build\classes hello\Habr.java

Однако, нам надо скомпилировать наш класс прямо из кода. И для этого есть библиотека компилятора, до которой можно достучаться через javax/tools/JavaCompiler. Это реализация javax/tools/Tool (которая лежит в <JDK_HOME>/lib/tools.jar). Выглядеть это будет как-то так:

Path srcPath = Paths.get("hello");

List<File> files = Files.list(srcPath)
        .map(Path::toFile)
        .collect(Collectors.toList());
//получаем компилятор
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
//получаем новый инстанс fileManager для нашего компилятора 
try(StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null)){
  //получаем список всех файлов описывающих исходники
  Iterable<? extends JavaFileObject> javaFiles = fileManager.getJavaFileObjectsFromFiles(files);
  
  DiagnosticCollector<JavaFileObject> diagnostics = new DiagnosticCollector<>();
  //заводим задачу на компиляцию
  JavaCompiler.CompilationTask task = compiler.getTask(
          null,
          fileManager,
          diagnostics,
          null,
          null,
          javaFiles
  );
  //выполняем задачу
  task.call();
  //выводим ошибки, возникшие в процессе компиляции
  for (Diagnostic diagnostic : diagnostics.getDiagnostics()) {
      System.out.format("Error on line %d in %s%n",
              diagnostic.getLineNumber(),
              diagnostic.getSource());
  }
}

Шаг 3. Загрузка и выполнение кода

Для выполнения кода нам надо загрузить его через ClassLoader и через reflection api вызвать наш метод.

//получаем ClassLoader, лучше получать лоадер от текущего класса, 
//я сделал от System только чтоб пример был рабочий
ClassLoader classLoader = System.class.getClassLoader();
//получаем путь до нашей папки со сгенерированным кодом
URLClassLoader urlClassLoader = new URLClassLoader(
        new URL[]{Paths.get(".").toUri().toURL()},
        classLoader);
//загружаем наш класс
Class<?> helloHabrClass = urlClassLoader.loadClass("hello.Habr");
//находим и вызываем метод helloHabr
Method methodHelloHabr = helloHabrClass.getMethod("helloHabr");
//в параметре передается ссылка на экземпляр класса для вызова метода 
//либо null при вызове статического метода
methodHelloHabr.invoke(null);

Итог

В этой статье я постарался показать полноценный сценарий генерации и выполнения кода в Runtime. Самому мне это пригодилось при написании unit-тестов для библиотеки по генерации DTO классов на базе документации сгенерированной библиотекой springwolf. Реализацию тестов в моем проекте можно посмотреть тут.

Комментарии (8)


  1. sparhawk
    16.09.2022 13:26
    -2

    Спасибо за труд, показали, что кодогенерация в Java — это боль. Правда, она не часто требуется. Подобный код всегда write-only, в нем не разобраться и очень сложно его развивать.

    Пока нашел только один нормальный способо кодогенерации в мире JVM — создание классов из функций Clojure через gen-class. В чем-то он даже логичен — логика пишется в виде функций Clojure, потом прикручиваем поля класса, аннотации и прочюю ненужнную с т. з. функциональщика фигню.
    Единственное, Clojure может поначалу испугать не видавшего обширный мир айти разработчика, но надо же расширять кругозор.


  1. ksbes
    16.09.2022 14:02
    +1

    1) Зачем всё это писать на диск, когда можно компилировать из памяти ?
    2) Зачем передавать удалённо код, когда можно сразу передавать удалённо байт-код? Для чего Java в общем-то и затачивалась с середины 90-х.

    Да и вообще — я написал много сотен тысяч строк (наверное — не считал) на Java и C# — и копоративные, и игровые, и банковские, и просто, но мне ни разу не пришлось сталкиваться с задачей самостоятельной кодогенерации. Точнее вру — один раз пришлось (ещё джуном), но это была генерация кода PHP на C# и TSQL (совместно) — и тот проект ожидаемо утонул не дойдя до стадии даже альфы.
    Что я делаю не так? Не самым странным образом выстраиваю процесс разработки и архитектуру?


    1. stepanovD Автор
      16.09.2022 14:16
      +1

      Спасибо за замечание. Конечно можно компилировать из памяти, но если вы хотите все таки генерировать код, который сможете глазами посмотреть и добавить в git, то без сохранения никак. А так, да, в общем случае не важно где хранится исходник.

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


      1. Artyomcool
        17.09.2022 09:40

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


  1. imanushin
    16.09.2022 18:52

    У меня была похожая статья, но с другим JVM языком - https://habr.com/ru/company/dbtc/blog/505162/ .


  1. exadmin
    17.09.2022 13:46

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


  1. kpmy
    17.09.2022 15:02

    Java Eclipse Compiler кажется справляется с этим поудобнее, даже в JRE (querydsl как пример).


  1. qideil
    18.09.2022 15:26

    Кодогенерация придумана очень давно, тем более для таких простых вещей как DTO. Тем более, когда у вас есть «стандарты». Посмотрите в сторону XSLT. Простейший DTD и шаблон в XSL решает все проблемы. Если же вам нужна версионность, и вы не хотите перезагружать JVM, то стоит посмотреть на OSGi — проверенный временем механизм. Ну или gRPC, если уж совсем нужно быстро. И да, передавать сорцы или байткод с последующей подгрузкой на сервере — огромная дыра в безопасности.