Чисто экспериментальные приёмы портирования 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).

Комментарии (0)

© Habrahabr.ru