Implémenter automatiquement un service de données avec GORM

Avec sa nouvelle version 6.1, la librairie d'accès aux données GORM pour Hibernate, utilisée par le framework web Grails (mais pouvant également s'en affranchir), propose l'implémentation automatique de services de données (data services).
En quoi cela diffère t-il des opérations CRUD qui sont injectés dans les objets métiers pris en charge par GORM, et des différentes manières d'effectuer des requêtes à partir des types de ces objets ?

Les services de données GORM vont plus loin : ils ont la capacité d'implémenter automatiquement pour vous des interfaces et des classes abstraites, pour faciliter l'implémentation de la logique de persistance et de requêtage des objets métiers.

Les aspects intéressants de cette approche sont :

  • le typage fort ;
  • la performance, car les services générés sont produits au moment de la compilation (transformations AST) ;
  • la gestion transactionnelle automatique.

Examinons ensemble un exemple d'application Grails 3.3 classique, odelia-gina-dataservices, partagé sur Bitbucket, et définissant un service de données appelé NoteService portant sur l'objet métier Note, destiné à conserver des notes.

On se limitera dans cet article à la description de quelques méthodes de service.

La classe métier Note

La classe métier Note y est déclarée ainsi :

1
2
3
4
5
6
7
8
class Note {
    Date dateCreated
    String text

    static constraints = {
        text blank: false
    }
}

La classe Note comprend les propriétés dateCreated et text, dateCreated faisant l'objet d'une prise en charge particulière par GORM (qui renseignera automatiquement sa valeur avec la date d'enregistrement de l'objet en base de données).
Dans l'application d'exemple, on crée d'ailleurs plusieurs objets Note en base de données lors du démarrage, dans la closure init de la classe BootStrap :

1
2
3
4
5
def init = { servletContext ->
    ['Hello!', 'Salut !', 'Hola !', 'Ciao !'].each {
        new Note(text: it).save()
    }
}

Définition du service de données NoteService

Apparaissant dans la couche services de l'application Grails, le service de données est simplement une interface, NoteService, annotée avec @grails.gorm.services.Service permettant de définir sur quelle entité métier le service de données porte :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import grails.gorm.services.Service
import grails.gorm.services.Where

@Service(Note)
interface NoteService {
    List<Note> listNotes(Map args)

    Note getNote(Serializable id)

    Note save(String text)

    List<Note> findAllByTextLike(String pattern, Map args)

    @Where({ text ==~ pattern && dateCreated > fromDate })
    List<Note> searchNotes(String pattern, Date fromDate, Map args)
}

Grâce à l'annotation @Service, et à un ensemble de convention sur les noms des méthodes de l'interface, GORM pourvoit automatiquement à leurs implémentations lors de la compilation.

Comme l'on peut s'en douter, compte-tenu de son nom et de sa signature, la méthode de service listNotes permet de renvoyer une liste d'entités Note enregistrées en base de données. Le paramètre args de type Map permettra de passer sous la forme d'une Map Java, un ensemble de paramètres auxquels les développeurs Grails sont habitués pour effectuer de la pagination, ou demander un tri particulier par exemple.

Voyons maintenant comment invoquer le service de données depuis le contrôleur NoteController défini dans l'application d'exemple.

Invoquer le service de données NoteService

Une référence au service de données s'obtient facilement par le mécanisme d'injection de Spring, avec l'annotation @Autowired.

C'est ce que l'on retrouve dans le contrôleur NoteController du projet d'exemple :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@GrailsCompileStatic
class NoteController {
    static defaultAction = "list"

    @Autowired NoteService noteService

    // URL: http://localhost:8080/note/list or http://localhost:8080/note
    // (because of defaulft action)
    def list() {
        def max = Math.min(params.int('max') ?: 10, 100)
        render noteService.listNotes([max: max]) as JSON
    }

    // ...
}

Vous pouvez constater que l'action du contrôleur list() appelle la méthode listNotes() du service de données, dont on obtient une référence dans la propriété noteService du contrôleur par injection.
La méthode NoteService.listNotes retourne une liste d'entités Note qui est rendue par l'action sous la forme d'une liste d'objets JSON.

Obtenir la liste des notes

A l'exécution, en mode développement, en naviguant vers l'URL http://localhost:8080/note/list, vous obtiendriez quelque chose comme :

[{"id":1,"dateCreated":"2017-08-23T11:31:56Z","text":"Hello!"},{"id":2,"dateCreated":"2017-08-23T11:31:56Z","text":"Salut !"},{"id":3,"dateCreated":"2017-08-23T11:31:56Z","text":"Hola !"},{"id":4,"dateCreated":"2017-08-23T11:31:56Z","text":"Ciao !"}]

Remarquez que dans l'appel à la méthode listNotes() du service NoteService, on transmet une Map contenant une entrée avec la clé max fixant le nombre maximal d'éléments à retourner.
Note : la valeur associée à cette clé peut être influencée par la présence d'un paramètre d'URL max déclenchant l'action du contrôleur (par exemple, si l'on souhaite limiter le nombre d'éléments à afficher à 2, on utilisera l'URL http://localhost:8080/note/list?max=2).

Notez également la présence de l'annotation @GrailsCompileStatic sur la classe du contrôleur : celle-ci n'est bien sûr pas obligatoire, mais permettra de détecter dès la compilation des erreurs de saisie dans les noms des méthodes du service de données.

Récupérer une note

En ayant défini la méthode Note getNote(Serializable id) dans le service NoteService, GORM génère également le code nécessaire à la récupération d'une note particulière à partir de son identifiant.

Voici un exemple trivial d'action de notre contrôleur permettant de renvoyer une note particulière au format JSON, au moyen d'un appel à la méthode getNote() du service de données :

1
2
3
4
5
6
7
8
9
10
11
12
13
// URL: http://localhost:8080/note/get/<id> or http://localhost:8080/note/get?id=<id>
def get(final Long id) {
    if (!id) {
        render 'Wrong id!'
    }
    else {
        def note = noteService.getNote(id)
        if (note)
            render note as JSON
        else
            render 'Not found!'
    }
}

Ainsi, utilisez par exemple l'URL http://localhost:8080/note/get/1 pour obtenir l'entité Note d'id 1 au format JSON. L'id est automatiquement extrait de l'URL et se retrouve dans le paramètre id de l'action get() ; il est ensuite utilisé comme paramètre dans l'appel de la méthode de service getNote(), qui retourne alors une entité Note à partir des données en base.

Utiliser un finder dynamique

Toujours en se basant sur un nom de méthode de service suivant une certaine convention, GORM permet l'implémentation automatique de requêtes appelées dynamic finders. Cela est en lien avec les mêmes types de méthode que l'on peut invoquer en tant que méthodes static sur les types des objets métiers.

Voyons commet obtenir la liste des notes contenant un certain modèle de phrase, tout en permettant de la pagination, et plus. Cela correspond à la méthode de service suivante :

1
List<Note> findAllByTextLike(String pattern, Map args)

La nom de la méthode commençant par findAllBy, GORM sait qu'il a affaire à un finder dynamique qui portera sur la propriété text (car Text suit findAllBy) de la classe Note, et sur laquelle il devra appliquer l'opérateur Like avec le premier paramètre passé à la méthode (le second paramètre étant réservé à des paramètres supplémentaires pour le support de la pagination par exemple).

Pour tester cette méthode, comme dans l'application d'exemple, utilisez une action de contrôleur ressemblant à ceci :

1
2
3
4
// URL http://localhost:8080/note/search?pattern=H%25 (searching for H% pattern)
def search(final String pattern) {
    render noteService.findAllByTextLike(pattern, params) as JSON
}

Ainsi, soumette une requête HTTP à l'application, avec cette URL http://localhost:8080/note/search?pattern=H%25, permettra d'obtenir la liste des notes commençant par H, au format JSON.

Utiliser une requête Where

GORM permet déjà d'exprimer des requêtes plus complexes grâce à la méthode static where injectée sur les types des objets métiers. On retrouve la même approche avec les services de données, avec l'application de l'annotation @grails.gorm.services.Where sur une méthode de service.

1
2
@Where({ text ==~ pattern && dateCreated > fromDate })
List<Note> searchNotes(String pattern, Date fromDate, Map args)

Ici, on souhaite obtenir la liste des notes contenant un certain modèle de phrase et qui ont été créées après une certaine date.
Dans l'annotation, la requête est exprimée dans une closure et en utilisant des opérateurs du langage Groovy ; on y retrouve les propriétés text et dateCreated de la classe métier Note, ainsi que les paramètres pattern et fromDate passés à la méthode du service.

Dans le projet d'exemple, on a défini une action, searchFrom, permettant d'invoquer la méthode searchNotes() du service :

1
2
3
4
5
6
// URL: http://localhost:8080/note/searchFrom?pattern=H%25&from=2017-08-19-12:00
def searchFrom(final String pattern, final String from) {
    def fromDate = params.date('from', 'yyyy-MM-dd-HH:mm')

    render noteService.searchNotes(pattern, fromDate, params) as JSON
}

Plus loin

Au travers de ces quelques exemples, nous avons à peine gratté la surface du sujet des services de données, et il heureux de constater les améliorations constantes apportées à Grails et à GORM !
N'hésitez pas à vous reporter à la documentation, et à utiliser le projet odelia-gina-dataservices pour réaliser vos propres expérimentations.