Чисто експериментальні прийоми портування Stream Java API з 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, приклад портування буде здійснюватися за допомогою 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
у міру необхідності. Переконатися в цьому можна, використовуючи ключ
детального: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 недостатньо.
Експериментальний хак є експериментальний хак, і я сумніваюся, що далі демонстрації є великий сенс йти далі. Але, в цілях дослідження і дух експериментаторства, чому б і ні? Ознайомитися з експериментом ближче можна на 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 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.