Год назад я рассказывал о том, как с помощью 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
И сопутствующие инструменты, вовлечённые в эксперимент:
Помимо более старых версий 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 почти всегда идёт создание целевой директории.
<target name="init" description="Initializes the workspace">
<mkdir dir="${targetDir}"/>
</target>
Шаг 1. Grab
Крайне важной состовляющей экперимента является минимальный точный список всех классов, от которых зависит тестовый пример. К сожалению, мне не известно, можно ли это сделать проще, и я потратил довольно много времени, чтобы методом многократных повторных запусков зарегистрировать все нужные классы из JRE 8.
С другой стороны, есть некоторый смысл попробовать стянуть весь пакет java.util.stream
и потом потратить ещё больше времени на подтягивание других зависимостей (и, наверняка, обработку инструментами типа ProGuard). Но я решил пойти на другое простое ухищрение: вложенные и внутренние классы я просто копирую с помощью маски $**
. Это очень существенно экономит время и список. Некоторые классы, существовавшие и в более старых версиях Java, скорее всего, нужно будет скопировать также, поскольку в Java 8 они обрели новые возможности. Это касается, например, нового метода по-умолчанию Map.putIfAbsent(Object,Object)
, который не задействован в тесте, но требуется для его корректной работы.
<target name="01-grab" depends="init" description="Step 01: Grab some JRE 8 classes">
<unzip src="${java.home}/lib/rt.jar" dest="${step01TargetDir}">
<patternset>
<include name="java/lang/AutoCloseable.class"/>
<include name="java/lang/Iterable.class"/>
<include name="java/util/Arrays.class"/>
<include name="java/util/AbstractMap.class"/>
<include name="java/util/EnumMap.class"/>
<include name="java/util/EnumMap$**.class"/>
<include name="java/util/function/Consumer.class"/>
<include name="java/util/function/Function.class"/>
<include name="java/util/function/Supplier.class"/>
<include name="java/util/Iterator.class"/>
<include name="java/util/Map.class"/>
<include name="java/util/Objects.class"/>
<include name="java/util/Spliterator.class"/>
<include name="java/util/Spliterator$**.class"/>
<include name="java/util/Spliterators.class"/>
<include name="java/util/Spliterators$**.class"/>
<include name="java/util/stream/AbstractPipeline.class"/>
<include name="java/util/stream/BaseStream.class"/>
<include name="java/util/stream/ForEachOps.class"/>
<include name="java/util/stream/ForEachOps$**.class"/>
<include name="java/util/stream/PipelineHelper.class"/>
<include name="java/util/stream/ReferencePipeline.class"/>
<include name="java/util/stream/ReferencePipeline$**.class"/>
<include name="java/util/stream/Sink.class"/>
<include name="java/util/stream/Sink$**.class"/>
<include name="java/util/stream/Stream.class"/>
<include name="java/util/stream/StreamShape.class"/>
<include name="java/util/stream/StreamOpFlag.class"/>
<include name="java/util/stream/StreamOpFlag$**.class"/>
<include name="java/util/stream/StreamSupport.class"/>
<include name="java/util/stream/TerminalSink.class"/>
<include name="java/util/stream/TerminalOp.class"/>
</patternset>
</unzip>
</target>
Действительно, весьма впечатляющий список классов, нужный только для простых, как сперва кажется, map()
и forEach()
.
Шаг 2. Compile
Скучная компиляция тестового кода. Проще некуда.
<target name="02-compile" depends="01-grab" description="Step 02: Compiles the source code dependent on the grabbed JRE 8 classes">
<mkdir dir="${step02TargetDir}"/>
<javac srcdir="${srcDir}" destdir="${step02TargetDir}" source="1.8" target="1.8"/>
</target>
Шаг 3. Merge
Этот шаг может показаться немного странным, поскольку он просто сливает воедино результат копирования классов из Java 8 rt.jar
и тестового примера. На самом деле это нужно для нескольких следующих шагов, которые перемещают Java-пакеты для их правильной последующей обработки.
<target name="03-merge" depends="02-compile" description="Step 03: Merge into a single JAR in order to relocate Java 8 packages properly">
<zip basedir="${step01TargetDir}" destfile="${step03TargetFile}"/>
<zip basedir="${step02TargetDir}" destfile="${step03TargetFile}" update="true"/>
</target>
Шаг 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
в поисках лишней тильды.
<target name="04-shade" depends="03-merge" description="Step 04: Rename java.* to ~.java.* in order to let RetroLambda work since MethodHandles require non-java packages">
<shade jar="${step03TargetFile}" uberJar="${step04TargetFile}">
<relocation pattern="java" shadedPattern="~.java">
<include value="java.lang.AutoCloseable"/>
<include value="java.lang.Iterable"/>
<include value="java.util.Arrays"/>
<include value="java.util.AbstractMap"/>
<include value="java.util.EnumMap"/>
<include value="java.util.EnumMap$**"/>
<include value="java.util.function.Consumer"/>
<include value="java.util.function.Function"/>
<include value="java.util.function.Supplier"/>
<include value="java.util.Iterator"/>
<include value="java.util.Map"/>
<include value="java.util.Objects"/>
<include value="java.util.Spliterator"/>
<include value="java.util.Spliterator$**"/>
<include value="java.util.Spliterators"/>
<include value="java.util.Spliterators$**"/>
<include value="java.util.stream.AbstractPipeline"/>
<include value="java.util.stream.BaseStream"/>
<include value="java.util.stream.ForEachOps"/>
<include value="java.util.stream.ForEachOps$**"/>
<include value="java.util.stream.PipelineHelper"/>
<include value="java.util.stream.ReferencePipeline"/>
<include value="java.util.stream.ReferencePipeline$**"/>
<include value="java.util.stream.Sink"/>
<include value="java.util.stream.Sink$**"/>
<include value="java.util.stream.Stream"/>
<include value="java.util.stream.StreamShape"/>
<include value="java.util.stream.StreamOpFlag"/>
<include value="java.util.stream.StreamOpFlag$**"/>
<include value="java.util.stream.StreamSupport"/>
<include value="java.util.stream.TerminalSink"/>
<include value="java.util.stream.TerminalOp"/>
</relocation>
</shade>
</target>
Небольшое отступление. Теоретически, дублирование списка в Ant можно решить с помощью элементов, поддерживающих refid
, но это не получится по нескольким причинам:
<relocation>
не поддерживаетrefid
в первую очередь потому, что аналог этого аттрибута просто отсутствует в Maven-реализации. И я бы хотел, чтобы две реализации были похожи друг на друга один в один. По крайней мере, сейчас.
Анатомически
<relocation>
и<patternset>
различаются. В первом применяется<include name=”...”
, а во втором —<include value=”...”>
. Здесь, подозреваю, мой косяк, и я не слишком следовал общепринятым соглашениям.
SimpleRelocator
, используемый плагином для Maven, по видимому, не поддерживает пути к класс-файлам. Поэтому во втором случае названия классов нужно прописывать формате, где разделителем является точка, а не косая черта. Ещё одна несовместимость. Конечно, можно написать свою реализацию правил перемещения, но у меня, наверняка, если бы это не противоречило никаким правилам Maven-плагина, возник бы соблазн предложить такое расширение разработчикам maven-shade-plugin. Но, имея даже минимальный опыт, могу сказать, что даже в случае положительного ответа на такой запрос, это заняло бы кучу времени. Просто экономия времени.
Так что все эти недостатки решаются, но явно не в рамках этой статьи.
Шаг 5. Unzip
Следующий шаг распаковывает JAR-файл с перемещёнными пакетами, поскольку Retrolambda может работать только с директориями.
<target name="05-unzip" depends="04-shade" description="Step 05: Unpacking shaded JAR in order to let Retrolamda work">
<unzip src="${step04TargetFile}" dest="${step05TargetDir}"/>
</target>
Шаг 6. Retrolambda
Само сердце эксперимента: преобразование байткода версии 52 (Java 8) в версию 50 (Java 6). Причём из-за использованых выше ухищрений, Retrolambda (или, стало быть, JDK 8) спокойно и уже без лишних вопросов проинструментирует классы. Также обязательно нужно включить поддержку методов по-умолчанию, потому что множество нового функионала в Java 8 строится именно на них. Поскольку JRE 7 и ниже не умеет работать с такими методами, Retrolambda просто копирует реализацию такого метода для каждого класса, в котором он не был переопределён (это, кстати говоря, означает, что применять Retrolambda нужно только для связки “конечное приложение и его библиотеки”, иначе скорее всего можно столкнуться с проблемой, когда реализация default-метода попросту будет отсутствовать).
<target name="06-retrolambda" depends="05-unzip" description="Step 06: Perform downgrade from Java 8 to Java 6 bytecode">
<java jar="${retrolambdaJar}" fork="true" failonerror="true">
<sysProperty key="retrolambda.bytecodeVersion" value="50"/>
<sysProperty key="retrolambda.classpath" value="${step05TargetDir}"/>
<sysProperty key="retrolambda.defaultMethods" value="true"/>
<sysProperty key="retrolambda.inputDir" value="${step05TargetDir}"/>
<sysProperty key="retrolambda.outputDir" value="${step06TargetDir}"/>
</java>
</target>
Шаг 7. Zip
Собираем проинструментированную версию обратно в один файл, чтобы запустить shade-плагин в обратном направлении:
<target name="07-zip" depends="06-retrolambda" description="Step 07: Pack the downgraded classes back before unshading">
<zip basedir="${step06TargetDir}" destfile="${step07TargetFile}"/>
</target>
Шаг 8. Unshade
К счастью, для работы shade-плагина с перемещением в обратном направлении достаточно только двух параметров. По завершению этого этапа пакеты в приложении будут выровнены обратно, и всё, что было ~.java.*
снова станет java.*
.
<target name="08-unshade" depends="07-zip" description="Step 08: Relocate the ~.java package back to the java package">
<shade jar="${step07TargetFile}" uberJar="${step08TargetFile}">
<relocation pattern="~.java" shadedPattern="java"/>
</shade>
</target>
Шаг 9. Unpack
В этом шаге классы просто распаковываются для последующей сборки двух отдельных JAR-файлов. Снова ничего интересного.
<target name="09-unpack" depends="08-unshade" description="Step 09: Unpack the unshaded JAR in order to create two separate JAR files">
<unzip src="${step08TargetFile}" dest="${step09TargetDir}"/>
</target>
Шаги 10 и 11. Pack
Собираем все классы воедино, но отдельно — “новый рантайм” и само тестовое приложение. И в который раз — весьма тривиальный и неинтересный шаг.
<target name="10-pack" depends="09-unpack" description="Step 10: Pack the downgraded Java 8 runtime classes">
<zip basedir="${step09TargetDir}" destfile="${step10TargetFile}">
<include name="java/**"/>
</zip>
</target>
<target name="11-pack" depends="09-unpack" description="Step 11: Pack the downgraded application classes">
<zip basedir="${step09TargetDir}" destfile="${step11TargetFile}">
<include name="test/**"/>
</zip>
</target>
Тестирование результата
Вот и всё. В целевой директории лежит крошечный порт небольшого аспекта из реального Stream API, и он может запуститься на Java 6! Для этого создадим ещё одно правило для Ant-а:
<target name="run-as-java-6" description="Runs the target artifact in Java 6">
<fail unless="env.JDK_6_HOME" message="JDK_6_HOME not set"/>
<java jvm="${env.JDK_6_HOME}/bin/java" classpath="${step11TargetFile}" classname="${mainClass}" fork="true" failonerror="true">
<jvmarg value="-Xbootclasspath/p:${step10TargetFile}"/>
<arg value="foo"/>
<arg value="bar"/>
<arg value="baz"/>
</java>
</target>
И вот тут нужно обратить просто особое внимание на использование не совсем стандартного -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).
Комментарии (3)
soul_survivor
30.10.2016 12:48Для андроид существует замечательный порт Lightweight-Stream-API(https://github.com/aNNiMON/Lightweight-Stream-API) в котором есть подавляющее большинство вещей из стримов java 8(и даже кое-что из java 9), за исключением .parallel(). Для андроида параллельные стримы как пушкой по воробьям
vedenin1980
Но ведь есть же streamsupport в той же retrolambda. Мы портировали стримы с его помощью на Java 6-7 без особых проблем (кроме замены имён пакетов). Рассматривали этот вариант?
lyubomyr-shaydariv
Честно говоря, нет, не рассматривал, поскольку ориентировался исключительно на чистую Retrolambda. Пример именно со Stream API (а не с таким "несерьёзным"
Optional
) был больше интересен как эксперимент, который показал бы некоторые нетривиальные техники портирования любой библиотеки или API, даже если для неё не существует бекпорта. Насколько я понимаю как работает streamsupport/streamsupport, она также требует привязки з пакетуjava8.*
?