III. Premiers pas pour le modèle de données▲
Dans ce chapitre, nous allons écrire le modèle pour notre moteur de blog.
III-A. Introduction à JPA▲
La couche modèle occupe une place centrale au sein des applications Play (c'est également le cas pour les applications correctement architecturées). Il s'agit d'une représentation des données en langage dédié (domain-specific language) à partir de laquelle l'application travaille. Notre but étant de créer un moteur de blog, la couche modèle contiendra des classes telles que User, Post ou Comment.
Parce qu'il est préférable que la plupart des objets du modèle persistent même après un redémarrage de l'application, nous devons les stocker dans une base de données persistante. Un choix assez courant est d'utiliser une base de données relationnelle. Java étant un langage orienté objet, nous utiliserons un mapping objet-relationnel (Object Relational Mapper) afin de limiter la différence d'impédance (voir Wikipédia).
JPA est une spécification Java qui définit l'API standard pour le mapping objet-relationnel. Play choisit le célèbre framework Hibernate pour l'implémentation de cette spécification. L'un des avantages à utiliser JPA plutôt que l'API standard d'Hibernate est que tout le mapping est déclaré directement dans les objets Java.
Si vous avez déjà utilisé Hibernate ou JPA auparavant, vous serez surpris de la simplicité proposée par Play concernant ce point. Il n'est plus nécessaire de configurer quoique ce soit. JPA fonctionne sans aucune configuration ou installation supplémentaire (out of the box).
Si vous souhaitez avoir plus d'informations sur JPA, vous pouvez consulter le site de Sun à ce propos.
III-B. Commençons avec la classe User▲
Nous allons commencer par coder notre moteur de blog en créant la classe User. Tout d'abord, créons le fichier /yabe/app/models/User.java, et écrivons une première implémentation de cette classe :
package
models;
import
java.util.*;
import
javax.persistence.*;
import
play.db.jpa.*;
@Entity
public
class
User extends
Model {
public
String email;
public
String password;
public
String fullname;
public
boolean
isAdmin;
public
User
(
String email, String password, String fullname) {
this
.email =
email;
this
.password =
password;
this
.fullname =
fullname;
}
}
L'annotation @Entity marque cette classe comme une entité JPA. La classe parente, Model, offre un ensemble d'assistants pour JPA que nous découvrirons plus tard. Tous les champs de cette classe seront automatiquement persistés en base.
Il n'est pas nécessaire que cette classe étende play.db.jpa.Model. Toutefois, cela peut être une bonne idée car cela offre un certain nombre d'outils JPA.
Si vous avez utilisé JPA auparavant, vous devez savoir que chaque entité JPA doit fournir une propriété marquée par l'annotation @Id. Ici, notre classe parente Model fournit automatiquement un ID numérique auto-généré, ce qui est suffisant dans la plupart des cas.
Il ne faut pas voir ce champ généré comme un identifiant fonctionnel, mais bien comme un identifiant technique. Il est généralement conseillé de conserver ces deux concepts séparés, et ainsi de conserver un ID numérique auto-généré comme identifiant technique.
Un point qui ne vous a sans doute pas échappé, surtout si vous êtes un développeur confirmé, c'est que toutes les propriétés de notre classe sont publiques ! En Java (ainsi que pour les autres langages orientés objet), une bonne pratique stipule de mettre les propriétés en accès privé, mais de fournir des méthodes d'accès (les getters et setters), cela dans le but de promouvoir le principe important d'encapsulation. En réalité, Play se charge pour nous de générer les getters et setters afin de préserver cette encapsulation ! Nous verrons plus tard comment cela fonctionne exactement.
Nous pouvons désormais rafraîchir la page d'accueil et vérifier le résultat. A moins d'avoir fait une erreur lors de l'écriture de la classe User, nous ne devrions voir aucun changement. Effectivement, Play a automatiquement compilé et chargé la classe User mais pour l'instant, cela n'apporte aucune fonctionnalité particulière à l'application...
III-C. Ecriture du premier test▲
La meilleure méthode pour vérifier la classe User est d'écrire un cas de test JUnit. De cette façon, nous pourrons compléter progressivement notre modèle de données, et tester que tout se passe bien.
Avec Play, le lancement d'un test unitaire se fait par le démarrage de l'application dans un mode spécial, le mode "test". Nous devons tout d'abord stopper notre application, puis la redémarrer avec la commande suivante :
> play test
La commande play test est très similaire à la commande play run à l'exception qu'il démarre également un module d'exécution de tests qui nous permet alors de lancer des tests depuis l'interface web !
Lorsque nous lançons une application Play en mode de test, Play utilise automatiquement l'ID du framework de test et charge le fichier application.conf automatiquement. Des informations supplémentaires sont disponibles ici.
Ouvrons maintenant notre navigateur à la page http://localhost:9000/@tests afin de voir l'exécuteur de tests. Sélectionnons tous les tests par défaut, et exécutons-les. Normalement, tous les indicateurs devraient virer au vert... Cependant, ces tests par défaut ne testent pas grand chose !
Pour tester la partie du modèle de données, nous allons utiliser des tests JUnit. Comme vous pourrez le constater, il existe déjà un fichier de test, /yabe/test/BasicTest.java :
import
org.junit.*;
import
play.test.*;
import
models.*;
public
class
BasicTest extends
UnitTest {
@Test
public
void
aVeryImportantThingToTest
(
) {
assertEquals
(
2
, 1
+
1
);
}
}
Supprimons ce test (c'est-à-dire supprimons la méthode aVeryImportantThingToTest() et non la classe), puis écrivons un test qui vise à vérifier la création d'un nouvel utilisateur ainsi que sa récupération :
@Test
public
void
createAndRetrieveUser
(
) {
// Create a new user and save it
new
User
(
"bob@gmail.com"
, "secret"
, "Bob"
).save
(
);
// Retrieve the user with email address bob@gmail.com
User bob =
User.find
(
"byEmail"
, "bob@gmail.com"
).one
(
);
// Test
assertNotNull
(
bob);
assertEquals
(
"Bob"
, bob.fullname);
}
Nous constatons ici deux méthodes utiles proposées par la classe parent Model : save() et find().
Vous pouvez obtenir plus d'informations sur ces méthodes dans la documentation de Play sur JPA.
De retour sur la page web de l'exécuteur de test, choisissons le fichier BasicTests.java, démarrons-le, et vérifions que les indicateurs restent verts.
Nous souhaitons maintenant ajouter une méthode dans la classe User qui a pour but de vérifier l'existence d'un utilisateur et d'un mot de passe donnés. Ecrivons-la, puis testons-la. Pour ce faire, nous écrivons la méthode connect() à la classe User ainsi que son test JUnit correspondant :
public
static
User connect
(
String email, String password) {
return
find
(
"byEmailAndPassword"
, email, password).first
(
);
}
@Test
public
void
tryConnectAsUser
(
) {
// Create a new user and save it
new
User
(
"bob@gmail.com"
, "secret"
, "Bob"
).save
(
);
// Test
assertNotNull
(
User.connect
(
"bob@gmail.com"
, "secret"
));
assertNull
(
User.connect
(
"bob@gmail.com"
, "badpassword"
));
assertNull
(
User.connect
(
"tom@gmail.com"
, "secret"
));
}
A chaque fois que nous modifions notre code, nous devrions lancer les tests afin de s'assurer que nous n'avons pas ajouté de régressions.
III-D. La classe Post▲
La classe Post représente un billet de notre blog. Voyons une première implémentation de cette classe :
package
models;
import
java.util.*;
import
javax.persistence.*;
import
play.db.jpa.*;
@Entity
public
class
Post extends
Model {
public
String title;
public
Date postedAt;
@Lob
public
String content;
@ManyToOne
public
User author;
public
Post
(
User author, String title, String content) {
this
.author =
author;
this
.title =
title;
this
.content =
content;
this
.postedAt =
new
Date
(
);
}
}
Nous utilisons ici l'annotation @Lob qui indique à JPA que nous utilisons ici un type de données permettant le stockage d'une grande quantité de texte. Cette propriété correspond au contenu du billet. Nous déclarons ensuite la relation entre le User et notre classe, en utilisant le @ManyToOne, ce qui signifie qu'un utilisateur peut être à l'origine de plusieurs billets, mais qu'un billet n'a qu'un seul rédacteur.
Nous écrivons maintenant un nouveau cas de test permettant de nous assurer que cette classe fonctionne comme nous l'avions prévu. Mais avant cela, nous allons apporter une modification au niveau de notre classe JUnit. Dans l'état actuel du test, le contenu de la base de données n'est jamais supprimé, ce qui signifie qu'à chaque exécution, de nouveaux objets sont stockés dans la base de données. Cela devient problématique assez rapidement dès le moment où l'on disposera de tests qui auront besoin de compter le nombre d'éléments en base pour s'assurer que tout s'est bien passé.
Ecrivons donc une méthode setup() qui supprimera les données avant chaque test :
public
class
BasicTest extends
UnitTest {
@Before
public
void
setup
(
) {
Fixtures.deleteAll
(
);
}
...
}
@Before est une annotation proposée par JUnit indiquant que notre méthode doit être appelée avant chaque méthode de test de notre classe.
Comme nous le voyons, la classe Fixtures est un assistant qui vient nous aider à gérer la base de données pendant les phases de tests. Nous pouvons à nouveau lancer les tests afin de nous assurer que nous n'avons rien cassé de l'existant, et commencer à écrire un nouveau test :
@Test
public
void
createPost
(
) {
// Create a new user and save it
User bob =
new
User
(
"bob@gmail.com"
, "secret"
, "Bob"
).save
(
);
// Create a new post
new
Post
(
bob, "My first post"
, "Hello world"
).save
(
);
// Test that the post has been created
assertEquals
(
1
, Post.count
(
));
// Retrieve all post created by bob
List<
Post>
bobPosts =
Post.find
(
"byAuthor"
, bob).fetch
(
);
// Tests
assertEquals
(
1
, bobPosts.size
(
));
Post firstPost =
bobPosts.get
(
0
);
assertNotNull
(
firstPost);
assertEquals
(
bob, firstPost.author);
assertEquals
(
"My first post"
, firstPost.title);
assertEquals
(
"Hello world"
, firstPost.content);
assertNotNull
(
firstPost.postedAt);
}
Comme nous n'utilisons pas forcément un IDE pour écrire notre code Java, il ne faut pas oublier ici d'inclure dans nos imports la classe java.util.List. Autrement, nous aurons une erreur de compilation.
III-E. Terminons avec Comment▲
Avant de considérer notre premier brouillon de modèle de données terminé, nous allons y ajouter la classe Comment et autoriser l'ajout de commentaires à un billet. L'écriture de cette classe ne révèle aucune difficulté particulière :
package
models;
import
java.util.*;
import
javax.persistence.*;
import
play.db.jpa.*;
@Entity
public
class
Comment extends
Model {
public
String author;
public
Date postedAt;
@Lob
public
String content;
@ManyToOne
public
Post post;
public
Comment
(
Post post, String author, String content) {
this
.post =
post;
this
.author =
author;
this
.content =
content;
this
.postedAt =
new
Date
(
);
}
}
Comme auparavant, nous rédigeons notre test unitaire :
@Test
public
void
postComments
(
) {
// Create a new user and save it
User bob =
new
User
(
"bob@gmail.com"
, "secret"
, "Bob"
).save
(
);
// Create a new post
Post bobPost =
new
Post
(
bob, "My first post"
, "Hello world"
).save
(
);
// Post a first comment
new
Comment
(
bobPost, "Jeff"
, "Nice post"
).save
(
);
new
Comment
(
bobPost, "Tom"
, "I knew that !"
).save
(
);
// Retrieve all comments
List<
Comment>
bobPostComments =
Comment.find
(
"byPost"
, bobPost).fetch
(
);
// Tests
assertEquals
(
2
, bobPostComments.size
(
));
Comment firstComment =
bobPostComments.get
(
0
);
assertNotNull
(
firstComment);
assertEquals
(
"Jeff"
, firstComment.author);
assertEquals
(
"Nice post"
, firstComment.content);
assertNotNull
(
firstComment.postedAt);
Comment secondComment =
bobPostComments.get
(
1
);
assertNotNull
(
secondComment);
assertEquals
(
"Tom"
, secondComment.author);
assertEquals
(
"I knew that !"
, secondComment.content);
assertNotNull
(
secondComment.postedAt);
}
Comme nous pouvons le constater, naviguer entre le Post et ses Comments n'est pas aisé. Nous devons ainsi passer par une requête pour retrouver l'ensemble des commentaires liés au billet. Nous pouvons cependant améliorer les choses en définissant, au niveau du Post le lien avec les commentaires. Cela se fait par l'ajout d'une propriété comments dans la classe Post :
...
@OneToMany
(
mappedBy=
"post"
, cascade=
CascadeType.ALL)
public
List<
Comment>
comments;
public
Post
(
User author, String title, String content) {
this
.comments =
new
ArrayList<
Comment>(
);
this
.author =
author;
this
.title =
title;
this
.content =
content;
this
.postedAt =
new
Date
(
);
}
...
Notons l'utilisation de l'attribut mappedBy que nous avons faite pour dire à JPA que c'est la classe Post qui maintient la relation. Lorsqu'on définit une relation bidirectionnelle en JPA, il est très important de dire de quel côté la relation est maintenue. Dans notre cas, puisque les commentaires appartiennent à un billet, il est préférable que ce soit la classe Comment qui maintienne cette relation.
La propriété cascade indique à JPA que nous souhaitons que dans le cas où un billet est supprimé, alors tous les commentaires qui lui sont liés soient aussi supprimés.
Maintenant que nous avons établit cette relation, nous pouvons ajouter une méthode à la classe Post pour simplifier l'ajout de nouveaux commentaires :
public
Post addComment
(
String author, String content) {
Comment newComment =
new
Comment
(
this
, author, content).save
(
);
this
.comments.add
(
newComment);
return
this
;
}
A nouveau, écrivons un nouveau cas de test :
@Test
public
void
useTheCommentsRelation
(
) {
// Create a new user and save it
User bob =
new
User
(
"bob@gmail.com"
, "secret"
, "Bob"
).save
(
);
// Create a new post
Post bobPost =
new
Post
(
bob, "My first post"
, "Hello world"
).save
(
);
// Post a first comment
bobPost.addComment
(
"Jeff"
, "Nice post"
);
bobPost.addComment
(
"Tom"
, "I knew that !"
);
// Count things
assertEquals
(
1
, User.count
(
));
assertEquals
(
1
, Post.count
(
));
assertEquals
(
2
, Comment.count
(
));
// Retrieve Bob's post
bobPost =
Post.find
(
"byAuthor"
, bob).first
(
);
assertNotNull
(
bobPost);
// Navigate to comments
assertEquals
(
2
, bobPost.comments.size
(
));
assertEquals
(
"Jeff"
, bobPost.comments.get
(
0
).author);
// Delete the post
bobPost.delete
(
);
// Check that all comments have been deleted
assertEquals
(
1
, User.count
(
));
assertEquals
(
0
, Post.count
(
));
assertEquals
(
0
, Comment.count
(
));
}
Est-ce que nos indicateurs sont toujours dans le vert ?
III-F. Utilisation de Fixtures pour l'écriture de tests plus complexes▲
Lorsqu'on aborde l'écriture de tests plus complexes, la question d'avoir un vrai jeu de données se pose. Fixtures nous autorise à écrire notre jeu de données en se basant sur le format YAML. Ces données seront ainsi chargées en base avant chaque test.
Editons le fichier /yabe/test/data.yml et décrivons un utilisateur :
User(bob):
email: bob@gmail.com
password: secret
fullname: Bob
...
Pour la suite de notre tutoriel, vous pouvez télécharger le fichier Yaml final.
Nous rédigeons maintenant un nouveau cas de test qui utilise ces données, et réalise des vérifications sur celles-ci :
@Test
public
void
fullTest
(
) {
Fixtures.load
(
"data.yml"
);
// Count things
assertEquals
(
2
, User.count
(
));
assertEquals
(
3
, Post.count
(
));
assertEquals
(
3
, Comment.count
(
));
// Try to connect as users
assertNotNull
(
User.connect
(
"bob@gmail.com"
, "secret"
));
assertNotNull
(
User.connect
(
"jeff@gmail.com"
, "secret"
));
assertNull
(
User.connect
(
"jeff@gmail.com"
, "badpassword"
));
assertNull
(
User.connect
(
"tom@gmail.com"
, "secret"
));
// Find all bob's posts
List<
Post>
bobPosts =
Post.find
(
"author.email"
, "bob@gmail.com"
).fetch
(
);
assertEquals
(
2
, bobPosts.size
(
));
// Find all comments related to bob's posts
List<
Comment>
bobComments =
Comment.find
(
"post.author.email"
, "bob@gmail.com"
).fetch
(
);
assertEquals
(
3
, bobComments.size
(
));
// Find the most recent post
Post frontPost =
Post.find
(
"order by postedAt desc"
).first
(
);
assertNotNull
(
frontPost);
assertEquals
(
"About the model layer"
, frontPost.title);
// Check that this post has two comments
assertEquals
(
2
, frontPost.comments.size
(
));
// Post a new comment
frontPost.addComment
(
"Jim"
, "Hello guys"
);
assertEquals
(
3
, frontPost.comments.size
(
));
assertEquals
(
4
, Comment.count
(
));
}
III-G. Sauvegardons notre travail▲
Nous avons maintenant terminé une partie importante de notre application de blog. Comme nous avons créé et testé toutes ces choses, nous allons à présent nous occuper de l'application web à proprement parler.
Mais avant cela, il est temps pour nous de sauvegarder notre travail dans notre gestionnaire de configuration, Bazaar. Exécutons la ligne de commande bzr st afin de voir les modifications apportées depuis le précédent commit :
> bzr st
Comme nous pouvions nous y attendre, certains nouveaux fichiers ne sont pas encore versionnés. Le répertoire test-result ne devant pas être versionné, nous indiquons à Bazaar de l'ignorer :
> bzr ignore test-result
Nous pouvons dès à présent ajouter les autres fichiers grâce à la commande bzr add :
> bzr add
Enfin, le commit final peut être réalisé, avec le petit commentaire qui va bien :
> bzr commit -m "The model layer is ready"