jeudi 7 juillet 2011

Hibernate: flush et dirty checking

Avec Hibernate, lorsqu'une entité (attachée) est manipulée, toute modification qui lui est apportée est supposée être reportée dans la base de données.

Néanmoins, afin d'éviter des "update" permanents, Hibernate retarde le plus possible cette mise à jour en utilisant la session comme cache. A certains moments, la session sera synchronisée avec la base de données (ce qu'on appelle le "flush"). Hibernate vérifiera si les entités attachées ont subi des modifications avant de lancer les updates (ce qu'on appelle le "dirty checking").

L'article qui suit aborde le fonctionnement du dirty checking et montre à quelles occasions un flush est effectué.

Pour illustrer tout cela, je vais définir un jeu d'entités. La version d'Hibernate utilisée est 3.3.0.SP1. Le fichier hibernate.cfg.xml est configuré de manière à afficher le SQL généré.

Les entités


Voici les classes utilisées pour les tests. Le schéma DB a été généré sur base du mapping décrit dans les classes.

Dog


import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
public class Dog {
   @Id @GeneratedValue(strategy=GenerationType.AUTO)
   private Long id;
   private String name;
   public Long getId() {
       return id;
   }
   public void setId(Long id) {
       this.id = id;
   }
   public String getName() {
       return name;
   }
   public void setName(String name) {
       this.name = name;
   }
   
   @Override
   public int hashCode() {
       final int prime = 31;
       int result = 1;
       result = prime * result + ((name == null) ? 0 : name.hashCode());
       return result;
   }
   @Override
   public boolean equals(Object obj) {
       if (this == obj)
           return true;
       if (obj == null)
           return false;
       if (getClass() != obj.getClass())
           return false;
       Dog other = (Dog) obj;
       if (name == null) {
           if (other.name != null)
               return false;
       } else if (!name.equals(other.name))
           return false;
       return true;
   }
}

Address


import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;

@Entity
public class Address {
   @Id @GeneratedValue(strategy=GenerationType.AUTO)
   private Long id;
   private String town;
   public Long getId() {
       return id;
   }
   public void setId(Long id) {
       this.id = id;
   }
   public String getTown() {
       return town;
   }
   public void setTown(String town) {
       this.town = town;
   }
}

Master


import java.util.Set;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.JoinColumn;
import javax.persistence.OneToMany;
import javax.persistence.OneToOne;

@Entity
public class Master {
   @Id @GeneratedValue(strategy=GenerationType.AUTO)
   private Long id;
   private String name;
   private String couleurCheveux;
   @Column(updatable=false)
   private int age;
   @OneToOne
   private Address address;
   @OneToMany
   @JoinColumn(name="master_id")
   private Set<Dog> dogs;
   
   public Long getId() {
       return id;
   }
   public void setId(Long id) {
       this.id = id;
   }
   public String getName() {
       return name;
   }
   public void setName(String name) {
       this.name = name;
   }
   public String getCouleurCheveux() {
       return couleurCheveux;
   }
   public void setCouleurCheveux(String couleurCheveux) {
       this.couleurCheveux = couleurCheveux;
   }
   public int getAge() {
       return age;
   }
   public void setAge(int age) {
       this.age = age;
   }
   public Address getAddress() {
       return address;
   }
   public void setAddress(Address address) {
       this.address = address;
   }
   public Set<Dog> getDogs() {
       return dogs;
   }
   public void setDogs(Set<Dog> dogs) {
       this.dogs = dogs;
   }
}

Les données


Voici un script qui permet d'insérer les données utilisées pour ces tests:

INSERT INTO address (id, town) VALUES (2, 'Bruxelles');
INSERT INTO address (id, town) VALUES (3, 'Anvers');
INSERT INTO dog (id, name, master_id) VALUES (4, 'Bill', 7);
INSERT INTO dog (id, name, master_id) VALUES (5, 'Médor', 7);
INSERT INTO dog (id, name, master_id) VALUES (6, 'Brutus', NULL);
INSERT INTO master (id, age, name, address_id, couleurcheveux) VALUES (7, 12, 'Boule', 2, 'Roux');

Le dirty checking


C'est le mécanisme utilisé par Hibernate pour déterminer quelles entités attachées à la session ont été modifiées et doivent déclencher un update de la base de données.

D'une manière générale, cette opération est effectuée lors d'un flush de la session. Nous verrons plus loin à quelles occasions cela arrive.

En pratique, dans ce qui suit, je provoquerai le flush manuellement, en appelant la méthode "flush" de l'objet session.

Tests effectués


Dans les exemples qui suivent, je ne souhaite pas modifier réellement mes données. Aussi, les modifications seront à l'intérieur d'une transaction sur laquelle j'appellerai rollback au final.

De cette manière, je verrai le SQL émis par Hibernate vers la base de données, mais les modifications ne seront pas commitées.

C'est d'ailleurs un point qu'il est important de noter car certains développeurs s'inquiètent des flush qu'Hibernate est susceptible de faire à tout moment (voir plus loin) et ne réalisent pas toujours que les données ne sont pas forcément commitées.

Modification d'une propriété


Voici le premier test (pour les puristes, ce n'est pas un test unitaire, mais juste une méthode "main"). Il servira de base aux tests suivants et sera modifié au besoin:

import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.cfg.AnnotationConfiguration;

public class PropertyUpdate {
   public static void main(String[] args) {
       SessionFactory sf = new AnnotationConfiguration().configure().buildSessionFactory();
       Session session = sf.openSession();
       Transaction tx = session.beginTransaction();

       Master master = (Master) session.get(Master.class,7L);
       master.setName("Marcel");
       
       session.flush();
       
       tx.rollback();
       session.close();
       sf.close();
   }
}

Le résultat est l'écriture dans la console de:

Hibernate: update Master set address_id=?, couleurCheveux=?, name=? where id=?

Ce qui montre qu'Hibernate met à jour la row correspondant à l'objet Master modifié. Il est facile de vérifier que cette mise à jour arrive au moment du flush. C'est lui qui entraîne un dirty checking et c'est le résultat de ce dirty checking qui entraîne l'update.

Au passage, remarquons que la colonne "age" n'est pas mise à jour. Heureusement, puisqu'elle est updatable=false.

Fonctionnement

Lorsqu'il charge une entité, Hibernate conserve l'état initial des données d'un côté et crée une entité de l'autre. Il renvoie ensuite la référence de cette dernière. Lors du flush, Hibernate compare la valeur des propriétés de l'entité avec son état initial, conservé au niveau de la session. S'il constate une différence, il déclare l'entitié "dirty" ce qui provoquera l'update.

Modification d'une propriété non updatable


Cette fois, j'essaye

master.setAge(50);

en remplacement de

master.setName("Marcel");

Ce code ne provoque aucun update. Hibernate ne vérifie pas les propriétés non updatables lors du dirty checking. (On discutera plus loin de ce setAge...)

Modification d'une collection


Je vais ajouter un nouveau chien à la collection "dogs". A noter que ce chien est persistant. Bien sûr, je ne modifie aucune autre propriété. Donc en remplacement de la ligne ci-dessus, j'ai:

Dog dog = (Dog) session.get(Dog.class,6L);
master.getDogs().add(dog);

Le flush provoque bien un update.

Hibernate: update Dog set master_id=? where id=?

Pour les collections, Hibernate utilise un système particulier. Dans une entité provenant de la session, les références collections sont des implémentations propres à Hibernate. Par exemple, dans le cas présents, "dogs" est un PersistentSet. Une particularité de ces implémentations est de posséder une propriété "dirty", un boolean qui est à false au début mais qui sera mis à true lors d'une modification du contenu.

La ligne master.getDogs().add(dog) provoque plusieurs choses:

  1. l'initialisation de la collection (pour faire le "add"), car la collection étant lazy-loadée (c'est la valeur par défaut), le set doit être initialisé. Dans la console, la ligne
    Hibernate: select dogs0_.master_id as master3_1_, dogs0_.id as id1_, dogs0_.id as id2_0_, dogs0_.name as name2_0_ from Dog dogs0_ where dogs0_.master_id=?
    apparaît.
  2. l'ajout du chien provoque la levée du flag "diry" qui indique que la collection a été modifiée et qu'Hibernate doit faire un update des relations.
Lors du dirty checking, Hibernate vérifie également le statut des collections et provoque un update si elles sont "dirty".

Supposons à présent qu'on ajoute un chien déjà présent dans la collection:
Dog dog = (Dog) session.get(Dog.class,4L); //Bill
master.getDogs().add(dog);

Cette fois, le flag dirty n'est pas levé, puisque le contenu de la collection n'est pas modifié, et aucun update n'est effectué.

Que se passe-t-il par contre si j'ajoute un autre chien nommé "Bill"?

Dog dog = (Dog) session.get(Dog.class,12L); //Un autre Bill
master.getDogs().add(dog);

Parce que j'ai défini l'égalité sur la propriété "name" de Dog, la collection n'est pas modifiée et il n'y a pas d'update.

En fait, la logique globale est incorrecte. Si l'égalité porte effectivement sur le "name" alors il ne devrait pas y avoir deux chiens différents qui s'appellent de la même manière (et nous sommes bien d'accord: sur un plan purement fonctionnel, ça n'a aucun sens). Je considère comme une bonne pratique de combiner une contrainte d'unicité (qui peut porter sur plusieurs colonnes) à la définition de la méthode equals.

Et pour ceux qui pourraient être tentés, je rappelle que ce n'est pas une bonne pratique - c'est même une importante source d'erreur - que de définir la méthode equals sur base d'un id auto-généré.

Modification d'une référence si la table possède la FK


Address a = (Address) session.get(Address.class,3L);
master.setAddress(a);

Hibernate fait l'update. En fait, il compare les id des références. Si on explore la session en mode debug, on constate qu'Hibernate a bien conservé comme valeur de référence, pour la propriété "address", l'id de l'objet Address référencé.

Flush? Oui mais quand?


Dans les exemples précédents, nous avons provoqué un flush en appelant la méthode flush. Un flush aura également lieu à la fin d'une transaction, lors du commit. A noter que ce comportement est "par défaut" et qu'il peut être modifié en changeant de le FlushMode de la session.

Ainsi, si on ajoute dans le code:

session.setFlushMode(FlushMode.MANUAL);

le commit de la transaction ne provoquera pas de flush.

Il existe d'autres circonstances, moins bien cernées par les développeurs. Par exemple, un flush sera nécessaire avant certaines requêtes HQL (ou SQL).

Imaginons le cas suivant: je récupère une entité dont je change une propriété. Puis je fais une requête portant sur la propriété modifiée.

Voici le code:

Master master = (Master) session.get(Master.class,7L);
master.setName("toto");

session.createQuery("from Master m where m.name=:name").setParameter("name", "Alfred").list();

Pour les raisons déjà citées, cela se passe dans le cadre d'une transaction et il y a un rollback à la fin. Attention aussi, si vous avez suivi les exemples, de ne pas modifier le FlushMode par défaut.

En pratique, j'exécute ce code en mode debug et je place un breakpoint sur le "createQuery". De cette manière, je vois quand les flush sont fait.

En effet, c'est sur cette ligne que le SQL suivant apparaît:

Hibernate: update Master set address_id=?, couleurCheveux=?, name=? where id=?

suivi de:

Hibernate: select master0_.id as id0_, master0_.address_id as address4_0_, master0_.age as age0_, master0_.name as name0_ from Master master0_ where master0_.name=?

Hibernate comprend que la requête porte sur des entités attachées qui peuvent avoir été modifiées et synchronise toutes ces entités de la session en effectuant un flush, et donc un dirty checking qui provoque l'update.

Dans le cas présent, les modifications effectuées n'ont pas d'impact sur le résultat de la requête, mais ça, Hibernate ne peut pas le savoir.

Hibernate détecte néanmoins certaines situations. Par exemple:

Dog dog = (Dog) session.get(Dog.class, 4L); //Bill, lié à Boule
dog.setName("Patch");

session.createQuery("from Master m where m.name=:name").setParameter("name", "Boule").list();

On peut tester ce code avec la collection de Dogs en lazy ou en eager, ça ne change rien. Hibernate ne fait pas d'update de Dog. En effet, la requête ne porte pas sur une entitié attachée à la session.

Le eager n'y change rien car dans ce cas, Hibernate fait un select sur Dog pour initialiser la collection, y retrouve un Dog d'id 4 qu'il a déjà dans sa session et renvoie l'entité de sa session, celle qui s'appelle désormais Patch...

Voici d'autres cas de figure.

Recherche par id


Master master = (Master) session.get(Master.class,7L);
master.setName("toto");

Master m = (Master) session.get(Master.class,7L);

Dans ce cas, Hibernate ne fait même pas de deuxième requête. Il se contente d'aller vérifier que la session ne contient pas déjà un objet Master d'id 7. Ce qui est le cas, et la référence de l'entité modifiée est renvoyée.

Recherche sur une propriété non modifiée


Que se passe-t-il si on fait une recherche sur une propriété qui n'a pas été modifiée?

Master master = (Master) session.get(Master.class,7L);
master.setCouleurCheveux("Blond");

session.createQuery("from Master m where m.name=:name").setParameter("name", "Boule").uniqueResult();

Hibernate fait l'update (le moyen qu'il devrait mettre en oeuvre pour ne pas le faire serait de confronter les critères de recherche aux paramètres modifiés... Un peu trop complexe.)

Recherche sur une propriété non modifiable


Quid d'une recherche sur "age"? Propriété updatable=false?

Master master = (Master) session.get(Master.class,7L);
master.setCouleurCheveux("Blond");

session.createQuery("from Master m where m.age=:age").setParameter("age", 12).uniqueResult();

Il y a update... Ce qui est peut-être un peu frustrant car Hibernate pourrait détecter que la propriété ne peut être modifiée...

Recherche sur une propriété après modification d'une propriété non modifiable


Tout est dans le titre: modification d'une propriété non modifiable (autrement dit, non updatable)? Attention, frustration possible...

Je modifie l'âge, propriété non updatable, et je fais une recherche sur le nom.

Master master = (Master) session.get(Master.class,7L);
master.setAge(15);

Master m = (Master) session.createQuery("from Master m where m.name=:name").setParameter("name", "Boule").uniqueResult();

Hibernate ne fait pas l'update ! Pourquoi? En fait, le flush est fait, mais le dirty checking ne signale aucune modification de l'entité puisqu'il ne porte pas sur les propriétés non updatables.

Cela semble correct, non? Certainement, mais vous pourriez vous sentir frustrés car, pour la première fois, vous manipulez avec m une entité qui ne correspond pas à la valeur de la base de données. Son âge est de 15. En DB l'âge reste (et restera) 12.

En fait, m et master pointe sur la même instance car Hibernate a détecté que le résultat était l'entité d'id 7 et a donc renvoyé la référence contenue dans la session.

Quel est le problème? Ce n'est pas Hibernate, ni la DB. Non, le problème est entre la chaise et le clavier. C'est le développeur qui a construit un modèle peu solide en permettant la modification d'une propriété qui ne peut pas être modifiée. En fait, "age" ne devrait avoir aucun setter...

Conclusion


On pourrait continuer et faire d'autres tests. Le principe de base, c'est qu'Hibernate essaye de maintenir une cohérence entre les entités attachées et la base de données. Ainsi, toute modification apportée à une entité devrait être répercutée sur la base de données.

Mais pour améliorer ses performances, Hibernate retarde cette mise à jour jusqu'au dernier moment. Quand Hibernate sent que le résultat d'une requête pourrait être impacté par les modifications apportées aux entités, il provoque un flush de la session, ce qui déclenche un dirty checking et, le cas échéant, une mise à jour de la base de données.

Pas sorcier, juste logique.

dimanche 26 juin 2011

Scala: persistance avec MyBatis

Mon exploration de la persistance avec Scala m'amène aujourd'hui vers MyBatis (anciennement iBatis).

Le projet est le même que dans l'article consacré à OrBroker. Aussi, la table et son initialisation sont reprises de ce projet.

J'ai juste légèrement modifié la classe entité où mon id est passé de Option[Long] à Option[Int].

Là voici
package entities

class Book (var id:Option[Int], 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+")"
}

Pour la justification du Option, voir l'article précédent.


Problèmes

L'utilisation d'un Option[Int] pour l'id nécessite un travail supplémentaire. A noter que ce travail aurait de toute façon été nécessaire si l'id avec été un Int (Scala). Par défaut, MyBatis aurait voulu un int (Java) ou un java.lang.Integer.

Je décide d'utiliser un TypeHandler pour transformer mon int en Option[Int].

Le TypeHandler

package typehandlers

import org.apache.ibatis.`type`.{TypeHandler,JdbcType}
import java.sql._

import entities._

class OptionalIntTypeHandler extends TypeHandler{
    override def setParameter (ps : PreparedStatement,i : Int, parameter : Object, jdbcType : JdbcType) : Unit = {
        parameter match {
            case Some(j:Int) => ps.setInt(i,  j)
            case _ => ps.setNull(i,Types.INTEGER)
        }
    }

    override def getResult (rs : ResultSet, columnName : String) : Object = {
        Some(rs.getInt(columnName)).asInstanceOf[Object]
    }

    override def getResult (cs : CallableStatement, columnIndex : Int) : Object = {
        Some(cs.getInt(columnIndex)).asInstanceOf[Object]
    }
}

Mise en place

Il s'agit d'un projet sbt (voir cet article pour la configuration).

Le fichier build est le suivant:

import sbt._
import de.element34.sbteclipsify._

class IBatisBuild(info: ProjectInfo) extends DefaultProject(info) with Eclipsify{
    val ibatis = "org.mybatis" % "mybatis" % "3.0.1"
    val postgres = "postgresql" % "postgresql" % "8.4-701.jdbc4"
}

Fichiers de configurations

Configuration de MyBatis

Dans src/main/resources/ibatis, un fichier SqlMapConfig.xml:

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
    PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-config.dtd">  
<configuration>
   <typeHandlers>
       <typeHandler handler="typehandlers.OptionalIntTypeHandler" javaType="scala.Some"/>
       <typeHandler handler="typehandlers.OptionalIntTypeHandler" javaType="scala.Option"/>
       <typeHandler handler="typehandlers.OptionalIntTypeHandler" javaType="scala.None"/>
   </typeHandlers>
   <environments default="development">
       <environment id="development">
           <transactionManager type="JDBC"/>
           <dataSource type="POOLED">
               <property name="driver" value="org.postgresql.Driver"/>
               <property name="url" value="jdbc:postgresql://localhost:5432/formation"/>
               <property name="username" value="formation"/>
               <property name="password" value="formation"/>
           </dataSource>
       </environment>
    </environments>
    <mappers>
       <mapper resource="ibatis/BOOK_SqlMap.xml"/>
    </mappers>
</configuration>

Difficultés rencontrées
La configuration du typehandler m'a causé quelques soucis. Ce qui me semblait logique, c'était de le configurer sur le type sclala.Option et qu'il serait utilisé si le type de la variable était Option (ce qui est effectivement le cas), mais aussi pour les types dérivés (scala.Some). Et ça, apparemment, ce n'est pas le cas.

J'ai donc dû ajouter une ligne pour "scala.Some" et tant que j'y étais (même si ce n'était pas nécessaire ici) pour "scala.None".

Configuration du mapping

Dans src/main/resources/ibatis, un fichier BOOK_SqlMap.xml:

<!DOCTYPE mapper
    PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">  
<mapper namespace="be.fabrice.BookMapper">
    <resultMap id="bookResult" type="entities.Book">
       <constructor>
           <idArg column="id" javaType="scala.Option"/>
           <arg column="title" javaType="String"/>
           <arg column="author" javaType="String"/>
           <arg column="isbn" javaType="String"/>
       </constructor>
    </resultMap>

    <select id="findBook" parameterType="int" resultMap="bookResult">
       SELECT id, title, author, isbn FROM book WHERE id = #{value}
    </select>

    <select id="findAll" resultMap="bookResult">
       SELECT id, title, author, isbn FROM book
    </select>

    <insert id="insertBook" parameterType="entities.Book" keyProperty="id" useGeneratedKeys="true" >
       insert into book (title,author,isbn) values (#{title},#{author},#{isbn});
    </insert>

    <update id="updateBook" parameterType="entities.Book">
         update book SET title=#{title},author=#{author} WHERE id = #{id};
    </update>
     
    <delete id="deleteBook" parameterType="entities.Book">
         delete from book where id=#{id}
    </delete>
</mapper>

BookDao.scala

Je ne suis pas certain que le pattern est bien exploité. La construction du sqlMap devrait par exemple être unique au niveau de tous les Dao à venir. Néanmoins, c'est un point de départ.

Pas d’interface non plus, juste un objet pour définir un singleton. Pas très propre comme programmation, mais pleinement assumé :-)

La classe est créée dans un sous-répertoire “dao”:

package dao

import org.apache.ibatis.session._
import org.apache.ibatis.io._

import entities.Book

import scala.collection.JavaConversions._

object BookDao {
    val sqlMap = new  SqlSessionFactoryBuilder().build(Resources.getResourceAsReader("ibatis/SqlMapConfig.xml"))

    def find(id:Int):Book =  {
        val session = sqlMap.openSession()
        try{
            session.selectOne("be.fabrice.BookMapper.findBook", id).asInstanceOf[Book]
        }finally {
            session.close
        }
    }

    def findAll():List[Book] = {
        val session = sqlMap.openSession()
        try{
            session.selectList("be.fabrice.BookMapper.findAll").toList.asInstanceOf[List[Book]]
        }finally {
            session.close
        }
    }

    def save(book:Book) = {
        val session = sqlMap.openSession()
        try {
            book.id match {
                case None =>  session.insert("be.fabrice.BookMapper.insertBook",book)
                case _ =>     session.update("be.fabrice.BookMapper.updateBook",book)
            }
            session.commit()
        } finally {
            session.close
        }
    }

    def delete(book:Book) = {
        val session = sqlMap.openSession()
        try {
            session.delete("be.fabrice.BookMapper.deleteBook",book)
            session.commit()
        } finally {
            session.close()
        }
    }
}

Explications


Initialisation


val sqlMap = new  SqlSessionFactoryBuilder().build(Resources.getResourceAsReader("ibatis/SqlMapConfig.xml"))

Comme annoncé ci-dessus, cette ligne devrait être commune à tous les Dao. Elle lit le fichier de configuration décrit plus haut.

Le find

def find(id:Int):Book =  {
    val session = sqlMap.openSession()
    try{
        session.selectOne("be.fabrice.BookMapper.findBook", id).asInstanceOf[Book]
    }finally {
        session.close
    }
}

Chacune des méthodes du Dao commence par ouvrir une session et termine en la fermant. La fermeture est dans le finally pour garantir qu'elle est faite.

Dans le cas présent, l'objet reçu doit être casté en Book. Par ailleurs, null est renvoyé par cette méthode si aucun livre n'est retrouvé (c'est le résultat de selectOne).

Le findAll

def findAll():List[Book] = {
    val session = sqlMap.openSession()
    try{
        session.selectList("be.fabrice.BookMapper.findAll").toList.asInstanceOf[List[Book]]
    }finally {
        session.close
    }
}

La seule difficulté ici est que le selectList renvoie une java.util.List. Il faut donc la transformer en liste scala.

Le save

def save(book:Book) = {
    val session = sqlMap.openSession()
    try {
        book.id match {
            case None =>  session.insert("be.fabrice.BookMapper.insertBook",book)
            case _ =>     session.update("be.fabrice.BookMapper.updateBook",book)
        }
        session.commit()
    } finally {
        session.close
    }
}

Il s'agit d'insérer les livres non encore persistés et de mettre à jour ceux qui le sont déjà. C'est l'équivalent d'un saveOrUpdate Hibernate.

Pour cela, je teste l'id du livre qui n'existe (Some) que si le livre a été persisté (id auto-généré). Le matcher m'amène soit vers un insert, soit vers un update.

L'update ne pose pas de problèmes, une fois le typehandler correctement configuré. Par contre l'insert m'a fait suer.

En cause, la récupération de l'id auto-généré. Difficile de dire avec le recul quel a été le problème avec toutes les solutions testées. En final, je pense que la bonne configuration du typehandler a éclairci pas mal de choses.

La récupération de l'id est en fait définie dans le code xml:

<insert id="insertBook" parameterType="entities.Book" keyProperty="id" useGeneratedKeys="true" >
       insert into book (title,author,isbn) values (#{title},#{author},#{isbn});
</insert>

Le delete

def delete(book:Book) = {
    val session = sqlMap.openSession()
    try {
        session.delete("be.fabrice.BookMapper.deleteBook",book)
        session.commit()
    } finally {
        session.close()
    }
}

Rien de très compliqué....

Test

Il ne s'agit pas d'un test à proprement parler, mais d'un main qui essaye les différentes options. Il est très semblable à celui de l'article sur OrBroker:

package tests

import entities._
import dao._

object Test {
    def main(args:Array[String]) = {
        println("-------- Recherche d'un livre existant --------")
        println(BookDao.find(1))

        println("-------- Recherche d'un livre inexistant --------")
        println(BookDao.find(4785))

        printAll(BookDao.findAll())

        println("-------- Insertion d'un nouveau livre --------")
        val newBook = new Book("Test","toto","0000000")
        BookDao.save(newBook)
        println("Id du nouveau livre: "+newBook.id)
        printAll(BookDao.findAll)

        println("-------- Edition du nouveau livre --------")
        newBook.title = "Un bouquin"
        BookDao.save(newBook)
        printAll(BookDao.findAll)

        println("-------- Suppression du nouveau livre --------")
        BookDao.delete(newBook)
        printAll(BookDao.findAll)
    }

    def printAll(books:Seq[Book]){
        println("-------- Liste des livres --------")
        books.foreach(println(_))
    }
}

Le résultat de son exécution (à condition que la base de données aient été initialisée comme décrit dans l'article précédent) donne:

-------- Recherche d'un livre existant --------
Dune de Frank Herbert (ISBN: 1234567891231, id: Some(1))
-------- Recherche d'un livre inexistant --------
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))
-------- Insertion d'un nouveau livre --------
Id du nouveau livre: Some(65)
-------- 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 toto (ISBN: 0000000, id: Some(65))
-------- Edition 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))
Un bouquin de toto (ISBN: 0000000, id: Some(65))
-------- 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))

Ce qui correspond à ce que l'on souhaite.

Conclusions

Bien sûr nous eûmes des orages...

Je me souviendrai longtemps de la configuration du typehandler (on apprend par l'erreur, n'est-ce pas?). C'est plus la méconnaissance de MyBatis (c'est la première fois que je m'y essaye) qui m'a posé problème.

Sans cela, avec le recul, je dirais que je n'ai pas rencontré de difficultés majeures.

Si je devais choisir entre MyBatis et OrBroker, je choisirais sans hésitation MyBatis. Le code me paraît plus clair. Les librairies sont mieux documentées. Le résultat est satisfaisant, alors que j'avais encore beaucoup de questions avec OrBroker.

Maintenant, il y a peut-être d'autres solutions à explorer...

jeudi 2 juin 2011

The case of the mysterious extractors

En Scala, les case classes sont formidables, en particulier parce qu'elles facilitent le pattern matching. Mais comment faire avec des classes qui ne sont pas "case"?

Les case classes


Prenez celle-ci par exemple:

case class Person(firstname:String,lastname:String)

On peut difficilement faire plus court et pourtant, ce simple morceau de code cache toute une série de bonus:
  • une factory (objet compagnon avec méthode apply basée sur le constructeur primaire)
    val p = Person("John","Doe")
  • des propriétés immutables (val) publiques
    println(p.firstname)
    p.firstname = "Toto" //Ne compile pas
  • une méthode toString de base
    println(p)
    qui écrit
    Person(John,Doe)
  • une méthode equals (et la méthode hashCode correspondante) basée sur l'égalité de tous les paramètres du constructeur primaire
    val p1 = Person("Toto","Tata")
    val p2 = Person("John","Doe")
    p == p1 //FAUX
    p == p2 //VRAI
  • la possibilité de faire du pattern matching: nous y reviendrons dans un instant car c'est le sujet de cet article

Toute cela ne nous empêche pas de modifier certains comportements générés par défaut:

  • on peut ajouter des propriétés
    case class Person(firstname:String, lastname:String){
            var age:Int = _
    }
  • les paramètres du constructeurs peuvent être transformés en var (donc mutables même s'il n'est jamais conseillé de modifier une propriété qui conditionne le résultat du equals et du hashCode)
    case class Person(var firstname:String...
  • les paramètres du constructeur peuvent être rendus private (et éventuellement mutables, voir ci-dessus)
    case class Person(private val firstname...
  • des méthodes peuvent être ajoutées:
    case class Person(firstname:String, lastname:String){
            def speak(text:String) = this + " dit: "+text
    }
  • les méthodes toString, equals et hashCode peuvent être personnalisées
  • d'autres constructeurs peuvent être ajoutés (mais, étant auxiliaires, ne génèrent pas de méthode factory - apply):
    def this(f:String) = this(f,"Doe")
  • d'autres méthodes "apply" peuvent être ajoutées à l'"object" Person (mais sans redéfinir celle qui a été autogénérée par le constructeur de base):
    object Person {
            def apply(f:String) = new Person(f,"Doe")
    }


Le pattern matching


Le but premier d'une case class est de permettre le pattern matching. Voici quelques exemples qui sont purement informatifs. Ils n'ont pas vraiment d'intérêt et auraient pu être écrits autrement.

/**
* Méthode qui renvoie "prenom nom"
*/
def toFullName(p:Person):String = p match {
    case Person(f,s) => f+" "+s
}

/**
* Méthode qui renvoie true si le nom de famille de la personne est "Doe"
* false dans les autres cas
*/
def inconnu(p:Person):Boolean = p match {
    case Person(_,"Doe") => true
    case _ => false
}

/**
* Méthode qui écrit dans la console "Hello prenom"
*/
def hello(p:Person):Unit = p match {
    case Person(f,_) => println("Hello "+f)
}

/**
* Méthode qui écrit dans la console "Bonjour nom"
*/
def bonjour(p:Person):Unit = p match {
    case Person(_,l) => println("Bonjour "+l)
}

Classes pas case

Si vous n'avez jamais vu ça auparavant, vous vous dites, la langue pendante: "Whouaaa, génial... Mieux que Java....". Ok, ok... Je suis d'accord. Mais ce n'était pas mon objectif.

Le pattern matching, c'est génial. Mais comment faire avec une classe qui n'est pas case? Par exemple, parce que vous utilisez une classe que vous n'avez pas écrite vous-même.

Prenons une autre classe (honnêtement... elle ressemble fort à la première):

class Personne(val firstname:String,val lastname:String)

et son objet compagnon avec une méthode factory:

object Personne{
 def apply(f:String,l:String) = new Personne(f,l)
}

La classe Personne n'est pas équivalente à la classe Person du début. Il y manque beaucoup de fonctionnalités que l'on peut bien sûr implémenter "manuellement". Je pense à equals, hashCode, toString.

La classe Personne ne permet cependant pas de faire du pattern matching. Bien sûr, on peut l'utiliser dans des match/case. Par exemple:

def isPersonne(o:Any) = o match {
    case Personne => true
    case _ => false
}

Mais ceci ne fonctionnera pas:

def toFullName(p:Personne):String = p match {
    case Personne(f,s) => f+" "+s
}

Les extractors

Ce qu'il lui faut, à cette classe, c'est un extractor dont l'objectif est de transformer un objet Personne en un tuple (firstname,lastname).

C'est assez simple. Il suffit de définir une méthode unapply dans l'objet compagnon Personne, méthode qui prend une référence de type Personne en paramètre et renvoie une Option[(String,String)]. La voici ajoutée à notre objet compagnon:

object Personne{
 def apply(f:String,l:String) = new Personne(f,l)
 def unapply(p:Personne):Option[(String,String)] = Some(p.firstname,p.lastname)
}

Et maintenant, le pattern matching fonctionne.

Toujours plus loin


Les extractors peuvent aussi être utilisés pour valider un format ou pour extraire des informations sans pour autant construire un objet.

Considérez la deuxième méthode unapply ci-dessous:

object Personne{
 def apply(f:String,l:String) = new Personne(f,l)

 def unapply(p:Personne):Option[(String,String)] = Some(p.firstname,p.lastname)

 def unapply(fullname:String):Option[(String,String)] = {
    val parts = fullname.split(" ")
    if(parts.length == 2) Some(parts(0),parts(1)) else None
 }
}

Elle permet de faire ceci:

def testName(name:String) = name match {
       case Personne(p,n) => println("Prénom: "+p+", nom: "+n)
       case _ => println("Pas complet")
}

testName("Fabrice Claes")
testName("Toto")

Ce qui affiche:

Prénom: Fabrice, nom: Claes
Pas complet

Dans ce cas-ci, on extrait de la String le nom et le prénom (du moins si on l'écrit sous la forme prénom+nom). Aucune instance de Personne n'a été créée.

Le retour d'une Option n'est pas nécessaire si on ne souhaite pas extraire des informations. Ceci aurait fonctionné (différemment):

def unapply(fullname:String):Boolean = {
    val parts = fullname.split(" ")
    parts.length == 2
}

et testName serait devenue

def testName(name:String) = name match {
       case Personne() => println("Potentiellement complet")
       case _ => println("Pas complet")
}

Avec la première version (celle qui retourne un Option), on pourrait également concevoir une factory basée sur une String complexe.

def apply(fullName:String) = unapply(fullName) match {
    case Some((f,l)) => new Personne(f,l)
    case _ => null
}

Ce permettrait d'écrire:

val p1 = Personne("Toto Lehéros")

Par contre, avec

val p2 = Personne("Raoul")

p2 serait null.

Pas mal, hein? J'aurai certainement l'occasion d'y revenir.

mercredi 1 juin 2011

Configuration d'une application Struts2, Spring et Hibernate

Depuis quelques temps, je vois des articles qui expliquent comment créer le squelette d'une application Struts2. Malheureusement, ils s'en tiennent à la partie purement Web et négligent l'intégration avec Spring,  Hibernate (en supposant qu'on en aie besoin), et avec Maven...

Le but de cet article est de décrire, étape par étape, la création d'un squelette d'application mêlant Struts2, Spring et Hibernate, développé sur Eclipse avec le plugin d'intégration Maven et immédiatement déployable sur Tomcat (parce qu'il est bien intégré à Eclipse, mais l'application peut - sous certaines conditionss - se déployer sur n'importe quel serveur).

Allez, au boulot !

Environnement

L'environnement de développement visé est composé de :
  • jdk1.6 (mais ça fonctionne avec un jdk1.5)
  • Eclipse 3.6 (mais la procédure fonctionne globalement avec les versions 3.5 et 3.4, les plugins doivent éventuellement être adaptés). J'utilise la perspective Java EE.
  • le plugin maven-integration for Eclipse 0.12 (http://m2eclipse.sonatype.org/sites/m2e/)
  • le plugin maven-integration for WTP 0.12 ( à partir de http://m2eclipse.sonatype.org/sites/m2e-extras/)
  • un Tomcat 6 et donc jee5.

Suppositions et partis pris

Je pars du principe que vous savez utiliser Eclipse et Maven, que vous savez comment développer une application Web en Java et que vous savez déployer la dite application sur un Tomcat depuis Eclipse.

Les plugins d'intégration Maven ont été correctement installés.

Enfin, ce n'est pas non plus un tutorial pour utiliser Struts2, Spring ou Hibernate...

Création du projet de base

C'est un projet Maven. Donc, menu File>New>Other, choisir Maven Project (filtrer éventuellement sur Maven). Ne surtout pas cocher "create a simple project" parce qu'on va utiliser un archetype.

Next >

Dans la fenêtre suivante, sélectionner l'archetype. Pour cela, filtrer sur "webapp" pour plus de facilité et sélectionner org.codehaus.mojo.archetypes:webapp-jee5. (Voir note ci-dessous en cas de problème.)

Next >

Remplir les groupId, artifactId et version du projet (la valeur par défaut est généralement acceptable: 0.0.1-SNAPSHOT). Dans mon cas, j'ai choisi "be.fabrice" pour le groupId et "zeDemo" pour l'artifactId.

Le package se rempli automatiquement à partir de ces deux noms. Si ce n'est pas le cas, vous pouvez le remplir manuellement, mais l'archetype n'en fait a priori aucun cas.

Note

Il est commun dans certaines entreprises d'avoir un proxy. Dans certains cas, le plugin d'intégration Maven ne fonctionne pas correctement, en particulier en ce qui concerne les archetypes. C'est le cas pour la version 0.12. J'espère que la version suivante réglera ça.

Eventuellement, il est possible d'utiliser la ligne de commande et de créer le projet avec, en ligne de commande:

mvn archetype:generate -DgroupId=be.fabrice -DartifactId=zeDemo -DarchetypeGroupId=org.codehaus.mojo.archetypes -DarchetypeArtifactId=webapp-jee5 -DarchetypeVersion=1.2

Remplir les données demandées. Pour terminer, il faudra faire un "import existing maven projects" dans Eclipse.

Néanmoins, certaines facilités offertes par le plugin Maven ne seront pas disponibles.

Configuration de base

J'ai déjà remarqué qu'en fonction de la version plugin maven et de la version de l'archetype utilisé, certains éléments pouvaient ne pas exister. Notamment certains répertoires sources.

Dans mon cas, j'ai les répertoires src/main/java et src/test/java, mais pas les "resources". Il suffit de faire un clic droit sur le répertoire src/main pour y ajouter le répertoire "resources" et la même chose pour src/test.

Ces répertoires ne sont pas encore reconnus par Eclipse comme des répertoires sources. Il suffit d'un clic doit sur le projet et, dans le menu contextuel de choisir Maven > Upade Project Configuration.



Le projet est maintenant configuré.

Ajout des dépendances Maven

J'ai un principe quand j'ajoute les dépendances: réduire leur nombre au minimum et profiter des dépendances transitives.

Par exemple, pour utiliser Struts2, dans la mesure où l'intégration avec Spring doit se faire, la dépendance struts2-spring-plugin est suffisante. Elle importe de manière transitive struts2-core et la plupart des modules de spring 2.5.6.

Pour cela, ouvrir le fichier pom.xml dans l'éditeur par défaut (celui qui vient du plugin). Choisir l'onglet "Dependencies", cliquer sur le bouton Add et dans le champ de saisie du dialogue qui s'affiche, sélectionner struts2-spring. Rapidement, le plugin filtre les dépendances disponibles. Développez l'entrée org.apache.struts:struts2-spring-plugin et sélectionnez la version 2.2.3.



Note: de nouveau, il peut y avoir des problèmes avec un proxy... Il faudra cliquer sur "create" plutôt  que "add" et remplir les informations "à la main"...

Confirmez et sauvez le projet.

Vérifiez toujours les dépendances transitives. Les versions évoluant, elles changent parfois.

Pour cela, cliquez sur l'onglet "Dependency hierarchy". Vous y verrez la liste des dépendances importées transitivement par ce premier ajout.


Il reste à présent à ajouter les dépendances Hibernate. Souvent les annotations propres à Hibernate seront nécessaires en plus de celles venant de JPA pour pouvoir faire la jonction entre les entités et un schéma DB imposé.

Pour cela, la dépendance à importer est (même opération que pour Struts2) org.hibernate:hibernate-annotations:3.5.6-Final.

De nombreuses dépendances Spring ont déjà été importées, mais pas toutes celles dont nous avons besoin.

Pour que ce projet fonctionne, il faut encore ajouter org.springframework:spring-orm:2.5.6, org.springframework:spring-aop:2.5.6 et org.springframework:spring-jdbc:2.5.6 (apparemment nécessaire pour logback).

Car slf4j étant une dépendance transitive de nos framework, il lui faut un logger. Pour ma part, j'utilise logback et j'ajoute donc ch.qos.logback:logback-classic:0.9.17.

Je laisse de côté les dépendances de test.

Configuration xml

La configuration du projet se fait dans trois fichiers xml différents:

web.xml

Deux éléments à configurer obligatoirement: Struts2 et Spring.

Spring se charge à l'aide d'un ServletContextListener implémenté par une classe de Spring: ContextLoaderListener.

<listener>
     <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>

Par défaut, cette configuration ira chercher le fichier applicationContext.xml à la racine du WEB-INF. La pratique veut toutefois que les fichiers de configuration (à l'exception du web.xml) soient placés dans le répertoire src/main/resources, ce qui aura pour effet de les placer dans le WEB-INF/classes. Il arrive aussi parfois que l'on souhaite changer le nom du fichier de configuration de spring.

Il importe donc d'ajouter les lignes suivantes:

<context-param>
     <param-name>contextConfigLocation</param-name>
     <param-value>classpath:applicationContext.xml</param-value>
</context-param>

Le <param-value> est à adapter en fonction de la configuration. Celui-ci cherchera le fichier applicationContext.xml situé à la racine du classpath (soit WEB-INF/classes).

Il faut enfin configurer l'application pour qu'elle utilise Struts2. Cela se fait en configurant un filtre:

<filter>
       <filter-name>struts2</filter-name>
    <filter-class>org.apache.struts2.dispatcher.ng.filter.StrutsPrepareAndExecuteFilter</filter-class>
</filter>

<filter-mapping>
       <filter-name>struts2</filter-name>
       <url-pattern>/*</url-pattern>
</filter-mapping>

OpenSessionInViewFilter?

Une pratique assez courante dans les applications Web "simples" avec Hibernate, est d'ajouter l'OpenSessionInViewFilter qui permet de garder "en vie" la session Hibernate dans le thread d'exécution de la requête. Cela permet notamment d'accéder aux objets lazy-loadés d'Hibernate de manière transparente partout dans l'application.

La configuration se fait à l'aide d'un filtre, placé AVANT le filtre de Struts2. Il est ici configuré pour n'intercepter que les requêtes se terminant par .demo, requêtes qui seront gérées par Struts2 (comme nous le verrons dans un instant).

<filter>
       <filter-name>hibernate</filter-name>
       <filter-class>org.springframework.orm.hibernate3.support.OpenSessionInViewFilter</filter-class>
</filter>

<filter-mapping>
       <filter-name>hibernate</filter-name>
       <url-pattern>*.demo</url-pattern>
</filter-mapping>

Fichier final

Pour récapituler voici le fichier web.xml final.
<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">
  <display-name>ZeDemo demo application</display-name>
  <context-param>
      <param-name>contextConfigLocation</param-name>
      <param-value>classpath:applicationContext.xml</param-value>
  </context-param>

  <filter>
      <filter-name>hibernate</filter-name>
      <filter-class>org.springframework.orm.hibernate3.support.OpenSessionInViewFilter</filter-class>
  </filter>

  <filter>
      <filter-name>struts2</filter-name>
      <filter-class>org.apache.struts2.dispatcher.ng.filter.StrutsPrepareAndExecuteFilter</filter-class>
  </filter>

  <filter-mapping>
      <filter-name>hibernate</filter-name>
      <url-pattern>*.demo</url-pattern>
  </filter-mapping>

  <filter-mapping>
      <filter-name>struts2</filter-name>
      <url-pattern>/*</url-pattern>
  </filter-mapping>

  <listener>
      <listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
  </listener>
</web-app>

applicationContext.xml

Voici le fichier de configuration de Spring. Il sera placé dans src/main/resources.

Il est configuré pour fonctionner de la manière suivante:
  • les beans sont configurés à l'aide d'annotations (@Component et dérivées) à l'exception de certains beans provenant de frameworks. Spring scannera les packages be.fabrice.zeDemo pour les trouver.
  • un pool de connexions doit exister dans le serveur et être exposé comme une ressource JNDI du nom de jdbc/zeDemoDatasource.
  • les transactions seront définies à l'aide de l'annotation @Transactionnal.
  • le gestionnaire de base de données que j'utilise est PostgreSQL et je souhaite mettre le schéma à jour en fonction des entités (nous sommes en phase de développement).
  • la configuration des entités Hibernate se fait par annotations et la SessionFactory scannera les packages be.fabrice.zeDemo.entities pour les trouver.

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
   xmlns:tx="http://www.springframework.org/schema/tx"
   xmlns:context="http://www.springframework.org/schema/context"
   xmlns:jee="http://www.springframework.org/schema/jee"
   xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans-2.5.xsd
        http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx-2.5.xsd
        http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context-2.5.xsd
        http://www.springframework.org/schema/jee http://www.springframework.org/schema/jee/spring-jee-2.5.xsd"
        default-autowire="autodetect">

   <context:component-scan base-package="be.fabrice.zeDemo.*" annotation-config="true" />
   
   <jee:jndi-lookup id="datasource" jndi-name="jdbc/zeDemoDatasource" />
   
   <bean id="sessionFactory" class="org.springframework.orm.hibernate3.annotation.AnnotationSessionFactoryBean">
       <property name="dataSource" ref="datasource" />
       <property name="hibernateProperties">
           <value>
               hibernate.show_sql=true
               hibernate.hbm2ddl.auto=update
               hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
           </value>
       </property>
       <property name="packagesToScan" value="be.fabrice.zeDemo.entities" />
   </bean>

   <tx:annotation-driven transaction-manager="transactionManager" />
   
   <bean id="transactionManager"
       class="org.springframework.orm.hibernate3.HibernateTransactionManager">
       <property name="sessionFactory" ref="sessionFactory" />
   </bean>
</beans>

Config sans ressource JNDI

Une petite note pour indiquer les modifications à effectuer si on ne souhaite pas utiliser une ressource JNDI pour la configuration.

Il faut modifier le pom.xml pour y ajouter, par exemple, les apache-commons DBCP et le driver du gestionnaire de la base de données.

Dans le fichier applicationContext.xml, il faut remplacer:

<jee:jndi-lookup id="datasource" jndi-name="jdbc/zeDemoDatasource" />

par

<bean id="datasource" class="org.apache.commons.dbcp.BasicDataSource">
       <property name="driverClassName" value="driverClass" />
       <property name="username" value="username" />
       <property name="password" value="password" />
       <property name="url" value="urlDb" />
</bean>

Les valeurs "driverClass", "username", "password" et "urlDB" sont à remplacer par les valeurs adéquates...

struts.xml

Le dernier fichier à configurer est le fichier struts.xml, lui aussi dans les src/main/resources. Le seul élément que nous devons configurer pour le moment, c'est que les requêtes gérées par Struts2 doivent se terminer par "demo":

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE struts PUBLIC "-//Apache Software Foundation//DTD Struts Configuration 2.0//EN" "http://struts.apache.org/dtds/struts-2.1.7.dtd">

<struts>
   <constant name="struts.action.extension" value="demo" />

   <include file="struts-default.xml" />

   <package name="default" extends="struts-default" >
   </package>
</struts>


logback.xml

Pas absolument nécessaire mais ça vaut mieux si vous utilisez logback. Toujours dans src/main/resources, le fichier logback.xml:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
 <appender name="STDOUT"  class="ch.qos.logback.core.ConsoleAppender">
    <layout class="ch.qos.logback.classic.PatternLayout">
     <Pattern>ZeDemo - %d{HH:mm:ss.SSS} %-5level %logger{55} - %msg%n</Pattern>
    </layout>
 </appender>

 <root>
    <level value="INFO" />
    <appender-ref ref="STDOUT" />
 </root>
</configuration>

Test

Testons que l'application est correctement configurée. Pour cela, nous devons configurer le serveur et créer une action de test. On pourrait aller plus loin en créant un bean Spring et vérifier qu'il est bien injecté dans l'action, mais ce n'est pas nécessaire.

Configuration du serveur

Généralement, je n'ai pas grand chose à faire de ce côté car le serveur est prêt. Néanmoins, voici les éléments indispensables.

Driver de la base de données
Lorsqu'on utilise une ressource JNDI pour définir la DataSource, il faut que le driver du gestionnaire de base de données soit dans le classpath du serveur et non de celui de l'application.

Le jar du driver doit être copié dans le TOMCAT_HOME/lib, tout simplement...

Ressource JNDI
Lorsque vous créez un runtime de serveur dans Eclipse, un dossier "Servers" est créé dans le "Project Explorer". Il y a un sous-répertoire pour le runtime du Tomcat et dans ce répertoire plusieurs fichiers de configuration, dont context.xml.

La configuration de la ressource JNDI consiste à ajouter dans ce fichier les lignes suivantes:

<Resource name="jdbc/zeDemoDatasource" auth="Container" type="javax.sql.DataSource"
              maxActive="30" maxIdle="10" maxWait="10000"
              username="username" password="password" 
driverClassName="org.postgresql.Driver"
              url="urlDb"/>

Le nom de la ressource doit être le même que celui configuré dans l'applicationContext.xml. Les propriétés "username", "password" et "urlDB" sont à modifier en fonction de vos propres valeurs.

Pour que le déploiement fonctionne, la base de données doit exister.

Action de test

Il ne s'agit pas de faire compliqué.

Pour l'action, une classe Java:

package be.fabrice.zeDemo;

public class TestAction {
   public String execute(){
       return "success";
   }
}

et une JSP test.jsp (que je mets dans le WEB-INF pour le moment):

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>OK</title>
</head>
<body>
Ca marche...
</body>
</html>

Bref, ça ne fait rien, mais permet de valider la configuration.

Pour terminer, il faut ajouter l'action dans l'élément <package> du fichier struts.xml :

<action name="test" class="be.fabrice.zeDemo.TestAction">
       <result name="success">/WEB-INF/test.jsp</result>
</action>

Les valeurs sont à modifier en fonction de vos propres packages...

Déploiement

Dans la view "Servers", clic droit sur le runtime du serveur, "Add and Remove". Sélectionner le nouveau projet (zeDemo) dans la liste de gauche et cliquer sur Add puis finish.

Il suffit de lancer le serveur. Souvent, pour éviter le timeout du serveur, je force le "publish" avant.

Si tout s'est bien passé, il suffit d'entrer l'adresse suivante dans le navigateur: http://localhost:8080/zeDemo/test.demo

Normalement, "Ca marche" devrait s'afficher...

Ouf...

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

jeudi 17 mars 2011

Traits, philosophie et stratégie en Scala

Les traits en Scala sont vraiment intéressants. Cette fois, j'ai envie de m'en servir pour ajouter des comportement à des classes.

Le comportement, c'est "Philosophe" et je veux l'ajouter à... à des grenouilles. Ce comportement se traduira par une méthode “philosophe”, sans arguments et qui renverra la String "Je pense donc je suis".

Commençons avec une Grenouille:
class Grenouille(nom:String) {
   def croasse() = "Croua, croua! dit "+nom
}

val g = new Grenouille(“Reinette”)
println(g croasse)
Croua, croua! dit Reinette

(Quand je disais que c’était bête...)

Et maintenant, voici le comportement “Philosophe”, défini dans un trait:
trait Philosophe {
   def philosophe() = "Je pense donc je suis"
}

Si je veux une Grenouille philosophe, il me suffit d’écrire:
class GrenouillePhilosophe(nom:String) extends Grenouille(nom) with Philosophe
Ce qui me donne les résultats suivants:
val g = new GrenouillePhilosophe(“Reinette”)
println(g croasse)
Croua, croua! dit Reinette
println(g philosophe)
Je pense donc je suis

L’intérêt du comportement, c’est que je peux l’ajouter à n’importe quoi.

A une vache par exemple:
class Vache(nom:String) {
   def meugle() = "Meuhh! dit "+nom
}

class VachePhilosophe(nom:String) extends Vache(nom) with Philosophe

Ou encore à un rocher:
class Rocher(nom:String) {
   def roule() = nom + " n'amasse pas mousse"
}

class RocherPhilosophe(nom:String) extends Rocher(nom) with Philosophe

Je vous passe les arbres, les astres... (mais si un jour quelqu’un doit écrire une application dans laquelle des grenouilles et des rochers deviennent philosophes, qu’il me contacte !)

L'équivalent en Java

Le code Scala se compile en bytecode exécutable par une JVM. La question est alors "comment obtenir le même effet en Java?"
Soit trois classes de base à transformer en philosophes. Les voici, toutes à la suite les unes des autres:
public class Grenouille {
   private final String nom;
   
   public Grenouille(String nom){
       this.nom = nom;
   }
   
   public String croasse(){
       return "Croua, croua! dit "+nom;
   }
}

public class Vache {
   private final String nom;
   
   public Vache(String nom){
       this.nom = nom;
   }
   
   public String meugle(){
       return "Meuhh! dit "+nom;
   }
}

public class Rocher {
   private final String nom;
   
   public Rocher(String nom){
       this.nom = nom;
   }
   
   public String roule(){
       return nom + " n'amasse pas mousse";
   }
}
A noter au passage la longueur du code Java par rapport à celui en Scala...

A présent, imaginons les différentes façons d'ajouter un comportement "philosophe" à nos classes. La bonne solution devrait sauter aux yeux de tout le monde, mais l’expérience m’a appris que ce n’était pas toujours le cas.

L’héritage

Par exemple:
public class GrenouillePhilosophe extends Grenouille {
   public GrenouillePhilosophe(String nom){
       super(nom);
   }
   
   public String philosophe(){
       return "Je pense donc je suis";
   }
}

Si je pose la question autour de moi, je suis sûr que c’est la réponse qui va revenir le plus souvent. Les développeurs orientés objet aiment l’héritage, qu’ils considèrent comme le fond même de l’orienté objet. Il faut reconnaître que cette méthode semble fonctionner.

Je peux bien sûr l’appliquer à Vache et à Rocher (mais je vous passe le code).

Néanmoins, voici quelques objections:
  1. Java ne permet pas l’héritage multiple. Dans un exemple aussi simple, ça ne pose pas de problème, mais dans une structure complexe, composée de différentes hiérarchies de classes, cette méthode ne sera pas possible.
  2. Chaque classe implémente sa méthode “philosophe”. D’où un coût de maintenance élevé s’il faut modifier le comportement, avec des risques d’erreur.
  3. Les classes “philosophes” ne sont pas de type Philosophe. Elles n’ont rien en commun. En Scala, je peux écrire le code suivant:
    val phi = Set(new VachePhilosophe("Rita"), new GrenouillePhilosophe("Reinette"), new RocherPhilosophe("Pierre"))
    phi.foreach((p) =>println(p philosophe))
    Je pense donc je suis
    Je pense donc je suis
    Je pense donc je suis
    Dans la solution proposée ci-dessus en Java, c’est impossible (à moins de passer par la réflexion).

Bref, cette solution n’est pas équivalente à Scala.

Implémentation d’une interface

Etre Philosophe, c’est implémenter l’interface suivante:
public interface Philosophe {
   String philosophe();
}
Voici son utilisation:
public class VachePhilosophe extends Vache implements Philosophe {
   public VachePhilosophe(String nom) {
       super(nom);
   }
   
   public String philosophe() {
       return "Je pense donc je suis";
   }
}

Cette solution a le mérite de résoudre deux des précédentes objections: le problème de l’héritage multiple est résolu et toutes les classes “philosophes” sont bien des Philosophe(s).

En fait, on très proche des traits Scala qui sont souvent proposés en remplacement des interfaces (lesquelles n’existent pas en Scala).

Il reste cependant la dernière objection: chaque classe devra implémenter sa méthode philosophe, avec duplication de code et problèmes de maintenance.

C’est mieux, mais pas encore ça.

La composition "simple"

A la base, la composition permet d’encapsuler un comportement dans une classe et d’utiliser cette classe pour "hériter" du comportement.

Par exemple, le comportement du philosophe est le suivant:
public class Philosophe {
   public String philosophe(){
       return "Je pense donc je suis";
   }
}
La création d’un Rocher philosophe, se ferait ainsi:
public class RocherPhilosophe extends Rocher {
   private Philosophe philosopheComportement = new Philosophe();
   
   public RocherPhilosophe(String nom) {
       super(nom);
   }
   
   public String philosophe(){
       return philosopheComportement .philosophe();
   }
}

Est-ce mieux? Pas vraiment. Certes, le comportement est encapsulé et s’il est modifié, les modifications seront répercutées automatiquement. On peut éventuellement objecter qu’une partie de code sera dupliquée (l’utilisation de la composition), mais c'est finalement peu de chose.

Par contre, notre RocherPhilosophe n’est pas un Philosophe. Petit problème simple à résoudre...

La composition améliorée



C’est beaucoup mieux à présent:
  • le comportement "philosophe" est isolé dans une classe, comme avec la composition simple ci-dessus
  • notre RocherPhilosophe est un Philosophe
  • cette relation “isA”, parce qu’elle vient de l’implémentation d’une interface, n’interfère pas avec l’héritage

Il reste un point noir: dans chaque classe “Philosophe” (comme RocherPhilosophe), il faudra implémenter la logique de composition. Je ne vois pas de solution en Java.

A ce niveau, le trait de Scala est bien sûr supérieur.

Une petite remarque en passant: il n’est pas vraiment nécessaire que PhilosopheImpl et RocherPhilosophe implémentent la même interface (Philosophe). Le but de l'exercice était de trouver l'équivalent d'une utilisation des traits Scala en Java, ce à quoi répond le schéma ci-dessus.

Néanmoins, il y a mieux à faire, car ce schéma a des airs de pattern Strategy. Alors, poussons la logique plus loin, même si cela nous éloigne momentanément de code Scala initial.

Pattern strategy

Les philosophes que sont nos vaches, grenouilles ou rochers peuvent appartenir à différentes écoles de pensée. Tous sont philosophes, mais philosophent différemment. Mieux, ce comportement (leur école) peut être modifié au runtime.

Voici l’implémentation Java (toutes les classes à la suite des unes des autres, à l'exception des classes de base déjà écrites plus haut):
public abstract class EcolePhilosophique {
   public abstract String penser();
}

public class EcoleCartesienne extends EcolePhilosophique {
   public String penser() {
       return "Je pense donc je suis";
   }
}

public class EcoleExistentialiste extends EcolePhilosophique {
   public String penser() {
       return "Le faire est révélateur de l'être";
   }
}

public interface Philosophe {
   String philosophe();
}

public class VachePhilosophe extends Vache implements Philosophe {
   private EcolePhilosophique ecolePhilosophique = new EcoleExistentialiste();

   public VachePhilosophe(String nom) {
       super(nom);
   }
   
   public String philosophe() {
       return ecolePhilosophique.penser();
   }
}

public class GrenouillePhilosophe extends Grenouille implements Philosophe {
   private EcolePhilosophique ecolePhilosophique = new EcoleCartesienne();

   public GrenouillePhilosophe(String nom) {
       super(nom);
   }
   
   public String philosophe() {
       return ecolePhilosophique.penser();
   }
}

L’implémentation est simple. Ici, la séparation entre la stratégie (l’école philosophique) et le contexte (les objets qui l’utilisent) est claire. Le comportement est défini de manière statique, mais il est possible d'apporter quelques modifications au code pour pouvoir le changer au runtime.

Le seul point noir est que la composition doit encore une fois être explicitement écrite.

Est-ce que Scala apporte une solution?

Implémentation du pattern Strategy en Scala

Le voilà donc, tel qu’il est décrit dans le schéma. Les classes Vache, Grenouille et Rocher sont celles données tout au début de cet article.

abstract class EcolePhilosophique {
   def penser() : String
}

class EcoleCartesienne extends EcolePhilosophique {
   override def penser () = "Je pense donc je suis"
}

class EcoleExistentialiste extends EcolePhilosophique {
   override def penser () = "Le faire est révélateur de l'être"
}

trait Philosophe {
   def ecolePhilosophie: EcolePhilosophique
   def philosophe() = ecolePhilosophie.penser
}

class GrenouillePhilosophe(nom:String) extends Grenouille(nom) with Philosophe{
   def ecolePhilosophie = new EcoleCartesienne
}

class VachePhilosophe(nom:String) extends Vache(nom) with Philosophe{
   def ecolePhilosophie = new EcoleExistentialiste
}

Quelques remarques:
  • la stratégie (l’école) est choisie lors de la construction de l’objet et est donc statique. Néanmoins, le code peut être facilement modifié pour que le comportement soit modifiable dynamiquement.
  • EcolePhilosophique est une classe abstraite. Elle pourrait être un trait, mais je n’en ai pas vu l’intérêt. De plus, dans le schéma UML, elle n’est pas définie comme une interface.

Grâce à l’utilisation du trait “Philosophe”, les classes “philosophes” ne doivent plus implémenter la logique de la composition (une référence vers l’école et l’invocation de la méthode “penser” dans l’implémentation de la méthode “philosophe”). A cause de cela, l’implémentation en Scala, bien que ni plus ni moins souple qu’en Java, est meilleure.

Et puis, franchement, c'est beaucoup plus court en Scala !