Тривиальная и неправильная «облачная» компиляция


    Введение


    Данная статья не история успеха, а скорее руководство «как не надо делать». Весной 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. Ссылка на гитхаб с исходным кодом из статьи.

    Комментарии 9

      +3
      Простите, но хакатон?

      Идея положить в файлик и вызвать компилятор появляется через минуту и пишется минут за 15.
      Идея погуглить а можно ли без файлика появляется сразу после написания версии с файликом и гуглится за минуту. Первые же ссылки в гугле ведут куда надо «java compile code from string»

      Даже для собеседования слабовато. Студенты посерьезнее вещи могут написать для хакатона.
        0
        >появляется через минуту и пишется минут за 15.
        А впервые написана наверное лет 10 назад, не меньше. JSR 223 ключевое слово, реализован в 2006, и движок для самого языка java существовал по-моему с самого начала. Т.е. я согласен, это странная задача — реализовать то, что делается много лет вполне стандартными средствами.
          0

          Согласен, что приведённый код — это далеко не rocket science. Но, мне кажется, Вы не учитываете некоторые моменты. Статья покрывает реализацию только одного модуля требуемого решения. Помимо этого, там ещё надо было сделать портал с авторизацией, хранилищем, ролевой моделью и тд и тп. К тому же, на любом хакатоне реализовать решение — только половина дела. Примерно столько же времени уходит на создание презентации и подготовку к защите. Да и вообще, Java — это не моя специализация, с этим языком я познакомился в рамках семестрового курса в университете. Так что не всё в этом мире так однозначно)

            0

            Всякий типовой энтерпрайзный бойлерплейт писать совсем не интересно. И это тоже не рокетсаенс.


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

          +1
          Я довольно давно пробовал писать что-то подобное — и основные проблемы были как раз с «скомпилированные классы присоединяются в runtime, и там их можно использовать». После загрузки первой версии скомпилированного класса она кэшировалась и следующие уже не загружались. Уже не помню как это решилось — по-моему, пришлось написать собственный загрузчик классов.
            0

            Круто! Было бы интересно почитать про данный опыт

              0
              Это было еще во времена Java 2 ;) чисто в порядке эксперимента. На StackOverflow и в форумах есть много постов на тему dynamic loading/unloading classes. Единственное что я помню точно — пока последний обьект класса не будет разрушен сборщиком мусора, класс вы не переопределите (так как ссылка на него есть в обьекте).
                0

                Спасибо! Покопаюсь на досуге)

            0

            Что все так плохо с названиями-то. Почему compile и testRun в классе Validator. Если он Validator логично что у него будет функция validate или isValid. но compile и testRun, кто может себе такое представить

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

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