La solution que nous avons mise en place souffre d'une carence importante : elle manque cruellement d'ambition ! Je n'ai fait qu'effleurer la question lors de notre apprentissage de JDBC, il est maintenant temps de nous y attarder et d'y apporter une solution simple et efficace.
Contexte
Dans le second chapitre de cette partie, je vous ai avertis que l'ouverture d'une connexion avec la BDD avait un coût non négligeable en termes de performances, et que dans une application très fréquentée, il était hors de question de procéder à des ouvertures/fermetures à chaque requête effectuée sans signer l'arrêt de mort de votre serveur ! À quoi faisais-je allusion ? Penchons-nous un instant sur le contexte du problème.
Une application multi-utilisateurs
Une application web est une application centralisée à laquelle tous les utilisateurs accèdent à distance depuis leurs navigateurs. Partant de cette définition simpliste, il est aisé de deviner les limites du système. Si un nombre conséquent d'utilisateurs différents réalise des actions sur le site dans un intervalle de temps restreint, voire de manière simultanée, le serveur va devoir traiter l'ensemble des requêtes reçues à une vitesse folle pour assurer un certain confort de navigation au visiteur d'une part, et pour ne pas s'écrouler sous la charge d'autre part.
Ceci étant dit, il ne faut pas s'alarmer pour autant : tous les composants d'un serveur d'applications ne sont pas critiques. Sans plus attendre, voyons celui qui pèche le premier…
Le coût d'une connexion à la BDD
Le maillon faible de la chaîne est sans surprise la liaison entre l'application (pour généraliser, disons le conteneur web) et la base de données. C'est ici que la dégradation des performances sera la plus importante, tant par sa rapidité que par sa lourdeur de conséquences.
De quel problème exactement sommes-nous en train de parler ?
L'établissement d'une connexion entre notre application et notre base de données MySQL a un coût en temps non négligeable, qui peut monter jusqu'à plusieurs centaines de millisecondes. Les principales raisons de cette latence sont la nécessité d'établir une connexion TCP/IP entre le conteneur et le SGBD d'une part, avec les contraintes naturelles que cela pose (lenteur du réseau, pare-feu, filtrages, etc.), et le fait que le SGBD va devoir préparer des informations relatives à l'utilisateur entrant à chaque nouvelle connexion.
Alors certes, à l'échelle d'une connexion cela reste tout à fait négligeable : quelques dixièmes de seconde de ralentissement sont presque anecdotiques… Oui, mais imaginez maintenant que cinquante utilisateurs effectuent simultanément des actions faisant intervenir une communication avec la base de données sur notre site : notre SGBD doit alors créer autant de nouvelles connexions, et nous passons ainsi de quelques dixièmes de secondes à plusieurs secondes de latence ! Et ça bien évidemment, c'est inacceptable. Sans parler de l'attente côté utilisateur, votre application va de son côté continuer à recevoir des requêtes et à tenter d'établir des connexions avec votre base, jusqu'au moment où la limite sera atteinte et votre SGBD cessera de répondre, causant tout bonnement le plantage lamentable de votre application.
En revanche, le reste des opérations effectuées sur une base de données est très rapide ! Typiquement, la plupart de celles basées sur une connexion déjà ouverte s'effectuent en quelques millisecondes seulement.
La structure actuelle de notre solution
Pour couronner le tout, la solution que nous avons mise en place fonctionne très bien pour effectuer des tests sur notre poste de développement, mais n'est pas du tout adaptée à une utilisation en production ! Eh oui, réfléchissez bien : alors que nous aurions pu nous contenter d'ouvrir une seule connexion et de la partager à l'ensemble des méthodes d'un DAO, nous procédons à l'ouverture/fermeture d'une connexion à chaque requête effectuée ! Pour vous rafraîchir la mémoire, voici mis à plat le squelette basique que nous avons appliqué :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | Connection connexion = null; PreparedStatement preparedStatement = null; ResultSet resultat = null; try { connexion = daoFactory.getConnection(); preparedStatement = connexion.prepareStatement( REQUETE_SQL ); ... resultat = preparedStatement.executeQuery(); ... } finally { if (resultat != null) try { resultat.close(); } catch (SQLException ignore) {} if (preparedStatement != null) try { preparedStatement.close(); } catch (SQLException ignore) {} if (connexion != null) try { connexion.close(); } catch (SQLException ignore) {} } |
Structure de base des méthodes de nos DAO
Dans ce cas, pourquoi vous avoir fait développer une structure si peu adaptée à une utilisation en conditions réelles ?
En réalité, le tableau n'est pas aussi sombre qu'il n'y paraît. Certes, l'obtention d'une nouvelle connexion à chaque requête est une catastrophe en termes de performances. Mais le bon point, c'est que notre code est correctement organisé et découpé ! Regardez de plus près, nos DAO ne font pas directement appel à la méthode JDBC DriverManager.getConnection()
pour établir une connexion. Ils le font par l'intermédiaire de la méthode getConnection()
de notre DAOFactory :
1 2 3 4 | /* Méthode chargée de fournir une connexion à la base de données */ /* package */Connection getConnection() throws SQLException { return DriverManager.getConnection( url, username, password ); } |
DAOFactory.getConnection()
Ainsi notre solution est clairement mauvaise pour le moment, mais nous sentons bien qu'en modifiant la manière dont notre DAOFactory obtient une connexion, nous pouvons améliorer grandement la situation, et ce sans rien avoir à changer dans nos DAO ! Bref, je ne vous ai pas fait coder n'importe quoi…
Principe
Réutilisation des connexions
Afin d'alléger la charge que le SGBD doit actuellement supporter à chaque requête, nous allons utiliser une technique très simple : nous allons précharger un certain nombre de connexions à la base de données, et les réutiliser. L'expression employée pour nommer cette pratique est le « connection pooling », souvent très sauvagement francisée « pool de connexions ».
Comment fonctionne ce mécanisme de réutilisation ?
Pour faire simple, le pool de connexions va pré-initialiser un nombre donné de connexions au SGBD lorsque l'application démarre. Autrement dit, il va créer plusieurs objets Connection et les garder ouverts et bien au chaud.
Ensuite, le pool de connexions va se charger de distribuer ses objets Connection aux méthodes de l'application qui en ont besoin. Concrètement, cela signifie que ces méthodes ne vont plus faire appel à DriverManager.getConnection()
, mais plutôt à quelque chose comme pool.getConnection()
.
Enfin, puisque l'objectif du système est de partager un nombre prédéfini de ressources, un appel à la méthode Connection.close()
ne devra bien entendu pas provoquer la fermeture réelle d'une connexion ! En lieu et place, c'est tout simplement un renvoi dans le pool de l'objet Connection
qui va avoir lieu. De cette manière, et seulement de cette manière, la boucle est bouclée : l'objet Connection
inutilisé retourne à la source, et est alors prêt à être à nouveau distribué.
De manière imagée, vous pouvez voir le pool comme un système de location de véhicules. Il dispose d'une flotte de véhicules dont il est le seul à pouvoir autoriser la sortie, sortie qu'il autorise à chaque demande d'un client. Cette sortie est par nature prévue pour être de courte durée : si un ou plusieurs clients ne rendent pas le véhicule rapidement, voire ne le rendent pas du tout, alors petit à petit la flotte de véhicules disponibles va se réduire comme peau de chagrin, et le pool n'aura bientôt plus aucun véhicule disponible…
Bref, la libération des ressources dont je vous ai parlé avec insistance dans les précédents chapitres prend ici tout son intérêt : elle est la clé de voûte du système. Si une ressource inutilisée ne retourne pas au pool, les performances de l'application vont inévitablement se dégrader.
Remplacement du DriverManager par une DataSource
Maintenant que nous savons comment tout cela fonctionne en théorie, intéressons-nous à la pratique. Jusqu'à présent, nous avons utilisé le DriverManager
du package java.sql
pour obtenir une connexion à notre base de données. Or nous venons de découvrir qu'afin de mettre en place un pool de connexions, il ne faut plus passer par cet objet…
Quel est le problème avec le DriverManager
?
Sur une application Java classique, avec un utilisateur unique et donc une seule connexion vers une base de données, il convient très bien. Mais une application Java EE est multi-threads et multi-utilisateurs, et sous ces contraintes cet objet ne convient plus. Dans une application Java EE en conditions réelles, plusieurs connexions parallèles sont ouvertes avec la base de données et pour les raisons évoquées un peu plus tôt, il n'est pas envisageable d'ouvrir des connexions à la volée pour chaque objet ou méthode agissant sur la base.
Nous avons besoin d'une gestion des ressources plus efficace, et notre choix va se porter sur l'objet DataSource
du nouveau package javax.sql
. Il s'agit en réalité d'une interface qu'Oracle recommande d'utiliser en lieu de place du DriverManager
, et ce peu importe le cas d'utilisation.
Dans ce cas, pourquoi ne pas avoir directement appris à manipuler une DataSource ?
Tout simplement parce qu'encore aujourd'hui, l'objet DriverManager
est très répandu dans beaucoup de projets de faible ou moyenne envergure, et il est préférable que vous sachiez comment il fonctionne. En outre, vous allez bientôt découvrir que la manipulation d'une DataSource
n'est pas si différente !
Choix d'une implémentation
Une DataSource
n'est qu'une interface, il est donc nécessaire d'en écrire une implémentation. Rassurez-vous, nous n'allons pas nous occuper de cette tâche : il existe plusieurs bibliothèques, libres et gratuites, qui ont été créées par des équipes de développeurs expérimentés et validées par des années d'utilisation. Sans être exhaustif, voici une liste des solutions les plus couramment rencontrées :
Le seul petit souci, c'est que toutes ces bibliothèques ne disposent pas d'une communauté active, et toutes n'offrent pas les mêmes performances. Je vous épargne la recherche d'informations sur chacune des solutions et leurs benchmarks respectifs, et vous annonce que nous allons utiliser BoneCP !
Ce cours a été rédigé en 2012, et BoneCP était la solution par excellence à l'époque. Depuis, la solution HikariCP est apparue et a pris sa place sur le podium. Si vous êtes intéressés par cette solution, faites-le moi savoir dans les commentaires du cours ou par MP, et j'envisagerai alors peut-être de rédiger un chapitre sur HikariCP, voire de remplacer BoneCP par HikariCP dans le cours.
Quoi qu'il en soit, peu importe la solution que vous choisissez dans vos projets, le principe de base ne change pas et le processus de configuration que nous allons apprendre dans le paragraphe suivant reste sensiblement le même.
Mise en place
Ajout des jar au projet
Comme pour toute bibliothèque, il va nous falloir placer l'archive jar de la solution dans notre environnement. De la même manière que nous l'avions fait pour le driver JDBC, nous allons déposer le jar de BoneCP, téléchargeable en cliquant ici, dans le répertoire /lib de Tomcat.
En outre, la page listant les conditions requises à un bon fonctionnement de la solution nous signale qu'il faut également inclure les bibliothèques SLF4J et Google Guava à notre projet, car la solution BoneCP en dépend. Vous devez donc télécharger les deux archives en cliquant sur ce lien pour SLF4J et sur celui-ci pour Guava, et les placer sous le répertoire /WEB-INF/lib aux côtés de l'archive de BoneCP.
Prise en main de la bibliothèque
Nous devons ensuite étudier la documentation de BoneCP pour comprendre comment tout cela fonctionne. Il existe plusieurs méthodes de configuration, chacune propre à un usage particulier. Ainsi, il est possible de paramétrer l'outil à la main, via un DataSource, pour une utilisation avec le framework Spring ou encore pour une utilisation avec le duo de frameworks Spring et Hibernate.
Quoi qu'il en soit, peu importe le contexte, les propriétés accessibles sont sensiblement les mêmes et nous allons toujours retrouver :
- jdbcUrl, contenant l'URL de connexion JDBC ;
- username, contenant le nom d'utilisateur du compte à utiliser sur la BDD ;
- password, contenant le mot de passe du compte à utiliser sur la BDD ;
- partitionCount, contenant un entier de 1 à N symbolisant le nombre de partitions du pool. C'est un paramètre spécifique à BoneCP, qui n'existe pas dans des solutions comme c3p0 ou DBCP ;
- maxConnectionsPerPartition, contenant le nombre maximum de connexions pouvant être créées par partition. Concrètement, cela signifie que si cette valeur vaut 5 et qu'il y a 3 partitions, alors il y aura en tout 15 connexions uniques disponibles et partagées via le pool. À noter que BoneCP ne va pas initialiser l'ensemble de ces connexions d'une traite, mais va commencer par en créer autant que spécifié dans la propriété minConnectionsPerPartition, puis il va graduellement augmenter le nombre de connexions disponibles au fur et à mesure que la charge va monter ;
- minConnectionsPerPartition, contenant le nombre minimum de connexions par partition.
En ce qui nous concerne, nous allons dans le cadre de ce cours mettre en place un pool de connexions à la main. Toujours en suivant la documentation de BoneCP, nous apprenons qu'il faut passer par l'objet BoneCPConfig
pour initialiser les différentes propriétés que nous venons de découvrir. Il suffit ensuite de créer un pool via l'objet BoneCP
. Voici le code d'exemple :
1 2 3 4 5 6 7 8 9 10 11 12 13 | Class.forName( "com.mysql.jdbc.Driver" ); // chargement du driver JDBC (ici MySQL) BoneCPConfig config = new BoneCPConfig(); // création d'un objet BoneCPConfig config.setJdbcUrl( url ); // définition de l'URL JDBC config.setUsername( nomUtilisateur ); // définition du nom d'utilisateur config.setPassword( motDePasse ); // définition du mot de passe config.setMinConnectionsPerPartition( 5 ); // définition du nombre min de connexions par partition config.setMaxConnectionsPerPartition( 10 ); // définition du nombre max de connexions par partition config.setPartitionCount( 2 ); // définition du nombre de partitions BoneCP connectionPool = new BoneCP( config ); // création du pool à partir de l'objet BoneCPConfig ... |
Exemple de mise en place d'un pool de connexions
Voilà tout ce que nous avons besoin de savoir. Pour le reste, la documentation est plutôt exhaustive.
Modification de la DAOFactory
Maintenant que nous savons comment procéder, nous devons modifier le code de notre DAOFactory pour qu'elle travaille non plus en se basant sur l'objet DriverManager
, mais sur un pool de connexions. Je vous donne le code pour commencer, et vous explique le tout ensuite :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 | package com.sdzee.dao; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.sql.Connection; import java.sql.SQLException; import java.util.Properties; import com.jolbox.bonecp.BoneCP; import com.jolbox.bonecp.BoneCPConfig; public class DAOFactory { private static final String FICHIER_PROPERTIES = "/com/sdzee/dao/dao.properties"; private static final String PROPERTY_URL = "url"; private static final String PROPERTY_DRIVER = "driver"; private static final String PROPERTY_NOM_UTILISATEUR = "nomutilisateur"; private static final String PROPERTY_MOT_DE_PASSE = "motdepasse"; /* package */BoneCP connectionPool = null; /* package */DAOFactory( BoneCP connectionPool ) { this.connectionPool = connectionPool; } /* * Méthode chargée de récupérer les informations de connexion à la base de * données, charger le driver JDBC et retourner une instance de la Factory */ public static DAOFactory getInstance() throws DAOConfigurationException { Properties properties = new Properties(); String url; String driver; String nomUtilisateur; String motDePasse; BoneCP connectionPool = null; ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); InputStream fichierProperties = classLoader.getResourceAsStream( FICHIER_PROPERTIES ); if ( fichierProperties == null ) { throw new DAOConfigurationException( "Le fichier properties " + FICHIER_PROPERTIES + " est introuvable." ); } try { properties.load( fichierProperties ); url = properties.getProperty( PROPERTY_URL ); driver = properties.getProperty( PROPERTY_DRIVER ); nomUtilisateur = properties.getProperty( PROPERTY_NOM_UTILISATEUR ); motDePasse = properties.getProperty( PROPERTY_MOT_DE_PASSE ); } catch ( FileNotFoundException e ) { throw new DAOConfigurationException( "Le fichier properties " + FICHIER_PROPERTIES + " est introuvable.", e ); } catch ( IOException e ) { throw new DAOConfigurationException( "Impossible de charger le fichier properties " + FICHIER_PROPERTIES, e ); } try { Class.forName( driver ); } catch ( ClassNotFoundException e ) { throw new DAOConfigurationException( "Le driver est introuvable dans le classpath.", e ); } try { /* * Création d'une configuration de pool de connexions via l'objet * BoneCPConfig et les différents setters associés. */ BoneCPConfig config = new BoneCPConfig(); /* Mise en place de l'URL, du nom et du mot de passe */ config.setJdbcUrl( url ); config.setUsername( nomUtilisateur ); config.setPassword( motDePasse ); /* Paramétrage de la taille du pool */ config.setMinConnectionsPerPartition( 5 ); config.setMaxConnectionsPerPartition( 10 ); config.setPartitionCount( 2 ); /* Création du pool à partir de la configuration, via l'objet BoneCP */ connectionPool = new BoneCP( config ); } catch ( SQLException e ) { e.printStackTrace(); throw new DAOConfigurationException( "Erreur de configuration du pool de connexions.", e ); } /* * Enregistrement du pool créé dans une variable d'instance via un appel * au constructeur de DAOFactory */ DAOFactory instance = new DAOFactory( connectionPool ); return instance; } /* Méthode chargée de fournir une connexion à la base de données */ /* package */Connection getConnection() throws SQLException { return connectionPool.getConnection(); } /* * Méthodes de récupération de l'implémentation des différents DAO (un seul * pour le moment) */ public UtilisateurDao getUtilisateurDao() { return new UtilisateurDaoImpl( this ); } } |
com.sdzee.dao.DAOFactory
Comme vous pouvez le constater, le principe est très légèrement différent. Auparavant lors d'un appel à getInstance()
, nous enregistrions les informations de connexion à la base de données dans des variables d'instance et nous les réutilisions à chaque appel à getConnection()
. Dorénavant, lors d'un appel à getInstance()
nous procédons directement à l'initialisation du pool de connexions, et nous enregistrons uniquement le pool ainsi obtenu dans une variable d'instance, qui est alors réutilisée à chaque appel à getConnection()
.
Ainsi, seuls quelques éléments changent dans le code :
- le constructeur se base maintenant sur l'objet
BoneCP
, ici aux lignes 23 à 25 ; - la lecture des informations depuis le fichier Properties ne change pas ;
- le chargement du driver ne change pas ;
- avant d'appeler le constructeur, nous procédons aux lignes 64 à 83 à la création du pool de connexions ;
- enfin, la méthode
getConnection()
se base maintenant sur le pool, et non plus sur le DriverManager !
Voilà tout ce qu'il est nécessaire de modifier pour mettre en place un pool de connexions dans notre application ! Simple et rapide, n'est-ce pas ?
Vous voilà devant un autre exemple de l'intérêt de bien organiser et découper le code d'une application. Dans cet exemple, la transparence est totale lors de l'utilisation des connexions, il nous suffit uniquement de modifier la méthode getConnection()
de la classe DAOFactory ! Si nous n'avions pas mis en place cette Factory et avions procédé directement à l'ouverture d'une connexion depuis nos servlets ou objets métier, nous aurions dû reprendre l'intégralité du code concerné pour mettre en place notre pool…
À noter également l'importance dans le code existant d'avoir correctement fermé/libéré les ressources utilisées (et notamment les objets Connection
) lors des requêtes dans nos DAO, car si les connexions utilisées n'étaient pas systématiquement renvoyées au pool, alors nous nous heurterions très rapidement à un problème d'épuisement de stock, menant inéluctablement au plantage du serveur !
Vérifications
Une fois toutes les modifications effectuées, il ne vous reste plus qu'à vérifier que votre code compile correctement et à redémarrer Tomcat ! Effectuez alors quelques tests de routine sur la page http://localhost:8080/pro/inscription pour vous assurer du bon fonctionnement de votre application :
- essayez de vous inscrire en oubliant de confirmer votre mot de passe ;
- essayez de vous inscrire avec une adresse mail déjà utilisée ;
- enfin, inscrivez-vous avec des informations valides.
Configuration fine du pool
Ce dernier paragraphe va peut-être vous décevoir, mais il n'existe pas de règles empiriques à appliquer pour « bien » configurer un pool de connexions. C'est un processus qui est fortement lié à la fois au nombre moyen d'utilisateurs simultanés sur le site, à la complexité des requêtes effectuées sur la base, aux capacités matérielles du serveur, au SGBD utilisé, à la qualité de service souhaitée, etc.
En somme, la configuration fine d'un pool s'effectue au cas par cas. Cela passe par une série de tests grandeur nature, durant lesquels un certain nombre de connexions et utilisations simultanées sont simulées en accord avec les spécifications du projet, afin de vérifier comment réagit la base de données, comment réagit le pool et comment sont impactées les performances globales de l'application en période de charge faible, moyenne et forte, le tout pour une configuration donnée. Si les tests sont convaincants, alors la configuration est validée. Sinon, elle est modifiée - changement du nombre de connexions minimum et maximum, et du nombre de partitions - et une autre série de tests est lancée.
Bref, aller plus loin ici n'a aucun intérêt pédagogique pour nous : nous sommes ici pour apprendre à développer une application, pas pour devenir des experts en déploiement !
En résumé
- La connexion à une base de données est une étape coûteuse en termes de temps et de performances.
- Il est nécessaire d'initialiser un nombre prédéfini de connexions, et de les partager/distribuer/réutiliser pour chaque requête entrante : c'est le principe du pool de connexions.
- Lorsqu'un pool de connexions est en place, un appel à la méthode
connexion.close()
ne ferme pas littéralement une connexion, mais la renvoie simplement au pool. - La méthode
getConnection()
étant centralisée et définie dans notre Factory, il nous est très aisé de modifier son comportement. - Un pool de connexions se base sur le principe d'une
DataSource
, objet qu'il est vivement recommandé d'utiliser en lieu et place duDriverManager
. - BoneCP est une solution de pooling très efficace, aisément configurable et intégrable à n'importe quelle application Java EE.