Введение


Данная статья не история успеха, а скорее руководство «как не надо делать». Весной 2020 для поддержания спортивного тонуса участвовал в студенческом хакатоне (спойлер: заняли 2-е место). Удивительно, но задача из полуфинала оказалась более интересной и сложной чем финальная. Как вы поняли, о ней и своём решении расскажу под катом.


Задача


Данный кейс был предложен Deutsche Bank в направлении WEB-разработка.
Необходимо было разработать онлайн-редактор для проекта Алгосимулятор – тестового стенда для проверки работы алгоритмов электронной торговли на языке Java. Каждый алгоритм реализуется в виде наследника класса AbstractTradingAlgorythm.


AbstractTradingAlgorythm.java
public abstract class AbstractTradingAlgorithm {

    abstract void handleTicker(Ticker ticker) throws Exception;

    public void receiveTick(String tick) throws Exception {
        handleTicker(Ticker.parse(tick));
    }

    static class Ticker {
        String pair;
        double price;

       static Ticker parse(String tick) {
           Ticker ticker = new Ticker();
           String[] tickerSplit = tick.split(",");
           ticker.pair = tickerSplit[0];
           ticker.price = Double.valueOf(tickerSplit[1]);
           return ticker;
       }

    }

}

Сам же редактор во время работы говорит тебе три вещи:


  1. Наследуешь ли ты правильный класс
  2. Будут ли ошибки на этапе компиляции
  3. Успешен ли тестовый прогон алгоритма. В данном случае подразумевается, что "В результате вызова new <ClassName>().receiveTick(“RUBHGD,100.1”) отсутствуют runtime exceptions".


Ну окей, скелет веб-сервиса через spring накидать дело на 5-10 минут. Пункт 1 — работа для регулярных выражений, поэтому даже думать об этом сейчас не буду. Для пункта 2 можно конечно написать синтаксический анализатор, но зачем, когда это уже сделали за меня. Может и пункт 3 получится сделать, использовав наработки по пункту 2. В общем, дело за малым, уместить в один метод, ну например, компиляцию исходного кода программы на Java, переданного в контроллер строкой.


Решение


Здесь и начинается самое интересное. Забегая вперёд, как сделали другие ребята: установили на машину джаву, отдавали команды на ось и грепали stdout. Конечно, это более универсальный метод, но во-первых, нам сказали слово Java, а во-вторых...



… у каждого свой путь.


Естественно, Java окружение устанавливать и настраивать всё же придётся. Правда компилировать и исполнять код мы будем не в терминале, а, как бы это ни звучало, в коде. Начиная с 6 версии, в Java SE присутствует пакет javax.tools, добавленный в стандартный API для компиляции исходного кода Java.
Теперь привычные понятия такие, как файлы с исходным кодом, параметры компилятора, каталоги с выходными файлами, сообщения компилятора, превратились в абстракции, используемые при работе с интерфейсом JavaCompiler, через реализации которого ведётся основная работа с задачами компиляции. Подробней о нём можно прочитать в официальной документации. Главное, что оттуда сейчас перейдёт моментально в текст статьи, это класс JavaSourceFromString. Дело в том, что, по умолчанию, исходный код загружается из файловой системы. В нашем же случае исходный код будет приходить строкой извне.


JavaSourceFromString.java
import javax.tools.SimpleJavaFileObject;
import java.net.URI;

public class JavaSourceFromString extends SimpleJavaFileObject {
    final String code;

    public JavaSourceFromString(String name, String code) {
        super(URI.create("string:///" + name.replace('.', '/') + Kind.SOURCE.extension), Kind.SOURCE);
        this.code = code;
    }

    @Override
    public CharSequence getCharContent(boolean ignoreEncodingErrors) {
        return code;
    }
}

Далее, в принципе уже ничего сложного нет. Получаем строку, имя класса и преобразуем их в объект JavaFileObject. Этот объект передаём в компилятор, компилируем и собираем вывод, который и возвращаем на клиент.


Сделаем класс Validator, в котором инкапсулируем процесс компиляции и тестового прогона некоторого исходника.


public class Validator {
    private JavaSourceFromString sourceObject;

    public Validator(String className, String source) {
        sourceObject = new JavaSourceFromString(className, source);
    }
}

Далее добавим компиляцию.


public class Validator {
    ...
    public List<Diagnostic<? extends JavaFileObject>> compile() {
        // получаем компилятор, установленный в системе
        var compiler = ToolProvider.getSystemJavaCompiler();

        // компилируем
        var compilationUnits = Collections.singletonList(sourceObject);
        var diagnostics = new DiagnosticCollector<JavaFileObject>();
        compiler.getTask(null, null, diagnostics, null, null, compilationUnits).call();

        // возворащаем диагностику
        return diagnostics.getDiagnostics();
    }
}

Пользоваться этим можно как-то так.


public void TestTradeAlgo() {
        var className = "TradeAlgo";
        var sourceString = "public class TradeAlgo extends AbstractTradingAlgorithm{\n" +
                "@Override\n" +
                "    void handleTicker(Ticker ticker) throws Exception {\n" +
                "       System.out.println(\"TradeAlgo::handleTicker\");\n" +
                "    }\n" +
                "}\n";
        var validator = new Validator(className, sourceString);
        for (var message : validator.compile()) {
            System.out.println(message);
        }
    }

При этом, если компиляция прошла успешно, то возвращённый методом compile список будет пуст. Что интересно? А вот что.



На приведённом изображении вы можете видеть директорию проекта после завершения выполнения программы, во время выполнения которой была осуществлена компиляция. Красным прямоугольником обведены .class файлы, сгенерированные компилятором. Куда их девать, и как это чистить, не знаю — жду в комментариях. Но что это значит? Что скомпилированные классы присоединяются в runtime, и там их можно использовать. А значит, следующий пункт задачи решается тривиально с помощью средств рефлексии.


Создадим вспомогательный POJO для хранения результата прогона.


TestResult.java
public class TestResult {
    private boolean success;
    private String comment;

    public TestResult(boolean success, String comment) {
        this.success = success;
        this.comment = comment;
    }

    public boolean success() {
        return success;
    }

    public String getComment() {
        return comment;
    }
}

Теперь модифицируем класс Validator с учётом новых обстоятельств.


public class Validator {
    ...
    private String className;
    private boolean compiled = false;

    public Validator(String className, String source) {
        this.className = className;
        ...
    }

    ...

    public TestResult testRun(String arg) {
        var result = new TestResult(false, "Failed to compile");
        if (compiled) {
            try {
                // загружаем класс
                var classLoader = URLClassLoader.newInstance(new URL[]{new File("").toURI().toURL()});
                var c = Class.forName(className, true, classLoader);
                // создаём объект класса
                var constructor = c.getConstructor();
                var instance = constructor.newInstance();
                // выполняем целевой метод
                c.getDeclaredMethod("receiveTick", String.class).invoke(instance, arg);
                result = new TestResult(true, "Success");
            } catch (IllegalAccessException | InvocationTargetException | NoSuchMethodException | ClassNotFoundException | RuntimeException | MalformedURLException | InstantiationException e) {
                var sw = new StringWriter();
                e.printStackTrace(new PrintWriter(sw));
                result = new TestResult(false, sw.toString());
            }
        }
        return result;
    }
}

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


public void TestTradeAlgo() {
        ...
        var result = validator.testRun("RUBHGD,100.1");
        System.out.println(result.success() + " " + result.getComment());
    }

Вставить этот код в реализацию API контроллера — задача нетрудная, поэтому подробности её решения можно опустить.


Какие проблемы?


  1. Ещё раз напомню про кучу .class файлов.


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


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



Поэтому делать в точности как я не надо)


P.S. Ссылка на гитхаб с исходным кодом из статьи.