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é.
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 & 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.