Groovy как скриптовый язык и DSL для Java

в 7:04, , рубрики: dsl, groovy, java, script, метки: , , ,

Зачем?

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

Наверное, самыми простыми примерами таких сценариев, с которыми все сталкивались в том или ином виде, могут служить обычные пакетные файлы — bat или sh.

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

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

Как?

Скрипты

Я не буду рассказывать основы Groovy, так как материалов в сети даже на русском уже предостаточно. Остановлюсь лишь на некоторых ключевых для нас моментах.

Groovy позволяет нам из Java кода выполнять не скомпилированный исходный Groovy код, что позволяет нам выполнять сценарии добавленные или измененные в runtime.

Рассмотрим пример выполнения Groovy скрипта в Java. Для поддержки Groovy в Вашем Java проекте нужно подключить в зависимости лишь одну библиотеку «groovy» нужной Вам версии.

Напишем следующий Groovy код в файле x:GroovyScript.groovy:

println "Groovy script"
multi = {
num1, num2 -> num1*num2
}
multi(4,4)

Код для выполнения данного скрипта в Вашем Java код может быть таким:

GroovyShell shell = new GroovyShell();
Object result = shell.evaluate(new File("x:/GroovyScript.groovy"));
System.out.println("result=" + result);

В итоге выполнения, в консоль выведется 2 строки, первая из скрипта, вторая — из Java, с результатом выполнения скрипта:
Groovy script
result=16

Пример не несет функциональной нагрузки в скрипте, однако показывает возможности динамической загрузки и выполнения – всего две строчки кода и мы получаем возможность выполнения скриптов.

Немного о Java коде. GroovyShell – это класс предоставляемый Groovy для выполнения groovy скриптов. Существуют и другие способы выполнения groovy скриптов, подробнее смотрите тут

DSL

DSL – domain-specific language или предметно ориентированный язык. Язык, позволяющий использовать основные операции предметной области через набор простых и понятных высокоуровневых функций, скрывающий от пользователя их реализацию.

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

Рассмотрим небольшой пример. Требуется написать скрипт, который будет выполнять архиваций, распаковку архивов, удаление, некоторые проверки и уведомления.
Один их кусочков сценария мог бы быть таким – проверить состояние процесса и в случае его завершения заархивировать каталог и послать уведомление:

//import

//check state
Process p = getProcess(…)
int state = p.getCompleteState(…)
if (state == 1){
//doSomeLogicForArchive
Zip z = new Zip(…)
z.makeZip(…)
} else{
//doAnotherLogic
return
}
//doSomeLogicForSendNotify
Smtp smtp =new Smtp(…)
Message m = new Message(…)
smtp.send(to,m...)

Код получается достаточно большим, и он будет понятен, в основном, лишь программистам. Давайте его упростим и вынесем три указанных действия в класс ArchiveScript со статическими методами. Скрипт после вынесения методов:

import ArchiveScript
if (ArchiveScript.checkState()){
ArchiveScript.makeArchive(..)
}else{
//doAnotherLogic
return
}
ArchiveScript.sendNotify(…)

Уже лучше? — Лучше, но все еще есть артефакты – импорт и названия класса, которые тоже стоило бы убрать. И в Groovy есть подобная возможность – возможность задания базового класса для скрипта вне самого скрипта. Класс ArchiveScript для этого должен наследоваться от Script и методы могут быть не статическими. Код скрипта при этом еще упрощается – исчезает импорт и префикс класса:

If (checkState()){
makeArchive(..)
}else{
//doAnotherLogic
Return
}
sendNotify(…)

Уже достаточно хорошо. В случае если код внутри блока ветвления условия однострочный, можно отказаться и от фигурных скобок. А в случае Groovy часто и от скобок справа от имени метода. Код выполнения скрипта немного усложняется — нужно создать объект CompilerConfiguration, установить значение scriptBaseClass равное имени созданного нами класса ArchiveScript и передать этот объект в GroovyShell:
CompilerConfiguration conf = new CompilerConfiguration();
conf.setScriptBaseClass("package.ArchiveScript");
GroovyShell shell = new GroovyShell(conf);

Далее, давайте рассмотрим, как задаются параметры методов в скрипте при вызове. В случае определения в классе ArchiveScript метода makeArchive в таком виде:

def makeArchive(sourcePath, destPath, deleteSource)

В скрипте вызов должен был бы выглядеть так:

makeArchive("x:/aaa/","x:/a.zip", true)

//или так

makeArchive "x:/aaa/","x:/a.zip", true

И если говорить о наглядности и даже удобстве Groovy позволяет нам сделать передачу параметров через именованные параметры, так:
makeArchive sourcePath:'x:/aaa/',
destPath:'x:/a.zip',
deleteSource:true

Однако в этом случае параметры будут передаваться внутри HashMap и, соответственно, получение параметров в makeArchive в классе ArchiveScript должно быть таким:

def makeArchive(params){
makeArchiveInternal params.sourcePath, params.destPath, params.deleteSource
}

Если применить преобразование и для других вызовов, то в конечном итоге наш скрипт мог бы выглядеть так:

if (checkState ('SomeData')) {
makeArchive sourcePath: 'c:/1/*.*',
destPath: 'c:/testarch.zip',
deleteSource: true
}else{
//doAnotherLogic
Return
}
sendNotify to: 'aaa@gdsl.ru', content: 'сообщение'

И это уже не слишком сложный и достаточно читаемый код.

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

Замечу, что я рассмотрел лишь малую толику разработки DSL. В Groovy есть более широкая поддержка разработки своего DSL, а так же DSL-поддержка для Eclipse и для IntelliJ Idea

Тестирование

Хотелось бы сказать несколько слов о тестировании сценариев. Каким бы простым не был скрипт, ошибки могут быть и в нем. Даже если Вы пишите сценарии в IDE, полноценной проверки на корректность синтаксиса Вы можете не получить. Это возможно лишь при его выполнении. Так же необходимо проверять поведение скрипта.
Так как нам не хотелось бы выполнять реальные действия при выполнении тестирования сценария, то нужно каким-то образом заменить реальную логику на имитацию. Groovy позволяет нам это сделать многими путями. Покажу несколько из них.

Замена базового скрипта

Создаем новый класс ArchiveSciptMock который имеет интерфейс аналогичный ArchiveScript, и реализующий нужное нам поведение (или ничего не делающий). При создании объекта конфигурации CompilerConfiguration передаем его имя вместо оригинала.

CompilerConfiguration conf = new CompilerConfiguration();
conf.setScriptBaseClass("package.ArchiveScriptMock");

Замена методов в базовом классе скрипта

Другим вариантом без создания дополнительного mock класса может быть замена методов на mock в самом ArchiveScript. В groovy это можно сделать, например, таким способом:

ArchiveScript.metaClass.with {
checkState{t->true}
makeArchive{params->}
sendNotify{params->}
}
runScript()


Недостатком и первого и второго способа я бы считал необходимость написания дублирующей логики по проверки правильности передаваемых параметров. Так как если в ArchiveScriptMock метод makeArchive будет таким:

def makeArchive(params){
//makeArchiveInternal params.sourcePath, params.destPath, params.deleteSource
}

То мы не проверим все ли параметры были переданы. Нужно будет писать что-то похожее на это:

def makeArchive(params){
makeArchiveInternalMock params.sourcePath, params.destPath, params.deleteSource
}

Я бы предложил сделать небольшой рефакторинг ArchiveScript — сделать ArchiveScript фасадом и всю логику перенести в другой класс. Например, в Java класс Archive.
Рефакторинг — не только для целей тестирования, но и из других соображений, например, для отделения поведения от способа выполнения (нет зависимости от Script). В итоге, после изменения, ArchiveScript примет такой вид:

abstract class ArchiveScript extends Script{
Archive arc = new Archive()
def makeArchive(params){
arc.archive params.sourcePath,params.destPath, params.deleteSource
}

Теперь, можно тестировать логику и сценарий в отдельности. Заменим Archive на его mock и выполним скрипт:
//определяем mock методы
def mockedMethods = {
checkState {String type -> true}
makeArchive {String sourcePath, String destPath, Boolean deleteSource ->}
sendNotify {String to, String content ->}
}

//заменяем и выполняем скрипт так
Archive.metaClass.with(mockedMethods)
runScript()

//или так
StubFor stub = new StubFor(Archive);
stub.demand.with{
mockedMethods
}
stub.use{
runScript()
}

Естественно поведение Archive можно заменить и с помощью java mock фреймворков, однако, нам пока достаточно и этого.

Итог

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

Проект с исходными кодами проекта — groovydsl. Компилируется Gradle через враппер.

Некоторые идеи взяты из книги Groovy for Domain-Specific Languiages, Fergal Dearle, 2010

Enjoy!

Автор: SmoggIT

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js