Чисто экспериментальные приёмы портирования Stream API из Java 8 на Java 6
Год назад я рассказывал о том, как с помощью Maven и Retrolambda портировать своё приложение, использующее языковые средства Java 8, а также сопутствующие «не совсем Java 8» библиотеки, на Android. К сожалению, новые Java 8 API использовать не удастся ввиду банального их отсутствия на более старой целевой платформе. Но, поскольку сама идея не покидала меня продолжительное время, мне стало интересным: можно ли портировать, например, Stream API на более старую платформу и не ограничиваться самими только возможностями языка вроде лямбда-выражений.
В конечном итоге, такая идея подразумевает следующее: как и в предыдущем случае, нужно с помощью доступных инструментов, в частности старой-доброй Retrolambda, переписать байткод Stream API таким образом, чтобы код, использующий этот API, мог работать и на старых версиях Java. Почему именно Java 6? Честно говоря, с этой версией Java я проработал дольшее время, Java 5 я не застал, а Java 7 для меня скорее как пролетела мимо.
Также сразу повторюсь, что все инструкции, приведённые в этой статье, носят чисто экспериментальный характер, и вряд ли — практический. В первую очередь из-за того, что придётся пользоваться boot-classloader-ом, что не всегда приемлимо или возможно вообще. А во-вторых, сама реализация идеи откровенно сыровата и в ней присутствует множество неудобств и не совсем очевидных подводных камней.
Инструменты
Итак, набор необходимых инструментов представлен следующими основныним пакетами:
- OpenJDK 1.8.0.45
- Apache Ant 1.9.7
- OpenJDK/JRE 1.6.0.40
И сопутствующие инструменты, вовлечённые в эксперимент:
- Retrolambda 2.3.0
- Ant Shade Task 0.1-SNAPSHOT
Помимо более старых версий OpenJDK, пример портирования будет осуществляться с помощью Ant, а не Maven. Я хоть и приверженец convention over configuration и уже лет пять-шесть не пользуюсь Ant, для решения именно этой задачи мне Ant кажется куда более удобным инструментом. В первую очередь из-за простоты, а также из-за тонкой настройки, что, по правде говоря, труднодостижимо в Maven, скорости работы и кросс-платформенности (shell-скрипты были бы ещё короче, но я также часто использую Windows без Cygwin и похожех примочек).
В качестве proof of concept будет использоваться простой пример на Stream API.
package test;
import java.util.stream.Stream;
import static java.lang.System.out;
public final class EntryPoint {
private EntryPoint() {
}
public static void main(final String... args) {
runAs("stream", () -> Stream.of(args).map(String::toUpperCase).forEach(EntryPoint::dump));
}
private static void runAs(final String name, final Runnable runnable) {
out.println("pre: " + name);
runnable.run();
out.println("post: " + name);
}
private static void dump(final Object o) {
out.println(">" + o);
}
}
Несколько слов о том, как будет проходить эксперимент. Ant-овский build.xml
разделён на множество шагов или этапов, каждому из которых в процессе портирования отведена своя собственная директория. Это, по крайней мере мне, здорово упрощает процесс поиска решения и отладки, прослеживать изменения от шага к шагу.
Процесс портирования
Шаг 0. Init
Как обычно, первым делом в Ant почти всегда идёт создание целевой директории.
Шаг 1. Grab
Крайне важной состовляющей экперимента является минимальный точный список всех классов, от которых зависит тестовый пример. К сожалению, мне не известно, можно ли это сделать проще, и я потратил довольно много времени, чтобы методом многократных повторных запусков зарегистрировать все нужные классы из JRE 8.
С другой стороны, есть некоторый смысл попробовать стянуть весь пакет java.util.stream
и потом потратить ещё больше времени на подтягивание других зависимостей (и, наверняка, обработку инструментами типа ProGuard). Но я решил пойти на другое простое ухищрение: вложенные и внутренние классы я просто копирую с помощью маски $**
. Это очень существенно экономит время и список. Некоторые классы, существовавшие и в более старых версиях Java, скорее всего, нужно будет скопировать также, поскольку в Java 8 они обрели новые возможности. Это касается, например, нового метода по-умолчанию Map.putIfAbsent(Object,Object)
, который не задействован в тесте, но требуется для его корректной работы.
Действительно, весьма впечатляющий список классов, нужный только для простых, как сперва кажется, map()
и forEach()
.
Шаг 2. Compile
Скучная компиляция тестового кода. Проще некуда.
Шаг 3. Merge
Этот шаг может показаться немного странным, поскольку он просто сливает воедино результат копирования классов из Java 8 rt.jar
и тестового примера. На самом деле это нужно для нескольких следующих шагов, которые перемещают Java-пакеты для их правильной последующей обработки.
Шаг 4. Shade
Для Maven существует один интересный плагин, который умеет перемещать пакеты, изменяя байткод class-файлов напрямую. Я не знаю, может я плохо искал в Интернете, существует ли его Ant-овский аналог, но мне не осталось ничего другого, кроме как самому написать небольшое расширение для Ant, являющееся простым адаптером для Maven-плагина с единственной возможностью: только перемещение пакетов. Другие возможности maven-shade-plugin
отсутствуют.
На этом этапе для того, чтобы дальше можно было воспользоваться Retrolambda, нужно переименовать все пакеты java.*
во что-либо типа ~.java.*
(да-да, именно «тильда» — ведь почему бы и нет?). Дело в том, что Retrolambda полагается на работу класса java.lang.invoke.MethodHandles
, который запрещает использование классов с пакетов java.*
(и sun.*
, как это есть в Oracle JDK/JRE). Поэтому временное перемещение пакетов просто явлется способом «ослепить» java.lang.invoke.MethodHandles
.
Как и в шаге №1, мне пришлось указать полный список классов по-отдельности через include-список. Если этого не сделать и опустить список полностью, shade
в класс-файлах также переместит и те классы, которые не планируется подвергать обработке. В таком случае, например, java.lang.String
станет ~.java.lang.String
(по крайней мере, это чётко видно из декомпилированных с помощью javap
классов), что сломает Retrolambda, которая просто молча перестанет преобразовавывать код и не сгенерирует ниодного класса для лямбд/invokedynamic
. Прописывать все классы в exclude-список считаю более нецелесообразным, потому что их просто сложнее искать и пришлось бы ковыряться в class-файлах с помощью javap
в поисках лишней тильды.
Небольшое отступление. Теоретически, дублирование списка в Ant можно решить с помощью элементов, поддерживающих refid
, но это не получится по нескольким причинам:
не поддерживаетrefid
в первую очередь потому, что аналог этого аттрибута просто отсутствует в Maven-реализации. И я бы хотел, чтобы две реализации были похожи друг на друга один в один. По крайней мере, сейчас.- Анатомически
и
различаются. В первом применяется, а во втором —
. Здесь, подозреваю, мой косяк, и я не слишком следовал общепринятым соглашениям. SimpleRelocator
, используемый плагином для Maven, по видимому, не поддерживает пути к класс-файлам. Поэтому во втором случае названия классов нужно прописывать формате, где разделителем является точка, а не косая черта. Ещё одна несовместимость. Конечно, можно написать свою реализацию правил перемещения, но у меня, наверняка, если бы это не противоречило никаким правилам Maven-плагина, возник бы соблазн предложить такое расширение разработчикам maven-shade-plugin. Но, имея даже минимальный опыт, могу сказать, что даже в случае положительного ответа на такой запрос, это заняло бы кучу времени. Просто экономия времени.
Так что все эти недостатки решаются, но явно не в рамках этой статьи.
Шаг 5. Unzip
Следующий шаг распаковывает JAR-файл с перемещёнными пакетами, поскольку Retrolambda может работать только с директориями.
Шаг 6. Retrolambda
Само сердце эксперимента: преобразование байткода версии 52 (Java 8) в версию 50 (Java 6). Причём из-за использованых выше ухищрений, Retrolambda (или, стало быть, JDK 8) спокойно и уже без лишних вопросов проинструментирует классы. Также обязательно нужно включить поддержку методов по-умолчанию, потому что множество нового функионала в Java 8 строится именно на них. Поскольку JRE 7 и ниже не умеет работать с такими методами, Retrolambda просто копирует реализацию такого метода для каждого класса, в котором он не был переопределён (это, кстати говоря, означает, что применять Retrolambda нужно только для связки «конечное приложение и его библиотеки», иначе скорее всего можно столкнуться с проблемой, когда реализация default-метода попросту будет отсутствовать).
Шаг 7. Zip
Собираем проинструментированную версию обратно в один файл, чтобы запустить shade-плагин в обратном направлении:
Шаг 8. Unshade
К счастью, для работы shade-плагина с перемещением в обратном направлении достаточно только двух параметров. По завершению этого этапа пакеты в приложении будут выровнены обратно, и всё, что было ~.java.*
снова станет java.*
.
Шаг 9. Unpack
В этом шаге классы просто распаковываются для последующей сборки двух отдельных JAR-файлов. Снова ничего интересного.
Шаги 10 и 11. Pack
Собираем все классы воедино, но отдельно — «новый рантайм» и само тестовое приложение. И в который раз — весьма тривиальный и неинтересный шаг.
Тестирование результата
Вот и всё. В целевой директории лежит крошечный порт небольшого аспекта из реального Stream API, и он может запуститься на Java 6! Для этого создадим ещё одно правило для Ant-а:
И вот тут нужно обратить просто особое внимание на использование не совсем стандартного -Xbootclasspath/p
. Вкратце, его суть заключается в следующем: он позволяет JVM указать, откуда нужно загружать базовые классы в первую очередь. При этом, остальные классы из оригинального rt.jar будут лениво загружаться из $JAVA_HOME/jre/lib/rt.jar
по мере необходимости. Убедиться в этом можно, используя ключ -verbose:class
при запуске JVM.
Запуск самого примера также требует переменной окружения JDK_6_HOME
, указывающей на JDK 6 или JRE 6. Теперь при вызове run-as-java-6
результат успешного портирования будет выведен на стандартный вывод:
PRE: stream
>FOO
>BAR
>BAZ
POST: stream
Работает? Да!
Заключение
Привыкнув в написанию кода на Java 8, хочется, чтобы этот код работал и на более старых версиях Java. Особенно, если в наличии есть довольно старая и увесистая кодовая база. И если в Интернете часто можно увидеть вопрос о том, существует ли вообще возможность работать именно со Stream API на более старых версиях Java, всегда скажут, что нет. Ну, почти что нет. И будут правы. Конечно, предлагаются альтернативные библиотеки со схожим функционалом, работающие на старых JRE. Мне лично больше всего импонирует Google Guava, и я часто использую её, когда Java 8 недостаточно.
Экспериментальный хак есть экспериментальный хак, и я сомневаюсь, что дальше демонстрации есть большой смысл идти дальше. Но, в целях исследования и духа экcпериментаторства, почему бы и нет? Ознакомиться с экспериментом поближе можно на GitHub.
Нерешённые и нерешаемые вопросы
Помимо проблемы с refid
в Ant, открытыми для меня лично остаются несколько вопросов:
Работает ли этот пример на других реализациях JVM?
Работает на Oracle JVM, но лицензия Oracle запрещает развёртывание приложений, заменяющих часть rt.jar
с использованием -Xbootclasspath
.
Можно ли сформировать список классов зависимостей автоматически, не прибегая к ручному перебору?
Мне лично неизвестны автоматические методы такого анализа. Можно попробовать стянуть весь пакет java.util.stream.*
целиком, но и проблем, думаю, будет больше.
Есть ли возможность запустить этот пример на Dalvik VM?
Имеется в виду Android. Я пробовал пропускать результаты через dx и запускать Dalvik VM с -Xbootclasspath
прямо на реальном устройстве, но Dalvik упорно игнорирует такую просьбу. Подозреваю, причиной этого является то, что приложения для Dalvik VM форкаются от Zygote, которая, очевидно, ничего не подозревает о таких намерениях. Больше почитать о том, почему это сделать нельзя и чем это чревато, можно почитать на StackOverflow. И если бы и удалось запустить dalvikvm
с -Xbootclasspath
, я подозреваю, потребовался бы некий лончер и для самого приложения, который бы этот boot classpath и подменял. Такой сценарий, по всей видимости, не предоставляется возможным.
А как с GWT?
А это совершенно другая история и другой подход. Буквально на днях состоялся долгожданный релиз GWT 2.8.0 (к сожалению, версия 2.7.0 ещё два года назад), в которой полноценно реализованы лямбды и прочие возможности для исходников, написанных на Java 8. Впрочем, это всё было и до релиза в SNAPSHOT-версиях. Возиться с байткодом в GWT нельзя, потому как GWT работает только с исходным кодом. Для портирования Stream API на клиентскую сторону придётся, я думаю, просто собрать часть исходников из JDK 8, предварительно пропустив их через некий препроцессор, который преобразует исходники в удобоваримый для GWT вид (пример портирования RxJava).