Чисто экспериментальные приёмы портирования Stream API из Java 8 на Java 6

в 19:31, , рубрики: ant, java, java 8, jvm, porting

Год назад я рассказывал о том, как с помощью 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, пример портирования будет осуществляться с помощью 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).

Автор: lyubomyr-shaydariv

Источник

Поделиться новостью

* - обязательные к заполнению поля