Запускаем однофайловые программы в Java 11 без компилирования

Автор оригинала: Mohamed Taman
  • Перевод
  • Tutorial


Пусть исходный файл HelloUniverse.java содержит определение класса и статичный метод main, который выводит в терминал одну строку текста:

public class HelloUniverse{
      public static void main(String[] args) { 
            System.out.println("Hello InfoQ Universe");
      }
}

Обычно для запуска этого класса требуется сначала скомпилировать его с помощью Java-компилятора (javac), который создаст файл HelloUniverse.class:

mohamed_taman$ javac HelloUniverse.java

Затем нужно с помощью команды виртуальной машины Java (интерпретатора) запустить получившийся файл:

mohamed_taman$ java HelloUniverse
Hello InfoQ Universe

Тогда сначала запустится виртуалка, которая загрузит класс и исполнит код.

А если вам нужно быстро проверить фрагмент кода? Или вы новичок в Java (в данном случае это ключевой момент) и хотите поэкспериментировать с языком? Описанные два этапа могут всё усложнить.

В Java SE 11 можно напрямую запускать одиночные исходные файлы без промежуточной компиляции.

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

Профессионалы могут с помощью этих инструментов изучать нововведения в языке или тестировать незнакомые API. На наш взгляд, лучше автоматизировать многие задачи, вроде написания Java-программ в виде скриптов с последующим исполнением из оболочки ОС. В результате мы можем гибко работать с shell-скриптами и пользоваться всеми возможностями Java. Поговорим об этом подробнее во второй части статьи.

Эта прекрасная возможность Java 11 позволяет напрямую исполнять одиночный исходный файл без компилирования. Давайте обсудим.

Что вам потребуется


Для запуска кода, приведённого в статье, вам понадобится версия Java не ниже 11. На момент написания статьи текущим релизом был Java SE Development Kit 12.0.1 — финальная версия находится здесь, достаточно принять условия лицензии и кликнуть на ссылку для вашей ОС. Если хотите поэкспериментировать с самыми свежими возможностями, то можете скачать JDK 13 early access.

Обратите внимание, что сейчас также доступны релизы OpenJDK разных вендоров, в том числе AdoptOpenJDK.

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

Запускаем .java с помощью Java


Функция JEP 330 (запуск однофайловых программ с исходным кодом) появилась в JDK 11. Она позволяет напрямую исполнять исходные файлы с исходным Java-кодом, без использования интерпретатора. Исходный код компилируется в памяти, а затем исполняется интерпретатором без создания на диске .class-файла.

Однако эта функция ограничена кодом, который хранится в одном файле. Вы не можете исполнять сразу несколько исходных файлов.

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

Первый класс, определённый в файле, будет считаться основным, и в него нужно поместить метод main. То есть важна очерёдность.

Первый пример


Начнём с классического простейшего примера — Hello Universe!

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

Создайте файл HelloUniverse.java с кодом из начала статьи, скомпилируйте и запустите получившийся class-файл. Затем удалите его, сейчас поймёте зачем:

mohamed_taman$ rm HelloUniverse.class

Если теперь с помощью Java-интерпретатора вы запустите class-файл без компиляции:

mohamed_taman$ java HelloUniverse.java
Hello InfoQ Universe

то увидите тот же результат: файл будет исполнен.

Это означает, что теперь можно просто выполнить java HelloUniverse.java. Мы передаём сам исходный код, а не class-файл: система внутри себя компилирует его, запускает и показывает в консоли сообщение.

То есть под капотом всё же выполняется компиляция. И в случае её ошибки мы получим уведомление об этом. Можете проверить структуру директорий и убедиться, что class-файл не генерируется, компиляция выполняется в памяти.

Теперь давайте разберёмся, как это всё устроено.

Как интерпретатор Java выполняет программу HelloUniverse


В JDK 10 модуль запуска Java может работать в трёх режимах:

  1. Исполнение class-файла.
  2. Исполнение основного класса из JAR-файла.
  3. Исполнение основного класса модуля.

А в Java 11 появился четвёртый режим:

  1. Исполнение класса, объявленного в исходном файле.

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

Система определяет ваше намерение ввести исходный файл по двум признакам:

  1. Первый элемент в командной строке не является ни опцией, ни частью опции.
  2. В строке может присутствовать опция --source <vеrsion>.

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

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

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

Это важно в случаях, когда исходный файл представляет из себя «скрипт», который нужно выполнить, а имя файла не соответствует обычным соглашениям о наименованиях исходных файлов с Java-кодом.

С помощью опции --source можно определять версию языка исходника. Об этом мы поговорим ниже.

Можно ли передавать в командной строке аргументы?


Давайте расширим нашу программу Hello Universe, чтобы она выводила персональное приветствие любому пользователю, зашедшему на InfoQ Universe:

public class HelloUniverse2{
    public static void main(String[] args){
        if ( args == null || args.length< 1 ){
System.err.println("Name required");
System.exit(1);
        }
  var name = args[0];
  System.out.printf("Hello, %s to InfoQ Universe!! %n", name);
    }
}

Сохраним код в файле Greater.java. Обратите внимание, что имя файла не соответствует имени публичного класса. Это нарушает правила спецификации Java.

Запустим код:

mohamed_taman$ java Greater.java "Mo. Taman"
Hello, Mo. Taman to InfoQ universe!!

Как видите, совершенно не важно, что имена класса и файла не совпадают. Внимательный читатель мог также заметить, что мы передали в код аргументы после обработки имени файла. Это означает, что любой аргумент в командной строке, идущий после имени файла, передаётся стандартному основному методу.

Определяем уровень исходного кода с помощью опции --source


Есть два сценария использования опции --source:

  1. Определение уровня исходного кода.
  2. Принудительный перевод runtime-среды Java в режим работы с исходным файлом.

В первом случае, если вы не указали уровень исходного кода, за него принимается текущая версия JDK. А во втором случае файлы с расширениями, отличными от .java, можно передавать для компилирования и выполнения на лету.

Давайте сначала рассмотрим второй сценарий. Переименуем Greater.java просто в greater без расширения и попробуем выполнить:

mohamed_taman$ java greater "Mo. Taman"
Error: Could not find or load main class greater
Caused by: java.lang.ClassNotFoundException: greater

При отсутствии расширения .java интерпретатор команд ищет скомпилированный класс по имени, переданному в виде аргумента — это первый режим работы модуля запуска Java. Чтобы это не происходило, воспользуемся опцией --source для принудительного переключения в режим работы с исходным файлом:

mohamed_taman$ java --source 11 greater "Mo. Taman"
Hello, Mo. Taman to InfoQ universe!!

Теперь перейдём к первому сценарию. Класс Greater.java совместим с JDK 10, поскольку содержит ключевое слово var, но не совместим с JDK 9. Изменим source на 10:

mohamed_taman$ java --source 10 Greater.java "Mo. Taman"
Hello Mo. Taman to InfoQ universe!!

Снова запустим предыдущую команду, но в этот раз передадим --source 9 вместо 10:

mohamed_taman$ java --source 9 Greater.java "Mo. Taman"
Greater.java:8: warning: as of release 10, 'var' is a restricted local variable type and cannot be used for type declarations or as the element type of an array
var name = args[0];
            ^
Greater.java:8: error: cannot find symbol
var name = args[0];
        ^
  symbol:   class var
  location: class HelloWorld
1 error
1 warning
error: compilation failed

Обратите внимание: компилятор предупреждает о том, что var стала в JDK 10 ограниченным именем типа. Но поскольку у нас язык уровня 10, компиляция продолжается. Однако возникает сбой, потому что в исходном файле нет типа с именем var.

Всё просто. Теперь рассмотрим использование нескольких классов.

Работает ли этот подход с несколькими классами?


Да, работает.

Рассмотрим пример с двумя классами. Код проверяет, является ли заданное строковое значение палиндромом.

Вот код, сохранённый в файле PalindromeChecker.java:

import static java.lang.System.*;
public class PalindromeChecker {
      public static void main(String[] args) {
            
            if ( args == null || args.length< 1 ){
                err.println("String is required!!");
                exit(1);
            }
            out.printf("The string {%s} is a Palindrome!! %b %n",
                  args[0],
                  StringUtils
                        .isPalindrome(args[0]));            
      }
}
public class StringUtils {
      public static Boolean isPalindrome(String word) {
      return (new StringBuilder(word))
            .reverse()
            .toString()
            .equalsIgnoreCase(word);
      }
}

Запустим файл:

mohamed_taman:code$ java PalindromeChecker.java RediVidEr
The string {RediVidEr} is a Palindrome!! True

Запустим снова, подставив «RaceCar» вместо «MadAm»:

mohamed_taman:code$ java PalindromeChecker.java RaceCar
The string {RaceCar} is a Palindrome!! True

Теперь подставим «Mohamed» вместо «RaceCar»:

mohamed_taman:code$ java PalindromeChecker.java Taman
The string {Taman} is a Palindrome!! false

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

Можно использовать модули?


Да, никаких ограничений. Скомпилированный в памяти код запускается как часть безымянного модуля с опцией --add-modules=ALL-DEFAULT, которая даёт доступ ко всем модулям, поставляемым с JDK.

То есть код может использовать разные модули без необходимости явно определять зависимости с помощью module-info.java.

Давайте рассмотрим код, делающий HTTP-вызов с помощью нового HTTP Client API, появившегося в JDK 11. Обратите внимание, что эти API были представлены в Java SE 9 в качестве экспериментальной возможности, но теперь они имеют статус полноценной функции модуля java.net.http.

В этом примере мы вызовем простой REST API с помощью метода GET, чтобы получить список пользователей. Обратимся к публичному сервису reqres.in/api/users?page=2. Код сохраним в файл с именем UsersHttpClient.java:

import static java.lang.System.*;
import java.net.http.*;
import java.net.http.HttpResponse.BodyHandlers;
import java.net.*;
import java.io.IOException;

public class UsersHttpClient{
    public static void main(String[] args) throws Exception{
var client = HttpClient.newBuilder().build(); 
var request = HttpRequest.newBuilder()
.GET()
.uri(URI.create("https://reqres.in/api/users?page=2"))
.build();

var response = client.send(request, BodyHandlers.ofString());
out.printf("Response code is: %d %n",response.statusCode());
out.printf("The response body is:%n %s %n", response.body());     
    }
}

Запустим программу и получим результат:

mohamed_taman:code$ java UsersHttpClient.java
Response code is: 200
The response body is:
{"page":2,"per_page":3,"total":12,"total_pages":4,"data":[{"id":4,"first_name":"Eve","last_name":"Holt","avatar":"https://s3.amazonaws.com/uifaces/faces/twitter/marcoramires/128.jpg"},{"id":5,"first_name":"Charles","last_name":"Morris","avatar":"https://s3.amazonaws.com/uifaces/faces/twitter/stephenmoon/128.jpg"},{"id":6,"first_name":"Tracey","last_name":"Ramos","avatar":"https://s3.amazonaws.com/uifaces/faces/twitter/bigmancho/128.jpg"}]}

Теперь вы можете быстро тестировать новые фичи, предоставляемые разными модулями, не создавая свой собственный модуль.

Почему скрипты так важны в Java?


Сначала давайте вспомним, что такое скрипты:

Скрипт — это программа, написанная для определённого runtime-окружения, которая автоматизирует исполнение задач или команд, которые человек может исполнять поочерёдно.

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

Скриптовый язык использует серии команд, записанных в файле. Часто такие языки являются интерпретируемыми (а не компилируемыми) и придерживающимися процедурного стиля программирования (хотя некоторые скриптовые языки также обладают свойствами объектно-ориентированных языков).

В целом скриптовые языки легче в освоении и быстрее в наборе кода по сравнению с более структурированными компилируемыми языками вроде Java, C и С++. К серверным скриптовым языкам относятся Perl, PHP и Python, а на клиентской стороне — JavaScript.

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

Тем не менее, Java уже исполнилось 24 года, его использует около 10 млн разработчиков по всему миру. В последних релизах добавили ряд новых возможностей, чтобы молодым программистам было легче изучать этот язык, а также чтобы пользоваться функциями языка и API без компилирования и IDE. Например, в Java SE 9 появился инструмент JShell (REPL), который поддерживает интерактивное программирование.

А с выходом JDK 11 этот язык получил возможность поддержки скриптов, поскольку теперь вы можете исполнять код с помощью простого вызова команды java!

В Java 11 есть два основных способа использования скриптов:

  1. Прямой вызов команды java.
  2. Применение *nix-скриптов для командной строки, аналогичных Bash-скриптам.

Первый вариант мы уже рассмотрели, теперь разберёмся со вторым. Он открывает нам много возможностей.

Shebang-файлы: запускаем Java как shell-скрипт


Итак, в Java SE 11 появилась поддержка скриптов, включая традиционные shebang-файлы из мира *nix. Для их поддержки не потребовалось спецификации языка.

В shebang-файле первые два байта должны быть 0x23 и 0x21. Это ASCII-кодировка символов #!.. Все последующие байты в файле читаются на основе действующей по умолчанию на данной платформе системы кодировки.

Таким образом, чтобы файл исполнился с помощью встроенного в ОС shebang-механизма, необходимо соблюсти лишь одно требование: чтобы первая строка начиналась с #!.. Это означает, что нам не нужна какая-то особенная первая строка, когда модуль запуска Java явно используется для запуска кода из исходного файла, как в случае с HelloUniverse.java.

Запустим следующий пример в терминале, работающем под macOS Mojave 10.14.5. Но сначала определим важные правила, которым нужно следовать при создании shebang-файла:

  • Не смешивать Java-код с кодом скриптового языка оболочки вашей ОС.
  • Если вам нужно добавить опции виртуальной машины, необходимо после имени исполняемого файла в shebang-файле первой опцией задать --source. К опциям виртуальной машины относятся: --class-path, --module-path, --add-exports, --add-modules, --limit-modules, --patch-module, --upgrade-module-path, а также любые их вариации. Также в этот список могут включить новую опцию --enable-preview, описанную в JEP 12.
  • Вы должны задать версию Java, которая используется в исходном файле.
  • Первая строка файла должна начинаться с shebang-символов (#!). Например:
    #!/path/to/java --source <vеrsion>
  • Применительно к исходным Java-файлам НЕЛЬЗЯ использовать shebang-механизм для исполнения файлов, которые соответствуют стандартному соглашению о наименованиях (заканчиваются на .java)
  • Вы должны пометить файл как исполняемый с помощью команды:
    chmod +x <Filеname>.<Extеnsion>.

Создадим shebang-файл (скриптовую программу), который выведет список содержимое директории, чьё имя будет передано в качестве параметра. Если никаких параметров не передаётся, по умолчанию будет взята текущая директория.

#!/usr/bin/java --source 11
import java.nio.file.*;
import static java.lang.System.*;

public class DirectoryLister {
      public static void main(String[] args) throws Exception {
            vardirName = ".";

            if ( args == null || args.length< 1 ){
err.println("Will list the current directory");
            } else {
                  dirName = args[0];
            }

            Files
            .walk(Paths.get(dirName))
            .forEach(out::println);       
      }
}

Сохраним код в файл с именем dirlist без расширения, а затем пометим его как исполняемый: mohamed_taman:code$ chmod +x dirlist.

Запустим файл:

mohamed_taman:code$ ./dirlist
Will list the current directory
.
./PalindromeChecker.java
./greater
./UsersHttpClient.java
./HelloWorld.java
./Greater.java
./dirlist

Запустим снова с помощью команды, которая передаёт родительскую директорию, и проверим результат.

mohamed_taman:code$ ./dirlist ../

Примечание: при оценке исходного кода интерпретатор игнорирует shebang-строку (первую строку). Таким образом, shebang-файл можно явно вызвать с помощью модуля запуска, например, с дополнительными опциями:

$ java -Dtrace=true --source 11 dirlist

Также нужно отметить: если скриптовый файл лежит в текущей директории, то вы можете выполнить его так:

$ ./dirlist

А если скрипт лежит в директории, путь которой указан в пользовательском PATH, то выполнить его можно так:

$ dirlist

И в завершение дам несколько советов, о чём нужно помнить при использовании скриптов.

Советы


  1. Некоторые опции, которые вы будете передавать в javac, могут не передаться (или не распознаться) java, например, опции -processor или -Werror.
  2. Если в classpath есть файлы .class и .java, то модуль запуска заставит вас использовать class-файл.

    mohamed_taman:code$ javac HelloUniverse.java
    mohamed_taman:code$ java HelloUniverse.java
    error: class found on application class path: HelloUniverse

  3. Помните о возможности конфликта имён класса и пакета. Взгляните на эту структуру директорий:

    mohamed_taman:code$ tree
    .
    ├── Greater.java
    ├── HelloUniverse
    │   ├── java.class
    │   └── java.java
    ├── HelloUniverse.java
    ├── PalindromeChecker.java
    ├── UsersHttpClient.java
    ├── dirlist
    └── greater

    Обратите внимание на два файла java.java в пакете HelloUniverse и файл HelloUniverse.java в той же директории. Если вы попробуете выполнить:

    mohamed_taman:code$ java HelloUniverse.java

    то какой файл будет выполнен первым, а какой вторым? Модуль запуска больше не ссылается на class-файл в пакете HelloUniverse. Вместо этого он загрузит и выполнит исходный файл HelloUniverse.java, то есть будет запущен файл в текущей директории.

Shebang-файлы открывают массу возможностей по созданию скриптов для автоматизации всевозможных задач с использованием средств Java.

Резюме


Начиная с Java SE 11 и впервые в истории программирования вы можете напрямую исполнять скрипты с Java-кодом без компилирования. Это позволяет писать скрипты на Java и исполнять их из *nix-командной строки.

Поэкспериментируйте с этой функцией и поделитесь знанием с другими.

Полезные источники


NIX
Компания

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

    +1

    Я так полагаю, если фича зайдет, то следующей итерацией будет встроить в Java простенький dependency manager типа Grape в Groovy.

      0

      Да, было бы круто. Я пока выкручивался используя gradle и писал скриптовую логику в build.gradle файле. Запускал из gradlew.
      Всё в принципе работало, но приходилось несколько доп файлов тянуть, помимо самого скрипта, — jar, property и скрипт враппера.

      0
      Запуск исходника это, конечно, круто, но если использовать IDE, например eclipse, то, в принципе, о сборке не думаешь. Просто запускаешь и все.
      И если тут умные люди, верно ли что модули это какая-то замена мавену?
        0

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


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


        UPD: Кажется, это было здесь, по ссылке "Modules in One Lesson".

        0
        Я тоже заметил, что можно исполнять файл с несколькими публичными классами. Но так и не понял, зачем это нужно. Если файл один, то всё и так находится в одном пакете, и public/не-public уже не имеет значения.

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

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