10. L'accès aux bases de données avec JDBC
10.1. Le pilote JDBC
Reprenons l'architecture en couches utilisée à diverses reprises depuis le chapitre 6. Jusqu'à présent, la couche [dao] de l'application [Impots] a exploité trois types de sources de données :
- des données placées « en dur » dans le code ([HardwiredImpot]) ;
- des données provenant d'un fichier texte ([FileImpot]) ;
- des données provenant d'un fichier JSON ([JsonImpot]).
Nous étudions dans ce chapitre le cas où les données proviennent d'une base de données. L'architecture devient la suivante :

Dans le schéma ci-dessus, la couche [dao] [1] dialogue avec le SGBD (Système de Gestion de Bases de Données) [3], qui gère la base de données [4], au travers d'une bibliothèque de classes propre au SGBD utilisé [2]. Cette bibliothèque implémente une API standard de la plateforme Java, JDBC (« Java DataBase Connectivity »), présente dans le JDK depuis sa version 1.1, en 1997. On l'appelle un pilote JDBC (« JDBC driver »). Tous les SGBD courants disposent d'un pilote JDBC, distribué sous la forme d'une archive [.jar].
Kotlin n'a pas sa propre API d'accès aux bases de données : un programme Kotlin exécuté sur la JVM utilise JDBC directement, comme il utilise les fichiers NIO ou les threads du JDK. C'est l'un des grands atouts du langage : tous les pilotes JDBC écrits pour Java, et toutes les bibliothèques construites au-dessus d'eux, sont utilisables sans adaptation. Ce que Kotlin apporte, nous le verrons tout au long du chapitre, c'est une écriture plus concise et plus sûre du code JDBC : fonction [use] pour fermer les ressources, fonctions d'extension pour lire les résultats, classes de données pour représenter les lignes, absence d'exceptions contrôlées.
JDBC est d'abord un ensemble d'interfaces, définies dans les paquetages [java.sql] et [javax.sql] du module [java.sql] du JDK : [Connection], [Statement], [PreparedStatement], [ResultSet], [DataSource]… Un pilote JDBC fournit des classes qui implémentent ces interfaces. Le code de l'application, lui, ne manipule que les interfaces : il ne contient pas, en général, le nom des classes du pilote. C'est une différence avec d'autres plateformes, où le code utilise souvent directement les classes propres à chaque SGBD ([MySqlConnection], [SqlConnection]… en C#) : sur la JVM, l'abstraction est la règle depuis l'origine, et changer de SGBD revient d'abord à changer de pilote et d'adresse de la base, comme nous le verrons. Il reste que chaque SGBD a son dialecte SQL : cette indépendance a des limites, que nous verrons aussi.
Nous utilisons un seul SGBD, MySQL 8, que l'annexe « Le SGBD MySQL avec Laragon » installe sous Windows, et son pilote JDBC officiel, MySQL Connector/J, dans sa version 9.7.0. Les bases sont créées par des scripts SQL exécutés avec le client en ligne de commande [mysql] ; l'annexe montre aussi comment les créer avec le client graphique HeidiSQL livré avec Laragon.
10.2. Les deux modes d'exploitation d'une source de données
Une base de données peut être exploitée de deux manières différentes :
- le mode connecté ;
- le mode déconnecté.
En mode connecté, l'application :
- ouvre une connexion avec la source de données ;
- travaille avec la source de données en lecture / écriture ;
- ferme la connexion.
En mode déconnecté, l'application :
- ouvre une connexion avec la source de données ;
- obtient une copie en mémoire de tout ou partie des données de la source ;
- ferme la connexion ;
- travaille avec la copie en mémoire des données, en lecture / écriture ;
- lorsque le travail est fini, ouvre une connexion, envoie les données modifiées à la source de données pour qu'elle les prenne en compte, et ferme la connexion.
Le mode connecté est celui de l'interface [ResultSet] de JDBC : les lignes du résultat d'une requête sont lues pendant que la connexion est ouverte. Le mode déconnecté est celui de l'interface [CachedRowSet] du paquetage [javax.sql.rowset] (Java 5) : une table en mémoire qui se remplit à partir d'une requête, se modifie hors connexion, puis renvoie ses modifications à la base. Ces « RowSet » sont toujours présents dans le JDK 25 mais ne sont plus guère utilisés dans les applications nouvelles : le travail sur une copie en mémoire des données, avec enregistrement ultérieur des modifications, est aujourd'hui confié à un ORM (« Object-Relational Mapper »), qui manipule des objets ordinaires plutôt que des lignes de tables. L'ORM le plus utilisé sur la JVM, Hibernate, est l'objet du chapitre 11 ; il s'utilise en Kotlin comme en Java. Nous étudions donc surtout le mode connecté, sur lequel repose d'ailleurs Hibernate, et nous ne montrerons le mode déconnecté que par un exemple, en fin de chapitre.
10.3. Les concepts de base de l'exploitation d'une base de données
10.3.1. La base de données exemple
Nous allons exposer les principaux concepts de l'utilisation d'une base de données avec une base MySQL nommée [dbarticles]. Elle contient une seule table, [articles], dont chaque ligne décrit un article d'un magasin :
- [ID] : un identifiant unique de l'article, clé primaire de la table ;
- [NOM] : le nom de l'article, unique lui aussi ;
- [PRIX] : son prix ;
- [STOCKACTUEL] : son stock actuel ;
- [STOCKMINIMUM] : le stock minimum en deçà duquel il faut réapprovisionner l'article.
La base est créée par le script SQL [sql/dbarticles.sql] suivant :
- ligne 5 : le script est un fichier texte codé en UTF-8. [SET NAMES utf8mb4] l'indique au serveur, sans quoi les caractères accentués des lignes 31-36 pourraient être mal interprétés par le client [mysql] ;
- lignes 8-9 : la base est supprimée si elle existe, puis recréée. Le script peut ainsi être exécuté plusieurs fois pour remettre la base dans son état initial. [utf8mb4] est le codage Unicode complet de MySQL (le codage historique [utf8] de MySQL ne code que les caractères sur trois octets au plus) ;
- ligne 12 : [dbarticles] devient la base courante : les ordres SQL qui suivent s'exécuteront sur elle ;
- lignes 15-27 : la définition de la table [articles] :
- ligne 16 : la colonne [ID] est la clé primaire de la table ([PRIMARY KEY]). L'attribut [AUTO_INCREMENT], propre à MySQL, indique que sa valeur est générée par le SGBD lui-même lors de l'insertion d'une ligne : 1, 2, 3… Les autres SGBD ont des notions équivalentes, avec une autre syntaxe ([IDENTITY] pour SQL Server, [GENERATED … AS IDENTITY] pour PostgreSQL et Oracle) ;
- ligne 18 : le prix est un nombre décimal exact de 10 chiffres dont 2 après la virgule. On évite pour des montants les types à virgule flottante comme [DOUBLE] (cf. la différence entre [Double] et [BigDecimal] au chapitre 3). Le type SQL [DECIMAL] correspond à la classe [java.math.BigDecimal], que Kotlin utilise telle quelle ;
- lignes 22-26 : des contraintes d'intégrité. [UNQ_NOM] interdit que deux articles aient le même nom ; les contraintes [CHECK] interdisent un nom vide, un prix négatif ou nul, un stock négatif. MySQL ignorait silencieusement les contraintes [CHECK] jusqu'à sa version 8.0.16 ; il les applique depuis ;
- lignes 30-36 : l'insertion de six lignes. La colonne [ID] n'est pas initialisée : c'est le SGBD qui lui donne une valeur.
On exécute le script avec le client en ligne de commande [mysql], dans le dossier [sql] (l'utilisateur [root] de MySQL n'a pas de mot de passe dans l'installation de l'annexe ; sinon on ajoute l'option [-p] et le client le demande) :
La commande n'affiche rien si tout se passe bien. On peut vérifier la structure et le contenu de la table avec le même client :
- ligne 1 : l'option [-t] demande un affichage en tableau, l'option [-e] donne les ordres SQL à exécuter, et l'option [--default-character-set=utf8mb4] fait afficher correctement les caractères accentués dans un terminal UTF-8 ;
- lignes 3-9 : la structure de la table, obtenue par l'ordre MySQL [describe] ;
- lignes 12-19 : le contenu de la table. Les valeurs de la colonne [ID] ont été générées par le SGBD.
Une remarque sur la casse des noms : les noms de colonnes ne distinguent jamais majuscules et minuscules ([nom] et [NOM] désignent la même colonne), mais les noms de tables sont des noms de fichiers du serveur. Ils ne distinguent pas majuscules et minuscules sous Windows et les distinguent sous Linux. Pour que les exemples fonctionnent partout, la table s'appelle [articles], en minuscules, et les requêtes l'écrivent ainsi.
Les programmes de ce chapitre modifient la table [articles]. Les résultats reproduits ont été obtenus en exécutant de nouveau le script [dbarticles.sql] avant chaque programme : le lecteur qui veut retrouver exactement les mêmes résultats fera de même.
Maintenant que nous avons une base de données, nous allons pouvoir l'exploiter. Auparavant, faisons quelques rappels de SQL.
10.3.2. Les quatre commandes de base du langage SQL
SQL (« Structured Query Language ») est un langage, partiellement normalisé, d'interrogation et de mise à jour des bases de données. Tous les SGBD respectent la partie normalisée de SQL mais ajoutent au langage des extensions propriétaires qui exploitent leurs particularités. Nous en avons déjà rencontré deux exemples : la génération automatique des clés primaires et les types autorisés pour les colonnes d'une table dépendent souvent du SGBD.
Les quatre commandes de base du langage SQL que nous présentons sont normalisées et acceptées par tous les SGBD :
Commande | Rôle |
[select col1, col2, … from table1, table2, … where condition order by expression] | La requête qui permet d'obtenir les données contenues dans une base. Seuls les mots clés [select] et [from] sont obligatoires. Une jointure est faite avec toutes les tables qui suivent le mot clé [from] ; seules les colonnes qui suivent le mot clé [select] sont conservées ; seules les lignes vérifiant la condition du mot clé [where] sont conservées ; les lignes résultantes, ordonnées selon l'expression du mot clé [order by], forment le résultat de la requête. Ce résultat est une table. |
[insert into table(col1, col2, …) values (val1, val2, …)] | Insère une ligne dans [table]. [(col1, col2, …)] précise les colonnes de la ligne à initialiser avec les valeurs [(val1, val2, …)]. |
[update table set col1=val1, col2=val2, … where condition] | Met à jour les lignes de [table] vérifiant [condition] (toutes les lignes s'il n'y a pas de [where]). Pour ces lignes, la colonne [coli] reçoit la valeur [vali]. |
[delete from table where condition] | Supprime toutes les lignes de [table] vérifiant [condition] (toutes les lignes s'il n'y a pas de [where]). |
Nous allons écrire une application console permettant d'émettre des ordres SQL sur la base [dbarticles]. Voici un exemple d'exécution (les ordres SQL tapés au clavier figurent après l'invite « Requête SQL » ; pour ce document, ils ont été fournis au programme par une redirection de l'entrée standard, puis replacés après l'invite à laquelle ils répondent) ; le lecteur est invité à comprendre les ordres SQL émis et leurs résultats :
- ligne 2 : l'URL JDBC de la base et l'utilisateur MySQL : ce sont les paramètres qui permettent de se connecter à la base de données ;
- ligne 4 : on demande le contenu de la table [articles] ;
- ligne 17 : on insère une nouvelle ligne. On notera que la colonne [id] n'est pas initialisée dans cette opération car c'est le SGBD qui va générer sa valeur ;
- ligne 20 : vérification. Ligne 32, la ligne a bien été ajoutée, avec l'[id] 7. Avec [select *], les noms des colonnes sont ceux de la table, en majuscules ;
- ligne 34 : on augmente de 10 % le prix de l'article qui vient d'être ajouté ;
- ligne 43 : l'augmentation du prix a bien eu lieu ;
- ligne 45 : on supprime l'article ajouté précédemment ;
- ligne 48 : on vérifie qu'il ne reste plus que six articles ;
- ligne 56 : le programme n'accepte que les quatre ordres [select], [insert], [update] et [delete] ;
- ligne 60 : une erreur signalée par le SGBD : la table [article] n'existe pas ;
- ligne 63 : la contrainte d'unicité [UNQ_NOM] interdit d'ajouter un second article [vélo]. Les messages d'erreur de MySQL, comme ceux du pilote, sont en anglais.
10.3.3. Les interfaces de base de JDBC pour le mode connecté
Revenons au schéma d'une application exploitant une base de données au travers d'un pilote JDBC :

En mode connecté, l'application :
- ouvre une connexion avec la source de données ;
- travaille avec la source de données en lecture / écriture ;
- ferme la connexion.
Trois types d'objets sont principalement concernés par ces opérations :
- la connexion ([Connection]), qui encapsule les propriétés et méthodes de la liaison avec la base ;
- l'ordre SQL ([Statement], ou [PreparedStatement] pour un ordre paramétré), qui encapsule les propriétés et méthodes de l'ordre SQL exécuté ;
- le résultat ([ResultSet]), qui encapsule les propriétés et méthodes du résultat d'un ordre SQL [select].
Ce sont des interfaces du paquetage [java.sql]. Le pilote MySQL les implémente par des classes de ses propres paquetages ([com.mysql.cj.jdbc.ConnectionImpl], [ClientPreparedStatement], [ResultSetImpl]…), que l'application n'a pas à connaître : elle obtient ses objets par des méthodes qui rendent des interfaces. La connexion est obtenue de la classe [DriverManager] (ou d'une source de données [DataSource], que nous verrons plus loin), l'ordre SQL est créé par la connexion, le résultat est rendu par l'ordre SQL.
Ces interfaces sont des interfaces Java. Kotlin les utilise avec ses règles habituelles d'accès au code Java : un couple d'accesseurs [getXxx] / [setXxx] s'utilise comme une propriété [xxx] ([resultat.metaData] plutôt que [resultat.getMetaData()], [connexion.autoCommit = false] plutôt que [connexion.setAutoCommit(false)]), et les valeurs rendues par une méthode Java ont un type de plate-forme (noté [String!] dans les messages du compilateur) : Kotlin ne sait pas si elles peuvent être [null] et laisse le développeur choisir de les traiter comme [String] ou comme [String?] (chapitre 3). Nous verrons que ce point compte pour les colonnes qui peuvent contenir la valeur SQL [NULL].
10.3.3.1. L'interface Connection
Elle sert à gérer la connexion avec la base de données. Une connexion est obtenue par la méthode statique [DriverManager.getConnection(url, utilisateur, motDePasse)], qui reçoit l'URL JDBC de la base, par exemple [jdbc:mysql://localhost:3306/dbarticles], et les identifiants de l'utilisateur. La connexion rendue est déjà ouverte. Les méthodes de l'interface [Connection] que nous utiliserons sont les suivantes :
Méthode | Rôle |
[createStatement] | crée un ordre SQL [Statement] associé à cette connexion. |
[prepareStatement] | [prepareStatement(sql)] crée un ordre SQL paramétré [PreparedStatement] associé à cette connexion. |
[prepareCall] | [prepareCall("{call nom(?, ?)}")] crée un ordre [CallableStatement] qui exécute une procédure stockée dans la base. |
[autoCommit] | [autoCommit = false] (la méthode Java [setAutoCommit]) démarre une transaction (voir plus loin). |
[commit], [rollback] | valide, annule la transaction en cours. |
[metaData] | (la méthode Java [getMetaData]) rend un objet [DatabaseMetaData] qui décrit le SGBD et le pilote. |
[isValid] | [isValid(secondes)] vérifie que la connexion est toujours utilisable. |
[close] | ferme la connexion, ainsi que les ordres SQL et les résultats qu'elle a créés. |
L'ouverture d'une connexion a la forme suivante (les schémas de code de ce paragraphe sont extraits du fichier [modeles/Modeles.kt] du projet, qui compile mais n'est pas exécuté) :
L'inférence de type donne à [connexion] le type [Connection!] : une connexion, qu'on peut utiliser sans test de nullité ([getConnection] ne rend jamais [null] : en cas d'échec, elle lance une exception).
10.3.3.2. L'interface Statement
Elle sert à exécuter un ordre SQL. Les méthodes de cette interface que nous utiliserons sont les suivantes :
Méthode | Rôle |
[executeQuery] | [executeQuery(sql)] exécute un ordre SQL [select]. On obtient un objet [ResultSet] représentant le résultat du [select]. |
[executeUpdate] | [executeUpdate(sql)] exécute un ordre SQL [update], [insert], [delete] (ou un ordre de définition comme [create table]). On obtient le nombre de lignes affectées par l'opération (mises à jour, insérées, supprimées). [executeLargeUpdate] (Java 8) rend ce nombre sous la forme d'un [Long]. |
[execute] | [execute(sql)] exécute un ordre SQL quelconque. Rend [true] si l'ordre a produit un [ResultSet], obtenu ensuite par la propriété [resultSet], [false] s'il a produit un nombre de lignes, obtenu par la propriété [updateCount]. |
[addBatch], [executeBatch] | ajoutent un ordre à un lot, exécutent le lot (voir plus loin). |
[generatedKeys] | (la méthode Java [getGeneratedKeys]) rend les clés primaires générées par le SGBD lors d'un [insert]. |
[queryTimeout] | [queryTimeout = secondes] fixe la durée maximale d'exécution de l'ordre. |
[close] | ferme l'ordre SQL, ainsi que son [ResultSet]. |
L'interface [PreparedStatement] dérive de [Statement] : c'est un ordre SQL paramétré, dont le texte est fixé à sa création et dont les paramètres reçoivent leur valeur par des méthodes [setXxx]. Nous l'étudierons au paragraphe « Les requêtes paramétrées ». L'exécution d'ordres SQL sans transaction a la forme suivante :
10.3.3.3. L'interface ResultSet
Elle encapsule les résultats d'un ordre SQL [select]. Un objet [ResultSet] représente une table avec des lignes et des colonnes, que l'on exploite séquentiellement : d'abord la première ligne, puis la deuxième, etc., sans retour en arrière possible (c'est le type par défaut [TYPE_FORWARD_ONLY] ; JDBC connaît aussi des résultats que l'on peut parcourir dans les deux sens, mais on les utilise rarement). Le [ResultSet] a un curseur placé initialement avant la première ligne : il faut appeler [next] pour lire la première ligne. Les méthodes de cette interface que nous utiliserons sont les suivantes :
Méthode | Rôle |
[next] | passe à la ligne suivante de la table résultat. Rend [true] si la lecture a pu se faire, [false] s'il n'y a plus de ligne. |
[getInt] | [getInt(i)] rend la valeur entière de la colonne n° i de la ligne courante. Les colonnes sont numérotées à partir de 1, et non de 0. [getInt("nom")] rend la valeur de la colonne de nom [nom]. Les autres méthodes analogues sont [getBoolean], [getBigDecimal], [getDouble], [getLong], [getString]… Comme elles ont un paramètre, Kotlin ne les transforme pas en propriétés. |
[getObject] | [getObject(i)] rend la valeur de la colonne n° i sous la forme d'un [Any] (un [Object] Java), du type choisi par le pilote ; [getObject(i, LocalDate::class.java)] la rend dans le type demandé (en Kotlin, [X::class.java] désigne l'objet [Class] Java d'une classe, le [X.class] de Java). |
[wasNull] | rend [true] si la dernière colonne lue n'avait pas de valeur, ce qui est symbolisé par la valeur SQL [NULL]. Les méthodes qui rendent un objet ([getString], [getBigDecimal], [getObject]…) rendent alors [null] ; celles qui rendent un type de base ([getInt], [getDouble]…) rendent 0 : seul [wasNull] permet de distinguer le 0 du [NULL]. |
[metaData] | (la méthode Java [getMetaData]) rend un objet [ResultSetMetaData] qui décrit les colonnes du résultat : [columnCount] (le nombre de colonnes), [getColumnLabel(i)] (le nom de la colonne n° i), [getColumnTypeName(i)] (son type SQL)… |
[close] | ferme le résultat. |
L'exploitation d'un objet [ResultSet] ressemble souvent à ce qui suit :
La boucle [while (resultat.next())] est la même qu'en Java : nous verrons qu'en Kotlin on l'écrit une fois pour toutes dans une fonction d'extension, plutôt que de la répéter dans chaque fonction qui lit un résultat.
Le type de plate-forme mérite ici une remarque. Pour une colonne qui accepte la valeur SQL [NULL], [getString(i)] rend [null] ; si l'on écrit [val nom: String = resultat.getString(2)], Kotlin insère une vérification et lance une [NullPointerException] dès l'affectation, avec le message « getString(...) must not be null ». Il faut donc déclarer [String?] les valeurs des colonnes qui peuvent être [NULL], et les traiter ensuite avec les opérateurs [?.] et [?:] du chapitre 3. Dans notre table, toutes les colonnes sont [NOT NULL] : le problème ne se pose pas.
Avec MySQL Connector/J, les lignes du résultat sont par défaut toutes transférées du serveur vers la mémoire de l'application dès l'exécution de la requête ; [next] les parcourt ensuite. Pour un résultat de plusieurs millions de lignes, on peut demander au pilote de les recevoir au fur et à mesure de leur lecture (paramètre [useCursorFetch=true] de l'URL et [fetchSize = n] sur l'ordre SQL) : c'est un réglage propre au pilote, que nous n'utiliserons pas.
10.3.4. La gestion des erreurs
La couche [dao] peut rencontrer de nombreuses erreurs lors de l'exploitation de la base de données : serveur arrêté, base inexistante, mot de passe incorrect, erreur de syntaxe SQL, violation d'une contrainte d'intégrité… Celles-ci sont remontées sous la forme d'exceptions [SQLException]. En Java, [SQLException] est une exception contrôlée : toutes les méthodes de JDBC la déclarent dans leur clause [throws], et le compilateur Java oblige le code qui les appelle à l'intercepter ou à la déclarer à son tour. Kotlin n'a pas d'exceptions contrôlées (chapitre 3) : le compilateur n'impose rien, et une fonction Kotlin qui appelle JDBC n'a rien à déclarer. C'est une liberté, et une responsabilité : rien ne signale au développeur qu'une ligne peut échouer, c'est à lui de décider où les erreurs sont traitées. Une couche [dao] doit gérer ces erreurs et, quoi qu'il arrive, libérer les ressources qu'elle a obtenues : la connexion, l'ordre SQL et le résultat doivent être fermés. Écrit avec des clauses [finally], comme on le faisait en Java avant Java 7, le code vu plus haut pour exploiter le résultat d'un ordre [select] devient le suivant :
Ce code est lourd : la connexion doit être déclarée [var], de type [Connection?], avant le [try] pour être visible dans la clause [finally], et sa fermeture peut elle-même lancer une [SQLException] (ligne 23), qu'il faut intercepter dans la clause [finally]. On notera l'appel sûr [connexion?.close()] : si la connexion n'a pas pu être ouverte, [connexion] vaut [null] et rien n'est fait.
Le chapitre 3 a présenté la solution de Kotlin : la fonction d'extension use, définie pour tout objet [AutoCloseable], exécute le bloc (la lambda) qu'on lui passe et ferme l'objet à la sortie du bloc, quelle que soit la façon dont on en sort. C'est l'équivalent de l'instruction try-with-resources de Java. Les objets [Connection], [Statement] et [ResultSet] implémentent l'interface [AutoCloseable] :
- lignes 3-5 : trois ressources sont ouvertes, chacune par un [use]. L'objet ouvert est le paramètre de la lambda ([connexion], [ordre], [resultat]) ; on lui donne un nom plutôt que d'utiliser [it], car les trois lambdas sont imbriquées et un [it] désignerait l'objet du [use] le plus intérieur. Contrairement au try-with-resources de Java, qui accepte plusieurs ressources entre ses parenthèses, [use] ne gère qu'une ressource : on imbrique donc les [use]. Les ressources sont fermées dans l'ordre inverse de leur ouverture : le résultat, puis l'ordre SQL, puis la connexion. Une exception lancée par une fermeture n'efface pas celle qui a éventuellement provoqué la sortie du bloc : elle lui est attachée comme exception « supprimée » ([suppressedExceptions]), comme en Java ;
- ligne 13 : la clause [catch] intercepte aussi bien les erreurs de connexion et d'exécution que celles des fermetures.
[use] étant une fonction [inline] (chapitre 7), son bloc n'est pas un objet lambda : il est recopié à la place de l'appel, et une instruction [return] écrite dans le bloc termine la fonction englobante, après la fermeture de la ressource. De plus, [use] rend la valeur de son bloc : [val n = connexion.createStatement().use { it.executeUpdate(sql) }] exécute l'ordre, ferme l'objet [Statement] et rend le nombre de lignes modifiées. Nous utiliserons souvent ces deux propriétés.
L'intérêt de [use] n'est pas seulement l'économie de lignes : il évite au développeur de fermer lui-même la connexion. Or l'oubli de la fermeture d'une connexion peut passer inaperçu et « planter » l'application d'une façon qui paraîtra aléatoire, chaque fois que le SGBD (ou le pool de connexions, que nous verrons) atteindra le nombre maximal de connexions ouvertes qu'il peut supporter. Dans la suite du cours, toute ressource JDBC est ouverte par un use.
La classe [SQLException] donne trois informations sur l'erreur, que Kotlin présente comme des propriétés :
- [message] : le message d'erreur ;
- [errorCode] : le code d'erreur propre au SGBD (le numéro d'erreur MySQL : 1049, 1062…) ;
- [sqlState] : un code d'erreur de cinq caractères défini par la norme SQL (« SQLSTATE »), commun à tous les SGBD pour les erreurs les plus courantes : la classe [42] désigne une erreur de syntaxe ou d'accès, [23] une violation de contrainte d'intégrité, [08] une erreur de connexion…
On notera la propriété [sqlState], issue de la méthode Java [getSQLState] : quand le nom commence par plusieurs majuscules, Kotlin les met en minuscules, sauf la dernière lorsqu'elle commence le mot suivant ([SQLState] devient [sqlState]). Depuis Java 6 (JDBC 4.0), [SQLException] a aussi des classes dérivées standard, que les pilotes lancent selon la nature de l'erreur. Les principales sont les suivantes :
Classe | Erreur |
[SQLSyntaxErrorException] | erreur de syntaxe SQL, table ou base inexistante, droits insuffisants (SQLSTATE de classe 42) |
[SQLIntegrityConstraintViolationException] | violation d'une contrainte d'intégrité : clé en double, clé étrangère, colonne [NOT NULL] (SQLSTATE de classe 23) |
[SQLDataException] | donnée incorrecte : valeur trop grande, conversion impossible (SQLSTATE de classe 22) |
[SQLTimeoutException] | délai d'exécution dépassé ([queryTimeout]) |
[SQLTransactionRollbackException] | transaction annulée par le SGBD, par exemple à la suite d'un interblocage (« deadlock ») |
[SQLNonTransientConnectionException], [SQLRecoverableException] | problème de connexion |
Le programme [Jdbc02.kt] provoque diverses erreurs et les affiche :
Résultats :
Commentaires :
- lignes 10-12 : les paramètres de connexion à la base, écrits en tête du programme sous la forme de constantes [const val] de haut niveau, privées au fichier ;
- ligne 15 : le numéro de l'erreur MySQL « Duplicate entry », sous le nom que lui donne la documentation de MySQL ;
- lignes 19-27 : six opérations qui échouent : base inexistante ; serveur injoignable (aucun serveur MySQL n'écoute sur le port 3399, ce qui produit la même erreur qu'un serveur arrêté) ; erreur de syntaxe ; table inexistante ; nom d'article en double ; prix négatif. Le paramètre [connectTimeout=5000] de l'URL de la ligne 20 limite à 5 secondes (5000 ms) l'attente de la connexion ;
- ligne 31 : la fonction [executer] exécute l'ordre SQL [requete] sur la base désignée par l'URL [url] ;
- lignes 34-35 : la connexion et l'ordre SQL sont ouverts par deux [use] imbriqués. Le second tient sur une ligne : la lambda [{ ordre -> ordre.execute(requete) }] n'a qu'une instruction ;
- ligne 35 : la méthode [execute] exécute un ordre SQL quelconque : [select] ou [insert]. Si elle lance une exception, l'affichage « pas d'erreur » de la ligne 37 n'a pas lieu ;
- ligne 39 : un [when] sur l'exception, avec des clauses [is Type] (chapitre 3) ;
- ligne 41 : une garde ([if], Kotlin 2.2) permet de traiter à part une erreur précise : une violation de contrainte d'intégrité ([SQLIntegrityConstraintViolationException]) dont le code MySQL est 1062, c'est-à-dire une clé en double. C'est l'équivalent du motif gardé [case … when …] du [switch] de Java 21. Une application afficherait dans ce cas un message compréhensible par l'utilisateur ;
- lignes 44-46 : les autres erreurs. On affiche la classe de l'exception ([ex.javaClass.simpleName] : [javaClass] rend l'objet [Class] Java d'un objet), le code d'erreur MySQL et le code SQLSTATE, puis la première ligne du message : certains messages du pilote occupent plusieurs lignes. La propriété [message] est de type [String?] (une exception peut ne pas avoir de message) : on l'exploite avec des appels sûrs, [?.lineSequence()?.first()] ; [lineSequence] découpe la chaîne en une séquence de lignes, dont [first] prend la première. Si le message était [null], l'expression vaudrait [null] et le gabarit afficherait « null ».
Les résultats montrent que le pilote MySQL utilise les classes standard : [SQLSyntaxErrorException] pour une base inexistante (1049), une erreur de syntaxe (1064) ou une table inexistante (1146), toutes de SQLSTATE [42xxx]. Le serveur injoignable produit une [CommunicationsException], une classe du pilote (dérivée de [SQLRecoverableException]) : il n'y a pas de code d'erreur MySQL, puisque le serveur n'a pas répondu, mais le SQLSTATE [08S01] indique un échec de la liaison. La violation d'une contrainte [CHECK] (erreur 3819) est une simple [SQLException], de SQLSTATE [HY000] (« erreur générale ») : MySQL ne lui attribue pas de SQLSTATE de classe 23. On ne peut donc pas se fier entièrement aux classes dérivées : le code d'erreur et le SQLSTATE restent utiles.
Une couche [dao] qui veut rester indépendante du SGBD n'utilise que les classes standard et le SQLSTATE ; les codes d'erreur ([errorCode]) sont propres à chaque SGBD.
Une dernière remarque sur les exceptions contrôlées. Une fonction Kotlin qui laisse passer une [SQLException] ne le déclare nulle part ; si elle doit être appelée depuis du code Java, celui-ci ne pourra pas intercepter l'exception par un [catch (SQLException e)], que le compilateur Java refuse lorsqu'aucune méthode appelée ne la déclare. On ajoute alors à la fonction Kotlin l'annotation [@Throws(SQLException::class)], qui produit la clause [throws] attendue par Java. Nos programmes, entièrement écrits en Kotlin, n'en ont pas besoin.
10.3.5. Configuration du projet exemple
Le pilote JDBC de MySQL est une bibliothèque publiée sur Maven Central, le dépôt de bibliothèques où Gradle va chercher les dépendances (chapitre 2). Le dossier [10-jdbc] du chapitre est une construction Gradle multi-projets (chapitre 2) : les programmes de ce chapitre forment le sous-projet [jdbc], et l'application [Impots] version 6, étudiée en fin de chapitre, deux autres sous-projets :
Le fichier [settings.gradle.kts] du chapitre déclare les sous-projets :
- lignes 3-6 : le plugin qui permet à Gradle de télécharger lui-même le JDK 25 s'il ne le trouve pas sur le poste (chapitre 2) ;
- ligne 11 : le sous-projet [jdbc], dont le dossier porte le même nom ;
- lignes 13-16 : la version 6 de l'application [Impots], présentée en fin de chapitre.
Le fichier [build.gradle.kts] du sous-projet [jdbc] est le suivant :
- lignes 2-5 : le greffon Kotlin pour la JVM, dont la version est fixée par le projet racine (chapitre 2), et le greffon [application], qui fournit la tâche [run] ;
- lignes 15-23 : les quatre bibliothèques du projet, téléchargées par Gradle depuis Maven Central lors de la première construction :
- ligne 17 : le pilote JDBC de MySQL, MySQL Connector/J, de coordonnées [com.mysql:mysql-connector-j]. Jusqu'à sa version 8.0.30, il était publié sous les coordonnées [mysql:mysql-connector-java], que l'on rencontre encore dans de nombreux projets. Un programme qui n'utilise que les interfaces de JDBC n'a besoin du pilote qu'à l'exécution : on le déclare alors avec [runtimeOnly], ce qui garantit qu'aucune classe du pilote ne peut être utilisée par erreur dans le code. Nous le déclarons avec [implementation] parce que le programme [Jdbc03] utilise une classe du pilote, [MysqlDataSource], pour une démonstration ;
- ligne 18 : le pool de connexions HikariCP, que nous utiliserons à partir du programme [Jdbc03] ;
- ligne 21 : HikariCP écrit ses messages de journalisation au travers de l'API SLF4J (« Simple Logging Facade for Java »), qui a besoin d'une implémentation : nous utilisons Logback, nécessaire seulement à l'exécution ([runtimeOnly]). Sans elle, SLF4J afficherait un avertissement au démarrage. Le fichier [src/main/resources/logback.xml], présenté plus bas, n'affiche que les avertissements et les erreurs ;
- ligne 23 : la bibliothèque des coroutines, kotlinx-coroutines (chapitre 9), utilisée par le programme [Jdbc08Coroutines], qui exécute des requêtes JDBC dans des coroutines ;
- lignes 27-33 : le compilateur, configuré comme dans tous les projets du cours : JDK 25, avertissements traités comme des erreurs, noms des paramètres conservés dans les classes compilées ;
- lignes 38-40 : le programme exécuté par [gradlew run] est désigné par la propriété [main] ; par défaut, c'est [jdbc01.Jdbc01Kt] ;
- lignes 43-45 : le programme exécuté peut lire le clavier, ce dont [Jdbc01] a besoin ;
- lignes 48-51 : les programmes lancés par Gradle écrivent leurs sorties en UTF-8, pour que les caractères accentués s'affichent correctement sous Windows (chapitre 2).
Le fichier de configuration de Logback est le suivant :
- lignes 4-8 : les messages sont écrits sur la console, sous la forme « niveau classe - message » ;
- ligne 9 : seuls les messages de niveau [WARN] (avertissement) ou [ERROR] sont affichés. Avec le niveau [INFO], on verrait les messages de démarrage et d'arrêt du pool de HikariCP.
Il existe deux pilotes JDBC courants pour MySQL :
- [com.mysql:mysql-connector-j] (« Connector/J »), publié par Oracle, propriétaire de MySQL, sous licence GPL v2 avec une exception (« Universal FOSS Exception ») qui permet de l'associer à des logiciels distribués sous d'autres licences libres ; une licence commerciale couvre les autres cas. C'est le pilote officiel, que nous utilisons ;
- [org.mariadb.jdbc:mariadb-java-client], le pilote de MariaDB (une variante libre de MySQL), sous licence LGPL, qui fonctionne aussi avec MySQL.
Les deux pilotes implémentent les mêmes interfaces JDBC : passer de l'un à l'autre ne demande que de changer la dépendance du [build.gradle.kts] et le début de l'URL ([jdbc:mariadb:] au lieu de [jdbc:mysql:]).
Une remarque historique : jusqu'à Java 5, il fallait charger la classe du pilote avant d'ouvrir une connexion, par une instruction comme [Class.forName("com.mysql.jdbc.Driver")], que l'on trouve encore dans de nombreux exemples sur Internet. Depuis Java 6 (JDBC 4.0), c'est inutile : chaque archive de pilote déclare sa classe dans un fichier [META-INF/services/java.sql.Driver], et [DriverManager] charge automatiquement, par le mécanisme des services ([ServiceLoader]), tous les pilotes présents dans le classpath.
Les paramètres de connexion ne doivent pas être écrits dans le code d'une application réelle : ils dépendent de la machine sur laquelle l'application est installée. Le premier programme du chapitre les lit dans un fichier de propriétés (chapitre 6), [dbarticles.properties], placé dans le dossier du sous-projet :
- ligne 2 : l'URL JDBC de la base. Une URL JDBC a la forme [jdbc:sous-protocole:détails]. Le sous-protocole désigne le pilote ([mysql]) et les détails, dont la forme dépend du pilote, désignent la base. Pour MySQL, ce sont :
- [//localhost] : le nom de la machine sur laquelle se trouve le SGBD MySQL, ici [localhost], c'est-à-dire la machine sur laquelle s'exécute le programme ;
- [:3306] : le port sur lequel le serveur MySQL attend les connexions, 3306 par défaut ;
- [/dbarticles] : le nom de la base de données ;
- des paramètres facultatifs du pilote peuvent suivre, après un point d'interrogation, sous la forme [?param1=valeur1¶m2=valeur2]. Nous rencontrerons [connectTimeout], [rewriteBatchedStatements] et [allowMultiQueries] ;
- ligne 3 : l'utilisateur MySQL, ici [root], l'administrateur du SGBD ;
- ligne 4 : son mot de passe, vide ici.
La documentation de Connector/J donne la liste complète des paramètres de l'URL : Connector/J Configuration Properties.
Les autres programmes du chapitre, plus courts, écrivent leurs paramètres de connexion dans des constantes en tête du programme, comme [Jdbc02.kt] : c'est plus simple pour des exemples, à ne pas faire dans une application.
Pour exécuter les programmes, on se place dans le dossier [10-jdbc] et on tape :
La première commande exécute [Jdbc01], la seconde [Jdbc02]. Le préfixe [:jdbc:] désigne la tâche [run] du sous-projet [jdbc] ; la tâche [run] s'exécute dans le dossier du sous-projet, [10-jdbc/jdbc], où se trouve le fichier [dbarticles.properties]. L'option [-q] (« quiet ») supprime les messages de Gradle : seule reste la sortie du programme. On peut aussi, bien sûr, exécuter les programmes depuis IntelliJ IDEA, par le triangle vert placé à côté de la fonction [main] (le dossier courant est alors à vérifier dans la configuration d'exécution). Dans la suite, nous ne reproduisons que la sortie des programmes.
10.3.6. Le programme exemple
Le programme [Jdbc01.kt] dont nous avons vu un exemple d'exécution est le suivant (programme principal) :
- lignes 5-8 : les interfaces et classes JDBC utilisées, toutes du paquetage [java.sql]. Le programme ne contient le nom d'aucune classe du pilote MySQL : seule l'URL JDBC, lue dans la configuration, désigne MySQL ;
- ligne 16 : les quatre ordres SQL acceptés, dans un ensemble en lecture seule créé par [setOf] (chapitre 5) ;
- lignes 19-21 : les trois paramètres de connexion sont réunis dans une classe de données. Plutôt que de passer trois chaînes à chaque fonction, comme le fait le programme Java du cours, on passe un objet [ParametresConnexion], qui sait ouvrir une connexion : sa méthode [connecter] appelle [DriverManager.getConnection] avec ses propres propriétés. Le type rendu, [Connection], est écrit explicitement : sans lui, la fonction rendrait le type de plate-forme [Connection!] ;
- lignes 25-29 : la lecture du fichier de propriétés, comme au chapitre 6. La fonction d'extension [bufferedReader] de [kotlin.io.path] ouvre le fichier en UTF-8 et [use] le ferme. [getProperty] rend la valeur d'une clé, ou [null] si la clé est absente : son type Kotlin est [String?]. L'URL étant obligatoire, l'opérateur Elvis [?:] lance une [IOException] lorsqu'elle manque, ce qui permet de traiter de la même façon un fichier absent et un fichier incomplet ; [getProperty(clé, défaut)] rend une valeur par défaut pour les deux autres paramètres ;
- ligne 32 : la fonction [main] de haut niveau, sans paramètre puisque le programme n'utilise pas ses arguments ;
- lignes 34-38 : [try] est une expression (chapitre 3) : sa valeur est celle du bloc [try] si tout se passe bien ; en cas d'erreur, la clause [catch] affiche un message et termine le programme par [return]. Le fichier est cherché dans le dossier courant, celui à partir duquel le programme est lancé : c'est le dossier du sous-projet lorsqu'on tape [gradlew :jdbc:run] ;
- ligne 45 : la boucle de saisie des ordres SQL tapés au clavier et de leur exécution sur la base de données ;
- lignes 47-48 : [print] affiche l'invite sans passer à la ligne, puis [readlnOrNull] lit une ligne tapée au clavier ; elle rend [null] si l'entrée standard est fermée. L'appel sûr [?.trim()] suivi de [orEmpty] transforme ce [null] en chaîne vide, ce qui arrête la boucle à la ligne suivante ;
- ligne 52 : le premier mot de la requête, obtenu en découpant la requête sur les espaces (expression régulière [\s+], chapitre 5). Il est mis en minuscules pour la comparaison, mais la requête elle-même n'est pas modifiée : la mettre tout entière en minuscules empêcherait d'insérer un article nommé « Vélo » ;
- lignes 53-55 : si la requête est invalide, un message d'erreur est affiché et on passe à la requête suivante. L'opérateur [!in] appelle la méthode [contains] de l'ensemble (chapitre 4) ;
- lignes 58-61 : l'exécution de l'ordre SQL saisi. Elle prend une forme différente selon qu'il s'agit d'un ordre [select] ou d'un ordre [insert], [update], [delete] : dans le premier cas, l'ordre ramène des données de la base sans la modifier ; dans le second, il met la base à jour sans ramener de données. Dans les deux cas, l'exécution est déléguée à une fonction qui reçoit les paramètres de connexion et l'ordre SQL à exécuter.
On remarquera l'absence de toute déclaration d'exception : en Java, la lecture du fichier oblige à intercepter ou déclarer l'[IOException] contrôlée. En Kotlin, c'est un choix : nous interceptons l'[IOException] ici parce que c'est l'endroit où l'on sait quoi faire de l'erreur (prévenir l'utilisateur et s'arrêter).
10.3.7. Exécution d'une requête SELECT
L'exécution d'ordres SQL comporte les étapes suivantes :
- connexion à la base de données ;
- émission des ordres SQL vers la base ;
- traitement des résultats des ordres SQL ;
- fermeture de la connexion.
Les étapes 2 et 3 peuvent être répétées, la fermeture de la connexion n'ayant lieu qu'à la fin de l'exploitation de la base. Les connexions ouvertes sont des ressources limitées d'un SGBD : il faut les économiser, et l'on cherche toujours à limiter la durée de vie d'une connexion ouverte. Dans notre programme, la connexion est fermée après chaque ordre SQL, et une nouvelle connexion est ouverte pour l'ordre suivant.
L'ouverture d'une connexion est pourtant coûteuse : il faut établir une connexion réseau avec le serveur, négocier éventuellement un chiffrement, s'authentifier. [DriverManager] ne fait rien pour diminuer ce coût : chaque appel à [getConnection] ouvre une nouvelle connexion réseau, et chaque [close] la ferme réellement. Pour un outil interactif comme celui-ci, ce n'est pas gênant. Une application qui exécute de nombreux ordres SQL, un serveur web par exemple, utilise au contraire un pool de connexions : lorsque l'application ferme une connexion, le pool ne la ferme pas réellement mais la remet dans un réservoir (le pool) ; lorsque l'application demande une nouvelle connexion, elle reçoit l'une des connexions du pool, déjà établie. L'intérêt de ce système est qu'il est transparent pour le développeur : le code qui ouvre et ferme ses connexions par un [use] n'a pas à être modifié. Nous verrons au paragraphe « Les sources de données et les pools de connexions » comment l'utiliser. Ouvrir une connexion au début de chaque opération et la fermer à la fin est donc la bonne pratique.
Nous nous intéressons tout d'abord à l'exécution des ordres SQL [select]. La fonction [executeSelect] de notre programme exemple et la fonction [afficherResultSet] qu'elle utilise sont les suivantes :
- ligne 2 : la fonction reçoit les paramètres de connexion et l'ordre SQL [select] [requete] à exécuter. Comme les deux autres fonctions du programme, elle est déclarée [private] : elle n'est visible que dans le fichier [Jdbc01.kt] ;
- lignes 5-7 : trois [use] imbriqués ouvrent trois ressources :
- ligne 5 : la connexion avec la base, obtenue de [DriverManager] par la méthode [connecter]. [DriverManager] interroge chacun des pilotes du classpath : le premier qui reconnaît l'URL ouvre la connexion. C'est à ce moment que les paramètres de connexion sont utilisés, et que se produisent les erreurs de connexion. La connexion est du type de l'interface [Connection] : le code ne sait pas qu'il s'agit d'un objet du pilote MySQL ;
- ligne 6 : l'ordre SQL, un objet [Statement] créé par la connexion. Le même type d'objet sert à exécuter un ordre [select] et un ordre [update], [insert], [delete] ;
- ligne 7 : l'ordre [select] est exécuté par la méthode [executeQuery], qui rend un objet [ResultSet] ;
- ligne 9 : l'affichage des résultats est confié à la fonction [afficherResultSet] ;
- lignes 13-14 : toute opération avec une base de données peut lancer une [SQLException]. C'est d'autant plus probable ici que les ordres SQL donnés par l'utilisateur peuvent être syntaxiquement erronés. Il faut qu'on puisse le lui dire : l'exception est interceptée et son message affiché. Au moment où la clause [catch] s'exécute, que l'exception vienne de la connexion, de l'exécution ou de la lecture des lignes, les [use] ont déjà fermé le résultat, l'ordre SQL et la connexion ;
- ligne 19 : la fonction reçoit un objet [ResultSet]. Ses méthodes peuvent lancer une [SQLException] ; en Java, la méthode correspondante doit le déclarer par [throws SQLException]. En Kotlin, rien à déclarer : l'exception remonte simplement à la fonction appelante, qui l'intercepte ;
- lignes 21-25 : on affiche les noms des colonnes de la table résultat du [select]. Ce sont les colonnes [coli] de la requête [select col1, col2, … from table …]. Ils sont donnés par les métadonnées du résultat, un objet [ResultSetMetaData] obtenu par la propriété [metaData] : [columnCount] donne le nombre de colonnes et [getColumnLabel(i)] le nom de la colonne n° i, ou son alias si la requête en donne un ([select count(*) as nombre …]). Les colonnes étant numérotées à partir de 1, on construit l'intervalle [1..metadonnees.columnCount] (ligne 22), puis [joinToString] (chapitre 5) transforme chaque numéro en nom et assemble les noms en les séparant par des virgules. Aucun tableau intermédiaire n'est nécessaire, contrairement à la version Java. ["-".repeat(n)] produit une chaîne de n tirets ;
- lignes 27-28 : on parcourt la table résultat et on affiche les valeurs de chaque ligne, séparées par des espaces, avec le même intervalle [colonnes] et la même fonction [joinToString] ;
- ligne 28 : on ne connaît pas le type de la colonne n° i du résultat parce qu'on ne connaît pas la requête tapée par l'utilisateur. On ne peut donc pas utiliser la syntaxe [resultat.getXxx(i)], où [Xxx] est le type de la colonne. On utilise alors [getObject(i)], qui rend la valeur sous la forme d'un [Any!] ; le gabarit ["${resultat.getObject(it)}"] la convertit en chaîne au moyen de sa méthode [toString] (et affiche « null » pour une valeur SQL [NULL]). Pour la colonne [prix], de type SQL [DECIMAL(10,2)], l'objet est un [BigDecimal] de deux décimales, que sa méthode [toString] affiche avec un point décimal ([500.00]) : contrairement à [String.format], [toString] ne dépend pas de la langue du poste. Un affichage « à la française » utiliserait ["%.2f".format(prix)] ou la classe [NumberFormat] (chapitre 5).
10.3.8. Exécution d'un ordre de mise à jour : INSERT, UPDATE, DELETE
Le code de la fonction [executeUpdate] est le suivant :
L'exécution d'un ordre de mise à jour ne diffère de celle d'un ordre [select] que par la méthode de l'ordre SQL utilisée : [executeQuery] pour [select], [executeUpdate] pour [update], [insert], [delete] :
- lignes 4-5 : il n'y a cette fois que deux ressources : la connexion et l'ordre SQL ;
- ligne 7 : l'ordre [update], [insert] ou [delete] est exécuté par la méthode [executeUpdate]. Si elle réussit, cette méthode rend le nombre de lignes mises à jour ([update]), insérées ([insert]) ou supprimées ([delete]) ;
- ligne 9 : ce nombre de lignes est affiché.
Le lecteur est invité à revoir l'exemple d'exécution donné au paragraphe « Les quatre commandes de base du langage SQL ».
10.4. JDBC et l'indépendance vis-à-vis du SGBD
10.4.1. Les pilotes JDBC actuels
Le code que nous avons étudié ne contient le nom d'aucune classe du pilote MySQL : il n'utilise que les interfaces de [java.sql]. Pour le faire travailler avec un autre SGBD, il suffit en principe d'ajouter au projet le pilote de ce SGBD et de changer l'URL JDBC. Voici les principaux pilotes JDBC d'aujourd'hui, tous publiés sur Maven Central :
SGBD | Dépendance Gradle | URL JDBC | Remarques |
MySQL | [com.mysql:mysql-connector-j] | [jdbc:mysql://hôte:3306/base] | le pilote officiel, utilisé dans ce cours |
MariaDB, MySQL | [org.mariadb.jdbc:mariadb-java-client] | [jdbc:mariadb://hôte:3306/base] | |
PostgreSQL | [org.postgresql:postgresql] | [jdbc:postgresql://hôte:5432/base] | |
SQL Server, Azure SQL | [com.microsoft.sqlserver:mssql-jdbc] | [jdbc:sqlserver://hôte:1433;databaseName=base] | le pilote de Microsoft |
Oracle | [com.oracle.database.jdbc:ojdbc11] | [jdbc:oracle:thin:@//hôte:1521/service] | |
H2 | [com.h2database:h2] | [jdbc:h2:mem:base] ou [jdbc:h2:./fichier] | un SGBD écrit en Java, embarqué dans l'application : base en mémoire ou dans un fichier, sans serveur ; très utilisé pour les tests |
SQLite | [org.xerial:sqlite-jdbc] | [jdbc:sqlite:fichier.db] | une base dans un fichier, sans serveur |
On remarquera que la forme de l'URL, après [jdbc:], est propre à chaque pilote.
L'architecture est la suivante :

L'application ne connaît que les interfaces de JDBC. Les objets concrets sont créés par le pilote, que [DriverManager] choisit d'après l'URL : chaque pilote présent dans le classpath déclare sa classe principale, une implémentation de l'interface [java.sql.Driver], dont la méthode [connect(url, propriétés)] rend [null] si l'URL ne lui est pas destinée ([acceptsURL(url)] permet aussi de le lui demander). Le programme [Jdbc03.kt] le montre. Il reçoit en argument l'URL JDBC de la base :
Résultats avec l'URL de MySQL (valeur par défaut) :
Résultats avec l'URL d'une base PostgreSQL, dont le pilote n'est pas dans le classpath :
Nous commentons ici la première partie du programme (fonction [main] jusqu'à la ligne 36, et fonction [afficherArticles]) ; la seconde est étudiée au paragraphe suivant :
- ligne 17 : l'URL JDBC de la base, passée en argument, celle de la base MySQL [dbarticles] par défaut. [firstOrNull] rend le premier élément du tableau des arguments, ou [null] s'il est vide ; l'opérateur Elvis [?:] remplace alors [null] par l'URL par défaut. Avec Gradle, l'argument est passé par l'option [--args] ;
- lignes 21-22 : [DriverManager.drivers] (Java 9) rend un flux ([Stream]) des pilotes chargés. Un flux Java n'est pas une collection Kotlin, mais il a une méthode [iterator()] : cela suffit pour que la boucle [for] de Kotlin puisse le parcourir. Pour chaque pilote, [pilote.javaClass.name] donne le nom complet de sa classe, et les propriétés [majorVersion] et [minorVersion] (les méthodes Java [getMajorVersion] et [getMinorVersion]) sa version. Un seul pilote est présent dans le classpath : celui de MySQL, de classe [com.mysql.cj.jdbc.Driver], en version 9.7 (ligne 3 des résultats) ;
- ligne 27 : [DriverManager] cherche un pilote qui accepte l'URL. Si aucun ne l'accepte, [getConnection] lance une [SQLException] « No suitable driver found » (ligne 4 des seconds résultats). C'est l'erreur qu'on obtient lorsqu'on a oublié la dépendance du pilote dans le [build.gradle.kts], ou fait une faute de frappe dans l'URL ;
- lignes 28-31 : les métadonnées de la connexion, un objet [DatabaseMetaData], décrivent le SGBD (nom, version), le pilote et la version de JDBC qu'il implémente : JDBC 4.2, la version de Java 8. Le JDK 25 définit JDBC 4.3 (Java 9), dont les ajouts sont mineurs ; [DatabaseMetaData] décrit aussi les possibilités du SGBD (transactions, lots…), ses tables et leurs colonnes. Toutes ces informations sont obtenues par des méthodes [getXxx] sans paramètre, que Kotlin présente comme des propriétés ([databaseProductName], [driverVersion]…). La propriété [jdbcMajorVersion] correspond à la méthode Java [getJDBCMajorVersion] : comme pour [sqlState], le groupe de majuscules initial est mis en minuscules ;
- ligne 32 : l'exécution d'une requête paramétrée par la fonction [afficherArticles] ;
- lignes 61-62 : la requête a un paramètre, noté [?], qui reçoit sa valeur par [setInt(1, 200)]. Les requêtes paramétrées sont étudiées plus loin ;
- lignes 64-65 : les résultats montrent que les objets manipulés au travers des interfaces sont des objets du pilote MySQL : [ConnectionImpl], [ClientPreparedStatement], [ResultSetImpl] (ligne 6 des résultats) ;
- lignes 66-67 : le type des colonnes étant connu, on utilise les méthodes [getInt], [getString], [getBigDecimal], qui rendent directement une valeur du type attendu.
Pour que ce programme fonctionne avec PostgreSQL, il suffirait d'ajouter au [build.gradle.kts] la dépendance [runtimeOnly("org.postgresql:postgresql:…")] et de créer la base [dbarticles] dans PostgreSQL : [DriverManager] trouverait alors deux pilotes et choisirait celui de PostgreSQL pour une URL [jdbc:postgresql:…]. Seul MySQL est installé sur la machine qui a servi à écrire ce cours : nous ne l'avons pas fait.
10.4.2. Les sources de données et les pools de connexions
[DriverManager] est la façon historique d'obtenir une connexion. Depuis JDBC 2.0, on lui préfère dans les applications une source de données, un objet qui implémente l'interface [javax.sql.DataSource]. Une source de données est configurée une fois pour toutes (URL, utilisateur, mot de passe, réglages du pilote) et fournit ensuite des connexions par sa méthode [getConnection()], sans paramètres (la propriété [connection] en Kotlin). Le code qui utilise la source de données ne connaît ni l'URL ni les identifiants : dans une application, c'est elle que l'on enregistre dans le conteneur d'injection de dépendances (chapitre 6), plutôt que les paramètres de connexion. Il existe deux grandes familles de sources de données :
- celles fournies par les pilotes, comme [com.mysql.cj.jdbc.MysqlDataSource] pour MySQL. Comme [DriverManager], elles ouvrent une nouvelle connexion réseau à chaque appel de [getConnection] ;
- celles qui gèrent un pool de connexions. La plus utilisée est HikariCP, le pool par défaut de Spring Boot. Son [getConnection] rend une connexion du pool, et le [close] de cette connexion la remet dans le pool au lieu de la fermer.
La seconde partie du programme [Jdbc03] compare les deux (lignes 39-55 et fonction [mesurer]) :
- lignes 40-43 : une source de données [MysqlDataSource]. La fonction de portée [apply] (chapitre 7) exécute le bloc avec l'objet créé comme receveur ([this]) puis rend cet objet : on peut ainsi configurer l'objet juste après sa création, sans répéter son nom. [user = "root"] appelle la méthode Java [setUser], et [password = ""] la méthode [setPassword]. Pour l'URL, il faut appeler [setURL(url)] : la classe [MysqlDataSource] a deux couples d'accesseurs, [getUrl] / [setUrl] et [getURL] / [setURL], qui deviendraient tous deux une propriété Kotlin [url] ; le compilateur refuse alors d'écrire [url = …] (« overload resolution ambiguity between candidates »). C'est la seule partie du programme qui nomme une classe du pilote MySQL : c'est pour elle que le pilote est déclaré par [implementation] dans le [build.gradle.kts] ;
- ligne 45 : la fonction [mesurer] reçoit la source de données sous le type de l'interface [DataSource] : elle conviendrait à n'importe quel pilote ;
- lignes 48-52 : un objet [HikariConfig] réunit la configuration du pool : URL JDBC, identifiants et taille maximale du pool (5 connexions ; la valeur par défaut est 10). Il est lui aussi configuré par [apply], avec ses propriétés [jdbcUrl], [username], [password] et [maximumPoolSize]. HikariCP utilise [DriverManager] pour ouvrir ses connexions : il fonctionne avec n'importe quel pilote JDBC ;
- ligne 54 : la source de données [HikariDataSource] est créée à partir de cette configuration. Elle ouvre aussitôt ses premières connexions. Comme elle détient des connexions ouvertes, il faut la fermer à la fin du programme : [HikariDataSource] implémente [Closeable], et [use] s'en charge ;
- lignes 74-82 : la fonction [mesurer] demande 100 fois une connexion à la source de données ([repeat(100) { … }]) et la ferme aussitôt. La fonction [measureTime] de [kotlin.time] exécute le bloc qu'elle reçoit et rend sa durée, un objet [Duration], dont [inWholeMilliseconds] donne le nombre de millisecondes (en Java, on calcule soi-même la différence de deux appels à [System.nanoTime]). Le bloc modifie la variable [classe], déclarée par [var] hors du bloc : les lambdas de Kotlin peuvent modifier les variables qu'elles capturent (chapitre 7), ce que Java interdit. Le résultat est éloquent (lignes 10-11 des premiers résultats) : sans pool, l'ouverture d'une connexion demande plusieurs dizaines de millisecondes, pour la connexion réseau et l'authentification ; avec le pool, la connexion est obtenue en une fraction de milliseconde. Les durées dépendent de la machine et varient d'une exécution à l'autre, mais le rapport reste de cet ordre ;
- ligne 79 : avec HikariCP, la connexion rendue est un objet [HikariProxyConnection] : un « mandataire » (« proxy ») qui enveloppe la connexion du pilote et dont la méthode [close] la rend au pool.
Une application qui sert de nombreux utilisateurs utilise donc toujours un pool de connexions. Nous en reverrons un usage au paragraphe sur les threads virtuels.
10.4.3. Quel niveau d'abstraction choisir ?
L'indépendance de JDBC vis-à-vis du SGBD semble idéale. Dans la pratique, JDBC n'arrive pas à cacher toutes les particularités d'un SGBD :
- le SQL lui-même diffère d'un SGBD à l'autre dès qu'on sort des ordres de base : génération des clés primaires, limitation du nombre de lignes rendues ([limit 10] pour MySQL et PostgreSQL, [top 10] ou [offset … fetch] pour SQL Server), types des colonnes, fonctions de dates, etc. ;
- les types rendus par [getObject] peuvent varier : nous verrons que le [count(*)] de MySQL rend un [Long] ;
- les codes d'erreur ([errorCode]) sont propres à chaque SGBD, et les SQLSTATE ne sont pas toujours utilisés de la même façon ;
- les URL et les paramètres de réglage sont propres à chaque pilote.
Les paramètres des requêtes, en revanche, sont uniformes : JDBC impose la notation [?] (des paramètres positionnels, sans nom), que chaque pilote traduit dans la syntaxe de son SGBD.
Pour s'affranchir de ces limites ou alléger l'écriture du code JDBC, on utilise aujourd'hui principalement :
- un ORM, le plus souvent Hibernate, présenté au chapitre 11 : la couche [dao] manipule des objets et écrit ses requêtes en HQL, un langage de requêtes objet ; c'est Hibernate qui génère le SQL propre à chaque SGBD (son « dialecte ») ;
- des bibliothèques qui gardent le SQL mais suppriment le code répétitif de JDBC : [JdbcTemplate] et [JdbcClient] de Spring (ce dernier depuis Spring Framework 6.1), qui transforment directement les lignes d'un [select] en objets et convertissent les [SQLException] en exceptions non contrôlées ; jOOQ, qui construit les requêtes SQL dans le code avec vérification par le compilateur, et propose des extensions pour Kotlin ; MyBatis, qui associe des requêtes SQL écrites dans des fichiers XML ou des annotations à des méthodes ;
- côté Kotlin, Exposed, la bibliothèque d'accès aux bases de données de JetBrains, qui offre les deux styles : un langage de requêtes écrit en Kotlin ([Articles.selectAll().where { Articles.prix greater 200 }]) et une couche objet légère.
Ces outils s'appuient tous sur JDBC : comprendre JDBC reste utile pour comprendre ce qu'ils font et diagnostiquer leurs problèmes. Et, nous allons le voir, quelques fonctions d'extension suffisent déjà à rendre le code JDBC écrit en Kotlin beaucoup moins répétitif.
10.5. Les requêtes paramétrées
10.5.1. Le principe
Une requête paramétrée est un ordre SQL dont certaines valeurs sont remplacées par des paramètres, dont les valeurs sont fournies séparément du texte de la requête. Avec JDBC, un paramètre est noté par un point d'interrogation [?], et l'ordre SQL est un objet [PreparedStatement], créé par [connexion.prepareStatement(requête)]. Les paramètres sont désignés par leur position dans la requête, à partir de 1, et reçoivent leur valeur par les méthodes [setXxx] de l'ordre : [setString(1, "vélo")], [setInt(2, 10)]… Ces méthodes ayant deux paramètres, Kotlin ne les présente pas comme des propriétés : on les appelle comme en Java.
Les programmes qui suivent représentent une ligne de la table [articles] par un objet [Article]. Comme cette classe sert à plusieurs programmes, elle est rangée, avec des fonctions utilitaires, dans le paquetage [commun] du projet. Le fichier [commun/Article.kt] est le suivant :
- lignes 7-12 : une classe de données (chapitre 4), l'équivalent Kotlin du [record] de Java : une classe dont le constructeur primaire définit les propriétés, ici toutes en lecture seule ([val]), et dont le compilateur génère les méthodes [equals], [hashCode], [toString], [copy] et les fonctions [componentN] de la déstructuration. Le prix est un [BigDecimal], comme la colonne [DECIMAL] correspondante. La virgule qui suit le dernier paramètre (ligne 12) est permise par Kotlin : elle facilite l'ajout d'un paramètre et la lecture des différences entre deux versions du fichier ;
- lignes 16-21 : une fonction d'extension (chapitre 4) de l'interface [ResultSet]. Elle s'appelle comme une méthode du résultat, [resultat.toArticle()], et transforme sa ligne courante en un objet [Article]. Dans son corps, [this] est le [ResultSet] : [getInt("id")] est un appel de [this.getInt("id")]. Les colonnes sont désignées par leur nom, et les arguments du constructeur aussi (arguments nommés, chapitre 3) : on voit d'un coup d'œil quelle colonne alimente quelle propriété. La fonction suppose que le résultat a les colonnes [id], [nom], [prix], [stockactuel] et [stockminimum] ; une requête qui ne les fournit pas provoque une [SQLSyntaxErrorException] « Column 'xxx' not found. ». Le type rendu n'est pas écrit : la fonction a un corps d'expression ([= Article(…)]), dont le compilateur infère le type.
L'interface [ResultSet] n'a évidemment pas été écrite pour nos articles : la fonction d'extension permet de lui ajouter, dans notre projet, l'opération qui nous manque, sans dériver de classe ni écrire de classe utilitaire à méthodes statiques comme on le ferait en Java (C# offre la même possibilité avec ses méthodes d'extension). Le fichier [commun/ResultSets.kt] ajoute de la même façon à [ResultSet] deux fonctions qui lisent toutes ses lignes, pour n'importe quel type de ligne :
- lignes 8-13 : la fonction générique [toList] parcourt les lignes du résultat par la boucle [while (next())] et transforme chacune par la fonction [transformation] reçue en paramètre, de type [(ResultSet) -> T] (chapitre 7). Elle rend la liste des objets obtenus. C'est la boucle que l'on écrit en Java dans chaque méthode qui lit un résultat ; elle est écrite ici une fois pour toutes. Comme le dernier paramètre est une fonction, l'appel s'écrit avec une lambda placée après les parenthèses, qu'on peut omettre : [resultat.toList { it.toArticle() }] rend une [List<Article>] ;
- lignes 17-18 : la même chose sous la forme d'une séquence (chapitre 5), c'est-à-dire d'une suite d'éléments calculés à la demande. [generateSequence] construit une séquence à partir d'une fonction appelée chaque fois qu'on demande l'élément suivant ; la séquence se termine lorsque cette fonction rend [null]. Ici, la fonction passe à la ligne suivante du résultat et rend sa transformation, ou [null] s'il n'y a plus de ligne. Aucune ligne n'est lue tant qu'on ne parcourt pas la séquence : il faut donc la parcourir avant la fermeture du résultat, c'est-à-dire dans le bloc du [use] qui l'a ouvert. Une séquence ne peut en outre être parcourue qu'une fois, puisque le curseur du [ResultSet] ne revient pas en arrière.
[toList] convient lorsqu'on veut rendre les lignes à l'appelant (la liste survit à la fermeture du résultat) ; [asSequence] lorsqu'on traite les lignes au fur et à mesure, sans les accumuler en mémoire.
Le programme [Jdbc04.kt] met en œuvre les requêtes paramétrées : il vide la table [articles], y insère cinq articles avec une requête paramétrée, puis les relit avec une autre requête paramétrée :
Résultats :
Commentaires :
- lignes 3-5 : la classe [Article] et les fonctions d'extension du paquetage [commun] doivent être importées : une fonction d'extension n'est visible que là où elle est importée (ou dans son propre paquetage) ;
- ligne 21 : une liste de cinq articles, construite par [map] à partir de l'intervalle [1..5] (chapitre 5). L'[id] 0 n'a pas d'importance : il ne sera pas inséré dans la base. [BigDecimal(i * 100)] appelle le constructeur de [BigDecimal] qui reçoit un entier ;
- ligne 25 : on supprime tous les articles de la table. La fonction [executeUpdate] (ligne 77) rend cette fois le nombre de lignes supprimées. L'appel est placé directement dans le gabarit de la chaîne affichée, entre [${ }] ;
- ligne 29 : [forEach(::println)] affiche chaque article de la liste, au moyen d'une référence de fonction (chapitre 7). Les résultats montrent la méthode [toString] générée pour la classe de données, de la forme [Article(id=7, nom=article1, …)] (un [record] Java affiche [Article[id=7, …]]) ;
- ligne 39 : la fonction d'insertion d'une liste d'articles :
- ligne 41 : la requête d'insertion d'un article. Elle a quatre paramètres, notés [?] : le nom (paramètre n° 1), le prix (n° 2), le stock actuel (n° 3) et le stock minimum (n° 4) ;
- ligne 43 : [prepareStatement] crée l'ordre paramétré. Le second argument, [Statement.RETURN_GENERATED_KEYS], demande au pilote de récupérer les clés primaires générées par le SGBD lors des insertions ;
- lignes 45-50 : les articles sont insérés un à un. Chacun des quatre paramètres reçoit sa valeur par la méthode [setXxx] correspondant à son type, puis la requête, maintenant complète, est exécutée par [executeUpdate], sans argument cette fois : le texte de la requête a été donné à la création de l'ordre. Les valeurs des paramètres sont conservées d'une exécution à l'autre jusqu'à ce qu'on les change (ou qu'on appelle [clearParameters]) ;
- lignes 52-54 : la propriété [generatedKeys] (la méthode Java [getGeneratedKeys]) rend, sous la forme d'un [ResultSet], la clé primaire générée par le SGBD pour la ligne insérée. Ce résultat est lui aussi une ressource, fermée par [use]. Les résultats montrent les [id] 7 à 11 : MySQL ne réutilise pas les valeurs 1 à 6 des lignes supprimées. C'est le moyen de connaître l'identifiant d'une ligne que l'on vient d'insérer ;
- ligne 62 : la fonction qui rend les articles dont le nom correspond à un modèle [like] de SQL (le caractère [%] y désigne une suite quelconque de caractères : [%3] désigne les noms qui se terminent par 3) :
- lignes 63-66 : la requête a un paramètre, le modèle. Elle est écrite dans une chaîne brute (chapitre 5), délimitée par trois guillemets, qui peut s'étendre sur plusieurs lignes ; [trimIndent] retire l'indentation commune des lignes ainsi que la première et la dernière ligne, vides. Les requêtes longues se lisent ainsi comme dans un client SQL, sans concaténation. Le saut de ligne conservé entre les deux lignes de la requête est, pour SQL, un séparateur comme un autre ;
- ligne 69 : le paramètre reçoit sa valeur ;
- ligne 71 : l'ordre est exécuté, et chaque ligne du résultat devient un objet [Article] grâce aux deux fonctions d'extension : [resultat.toList { it.toArticle() }]. Ces quelques mots remplacent la liste modifiable, la boucle [while] et l'appel du constructeur de la version Java. Le [use] le plus intérieur rend la liste (la valeur de son bloc), et l'instruction [return], placée dans des lambdas de fonctions [inline], termine la fonction [getArticles] : les trois [use] ferment alors, dans l'ordre, le résultat, l'ordre SQL et la connexion. La liste rendue, elle, ne dépend plus d'aucune ressource JDBC ;
- lignes 77-79 : la fonction [executeUpdate] a un corps d'expression : sa valeur est celle du premier [use], qui est celle du second, qui est le nombre de lignes rendu par [executeUpdate]. On voit là l'intérêt d'un [use] qui rend une valeur.
Les colonnes sont lues par leur nom dans la fonction [toArticle]. Les méthodes [getXxx] acceptent aussi le numéro de la colonne : [getInt(1)], [getString(2)]… L'accès par le nom est plus lisible et résiste mieux à une modification de la requête ; l'accès par le numéro est un peu plus rapide.
Le nom [PreparedStatement] (ordre « préparé ») vient de ce qu'un SGBD peut « préparer » une requête paramétrée, c'est-à-dire la compiler une fois pour toutes. Lorsqu'un SGBD exécute un ordre SQL, il fait un travail d'analyse et d'optimisation avant de l'exécuter (il établit un « plan d'exécution »). Une requête paramétrée est destinée à être exécutée plusieurs fois avec des paramètres différents, mais son texte ne change pas : ce travail peut n'être fait qu'une fois. Avec MySQL Connector/J, la préparation est par défaut simulée par le pilote : il insère lui-même les valeurs des paramètres, correctement protégées, dans le texte de la requête avant de l'envoyer. Le paramètre [useServerPrepStmts=true] de l'URL lui fait utiliser la préparation du serveur MySQL. Dans les deux cas, le code Kotlin est le même.
Les principales correspondances entre les types SQL de MySQL, les méthodes de JDBC et les types Kotlin sont les suivantes :
Type de colonne MySQL | Méthodes JDBC | Type Kotlin |
[INT] | [setInt], [getInt] | [Int] ([java.lang.Integer] pour [getObject]) |
[BIGINT] | [setLong], [getLong] | [Long] |
[DECIMAL(p,d)] | [setBigDecimal], [getBigDecimal] | [BigDecimal] |
[DOUBLE] | [setDouble], [getDouble] | [Double] |
[VARCHAR(n)], chaîne de longueur variable | [setString], [getString] | [String] ([String?] si la colonne accepte [NULL]) |
[DATETIME] | [setObject(i, valeur)], [getObject(i, LocalDateTime::class.java)] | [LocalDateTime] |
[DATE] | [setObject(i, valeur)], [getObject(i, LocalDate::class.java)] | [LocalDate] |
[BOOL] (synonyme de [TINYINT(1)]) | [setBoolean], [getBoolean] | [Boolean] |
Les dates sont représentées par les classes de [java.time] (chapitre 5), que JDBC accepte depuis sa version 4.2 (Java 8) par [setObject] et [getObject]. Les anciennes classes [java.sql.Date], [java.sql.Time] et [java.sql.Timestamp], dérivées de [java.util.Date], et les méthodes [getDate], [getTimestamp] qui les rendent, ne sont plus à utiliser dans du code nouveau. Pour donner la valeur SQL [NULL] à un paramètre, on écrit [setNull(i, Types.INTEGER)], où la classe [java.sql.Types] (ou l'énumération [JDBCType], Java 8) désigne le type SQL du paramètre. Pour lire une colonne entière qui accepte [NULL], on peut écrire en Kotlin [resultat.getInt(i).takeUnless { resultat.wasNull() }], qui rend un [Int?] : la valeur lue, ou [null] si la colonne n'avait pas de valeur.
10.5.2. L'injection SQL
La préparation n'est pas l'unique avantage des requêtes paramétrées, ni le plus important. Considérons la recherche d'un article par son nom. Les gabarits de chaînes de Kotlin rendent très tentante la construction du texte de la requête par programme, en y insérant le nom cherché :
(c'est l'une des lignes du programme [Jdbc05] ci-dessous). Si le nom vaut « vélo », la requête devient :
Mais si le nom est « bouteilles d'oxygène », elle devient :
et elle est syntaxiquement incorrecte, à cause de l'apostrophe du nom. Il y a pire : si le nom provient d'une saisie de l'utilisateur, celui-ci peut taper un texte qui modifie le sens de la requête. C'est l'injection SQL, l'une des failles de sécurité les plus répandues et les plus graves des applications qui utilisent une base de données. Le gabarit ["… '$nom'"] n'est rien d'autre qu'une concaténation écrite plus agréablement : il a exactement le même défaut que le ["… '" + nom + "'"] de Java, et sa lisibilité même le rend plus dangereux, car le code paraît propre. Le programme [Jdbc05.kt] met la faille en évidence sur une recherche d'article par son nom :
Résultats :
Commentaires :
- ligne 16 : trois noms tels qu'un utilisateur pourrait les taper : un nom ordinaire, un nom qui contient une apostrophe, et un texte malveillant ;
- lignes 19 et 21 : chaque nom est passé successivement aux deux fonctions de recherche, désignées par des références de fonctions ;
- ligne 25 : la première fonction de recherche insère le nom dans le texte de la requête par un gabarit de chaîne (ligne 26), exécuté par un [Statement] ordinaire :
- ligne 3 des résultats : avec un nom ordinaire, tout se passe bien ;
- ligne 6 : l'apostrophe du nom termine prématurément la chaîne SQL ; la requête est incorrecte (ligne 7). Un article dont le nom contient une apostrophe ne peut pas être trouvé ;
- ligne 8 : l'utilisateur a tapé un texte qui contient des apostrophes placées de façon à produire une requête correcte, mais dont la condition [nom = 'x' or '1'='1'] est vraie pour toutes les lignes : la requête rend toute la table (ligne 15). Si la requête vérifiait un nom d'utilisateur et un mot de passe, le même procédé permettrait de se connecter sans connaître le mot de passe. Avec un pilote qui accepte plusieurs ordres SQL dans une même requête, l'utilisateur pourrait même ajouter un ordre de son choix après un point-virgule, par exemple un [delete]. Connector/J le refuse par défaut (paramètre [allowMultiQueries=false]), ce qui limite la portée de l'injection sans la supprimer ;
- ligne 31 : le résultat est passé à la fonction [afficher] par une référence de fonction : [use(::afficher)] équivaut à [use { afficher(it) }] ;
- ligne 40 : la seconde fonction utilise une requête paramétrée (lignes 44-45). La valeur du paramètre n'est jamais interprétée comme du SQL : c'est une donnée, quel que soit son contenu. Le nom avec apostrophe est trouvé (ligne 22 des résultats) et le texte malveillant est simplement un nom d'article qui n'existe pas (ligne 25) ;
- lignes 55-59 : l'affichage du résultat utilise la fonction d'extension [asSequence] du paquetage [commun]. La séquence transforme chaque ligne en chaîne ; [onEach] affiche chaque chaîne au passage et rend la même séquence, que [count] parcourt jusqu'au bout pour compter ses éléments. Comme une séquence est paresseuse, c'est [count] qui provoque la lecture des lignes : chacune est lue, affichée et comptée avant que la suivante soit lue, sans liste intermédiaire. Le parcours a lieu pendant l'appel de [afficher], c'est-à-dire dans le bloc du [use] qui ferme le résultat, comme il se doit.
On pourrait « neutraliser » les apostrophes en les doublant, mais les règles dépendent du SGBD et il est facile d'oublier un cas. La règle est simple : les valeurs fournies par l'utilisateur (ou par toute source extérieure) ne doivent jamais être insérées dans le texte d'une requête SQL, ni par concaténation, ni par un gabarit de chaîne : on utilise toujours un PreparedStatement et des paramètres. Les gabarits restent utiles pour les parties fixes d'une requête choisies par le programme lui-même (un nom de table ou de colonne pris dans une liste connue, par exemple), jamais pour des valeurs. Le programme interactif [Jdbc01.kt] ne respecte pas cette règle, par nature : il exécute les ordres SQL tapés par l'utilisateur. C'est un outil d'administrateur, pas un exemple à suivre.
10.6. Les transactions
10.6.1. Généralités
Une transaction est une suite d'ordres SQL exécutée de façon « atomique » :
- soit toutes les opérations réussissent ;
- soit l'une d'elles échoue, et alors toutes celles qui l'ont précédée sont annulées.
Au final, les opérations d'une transaction ont soit toutes été appliquées avec succès, soit aucune ne l'a été. L'exemple classique est le virement bancaire : le débit d'un compte et le crédit d'un autre doivent être faits tous les deux ou pas du tout. Lorsque l'application a elle-même la maîtrise de la transaction, elle la valide par un ordre [COMMIT] ou l'annule par un ordre [ROLLBACK].
Dans nos exemples précédents, nous n'avons pas utilisé de transaction. Et pourtant il y en avait, car dans un SGBD un ordre SQL s'exécute toujours au sein d'une transaction. Si l'application ne démarre pas elle-même une transaction explicite, le SGBD utilise une transaction implicite. Il y a alors deux cas :
- chaque ordre SQL fait l'objet d'une transaction, ouverte par le SGBD avant l'ordre et validée après. On dit qu'on est en mode autocommit. Tout se passe comme si l'application faisait une transaction pour chaque ordre SQL ;
- le SGBD n'est pas en mode autocommit : il commence une transaction implicite au premier ordre SQL émis en dehors d'une transaction et laisse l'application la terminer. Tous les ordres SQL émis ensuite font partie de cette transaction implicite, qui peut se terminer sur différents événements (fermeture de la connexion, début d'une nouvelle transaction…), selon des règles propres au SGBD. C'est un mode à éviter.
JDBC impose le premier cas : une connexion JDBC est en mode autocommit lorsqu'elle est ouverte, quel que soit le réglage du SGBD (MySQL, avec le moteur de stockage InnoDB utilisé par défaut, est d'ailleurs lui aussi en mode autocommit).
Les ordres SQL des différents utilisateurs s'exécutent en même temps, dans des transactions qui travaillent en parallèle. Les opérations faites par une transaction peuvent affecter celles faites par une autre. On distingue quatre niveaux d'étanchéité (d'isolation) entre les transactions des différents utilisateurs :
- Read Uncommitted ;
- Read Committed ;
- Repeatable Read ;
- Serializable.
Read Uncommitted
Ce mode d'isolation est également appelé « Dirty Read ». Voici un exemple de ce qui peut se passer dans ce mode :
- un utilisateur U1 commence une transaction sur une table T ;
- un utilisateur U2 commence une transaction sur cette même table T ;
- l'utilisateur U1 modifie des lignes de la table T mais ne les valide pas encore ;
- l'utilisateur U2 « voit » ces modifications et prend des décisions à partir de ce qu'il voit ;
- l'utilisateur U1 annule sa transaction par un [ROLLBACK].
À l'étape 4, l'utilisateur U2 a pris une décision à partir de données qui se révèlent fausses ensuite.
Read Committed
Ce mode d'isolation évite l'écueil précédent : à l'étape 4, l'utilisateur U2 ne « voit » pas les modifications apportées par l'utilisateur U1 à la table T. Il ne les verra qu'après que U1 aura validé sa transaction par un [COMMIT]. Dans ce mode, on peut néanmoins rencontrer la situation suivante :
- un utilisateur U1 commence une transaction sur une table T ;
- un utilisateur U2 commence une transaction sur cette même table T ;
- l'utilisateur U2 fait un [select] pour obtenir la moyenne d'une colonne C des lignes de T vérifiant une certaine condition ;
- l'utilisateur U1 modifie ([update]) certaines valeurs de la colonne C de T et les valide ([commit]) ;
- l'utilisateur U2 refait le même [select] qu'à l'étape 3. Il découvre que la moyenne de la colonne C a changé à cause des modifications faites par U1.
L'utilisateur U2 ne voit plus que les modifications validées par U1, mais alors qu'il reste dans la même transaction, deux opérations identiques donnent des résultats différents. On appelle cette situation « Non-Repeatable Read ». Elle est ennuyeuse pour qui désire avoir une image stable de la table T.
Repeatable Read
Dans ce mode d'isolation, un utilisateur est assuré d'obtenir les mêmes résultats pour ses lectures de la base tant qu'il reste dans la même transaction. Il travaille sur une « photographie » des données sur laquelle ne sont pas répercutées les modifications apportées par les autres transactions, même validées. Il ne les verra que lorsqu'il aura lui-même terminé sa transaction.
Ce mode d'isolation n'est pas encore parfait en théorie : une autre transaction peut ajouter ([insert]) des lignes vérifiant la condition de l'étape 3, qui changeraient le résultat d'une nouvelle lecture. On appelle ces lignes des lignes fantômes (« phantom read »). C'est le niveau d'isolation par défaut de MySQL (InnoDB), qui évite d'ailleurs aussi les lignes fantômes pour les lectures simples.
Serializable
Dans ce mode d'isolation, les transactions sont complètement étanches les unes des autres : le résultat de deux transactions menées simultanément est le même que si elles avaient été faites l'une après l'autre. Pour y parvenir, le SGBD verrouille les données lues et fait attendre (ou échouer) les transactions qui voudraient les modifier. Ce niveau diminue le parallélisme et donc les performances.
Les quatre niveaux d'isolation ne sont pas disponibles dans tous les SGBD. Le niveau par défaut est en général Read Committed (SQL Server, PostgreSQL, Oracle) ou Repeatable Read (MySQL). Le niveau désiré peut être indiqué explicitement par l'application.
10.6.2. L'API de gestion des transactions
Avec JDBC, les transactions sont gérées par la connexion. Il n'y a pas d'objet « transaction » : une connexion a au plus une transaction en cours, et tous les ordres SQL exécutés sur cette connexion en font partie. Les méthodes de l'interface [Connection] concernées sont les suivantes. Kotlin voit les couples d'accesseurs Java [getXxx] / [setXxx] comme des propriétés (chapitre 4) : [connexion.setAutoCommit(false)] s'écrit [connexion.autoCommit = false], et [connexion.getAutoCommit()] s'écrit [connexion.autoCommit]. La colonne de gauche donne la forme Kotlin :
Méthode ou propriété | Rôle |
[autoCommit] | [autoCommit = false] ([setAutoCommit(false)] en Java) quitte le mode autocommit : les ordres SQL qui suivent font partie d'une transaction, qui commence avec le premier d'entre eux et se termine par [commit] ou [rollback]. Une nouvelle transaction commence alors avec l'ordre suivant. [autoCommit = true] revient au mode autocommit, en validant la transaction en cours. |
[commit()] | valide la transaction : les résultats des ordres SQL émis dans la transaction deviennent définitifs et visibles des autres transactions. |
[rollback()] | annule la transaction : les résultats des ordres SQL émis dans la transaction sont défaits. |
[setSavepoint()] | pose un point de sauvegarde (« savepoint », un objet [Savepoint]) dans la transaction en cours. Ce n'est pas un accesseur (il n'a pas de [getSavepoint] associé) : Kotlin l'appelle comme une fonction. |
[rollback(Savepoint)] | annule les ordres SQL exécutés depuis le point de sauvegarde, sans annuler ceux qui le précèdent : la transaction continue. |
[transactionIsolation] | le niveau d'isolation des transactions de la connexion ([setTransactionIsolation] en Java). Il doit être fixé avant le début d'une transaction. |
Le niveau d'isolation est l'une des constantes de l'interface [Connection] :
Valeur | Signification |
[TRANSACTION_READ_UNCOMMITTED] | la transaction peut lire des données écrites par une autre transaction que celle-ci n'a pas encore validées. À éviter. |
[TRANSACTION_READ_COMMITTED] | la transaction ne peut pas lire des données écrites par une autre transaction que celle-ci n'a pas encore validées. Les données lues deux fois de suite dans la transaction peuvent cependant changer, car une autre transaction a pu les modifier entre-temps ; une autre transaction a aussi pu ajouter des lignes (lignes fantômes). |
[TRANSACTION_REPEATABLE_READ] | les données lues par la transaction ne changent pas tant qu'elle n'est pas terminée. Cela n'évite pas, en théorie, l'ajout de lignes par une autre transaction. |
[TRANSACTION_SERIALIZABLE] | tout se passe comme si la transaction était seule. Diminue les performances, car les transactions ne travaillent plus en parallèle. |
Ce sont des constantes [static] d'une interface Java : Kotlin les désigne par le nom de l'interface, [Connection.TRANSACTION_READ_COMMITTED], comme les membres d'un [companion object].
Une connexion qui a quitté le mode autocommit doit être fermée après la validation ou l'annulation de sa transaction. Si elle est fermée avec une transaction en cours, le résultat dépend du pilote (MySQL Connector/J l'annule) : il faut toujours terminer explicitement une transaction.
10.6.3. Le programme exemple
Le programme [Jdbc06.kt] montre l'effet d'une transaction :
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 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 | |
Résultats :
Commentaires :
- lignes 21-23 : une liste de deux articles de même nom, [article] ([Article] est la classe de données du paquetage [commun]). La contrainte d'unicité [UNQ_NOM] de la table [articles] empêche qu'ils soient présents en même temps dans la base : l'insertion du second va provoquer une [SQLException]. Pour montrer l'effet d'une transaction, les deux articles vont être insérés de quatre façons :
- d'abord en dehors de toute transaction explicite. La connexion étant en mode autocommit, chaque insertion est faite dans sa propre transaction. Le premier article va être inséré, le second ne le sera pas ;
- ensuite dans une transaction englobant les deux insertions. Parce que la seconde insertion va échouer, la première sera défaite : au final, aucune insertion ne sera faite ;
- puis dans une transaction avec un point de sauvegarde avant chaque insertion : l'échec de la seconde insertion n'annulera qu'elle-même, et la transaction sera validée avec la première ;
- enfin dans une transaction gérée par une fonction d'ordre supérieur, [transaction], que nous présentons au paragraphe suivant ;
- lignes 27-35 : la table est vidée, puis on insère les deux articles sans transaction explicite. Comme on sait que la seconde insertion va lancer une exception, celle-ci est interceptée par un [try] / [catch] ; enfin la table est affichée ;
- lignes 37-40 : on refait la même séquence, mais avec une transaction. L'exception est cette fois gérée dans la fonction [insertArticlesInTransaction] ;
- lignes 43-46 : de même avec des points de sauvegarde ;
- ligne 65 : la fonction d'insertion sans transaction. Elle utilise deux fonctions, [creerOrdreInsert] (ligne 137), qui crée l'ordre paramétré d'insertion, et [insererArticle] (ligne 142), qui donne leur valeur aux paramètres et exécute l'ordre. Les deux [use] imbriqués ferment l'ordre puis la connexion, comme le faisait le try-with-resources de Java ;
- ligne 74 : la fonction d'insertion dans une transaction :
- ligne 77 : le niveau d'isolation [TRANSACTION_READ_COMMITTED], fixé avant le début de la transaction, par une affectation de propriété ;
- ligne 78 : la connexion quitte le mode autocommit : la transaction commencera avec la première insertion ;
- lignes 80-81 : les insertions ;
- ligne 84 : tout s'est bien passé : la transaction est validée, les insertions sont définitivement intégrées à la base ;
- lignes 86-89 : une insertion a échoué : la transaction est annulée ;
- ligne 96 : la fonction d'insertion avec des points de sauvegarde :
- ligne 103 : un point de sauvegarde est posé avant chaque insertion. [setSavepoint] n'est pas l'accesseur d'une propriété (elle n'a pas de paramètre) : on l'appelle comme une fonction ;
- ligne 108 : si l'insertion échoue, on revient au point de sauvegarde : seule l'insertion ratée est défaite, et la boucle continue ;
- ligne 113 : la transaction est validée, avec les insertions qui ont réussi ;
- lignes 142-149 : la fonction [with] de la bibliothèque standard exécute son bloc avec l'article pour receveur : dans le bloc, [nom] désigne [article.nom]. La fonction [afficherArticles] l'utilise de la même façon avec le résultat de la requête (ligne 171), ce qui évite de répéter cinq fois [resultat.] ;
- ligne 154 : comme toutes les fonctions du programme, [executeUpdate] ne déclare aucune exception : Kotlin n'a pas d'exceptions contrôlées (chapitre 3), et la clause [throws SQLException] que Java imposait à chaque méthode disparaît. Les [SQLException] remontent jusqu'au [try] de la fonction [main] (ligne 59).
Dans les résultats :
- ligne 5 : affichée par l'ordre [delete] : il y avait six lignes dans la table ;
- ligne 7 : l'exception provoquée par la seconde insertion. Le message indique que la contrainte [UNQ_NOM] de la table [articles] n'a pas été respectée ;
- ligne 9 : la table n'est pas vide : le premier article a été inséré ;
- ligne 12 : affichée par l'ordre [delete] : il y avait une ligne dans la table ;
- ligne 13 : la première insertion a réussi… dans la transaction ;
- ligne 14 : le message affiché lorsque la transaction échoue ;
- ligne 15 : la table est vide : le [rollback] de la transaction a défait la première insertion ;
- lignes 19-21 : avec les points de sauvegarde, la seconde insertion a été annulée et la transaction validée ;
- ligne 23 : la table contient le premier article. Son [id] est 11 et non 8 : chaque tentative d'insertion, même annulée ou refusée, a consommé une valeur de la colonne [AUTO_INCREMENT], et MySQL ne réutilise pas ces valeurs.
Avec MySQL, l'échec d'un ordre SQL n'annule que cet ordre, et la transaction peut continuer : le point de sauvegarde n'est donc pas indispensable ici. Ce n'est pas le cas de tous les SGBD : avec PostgreSQL, la moindre erreur rend la transaction inutilisable jusqu'à son annulation, ou jusqu'au retour à un point de sauvegarde. Le code avec points de sauvegarde fonctionne avec les deux.
10.6.4. Une fonction d'ordre supérieur : transaction { }
Les fonctions [insertArticlesInTransaction] et [insertArticlesWithSavepoint] répètent le même schéma : quitter le mode autocommit, faire le travail, valider, annuler en cas d'exception. Ce schéma est toujours le même, seul le travail change : c'est le cas d'usage type d'une fonction d'ordre supérieur (chapitre 7), qui reçoit le travail sous la forme d'une lambda, exactement comme [use] reçoit le travail à faire avant la fermeture d'une ressource. Nous l'écrivons une fois pour toutes, sous la forme d'une fonction d'extension de [Connection], dans le fichier [Transactions.kt] du paquetage [commun] :
- ligne 11 : la fonction [transaction] est une fonction d'extension de l'interface [Connection] : on l'appelle comme une méthode de la connexion, [connexion.transaction { … }]. Elle a deux paramètres :
- [isolation], le niveau d'isolation, de type [Int?] et de valeur par défaut [null] (le niveau de la connexion est alors inchangé) ;
- [bloc], le travail à faire dans la transaction. Son type, [Connection.() -> T], est celui d'une lambda avec receveur : dans le bloc, [this] est la connexion, et l'on peut écrire [prepareStatement(…)] au lieu de [connexion.prepareStatement(…)]. C'est le mécanisme qu'utilisent [with], [apply] ou [buildList] de la bibliothèque standard. Le bloc rend une valeur de type [T], que [transaction] rend à son tour. Le bloc étant le dernier paramètre, il s'écrit après les parenthèses de l'appel, ou sans parenthèses du tout s'il est le seul argument ;
- [inline] : le code de la fonction et celui du bloc sont recopiés à l'endroit de l'appel (chapitre 7) : aucun objet lambda n'est créé à chaque transaction. Mais une lambda passée à une fonction [inline] peut contenir un [return] qui quitte la fonction appelante (un retour « non local », comme dans le corps d'une boucle). Ici, ce serait un piège : un [return] au milieu du bloc sortirait du [try] sans passer ni par [commit] ni par [rollback], et la transaction resterait en suspens. Le modificateur [crossinline] du paramètre [bloc] l'interdit. Le compilateur refuse alors un bloc comme [connexion.transaction { if (nom.isEmpty()) return … }] :
Le bloc se termine donc toujours par la valeur qu'il rend (un [return@transaction valeur] reste permis : il ne quitte que la lambda) ;
- ligne 13 : si un niveau d'isolation est donné, il est fixé avant le début de la transaction ;
- lignes 14-15 : on mémorise le mode de la connexion, puis on quitte le mode autocommit ;
- lignes 17-19 : le bloc est exécuté, puis la transaction est validée et le résultat du bloc rendu ;
- lignes 20-27 : si le bloc lance une exception, quelle qu'elle soit, la transaction est annulée et l'exception relancée : l'appelant la reçoit comme si la fonction [transaction] n'existait pas. Si le [rollback] échoue lui-même (connexion perdue), son exception ne doit pas masquer celle d'origine, plus intéressante : elle lui est attachée comme exception supprimée ([addSuppressed]), comme le fait le try-with-resources de Java ou la fonction [use] de Kotlin pour les exceptions de [close] ;
- lignes 28-29 : le mode autocommit est rétabli dans tous les cas. C'est utile avec un pool de connexions : la connexion rendue au pool retrouve son état initial.
Le programme [Jdbc06] utilise cette fonction dans sa quatrième partie :
- lignes 2-11 : le code de la transaction se réduit au travail à faire. Le bloc reçoit la connexion pour receveur ([creerOrdreInsert(this)]), et rend le nombre d'articles insérés, qui devient la valeur de [connexion.transaction(…)]. Aucune exception n'est interceptée : en cas d'échec, la transaction est annulée et l'exception transmise à l'appelant ;
- dans la partie 4 de [main] (premier listing ci-dessus), la seconde insertion de la liste de deux articles échoue : la fonction [transaction] annule la transaction et relance l'exception, que l'appelant intercepte ; il affiche « transaction invalidée » (ligne 28 des résultats) ; la table est restée vide, puisque l'insertion de la ligne 27 a été défaite. L'insertion d'une liste d'un seul article, [articles.take(1)], réussit (lignes 29-30) : la table contient l'article (ligne 32).
La fonction [transaction] est dix lignes de code, mais elle exprime en Kotlin ce que proposent les cadriciels : Spring offre [TransactionTemplate] et l'annotation [@Transactional] (chapitre 11), et la bibliothèque Exposed de JetBrains, présentée en conclusion, a justement une fonction [transaction { … }]. Nous la réutiliserons dans les programmes [Jdbc08] et [Jdbc09].
10.7. Les requêtes qui rendent une valeur unique
Certaines requêtes ne rendent qu'une valeur, comme [select count(*) from articles] : une ligne d'une colonne. JDBC n'a pas de méthode particulière pour elles : on exécute la requête par [executeQuery] et on lit la première colonne de la première ligne du résultat. Le programme [Jdbc07.kt] écrit deux fonctions pour cela et en montre quelques utilisations :
Résultats :
Commentaires :
- ligne 53 : la fonction [valeurUnique] exécute la requête et rend la première colonne de la première ligne par [getObject(1)], ou [null] si la requête ne rend aucune ligne (ligne 57). Son type de retour est [Any?] : n'importe quel objet, ou [null]. Le corps de la fonction est une expression : les trois [use] imbriqués rendent la valeur de leur bloc, et la valeur du [if] devient celle de la fonction ;
- ligne 16 : le prix moyen des articles. Le type du résultat dépend du type SQL de la valeur : la moyenne d'une colonne [DECIMAL] est un [BigDecimal], ici de six décimales (ligne 2 des résultats). La fonction [type] (ligne 74) rend le nom simple de la classe de l'objet, [it::class.simpleName], ou « null » ;
- ligne 18 : le nombre d'articles. MySQL rend un entier de type [BIGINT], qui devient un [java.lang.Long], que Kotlin nomme [Long] (ligne 3) ;
- ligne 20 : le nom de l'article le plus cher, une chaîne de caractères. La requête contient une sous-requête, entre parenthèses ;
- ligne 22 : si la requête ne rend aucune ligne, la fonction rend [null] (ligne 5) ;
- ligne 24 : si la requête rend une ligne dont la valeur est SQL [NULL] (le maximum d'un ensemble vide), [getObject] rend aussi [null] (ligne 6). Notre fonction ne distingue donc pas les deux cas, pas de ligne ou une valeur [NULL] ; si la distinction importe, on teste soi-même le résultat de [next] ;
- ligne 64 : une seconde fonction, générique, rend une valeur du type [T] demandé, ou [null]. En Java, la méthode équivalente devait recevoir le type sous la forme d'un paramètre [Class<T> type], car les types génériques sont effacés à la compilation : à l'exécution, la méthode ne sait pas ce qu'est [T]. Une fonction [inline] dont le paramètre de type est [reified] (« réifié », chapitre 4) n'a pas cette limite : son code est recopié à chaque appel avec le vrai type, et [T::class.java] (ligne 68) est la classe Java de ce type, que [getObject(1, type)] utilise pour demander au pilote de convertir la valeur. L'appel s'écrit simplement [valeurUniqueTypee<BigDecimal>(…)] ;
- lignes 28-30 : le pilote convertit lui-même la valeur dans le type demandé : la moyenne en [BigDecimal], le nombre d'articles ([BIGINT]) en [Int]. Le résultat étant de type [BigDecimal?], l'appel de [setScale], qui arrondit la moyenne à deux décimales, passe par l'opérateur [?.] (ligne 7) ;
- lignes 32-33 : l'opérateur Elvis [?:] remplace l'absence de valeur par une valeur par défaut : [maximum] est de type [BigDecimal], non nullable (ligne 8) ;
- ligne 37 : le transtypage sûr [as?] rend [null] au lieu de lancer une exception lorsque l'objet n'est pas du type demandé : [nbArticles] n'est pas un [Int], mais c'est un [Long] (ligne 9) ;
- lignes 39-40 : après le test [is Number], [nbArticles] est automatiquement transtypé en [Number] dans le bloc du [if] (transtypage intelligent, chapitre 4), et l'on peut appeler [toInt]. C'est la façon robuste de traiter une valeur numérique dont on ne connaît pas le type exact (ligne 10) ;
- ligne 43 : un transtypage direct échoue, car l'objet est un [java.lang.Long] : sur la JVM, un [Int] rangé dans une variable de type [Any?] est un [java.lang.Integer], et un [Long] n'en est pas un. Une [ClassCastException] est lancée (ligne 11) : son message parle des classes Java [java.lang.Long] et [java.lang.Integer]. Ce code fonctionnerait avec un SGBD dont le [count] rend un entier de type [INT] et échoue avec MySQL : c'est un exemple des particularités des SGBD que JDBC ne cache pas. La lecture par [getInt], [getLong] ou par la fonction [valeurUniqueTypee], qui laisse la conversion au pilote, est plus sûre ;
- ligne 45 : Kotlin n'a pas de clause [catch] multiple comme le [catch (SQLException | ClassCastException ex)] de Java : on intercepte ici leur classe commune, [Exception]. Pour traiter différemment plusieurs types, on écrit plusieurs clauses [catch], ou une seule suivie d'un [when (ex)] avec des branches [is SQLException ->].
Pourquoi deux noms, [valeurUnique] et [valeurUniqueTypee], et non deux surcharges de [valeurUnique] comme en Java ? Parce que les deux fonctions auraient la même signature pour la JVM : le paramètre de type [T] étant effacé, la fonction générique devient, comme l'autre, une fonction qui reçoit un [String] et rend un [Object]. Le compilateur refuse les deux déclarations :
En Java, les deux méthodes se distinguaient par le paramètre [Class<T>] de la seconde, que le paramètre réifié rend inutile en Kotlin. (L'annotation [@JvmName("…")] permettrait de donner un autre nom JVM à l'une d'elles, mais un nom différent est ici plus clair.)
10.8. JDBC est bloquant : threads virtuels et coroutines
10.8.1. Les threads virtuels
Le chapitre 9 a montré que les opérations d'entrées / sorties ne doivent pas immobiliser un thread de l'application dans les applications qui servent de nombreux utilisateurs (serveurs, services web) : pendant qu'un ordre SQL s'exécute sur le serveur, le thread qui l'a émis attend. Or JDBC est une API bloquante : [getConnection], [executeQuery], [next], [commit]… ne rendent la main que lorsque le SGBD a répondu. Il n'existe pas de version asynchrone de JDBC : un projet d'API asynchrone (ADBA) a été abandonné en 2019, et l'API réactive R2DBC, qui existe en dehors du JDK, n'a pas remplacé JDBC. Il n'existe pas davantage de version « suspendue » de JDBC pour les coroutines : une fonction JDBC appelée dans une coroutine bloque le thread qui exécute la coroutine.
Depuis Java 21, la réponse de la plateforme Java est le thread virtuel (chapitre 8) : un thread léger, géré par la JVM, dont on peut créer des milliers. Lorsqu'un thread virtuel attend une réponse du réseau, la JVM le met de côté et libère le thread du système qui l'exécutait, qui peut alors exécuter un autre thread virtuel. Le code reste séquentiel et bloquant, simple à écrire et à lire, mais l'attente ne coûte plus rien. Les pilotes JDBC récents, dont Connector/J 9, sont écrits pour fonctionner avec les threads virtuels, et depuis Java 24 (JEP 491) un thread virtuel qui attend dans un bloc [synchronized] ne monopolise plus le thread du système. Kotlin, qui s'exécute sur la JVM, en profite sans rien faire de particulier.
Il reste une limite : le nombre de connexions. Mille threads virtuels qui interrogent la base n'ont pas mille connexions : ils se partagent celles du pool, et attendent qu'une connexion se libère. Le programme [Jdbc08.kt] le montre, avec un pool HikariCP de quatre connexions :
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 105 106 | |
Résultats :
Commentaires :
- lignes 22-26 : la configuration d'un pool d'au plus quatre connexions. Dans le bloc de [apply], [this] est l'objet [HikariConfig] : les accesseurs [setJdbcUrl], [setUsername]… deviennent des affectations de propriétés ;
- ligne 30 : la source de données [HikariDataSource], fermée à la fin du programme par [use] ;
- lignes 31-39 : un ordre de mise à jour dans une transaction, sur une connexion obtenue du pool ([dataSource.connection], la propriété qui correspond à [getConnection()]). La transaction est gérée par la fonction [transaction] du paquetage [commun] : le bloc rend le nombre de lignes modifiées, qui devient la valeur de [nbLignes]. La fermeture de la connexion par [use] la rend au pool. Les résultats montrent que les trois prix inférieurs à 200 euros ont été augmentés de 10 % (ligne 2 des résultats) ;
- ligne 95 : la fonction [getArticleLentement] lit un article dans une requête qui dure une seconde : la fonction MySQL [sleep(1)] (ligne 98) attend une seconde avant de rendre 0. Elle simule une requête lente. La ligne courante du résultat devient un [Article] par la fonction d'extension [toArticle] du paquetage [commun] ;
- lignes 42-50 : six lectures d'articles, exécutées en même temps :
- ligne 43 : la fonction [measureTime] de la bibliothèque standard ([kotlin.time]) exécute son bloc et rend sa durée, un objet [Duration] ; [duree.toDouble(DurationUnit.SECONDS)] l'exprime en secondes. C'est plus lisible que les différences de [System.nanoTime()] du code Java ;
- ligne 44 : [Executors.newVirtualThreadPerTaskExecutor] (Java 21, chapitre 8) crée un exécuteur qui lance chaque tâche dans un nouveau thread virtuel. Un [ExecutorService] est [AutoCloseable] depuis Java 19 : [use] le ferme à la fin du bloc (ligne 46), et sa méthode [close] attend la fin de toutes les tâches. La valeur du bloc de [use], la liste des tâches, devient celle de [taches] ;
- ligne 45 : [map] transforme les identifiants 1 à 6 en six tâches soumises à l'exécuteur. Chaque tâche est une lambda convertie en [Callable<Article>] (conversion SAM, chapitre 7). L'interface est nommée explicitement, [Callable { … }], car [submit] existe aussi pour un [Runnable]. [submit] rend un [Future<Article>]. La lambda utilise directement la variable [id] : contrairement à Java, qui exigeait une copie de la variable de boucle, Kotlin n'a pas cette contrainte, et [id] est de toute façon un paramètre de lambda, différent pour chaque élément ;
- ligne 47 : on affiche les articles dans l'ordre des tâches. [Future.get] rend le résultat de la tâche ; si la tâche a lancé une exception, [get] lance une [ExecutionException] qui l'encapsule (interceptée ligne 89). En Java, [get] obligeait aussi à déclarer ou intercepter l'exception contrôlée [InterruptedException] ; Kotlin n'impose rien ;
- lignes 49-50 : la durée totale. Six requêtes d'une seconde exécutées l'une après l'autre dureraient six secondes ; exécutées en même temps avec six connexions, une seconde. Avec quatre connexions, il faut environ deux secondes (ligne 9 des résultats) : quatre threads virtuels obtiennent une connexion et exécutent leur requête en même temps, les deux autres attendent dans [getConnection] qu'une connexion soit rendue au pool, puis exécutent la leur. Le reste du temps est celui de l'ouverture des connexions du pool. Un thread qui attend une connexion trop longtemps (30 secondes par défaut, réglage [connectionTimeout] de HikariCP) reçoit une [SQLTransientConnectionException]. La fonction [format] utilise la locale de la machine, d'où la virgule décimale ;
- lignes 52-57 : une valeur unique, la valeur du stock ;
- lignes 62-84 : l'abandon d'une requête trop longue :
- lignes 65-68 : une première connexion quitte le mode autocommit et modifie l'article n° 1 sans terminer sa transaction. Le SGBD a verrouillé la ligne modifiée jusqu'à la fin de la transaction ;
- lignes 71-81 : une seconde connexion veut modifier la même ligne. Elle doit attendre que la première transaction se termine… ce qu'elle ne fera pas. [ordre.queryTimeout = 1] (ligne 75, [setQueryTimeout(1)] en Java) limite à une seconde la durée d'exécution de l'ordre : au bout d'une seconde, Connector/J demande au serveur d'interrompre la requête (ordre MySQL [KILL QUERY]), et [executeUpdate] lance une [SQLTimeoutException], ici de la classe [MySQLTimeoutException] du pilote (ligne 11 des résultats). Sans ce délai, la requête aurait attendu le délai d'attente de verrou de MySQL, 50 secondes par défaut, puis échoué. Le chronomètre est une marque de temps ([TimeSource.Monotonic.markNow()]) dont [elapsedNow] donne le temps écoulé ;
- ligne 84 : la transaction [bloquante] n'est jamais validée : elle est annulée. La connexion est ensuite rendue au pool ;
- lignes 87-89 : deux clauses [catch], qui remplacent la clause multiple de Java.
Les threads virtuels n'apportent rien à un petit programme console qui fait une chose à la fois : ils ne rendent pas une requête plus rapide. Mais ils permettent à une application serveur, comme les services web Spring Boot du chapitre 13 (où il suffit de la propriété [spring.threads.virtual.enabled=true]), de servir de nombreux utilisateurs à la fois avec un code JDBC ordinaire. La taille du pool de connexions devient alors le vrai réglage du parallélisme vis-à-vis de la base.
10.8.2. Les coroutines
Une application Kotlin écrite avec des coroutines (chapitre 9) rencontre le même problème : JDBC n'a pas de fonctions de suspension. Appeler [executeQuery] dans une coroutine exécutée par le dispatcher [Dispatchers.Default] bloquerait l'un des rares threads de ce dispatcher (autant que de cœurs du processeur), réservé aux calculs. La règle vue au chapitre 9 s'applique : un appel bloquant est enveloppé dans [withContext(Dispatchers.IO) { … }], qui l'exécute sur un thread du dispatcher [IO], conçu pour attendre, et suspend la coroutine appelante pendant ce temps. On obtient une fonction de suspension qui cache l'appel bloquant. Le programme [Jdbc08Coroutines.kt] reprend avec des coroutines la partie 2 du programme précédent, avec trois dispatchers différents. Il utilise la bibliothèque kotlinx-coroutines, déclarée dans le fichier [build.gradle.kts] du sous-projet par [implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")] :
Résultats :
Commentaires :
- ligne 70 : la lecture lente d'un article est maintenant une fonction de suspension. Son corps est un appel de [withContext(dispatcher)] (ligne 75) : le code JDBC, identique à celui du programme précédent, s'exécute sur un thread du dispatcher reçu en paramètre, pendant que la coroutine appelante est suspendue. La valeur rendue par le bloc, l'article, est celle de [withContext], et donc de la fonction. En pratique, le dispatcher serait fixé dans la fonction ([withContext(Dispatchers.IO)]) ; il est ici un paramètre pour comparer plusieurs dispatchers ;
- ligne 54 : la fonction [lireSixArticles] lit les articles 1 à 6 de façon concurrente :
- lignes 60-61 : dans une portée [coroutineScope] (concurrence structurée, chapitre 9), six coroutines sont lancées par [async], puis [awaitAll] attend leurs six résultats. Si l'une d'elles échouait, les autres seraient annulées et l'exception transmise à l'appelant ;
- ligne 63 : les noms des articles, dans l'ordre des identifiants (lignes 2, 4 et 6 des résultats) ;
- ligne 24 : le programme principal est une coroutine, exécutée par [runBlocking] ;
- ligne 34 : les six coroutines exécutent leur code JDBC sur les threads du dispatcher [IO] (64 threads au plus). Comme avec les threads virtuels, quatre requêtes s'exécutent en même temps et deux attendent une connexion du pool : environ deux secondes (ligne 3 des résultats), plus le temps d'ouverture des connexions du pool, qui ne se produit qu'une fois ;
- ligne 36 : [limitedParallelism(4)] crée une vue du dispatcher [IO] qui n'exécute pas plus de quatre blocs à la fois (chapitre 9). Avec un parallélisme égal à la taille du pool, aucun thread n'attend dans [getConnection] : les coroutines en surnombre attendent, suspendues, qu'un des quatre emplacements se libère, ce qui ne coûte aucun thread. C'est la bonne pratique lorsque de nombreuses coroutines accèdent à la base : un dispatcher dont le parallélisme est celui du pool de connexions. La durée est la même (ligne 5) ;
- lignes 38-39 : un dispatcher qui exécute chaque bloc dans un nouveau thread virtuel, construit à partir de l'exécuteur de Java par [asCoroutineDispatcher] (chapitre 9) et fermé par [use]. Coroutines et threads virtuels se combinent : les coroutines structurent le code, les threads virtuels rendent l'attente bon marché (ligne 7) ;
- lignes 41-49 : une mise en garde. [withTimeout(300)] annule la coroutine au bout de 300 ms, et l'exception [TimeoutCancellationException] est bien lancée (ligne 8 des résultats)… mais au bout d'une seconde (ligne 9). L'annulation des coroutines est coopérative (chapitre 9) : elle n'est constatée qu'aux points de suspension. Or le code JDBC, bloqué dans [executeQuery], n'en a aucun : il va jusqu'au bout de sa requête, et [withContext] ne rend la main qu'ensuite, en lançant l'exception. La requête n'a pas été interrompue sur le serveur. Pour limiter vraiment la durée d'une requête, c'est le délai de JDBC qu'il faut utiliser, [ordre.queryTimeout = n], comme dans le programme précédent.
On retiendra que les coroutines n'ajoutent pas d'asynchronisme à JDBC : elles déplacent les appels bloquants sur des threads prévus pour attendre, ce qui permet d'intégrer l'accès à la base dans une application écrite avec des coroutines (un serveur Ktor, ou les contrôleurs à fonctions de suspension de Spring, par exemple). Le gain réel en nombre d'utilisateurs servis vient, comme en Java, des threads virtuels et de la taille du pool. Le chapitre 9 a comparé plus généralement les deux modèles.
10.9. L'exécution par lots
Chaque exécution d'un ordre SQL est un aller-retour entre l'application et le serveur. Lorsqu'on doit exécuter plusieurs ordres SQL, on peut les envoyer au SGBD en un seul échange, sous la forme d'un lot (« batch »). JDBC le permet depuis sa version 2.0 :
- pour un [Statement], [addBatch(sql)] ajoute un ordre SQL au lot ;
- pour un [PreparedStatement], [addBatch()] ajoute au lot les valeurs actuelles des paramètres : le lot contient le même ordre SQL exécuté avec différentes valeurs ;
- [executeBatch] exécute le lot et rend un tableau d'entiers ([IntArray] en Kotlin) : le nombre de lignes modifiées par chaque ordre du lot. Si un ordre échoue, elle lance une [BatchUpdateException] dont la méthode [getUpdateCounts] (la propriété [updateCounts] en Kotlin) donne les résultats des ordres exécutés.
Le programme [Jdbc09.kt] utilise les lots :
Résultats :
Commentaires :
- ligne 18 : la méthode [supportsBatchUpdates] des métadonnées de la connexion ([connexion.metaData], la propriété qui correspond à [getMetaData()]) indique si le pilote sait exécuter des lots ;
- lignes 20-31 : un lot de trois ordres de mise à jour différents, ajoutés au lot d'un [Statement] par [addBatch] :
- ligne 29 : les trois ordres sont exécutés. [executeBatch] rend le nombre de lignes modifiées par chacun : une insertion ([canoë]), une mise à jour (le stock des flèches, inférieur au stock minimum) et une suppression ([pompe]). Le type [IntArray] (le [int[]] de Java), inféré, est écrit ici pour l'explication ;
- lignes 30-31 : [contentToString] affiche le contenu du tableau (le [toString] d'un tableau n'affiche que son adresse), et [sum], fonction d'extension des tableaux, calcule la somme de ses éléments (ligne 3 des résultats). Java passait par [Arrays.toString] et [Arrays.stream(…).sum()] ;
- lignes 35-36 : la fonction [insererLot] est appelée deux fois, avec deux URL différentes ;
- ligne 73 : la fonction [insererLot] insère [n] articles avec un lot :
- ligne 76 : la fonction [measureTimedValue] de [kotlin.time] exécute son bloc et rend un objet [TimedValue], une classe de données qui contient la valeur rendue par le bloc et la durée de son exécution ; la déstructuration les range dans deux variables ;
- ligne 77 : les insertions sont faites dans une transaction, gérée par la fonction [transaction] du paquetage [commun], ce qui est aussi bien plus rapide que de valider chacune d'elles ;
- lignes 81-87 : pour chaque article, les paramètres reçoivent leur valeur, puis [addBatch()] ajoute ces valeurs au lot ;
- ligne 89 : le lot est exécuté ; son résultat est celui du bloc de [use], puis du bloc de [transaction], qui valide la transaction avant de le rendre, et enfin du bloc de [measureTimedValue] ;
- lignes 93-95 : on affiche le nombre d'insertions, la base (ce qui suit le dernier [/] de l'URL, par [substringAfterLast]), la durée en millisecondes et les trois premiers éléments du tableau rendu par [executeBatch] ([take(3)] rend une liste, que [println] affiche entre crochets) ;
- lignes 96-98 : les articles insérés sont supprimés, pour remettre la table dans son état précédent. La fonction [transaction] ayant rétabli le mode autocommit, la suppression est validée dès son exécution ;
- ligne 4 des résultats : avec l'URL simple, le pilote MySQL envoie les 1000 insertions l'une après l'autre : le lot n'apporte presque rien. Chaque élément du tableau vaut 1 ;
- ligne 5 des résultats : avec le paramètre [rewriteBatchedStatements=true] de l'URL, Connector/J réécrit le lot en quelques ordres [insert into articles(…) values (…), (…), (…)…], qui insèrent chacun de nombreuses lignes. L'exécution est de dix à vingt fois plus rapide. Les éléments du tableau valent alors -2, la valeur de la constante [Statement.SUCCESS_NO_INFO] : l'ordre a réussi, mais le pilote ne sait pas combien de lignes chaque élément du lot a modifiées. C'est un réglage à connaître : sans lui, les lots n'ont guère d'intérêt avec MySQL. Les durées varient d'une exécution à l'autre ; seul leur rapport est significatif ;
- lignes 41-63 : deux ordres [select] envoyés en un seul échange :
- ligne 43 : la connexion est ouverte avec le paramètre [allowMultiQueries=true], qui autorise plusieurs ordres SQL séparés par des points-virgules dans une même requête. Il est désactivé par défaut, nous l'avons vu, pour limiter la portée des injections SQL ;
- lignes 46-50 : les deux ordres, écrits dans une chaîne brute sur deux lignes, sont exécutés par [execute], qui accepte un ordre quelconque ;
- ligne 53 : la propriété [resultSet] ([getResultSet()]) rend le résultat du premier ordre ;
- ligne 59 : [getMoreResults] passe au résultat suivant (elle ferme le résultat courant et rend [true] si le résultat suivant est un [ResultSet]). Kotlin permettrait d'écrire [ordre.moreResults], comme pour une propriété ; mais lire une propriété ne devrait pas changer l'état d'un objet, et l'appel explicite de la méthode dit mieux ce qu'il fait ;
- lignes 60-63 : le résultat du second ordre : une ligne de deux colonnes, rangées dans deux variables par la déstructuration d'une paire ([a to b]).
Les lots sont utiles pour les insertions et mises à jour en grand nombre (importation de données, par exemple). Hibernate (chapitre 11) sait les utiliser lui aussi.
10.10. Le mode déconnecté : CachedRowSet
Le mode déconnecté de JDBC repose sur l'interface [javax.sql.rowset.CachedRowSet], dérivée de [ResultSet] : un [CachedRowSet] est un résultat de requête copié en mémoire, que l'on peut parcourir dans les deux sens et modifier hors connexion, puis dont on renvoie les modifications à la base. Le programme [Jdbc10.kt] le montre :
Résultats :
Commentaires :
- lignes 17-18 : [RowSetProvider.newFactory] (Java 7) rend une fabrique ([RowSetFactory]) des différents types de RowSet du JDK, dont [createCachedRowSet] crée un [CachedRowSet]. Un [CachedRowSet] est une ressource ([AutoCloseable], comme tout [ResultSet]), fermée par [use] ;
- lignes 20-23 : le [CachedRowSet] reçoit les paramètres de connexion et la requête qui le remplira. Les méthodes [setUrl], [setUsername], [setPassword] et [setCommand] ont chacune un accesseur [getXxx] associé : Kotlin les présente comme des propriétés ;
- lignes 25-26 : pour pouvoir renvoyer les modifications à la base, il doit connaître la table modifiée et sa clé primaire, ici la colonne n° 1, [id]. [intArrayOf(1)] crée le tableau [int[]] attendu par la méthode Java ;
- ligne 28 : [execute] ouvre une connexion, exécute la requête, copie toutes les lignes du résultat en mémoire et ferme la connexion. La suite du travail se fait hors connexion. [articles.size()] (ligne 29) donne le nombre de lignes : [size] n'étant pas un accesseur [getSize], elle reste une méthode ;
- lignes 32-39 : un [CachedRowSet] est un [ResultSet] : on le parcourt avec [next] et on lit les colonnes avec [getXxx]. Les méthodes [updateXxx] modifient une colonne de la ligne courante et [updateRow] enregistre la modification… dans la copie en mémoire. Le stock des flèches, inférieur au stock minimum, est porté à 30 (ligne 3 des résultats) ;
- lignes 43-51 : l'ajout d'une ligne. Les huit appels portent tous sur le [CachedRowSet] : la fonction [with] en fait le receveur du bloc, ce qui évite de répéter [articles.]. [moveToInsertRow] place le curseur sur une ligne spéciale, la « ligne d'insertion », dont on donne les colonnes par [updateXxx] ; [insertRow] l'ajoute au [CachedRowSet] et [moveToCurrentRow] ramène le curseur où il était. Le [CachedRowSet] exige une valeur pour toutes les colonnes [NOT NULL], y compris [id] : la valeur 0, insérée dans une colonne [AUTO_INCREMENT], demande à MySQL d'en générer une ;
- lignes 54-56 : [absolute(2)] place le curseur sur la deuxième ligne, ce que permet un [CachedRowSet], que l'on peut parcourir dans tous les sens, et [deleteRow] la supprime ;
- ligne 59 : la base n'a pas encore été modifiée (lignes 5-11 des résultats) ;
- lignes 61-63 : [acceptChanges] renvoie les modifications à la base : un [update], un [insert] et un [delete], exécutés dans une transaction validée à la fin. Pour chaque ligne modifiée, l'implémentation du JDK vérifie que la ligne de la base n'a pas été modifiée par quelqu'un d'autre depuis sa lecture (contrôle dit « optimiste ») ; sinon elle lance une [SyncProviderException]. La version sans paramètre de [acceptChanges] ouvre elle-même la connexion, mais la laisse en mode autocommit puis appelle [commit], ce que refuse Connector/J ; on lui passe donc une connexion qui a quitté le mode autocommit ;
- ligne 66 : la base a été modifiée (lignes 12-18 des résultats).
Le [CachedRowSet] a l'avantage d'être une API standard du JDK, mais on voit ses limites : on manipule des lignes et des noms ou numéros de colonnes, et non des objets métier ; les modifications sont faites par une implémentation dont on maîtrise mal le SQL. C'est pourquoi il est peu utilisé aujourd'hui : le travail sur des objets en mémoire avec enregistrement ultérieur des modifications est le domaine des ORM comme Hibernate (chapitre 11).
10.11. Application exemple - version 6
On reprend l'application exemple [Impots]. La dernière version, la version 5, a été étudiée au chapitre 6. C'était une application à trois couches dont la couche [dao] trouvait ses données dans un fichier texte, un fichier JSON ou dans son propre code, et dont les couches étaient intégrées par le conteneur d'injection de dépendances de Spring. Nous écrivons une nouvelle couche [dao] qui va chercher ses données dans une base de données MySQL :

Nous réutilisons les sous-projets de la version 5 tels quels, par des dépendances Gradle, et n'écrivons que ce qui est nouveau : la couche [dao] et le programme qui intègre les couches.
10.11.1. La base de données
Les tranches d'impôt sont placées dans la table [tranches] d'une base MySQL [dbimpots], créée par le script [sql/dbimpots.sql] :
- lignes 13-17 : la table [tranches] a quatre colonnes : la clé primaire [id], générée par le SGBD, puis les trois colonnes [limite], [coeffr] et [coeffn] d'une tranche d'impôt, de type [DECIMAL] : ce sont des montants et des coefficients qui doivent être exacts. Ils seront lus en Kotlin sous la forme de [BigDecimal], le type des propriétés de la classe de données [TrancheImpot] ;
- lignes 19-26 : les sept tranches du barème 2004 sur les revenus 2003, utilisé depuis le début du cours (c'est un exercice : le barème n'est pas à jour). Les tranches sont insérées dans l'ordre croissant des limites, et la colonne [id] reflète cet ordre ;
- lignes 29-30 : la création d'un utilisateur MySQL [admimpots], de mot de passe [mdpimpots], qui reçoit tous les droits ([GRANT ALL PRIVILEGES]) sur tous les objets de la base [dbimpots] ([dbimpots.*]), et seulement sur eux. L'application travaillera avec cet utilisateur plutôt qu'avec l'administrateur [root] : si son mot de passe venait à être connu, seule la base [dbimpots] serait exposée. [IF NOT EXISTS] permet d'exécuter le script plusieurs fois.
Le script est exécuté par l'administrateur :
puis on vérifie que l'utilisateur [admimpots] a accès aux tranches (l'option [-p] seule fait demander le mot de passe, qui n'est pas affiché lorsqu'on le tape) :
10.11.2. Le projet ImpotsV6
La version 6 est formée de deux sous-projets Gradle, placés dans le dossier [10-jdbc/ImpotsV6] de la construction du chapitre, qui utilisent les trois sous-projets de la version 5 :
Les sous-projets de la version 5 ne font pas partie de la construction du chapitre 10 : ce sont des dépendances, comme le pilote JDBC ou Spring. Le cours Java, construit avec Maven, devait d'abord les installer dans le dépôt local de la machine ([mvn install]). Gradle offre un mécanisme plus direct, la construction composite (« composite build ») : le fichier [settings.gradle.kts] du chapitre 10, déjà rencontré, inclut la construction du chapitre 6 tout entière :
- lignes 3-6 : le plugin qui permet à Gradle de télécharger lui-même le JDK 25 s'il ne le trouve pas sur le poste (chapitre 2) ;
- ligne 11 : le sous-projet des programmes [Jdbc01] à [Jdbc10] ;
- ligne 13 : les deux sous-projets de la version 6. Le dossier [ImpotsV6] n'a pas de fichier [build.gradle.kts] : c'est un simple dossier de regroupement, comme [ImpotsV5] au chapitre 6 ;
- ligne 16 : la construction du chapitre 6 est incluse. Lorsqu'un sous-projet du chapitre 10 déclare une dépendance de coordonnées [exemples.impots:impots-metier:5.0], Gradle constate qu'un sous-projet de la construction incluse a ces coordonnées (le [group] déclaré dans son [build.gradle.kts], chapitre 6, et son nom, [impots-metier] ; la version n'intervient pas) : il le substitue à la bibliothèque, le compile si nécessaire et utilise le résultat. Aucune installation préalable n'est nécessaire, et une modification de la version 5 serait prise en compte à la construction suivante.
On mesure ici l'intérêt de l'architecture en couches. Les couches [metier] et [ui] ne connaissent que l'interface [IImpotDao], définie dans le sous-projet [impots-dao] de la version 5 ; la nouvelle couche [dao] implémente cette interface dans un nouveau sous-projet. Aucune ligne de la version 5 n'est modifiée.
10.11.3. La couche [dao]
Le fichier [build.gradle.kts] du sous-projet [impots-dao-mysql] est le suivant :
- lignes 3-4 : le greffon Kotlin, dont la version est donnée par le projet racine, et le greffon des bibliothèques, qui ajoute la configuration [api] ;
- lignes 8-9 : les coordonnées de la nouvelle couche, [exemples.impots:impots-dao-mysql:6.0] ;
- ligne 18 : le sous-projet [impots-dao] de la version 5, qui contient les entités ([TrancheImpot], [ImpotException]) et l'interface [IImpotDao]. La dépendance est déclarée par ses coordonnées, et la construction composite la résout. Elle est déclarée avec [api] et non [implementation] : la classe [MySqlImpot] implémente [IImpotDao] et sa propriété [tranchesImpot] est de type [List<TrancheImpot>], si bien qu'un projet qui utilise [MySqlImpot] a besoin de ces types pour compiler ;
- ligne 20 : le pilote JDBC de MySQL, dans la configuration [runtimeOnly] : il n'est pas utilisé à la compilation, mais il est présent à l'exécution et pendant les tests. C'est une garantie : si le code utilisait par erreur une classe du pilote, il ne compilerait pas. La couche [dao] ne dépend que de JDBC. Les projets qui dépendent de [impots-dao-mysql] reçoivent le pilote à l'exécution, par transitivité ;
- lignes 22-26 : pour les tests seulement, la couche [metier] de la version 5, [kotlin.test] et JUnit 6 ;
- lignes 29-40 : la configuration du compilateur et des tests, la même qu'au chapitre 6.
La classe [MySqlImpot] implémente l'interface [IImpotDao] :
- ligne 1 : la classe est dans le paquetage [impots.dao], comme les autres implémentations de [IImpotDao], bien qu'elle soit dans un autre sous-projet ;
- lignes 12-17 : le constructeur primaire reçoit les paramètres de connexion et la requête qui rend les tranches. La requête a une valeur par défaut, [REQUETE_PAR_DEFAUT] : là où Java avait besoin de deux constructeurs, le second appelant le premier par [this(…)], Kotlin n'en a qu'un. Les paramètres ne sont pas des propriétés (ils n'ont ni [val] ni [var]) : ils ne servent qu'à la construction de l'objet, et le mot de passe n'est pas conservé ;
- ligne 20 : la propriété de l'interface [IImpotDao], que redéfinit la classe. Elle n'a pas de valeur initiale : elle reçoit sa valeur dans le bloc [init], une fois pour toutes ;
- ligne 22 : les tranches sont lues une fois pour toutes, à la construction de l'objet : la couche [dao] est un singleton dans l'application, et la base n'est plus interrogée ensuite. Une connexion ouverte par [DriverManager] suffit donc : un pool n'aurait aucun intérêt ;
- lignes 23-26 : la connexion, l'ordre SQL et le résultat, fermés par trois [use] imbriqués, comme dans les exemples précédents. Le [try] est une expression : sa valeur, celle des blocs imbriqués, devient celle de [tranches] ;
- lignes 28-30 : la fonction [buildList] de la bibliothèque standard construit une liste : dans son bloc, [this] est une liste modifiable, à laquelle [add] ajoute une tranche par ligne du résultat ; elle rend ensuite cette liste sous le type [List], en lecture seule. Le type de la variable, [List<TrancheImpot>], est indiqué explicitement : le compilateur doit connaître le type des éléments de la liste avant d'analyser le bloc ;
- lignes 36-38 : une erreur d'accès à la base est encapsulée dans une [ImpotException] de code [ACCES], comme le faisaient [FileImpot] et [JsonImpot] avec les erreurs d'entrées / sorties. La couche [ui] n'a pas à connaître les [SQLException] de JDBC ;
- lignes 41-42 : une table vide est une erreur de données. L'[ImpotException] du chapitre 6 n'a qu'un constructeur, [ImpotException(message, cause = null, code = 0)] : pour donner un code sans cause, l'argument [code] doit être nommé ;
- ligne 45 : la propriété reçoit la liste des tranches ;
- lignes 48-51 : les codes d'erreur propres à cette implémentation, sur le modèle de ceux de [FileImpot] et [JsonImpot] : 101 pour une erreur d'accès à la base, 102 pour des données incorrectes. Ce sont des constantes ([const val]) de l'objet compagnon : on les désigne par [MySqlImpot.ACCES] ;
- ligne 54 : la requête qui rend les tranches. La clause [order by id] est importante : la couche [metier] attend les tranches dans l'ordre croissant des limites, et un [select] sans [order by] rend les lignes dans un ordre que SQL ne garantit pas ;
- ligne 57 : la lecture d'une tranche, une fonction privée de l'objet compagnon (elle n'utilise aucune propriété de l'objet) :
- lignes 59-60 : [getBigDecimal(i)] lit la colonne n° i sous la forme d'un [BigDecimal]. Le type déclaré, [List<BigDecimal?>], est important : pour Kotlin, une méthode Java rend une valeur de type « plateforme », [BigDecimal!], dont il ne sait pas si elle peut être [null]. Or [getBigDecimal] rend [null] pour une valeur SQL [NULL] : en déclarant des éléments de type [BigDecimal?], nous obligeons le compilateur à nous faire vérifier ce cas ;
- lignes 61-63 : une requête qui rend moins de trois colonnes, ou une colonne non numérique, provoque une [SQLException] dans [getBigDecimal] : c'est aussi une erreur de données ;
- lignes 65-67 : la liste est déstructurée en trois variables ; une valeur [null] est une erreur de données ;
- ligne 70 : après le test, les trois variables sont de type [BigDecimal] et non plus [BigDecimal?] (transtypage intelligent) : le compilateur accepte de les passer au constructeur de [TrancheImpot], qui attend des valeurs non nulles. Sans le test, il aurait refusé de compiler. L'[ImpotException] lancée par [lireTranche] n'est pas une [SQLException] : elle traverse sans être interceptée le [catch] du bloc [init], et les trois [use] ferment néanmoins le résultat, l'ordre et la connexion.
On remarquera que le nom de la classe est le seul endroit où apparaît MySQL : le code n'utilise que les interfaces de [java.sql]. Nous y reviendrons.
10.11.4. Les tests de la couche [dao]
Les tests de la couche [dao] sont dans le dossier [src/test/kotlin] du sous-projet :
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 | |
- lignes 19-21 : les tests utilisent la base [dbimpots], qui doit avoir été créée par le script [dbimpots.sql]. Ce ne sont donc plus des tests unitaires au sens strict, qui testeraient une classe isolément, mais des tests d'intégration : ils vérifient le fonctionnement de la couche [dao] avec le SGBD. On utilise souvent pour eux une base H2 en mémoire, créée au début des tests, ou un SGBD lancé dans un conteneur Docker par la bibliothèque Testcontainers ; nous nous contentons ici de la base MySQL de la machine. JUnit crée une instance de la classe de tests pour chaque test : les trois propriétés pourraient aussi être des constantes d'un [companion object] ;
- lignes 25-35 : les tranches lues dans la base sont celles de la version « en dur » [HardwiredImpot]… mais pas au sens de [equals] (ligne 29) ! La méthode [equals] générée pour une classe de données compare ses propriétés avec leur méthode [equals], et celle de [BigDecimal] compare la valeur et l'échelle (le nombre de décimales) : les [BigDecimal] lus dans la base ont deux décimales ([4962.00]), comme la colonne [DECIMAL(10,2)], alors que ceux de [HardwiredImpot] n'en ont pas ([4962]). [compareTo], elle, ne compare que les valeurs. C'est un piège classique de [BigDecimal] (chapitre 3), que l'opérateur [==] de Kotlin n'évite pas, puisqu'il appelle [equals]. La fonction [zip] (ligne 32) associe les éléments de même rang des deux listes en paires, déstructurées en [attendue] et [lue] ;
- lignes 40-48 : la couche [metier] de la version 5 calcule avec la nouvelle couche [dao] les impôts attendus. [@CsvSource] fournit les paramètres des trois exécutions du test ; en Kotlin, les arguments d'une annotation de type tableau s'écrivent simplement à la suite ([value] est un [vararg]) ;
- lignes 53-59 : une base inexistante provoque une [ImpotException] de code [ACCES]. [assertFailsWith<ImpotException> { … }] de [kotlin.test] exécute le bloc, vérifie qu'il lance une exception de ce type et la rend. [assertIs<SQLException>(e.cause)] vérifie que la cause est une [SQLException] (ou d'une classe dérivée) ;
- lignes 64-66 : de même pour un mot de passe incorrect ;
- lignes 71-75 : un serveur injoignable (aucun serveur n'écoute sur le port 3399) provoque aussi une erreur d'accès ;
- tests [requête incorrecte] (ligne 80), [aucune tranche] (ligne 89) et [valeur NULL] (ligne 98) : une requête qui ne rend que deux colonnes, qui ne rend aucune ligne ou dont une colonne vaut [NULL] provoque une [ImpotException] de code [DONNEES]. Le dernier passe la requête par un argument nommé, [requete = "…"], ce qui rend l'appel plus lisible.
On exécute les tests par la commande suivante, tapée dans le dossier [10-jdbc] :
Gradle affiche une ligne par test exécuté (l'ordre d'exécution des tests n'est pas celui du fichier), grâce au réglage [testLogging { events("passed", "skipped", "failed") }] du fichier [build.gradle.kts]. Pour le test paramétré, chaque ligne donne le nom de la fonction suivi des types de ses paramètres tels que les voit la JVM ([(boolean, int, int, int)] : les [Boolean] et [Int] de Kotlin sont compilés en types primitifs), puis le numéro de l'exécution entre crochets et les noms et valeurs des paramètres, ces valeurs étant écrites entre guillemets, telles qu'elles figurent dans l'annotation [@CsvSource] (chapitre 6). Chaque jeu de valeurs comptant pour un test, les dix tests ont réussi.
10.11.5. L'intégration des couches
Le sous-projet [impots-ui-mysql] contient le programme qui intègre les couches. Son fichier [build.gradle.kts] est le suivant :
- lignes 2-4 : le greffon Kotlin et le greffon [application], qui fournit la tâche [run]. Le greffon [kotlin("plugin.spring")] n'est pas nécessaire : le programme n'a pas de classe annotée par Spring ([@Configuration]…), il enregistre les couches par programme ;
- ligne 16 : la nouvelle couche [dao], un sous-projet de la même construction, désigné par son chemin. Par transitivité, le pilote JDBC de MySQL, dépendance [runtimeOnly] de ce sous-projet, fait partie des bibliothèques de l'application à l'exécution ;
- lignes 18-19 : les couches [metier] et [ui] de la version 5, désignées par leurs coordonnées et fournies par la construction composite. Le sous-projet [impots-ui] contient les programmes principaux de la version 5, mais nous n'utilisons que son objet [Dialogue] ;
- ligne 21 : le conteneur d'injection de dépendances de Spring, comme dans la version 5 ;
- lignes 32-34 : le programme exécuté par [gradlew run]. Le fichier [Main.kt] du paquetage [impots.ui.mysql] produit la classe [impots.ui.mysql.MainKt] ;
- lignes 37-39 : le programme lit le clavier ;
- lignes 42-45 : les programmes lancés par Gradle écrivent leurs sorties en UTF-8, pour que les caractères accentués s'affichent correctement sous Windows (chapitre 2).
Le fichier de configuration [impots.properties] est le suivant :
- ligne 2 : l'implémentation de la couche [dao] ;
- lignes 3-5 : l'URL JDBC de la base [dbimpots] et les identifiants de l'utilisateur [admimpots] ;
- ligne 6 : la requête qui rend les tranches. La placer dans la configuration permet d'utiliser une autre base, dont la table ou les colonnes portent d'autres noms, sans modifier le code.
Le programme [Main.kt] est celui de la version 5, à l'enregistrement de la couche [dao] près :
- ligne 1 : le programme est dans un paquetage propre à la version 6, [impots.ui.mysql], pour ne pas être confondu avec le programme [impots.ui.MainKt] de la version 5, présent lui aussi dans le classpath ;
- ligne 21 : le fichier de configuration, [impots.properties] par défaut, ou celui passé en argument : [firstOrNull] rend [null] si le tableau des arguments est vide, et l'opérateur Elvis donne alors le nom par défaut ;
- ligne 24 : comme dans la version 5, le [try] est une expression : sa valeur est celle de la dernière expression du bloc, la couche [metier] obtenue du conteneur (ligne 50). Le bloc [catch] ne rend pas de valeur : [exitProcess] est de type [Nothing], elle ne rend jamais la main ;
- lignes 26-27 : la lecture du fichier de propriétés, dans le dossier courant ;
- lignes 28-33 : les paramètres de la couche [dao]. [getProperty] est une méthode Java qui rend [null] pour une propriété absente : son type pour Kotlin est [String!]. Pour l'URL (lignes 29-30), l'opérateur Elvis suivi d'un [throw] fait de l'absence une erreur, et [url] est de type [String] non nullable. Les autres paramètres ont une valeur par défaut, celle de la requête étant la constante [REQUETE_PAR_DEFAUT] de [MySqlImpot] ;
- lignes 38-42 : la couche [dao] est enregistrée dans le conteneur Spring sous l'interface [IImpotDao], avec une lambda qui la crée selon la configuration : un [MySqlImpot] (ligne 40) ou, pour comparaison, la version « en dur » de la version 5 (ligne 41). [registerBean] est ici la fonction d'extension Kotlin de Spring, à paramètre de type réifié, utilisée au chapitre 6. Les implémentations [FileImpot] et [JsonImpot] de la version 5 pourraient être ajoutées de la même façon : elles sont dans le classpath ;
- ligne 46 : la couche [metier] de la version 5 ;
- ligne 49 : le démarrage du conteneur crée la couche [dao], et donc lit la base, puis la couche [metier] ;
- lignes 51-54 : une erreur de configuration ou d'accès à la base est affichée par la fonction [afficherErreur] de l'objet [Dialogue] de la version 5, et le programme s'arrête ;
- ligne 58 : le dialogue de la version 5, inchangé.
Pour exécuter l'application, on lance la tâche [run] du sous-projet [impots-ui-mysql] depuis le dossier [10-jdbc] ; Gradle compile au passage, si nécessaire, la version 5 et la nouvelle couche [dao]. La tâche [run] exécute le programme dans le dossier du sous-projet, qui contient les fichiers de configuration. Exécution avec le fichier [impots.properties] (les saisies sont réinsérées après les questions, comme aux chapitres précédents) :
On retrouve les valeurs de contrôle du cours : 4282 euros pour un couple avec deux enfants, 2959 euros avec trois enfants, 11519 euros pour une personne seule sans enfant, avec un salaire annuel de 60 000 euros.
Avec le fichier [impots-base.properties], dont l'URL désigne une base [dbimpotsinconnue] (l'option [--args] de la tâche [run] transmet les arguments du programme) :
- lignes 2-3 : un message de journalisation de Spring, écrit sur la sortie d'erreur : le conteneur signale que la création de la couche [dao] a échoué. C'était déjà le cas dans la version 5 ;
- lignes 4-11 : la chaîne des exceptions affichée par [Dialogue.afficherErreur] : le conteneur Spring a encapsulé dans une [BeanCreationException] l'[ImpotException] lancée par la couche [dao], qui encapsule elle-même la [SQLException] du pilote. L'utilisateur [admimpots] n'a de droits que sur la base [dbimpots] : MySQL refuse l'accès à toute autre base, qu'elle existe ou non (ligne 10).
Le programme s'arrête avec le code 1 ([exitProcess(1)]) : Gradle considère alors que la tâche [run] a échoué et ajoute ses propres messages, non reproduits ici.
Avec le fichier [impots-motdepasse.properties], dont le mot de passe est incorrect :
Avec le fichier [impots-serveur.properties], dont l'URL désigne le port 3399, sur lequel n'écoute aucun serveur (c'est ce qu'on obtiendrait avec un serveur MySQL arrêté) :
La chaîne des exceptions montre ici que l'exception du pilote encapsule elle-même d'autres exceptions, jusqu'à la [ConnectException] du réseau : la connexion a été refusée.
Avec le fichier [impots-requete.properties], dont la requête ne demande que les colonnes [limite] et [coeffr] :
Dans tous les cas, la couche [dao] a encapsulé l'exception d'origine dans une [ImpotException], affichée par la fonction [Dialogue.afficherErreur] de la version 5, et le programme s'arrête.
10.11.6. Changer de base de données
La couche [dao] [MySqlImpot] n'utilise que les interfaces de JDBC : malgré son nom, elle n'est pas propre à MySQL (un nom comme [JdbcImpot] lui conviendrait mieux). Pour utiliser une base PostgreSQL (ou SQL Server, ou Oracle), il suffirait :
- de créer la table des tranches dans une base PostgreSQL, par exemple une table [data] de colonnes [data1], [data2], [data3], pour souligner l'intérêt de placer la requête dans la configuration ;
- d'ajouter au fichier [build.gradle.kts] du sous-projet [impots-ui-mysql] la dépendance du pilote JDBC de PostgreSQL, [runtimeOnly("org.postgresql:postgresql:…")] (ou [com.microsoft.sqlserver:mssql-jdbc], [com.oracle.database.jdbc:ojdbc11]) ;
- d'écrire un fichier de configuration analogue à [impots.properties], comme le fichier [impots-postgresql.properties] du sous-projet :
Seul MySQL est installé sur la machine qui a servi à écrire ce cours : nous n'avons pas exécuté cette configuration. Les couches [metier] et [ui], la classe [MySqlImpot] et le programme [Main.kt] n'ont pas à être modifiés : seuls changent la configuration et le pilote présent dans le classpath. On notera que la requête de [MySqlImpot] n'a pas de paramètres et n'utilise que du SQL standard : c'est ce qui permet de la faire exécuter par n'importe quel SGBD. Une couche [dao] plus riche (recherches, mises à jour) rencontrerait vite les particularités du SQL de chaque SGBD vues plus haut : c'est là qu'un ORM comme Hibernate prend le relais.
10.12. Conclusion
Ce chapitre a présenté l'accès aux bases de données avec JDBC, depuis Kotlin :
- JDBC est un ensemble d'interfaces ([Connection], [Statement], [PreparedStatement], [ResultSet], [DataSource]…) des paquetages [java.sql] et [javax.sql], implémentées par un pilote propre à chaque SGBD (MySQL Connector/J pour MySQL), que [DriverManager] choisit d'après l'URL JDBC de la base. Kotlin n'a pas d'API propre : il utilise JDBC directement, avec ses accesseurs vus comme des propriétés ([connexion.autoCommit = false]) ;
- une connexion est ouverte le plus tard possible et fermée le plus tôt possible, par la fonction [use] ; un pool de connexions comme HikariCP, au travers de l'interface [DataSource], rend cette pratique peu coûteuse ;
- [executeQuery] exécute un [select] et rend un [ResultSet] qu'on parcourt ligne à ligne avec [next], en lisant les colonnes, numérotées à partir de 1, par les méthodes [getXxx] ; des fonctions d'extension ([toList], [asSequence], [toArticle]) transforment les lignes en objets Kotlin. [executeUpdate] exécute un [insert], [update] ou [delete] et rend le nombre de lignes modifiées ;
- les erreurs sont des [SQLException], qui donnent un code d'erreur propre au SGBD et un code SQLSTATE normalisé. Elles sont contrôlées en Java, pas en Kotlin, qui n'oblige à rien : c'est au développeur de décider où les intercepter, typiquement dans la couche [dao] qui les encapsule dans ses propres exceptions ;
- les valeurs extérieures sont toujours passées par les paramètres d'un [PreparedStatement], jamais insérées dans le texte de la requête, ni par concaténation ni par un gabarit de chaîne, ce qui protège de l'injection SQL ;
- les transactions ([autoCommit = false], [commit], [rollback], points de sauvegarde) rendent atomique une suite d'ordres SQL ; une fonction d'ordre supérieur comme notre [connexion.transaction { … }] en cache la mécanique ;
- JDBC est bloquant : les threads virtuels permettent de servir de nombreux utilisateurs avec un code JDBC ordinaire, dans la limite de la taille du pool ; les coroutines l'intègrent en enveloppant les appels dans [withContext(Dispatchers.IO)], de préférence avec un parallélisme limité à la taille du pool. Les lots ([addBatch], [executeBatch]) réduisent le nombre d'échanges avec le serveur ;
- le mode déconnecté ([CachedRowSet]) existe, mais le travail sur des objets en mémoire est aujourd'hui le domaine des ORM.
La version 6 de l'application [Impots] a montré que, grâce à l'architecture en couches, passer d'un fichier à une base de données ne demande que d'écrire une nouvelle couche [dao] et de modifier la configuration ; la construction composite de Gradle a permis de réutiliser la version 5 sans la copier ni l'installer. Le chapitre suivant écrira une septième version de cette couche avec Hibernate, qui génère lui-même les ordres SQL.
Entre le JDBC « brut » de ce chapitre et un ORM complet comme Hibernate, l'écosystème Kotlin et Java offre des bibliothèques intermédiaires, qui gardent le contrôle du SQL en supprimant le code répétitif :
- Exposed, la bibliothèque d'accès aux bases de données de JetBrains, écrite en Kotlin : les tables sont décrites par des objets Kotlin, et les requêtes s'écrivent dans un langage dédié (DSL) typé, [Articles.selectAll().where { Articles.prix less 200 }], dans des blocs [transaction { … }] ; elle propose aussi une couche d'objets (DAO) plus proche d'un ORM ;
- jOOQ, qui génère à partir du schéma de la base des classes Java utilisables depuis Kotlin, et permet d'écrire des requêtes SQL vérifiées à la compilation ;
- Spring JDBC, dont l'interface [JdbcClient] (Spring 6.1) exécute une requête paramétrée et transforme ses lignes en objets en une seule instruction fluide, en gérant connexions, fermetures et exceptions.
Pour aller plus loin :
- le tutoriel JDBC d'Oracle : JDBC Database Access (écrit pour Java 8, mais l'API n'a guère changé depuis) ;
- la documentation de MySQL Connector/J : MySQL Connector/J Developer Guide ;
- la documentation de HikariCP : HikariCP ;
- l'injection SQL, expliquée par l'OWASP : SQL Injection Prevention Cheat Sheet ;
- la bibliothèque Exposed : Exposed ; jOOQ : jOOQ.