Java Script != JavaScript. Пять джав в одном классе. Скриптуем так, чтобы запомнили навсегда

habr.png


На этой неделе у JUG.ru Group, скорее всего, выйдет анонс. Пока не скажу чего. Участие в тайных проектах будит креатив, поэтому вот вам очередной ночной видосик про джаву.

Невероятные новости: теперь он не полтора часа длиной, а около 20 минут, и там даже есть что смотреть. Чуть менее чем полностью он состоит из скринкаста. Кто на дух не переносит этой видеодряни и любит потреблять текстовые расшифровки, пришлось запилить много текста после ката. Вэлкам, и да пребудет с вами Джава.
Скоро выйдет 12 джава, а многие всё ещё сидят на Cемёрочке и считают, что в случае апгрейда ничего особо нового или интересного они не увидят. В этом суперкоротком выпуске мы научимся превращать жизнь наших коллег в ад с помощью скриптовой джавы, и в паре мест я вверну новые примочки. Ну и мракобесы, конечно же, от прогресса не спасутся.

Скажите, зачем вы обновляете свою Идею? Там постоянно вылезают новые фичи, какие-то новые шорткаты, в десять раз повышающие вашу продуктивность как разработчика. Но вы то о них не знаете. Вы действительно читаете эти новости? Если вы — среднестатистический пользователь, то, наверное, нет. Вам, в принципе, большую часть времени — наплевать. Вы обновляете Идею просто потому что… можете. Потому что скинчики красивые, тёмная тема, тачбар на Макбуке. Но такое объяснение не проканает перед начальством как ответ «зачем покупать Идею».

Вместо этого можно сказать, что 29 октября, совсем недавно, JetBrains запилил поддержку сырых строк в бета-версии Идеи. Идея в бете, строки — в превью, но это уже никому не важно. Если вы уж настолько упоротый, что поставили 12 джаву, то у вас есть проблемы посерьезней. По себе знаю.

Давайте посмотрим, как это выглядит на практике. Для этого попробуем решить какую-нибудь демонстрационную задачу. Довольно часто возникает проблема что-нибудь заскриптовать. Например, в компьютерных играх это квесты, в дженкинсах это билд-скрипты, и так далее. Обычно для этого используют Пайтон или Груви, а давайте возьмём и заиспользуем голую джаву! Почему, зачем? Потому что можем, в три строчки, и даже без хаков. Звучит как отличная идея :-)


Всё есть на Гитхабе.
Представим, что у нас есть файл типа такого:

package io.javawatch;

public class HelloHabrPrototype {
    public String get() {
        return "Hello Habr!";
    }
};


Мы хотим, чтобы он выполнялся не при компиляции всего приложения, а как строка — уже после запуска. Как скрипт, то есть.
Вначале нужно перегнать всё в строку.


    private static final String SOURCE = "\n" +
            "    package io.javawatch;\n" +
            "    public class HelloHabr {\n" +
            "        public String get() {\n" +
            "             return \"Hello Habr!\";\n" +
            "        }\n" +
            "    };";
    public static final String CNAME = "io.javawatch.HelloHabr";
    private static String CODE_CACHE_DIR = "C:/temp/codeCache";


Все эти »\n», плюсики и отступы выглядят крайне убого.

Раньше всё что мы могли сделать — положить код в файл и прочитать его. Наверное, это хорошее решение, но оно подходит не всегда. Например, ты — докладчик на конференции и демонстрируешь код со слайдов. Даже вышеприведённая конструкция куда лучше, чем просто голословная отсылка, что у тебя где-то там есть код, который что-то там делает. Переключение слайдов жрёт время и внимание слушателей. Ну и так далее. Короче, кейсы, когда нужен код именно инлайном, придумать можно.

Теперь у нас есть возможность избавиться от мусора с помощью сырых строк. Встаем курсором на код, жмём Alt+Enter (или что там на вашей операционке запускает Quck Fix в Идее). Выбираем «convert to raw string literal» и получаем вот такую няшку:


    private static final String SOURCE = `
    package io.javawatch;
    public class HelloHabr {
        public String get() {
             return "Hello Habr!";
        }
    };`;
    public static final String CNAME = "io.javawatch.HelloHabr";
    private static String CODE_CACHE_DIR = "C:/temp/codeCache";


По-моему, ради этой фичи уже стоит бежать устанавливать JDK 12.

Кстати, чтобы фича заработала, нужно будет проделать несколько действий:

  • Скачать JDK 12 и прописать в настройках проекта
  • В глобальных настройках javac выставить флаг --release, версию байткода 12, дополнительные флаги --enable-preview -Xlint:preview
  • Теперь в любой run/debug конфигурации нужно добавлять VM-флаг --enable-preview


Если не врубились, как это делается, смотрите мой скринкаст из шапки поста, там всё довольно наглядно.

Теперь для запуска нужно выполнить три нехитрых шага: превратить строку в удобную внутреннюю репрезентацию исходника, скомпилировать его и запустить:

    public static void  main(String[] args) throws Exception {
        /* 1 */ RuntimeSource file = RuntimeSource.create(); //SimpleJavaFileObject
        /* 2 */ compile(Collections.singletonList(file));
        /* 3 */ String result = run();
        /* 4 */ /* ??? */
        /* 5 */ /* PROFIT! */ System.out.println(result);
    }


Для «удобной репрезентации» существует класс SimpleJavaFileObject, но есть у него одна любопытная особенность. Оно костыльно абстрактный. То есть его ключевой метод, который должен возвращать компилируемый исходник, всегда кидает экзепшен в надежде, что мы его засабклассим:

    /**
     * This implementation always throws {@linkplain
     * UnsupportedOperationException}.  Subclasses can change this
     * behavior as long as the contract of {@link FileObject} is
     * obeyed.
     */
    public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException {
        throw new UnsupportedOperationException();
    }


Так что приходится написать какого-то своего наследника. Обратите внимание, что оригинальный конструктор SimpleJavaFileObject хочет себе URI компилируемого класса, а где ж мы его возьмём? Поэтому я предлагаю просто клеить его максимально очевидным образом как здесь в функции buildURI:

    public static class RuntimeSource extends SimpleJavaFileObject {
        private String contents = null;

        @Override
        public CharSequence getCharContent(boolean ignoreEncodingErrors)
                throws IOException {
            return contents;
        }

        private static RuntimeSource create() throws Exception {
            return new RuntimeSource(CNAME, SOURCE);
        }

        public RuntimeSource(String className, String contents) throws Exception {
            super(buildURI(className), Kind.SOURCE);
            this.contents = contents;
        }

    public static URI buildURI(String className) {
        // io.javawatch.HelloHabr ->
        // string:///io/javawatch/HelloHabr.java
        URI uri = URI.create("string:///" + className.replace('.', '/') + Kind.SOURCE.extension);
        System.out.println(uri);
        return uri;
    }


Теперь переходим к компиляции:

    public static void compile(List files) throws IOException {
        File ccDir = new File(CODE_CACHE_DIR);
        if (ccDir.exists()) {
            FileUtils.deleteDirectory(ccDir);
            FileUtils.forceMkdir(ccDir);
        }

        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();

        Logger c = new Logger();
        StandardJavaFileManager fileManager = compiler.getStandardFileManager(c, Locale.ENGLISH, null);
        Iterable options = Arrays.asList("-d", CODE_CACHE_DIR,
                "--release=12", "--enable-preview", "-Xlint:preview");

        JavaCompiler.CompilationTask task = compiler.getTask(null, fileManager,
                c, options, null,
                files);

        if (task.call()) {
            System.out.println("compilation ok");
        }
    }


Обратите внимание: мы передаем для сборки четыре флага, три из которых отвечают за прописывание ровно тех же самых опций, что мы делали мышкой в настройках javac в Идее.

Ну и наконец, запускаем наш временный класс:

    public static String run() throws ClassNotFoundException, IllegalAccessException,
            InstantiationException, NoSuchMethodException, InvocationTargetException,
            MalformedURLException {

        // Загрузка класса
        File ccDir = new File(CODE_CACHE_DIR);
        ClassLoader loader = new URLClassLoader(new URL[]{ccDir.toURL()});
        var clаss = loader.loadClass("io.javawatch.HelloHabr"); // Java 10

        // Запуск метода рефлекшеном
        Object instance = clаss.getConstructor().newInstance(); // Java 9
//        Object instance = clаss.newInstance();
        Method thisMethod = clаss.getDeclaredMethod("get");
        Object result = thisMethod.invoke(instance);

        return (String) result;
    }


Обратите внимание, что var clаss = loader.loadClass удобней для написания, чем Class clаss = loader.loadClass, и не привносит никаких новых ворнингов. Ключевое слово var появилось в Десятке.

Еще обратите внимание, что clаss.newInstance() предлагается выпилить, начиная с Девятки. Он глотает исключения, что плохо. В Девятке предлагают вначале звать getConstructor, который параметризован и бросает правильные исключения.

Еще обратите внимание, что я назвал переменную словом class, но Java не стала ругаться. Домашнее задание: сделать это можно несколькими способами, надо понять, какой из них наиболее интересный и почему.

Ну в общем, и всё. Оно работает.


Тут критик воскликнет, здесь всё в чёрном цвете, ведь есть в джава-мире библиотеки, которые сами тебе скомпилируют всё в одну строчку.

Окей, давайте поглядим. Вот код на библиотеке JOOR, находящейся в топе гугла:

package io.javawatch;

import org.joor.Reflect;

import java.util.function.Supplier;

public class Automatic {
    public static void main(String[] args) {

        Supplier supplier = Reflect.compile(
                "io.javawatch.HelloHabr",
                `
                package io.javawatch;
                public class HelloHabr implements java.util.function.Supplier {
                    public String get() {
                         return "Hello Habr!";
                    }
                };`
        ).create().get();

        System.out.println(supplier.get());
    }
}


Как будто бы всё отлично. Действительно, в одну строчку, разве что пришлось саплаер притащить.

Но есть нюанс. Попробуйте вернуть «Hello Habr!» как raw string literal:

public String get() {
    return ``Hello Habr!``;
}


Всё мгновенно упадёт с ошибкой »(use --enable-preview to enable raw string literals)». Но мы же его уже включили? Да чёрта с два. Мы его включили в Идее, а JOOR собирает системным компилятором! Давайте глянем, что там внутри:

JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
ompiler.getTask(out, fileManager, null, null, null, files).call();


А как у нас было, когда мы сами звали тот же JavaCompiler?

  Iterable options = Arrays.asList("-d", CODE_CACHE_DIR,
                "--release=12", "--enable-preview", "-Xlint:preview");

        JavaCompiler.CompilationTask task = compiler.getTask(null, fileManager,
                c, options, null,
                files);


А в JOOR там просто голый null стоит вместо опций. Их даже нельзя передать внутрь!

Наверное, это хорошая идея, чтобы запушить им в JOOR такой pull request на Гитхабе. Если я сам не соберусь, вы его сделайте, ага?

А мораль простая, бойтесь опенсорцев даров приносящих. Иногда проще написать небольшую стену текста, но иметь над ней контроль.


Есть способ и не писать стену текста, и не использовать подозрительные библиотеки. Начиная с Java 9 у нас имеется интерактивный шелл (похожий на тот, что есть в Python, Ruby и других скриптовых языках), который умеет выполнять джавовские команды. Ну и конечно, он сам написан на Java и доступен в виде Java-класса. Можно создать его экземпляр и просто выполнить нужное присваивание напрямую:

public class Shell {
    public static void main(String[] args) {
        var jShell = JShell.create(); //Java 9
        jShell.eval("String result;");
        jShell.eval(`result = "Hello Habr!";`); //Java 12
        var result = jShell.variables() //Streams: Java 8, var: Java 10
            .filter((@Nullable var v) -> v.name().equals("result")) //var+lambda: Java 11
            .findAny()
            .get();
        System.out.println(jShell.varValue(result));
    }
}


Стримы появились в Java 8, и теперь их используют повсеместно, и даже не нужно самому вызывать stream(), его за тебя позовут другие. В данном случае вызов variables() возвращает именно стрим, а не ArrayList, как это сделал бы кто-то из непростого семёрочного детства. Результат стрима можно тут же влить в var.

Обратите внимание, что теперь можно писать var ещё и в параметрах лямбды. Эта возможность с нами начиная с Java 11.

В целом, это очень показательный класс. В нём используется куча фич из разных поколений Java, и всё это выглядит целостно и гармонично. Я уверен, что такие вещи будут использовать все и повсеместно, поэтому код на Java 12 будет прямо визуально отличаться от того, что мы имели в Java 7.


Мы посмотрели несколько фичей:

  • 8: Стримы (для тех, кто в танке)
  • 9: JShell и новые deprecated методы
  • 10: Ключевое слово var
  • 11: Развитие var для параметров лямбды
  • 12: Raw String Literals и их поддержка в IntelliJ IDEA


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

Если хотите больше узнать про джаву как язык, то стоит, например, смотреть на доклады Тагира Валеева. Вы его знаете как чувака из топа хаба Java на Хабре, lany. На Joker рассказывал про Amber, а на JPoint — свои знаменитые паззлеры, и так далее и тому подобное. Там и про язык Java, и про IntelliJ IDEA, всё есть. Всё это можно найти на YouTube. Собственно, из зависти к Тагиру, который может что-то рассказать про сам язык, а я — нет и получился этот пост. А еще будут новые джокеры и JPoint, но это мы потом обсудим.

Я свой моральный долг выполнил, долг перед Джавой. С моей стороны пули вылетели. А вы там, на семёрочке, у кого нет двенадцатой джавы, держитесь здесь, вам всего доброго, хорошего настроения и здоровья. Спасибо.

© Habrahabr.ru