Closure Stylesheets -- это компилятор CSS, написанный Гугл на Java в рамках набора инструментов Closure Tools для веб-разработки, который в свое время обладал внушительными функциями такими как экспансия браузерных префиксов (например, для трансформации display: flex;
в display: -webkit-flex; display: -ms-flexbox
), переменными и др. Прошло уже более 10 лет с момента зачатия этого проекта, многие инновации уже укоренились, и в прошлом году его архивировали, т.к. все, включая сотрудников Гугл, используют мейнстрим утилиты типа SASS / autoprefixer. Я же успешно держу свой форк Exposure Stylesheets, потому что верю, что инструменты должны быть простыми и с душой из 2010х. В статье речь пойдет про то, как я обновил свой форк с Java 1.8 на JDK 11 и использовал утилиту Oracle native-image из пакета GraalVM для того, чтобы собрать native binary, то есть бинарник с машинным кодом, который позволяет запускать программу вообще без Java. Я расскажу о том, какие проблемы встретил во время апгрейда, в том числе связанных с рефлексией, и сравню скорость запуска / работы / потребления памяти стандартного JAR и собранного bin.
Вступление: о форке
В моем форке я добавил несколько полезных изменений, например, я сделал возможность выгрузить все полу-современные (современные, да уже очень) правила, которые могут не поддерживать последние бразуеры (но скорее всего будут), в отдельный файл с префиксами, и загружать его только при надобности, вместо того, чтобы считать по процентам, сколько посетителей можно удовлетворить, а на скольких можно "забить", как это делает авто-префиксер. Такой метод позволяет не только сэкономить на весе итогово CSS, который не будет включать префиксы, ненужные для последних брауезров, которых большинство, но и гарантировать загрузку сайта для самых древних пользователей.
/* (1) original.css */
.col {
flex-flow: row-reverse nowrap;
display: inline-flex;
}
/* (2) result.css */
.col {
flex-flow: row-reverse nowrap;
display: -ms-inline-flexbox;
display: inline-flex;
}
/* (3) prefixes.css */
.col {
-ms-flex-flow: row-reverse nowrap;
-webkit-flex-flow: row-reverse nowrap;
display: -webkit-inline-box;
display: -webkit-inline-flex;
}
/* (4) prefixes.json */
{
"flex|-ms-flex": [
"auto"
],
"display": [
"flex|-ms-flexbox",
"inline-flex|-ms-inline-flexbox"
]
}
Вверху показан код (1) оригинального, чистого CSS; (2) трансформированого CSS и (3) CSS с префиксами. В то время как как -ms-inline-flex
еще может быть включен в оригинальный бандл, -webkit-inline-flex
уже неактуален, и может быть вынесен в префиксы. При старте страницы, небольшой скрипт проверит, поддерживаются ли префиксы из репорта (4), который тоже выдает компилятор, и если нет, до дозагрузит их. При этом, ожидается что современные браузеры будут поддерживать все правила из бандла, и не придется ждать блокирующего CSS для старых браузеров, то есть скорость открытия страницы пострадает только у тех, кто не хочет обновляться. И даже если у кого-то обнаружился старый браузер, ему все равно будет все хорошо показано -- в отличие от автопрефиксера, который просто считает людей как % (ты просто цифра).
Во-вторых, сам компилятор умеет переименовывать классы. После того, как CSS собран, я беру репорт с новыми именами классов, и встраиваю их на страницу. Теперь мои компоненты могут подобрать имя класса, который им нужно изменить, из мета-данных самой страницы, исключая тесную связь между презентацией и интеракцией: получая имена классов из вне, а не запихивая их в JavaScript бандл через import css, я имею полный контроль над этими двумя уровнями, что позволяет изменять внешний вид компонента через html/css без какой-либо работы в JS, что будет актуальным для тех, кто хочет делать сайты, не зная программирования.
В-третьих, я добавил возможность установить root selector, то есть имя класса, внутри которого применяются мои правила из листа стилей. Это что-то вроде shadow DOM, где правила находятся в скопе компонента, но сделано это простым и понятным способом (см. пример ниже). Это также полезно, когда страницы сайта разбиты на несколько частей, и для каждой части есть свой стиль -- при использовании ключевого выборщика (root selector), исключается возможность непредусмотренного наложения стилей из классов с одинаковым именем но из разных частей.
/* html */
<div class="MyComponent">
<p>Hello World</p>
</div>
/* original.css */
p {
color:orange;
}
/* compiled.css */
.MyComponent p {
color:orange;
}
Как видите, все проблемы можно решить адекватным способом, если только немного подумать. Как правило все решения -- прямиком из 2000х-2010х, когда делать сайты было еще просто, весело и интересно. Я очень хочу, чтобы каждый мог продолжать разрабатывать сайты с помощью надежных инструментов, а не следуя "трендам".
Обновление
Теперь подробно о том, как обновить проект до GraalVM. Для начала, нужно скачать и установить GraalVM, про это не будем. Затем добавим новую JDK в Eclipse и в настройках проекта выберем JRE System Library как graalvm-ce-java{x}-22.3.1
. Теперь проект будет собираться с помощью Грааля. По сути делать больше ничего не надо; изначально у меня были какие-то проблемы с "Unresolved compilation problems: The type String is not visible", которые я решил делая new String(...)
там, где это случалось, но я больше не могу это повторить, поэтому наверное я неправильно выбрал JRE. Иногда еще были проблемы с вроде "type var cannot be resolved" но опять же, это решилось удалением блока текста, где это происходило, и его вставкой обратно, после чего Эклипс запускал авто-билд, и проблема уходила. Последнее -- это не универсальное решение, но может кому-то пригодится на будущее, когда код ну просто должен работать, но не работает. Теперь более серьезные вещи.
1. Отсутствие поддержки пакета java.awt для MacOS
Closure Compiler умеет делать такие интересные вещи, как использовать кастомные функции, в том числе по изменению цвета. Например, с помощью функции lighten(color,step)
, можно сделать цвет более светлым на этапе сборки. В принципе, ничего нового, CSS препроцессоры такое умеют уже давно, но вы должны помнить, что проекту уже второй десяток лет, и в начале его пути это действительно было круто и нужно. Для работы с цветом внутри кода используется класс java.awt.Color
, с которым у GraalVM что-то не очень: хотя поддержка для Линукса (и теперь уже Windows) есть, MacOS остается в стороне. Поэтому поступаем следующим образом: открываем исходники класса Color от самого Oracle, и копируем вообще все в свой пакет, tools.exposure
. Убираем импорты других awt.
классов, и делаем так, чтобы не было красненького. Когда все сделано, в коде везде меняем java.awt.Color
на tools.exposure.Color
. Таким образом мы избавились от awt зависимости, которая не позволяла использовать цвета на Маке из бинарника.
/* original.gss */
@def COLOR #abc123;
.Test {
color: lighten(COLOR, 1);
}
/* output.css */
.Test{color:#afc524}
2. Распознавание динамических прокси
Динамические прокси -- это фича языка, которая перенаправляет вызовы методов через специальную функцию InvocationHandler. Т.к. интерпретация байт-кода невозможна в скомпилированном приложении, компилятор должен построить классы прокси на этапе сборки. На самом деле тут ничего делать особо не надо, поскольку анализатор кода сможет установить вызовы Proxy.newProxyInstance, и сам сформирует список необходимых прокси. Но есть одно но: анализ ограничен ситуациями, когда список интерфейсов поступает из статического массива или массива в том же методе, где создается прокси. В случае с Closure Stylesheets, в коде использовался непрямой вызов через com.google.common.reflect.newProxy(CssTreeVisitor.class)
, что не попадало под условие. Поэтому просто заменяем reflect.newProxy на Proxy.newProxyInstance, и все становится на свои места.
3. Парсинг аргументов из коммандной строки
Тут тоже используется рефлексия, скорее всего вообще всеми библиотеками Java, которые умеют считывать аргументы. Хотя у Грааля есть возможность проанализировать код с помощью динамического агента, подгруженного во время выполнения Java программы, для сбора информации о том, какие методы нужно оставить, получилось бы так, что мне нужно выполнять программу вообще с каждым аргументом и опцией, чтобы собрать полный список методов, чтобы они остались в бинарнике. Такой подход мне не нравится, поэтому я сгенерировал простенький парсер аргументов из xml-деклараций опций, который я создал еще раньше:
<types namespace="tools.exposure.stylesheets">
<record name="Options">
<string name="outputOrientation" opt proto="3">
<choice val="NOCHANGE" />
<choice val="LTR" />
<choice val="RTL" />
<java-type>OutputOrientation</java-type>
<java-default>OutputOrientation.LTR</java-default>
Allows to convert input stylesheets into the opposite orientation. If this
flags differs from the `inputOrientation`, the compiler will flip the left
and right values in applicable rules.
</string>
<bool name="prettyPrint" def proto="4">
<java-type>boolean</java-type>
<java-default>false</java-default>
Use indentation, newlines and whitespace to produce human-readable output.
</bool>
<string name="outputRenamingMap" opt proto="5">
<java-type>String</java-type>
<java-default>null</java-default>
<java-field>renameFile</java-field>
The path to the file where to save the map of how classes were renamed.
</string>
<!-- +15 more -->
</record>
</types>
Этот декларативный XML-список я сделал еще 2 года назад, чтобы перенести опции из королевства кода в королевство дизайна -- имея XML вместо Java кода, можно делать много удивительных и увлекательных трансформаций на модели, получая (js|py|xyz)-doc байндинги для других программных языков (например JavaScript) и protobuf файлы, чтобы можно было поднять сервер и получать опции не из CLI, а по сокету, и не через JSON, а через нормальную сериализацию (мы же инженеры). Только так (модельно-ориентировано) и должно делаться ПО, однако об этом почему-то никто не знает. Ну что же, теперь мне еще раз очень пригодился этот XML, для генерации статического парсера CLI:
class Options {
protected static void fromArgs(Options flags,String[] args) {
var allArgs= new ArrayList<>(Arrays.asList(args));
while(allArgs.size()>0) {
var arg=allArgs.remove(0);
if(arg.startsWith("--")) {
allArgs.add(0,arg);
break;
}else {
flags.arguments.add(arg);
}
}
for(var arg : allArgs) {
if(arg==null) continue;
switch(arg) {
case "--pretty-print": { // boolean
allArgs.set(allArgs.indexOf(arg),null);
flags.prettyPrint=true;
break;
}
case"-o":
case"--output-file":{ // string
var i=allArgs.indexOf(arg);
String next=allArgs.get(i+1);
flags.outputFile=next;
allArgs.set(i,null);
allArgs.set(i+1,null);
break;
}
case"--prefixes":{ // list
int num=0;
var i=allArgs.indexOf(arg);
while(true) {
String nextArg=allArgs.get(i+num+1);
if(nextArg==null) break;
if(nextArg.startsWith("--")) break;
flags.prefixes.add(nextArg);
}
allArgs.set(i,null);
while(num>0){
allArgs.set(i+num,null);
num--;
}
break;
}
// and so on...
}
}
}
}
Такой класс со статическим парсером аргументов очень легко компилируется в машинный код и избавляет от зависимости args4j, которая использует рефлексию. К слову, если скомпилировать с этой зависимостью, она потребует включения своих locale bundles, поэтому возникнет ошибка при запуске, если их не указать в resource-config. Команда Graal понемногу собирает коллекцию метаданных файлов библиотек, но если у вас не запустился бинарник с ошибкой "missing language resource", это из-за этого, и нужно вручную добавить ресурсы.
4. Убираем динамическую загрузку классов
Для того, чтобы разработчики могли легко добавить свои собственные CSS функции написанные на Java, авторы Closure Compiler оставили опцию --gss-function-map-provider
, через которую можно передать имя класса, содержащего их функции:
/**
* @param gssFunctionMapProviderClassName such as
* "com.google.common.css.compiler.gssfunctions.DefaultGssFunctionMapProvider"
* @return a new instance of the {@link GssFunctionMapProvider} that corresponds to the specified
* class name, or a new instance of {@link
* com.google.common.css.compiler.gssfunctions.DefaultGssFunctionMapProvider} if the class
* name is {@code null}.
*/
private static GssFunctionMapProvider getGssFunctionMapProviderForName(
String gssFunctionMapProviderClassName) {
// Verify that a class with the given name exists.
Class<?> clazz;
if(gssFunctionMapProviderClassName.isEmpty()) {
gssFunctionMapProviderClassName="com.google.common.css.compiler.gssfunctions.DefaultGssFunctionMapProvider";
}
try {
clazz = Class.forName(gssFunctionMapProviderClassName);
} catch (ClassNotFoundException e) {
throw new RuntimeException(
String.format("Class does not exist: %s", gssFunctionMapProviderClassName), e);
}
// Create the GssFunctionMapProvider using reflection.
try {
return (GssFunctionMapProvider) clazz.newInstance();
} catch (InstantiationException e) {
throw new RuntimeException(e);
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
}
Такая динамическая разгрузка внешнего класса все равно не будет иметь никакого эффекта после компиляции, потому что виртуальная машина Java для AOT отсутствует в бине. Поэтому выпилим эту опцию и просто оставим стандартные GSS функции.
Сравнение
Ну что же, в остальном все остается как прежде и менять ничего не надо. Как видите, все проблемы что были, связаны только с рефлексией, включая динамическую загрузку классов / ресурсов и прокси. И это не значит, что если такие вещи есть в вашем коде, Грааль не будет работать -- при правильной настройке со сборкой анализа через агента, можно заставить работать самый динамический код. Теперь сравним jar файл и бинарник.
Размер
JAR: 6.0M
BIN: 16M
Тут выигрывает JAR, потому что бинарник включает все Java классы, которые так или иначе были использованы в программе. Но есть учесть, что бин можно запускать вообще без Джавы, например создав минимальный контейнер, то 16М это мало (по сравнению c ~150MB для JRE).
Скорость запуска
Соберем 5ый бутстрап.
# time target/exposure-compiler styles/bootstrap.css --allow-duplicate-declaration
0.36s user 0.04s system 96% cpu 0.411 total
max memory: 99992 KB
0.35s user 0.03s system 98% cpu 0.397 total
max memory: 99988 KB
0.37s user 0.04s system 98% cpu 0.418 total
max memory: 105984 KB
0.37s user 0.04s system 98% cpu 0.421 total
max memory: 106012 KB
0.35s user 0.03s system 96% cpu 0.400 total
max memory: 99996 KB
# time java -jar target/exposure-stylesheets-1.13.4-SNAPSHOT-jar-with-dependencies.jar styles/bootstrap.css --allow-duplicate-declarations
3.14s user 0.32s system 179% cpu 1.921 total
max memory: 207544 KB
2.96s user 0.23s system 228% cpu 1.395 total
max memory: 268392 KB
2.92s user 0.21s system 208% cpu 1.504 total
max memory: 205752 KB
3.09s user 0.19s system 223% cpu 1.469 total
max memory: 199948 KB
2.85s user 0.21s system 202% cpu 1.510 total
max memory: 214116 KB
Сразу видно, что бинарник запускается почти в 10 раз быстрее каждый раз и расходует ~100KB памяти против ~200KB памяти, которые жрет Джава. Не уверен, что значит 202% CPU, но возможно это из-за того, что jvm умеет подключать второе ядро.
Скорость компиляции
Предыдущий тест включает скорость на загрузку VM, поэтому неизвестно, становится ли компиляция CSS быстрее. На самом деле, она будет быстрее только вначале, перед тем как Java прогрелась, после чего джавовский AOT может быть даже быстрее (смотрите видео по Граалю). Это можно проверить, потому что в моем форке есть функция сервера: вместо того, чтобы загружать jar каждый раз, я запускал сервер и подключался к нему по сокету.
bin: 337 343 333 323 314 307 344 333 336 (мс)
jar: 1014 529 445 407 376 366 350 328 322 (мс)
Как я и сказал, разницы во времени компиляции CSS нету, когда джаве дали прогреться (первые несколько запусков). А учитывая, что time выдавал ~0.3 для бинарника, видно, что у машинного кода вообще нету пенальти на такие вещи, как запуск виртуальной машины, из-за чего Грааль позиционируют как отличное решение для сценариев вроде микрофункций (не придется платить за то время, пока java включается).
Память при запущенном сервере
Продолжим работу сокет-сервера и измерим память.
bin: 90864 126176 140536 151876 146756 138600 111968 105824 103796 (КБ)
jar: 156940 165864 204700 257432 328224 192548 193220 194588 278644 (КБ)
Память собиралась после каждого запуска. Java ест в полтора-два-три раза больше памяти, взятой по ps aux
. Это, возможно, не лучшая метрика, но разница видна. Если у кого-то есть предложения по замеру реальной памяти из джавы во время исполнения, напишите в комментариях!
Заключение
ГраальВМ - уже очень стабильная, качественная технология от Oracle, с рядом интересных фич, которая будет полезна для интеграции кода под разные виртуальные машины в одну систему. Она так же позволяет собирать нативные бинарники под Windows, Mac и Линукс, что очень увлекательно! Особенно потому, что не требуется практически никаких плясок с бубном. Единственное, на чем можно "застрять", это рефлексия и динамическая загрузка классов. По возможности, их стоит обходить стороной. Я показал, на что именно стоит обратить внимание, но я не писал пошаговый гайд, что именно делать в той или иной ситуации, для этого есть доки.
Был показан трюк с генерацией статического парсера CLI аргументов из XML-модели опций, который позволил не запускать jar десятки раз с агентом под разными аргументами для сбора анализа. Возможно, кому-то пригодится данная стратегия, когда они будут собирать свои бины. Так же я обсудил проблему того, что java.awt недоступен на Маке, и как ее можно обойти, вытащив всего один Color class из всего пакета awt.
Затем я сравнил скорость загрузки бинарника и jar файла: машинный код запускался в 10 раз быстрее, но сама скорость компиляции CSS оставалась такой же, после того как Java достаточно прогрелась. Бинарник также тратил меньше памяти. В целом, это очень круто, что из Java-кода можно так просто собрать "железный" бин.
По вопросом приобретения сборки форка писать в ЛС (990 руб).
kale
"После чего джавовский AOT может быть даже быстрее".
Подразумевалось JIT?