mercredi 25 mai 2011

Scala: persistance avec OrBroker

Aujourd'hui, mon exploration de Scala m'amène sur le terrain de la persistance, élément indispensable pour le développement d’applications d’entreprise. Certes, il y a JDBC avec son cortège d'exceptions à gérer, ses ResultSet à mapper...

Ne peut-on utiliser un framework ORM avec Scala? Un framework qui permettrait de conserver des entités bien écrites?

Pour ce premier article, je vais examiner OrBroker, un petit framework de persistance orienté Scala. L'exercice consistera à écrire les fonctions CRUD classiques qui feront le lien entre une table "Book" qui contient... des livres et des objets Book.

Table

J’utilise PostgreSQL, version 8.4. Dans le cadre de cet article, je veux juste des mappings simples entre des tables et des objets, et non des relations dans un graphe d’objets.

La table utilisée est basique:

CREATE TABLE book
(
 id serial NOT NULL,
 title character varying(255) NOT NULL,
 author character varying(255),
 isbn character varying(13) NOT NULL,
 CONSTRAINT book_pk PRIMARY KEY (id)
)

Une fois créée, je remplis la table avec quelques données:

insert into book (title,author,isbn) values ('Dune','Frank Herbert','1234567891231');
insert into book (title,author,isbn) values ('Le seigneur des anneaux','JRR Tolkien','1234567891232');
insert into book (title,author,isbn) values ('La carte et le territoire','Michel Houellebeck','1234567891444');

En suivant cette procédure, les ids attribués sont 1, 2 et 3 dans l’ordre des insertions.

Note: le code ISBN n'est pas correctement formaté, mais ce n'est pas très important dans le cadre de cet exercice.

Configuration de projet

C’est un projet SBT, développé avec Eclipse (voir http://architecte-software.blogspot.com/2011/05/environnement-de-developpement-scala.html). Le projet s’appelle LibraryOrBroker. Soit HOME le répertoire de base du projet.

Dans le répertoire HOME/project/build/, je crée la classe de build:

import sbt._
import de.element34.sbteclipsify._
class LibraryOrbrokerBuild(info:ProjectInfo) extends DefaultProject(info) with Eclipsify{
    val postgres = "postgresql" % "postgresql" % "8.4-701.jdbc3"
       val orBroker = "org.orbroker" % "orbroker" % "3.1.1"
}

afin d’ajouter dans les dépendances le driver postgresql et le jar d’OrBroker.

Dans la console sbt, un petit reload, suivi d’un update et d’un eclipse.

J’importe ensuite le projet dans Eclipse (http://architecte-software.blogspot.com/2011/05/environnement-de-developpement-scala.html).

Création de l’entité

Dans un package entities, la classe scala s’appelle Book.

package entities

class Book (var id:Option[Long], var title:String, var author:String, val isbn:String){
  def this(title:String, author:String, isbn:String) = this(None,title,author,isbn)
  
  override def toString = title+" de "+author+ " (ISBN: "+isbn+", id: "+id+")"
}

Je ne sais pas encore si c’est la meilleure manière de procéder, mais l’id étant auto-généré (par une séquence), on peut avoir des objets Book sans id (transients) ou avec id (persistants). Avec un type Option[Long], je peux avoir une valeur None (transient) ou un Some[Long] (persistant).

Les deux constructeurs traduisent ces deux possibilités.

De la manière dont l’entité est définie, l’id peut être modifié (lorsqu’il est attribué), de même que le titre et l’auteur s’ils sont édités. Par contre, une fois l’isbn attribué, il ne peut être modifié.

Premier test de select

En suivant l’exemple basique donné dans le wiki d'OrBroker (http://code.google.com/p/orbroker/wiki/Example1) et après avoir rencontré quelques problèmes (notamment le fait qu’OrBroker ne trouve pas les fichiers SQL dans le ClassPath), j’opte pour la définition de deux objets.

Le premier est un RowExtractor (pattern RowMapper) qui va transforme le résultat d'un select en objet Book:

import org.orbroker._
import entities._

object BookExtractor extends RowExtractor[Book]{
    def extract(row:Row) =  new Book(row.bigInt("ID"),row.string("TITLE").get,row.string("AUTHOR").get,row.string("ISBN").get)
}

Le deuxième est mon application:

import entities._
import org.orbroker._
import org.orbroker.config._

object TestCrud {
     val ds = new SimpleDataSource("jdbc:postgresql://localhost:5432/formation")
     val builder = new BrokerBuilder(ds)
     builder.setUser("formation","formation")
     val SelectBook = Token[Book]("SELECT id, title, author, isbn FROM book WHERE ID = :bookID", 'selectBook, BookExtractor)
     val SelectAllBooks = Token[Book]("SELECT id, title, author, isbn FROM book", 'selectAllBooks, BookExtractor)
     val broker = builder.build

     def main(args:Array[String]) = {
         println(find(1))
         println(find(4785))
         println("Liste des livres")
         val books = findAll
         books.foreach(println(_))
     }
  
   def find(id:Long) =  broker.readOnly() {session =>
       session.selectOne(SelectBook,"bookID"->id)/
   }
  
   def findAll = broker.readOnly() { session =>
     session.selectAll(SelectAllBooks)
   }

}

L'exécution de TestCrud écrit sur la console:

Some(Dune de Frank Herbert (ISBN: 1234567891231, id: Some(1)))
None
Liste des livres
Dune de Frank Herbert (ISBN: 1234567891231, id: Some(1))
Le seigneur des anneaux de JRR Tolkien (ISBN: 1234567891232, id: Some(2))
La carte et le territoire de Michel Houellebeck (ISBN: 1234567891444, id: Some(3))

Quelques remarques

Pour commencer, le selectOne renvoie un Option[Book]. Cela lui permet de renvoyer None si la requête ne renvoie aucune ligne. Ce qui m'ennuie, c'est que les méthodes find et findAll renvoient des "types" différents. Bien sûr, la deuxième renvoie une List, mais de Book, alors que la deuxième, plutôt que renvoyer un livre, renvoie une Option[Book]. D'où une différence dans la gestion des résultats. Avec le find, je dois faire un get sur le résultat pour avoir le livre alors que je peux itérer directement sur les livres de la liste renvoyée par findAll. Est-ce gênant?

Toujours est-il qu'une possibilité plus "Java" consiste à renvoyer null si l'id n'existe pas.

D'où la nouvelle méthode select:

def select(id:Long):Book = broker.readOnly() {session =>
       session.selectOne(SelectBook,"bookID"->id) match {
         case Some(b) => b
         case None => null
       }
   }

Une autre possibilité (que je n'aime pas), lancer une exception après le case None.

Une deuxième remarque est à propos de la gestion de la connexion. La documentation est assez floue sur le sujet, mais il apparaît que la session, utilisée de cette manière, gère automatiquement l'ouverture et la fermeture de la connexion.

La dernière remarque est d'ordre pratique: écrire le SQL dans les Token risque d'alourdir le code si les requêtes deviennent complexes.

Deuxième test: externalisation des requêtes

L'exemple donné sur le site d'OrBroker travaille avec un fichier sql mais en pratique, il ne fonctionne pas tel quel (en tout cas, pas avec sbt).

En fait, la doc est assez silencieuse sur le fait que les ressources sql doivent être enregistrées dans le brokerBuilder. Il y a bien un exemple (http://code.google.com/p/orbroker/wiki/JoinExample) mais il enregistre les fichiers via le file system, pas via le classpath, ce qui échoue plus facilement qu'il ne réussit...

En explorant les API et le code source, je vois qu'il faut utiliser ClasspathRegistrant (au lieu de FileSystemRegistrant, ce n'était pas sorcier) qui enregistre une map de ressources (symbol -> fichier de la ressource) dans le builder... Manque de documentation à nouveau, les api ne font que lister les classes et les méthodes, sans autres commentaires.

Quoiqu'il en soit, allons-y et ajoutons par la même occasion la sauvegarde d'une entité, c'est-à-dire son insertion ou son update et le delete pour terminer le CRUD.

Fichiers sql

Quatre fichiers sql doivent être créés dans le répertoire /src/main/resources, à la racine par exemple. On y retrouve les requêtes sql qui étaient dans le code auparavant, plus l'insert et l'upadte.

selectBook.sql
SELECT id, title, author, isbn FROM book WHERE ID = :bookID;

selectAllBooks.sql
SELECT id, title, author, isbn FROM book;

insertBook.sql
insert into book (title,author,isbn) values (:title,:author,:isbn);


updateBook.sql
update book SET title=:title,author=:author WHERE id = :bookID;

deleteBook.sql
delete from book where id=:bookID;


L'object TestCrud

On garde le BookExtractor, qui ne change pas. Par contre, l'object TestCrud devient:

object TestCrud {
     val ds = new SimpleDataSource("jdbc:postgresql://localhost:5432/test")
     val builder = new BrokerBuilder(ds)
     builder.setUser("postgres","postgres")
     val resources = Map(
         'selectBook -> "/selectBook.sql",
         'selectAllBooks -> "/selectAllBooks.sql",
         'insertBook -> "/insertBook.sql",
         'updateBook -> "/updateBook.sql",
         'deleteBook -> "/deleteBook.sql"
         )
     ClasspathRegistrant(resources).register(builder)
     val SelectBook = Token[Book]('selectBook, BookExtractor)
     val SelectAllBooks = Token[Book]('selectAllBooks, BookExtractor)
     val InsertBook = Token[Book]('insertBook, BookExtractor)
     val UpdateBook = Token[Book]('updateBook)
     val DeleteBook = Token[Book]('deleteBook)

     val broker = builder.build

     def main(args:Array[String]) = {
         println(find(1))
         println(find(4785))
         println("Liste des livres")
         var books = findAll
         books.foreach(println(_))
         val newBook = new Book("Test","moi","0000000")
         save(newBook)
         println("Id du nouveau livre: "+newBook.id)
         println("Liste des livres")
         books = findAll
         books.foreach(println(_))
         println("Suppression du nouveau livre")
         delete(newBook)
         println("Liste des livres")
         books = findAll
         books.foreach(println(_))
     }
  
   def find(id:Long) =      broker.readOnly() {session =>
       session.selectOne(SelectBook,"bookID"->id) match {
         case Some(b) => b
         case None => null
       }
   }
  
   def findAll = broker.readOnly() { session =>
     session.selectAll(SelectAllBooks)
   }

   def save(book:Book) = book.id match {
     case None =>
       broker.transactional() {transaction =>
         val b =transaction.executeForKey(InsertBook,"title" -> book.title,"author" -> book.author,"isbn" -> book.isbn)
         transaction.commit
         book.id = b.get.id
       }
     case _ =>
       broker.transactional() {transaction =>
         transaction.execute(UpdateBook,"bookID" -> book.id.get, "title" -> book.title,"author" -> book.author)
         transaction.commit
       }
   }
  
   def delete(book:Book) = broker.transactional() { transaction =>
       transaction.execute(DeleteBook,"bookID" -> book.id.get)
       transaction.commit
   }
}

Le résultat de l'exécution donne:

Dune de Frank Herbert (ISBN: 1234567891231, id: Some(1))
null
Liste des livres
Dune de Frank Herbert (ISBN: 1234567891231, id: Some(1))
Le seigneur des anneaux de JRR Tolkien (ISBN: 1234567891232, id: Some(2))
La carte et le territoire de Michel Houellebeck (ISBN: 1234567891444, id: Some(3))
Id du nouveau livre: Some(4)
Liste des livres
Dune de Frank Herbert (ISBN: 1234567891231, id: Some(1))
Le seigneur des anneaux de JRR Tolkien (ISBN: 1234567891232, id: Some(2))
La carte et le territoire de Michel Houellebeck (ISBN: 1234567891444, id: Some(3))
Test de moi (ISBN: 0000000, id: Some(4))
Suppression du nouveau livre
Liste des livres
Dune de Frank Herbert (ISBN: 1234567891231, id: Some(1))
Le seigneur des anneaux de JRR Tolkien (ISBN: 1234567891232, id: Some(2))
La carte et le territoire de Michel Houellebeck (ISBN: 1234567891444, id: Some(3))
 

Conclusions

Si l'on exclut la configuration, l'utilisation du framework est assez simple. Les méthodes "find" et "findAll" s'écrivent facilement, de même que le RowMapper.

Mais la configuration est le gros point noir. Elle est peu intuitive et le manque de documentation n'arrange rien.
Quelle que soit l'option choisie, c'est la partie "lourde". Dans le premier cas, les requêtes sql sont des String définis dans les Token. Dans le deuxième, ce sont autant de fichiers que de requêtes, mais une configuration un peu plus lourde. Peut-être faut-il d'ailleurs combiner les deux. Garder les requêtes simple "inline" et externaliser dans des fichiers sql les requêtes complexes.

D'autres questions se posent comme "combien faut-il d'instance du BrokerBuilder"? Une seule, sans doute. Encore que... Et combien de Broker?

Des idées pour un prochain article...

En attendant, pas vraiment convaincu...

mercredi 18 mai 2011

Maven: quand test et compile s'emmêlent

Voici un problème un peu vicieux auquel j’ai été confronté récemment. En cause, un comportement inattendu (du moins à mes yeux) de Maven.

Le contexte, un petit projet Web avec, entre autres, de l’Hibernate. Dans mon pom.xml, j’ai donc les lignes suivantes:

<dependency>
         <groupId>org.hibernate</groupId>
         <artifactId>hibernate-annotations</artifactId>
         <version>3.4.0.GA</version>
         <scope>compile</scope>
</dependency>

Cette dépendance est suffisante pour obtenir toutes les librairies nécessaires à Hibernate. Elle importe notamment Hibernate-core.

Pour les tests de mes dao, j’utilise HSQL comme DB mémoire et DBCP pour me fournir un pool de connexions.

Il y a aussi du spring dans mon projet, ce qui a finalement peu d'importance. Je configure les fichiers web.xml, applicationContext.xml... et j’essaye de déployer "à blanc" l’application sur un Tomcat. C’est une opération que j’effectue toujours avec un nouveau projet, car elle me permet de valider que la configuration de base est correcte.

Et là, l’application refuse de se déployer. Dans les logs, je vois un java.lang.NoClassDefFoundError: org/apache/commons/collections/map/LRUMap. C'est Hibernate qui cherche cette classe mais ne la trouve pas.

C’est une classe des commons-collections. Eclipse, avec le plugin Maven m'indique pourtant qu'elle se trouve dans mes dépendances. Une autre vérification m'indique que le jar est effectivement déployé dans le répertoire WEB-INF/lib. Cependant, la version déployée ne contient pas la classe LRUMap.

Il s'agit de la version 2.1 et en y regardant de plus près, je vois que Hibernate demande (dépendance transitive) la version 3.1.

Comment cela se fait-il?

Une version peut en masquer une autre

Le problème vient de la version de DBCP utilisée pour les tests, la 1.2.1, une "vieille".

<dependency>
         <groupId>commons-dbcp</groupId>
         <artifactId>commons-dbcp</artifactId>
         <version>1.2.1</version>
         <type>jar</type>
         <scope>test</scope>
</dependency>

Celle-ci a comme dépendance transitive la version 2.1 des commons-collections.

Du point de vue Maven, il y a donc conflit entre les deux versions. Pour le résoudre, Maven va choisir la dépendance la plus proche de la racine.

La version 3.1 de commons-collection est une dépendance transitive d’une dépendance transitive (hibernate-core) d’hibernate-annotations. Donc, un niveau 2. Par contre, la version 2.1 est une dépendance transitive directe de dbcp, donc de niveau 1.

C’est la version 2.1 qui est choisie.

Oui mais !

Le mécanisme de résolution est clair, mais dbcp est en test ! Et quand je fais un package, les dépendances de test ne sont pas incluses dans mon War final (de même que les classes et les ressources de test). C’est normal.

Et pourtant...

Si on fait un mvn  dependency:resolve, on obtient le résultat suivant (je ne garde que les lignes intéressantes):

[INFO] The following files have been resolved:
...
[INFO]    commons-collections:commons-collections:jar:2.1:compile
[INFO]    commons-dbcp:commons-dbcp:jar:1.2.1:test
...
[INFO]    org.hibernate:hibernate-annotations:jar:3.4.0.GA:compile
[INFO]    org.hibernate:hibernate-commons-annotations:jar:3.1.0.GA:compile
[INFO]    org.hibernate:hibernate-core:jar:3.3.0.SP1:compile
…

Le scope de dbcp est bien test. Le scope d’hibernate est bien compile. De même pour commons-collection... sauf qu’il est dans la version 2.1, celle qui vient de test.

Et si je fais mvn dependency:resolve -DincludeScope=compile, je n’ai pas les jars de scope test (heureusement !). Commons-collections y est bien, puisqu’il est une dépendance transitive d’un jar en scope compile (hibernate)  MAIS sa version est celle qui vient de dbcp, lequel est en test.

Est-ce un bug? Toujours que le résultat est le même avec Maven version 2.2.1 et 3.0.3.

Le compile et le packaging ne devraient-ils se baser uniquement sur les dépendances compile et provided (hors test) pour résoudre les dépendances et leurs versions?

Quoi qu’il en soit, dans mon cas, la solution au problème était assez simple, puisqu’il suffisait de changer la version de dbcp:

<dependency>
       <groupId>commons-dbcp</groupId>
       <artifactId>commons-dbcp</artifactId>
       <version>1.3</version>
       <type>jar</type>
       <scope>test</scope>
</dependency>

Cela met les pendules à l’heure puisque la dépendance transitive vers les commons-collection est aussi 3.1.

N’empêche, le risque est  là. Dans le cas présent, le problème empêchait le déploiement, mais ne peut-on pas imaginer que le problème se produirait plus tard, à l'exécution d'une obscure méthode (forcément jamais testée...), avec alors beaucoup de difficultés pour déterminer la cause profonde.

Voilà qui fait froid dans le dos, non?

lundi 16 mai 2011

Environnement de développement Scala: sbt + Eclipse

Dans le cadre de tests de persistance avec Scala, je me suis rapidement rendu compte que travailler avec sbt (incontournable) et Notepad++ était assez inconfortable.
C'est d'autant plus vrai qu'Eclipse dispose d'un plugin qui fonctionne bien et qu'il existe pour sbt un plugin permettant de rendre un projet sbt compatible avec Eclipse: Eclipsify.
Dans un document où je tiens la liste des les ressources Scala que je trouve, j’ai indiqué le site d’Eclipsify (http://github.com/musk/SbtEclipsify) et j’ai ajouté comme commentaire "ça marche, bien lire!". Malgré tout, j’ai dû m’y reprendre à plusieurs reprises. Comme quoi "bien lire" est plus compliqué qu’il n’y paraît...
Voici la procédure que j’ai suivie. Bien lire...

Le plugin scala pour Eclipse

Pour ma part, j’utilise Eclipse 3.6 (Helios). Quant au plugin Scala, j'utilise la version 2.0.0, qui est certes en bêta, mais qui fonctionne correctement. Le site d'update se trouve à http://download.scala-ide.org/releases/2.0.0-beta/
L'installation du plugin se fait comme pour tous les plugins dans Eclipse.

Simple Build Tool - sbt

Le site de sbt se trouve à http://code.google.com/p/simple-build-tool/. Pour le faire fonctionner, il suffit de dowloader la dernière version du jar et de créer un script de lancement (indiqué sur le site).
Voici la version windows, sbt.bat
set SCRIPT_DIR=%~dp0
java -Dhttp.proxyHost=proxy -Dhttp.proxyPort=80 -Xmx512M -jar "%SCRIPT_DIR%sbt-launch-0.7.7.jar" %*
Il est bien entendu que la version de sbt-launch.jar correspond à la version utilisée.
Pour terminer, il faut ajouter le répertoire qui contient le ".bat" dans la variable d'environnement path.

Eclipsify

Voilà deux bons outils, mais qui ne sont pas liés. Par défaut, sbt utilise une structure de répertoires du genre Maven, là où Eclipse s'en tient à un simple répertoire src. Les deux peuvent être liés avec le plugin Eclipsify. Le terme "plugin" peut être ambigu ici: il s’agit d’un plugin sbt et non d'Eclipse.
Son utilisation doit être configurée dans un projet sbt.
Par convention, "HOME" est le répertoire de base du projet.

Config du plugin

Dans HOME/project/plugins (plugins doit sans doute être créé), créer un ficher NomDuProjectPlugins.scala. C’est là qu’on va configurer l’utilisation du plugin:

import sbt._
class NomDuProjetPlugins(info: ProjectInfo) extends PluginDefinition(info) {
    lazy val eclipse = "de.element34" % "sbt-eclipsify" % "0.7.0"
}

Config du projet

Dans HOME/project/build, créer le fichier de Build du nom NomDuProjectBuild.scala:

import sbt._
import de.element34.sbteclipsify._
class NomDuProjectBuild(info:ProjectInfo) extends DefaultProject(info) with Eclipsify{
    //dépendances du projet, configuration du projet...
}

A noter le "with Eclipsify" puisque c'est celui que j’oublie à chaque fois... :-)

Recharger la configuration

Dans la console sbt, taper reload. sbt recharge la configuration du projet. Si tout s’est bien passé, vous avez désormais une action “eclipse” disponible.
Il suffit de taper "eclipse" dans la console sbt pour “éclipsifier” le projet. Si vous n’en êtes pas là, "actions" vous montrera la liste des actions disponibles: "eclipse" devrait être dedans....

Importer le projet dans Eclipse

Dans Eclipse, import existing project... Choisir le HOME. Le projet est importé avec la nature scala.

Au boulot maintenant ! Ou presque.

En fait, il faut développer dans eclipse et utiliser sbt pour lancer les tests, packager...

Integration plugin

Il existe un plugin Eclipse permettant l'intégration de sbt avec Eclipse. Pour le moment, il sent encore le neuf et la documentation est un peu faible.
Voici l'adresse: http://www.assembla.com/spaces/sbt-eclipse-integration/wiki. Un site d'update existe momentanément car le responsable de ce projet souhaite le lier à Eclipsify. http://sbtei.element34.de/updatesite/
Le plugin s'installe sans problème et permet d'ajouter une nature "sbt" au projet. Cela se fait depuis la package view, un clic droit sur le projet. Une entrée sbt s'affiche dans le menu contextuel.
Dans le préférence, il faut aussi configurer le sbt-launcher.jar utilisé.
Ce qu'il fait d'autre? Pour le moment, je l'ignore...