Tutoriel sur Spring Data : une autre façon d'accéder aux données

| Matthieu Briend (Soat) | Soat |

Cet article s'intéresse à « Spring Data ». C'est un projet supplémentaire de Spring créé il y a quelques années pour répondre aux besoins d'écrire plus simplement l'accès aux données et d'avoir une couche d'abstraction commune à de multiples sources de données.

Pour réagir au contenu de cet article, un espace de dialogue vous est proposé sur le forum Commentez .

- Titre : Tutoriel sur Spring Data : une autre façon d'accéder aux données

- Auteurs : Matthieu Briend (Soat), Soat

- Parution : 15 décembre 2014

- Licence : Copyright ® 2014 Soat. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. Spring Data Commons

- II-A. Interfaces de base

- II-B. Méthodes requêtes

- III. Spring Data JPA

- III-A. Requêtes personnalisées

- III-B. QueryDsl

- III-C. Spécification

- III-D. Divers

- IV. Spring Data Neo4j

- V. Spring Data MongoDB

- VI. Fonctionnement

- VII. Conclusion

- VIII. Remerciements

I - Introduction

Dans ce billet, on va présenter Spring Data. C'est un projet supplémentaire de Spring créé il y a quelques années pour répondre aux besoins d'écrire plus simplement l'accès aux données et d'avoir une couche d'abstraction commune à de multiples sources de données.

Les applications peuvent avoir besoin d'une base Neo4j et en même temps être couplées à Oracle (ou seulement à la base Oracle d'ailleurs), obligeant ainsi le développeur à adapter les modèles de données aux API de chacune des bases.

Se greffant par dessus celles-ci en implémentant les opérations de base et le design pattern Repository du DDD (Domain Driven Design), Spring Data offre une API qui fait abstraction de ces API « bas niveau » (tout en prenant en compte les spécificités de chacune d'elles). Par exemple, pour bénéficier d'opérations CRUD de base, il suffit tout simplement d'étendre une interface de Spring Data pour y avoir accès.

Spring Data s'interface avec plusieurs sources de données parmi lesquelles JPA, Neo4j, MongoDB, GemFire, Hadoop, ElasticSearch, REST, Redis, Couchbase et quelques autres.

II - Spring Data Commons

Spring Data est découpé en une couche commune à toutes les sources de données sous-jacentes (Neo4j, MongoDB, JPA…), appelée Spring Data Commons, à laquelle s'ajoute une couche propre à la source de données. Donc si vous utilisez Spring Data ElasticSearch, vous aurez Spring Data Commons et Spring Data Elasticsearch.

Spring Data Commons contient les classes pour fonctionner, mais surtout les interfaces que l'utilisateur aura à étendre (directement ou pas) : Repository, CrudRepository et PagingAndSortingRepository.

II-A - Interfaces de base

En fonction des méthodes de base que le développeur voudra utiliser, ce dernier étendra une des trois interfaces. Repository ne sert qu'à dire que l'interface qui l'étend est un repository (merci Captain Obvious !). Si l'utilisateur étend CrudRepository, il aura des méthodes CRUD pour la source de données sous-jacente (save, findOne, findAll, etc.). Si au lieu d'étendre CrudRepository, son interface étend PagingAndSortingRepository (cette dernière étend CrudRepository qui étend Repository), il aura accès, en plus des méthodes CRUD, à un ensemble de méthodes permettant de faire de la pagination et du tri. Ces interfaces prennent deux types paramétrés : le type de l'entité que l'on manipule et le type de l'identifiant de l'entité.

Exemple avec JPA :

public interface PersonneRep extends CrudRepository<Personne, Long> { }

// à présent, on peut utiliser quelques méthodes de CrudRepository
public class PersonneRepTest {
@Autowired
private PersonneRep personneRep;

public void setup() {
personneRep.deleteAll();
}

public void testSave() {
...
Personne personneSauvee = personneRep.save(personne);...
}

public void testCrudRep() {
...
assertEquals(1, personneRep.count()); // nombre de personnes dans la base ?
assertEquals(true, personneRep.exists(personneSauvee.getId()));
for (Personne personneDeLaListe : personneRep.findAll()) {...}
Personne personneTrouvee = personneRep.findOne(personneSauvee.getId());
personneRep.delete(personneSauvee);
assertEquals(0, personneRep.count());...
}

...
}

Et si on veut des méthodes de pagination et de tri, on étend PagingAndSortingRepository :

public interface PersonnePaginationRep
extends PagingAndSortingRepository<Personne, Long>{}

public class PersonnePaginationRepTest {
@Autowired
private PersonnePaginationRep personnePaginationRep;

public void testTriDesc(){
...
Iterable<Personne> personnesTrouvees =
personnePaginationRep.findAll(new Sort(Sort.Direction.DESC, "nom"));
...
}

public void testPagination() {
assertEquals(10, personnePaginationRep.count());
Page<Personne> personnes =
// 1re page de résultats &amp; 3 résultats max.
personnePaginationRep.findAll(new PageRequest(1, 3));
assertEquals(1, personnes.getNumber());
assertEquals(3, personnes.getSize()); // la taille de la pagination
assertEquals(10, personnes.getTotalElements()); //nb total d'éléments
//récupérables
assertEquals(4, personnes.getTotalPages()); // nombre de pages
assertTrue(personnes.hasContent());
...
}

...
}

II-B - Méthodes requêtes

Néanmoins, cela peut ne pas suffire. Spring Data permet donc d'écrire des requêtes à partir des noms de méthode : vous écrivez la méthode avec certains mots-clés (And, Or, Containing, StartingWith, etc., définis dans les documentations de référence, par exemple celle-là), et il se charge de traduire ce nom de méthode en requête puis de l'exécuter au moment voulu, comme un grand :

public interface PersonneRep extends CrudRepository {
// recherche une personne par son attribut "nom"
Personne findByNom(String nom);

// ici, par son "nom" ou "prenom"
Personne findByNomOrPrenom(String nom, String prenom);

List<Personne> findByNomAndPrenomAllIgnoreCase(String nom, String prenom);

List<Personne> findByNomOrderByPrenomAsc(String nom)
}

Certains types sont reconnus par Spring Data, comme ceux de la pagination et du tri. Du coup, vous pouvez écrire ce genre de méthodes, sans forcément étendre l'interface PagingAndSortingRepository :

// Renvoie un résultat paginé avec des métadonnées sur la recherche (nombre de pages, etc.)
Page<User> findByLastname(String lastname, Pageable pageable);

// Renvoie une liste d'utilisateurs triée selon le paramètre "sort"
List<User> findByLastname(String lastname, Sort sort);

// Renvoie une liste d'utilisateurs qui prend en compte les paramètres de pagination donnés en paramètre
List<User> findByLastname(String lastname, Pageable pageable);

Bien que pratiques, les méthodes-requêtes peuvent devenir très longues et donc peu commodes pour les requêtes un peu complexes. Spring Data possède une annotation @Query dans laquelle on peut mettre en paramètre la requête sous forme de String. Dans ce cas, le nom de la méthode n'est pas pris en compte.

// JPA
@Query("from Personne p where p.nom = ?1 and p.prenom = ?2")
public Personne maRequêteAvecQueryDeRechercheParNomEtPrenom(String nom, String prenom);

// Neo4j
@Query("start acteur=node({0}) " +
"match acteur-[:aRealise]->film " +
"return film")
Iterable<Film> recupereMoiTousLesFilmsRealisesPar(Acteur acteur);

Si la requête fait des modifications sur les entités, il faut ajouter l'annotation @Modifying.

@Query("update Personne p set p.nom = :nom where p.id = :id")
@Modifying
public int metAJourNom(@Param("nom")String nom, @Param("id") Long id);

Tous les cas d'utilisation que nous avons évoqués fonctionnent quelle que soit la base de données prise en compte par Spring Data. C'est-à-dire que vous utilisez vos modèles de données sous forme de beans, et selon votre repository et les annotations utilisées, vous taperez sur l'une ou l'autre des bases de données. Par exemple, dans un bean, vous pouvez mettre des annotations propres à Spring Data Neo4j et d'autres propres à JPA.

III - Spring Data JPA

Spring Data JPA offre une interface supplémentaire qui étend PagingAndSortingRepository : JpaRepository. Elle propose des méthodes comme la suppression en lots des entités (deleteInBatch()), le vidage du cache (flush()), etc.

III-A - Requêtes personnalisées

Mais que se passe-t-il si vous avez une requête à écrire qui doit utiliser les particularités de l'ORM sous-jacent ? Un exemple que j'ai pu rencontrer : l'écriture d'une requête de recherche par l'exemple. Cette fonctionnalité n'existe pas en JPA, mais en Hibernate. La documentation de Spring Data décrit comment faire pour ajouter des requêtes pouvant utiliser l'API Criteria (et nous avons pu le faire). Ceci écrit, si cela peut se faire assez facilement pour un repository (il faut, en plus de l'interface Repository, avoir une interface exposant la méthode plus une classe qui implémente cette méthode avec par exemple le code Hibernate), le faire pour tous les repositories devient un peu plus compliqué puisqu'il faut créer une interface qui étend JpaRepository, créer une classe implémentant cette interface et enfin créer et déclarer un factory bean.

III-B - QueryDsl

Spring Data JPA partage avec Spring Data MongoDB l'intégration avec QueryDsl. Pour cela, vous devrez inclure le plugin maven de QueryDsl pour qu'il génère les classes à partir de vos entités. Puis il vous suffira d'étendre l'interface QueryDslPredicateExecutor. Celle-ci possède quelques méthodes de recherche qui prennent en paramètres des Predicate :

BooleanExpression ignoreGraviteDansLaMatrice =
heros.ignoreGraviteDansMatrice.eq(true);
BooleanExpression estBaleze = heros.nbEnnemisMisKO.gt(10);

customerRepository.findAll(ignoreGraviteDansLaMatrice.and(estBaleze));

Pour information, lors de la rédaction de cet article, l'utilisation de QueryDsl n'est pas expliquée dans la documentation de référence, mais dans le livre sur Spring Data mis en valeur sur le site du projet (et aussi dans la documentation de Spring Data MongoDB).

III-C - Spécification

Autre façon d'écrire les requêtes : avec des objets Specification. Les requêtes peuvent alors avoir un aspect un peu plus fonctionnel, ce qui permet de faire du DDD (Domain Driven Design). Pour en bénéficier, il suffit d'étendre l'interface JpaSpecificationExecutor et de créer les classes qui vont préciser les conditions des requêtes. Ces classes doivent implémenter l'interface Specification.

// on crée les méthodes qui vont renvoyer des instances de la classe  Specification
public HerosSpecifications { // un exemple très courant dans notre métier
public static Specification<Heros> herosPeutVoler() {
return new Specification<Heros> {
public Predicate toPredicate(Root<T> root, CriteriaQuery query,
CriteriaBuilder cb) {
return cb.equal(root.get(Heros_.ignoreGraviteDansLaMatrice), true);
}
};
}
public static Specification<Heros> estBaleze() {
return new Specification<Heros> {
public Predicate toPredicate(Root<T> root, CriteriaQuery query, CriteriaBuilder cb) {
return cb.lessThan(root.get(Heros_.metUneRacleeAToutLeMonde), true);
}
};
}
}

// notre repository étend en plus JpaSpecificationExecutor
public interface HerosRepository
extends JpaRepository<Heros>, JpaSpecificationExecutor {}

// on peut à présent utiliser les instances de la classe Specification
herosRepository.findAll(herosPeutVoler());
herosRepository.findAll(estBaleze());
herosRepository.findOne(where(herosPeutVoler()).and(estBaleze()));

Vous noterez qu'on peut combiner les Specification en utilisant l'API des Predicate de JPA. C'est leur grand intérêt.

III-D - Divers

Au cas où vous en auriez besoin, Spring Data JPA permet d'écrire des requêtes nommées, grâce à l'annotation @NamedQuery qui prend un identifiant et une requête en paramètres. Quand une méthode du repository est appelée et qu'elle correspond à une requête nommée, on appelle la requête JPQL associée.

De même, l'annotation @Query, avec le paramètre nativeQuery, permet d'écrire des requêtes en SQL directement.

Comme on a pu le voir, Spring Data JPA simplifie un peu l'écriture de repository et facilite le DDD, mais permet malgré tout une certaine liberté d'utilisation.

IV - Spring Data Neo4j

Spring Data peut aussi être utilisé avec Neo4j qui est une base de données orientée graphe (au lieu de stocker les données sous forme de table, on stocke des nœuds et des relations entre eux).

D'ordinaire, si on souhaite insérer ou rechercher des nœuds et des relations avec Neo4j, cela se fait avec une API qui est finalement assez bas niveau. Et si on essaie d'utiliser des beans métier, il faudra un peu tricher en incluant un Node en attribut du bean. Et les getters/setters utiliseront ce nœud, ce qui rend le code peu commode. Pour utiliser Neo4j, il faut donc des Node pour les nœuds et des enum pour les relations et appeler des méthodes qui manipulent ces objets. Voici un court exemple :

Transaction tx = graphDb.beginTx();
try {
acteur = graphDb.createNode();
acteur.setProperty("nom", "Weaver");
acteur.setProperty("prenom", "Sigourney");

Node noeudFilm = graphDb.createNode();
noeudFilm.setProperty(Film.TITRE, "Avatar");
acteur.createRelationshipTo(noeudFilm, JOUE_DANS); // JOUE_DANS est donc une enum

tx.success();
} finally {
tx.finish();
}

À noter : Neo4j utilise son propre langage de requêtage, le Cypher. Plus un langage de description des résultats voulus que le SQL, il ressemble à ceci :

start n=node(2)                // Le nœud de départ
match n-[:JOUE_DANS]->films    // Descriptions des relations entre les nœuds
where films.TITRE =~ 'Avat.*'  // Filtre, comme en SQL
return n, n.nom, n.prenom, films.TITRE // Ce qu'on retourne. Ici le nœud de départ,
// ses attributs nom et prénom, et le titre
// du film

Spring Data simplifie beaucoup les choses : avec lui, on peut utiliser un bean classique (donc avec des getters/setters classiques) pour représenter les nœuds et les relations. Spring Data Neo4j fournit un certain nombre d'annotations pour dire si le POJO que l'on manipule est un nœud ou une relation, ou si tel attribut est à indexer (donc qu'on peut le rechercher) :

@NodeEntity // Ce POJO est un nœud
public class Acteur {
@GraphId // Pour dire que cet attribut est l'identifiant. Toujours de type Long
private Long idNoeud;

// On souhaite pouvoir faire des recherches sur cet attribut
@Indexed(indexType = IndexType.FULLTEXT, indexName="nom")
private String nom;

private String prenom;

// Définit les relations directes vers d'autres nœuds. Cette relation n'a pas
// d'attribut
@RelatedTo(direction=Direction.BOTH, type="aJoueDans")
private Set<Film> films;

// Définit une relation "riche", c'est-à-dire une relation avec des attributs
// (genre date de réalisation)
@RelatedToVia
private Set<Realisation> realisations;

// Getters et setters classiques
}

Et pour les relations « riches », il y a l'annotation @RelationshipEntity :

@RelationshipEntity(type="aRealise") // Le champ type est utile pour les requêtes
// pour dire quelle relation on utilise
public class Realisation {
@GraphId // Pareil que pour les nœuds
private Long id;

// Cet attribut enrichit la relation Realisation entre deux nœuds Acteur
private Calendar dateRealisation;

// Une relation a un nœud d'entrée et un nœud de sortie. Celui-ci, c'est le
// nœud d'entrée...
@StartNode
private Acteur acteur;

@EndNode // ... et celui-là le nœud de sortie
private Film film;

// Getters et setters classiques
}

Les interfaces fournies par Spring Data Neo4j permettent d'exécuter des opérations de base : sauvegarde et recherche des nœuds et de relations, utilisation de classes de parcours de graphe. L'interface principale étant GraphRepository. Voici quelques exemples :

acteurRep.deleteAll();
filmRep.save(multiFacial);
EndResult<Film> filmsTrouves = filmRep.findAll();
EndResult<Film> filmsTrouves = filmRep.findAllByPropertyValue("titre", "Avatar");
Film filmTrouve = filmRep.findByPropertyValue("titre", "Ghost Buster");
Iterable<Film> filmsTrouves = filmRep.findByTitreContaining("Alien");
Acteur sigourneyWeaver = acteurRep.findOne(identifiantSigourneyWeaver);

// l'objet suivant va décrire comment parcourir le graphe
TraversalDescription traversalDescription =
Traversal.description().
breadthFirst(). // parcours en profondeur
evaluator(Evaluators.atDepth(1)); // on ne prend que les nœuds à une distance de 1

final Iterable<Film> filmsJouesParSigourneyWeaver =
filmRep.findAllByTraversal(sigourneyWeaver, traversalDescription);

Ça ne vous dépayse pas trop ? D'autant que l'annotation @Query permet d'écrire du Cypher comme on écrirait du JPQL.

Outre les opérations de base, Spring Data Neo4j propose aussi une interface (CypherDslRepository) pour écrire des requêtes avec CypherDSL (l'équivalent de QueryDSL pour Neo4j), et une autre (SpatialRepository) pour écrire les requêtes sur des coordonnées géographiques :

Iterable<Personne> contacts =
personneRep.findWithinBoundingBox("positionPersonne", 55, 15, 57, 17);

Iterable<Personne> contacts =
personneRep.findWithinWellKnownText("positionPersonne",
"POLYGON ((30 10, 40 40, 20 40, 10 20, 30 10))");

Iterable<Personne> contacts =
personneRep.findWithinDistance("positionPersonne", 16, 56, 70);

Et cerise sur le gâteau, les entités peuvent mélanger les annotations JPA et Spring Data Neo4j.

V - Spring Data MongoDB

Spring Data prend également en compte MongoDB. L'utilisation est similaire à Spring Data JPA et Neo4j : vous pouvez utiliser des interfaces pour déclarer que vos repositories et vos beans peuvent utiliser des annotations supplémentaires pour la conversion de l'objet en document. Vous retrouverez l'annotation @Query (avec du JSON dedans), les méthodes-requêtes, la pagination et QueryDsl.

Spring Data MongoDB offre également des mots-clés supplémentaires pour les méthodes-requêtes pour prendre en compte la géolocalisation.

Spring Data MongoDB permet aussi de mélanger JPA et MongoDB, comme Spring Data Neo4j.

Derrière Spring Data MongoDB se cache la classe MongoTemplate qui implémente plusieurs méthodes pour faire des recherches et des modifications qui peuvent prendre en paramètres des objets décrivant la requête (Query, Update). Cette classe utilitaire est l'équivalent de Neo4jTemplate.

VI - Fonctionnement

Comme une bonne partie de Spring, Spring Data fonctionne avec des objets Proxy fournis par la JDK. Et pour chaque base de données utilisée, c'est une implémentation de l'interface la plus permissive qui est utilisée par ce proxy. Pour JPA, SimpleJpaRepository implémente toutes les méthodes de l'interface JpaRepository.

Avec un exemple, c'est mieux :

public class SimulateurSpring {
public void injecteDansClient(final Client client) {
MonRep dao = (MonRep) // MonRep est une interface qui ressemble beaucoup à
// JpaRepository. Ici, elle étend InterfaceA
Proxy.newProxyInstance(Thread.currentThread(). // on crée le proxy...
getContextClassLoader(),
new Class[] { MonRep.class },
new ProxyInjecte()); // ... qui va utiliser cet objet-là pour
// implémenter MonRep
client.setDao(dao);    // On "injecte" le repository dans la classe
// utilisatrice
}
}

class ProxyInjecte implements InvocationHandler {
private MonRep monRepImpl =
new MonRepImpl(); // cette classe implémente toutes les méthodes de MonRep

public Object invoke(final Object proxy,
final Method method,
final Object[] args)
throws Throwable {

if (method.getName().equals("methodeInterfaceB")) {
this.monRepImpl.methodeInterfaceB();
} else if (method.getName().equals("methodeInterfaceA")) {
this.monRepImpl.methodeInterfaceA();
}
return null;
}
}

public interface InterfaceA {
void methodeInterfaceA();
}

public interface InterfaceB extends InterfaceA {
void methodeInterfaceB();
}

public class Client {
private MonRep dao;

public void setDao(final MonRep dao) {
this.dao = dao;
}

/**
* Appelle la ou les méthodes de dao. Si MonRep étend
* InterfaceB, vous pourrez appeler methodeInterfaceB().
*/
public void appelleMonRep() {
this.dao.methodeInterfaceA();
}
}

Quant à la création des requêtes (notamment la traduction du nom d'une méthode-requête en requête JPQL ou Cypher, etc.), elle se fait au chargement de l'applicationContext. Donc quand une méthode est appelée, soit la requête existe déjà, soit on regarde dans un cache s'il y en a une. Une fois trouvée, on l'exécute.

Lors du chargement de l'applicationContext, les noms des méthodes-requêtes sont convertis en requêtes compréhensibles par la base de données (en JPQL, Cypher, etc.) : on prend le nom de la méthode, on en extrait les mots-clés de Spring Data et les attributs des beans et avec ça, on peut construire une requête qu'on mettra dans un cache.

VII - Conclusion

Spring Data offre donc des facilités pour écrire le code d'accès aux données, surtout pour les bases NoSQL. Un autre avantage que j'y vois est la possibilité d'utiliser des beans classiques (à quelques annotations près), c'est-à-dire qu'on peut continuer à utiliser des termes métier y compris dans cette couche sans avoir à se préoccuper des détails techniques de l'accès aux données.

Ce projet s'inscrit dans un mouvement pour faciliter l'utilisation des bases NoSQL. Mouvement qui contient notamment Hibernate OGM qui, à la différence de Spring Data, utilise le JPQL pour accéder à la base sous-jacente.

VIII - Remerciements

Cet article a été publié avec l'aimable autorisation de la société Soat.

Nous tenons à remercier Claude Leloup pour la relecture orthographique de cet article et Régis Pouiller pour la mise au gabarit.