accueilLogicielsDéveloppement et Qualité LogicielsÉcosystème Java
 

Créer des scripts évolutifs avec Groovy

Utilisation des propriétés Groovy et de l’annotation @ListenerList

Comment rendre un script Groovy monolithique plus évolutif en utilisant les atouts de Groovy.


Introduction

Quand il s’agit d’effectuer des tâches d’administration sur un serveur, on utilise souvent le shell, ou un langage tel que Python, Perl ou Ruby. Pour les personnes plus à l’aise avec Java, Groovy permet également la création de scripts, tout en utilisant la JVM et les librairies disponibles.

Pour illustrer cet article, je vais créer un script Groovy qui va récupérer les prévisions météo d’une ville, et effectuer un traitement sur la vitesse du vent (par exemple, envoyer un mail si la vitesse dépasse une valeur).

La gestion des cas limites et des exceptions n’est pas mise en œuvre dans cet exemple.

Script monolithique

La première version du script est la suivante :

Fichier checkWindSpeedMonolithique.groovy

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
import groovy.json.JsonSlurper

// Propriétés
final WEATHER_URL = 'http://api.openweathermap.org/data/2.5/forecast?q=Toulouse&units=metric'
final SPEED_THRESHOLD = 5.0

// Récupération des données
def jsonWeather = WEATHER_URL.toURL().text
def weather = new JsonSlurper().parseText(jsonWeather)

// On ne récupère pas le dernier élément qui n'est pas une prévision
weather.list[0..-2].each { forecast ->
	def date = new Date((forecast.dt as long) * 1000)
	def windSpeed = forecast.wind.speed as float

	if (windSpeed >= SPEED_THRESHOLD) {
		// Traitement particulier si la vitesse du vent dépasse un certain seuil
		println "ALERT: at $date, wind speed will be $windSpeed m/s"
	}
}

Ce script récupère les prévisions météo en appelant un WebService REST (ligne 8). Le résultat est en JSON et est transformé par Groovy (ligne 9). Ensuite, on utilise une closure pour parcourir chaque prévision, et on récupère la date de la prévision, ainsi que la vitesse de vent prévue. Enfin, si la vitesse du vent dépasse un certain seuil, alors on applique un traitement particulier (ici, simplement un affichage sur la console).

Ce script fonctionne mais on peut l’améliorer en externalisant les propriétés.

Script configurable

On peut utiliser simplement les Properties Java afin d’externaliser les propriétés dans un fichier. En Groovy, on peut également utiliser un fichier Groovy en tant que fichier de propriétés, grâce à la classe ConfigSlurper. Cela permet d’avoir une hiérarchie dans les propriétés (par exemple un bloc "server", un autre bloc "mails", etc...), de pouvoir gérer dans un seul fichier différents environnements (développement, recette, ...), et même d’y exécuter du code. De plus, les valeurs sont typées, on n’a donc pas besoin de transformer un String en float par exemple.

Notre fichier de configuration sera très simple :

Fichier simpleConfig.groovy

1
2
3
4
5
6
weather {
	city = 'Toulouse'
	host = "http://api.openweathermap.org/data/2.5/forecast?q=$city&units=metric"
}

windSpeedLimit = 6.5

On voit que l’on peut réutiliser une propriété dans une autre (la ville est réutilisée dans l’URL).

L’utilisation se fait ensuite en chargeant se fichier avec la commande :

def config = new ConfigSlurper().parse(new File(configFile).text)

On a alors accès aux propriétés directement, par exemple config.weather.host, ou config.windSpeedLimit.

Pour le script, on va utiliser ce fichier, qui sera passé en paramètre :

Fichier checkWindSpeedConfigurable.groovy

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import groovy.json.JsonSlurper

// Récupération de la config
def config = new ConfigSlurper().parse(new File(args[0]).text)

// Récupération des données
def jsonWeather = config.weather.host.toURL().text
def weather = new JsonSlurper().parseText(jsonWeather)

// On ne récupère pas le dernier élément qui n'est pas une prévision
weather.list[0..-2].each { forecast ->
	def date = new Date((forecast.dt as long) * 1000)
	def windSpeed = forecast.wind.speed as float

	if (windSpeed >= config.windSpeedLimit) {
		// Traitement particulier si la vitesse du vent dépasse un certain seuil
		println "ALERT: at $date, wind speed will be $windSpeed m/s"
	}
}

Ce script est appelé de la manière suivante :

groovy checkWindSpeedConfigurable.groovy simpleConfig.groovy

On voit notamment l’utilisation de la configuration :
- pour l’URL du WebService : def jsonWeather = config.weather.host.toURL().text, ligne 7
- pour la valeur du seuil : if (windSpeed >= config.windSpeedLimit), ligne 15

Le script est un peu mieux, mais une chose n’est pas externalisée : le traitement effectué. C’est bien normal, car c’est le but du script d’effectuer le traitement, on ne va donc pas le mettre ailleurs. Cependant, si on a besoin (en plus des alertes sur le vent affichées dans la console) de calculer la vitesse moyenne du vent (sans tenir compte du seuil), ou de les stocker dans un fichier, on est obligé de modifier le script.

Pour des cas simples, cela peut être intéressant de déporter la description de ce qu’il faut faire ailleurs.

Script évolutif

On va utiliser le fichier de configuration Groovy pour indiquer quels sont les traitements que l’on souhaite faire. Ce traitement implémentera une interface avec une seule méthode (c’est ce qu’on appelle une interface fonctionnelle en ces temps de sortie de Java 8).

On utilisera les mécanismes de Groovy pour charger ce code. Puis, à chaque donnée de vent que l’on traite, on va envoyer à toutes les implémentations de cette interface les valeurs en cours de traitement.

L’idée est de reproduire un fonctionnement de type Publish/Subscribe JMS, mais sans sortir l’artillerie lourde d’un Broker. Il existe pour cela au moins trois solutions :
- utiliser les Listener de Java (mais gérer à la main les abonnements)
- utiliser un bus d’événement tel que Guava Event Bus
- utiliser l’annotation Groovy @ListenerList

Nous allons utiliser la troisième méthode : l’annotation Groovy @ListenerList (voir la documentation et un exemple).

Cette annotation s’utilise de la manière suivante : on a notre interface fonctionnelle qui sera implémentée pour effectuer différents traitements. Dans le code, on définit une liste d’instance de classes implémentant cette interface, que l’on annote avec @ListenerList. Cette annotation va transformer le code pour y ajouter automatiquement les méthodes fire<nom_de_la_methode>. Par exemple, si la méthode de l’interface est doSomething, alors la méthode fireDoSomething sera créée, ainsi que d’autres (voir la Javadoc).

On commence par créer notre structure de données qui sera envoyée aux listeners :

Fichier WindData.groovy

1
2
3
4
5
class WindData {
	Date date
	float speed
	boolean lastEvent = false
}

On a la date et la vitesse du vent prévue, ainsi qu’un flag permettant d’identifier si l’événement émis est le dernier ou non.

On crée ensuite notre interface fonctionnelle :

Fichier WindEventListener.groovy

1
2
3
public interface WindEventListener {
	void processWindData(WindData windData)
}

Quant à notre script, il faut le transformer en classe Groovy, avec une méthode main, afin de pouvoir utiliser l’annotation :

Fichier CheckWindSpeedEvolutif.groovy

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import groovy.beans.ListenerList
import groovy.json.JsonSlurper

class CheckWindSpeedEvolutif {
	def configFile

	// Mise en place des listeners
	@ListenerList
	List<WindEventListener> windEventListeners

	public static void main(String[] args) {
		new CheckWindSpeedEvolutif(configFile: args[0]).start();
	}

	void start() {
		// Récupération de la config
		def config = new ConfigSlurper().parse(new File(configFile).text)

		// Récupération des données
		def jsonWeather = config.weather.host.toURL().text
		def weather = new JsonSlurper().parseText(jsonWeather)

		// On ne récupère pas le dernier élément qui n'est pas une prévision
		weather.list[0..-2].each { forecast ->
			def date = new Date((forecast.dt as long) * 1000)
			def windSpeed = forecast.wind.speed as float

			// Emission du message pour traitement
			fireProcessWindData(new WindData(date: date, speed: windSpeed))
		}

		// Emission du message de fin
		fireProcessWindData(new WindData(lastEvent: true))
	}
}

On a donc mis en place les listeners au début de la classe (lignes 8 et 9), et la closure a été modifiée pour envoyer chaque donnée de vent aux listeners, grâce à la méthode fireProcessWindData (ligne 29). Une fois toutes les données traitées, un événement indiquant la fin des données est émis (ligne 33).

Maintenant, comment définir notre traitement dans le fichier de configuration ?

  • directement sous forme de closure
  • sous forme de chemin vers un script Groovy
  • sous forme d’une instance d’une classe implémentant l’interface fonctionnelle

Voici un exemple de fichier de configuration avec ces trois cas :

Fichier complexConfig.groovy

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
weather {
	city = 'Toulouse'
	host = "http://api.openweathermap.org/data/2.5/forecast?q=$city&units=metric"
}

windSpeedLimit = 6.5

dispatchers = [
	{ windData ->
		if (!windData.lastEvent && windData.speed >= windSpeedLimit) {
			println "Attention, le $windData.date, le vent va souffler à $windData.speed m/s"
		}
	} as WindEventListener,

	'windToFileDispatcher.groovy',

	new WindStatistics()
]

On a défini un tableau dispatchers qui contient :

  • une closure, qui sera traitée par Groovy comme une implémentation de l’interface WindEventListener,
  • une chaîne de caractères indiquant l’emplacement d’un script Groovy (qui contient également une closure, voir plus loin)
  • une instance de la classe WindStatistics, qui implémente l’interface WindEventListener

Pour gérer ces cas, le code suivant peut être utilisé :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// Chargement des listeners
def shell = new GroovyShell(this.class.classLoader)
config.dispatchers.each { dispatcher ->
	switch (dispatcher) {
		case String: // On va charger le fichier
			def windListener = shell.evaluate(new File(dispatcher))
			addWindEventListener windListener
			break;

		default: // C'est une closure ou une classe
			addWindEventListener dispatcher
	}
}

Dans ce code, on parcourt chaque élément du tableau dispatchers, et en fonction du type de ces éléments, on charge le contenu d’un fichier si besoin. Puis, on ajoute le listener ainsi créé avec la méthode (auto-générée par l’annotation @ListenerList) addWindEventListener.

Code final

Le code final est constitué des fichiers suivants :

WindData.groovy

1
2
3
4
5
class WindData {
	Date date
	float speed
	boolean lastEvent = false
}

WindEventListener.groovy

1
2
3
public interface WindEventListener {
	void processWindData(WindData windData)
}

complexConfig.groovy

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
weather {
	city = 'Toulouse'
	host = "http://api.openweathermap.org/data/2.5/forecast?q=$city&units=metric"
}

windSpeedLimit = 6.5

dispatchers = [
	{ windData ->
		if (!windData.lastEvent && windData.speed >= windSpeedLimit) {
			println "Attention, le $windData.date, le vent va souffler à $windData.speed m/s"
		}
	} as WindEventListener,

	'windToFileDispatcher.groovy',

	new WindStatistics()
]

windToFileDispatcher.groovy

1
2
3
4
5
{ windData ->
	if (!windData.lastEvent) {
		new File('data.txt').append("$windData.date,$windData.speed\n")
	}
} as WindEventListener

WindStatistics.groovy

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class WindStatistics implements WindEventListener {
	int count = 0
	float sum = 0
	@Override
	void processWindData(final WindData windData) {
		if (windData.lastEvent) {
			println "Vitesse moyenne = ${sum / count} m/s"
		} else {
			sum += windData.speed
			count++
		}
	}
}

CheckWindSpeedEvolutif.groovy

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import groovy.beans.ListenerList
import groovy.json.JsonSlurper

class CheckWindSpeedEvolutif {
	def configFile

	// Mise en place des listeners
	@ListenerList
	List<WindEventListener> windEventListeners

	public static void main(String[] args) {
		new CheckWindSpeedEvolutif(configFile: args[0]).start();
	}

	void start() {
		// Récupération de la config
		def config = new ConfigSlurper().parse(new File(configFile).text)

		// Récupération des données
		def jsonWeather = config.weather.host.toURL().text
		def weather = new JsonSlurper().parseText(jsonWeather)

		// Chargement des listeners
		def shell = new GroovyShell(this.class.classLoader)
		config.dispatchers.each { dispatcher ->
			switch (dispatcher) {
				case String: // On va charger le fichier
					def windListener = shell.evaluate(new File(dispatcher))
					addWindEventListener windListener
					break;

				default: // C'est une closure ou une classe
					addWindEventListener dispatcher
			}
		}

		// On ne récupère pas le dernier élément qui n'est pas une prévision
		weather.list[0..-2].each { forecast ->
			def date = new Date((forecast.dt as long) * 1000)
			def windSpeed = forecast.wind.speed as float

			// Emission du message pour traitement
			fireProcessWindData(new WindData(date: date, speed: windSpeed))
		}

		// Emission du message de fin
		fireProcessWindData(new WindData(lastEvent: true))
	}
}

Quand on lance ce code, avec

groovy CheckWindSpeedEvolutif complexConfig.groovy

on obtient le résultat suivant dans la console :

Attention, le Sun Mar 23 10:00:00 CET 2014, le vent va souffler à 7.09 m/s
Attention, le Sun Mar 23 16:00:00 CET 2014, le vent va souffler à 6.66 m/s
Attention, le Sun Mar 23 19:00:00 CET 2014, le vent va souffler à 6.69 m/s
Vitesse moyenne = 3.9067501068115233 m/s

De plus, le fichier data.txt est créé avec le contenu suivant :

Wed Mar 19 10:00:00 CET 2014,2.19
Wed Mar 19 13:00:00 CET 2014,2.63
Wed Mar 19 16:00:00 CET 2014,3.52
Wed Mar 19 19:00:00 CET 2014,1.6
Wed Mar 19 22:00:00 CET 2014,1.21
Thu Mar 20 01:00:00 CET 2014,1.3
[...]
Mon Mar 24 01:00:00 CET 2014,3.66
Mon Mar 24 04:00:00 CET 2014,3.31
Mon Mar 24 07:00:00 CET 2014,1.76

Conclusion

Groovy permet d’avoir des fichiers de configuration qui vont beaucoup plus loin qu’un simple fichier properties, puisqu’il est possible d’y mettre du code Groovy.

L’utilisation de l’annotation @ListenerList permet d’avoir très simplement un bus d’événement dans notre application ou script à moindre coût.

Pour des cas simples, il est possible de combiner les deux afin de pouvoir ajouter des fonctionnalités à un script sans avoir besoin de le modifier.

Cependant, il ne faut pas en abuser afin de ne pas rendre le fichier de configuration incompréhensible, notamment dès que les traitements deviennent plus complexes. De plus, il faut que les classes utilisées par les implémentations de l’interface fonctionnelle (via des closures ou une classe) soient disponibles dans le classpath du script, ce qui n’est pas forcément le cas.

 

Stéphane DERACO
Envoyer un courriel

 

Licence Creative Commons


ARESU
Direction des Systèmes d'Information du CNRS

358 rue P.-G. de Gennes
31676 LABEGE Cedex

Bâtiment 1,
1 Place Aristide Briand
92195 MEUDON Cedex



 

 

Direction des Systèmes d'Information

Pôle ARESU

Accueil Imprimer Plan du site Credits