Préambule

 

Les TD 1 & 2 se basent sur une seule table en BdD et le TP 1 utilise des jointure faites "à la main" entre 2 tables. Dans les faits, un modèle relationnel correct utilise le principe de clef étrangère pour établir des relations entre des tables. Le problème est de traduire cette relation en une relation entre objets puisque dans les projets spring boot, on manipule des instances plutôt que de traiter directement des enregistrements en BdD. Pour cela, hibernate propose un ensemble d'annotations que l'on utilise dans les entités, la difficulté étant de bien choisir quelle annotation utiliser pour représenter quelle relation.


Le projet de démonstration des points abordés est téléchargeable [ ici ]


 

1°/ Les bases du mapping non relationnel avec mongo

 

1.1°/ Les entités

Les principes utilisés avec les mappings relationnels ne peuvent absolument pas s'appliquer à une base non relationnelle telle que mongo. Il est cependant possible d'établir des "connexions" entre des entités qui reflètent des liens entre différentes collections. En effet, dès lors qu'un document contient une ou plusieurs références vers des documents d'une autre collection grâce à l'id de ceux-ci, il est possible de traduire cette référence grâce à des annotations dans les entités qui vont représenter les documents. 

Il existe plusieurs annotations possibles, mais si l'on veut utiliser une BdD classique où seuls des ids sont utilisés comme référence, alors l'annotation à utiliser est: @DocumentReference.

Par exemple, si l'on crée deux collections, courses et students pour représenter des cours et des étudiants, il est fort probable que cela se concrétise par des documents du type :

{
  _id: ObjectId("abcdef11111111"),
  lastname: "dupond",
  firstname: "jean",
  class: 2020
}
{
  _id: ObjectId("abcdef22222222"),
  lastname: "martin",
  firstname: "jeanne",
  class: 2020
}
{
  _id: ObjectId("ababab45678902"),
  name: "algo",
  students: [ ObjectId("abcdef11111111"), ObjectId("abcdef22222222") ]
}

Dans cet exemple, on considère que ce sont les documents de courses qui référencent ceux de students mais il est fort possible de faire l'inverse.

Pour traduire cette relation en une relation entre objets, le principe est relativement similaire à celui du mapping relationnel, mais en plus simple car il n'y a pas de principe de bidirectionnalité, ni besoin de spécifier des noms de clefs étrangères. Il suffit de :

  • créer une classe entité représentant les document d'une collection,
  • d'annoter la classe avec @Document("nom_collection"),
  • de mettre un attribut String id, avec l'annotation @Id juste avant, pour représenter le champ _id dans les documents,
  • d'ajouter des attributs avec des types correspondant à ceux des documents,
  • d'annoter avec @DocumentReference les attributs correspondant à des références à des documents d'autres collections (donc des ObjectId).

D'après ces principes, l'exemple précédent nécessite de créer les classes suivantes :

@Document("students")
public class Student {
  @Id
  private String id;
  private String firstname;
  private String lastname;
  private int class; 
  ... // getters+setters
}

 

@Document("courses")
public class Course {
  @Id
  private String id;
  private String name;
  @DocumentReference
  private List<Student> students;
  ... // getters+setters
}

 

ATTENTION ! Le fait d'utiliser @DocumentReference crée une relation entre les 2 classes comme dans le cas d'un mapping relationnel. Cela implique que lors de la récupération d'un objet Course, par défaut, son attribut students sera automatiquement initialisé grâce à des requêtes dans la collection students. Une telle relation peut donc rapidement devenir un problème si le volume d'informations ainsi récupérées devient énorme, ce qui est facilement le cas en non-relationnel. La section 2 présente des alternatives pour limiter ce genre de problème.

 

1.2°/ Les repository

Pour que spring récupère des instances d'entité de façon quasi automatique, le principe est inchangé, avec deux différences :

  • on crée un repository qui hérite de MongoRepository.
  • pour toutes les requêtes non triviales et non exprimables grâce au nom de la méthode, on utilise l'annotation @Query pour donner directement la requête à faire. Ce point sera abordé en section 2.

Par exemple, pour les étudiants :

public interface StudentsRepository extends MongoRepository<Student, String> {

    Optional<Student> findByLastname(String lastname);
    List<Student> findAllByClassLessThan(int class);
    Page<Student> findAllByFirstnameLike(String lastname, Pageable pageable);

}

 Comme pour les mapping relationnels, on remarque qu'il est possible :

  • de faire des noms de fonctions avec des mots-clés tels que Like, And, LessThan, ...
  • de faire des requêtes paginées simplement en ajoutant un paramètre Pageable.

 

1.3°/ Services et contrôleurs

Pour les service et les contrôleurs, il n'y a aucune différence avec l'utilisation d'un mapping relationnel. C'est pourquoi passer du relationnel au non relationnel n'impose généralement que de modifier les entités et dans une moindre mesure les repository.

A noter qu'il est fortement conseillé de créer des classes DTO pour représenter les données renvoyées par les contrôleurs. Ces derniers sont donc responsables de la traduction des entités récupérées grâce aux service en objets DTO, avant de les renvoyer au client. Sur l'exemple précédent, cela donne :

public class StudentDTO {
  private String id;
  private String firstname;
  private String lastname;
  private Integer class; 
  public StudentDTO(Student student) { ... }
  ... // getters+setters
}

 

public class Course {
  private String id;
  private String name;
  private List<StudentDTO> students;
  public CourseDTO(Course course) { ... }
  ... // getters+setters
}
 
 
2°/ Problèmes et solutions
 
2.1°/ Les entités "complexes"
 
En non relationnel, on n'hésite pas à construire des documents avec une structure complexe à plusieurs niveaux, donc des objets dans des objets. Par exemple, une collection users pourrait contenir des documents de la forme :
{
  _id: ObjectId("abcdef11111111"),
  login: "jdupond",
  pass: "azer",
  profile : {
    lastname: "dupond",
    firstname: "jean",
    age: 23
  }
}
 
Pour représenter ce type de document avec des entités, la solution est très simple : il suffit de créer des classes de type POJO pour représenter les "sous objets". Pour users, cela donne :
class Profile {
  private String lastname;
  private String firstname;
  private int age;
  ... // + constructors, getters/setters
}
 
@Document("users")
class User {
  @Id
  private String id;
  private String login;
  private String pass;
  private Profile profile;
  ... // + constructors, getters/setters
}
 
NB : dans cet exemple, la classe Profile ne contient elle-même pas d'attribut id. Or, quand on utilise d'autres langages et bibliothèques, notamment mongoose, ce dernier créé par défaut un champ _id dans les sous documents (cela peut toutefois s'éviter). Par conséquent, si la base est déjà existante et que les sous documents incluent un _id, alors il faudrait également ajouter un attribut String id aux classes représentant les sous objets.
 
 2.2°/ Lazy fetch
 
Le problème principal de l'annotation @DocumentReference est de potentiellement récupérer un volume d'informations important pour rien. Si l'on veut éviter le chargement automatique de ces informations, il est possible d'utiliser une récupération "si besoin" (= lazy fetch). Pour cela, il suffit d'utiliser : @DocumentReference(lazy = true). Dans l'exemple avec la classe Course, on peut utiliser le lazy fetch pour ne pas initialiser automatiquement l'attribut List<Student> students. Mais cela implique que l'on a accès à aucune information sur les étudiants du cours, même pas leur id. Et dès que l'on va accéder à un élément de la liste pour manipuler un objet Student, toute la liste va être initialisée avec les objets Student complets.
Comme en mapping relationnel, le lazy fethc ne permet donc pas de récupérer des informations de façon partielle, par exemple uniquement les ids, puis de compléter si besoin. Cependant, il existe une solution à ce problème, donnée dans la section suivante. 
 
 
 
2.3°/ Gérer les références "manuellement"
 
Pour garder entièrement la main sur la façon de récupérer des documents référencés, la solution la plus simple consiste ... à ne pas utiliser @DocumentReference. Cela implique donc de gérer les relations "manuellement", comme pour le relationnel quand on n'utilise pas de mapping.
 
Dans ce cas, au lieu d'utiliser un attribut de type entité, on utilise simplement un String, représentant l'id des documents. Par exemple, si on reprend l'entité Course, cela donne :
@Document("courses")
public class Course {
  @Id
  private String id;
  private String name;
  private List<String> students;
  ... // getters+setters
}

 

L'avantage de cette solution est qu'il est ainsi possible de renvoyer au client un objet CourseDTO, construit à partir d'un objet Course, et qui ne contient que les ids des étudiants. De cette façon, le client peut décider ensuite de demander à l'API les étudiants uniquement quand il en a besoin. L'inconvénient est que l'on reporte le travail de faire le lien au niveau du client et que l'on perd la capacité de récupérer les étudiants en même temps qu'un cours, ce qui serait peut être utile dans certains cas.

Heureusement, il existe une solution moyennement complexe pour effacer cet inconvénient pour proposer au client un moyen de soit récupérer uniquement des ids, soit récupérer des informations complètes. Sur l'exemple de Course et Student, cela implique :

  • de créer une classe StudentDTO avec au moins :
    • un constructeur qui initialise uniquement l'id et laisse les autres attributs à null.
    • un constructeur qui initialise complètement les attributs à partir d'une entité Student.
  • de créer une classe CourseDTO avec un attribut List<StudentDTO> annoté avec @JsonSerialiaze (cf. ci-dessous) et au moins :
    • un constructeur prenant uniquement en paramètre une entité Course, qui initialise la liste avec des instances de StudentDTO dont seul l'id est initialisé (cf. 1er constructeur mentionné ci-dessus)
    • un constructeur prenant en paramètre une entité Course et une List<Student>, qui initialise la liste avec des instance de StudentDTO complètement initialisées (cf. 2nd constructeur ci-dessus)
  • de créer une classe de sérialisation spécifique pour le type List<StudentDTO>, qui permette de renvoyer au client soit les informations complètes soit uniquement des ids, en fonction du nombre d'informations se trouvant dans les objets StudentDTO.
  • d'ajouter deux gestionnaires de route dans CourseController, pour renvoyer au client un objet CourseDTO plus ou moins complet. Dans les deux cas, les méthodes commencent par récupérer une entité Course grâce à CourseService puis :
    • pour la première route, la méthode utilise simplement le premier constructeur de CourseDTO mentionné ci-dessus, et renvoie directement l'instance ansi créée,
    • pour la deuxième route, la méthode parcours la List<String> students de l'entité Course et pour chaque élément, récupère une entité Student grâce à StudentsService. Toutes ces instances de Student sont mises dans une liste, avant d'appeler le second constructeur de CourseDTO mentionné ci-dessus.

L'archive de démonstration contient une mise en application de ce principe avec des organisations, contenant des équipes, contenant elles-mêmes des héros.

 

 2.4°/ Requêtes spéciales

Dans certains cas, il n'est pas possible d'exprimer une requête avec simplement un nom de méthode dans un repository. C'est le cas par exemple quand on veut chercher des documents en fonction d'informations se trouvant dans un tableau de sous-documents. Heureusement, il est possible de décrire la requête directement avant le nom de la méthode grâce à l'annotation @Query.

Exemple avec un document bankuser, contenant un tableau de sous-documents dans le champ accounts :

{
  _id: ObjectId("abcdef11111111"),
  login: "jdupond",
  pass: "azer",
  accounts : [
    { number: "abcdef1000", amount: 1000 },
    { number: "abcdef2000", amount: 342 }
  ]
}

Si on veut chercher les documents bankuser où il existe un compte dont le solde est supérieur à un certain montant, alors il faut utiliser @Query comme suivant :

class BankUserRepository extends MongoRepository<BankUser, String> {
  @Query(value=" ")
  List<BankUser> findAllBy...(int amount);
}