What for?
When developing various kinds of software, for example, monitoring or maintenance tasks, I encounter the need for support in the program of executing scripts that describe or perform a certain set of actions. And the specifics are such that adding or modifying such scenarios in software should not require rebuilding or restarting software.
Probably the simplest examples of such scenarios that everyone encountered in one form or another can be ordinary batch files — bat or sh.
In my practice, I sometimes use XML to describe scripts. In the case of a well-defined set of actions and their simple flat set without branches, XML is not bad to use. Here there is a fixed structure that allows validating the script file, and the simplicity of the XML language itself, which allows not only programmers to work with it. However, to implement branching or conditional logic in XML, in my opinion, is very expensive and akin to writing your own mini language. Expanding the set of actions is most often possible only when the source code of the program is changed, and the implementation of XML script support is initially labor intensive.
In general, in search of a simpler and more functional tool for describing scenarios, the gaze was turned to scripting languages. As a Java platform, I wanted to be able to integrate the scripting language and Java. As a result, the choice fell on Groovy - a dynamic JVM-based language that is easily integrated with Java, simple and expressive, and has many useful features for our task.
')
How?
Scripts
I will not tell the basics of Groovy, since there are already plenty of materials on the web even in Russian. I will dwell only on some key points for us.
Groovy allows us to execute non-compiled source Groovy code from Java code, which allows us to execute scripts added or modified in runtime.
Consider the example of running the Groovy script in Java. To support Groovy in your Java project, you need to add only one “groovy” library of the version you need depending on it.
Let's write the following Groovy code in the x: \ GroovyScript.groovy file:
println "Groovy script"
multi = {
num1, num2 -> num1 * num2
}
multi ( 4 , 4 )
The code for executing this script in your Java code may be as follows:
GroovyShell shell = new GroovyShell ( ) ;
Object result = shell. evaluate ( new File ( "x: /GroovyScript.groovy" ) ) ;
System . out . println ( "result =" + result ) ;
As a result of the execution, 2 lines will be output to the console, the first of the script, the second from Java, with the result of the script execution:
Groovy script
result=16
The example does not carry the functional load in the script, however, it shows the possibilities of dynamic loading and execution - just two lines of code and we get the ability to execute scripts.
A little bit about Java code. GroovyShell is the class provided by Groovy for executing groovy scripts. There are other ways to run groovy scripts, see
here for more details.
DSL
DSL is a domain-specific language or domain-specific language. A language that allows the use of basic domain operations through a set of simple and clear high-level functions that hides their implementation from the user.
In the example above, the code is quite simple, but in a real scenario it can be very large and complex. And only groovy-developers will be able to work with such scripts, it will be difficult to avoid errors without testing. In the case of previously known operations in scripts, all business logic can be put into code (java or groovy - it does not matter) and provide the ability to use it through a set of functions.
Consider a small example. It is required to write a script that will perform archiving, unpacking of archives, deletion, some checks and notifications.
One of the pieces of the script could be this - check the status of the process and, if it is completed, archive the catalog and send a notification:
// 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 ... )
The code is quite large, and it will be understood, mostly, only by programmers. Let's simplify it and render the three specified actions to the ArchiveScript class with static methods. Script after making methods:
import archiveScript
if ( ArchiveScript. checkState ( ) ) {
ArchiveScript. makeArchive ( .. )
} else {
// doAnotherLogic
return
}
ArchiveScript. sendNotify ( ... )
Better already? “Better, but there are still artifacts — import and class names that would also be worth removing.” And in Groovy there is a similar opportunity - the ability to set a base class for a script outside the script itself. The ArchiveScript class for this must be inherited from Script and the methods may not be static. At the same time, the script code is simplified - the import and the class prefix disappear:
if ( checkState ( ) ) {
makeArchive ( .. )
} else {
// doAnotherLogic
Return
}
sendNotify ( ... )
Already good enough. If the code inside the conditional single-line branching block, you can also give up curly braces. And in the case of Groovy, often from the brackets to the right of the method name. The script execution code is a bit more complicated - you need to create a CompilerConfiguration object, set the value of scriptBaseClass equal to the name of the ArchiveScript class we created, and pass this object to GroovyShell:
CompilerConfiguration conf = new CompilerConfiguration ( ) ;
conf. setScriptBaseClass ( "package.ArchiveScript" ) ;
GroovyShell shell = new GroovyShell ( conf ) ;
Next, let's consider how the parameters of the methods are set in the script when invoked. In the case of the definition in the class ArchiveScript method makeArchive in this form:
def makeArchive ( sourcePath, destPath, deleteSource )
In a script, the call would look like this:
makeArchive ( "x: / aaa /" , "x: /a.zip" , true )
// or so
makeArchive "x: / aaa /" , "x: /a.zip" , true
And if we talk about visibility and even convenience, Groovy allows us to make the transfer of parameters through named parameters, like this:
makeArchive sourcePath: 'x: / aaa /' ,
destPath: 'x: /a.zip' ,
deleteSource: true
However, in this case, the parameters will be passed inside the HashMap and, accordingly, getting the parameters in makeArchive in the ArchiveScript class should be like this:
def makeArchive ( params ) {
makeArchiveInternal params. sourcePath , params. destPath , params. deleteSource
}
If we apply the transformation for other calls, then ultimately our script could look like this:
if ( checkState ( 'SomeData' ) ) {
makeArchive sourcePath: 'c: / 1 /*.*' ,
destPath: 'c: /testarch.zip' ,
deleteSource: true
} else {
// doAnotherLogic
Return
}
sendNotify to: 'aaa@gdsl.ru' , content: 'message'
And this is not too complicated and quite readable code.
Thus, we got our mini DSL with several predefined functions specific to our task. We also still have the opportunity to use the full power of the source language.
I note that I have considered only a fraction of the development of DSL. Groovy has broader
support for developing your DSL, as well as DSL support
for Eclipse and
IntelliJ Idea.Testing
I would like to say a few words about testing scripts. No matter how simple the script, errors can be in it. Even if you write scripts in the IDE, you may not receive a full-fledged check for correct syntax. This is only possible with its implementation. It is also necessary to check the behavior of the script.
Since we would not like to perform real actions when testing a script, we need to somehow replace the real logic with imitation. Groovy allows us to do this in many ways. I will show a few of them.
Replacing the base script
We create a new class ArchiveSciptMock which has an interface similar to ArchiveScript, and implementing the behavior we need (or doing nothing). When creating a CompilerConfiguration configuration object, we transfer its name instead of the original.
CompilerConfiguration conf = new CompilerConfiguration ( ) ;
conf. setScriptBaseClass ( "package.ArchiveScriptMock" ) ;
Replacing methods in the base script class
Another option without creating an additional mock class could be to replace the methods with a mock in ArchiveScript itself. In groovy, this can be done, for example, in this way:
ArchiveScript. metaClass . with {
checkState { t -> true }
makeArchive { params -> }
sendNotify { params -> }
}
runScript ( )
The disadvantage of the first and second methods I would consider the need to write duplicate logic to verify the correctness of the transmitted parameters. So how if in ArchiveScriptMock the makeArchive method is:
def makeArchive ( params ) {
// makeArchiveInternal params.sourcePath, params.destPath, params.deleteSource
}
Then we will not check whether all the parameters have been transferred. You will need to write something like this:
def makeArchive ( params ) {
makeArchiveInternalMock params. sourcePath , params. destPath , params. deleteSource
}
I would suggest doing a small refactoring of ArchiveScript - make ArchiveScript a facade and transfer all logic to another class. For example, in the Java class Archive.
Refactoring - not only for testing purposes, but also for other reasons, for example, for separating behavior from execution method (no dependency on Script). As a result, after the change, ArchiveScript will look like this:
abstract class ArchiveScript extends Script {
Archive arc = new Archive ( )
def makeArchive ( params ) {
arc. archive params. sourcePath , params. destPath , params. deleteSource
}
...
Now, you can test the logic and script separately. Replace Archive with his mock and execute the script:
// define the mock methods
def mockedMethods = {
checkState { String type -> true }
makeArchive { String sourcePath, String destPath, Boolean deleteSource -> }
sendNotify { String to, String content -> }
}
// replace and execute the script
Archive. metaClass . with ( mockedMethods )
runScript ( )
// or so
StubFor stub = new StubFor ( Archive ) ;
stub. demand . with {
mockedMethods
}
stub. use {
runScript ( )
}
Naturally, the behavior of Archive can be replaced with the help of java mock frameworks, however, this is still enough for us.
Total
I believe that I got a fairly flexible tool for writing scripts, without flaws, voiced at the beginning of the text, and also quite easy to use. The control of the correctness of the script was also not lost - the use of mock behavior allows us to test them sufficiently before actual execution.
The project with the source code of the project -
groovydsl . Gradle compiles through a wrapper.
Some ideas are taken from Groovy for Domain-Specific Languiages, Fergal Dearle, 2010
Enjoy!