Présentation de MyBatis


A l’occasion de la sortie de MyBatis 3.1.0 je vous propose de découvrir cet ORM pas comme les autres. Avec un niveau d’abstraction à mi-chemin entre JDBC et JPA, le successeur d’iBatis est activement développé : plusieurs mises à jour mineures se sont succédées avant l’arrivée de la version 3.1.0.

L'ORM pas comme les autres

MyBatis est un framework de persistance de base de données relationnelles, c'est un ORM au même titre que les différentes implémentations JPA : il transforme des données issues d'une base de données relationnelles en objets Java. La différence majeure entre JPA et MyBatis, c'est que le premier opère un mapping des objets sur des tables (les entités), alors que le second ne travaille qu'avec des requêtes. JPA crée un couplage entre la base de données et le modèle objet par le biais d'annotations (ou en XML) là où MyBatis offre beaucoup de souplesse pour le mapping. Il est même possible de mapper les résultats sur des classes qu'on ne peut pas modifier (classes issues de bibliothèques tierces ou développées par une autre équipe par exemple).

Principales fonctionnalités:

  • Création de requêtes SQL dynamiques
  • Mapping avancé des résultats
  • Personnalisation du mapping
  • Gestion des transactions
  • Mise en cache des résultats
  • Pool de connexion
  • Configuration par environnement
  • Intégration à Spring et Guice

En terme d'outillage on trouve MyBatis Generator qui permet de démarrer son projet en générant des classes et des mappers de type CRUD. Un plugin Eclipse permet de faciliter l'affichage du SQL dynamique. Il existe également une version .NET de MyBatis.

Un cas d'utilisation simple

Afin d'illustrer l'utilisation de MyBatis, je vous propose de prendre un modèle de données simple en entrée, et un modèle de données différent en sortie. Le modèle en entrée est représenté par des tables dans une base de données relationnelle, et le modèle en sortie est représenté par des POJO. Entre les deux : un DAO qu'il faut implémenter pour passer de notre base de données à nos objets Java. Ajoutons comme hypothèse que les classes et l'interface ne peuvent pas être modifiées.

Base de données

public class Session {
    private Long idFormation = null;
    private String nomCours = null;
    private String nomFormateur = null;
    private Set<Participant> participants = new HashSet<>();
 
    ...
}
public class Participant {
    private Long id = null;
    private String nom = null;
 
    ...
}
public interface SessionDao {
 
    /**
     * Récupère la liste des sessions de formation auxquelles des
     * stagiaires se sont inscrits.
     */
    public List<Session> findAll();
}

Et on va se prêter à l'exercice avec du JDBC brut, JdbcTemplate, MyBatis et Hibernate. Il s'agit d'implémentations naïves, qui se contentent d'être correctes fonctionnellement. Les aspects tels que les best practices ou le tuning de performance ne seront donc pas abordés. Les différentes solutions sont implémentées sous la forme de tests junit et permet de s'assurer que le résultat obtenu est correct. Le code source complet est disponible à la fin de l'article.

Le résultat attendu est une liste de deux sessions, comptant trois participants chacune. D’un point de vue technique, le DAO renvoie une liste de sessions avec une relation 1-n vers des participants.

JDBC

Il s'agit là d'une API vieille de plus de 10 ans. JDBC est incontournable pour accéder à une base de données relationnelle, c'est d'ailleurs sur cette API que reposent les trois autres solutions de ce comparatif. L'ennui c'est que certaines fonctionnalités peuvent être supportées par certains drivers mais pas d'autres. Si on ajoute le fait qu'il s'agit d'une API très bas niveau, le JDBC brut n'est pas la solution à privilégier pour développer nos applications.

public class JdbcSessionDao implements SessionDao {
 
    private static final String FIND_ALL = 
            "select fo.id as id_formation, co.nom as nom_cours, fe.nom as nom_formateur, st.id as id_stagiaire, st.nom as nom_stagiaire " +
            "from formation fo, cours co, formateur fe, stagiaire st " +
            "where co.id = fo.id_cours and fe.id = fo.id_formateur and fo.id = st.id_formation " +
            "order by id_formation";
 
    @Override
    public List<Session> findAll() {
 
        Connection connection = null;
        Statement statement = null;
        ResultSet resultSet = null;
 
        try {
            connection = DriverManager.getConnection("jdbc:h2:mem:dataSource;DB_CLOSE_DELAY=-1", "sa", "");
            statement = connection.createStatement();
            resultSet = statement.executeQuery(FIND_ALL);
 
            Session session = null;
            List<Session> sessions = new ArrayList<>();
 
            while ( resultSet.next() ) {
                Long idFormation = resultSet.getLong("id_formation");
 
                // pour mapper la session il faut vérifier le changement d'id
                if ( isNewSession(session, idFormation) ) {
                    session = new Session();
                    session.setIdFormation(idFormation);
                    session.setNomCours( resultSet.getString("nom_cours") );
                    session.setNomFormateur( resultSet.getString("nom_formateur") );
                    sessions.add(session);
                }
 
                // dans le cas des participants pas besoin de vérifier (d'après la requête)
                Participant participant = new Participant();
                participant.setId( resultSet.getLong("id_stagiaire") );
                participant.setNom( resultSet.getString("nom_stagiaire") );
                session.getParticipants().add(participant);
            }
 
            return sessions;
        }
        catch (SQLException e) {
            throw new IllegalStateException("Echec de récupération des formations", e);
        }
        finally {
            JdbcUtils.closeResultSet(resultSet);
            JdbcUtils.closeStatement(statement);
            JdbcUtils.closeConnection(connection);
        }
    }
 
    private boolean isNewSession(Session session, Long idFormation) {
        return session == null || !session.getIdFormation().equals(idFormation);
    }
}
public class JdbcSessionDaoTest extends AbstractSessionDaoTest {
    @Test
    public void shouldFindFormationsWithPlainJdbc() throws Exception {
        checkSessions( new JdbcSessionDao().findAll() );
    }
}

La première chose qui saute aux yeux, c'est que le code fonctionnel du DAO est noyé dans le code technique. Sur les 55 lignes de code de cet extrait, seules 21 lignes ont un réel intérêt fonctionnel. La requête est dans une chaîne de caractère Java, c'est un peu gênant quand on veut la copier/coller dans un sqldeveloper par exemple. JDBC n’offre rien de ce côté là, et ce n’est pas son rôle. C’est donc au développeur de gérer une éventuelle externalisation des requêtes. Enfin, les API à manipuler ne sont pas les plus simples (ResultSet contient des dizaines de méthodes). Il n'y a donc rien de particulier à dire sur l'implémentation JDBC, si ce n'est que l'API est assez lourde à utiliser, et que l’utilisation du DriverManager n’est pas recommandée. On peut même identifier que certaines parties du code se répèteront d'un DAO à l'autre. C'est à cette problématique que répond le pattern template.

JdbcTemplate

JdbcTemplate est une classe du framework Spring qui propose de simplifier l'utilisation de JDBC en s'occupant des tâches répétitives. JdbcTemplate s'utilise en conjonction avec d'autres API (dans cet exemple l'interface RowCallbackHandler) et a vocation à s'intégrer d'une manière générale dans spring. Cet exemple a donc été implémenté dans un conteneur Spring et profite des mécanismes d'injection de dépendances.

<bean class="com.zenika.blog.mybatis.impl.jdbctemplate.JdbcTemplateSessionDao" />
public class JdbcTemplateSessionDao implements SessionDao {
 
    private static final String FIND_ALL = 
            "select fo.id as id_formation, co.nom as nom_cours, fe.nom as nom_formateur, st.id as id_stagiaire, st.nom as nom_stagiaire " +
            "from formation fo, cours co, formateur fe, stagiaire st " +
            "where co.id = fo.id_cours and fe.id = fo.id_formateur and fo.id = st.id_formation " +
            "order by id_formation";
 
    private JdbcTemplate jdbcTemplate;
 
    @Override
    public List<Session> findAll() {
        SessionRowCallbackHandler handler = new SessionRowCallbackHandler();
        jdbcTemplate.query(FIND_ALL, handler);
        return handler.getSessions();
    }
 
    @Autowired
    public void setDataSource(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }
}
class SessionRowCallbackHandler implements RowCallbackHandler {
 
    private Session session = null;
    private List<Session> sessions = new ArrayList<>();
 
    public void processRow(ResultSet resultSet) throws SQLException {
        Long idFormation = resultSet.getLong("id_formation");
 
        // pour mapper la session il faut vérifier le changement d'id
        if ( isNewSession(idFormation) ) {
            session = new Session();
            session.setIdFormation(idFormation);
            session.setNomCours( resultSet.getString("nom_cours") );
            session.setNomFormateur( resultSet.getString("nom_formateur") );
            sessions.add(session);
        }
 
        // dans le cas des participants pas besoin de vérifier (d'après la requête)
        Participant participant = new Participant();
        participant.setId( resultSet.getLong("id_stagiaire") );
        participant.setNom( resultSet.getString("nom_stagiaire") );
        session.getParticipants().add(participant);
    }
 
    private boolean isNewSession(Long idFormation) {
        return session == null || !session.getIdFormation().equals(idFormation);
    }
 
    List<Session> getSessions() {
        return sessions;
    }
}
public class JdbcTemplateSessionDaoTest extends AbstractSessionDaoTest {
    @Autowired
    SessionDao formationDao;
 
    @Test
    public void shouldFindFormationsWithJdbcTemplate() throws Exception {
        checkSessions( formationDao.findAll() );
    }
}

Premier constat, l'implémentation est découpée en deux classes, et le tout nécessite un peu de configuration. JdbcTemplate encourage une bonne séparation des responsabilités séparant le mapping du reste du traitement (que ce soit dans une classe à part ou dans une classe anonyme). En appliquant le pattern du template, les API de bas niveau ont presque disparu, seul le ResultSet subsiste, de façon quasi-anodine : on n'itère pas manuellement dessus. On ne retrouve donc dans la classe SessionRowCallbackHandler que les 21 lignes à valeur ajoutée de l’implémentation JDBC brute. La requête se trouve toujours dans une constante Java, pas d'amélioration de ce côté là. Un autre changement est visible au niveau du test, celui-ci se fait directement injecter le DAO. Le cycle de vie du DAO est donc géré par Spring tel qu'on lui a déclaré, là où l'implémentation JDBC brute devait instancier elle-même son DAO.

Comme dit précédemment, MyBatis se situe à un niveau d'abstraction entre JdbcTemplate et Hibernate. C'est donc maintenant que je vous propose de voir l'implémentation MyBatis.

MyBatis

Parmi les différents types d'ORM, on trouve les appellations micro et full. MyBatis est un micro ORM, qui se contente de faire le lien entre les mondes relationnel et objet, à la différence des implémentations JPA comme Hibernate qui sont des full ORM. En utilisant JPA, on crée tout un modèle objet à l'image des tables de la base de données (ou vice-versa). MyBatis repose sur la notion de mapper. Le mapper a pour tâche de transformer un résultat de requête en objet. C'est en substance la fonctionnalité principale du framework.

La mise en œuvre de MyBatis nécessite un peu de configuration.

<?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>
    <environments default="test">
        <environment id="test">
            <transactionManager type="JDBC" />
            <dataSource type="POOLED">
                <property name="driver" value="org.h2.Driver" />
                <property name="url" value="jdbc:h2:mem:dataSource;DB_CLOSE_DELAY=-1" />
                <property name="username" value="sa" />
            </dataSource>
        </environment>
    </environments>
 
    <mappers>
        <mapper resource="formation-mapper.xml" />
    </mappers>
</configuration>

On peut ensuite implémenter notre DAO.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 
<mapper namespace="com.zenika.blog.mybatis.SessionDao">
 
    <resultMap id="SessionResultMap" type="com.zenika.blog.mybatis.Session">
        <id     property="idFormation" column="id_formation" />
        <result property="nomCours" column="nom_cours" />
        <result property="nomFormateur" column="nom_formateur" />
        <collection property="participants" resultMap="ParticipantResultMap" />
    </resultMap>
 
    <resultMap id="ParticipantResultMap" type="com.zenika.blog.mybatis.Participant">
        <id     property="id" column="id_stagiaire" />
        <result property="nom" column="nom_stagiaire" />
    </resultMap>
 
    <select id="findAll" resultMap="SessionResultMap">
        select
            fo.id as id_formation,
            co.nom as nom_cours,
            fe.nom as nom_formateur,
            st.id as id_stagiaire,
            st.nom as nom_stagiaire
        from formation fo, cours co, formateur fe, stagiaire st
        where co.id = fo.id_cours
        and fe.id = fo.id_formateur
        and fo.id = st.id_formation
    </select>
</mapper>
public class MyBatisSessionDaoTest extends AbstractSessionDaoTest {
    @Test
    public void shouldFindFormationsWithMyBatis() throws Exception {
        InputStream stream = null;
        try {
            stream = Resources.getResourceAsStream("mybatis.xml");
            SqlSessionFactory sessionFactory = new SqlSessionFactoryBuilder().build(stream);
            SqlSession session = sessionFactory.openSession();
            checkSessions( session.getMapper(SessionDao.class).findAll() );
        }
        finally {
            IOUtils.closeSilently(stream);
        }
    }
}

Si vous êtes attentifs, vous avez remarqué que la classe implémentant SessionDao n'est pas présente. Si vous êtes très attentifs (ou que vous connaissez déjà iBatis/MyBatis) vous aurez compris que c'est MyBatis qui génère l'implémentation de l'interface. La contrepartie, c'est un fichier XML expliquant comment faire le mapping. L'XML est assez verbeux par nature, mais les mappers MyBatis restent tout à fait lisibles. La requête SQL peut être directement copiée sans avoir besoin de retirer des guillemets ou autres points-virgules! Attention par contre aux conflits entre XML et SQL sur des caractères comme < et >. On notera aussi que nous n’avons pas besoin de spécifier de clause ‘’order by’’ dans la requête, car c’est la balise <id> qui déterminera le passage à un nouvel objet. La configuration nous montre aussi qu'il existe une notion d'environnement dans MyBatis, et qu'il est capable de gérer un pool de connexion. C'est une des nombreuses fonctionnalités du framework en plus du simple mapping. Seul le test gagne en complexité, on a besoin de créer une SqlSessionFactory (une seule fois) et d'ouvrir une session afin de récupérer le Dao. Dans un conteneur comme spring, la question ne se pose pas : on peut directement se faire injecter les mappers où c'est nécessaire. Il ne reste donc plus qu'à voir l'implémentation avec Hibernate.

Hibernate

Je ne suis pas certain qu'il soit nécessaire de présenter Hibernate. C'est un full ORM qui nécessite donc de déclarer des entités correspondant aux tables de la base de données. Mon cas d'utilisation a volontairement été choisi pour afficher une des limitation des full ORM. Cela suppose donc de passer par des objets intermédiaires pour instancier les objets en sortie du DAO. Il est bien entendu possible en JPQL d'instancier directement une classe n'étant pas une entité, mais cela n'enlève pas la nécessité d'avoir des entités.

@Entity
@Table(name="formation")
public class Formation {
 
    @Id
    @Column(name="id")
    private Long id;
 
    @ManyToOne
    @JoinColumn(name="id_cours")
    private Cours cours;
 
    @ManyToOne
    @JoinColumn(name="id_formateur")
    private Formateur formateur;
 
    @Column(name="date_debut")
    private Date dateDebut;
 
    @OneToMany
    @JoinColumn(name="id_formation")
    private Set<Stagiaire> stagiaires;
 
    ...
}

Dans cet exemple nous avons besoin de créer quatre entités ne serait-ce que pour assurer le mapping. Il est faut aussi ajouter les champs que nous n'utilisons pas. On peut s'en passer pour de la lecture, mais une insertion ne fonctionnera pas si une colonne NOT NULL n'est pas mappée. Nous avons donc nos quatre entités, voyons maintenant l'implémentation.

On utilise Hibernate en tant qu'implémentation de JPA, il faut donc créer un fichier persistence.xml.

<persistence
    xmlns="http://java.sun.com/xml/ns/persistence"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="
        http://java.sun.com/xml/ns/persistence
        http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd"
    version="2.0">
    <persistence-unit name="test">
        <provider>org.hibernate.ejb.HibernatePersistence</provider>
        <class>com.zenika.blog.mybatis.impl.jpa.Cours</class>
        <class>com.zenika.blog.mybatis.impl.jpa.Formateur</class>
        <class>com.zenika.blog.mybatis.impl.jpa.Formation</class>
        <class>com.zenika.blog.mybatis.impl.jpa.Stagiaire</class>
        <properties>
            <property name="hibernate.connection.url" value="jdbc:h2:mem:dataSource;DB_CLOSE_DELAY=-1" />
            <property name="hibernate.dialect" value="org.hibernate.dialect.H2Dialect"></property>
            <property name="hibernate.connection.driver_class" value="org.h2.Driver" />
            <property name="hibernate.connection.username" value="sa" />
        </properties>
    </persistence-unit>
</persistence>

On peut ensuite implémenter le DAO.

public class HibernateSessionDao implements SessionDao {
    private EntityManager entityManager;
 
    public HibernateSessionDao(EntityManager entityManager) {
        this.entityManager = entityManager;
    }
 
    @Override
    public List<Session> findAll() {
        @SuppressWarnings("unchecked")
        List<Formation> formations = entityManager.createQuery("from Formation").getResultList();
        List<Session> sessions = new ArrayList<>();
 
        for (Formation formation : formations) {
            Session session = new Session();
            session.setIdFormation( formation.getId() );
            session.setNomCours( formation.getCours().getNom() );
            session.setNomFormateur( formation.getFormateur().getNom() );
            for (Stagiaire stagiaire : formation.getStagiaires()) {
                Participant participant = new Participant();
                participant.setId( stagiaire.getId() );
                participant.setNom( stagiaire.getNom() );
                session.getParticipants().add(participant);
            }
            sessions.add(session);
        }
 
        return sessions;
    }
}
public class HibernateSessionDaoTest extends AbstractSessionDaoTest {
    @Test
    public void shouldFindFormationsWithHibernate() throws Exception {
        EntityManager entityManager = Persistence.createEntityManagerFactory("test").createEntityManager();
        checkSessions( new HibernateSessionDao(entityManager).findAll() );
    }
}

Sans surprise, le code n'est pas compliqué. Il s'agit d'une simple transformation d'objets Formation en objets Session. Comme pour le test MyBatis, il est nécessaire de créer un EntityManager, mais JPA reposant sur des conventions, le code est beaucoup moins verbeux. Il ne serait bien entendu pas nécessaire d'écrire ce code dans un conteneur Spring, EJB ou autre... Il ne faut par contre pas perdre de vue qu'il a été nécessaire de créer quatre classes afin de pouvoir implémenter le DAO ce qui fait de cette solution la plus verbeuse. D'une manière générale, ce genre de solution pousse souvent au compromis, au détriment d'une solution plus naturelle.

Attention aussi à ne pas reproduire cet exemple dans vos projets. Afin de simplifier au maximum cet exemple, Hibernate se retrouve à faire 1+3n requêtes à cause des relations. Il faut donc préciser qu’on veut charger les relations en ‘’eager loading’’ soit par annotations, ou dans la requête JPQL. Il n’est pas non plus obligatoire de passer par les entités. On peut passer par l’opérateur ‘’new’’ en JPQL pour passer directement les valeurs par le constructeur. Par contre un des postulats de départ était que les classes ‘’Session’’ et ‘’Participant’’ ne sont pas sous notre contrôle. On ne peut donc pas ajouter de constructeur. De son côté MyBatis, peut travailler aussi bien avec des setters qu’avec des constructeurs, et on peut même combiner les deux. Hibernate propose également un système de ‘’ResultTransformer’’ qui répond au besoin de mapper les résultats en direct sans passer par les entités. Cette solution est cependant spécifique à Hibernate et ne s’applique donc pas à JPA.

Pour conclure

MyBatis est un ORM très souple. En prenant le parti de se concentrer sur le mapping de résultats, MyBatis nous laisse maîtres du modèle de données sous-jacent. Il tolère de fait un changement dans la structure de la base de données, si tant est que les nouvelles requêtes renvoient les mêmes résultats. Les DBA peuvent directement modifier les requêtes avec les développeurs et n'ont pas besoin d'apprendre un langage supplémentaire (et je n'ai jamais vu de DBA optimiser une requête JPQL/Criteria!).

MyBatis offre également une meilleure abstraction :

  • puisqu'on ne manipule pas de JDBC dans la grande majorité des cas
  • que le mapping est homogène, que nos classes soient mappées ou non sur nos tables
  • qu’il offre une maîtrise du SQL exécuté
  • et qu’il écarte les pièges courants liés à une mauvaise utilisation d’un full ORM

Zenika propose désormais une formation MyBatis si vous êtes intéressé par ce framework de persistance riche et souple. A l'issue des deux jours vous connaîtrez tout le nécessaire pour bien utiliser MyBatis. La courbe d’apprentissage de ce framework est bien plus douce que celles de JDBC ou JPA, et vous garantira d’être opérationnel très rapidement.

Les sources de l'article sont téléchargeables ici.


Commentaires

1. Le mardi 10 avril 2012, 15:56 par DuyHai

Bel article d'introduction à MyBatis.

A noter qu'il existe une configuration basée sur les annotations pour se passer du XML un peu lourdingue.

Cependant pour les cas de mapping les plus complexes, la config XML reste quand même plus puissante

2. Le mardi 17 avril 2012, 17:00 par Dridi Boukelmoune

Merci!

Effectivement la configuration des mappers peut se faire par le biais d'annotations. Personnellement je préfère l'XML même si c'est lourd, le plugin MyBatisEditor pour Eclipse me permet d'avoir une requête que je peux directement copier/coller dans un client SQL (sauf si j'ai du SQL dynamique). Le problème avec les annotations, c'est que c'est assez intrusif, et il faut entrer dans le code source pour modifier de la configuration.

Une idée de contribution pour le projet :
Un DSL (en groovy ?) alternatif pour la configuration

https://code.google.com/a/eclipsela...

Fil des commentaires de ce billet

Ajouter un commentaire

Le code HTML est affiché comme du texte et les adresses web sont automatiquement transformées.