Integrazione di Groovy nelle applicazioni Java

1. Introduzione

In questo tutorial, esploreremo le ultime tecniche per integrare Groovy in un'applicazione Java.

2. Qualche parola su Groovy

Il linguaggio di programmazione Groovy è un linguaggio potente, digitato opzionalmente e dinamico . È supportato dalla Apache Software Foundation e dalla comunità Groovy, con il contributo di oltre 200 sviluppatori.

Può essere utilizzato per costruire un'intera applicazione, per creare un modulo o una libreria aggiuntiva che interagisce con il nostro codice Java, oppure per eseguire script valutati e compilati al volo.

Per ulteriori informazioni, leggi Introduzione a Groovy Language o vai alla documentazione ufficiale.

3. Dipendenze di Maven

Al momento della scrittura, l'ultima versione stabile è la 2.5.7, mentre Groovy 2.6 e 3.0 (entrambi avviati nell'autunno '17) sono ancora in fase alpha.

Simile a Spring Boot, dobbiamo solo includere il groovy-all pom per aggiungere tutte le dipendenze di cui potremmo aver bisogno, senza preoccuparci delle loro versioni:

 org.codehaus.groovy groovy-all ${groovy.version} pom 

4. Compilazione congiunta

Prima di entrare nei dettagli di come configurare Maven, dobbiamo capire con cosa abbiamo a che fare.

Il nostro codice conterrà file Java e Groovy . Groovy non avrà alcun problema a trovare le classi Java, ma cosa succede se vogliamo che Java trovi classi e metodi Groovy?

Ecco che arriva la compilation congiunta in soccorso!

La compilazione congiunta è un processo progettato per compilare file Java e Groovy nello stesso progetto, in un unico comando Maven.

Con la compilazione congiunta, il compilatore Groovy:

  • analizzare i file di origine
  • a seconda dell'implementazione, creare stub compatibili con il compilatore Java
  • invoca il compilatore Java per compilare gli stub insieme ai sorgenti Java - in questo modo le classi Java possono trovare le dipendenze di Groovy
  • compilare i sorgenti Groovy - ora i nostri sorgenti Groovy possono trovare le loro dipendenze Java

A seconda del plug-in che lo implementa, potrebbe essere necessario separare i file in cartelle specifiche o indicare al compilatore dove trovarli.

Senza la compilazione congiunta, i file sorgente Java verrebbero compilati come se fossero sorgenti Groovy. A volte questo potrebbe funzionare poiché la maggior parte della sintassi di Java 1.7 è compatibile con Groovy, ma la semantica sarebbe diversa.

5. Plugin del compilatore Maven

Sono disponibili alcuni plugin per compilatori che supportano la compilazione congiunta , ciascuno con i suoi punti di forza e di debolezza.

I due più comunemente usati con Maven sono Groovy-Eclipse Maven e GMaven +.

5.1. Il plugin Groovy-Eclipse Maven

Il plugin Groovy-Eclipse Maven semplifica la compilazione congiunta evitando la generazione di stub , ancora un passaggio obbligatorio per altri compilatori come GMaven + , ma presenta alcune stranezze di configurazione.

Per abilitare il recupero degli artefatti del compilatore più recenti, dobbiamo aggiungere il repository Maven Bintray:

  bintray Groovy Bintray //dl.bintray.com/groovy/maven   never   false   

Quindi, nella sezione plugin, diciamo al compilatore Maven quale versione del compilatore Groovy deve usare.

In effetti, il plug-in che utilizzeremo, il plug-in del compilatore Maven, in realtà non si compila, ma delega invece il lavoro all'artefatto batch groovy-eclipse :

 maven-compiler-plugin 3.8.0  groovy-eclipse-compiler ${java.version} ${java.version}    org.codehaus.groovy groovy-eclipse-compiler 3.3.0-01   org.codehaus.groovy groovy-eclipse-batch ${groovy.version}-01   

La versione delle dipendenze groovy-all dovrebbe corrispondere alla versione del compilatore.

Infine, dobbiamo configurare la nostra autodiscovery sorgente: per impostazione predefinita, il compilatore esaminerà cartelle come src / main / java e src / main / groovy, ma se la nostra cartella java è vuota, il compilatore non cercherà il nostro groovy fonti .

Lo stesso meccanismo è valido per i nostri test.

Per forzare la scoperta dei file, potremmo aggiungere qualsiasi file in src / main / java e src / test / java , o semplicemente aggiungere il plug -in groovy-eclipse-compiler :

 org.codehaus.groovy groovy-eclipse-compiler 3.3.0-01 true 

Il è obbligatoria per consentire al plugin di aggiungere la fase di build extra e gli obiettivi, contenenti le due cartelle sorgenti di Groovy.

5.2. Il plugin GMavenPlus

Il plug-in GMavenPlus potrebbe avere un nome simile al vecchio plug-in GMaven, ma invece di creare una semplice patch, l'autore si è sforzato di semplificare e disaccoppiare il compilatore da una specifica versione di Groovy .

Per fare ciò, il plugin si separa dalle linee guida standard per i plugin del compilatore.

Il compilatore GMavenPlus aggiunge il supporto per funzionalità che all'epoca non erano ancora presenti in altri compilatori , come invokedynamic, la console della shell interattiva e Android.

D'altra parte, presenta alcune complicazioni:

  • si modifica la directory di origine di Maven per contenere sia il Java e le fonti Groovy, ma non gli stub Java
  • ci richiede di gestire gli stub se non li eliminiamo con gli obiettivi appropriati

Per configurare il nostro progetto, dobbiamo aggiungere il plugin gmavenplus:

 org.codehaus.gmavenplus gmavenplus-plugin 1.7.0    execute addSources addTestSources generateStubs compile generateTestStubs compileTests removeStubs removeTestStubs      org.codehaus.groovy groovy-all = 1.5.0 should work here --> 2.5.6 runtime pom   

Per consentire il test di questo plugin, abbiamo creato un secondo file pom chiamato gmavenplus-pom.xml nell'esempio.

5.3. Compilazione con il plugin Eclipse-Maven

Ora che tutto è configurato, possiamo finalmente costruire le nostre classi.

Nell'esempio che abbiamo fornito, abbiamo creato una semplice applicazione Java nella cartella sorgente src / main / java e alcuni script Groovy in src / main / groovy , dove possiamo creare classi e script Groovy.

Costruiamo tutto con il plugin Eclipse-Maven:

$ mvn clean compile ... [INFO] --- maven-compiler-plugin:3.8.0:compile (default-compile) @ core-groovy-2 --- [INFO] Changes detected - recompiling the module! [INFO] Using Groovy-Eclipse compiler to compile both Java and Groovy files ...

Qui vediamo che Groovy sta compilando tutto .

5.4. Compilazione con GMavenPlus

GMavenPlus mostra alcune differenze:

$ mvn -f gmavenplus-pom.xml clean compile ... [INFO] --- gmavenplus-plugin:1.7.0:generateStubs (default) @ core-groovy-2 --- [INFO] Using Groovy 2.5.7 to perform generateStubs. [INFO] Generated 2 stubs. [INFO] ... [INFO] --- maven-compiler-plugin:3.8.1:compile (default-compile) @ core-groovy-2 --- [INFO] Changes detected - recompiling the module! [INFO] Compiling 3 source files to XXX\Baeldung\TutorialsRepo\core-groovy-2\target\classes [INFO] ... [INFO] --- gmavenplus-plugin:1.7.0:compile (default) @ core-groovy-2 --- [INFO] Using Groovy 2.5.7 to perform compile. [INFO] Compiled 2 files. [INFO] ... [INFO] --- gmavenplus-plugin:1.7.0:removeStubs (default) @ core-groovy-2 --- [INFO] ...

Notiamo subito che GMavenPlus passa attraverso i passaggi aggiuntivi di:

  1. Generazione di stub, uno per ogni file groovy
  2. Compilazione dei file Java - stub e codice Java allo stesso modo
  3. Compilazione dei file Groovy

Generando stub, GMavenPlus eredita una debolezza che ha causato molti mal di testa agli sviluppatori negli ultimi anni, quando si lavorava con la compilazione congiunta.

Nello scenario ideale, tutto funzionerebbe bene, ma introducendo più passaggi abbiamo anche più punti di errore: ad esempio, la compilazione potrebbe non riuscire prima di poter ripulire gli stub.

If this happens, old stubs left around may confuse our IDE, which would then show compilation errors where we know everything should be correct.

Only a clean build would then avoid a painful and long witch hunt.

5.5. Packaging Dependencies in the Jar File

To run the program as a jar from the command line, we added the maven-assembly-plugin, which will include all the Groovy dependencies in a “fat jar” named with the postfix defined in the property descriptorRef:

 org.apache.maven.plugins maven-assembly-plugin 3.1.0    jar-with-dependencies     com.baeldung.MyJointCompilationApp      make-assembly  package  single    

Once the compilation is complete we can run our code with this command:

$ java -jar target/core-groovy-2-1.0-SNAPSHOT-jar-with-dependencies.jar com.baeldung.MyJointCompilationApp

6. Loading Groovy Code on the Fly

The Maven compilation let us include Groovy files in our project and reference their classes and methods from Java.

Although, this is not enough if we want to change the logic at runtime: the compilation runs outside the runtime stage, so we still have to restart our application in order to see our changes.

To take advantage of the dynamic power (and risks) of Groovy, we need to explore the techniques available to load our files when our application is already running.

6.1. GroovyClassLoader

To achieve this, we need the GroovyClassLoader, which can parse source code in text or file format and generate the resulting class objects.

When the source is a file, the compilation result is also cached, to avoid overhead when we ask the loader multiple instances of the same class.

Script coming directly from a String object, instead, won't be cached, hence calling the same script multiple times could still cause memory leaks.

GroovyClassLoader is the foundation other integration systems are built on.

The implementation is relatively simple:

private final GroovyClassLoader loader; private Double addWithGroovyClassLoader(int x, int y) throws IllegalAccessException, InstantiationException, IOException { Class calcClass = loader.parseClass( new File("src/main/groovy/com/baeldung/", "CalcMath.groovy")); GroovyObject calc = (GroovyObject) calcClass.newInstance(); return (Double) calc.invokeMethod("calcSum", new Object[] { x, y }); } public MyJointCompilationApp() { loader = new GroovyClassLoader(this.getClass().getClassLoader()); // ... } 

6.2. GroovyShell

The Shell Script Loader parse() method accepts sources in text or file format and generates an instance of the Script class.

This instance inherits the run() method from Script, which executes the entire file top to bottom and returns the result given by the last line executed.

If we want to, we can also extend Script in our code, and override the default implementation to call directly our internal logic.

The implementation to call Script.run() looks like this:

private Double addWithGroovyShellRun(int x, int y) throws IOException { Script script = shell.parse(new File("src/main/groovy/com/baeldung/", "CalcScript.groovy")); return (Double) script.run(); } public MyJointCompilationApp() { // ... shell = new GroovyShell(loader, new Binding()); // ... } 

Please note that the run() doesn't accept parameters, so we would need to add to our file some global variables initialize them through the Binding object.

As this object is passed in the GroovyShell initialization, the variables are shared with all the Script instances.

If we prefer a more granular control, we can use invokeMethod(), which can access our own methods through reflection and pass arguments directly.

Let's look at this implementation:

private final GroovyShell shell; private Double addWithGroovyShell(int x, int y) throws IOException { Script script = shell.parse(new File("src/main/groovy/com/baeldung/", "CalcScript.groovy")); return (Double) script.invokeMethod("calcSum", new Object[] { x, y }); } public MyJointCompilationApp() { // ... shell = new GroovyShell(loader, new Binding()); // ... } 

Under the covers, GroovyShell relies on the GroovyClassLoader for compiling and caching the resulting classes, so the same rules explained earlier apply in the same way.

6.3. GroovyScriptEngine

The GroovyScriptEngine class is particularly for those applications which rely on the reloading of a script and its dependencies.

Although we have these additional features, the implementation has only a few small differences:

private final GroovyScriptEngine engine; private void addWithGroovyScriptEngine(int x, int y) throws IllegalAccessException, InstantiationException, ResourceException, ScriptException { Class calcClass = engine.loadScriptByName("CalcMath.groovy"); GroovyObject calc = calcClass.newInstance(); Object result = calc.invokeMethod("calcSum", new Object[] { x, y }); LOG.info("Result of CalcMath.calcSum() method is {}", result); } public MyJointCompilationApp() { ... URL url = null; try { url = new File("src/main/groovy/com/baeldung/").toURI().toURL(); } catch (MalformedURLException e) { LOG.error("Exception while creating url", e); } engine = new GroovyScriptEngine(new URL[] {url}, this.getClass().getClassLoader()); engineFromFactory = new GroovyScriptEngineFactory().getScriptEngine(); }

This time we have to configure source roots, and we refer to the script with just its name, which is a bit cleaner.

Looking inside the loadScriptByName method, we can see right away the check isSourceNewer where the engine checks if the source currently in cache is still valid.

Every time our file changes, GroovyScriptEngine will automatically reload that particular file and all the classes depending on it.

Although this is a handy and powerful feature, it could cause a very dangerous side effect: reloading many times a huge number of files will result in CPU overhead without warning.

If that happens, we may need to implement our own caching mechanism to deal with this issue.

6.4. GroovyScriptEngineFactory (JSR-223)

JSR-223 provides a standard API for calling scripting frameworks since Java 6.

The implementation looks similar, although we go back to loading via full file paths:

private final ScriptEngine engineFromFactory; private void addWithEngineFactory(int x, int y) throws IllegalAccessException, InstantiationException, javax.script.ScriptException, FileNotFoundException { Class calcClas = (Class) engineFromFactory.eval( new FileReader(new File("src/main/groovy/com/baeldung/", "CalcMath.groovy"))); GroovyObject calc = (GroovyObject) calcClas.newInstance(); Object result = calc.invokeMethod("calcSum", new Object[] { x, y }); LOG.info("Result of CalcMath.calcSum() method is {}", result); } public MyJointCompilationApp() { // ... engineFromFactory = new GroovyScriptEngineFactory().getScriptEngine(); }

It's great if we are integrating our app with several scripting languages, but its feature set is more restricted. For example, it doesn't support class reloading. As such, if we are only integrating with Groovy, then it may be better to stick with earlier approaches.

7. Pitfalls of Dynamic Compilation

Using any of the methods above, we could create an application that reads scripts or classes from a specific folder outside our jar file.

This would give us the flexibility to add new features while the system is running (unless we require new code in the Java part), thus achieving some sort of Continuous Delivery development.

But beware this double-edged sword: we now need to protect ourselves very carefully from failures that could happen both at compile time and runtime, de facto ensuring that our code fails safely.

8. Pitfalls of Running Groovy in a Java Project

8.1. Performance

We all know that when a system needs to be very performant, there are some golden rules to follow.

Two that may weigh more on our project are:

  • avoid reflection
  • minimize the number of bytecode instructions

Reflection, in particular, is a costly operation due to the process of checking the class, the fields, the methods, the method parameters, and so on.

If we analyze the method calls from Java to Groovy, for example, when running the example addWithCompiledClasses, the stack of operation between .calcSum and the first line of the actual Groovy method looks like:

calcSum:4, CalcScript (com.baeldung) addWithCompiledClasses:43, MyJointCompilationApp (com.baeldung) addWithStaticCompiledClasses:95, MyJointCompilationApp (com.baeldung) main:117, App (com.baeldung)

Which is consistent with Java. The same happens when we cast the object returned by the loader and call its method.

However, this is what the invokeMethod call does:

calcSum:4, CalcScript (com.baeldung) invoke0:-1, NativeMethodAccessorImpl (sun.reflect) invoke:62, NativeMethodAccessorImpl (sun.reflect) invoke:43, DelegatingMethodAccessorImpl (sun.reflect) invoke:498, Method (java.lang.reflect) invoke:101, CachedMethod (org.codehaus.groovy.reflection) doMethodInvoke:323, MetaMethod (groovy.lang) invokeMethod:1217, MetaClassImpl (groovy.lang) invokeMethod:1041, MetaClassImpl (groovy.lang) invokeMethod:821, MetaClassImpl (groovy.lang) invokeMethod:44, GroovyObjectSupport (groovy.lang) invokeMethod:77, Script (groovy.lang) addWithGroovyShell:52, MyJointCompilationApp (com.baeldung) addWithDynamicCompiledClasses:99, MyJointCompilationApp (com.baeldung) main:118, MyJointCompilationApp (com.baeldung)

In this case, we can appreciate what's really behind Groovy's power: the MetaClass.

A MetaClass defines the behavior of any given Groovy or Java class, so Groovy looks into it whenever there's a dynamic operation to execute in order to find the target method or field. Once found, the standard reflection flow executes it.

Two golden rules broken with one invoke method!

If we need to work with hundreds of dynamic Groovy files, how we call our methods will then make a huge performance difference in our system.

8.2. Method or Property Not Found

As mentioned earlier, if we want to deploy new versions of Groovy files in a CD life cycle, we need to treat them like they were an API separate from our core system.

This means putting in place multiple fail-safe checks and code design restrictions so our newly joined developer doesn't blow up the production system with a wrong push.

Examples of each are: having a CI pipeline and using method deprecation instead of deletion.

What happens if we don't? We get dreadful exceptions due to missing methods and wrong argument counts and types.

And if we think that compilation would save us, let's look at the method calcSum2() of our Groovy scripts:

// this method will fail in runtime def calcSum2(x, y) { // DANGER! The variable "log" may be undefined log.info "Executing $x + $y" // DANGER! This method doesn't exist! calcSum3() // DANGER! The logged variable "z" is undefined! log.info("Logging an undefined variable: $z") }

By looking through the entire file, we immediately see two problems: the method calcSum3() and the variable z are not defined anywhere.

Anche così, lo script viene compilato correttamente, senza nemmeno un singolo avviso, sia staticamente in Maven che dinamicamente in GroovyClassLoader.

Fallirà solo quando proveremo a invocarlo.

La compilazione statica di Maven mostrerà un errore solo se il nostro codice Java si riferisce direttamente a calcSum3 () , dopo aver lanciato GroovyObject come facciamo nel metodo addWithCompiledClasses () , ma è ancora inefficace se usiamo invece la reflection.

9. Conclusione

In questo articolo, abbiamo esplorato come possiamo integrare Groovy nella nostra applicazione Java, esaminando diversi metodi di integrazione e alcuni dei problemi che potremmo incontrare con linguaggi misti.

Come al solito, il codice sorgente utilizzato negli esempi può essere trovato su GitHub.