♾️ Live Reloading на JVM

Этот пост также доступен на Английском.

В этой статье я хотел бы обобщить всё, что мы знаем про Hot/Live Reloading на JVM, и далее показать, как я пришел к реализации универсального Live Reloading решения для любых веб-приложений на JVM.

Вкратце, в этой статье мы:

  • Попробуем сформулировать, какие виды релоадинга бывают.
  • Подробно рассмотрим, какие реализации существуют на JVM.
  • И немножко обсудим детали реализации универсального решения, и что вообще к нему привело.

TL;DR: Репозиторий ♾️ seroperson/jvm-live-reload.

Вступление

Все время, которое я разрабатываю веб-сервисы на Scala, мне не хватало вот этой фичи с Live Reloading. Если мы говорим про typelevel / ZIO стек, то такого там нет от слова совсем. Да что уж говорить, в Scala вообще никакого релоадинга нигде нет, кроме фреймворка Play. Всё остальное, где оно есть, в том или ином виде больше относится к Java или Kotlin экосистемам.

Но ситуация не лучше в Java и Kotlin, если вы не используете какие-то супер-мажорные веб-фрейморки, а сидите на каких-то маленьких либах типа ktor, javalin, http4k и так далее. Если сильно хочется, конечно, есть способы. Но, спойлер, все они выглядят не очень.

Перед тем как рассмотреть эти самые способы, давайте вообще определимся, о чем конкретно мы тут говорим. Наверное, многие и так догадываются, что Hot/Live Reloading, это механизм замены кода в рантайме без явного рестарта приложения. Обычно это очень быстро и практически всегда это сильно быстрее, чем ручками всё стопать и заново стартовать. Это может быть реализовано разными путями, поэтому давайте перечислим основные вариации, к которым можно отнести тот или иной релоадинг. И, кстати, мы здесь если что говорим именно про релоадинг в процессе разработки, и не будем затрагивать такие вещи как релоадинг в проде, хотя и некоторые методы, про которые будет идти речь, могут быть применены и там.

Так вот, определения. На самом деле, я не нашел прям строгих каких-то определений, но на основе информации из интернета, можно выделить такие типы релоадинга в рантайме:

  • Hot Module Replacement (HMR) - релоад только определенной части приложения в рантайме. Это буквально значит, что приложение строго разделено на модули, которые можно динамически выгружать и загружать. Этот подход возможен с использованием, например, фреймворков OSGi.
  • Live Reloading - патчинг рантайма новым кодом с частичным рестартом. Обычно работает так, что приложение как бы по факту рестартуется, но не полностью, а только юзер-кодом. Загруженные библиотеки, всякие системные штучки и т.д. остаются при этом нетронутыми.
  • Hot Reloading - ну и самая advanced механика, это патчинг только прям вот измененного юзер-кода без рестарта вообще. То есть по факту просто патчится в рантайме работающий код, который на следующей итерации просто будет работать уже по новому. На JVM нормальной реализации релоадинга этого типа по сути нет (мы поговорим об этом далее).

Нам вообще нужен релоадинг при разработке?

Может прозвучать немножко кринжово, но в дискуссиях об отсутствии в том или фреймворке релоадинга, можно часто встретить фразы “да компиляторы сейчас и так быстрые”, “просто разбей код получше”, “если что-то долго собирается, то это проблема в проекте”, “просто запусти билд в continuous моде” и другие. Одновременно можно увидеть, что спрос на эту фичу просто огромен:

Помимо того, что людям это необходимо, многие мажорные веб-фреймворки (как Spring, Play, Quarkus и другие) уже давно изкоробки поддерживают эту фичу, и всё это в очередной раз доказывает, что релоадинг нужен и что его наличие явно лучше, чем его отсутствие.

Реализации релоадинга на JVM

Так какие реализации уже есть на JVM (и на Scala, в частности)? Давайте сделаем краткий обзор того, что я нашел во время ресерча. Вы можете сразу скипнуть скучную часть и перейти к саммари.

sbt-revolver и “Triggered execution”

Всегда, когда заходит речь за релоадинг в Scala, sbt-revolver, это первое, что люди упоминают как “решение”. Но по факту это не релоадинг в принципе. Что делает этот плагин, так это просто прям рестартует весь процесс как только изменились какие-то его сорцы на диске. То есть прям стопает JVM и стартует с нуля заново. Никакого патчинга рантайма тут нет.

Сюда же можно отнести и встроенную фичу ~ в sbt (так называемое Triggered execution), которая делает по сути то же самое. Ну и сюда же всякие “Continuous mode” и их аналоги в других системах сборки.

Всё это можно заменить одним шелл-скриптом и на практике оно просто сжигает ресурсы процессора, постоянно ребутая ваше приложение пока вы кодите. Это редко когда полезно и явно не то, что мы ищем.

OSGi

OSGi тоже часто упоминается как “решение” проблем релоадинга. Как я писал ранее, с ним можно применять подход HRM, но, собственно, проблема в том, что для этого необходимо писать приложение с использованием OSGi фреймворка. Учитывая, что это не просто “взял и добавил библиотечку”, и то, что нам это надо просто для релоадинга в процессе разработке, этот вариант нам не подходит.

Java Instrumentation API

Java Instrumentation API позволяет подцепиться “наблюдателем” к приложению для “for the purpose of gathering data to be utilized by tools”. Это API также содержит методы для hot релоадинга, но функциональность ограничена только изменением тел методов. Если быть более точным, то нельзя менять схему существующих классов (цитата из javadoc):

The redefinition may change method bodies, the constant pool, and attributes. The redefinition must not add, remove, or rename fields or methods, change the signatures of methods, or change inheritance.

Существует ещё больше всяких JVM-ных API с похожей функциональностью (JDI, JVMTI), но все они имеют те же самые ограничения. Возможно, это всё просто одно и то же API под капотом. Всё это предоставляет возможность делать тру hot релоадинг, но с такими ограничениями сложно придумать этому применение на практике, кроме как в дебаггерах (где оно успешно и применяется).

Кастомная VM

Dynamic Code Evolution Virtual Machine (DCEVM) - это модификация Java HotSpot VM, которая позволяет переопределять загруженные классы в рантайме (другими словами, реализует hot релоадинг). Оригинальный проект мертв, но был реализован заново в рамках других VM, например, JetBrains Runtime. Также есть обёртка, позволяющая, собственно, применять этот самый hot релоадинг, проект HotswapAgent.

Эта разработка реализует реальный hot релоадинг и позволяет релоадить любые изменения, но тоже не лишена недостатков:

  • Самый очевидный, это то, что нужно, собственно, использовать кастомную JVM: JBR (Java 21, Java 17), TravaJDK (Java 11), DCEVM (Java 8).
  • Корректно это всё сконфигурировать может быть не так просто и, возможно, придется реализовывать кастомные плагины, чтобы всё это по итогу завелось на вашем стеке.

Стоит также отметить, что в мире кастомных VM с hot релоадингом, есть ещё одно решение, Espresso VM, которое имеет все те же плюсы и минусы, но в довесок имеет более тесную интеграцию с Graal-экосистемой.

Всё это очень классно выглядит, но заставлять пользователей юзать кастомные VM звучит немножко кринжово, поэтому мы ищем решение дальше.

JRebel

Существует также, внезапно, платное решение JRebel, которое не требует ранить кастомную VM, но при этом всё ещё реализует тру hot релоадинг. Информации о том, как оно работает, практически нет, всё, что известно, что они используют Java Instrumentation API, но как именно они патчат в обход ограничений, непонятно. В любом случае, оно платное, а платное мы не любим, поэтому ищем дальше.

Dynamic Proxy API

Если вкратце, то это API позволяет перехватывать взаимодействие с классом и оборачивать его в какую-то кастомную логику. Теоретически мы можем поллить изменения файлов, компилировать их при нужде, и, реализовав прокси, ходить либо в старую реализацию, либо в новую, загруженную в новом класс-лоадере. Примерно так работает никому неизвестная библиотека scf37/hottie.

Звучит классно, но как будто тяжело будет распространить это на все классы юзер-кода, и чтобы это работало без аффекта производительности. И, опять же, непонятно как быть с какими-то сложными изменениями. Ну, короче, явно не наш вариант.

Если смотреть глобально, то по сути это тот же подход с класс-лоадерами.

Подход с класс-лоадерами

И, наконец, самое популярное решение в JVM-мире и то, что мы называем Live Reloading. Идея состоит в том, что есть, значит, два класс-лоадера:

  • Перманентный, в котором лежат зависимости и системные классы, который не релоадится.
  • Обновляемый, в котором лежит юзер-код, который релоадится.

Когда вносятся правки в код, приложение останавливается, обновляемый класс-лоадер дропается и на новом коде создается еще один, на котором приложение запускается снова.

Так работают Play, Spring Boot, Quarkus, Apache Tapestry.

Это относительно простое, но самое рабочее решение. У него всё равно есть минусы, но скорее всего вы их даже не заметите:

  • Обновляемый класс-лоадер может быть достаточно большим, поэтому релоад может быть не прям супер-быстрым (но всё равно это будет в разы быстрее, чем без релоада).
  • Когда обновляемый класс-лоадер дропается, очень важно корректно остановить и почистить все используемые ресурсы. Если что-то будет упущено, то память потечет и через пару релоадов всё упадет с out-of-memory (или будет какой-то еще undefined behavior).
  • Этот метод требует особого внимания к библиотекам, которые используют всякую класс-лоадерную магию.
  • Этот метод обычно доступен только как часть каких-то веб-фреймворков.

Поэтому, конечно, это могло бы быть подходящим нам решением, если бы не приходилось ради этого приложение на конкретных веб-фреймворках (спойлер: больше не придется).

Итого

Итого, если исключить дубликаты, то останется следующее:

  • Hot Reloading с использованием Java Instrumentation API, но этот метод не позволяет менять схему классов.
  • Hot Reloading с использованием кастомной VM, но заставлять пользователей юзать кастомную VM для релоадинга звучит как немножко кринж.
  • HMR с использованием OSGi, но строить приложение на OSGi только ради релоадинга в процессе разработки тоже звучит как немножко кринж.
  • Hot Reloading с использованием JRebel, но он стоит примерно половину зарплаты.
  • Live Reloading с использованием нескольких класс-лоадеров, но это решение доступно только как часть конкретных веб-фреймворков.

Короче, нормальных решений нет 🤡

Реализация универсального решения

Вот где-то на этом этапе у меня появилась идея реализации независимого от фреймворка релоадера, и где-то здесь родился проект ♾️ seroperson/jvm-live-reload. Чтобы попробовать его прямо сейчас, можете сразу перейти в секцию Installation в репозитории.

Preview

Этот проект реализует Play-like подход, который мы также частично знаем как описанный выше подход с класс-лоадерами. А для тех, кто не знаком с Play, вот как он работает в деталях:

  • Пользователь устанавливает плагин для своей системы сборки (Gradle, sbt и другие). Плагин реализует всю логику релоада и предоставляет собой “мост” для коммуникации между приложением и системой сборки.
  • Пользователь запускает приложение в dev-режиме.
  • Когда запрос прилетает в Play, фреймворк спрашивает систему сборки, были ли какие-то изменения в коде с момента последнего релоада (или с момента старта приложения).
  • Если в коде были изменения, то они компилируются, а плагин релоадид приложение с новым класс-лоадером.
  • Если изменений не было, то запрос проходит как обычно.

Таким образом, вы разрабатываете сколько надо, потом делаете curl-запрос, всё компилируется и релоадится (запрос всё это время просто висит в ожидании), и респонз на этот запрос уже будет сформулирован на новом коде. Никаких рестартов впустую, никаких рестартов всего процесса, и всё обычно очень быстро.

И, как вы поняли, ничего не мешает нам сделать это решение универсальным. Всё, что нужно, это:

  • Ре-реализовать логику взаимодействия “приложение -> система сборки” для каждой системы сборки.
  • Реализовать прокси, которая будет стоять перед приложением и решать, релоадить код или нет. Это позволит нам практически не трогать юзер-код при использовании плагина, так что в общем случае можно будет просто добавить его в билд и всё будет работать.
  • Также нам надо будет уметь стартовать и останавливать приложение программно, а еще нужны хоть какие-то гарантии, что все используемые ресурсы освобождены. Со стартом приложения проблем не будет - это решается просто вызовом static void main через рефлексию. А со всем остальным есть небольшие проблемы. Я это опишу чуть далее.

Всё это было реализовано для систем сборки Gradle, sbt и mill, и оно даже работает. Конечно, пока это наверняка очень сыро, но, думаю, мы всё отдебажим и пофиксим. Давайте расскажу, с какими сложностями я столкнулся и как их решил.

Необходимые компромиссы

Я хотел закодить это всё так, чтобы для работы плагинов не требовалось править вообще ничего - чтобы просто применил плагин и всё работало изкоробки. Но мир неидеален, поэтому в некоторых случаях придётся что-то подправить. Но в любом случае правок не должно быть много.

Итак, чтобы приложение было “live reloading ready”, нужно чтобы оно соответствовало следующим требованиям:

  • Веб-приложение должно иметь /health роут. Он должен отвечать с кодом 2xx, когда приложение готово отвечать на запросы. Это необходимо, потому что без этого плагин не знает, когда приложение действительно стартануло и ранится.
  • Веб-приложение должно быть “interruptible”. Можно прочитать мою статью ⏹️ Making your JVM application interruptible, чтобы узнать больше деталей. Если вкратце, то ваш static void main метод должен обрабатывать InterruptedException, останавливая приложение и освобождая все ресурсы. Это необходимо, чтобы, внезапно, плагин мог при нужде остановить приложение.
  • Метод static void main должен быть блокирующим и завершаться только тогда, когда приложение полностью остановлено и все ресурсы освобождены. Когда мы вышли из метода, плагин считает, что приложение остановлено и можно стартануть новый инстанс.

Все веб-фреймворки, которые реализуют релоадинг с таким подходом, имеют полный контроль над жизненным циклом приложения и вообще над всем, но наше универсальное решение так не умеет, отсюда и появились такие вот требования.

Планы на будущее

Я надеюсь, что этот проект найдет свое место и поможет пользователям в разработке. Как я уже сказал, это сейчас типа альфа, поэтому самой первой целью для меня является стабилизация, а потом, на основе фидбэка, мы сформируем роадмап с фичами до версии 1.0. Честно, понятия пока не имею, чего прям не хватает прямо сейчас, поэтому я весь во внимании.

Заключение

И подписывайтесь на мой 🛫 Telegram-канал, кстати. Там я размещаю всякие мысли про повседневную разработку, которые не подходят под формат блога.

Спасибо за прочтение этой небольшой статьи. Надеюсь, она была полезна.

Посмотрите также статьи на похожие темы: