Привет, Хабр! Меня зовут Дима, я разработчик в команде “Архитектура” в hh.ru. Среди прочего, я занимаюсь тем, что делаю разработку проще для коллег. Выполнение кода в продакшене является типовой задачей. Поэтому когда я услышал, что с этим есть проблемы, я решил заняться их устранением.
Не всегда изменения данных можно сделать простым UPDATE/INSERT — иногда нужно задействовать валидацию, шины событий и прочее. В таких случаях самым оптимальным решением является выполнение произвольного кода прямо в приложении. У нас Java, поэтому когда появился JEP-222, я сразу подумал, что JShell, возможно, сможет снова сделать написание скриптов удобным. Чуда не произошло, а потому под катом вы найдете не очень глубокое сравнение самых известных интерпретаторов Java (и «около-Java») для jvm с примерами. Всех интересующихся приглашаю под кат.
Для запуска скриптов мы используем BeanShell, и для 2019-го он ужасен: последний релиз от 2016 года, отсутствие поддержки лямбд и даже дженериков — все это заставляет писать код, который никто не писал со времен Java 1.4.
Критерии
Прежде чем начать сравнение, сформулируем требования к встроенному скриптовому движку. Почесав голову, я составил такой список:
- поддержка актуального java синтаксиса;
- возможность передать в интерпретатор внешний контекст;
- возможность прервать выполнение;
- возможность перенаправить I/O;
- информативная обратная связь.
Чем больше язык, на котором мы пишем скрипты, напоминает тот, который мы разрабатываем, тем меньше ошибок — руки помнят. Но когда мы допускаем ошибки, которые были выявлены на этапе компиляции, они должны позволить разработчику их пофиксить — это указания на имена отсутствующих переменных, строчки, стейктрейсы etc.
Далее скрипты должны работать в определенном контексте, с доступом к Spring-овому контексту, к логгеру, который будет обслуживать именно скрипты. Без такой возможности передачи контекста, его получение превращается в квест.
Если ошибка все же просочилась в рантайм, то рестартить весь инстанс, чтобы остановить выполнение, — плохая идея, поэтому нужно иметь возможность просто прервать выполнение скрипта в произвольный момент времени.
И последнее — любые сообщения в системный вывод в процессе работы скрипта имеют смысл только в контексте этого скрипта. В системных логах от такого вывода толку мало. Поэтому хочется иметь возможность эти сообщения перенаправить в ответ.
Итак, поехали
JShell
- поддержка актуального java синтаксиса — да
- возможность передать контекст — нет
- возможность прервать выполнение — да
- возможность перенаправить I/O — нет
- информативная обратная связь — да
Сразу скажу, JEP-222 не преследует своей целью создать встраиваемый интерпретатор — его цель именно REPL, то есть, возможность быстрого прототипирования кода. Это чревато рядом последствий.
Во-первых, жизнь не готовила компилятор Java к тому, что можно объявить метод вне класса, а в теле метода использовать переменные, которые еще не задекларированы. Поэтому само выполнение скрывается за внушительным слоем абстракции.
Во-вторых, REPL вполне может исполняться не локально, а где-то на удаленной машине, поэтому API сделан с учетом таких особенностей. Я думаю, это основная причина, по которой в API нет возможности передать в интерпретатор внешний контекст и перенаправить I/O.
Кроме того, возникают разные режимы запуска — удаленный, когда shell подключается к машине по JDI, и локальный. Так как передать контекст программно возможности нет, а нам все равно очень хочется, то надежда остается только на локальный режим и на то, что мы умеем пользоваться кодогенерацией
Но, к сожалению, локальный режим явно не задумывался как основной — вот такой скрипт вызывает дедлок на компиляторе. При том, что этот же код в режиме JDI работает без проблем.
Таким образом, от использования JShell пришлось отказаться, хотя в целом API странноват, но понятен — отдаем скрипт на вход, получаем поток событий, для каждого из них можно проверить статус, получить ошибки и дебажную информацию. Ошибки позволяют идентифицировать выражение, в котором её допустили:
Beanshell
- поддержка актуального java синтаксиса — нет
- возможность передать контекст — да
- возможность прервать выполнение — да
- возможность перенаправить I/O — да(но требует использования спецметодов)
- информативная обратная связь — да
Неудача заставила обратить внимание на то, что мы используем сейчас. После продолжительного перерыва, проект вроде бы ожил, и судя по roadmap'у, уверенно движется к релизу, который решит все наши проблемы — уже сейчас должно работать много фич.
На момент написания статьи в beanshell действительно появилась поддержка дженериков, но лямбды по-прежнему не работают. Возможно, к выходу релиза ситуация изменится.
Зато в плане интеграции движок вполне дружелюбен — поддержка стандартного javax.scripting, ошибки выполнения достаточно вербозны:
Тем не менее, использование стримов без лямбд — это ад, который горит в аду. Возможно, проще даже писать на другом языке. Поэтому я решил присмотреться к сегменту «около-java». И первый кандидат на роль скриптового интерпретатора тут, конечно же
Kotlin
- поддержка актуального java синтаксиса — нет
- возможность передать контекст — нет
- возможность прервать выполнение — да
- возможность перенаправить I/O — нет
- информативная обратная связь — да
Java-код, если очень повезет, будет валидным kotlin-кодом. Но запустить что-то хоть сколько-нибудь адекватное на java в kotlin у меня не вышло, но тем не менее давайте попробуем.
Котлин уже пару лет как анонсировал поддержку javax.scripting.
Первая проблема, с которой приходится столкнуться, — это dependency-hell.
Kotlin-compiler включает в себя классы org.jdom, которые стали драться с org.jdom в приложении и заверте… Итак, у нас есть kotlin-compiler-embeddable, где все эти классы переложены в кастомные пакеты.
Однако уже после настройки выясняется, что не работает передача внешнего контекста. И вот это уже серьезная проблема, до ее решения нет смысла копать глубже. Если знаете, в чем там проблема и как это починить — пишите в комментариях.
Ошибки же тоже вполне вербозны:
Groovy
- поддержка актуального java синтаксиса — нет, но есть аналоги
- возможность передать контекст — да
- возможность прервать выполнение — да
- возможность перенаправить I/O — да
- информативная обратная связь — да
Груви, помимо поддержки javax.scripting, предоставляет свой, более расширенный API для интеграции интерпретатора. Например, есть возможность передать AST-трансформацию, которая позволяет добавить условное прерывание после каждого выражения. Штука такая мощная, что аж страшно.
Более того, Java-(а особенно beanshell)-код может быть вполне валидным груви-кодом.
Интеграция и тестовая эксплуатация прошла успешно, за исключением инициализации листов и синтаксиса лямбд (их приходится заворачивать в фигурные скобки), существующие биншелл-скрипты отработали без проблем. Ошибки более чем вербозны:
Пожалуй, на сегодняшний день это единственный интерпретатор, который, с одной стороны, позволяет писать код образца 2019 года, а с другой — соответствует всем требованиям, которые разумно предъявить к интерпретатору.
Какие мы можем сделать выводы?
Первый и самый главный: REPL — не скриптовый движок. Может показаться, что от командной строчки до интеграции в приложение один шаг, но при ближайшем рассмотрении выясняется, что у этих инструментов разные задачи, иногда противоречащие друг другу.
Второй — на сегодняшний день нет ни одного инструмента для исполнения скриптов на Java, поэтому если вам требуется такой инструмент, будьте готовы осваивать новый синтаксис.
Dr_XaoS
Мы для удаленной консоли взяли CRaSH и дописали туда пару своих команд. Выглядит так: заходишь по ssh в приложение, запускаешь команду выполнения произвольного кода, вставляешь код на groovy, получаешь ответ.
usharik
Код на Java, а для скриптов Python, Bash или иногда Groovy. До настоящего момента у меня все было примерно так. Не могу сказать, что я сильно этим не доволен и мне срочно нужен интерпретатор Java.
bm13kk
На сколько я понимаю проблему автора, Java нужна не потому что хочется на ней писать. А потому что для скрипта нужна функциональность из самого приложения.
То есть перед началом выполнения скрипта нужно поднять (к примеру) DI приложения и извлечь нужные сервисы (сбилженые и запущеные).
Beholder
А не проще будет вместо всех этих мучений код скомпилировать и загрузить класс-файлы (возможно в jar)?
Jorixxx Автор
Вопрос в том, что эти классы еще надо как-то доставить до приложения — а там докер, «облако» — другой уровень начинается
usharik
М.б. какой-нибудь свой classloader написать для этого?..
Jorixxx Автор
classloader, который делает что?
загрузить байтики в класс не проблема — вопрос откуда они возьмутся и что это будут за байтики
Предлагаю просто прикинуть скольким критериям удовлетворяет выполнение скомпиленных классов — как скомпилить класс, чтобы перенаправить I/O, чтобы можно было снаружи в него контекст передать…
sshikov
Groovy один из самых адекватных? И почему я ни разу не удивлен?
Впрочем, автор сознательно себя ограничивает одинаковым или похожим синтаксисом, в то время как часто (но не обязательно всегда) синтаксис удобно иметь как раз другой. Кложа, условно.
grinCo
Я так и не понял, для чего код руками в проде нужно запускать?
Jorixxx Автор
Типичные кейсы:
grinCo
Спасибо за ответ.
Я к тому что это все очень небезопасно выглядит. Вы по сути запускаете нетестированный код на проде, я подозреваю что в CVS тоже скрипт не добавляется до запуска.
Конечно это может пригодиться, например в стартапе, или если уже и так все сломано и через 5 мин фирма обанкротится. Но если это рутина, то явно сломаны процессы разработки.
Jorixxx Автор
Да, это угроза безопасности, но т.к. мы об этом знаем, то все не так страшно.
Понятно, что функционал закрыт разрешениями и доступен только нескольким людям.
А что до процесса, то ревьюить, тестировать и складывать скрипты в VCS — можно и нужно, так же как и весь остальной код.
Просто тут все это работает на доверии. В кейсах, которые я описал, польза окупает этот риск