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

Aucun commentaire:

Enregistrer un commentaire