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