Skip to content

Utiliser une base de données

Dans cette section, nous verrons comment utiliser une base de données avec Java et nous réaliserons nos DAO.

Préparation

Pour la suite de ce tutoriel, nous utiliserons une base de données comportant les tables : - travel, - user, - reservation (pour la relation d'un utilisateur inscrit pour un voyage) - wait_list (pour la relation d'un utilisateur inscrit dans la file d'attente d'un voyage).

Récupérez les scripts de la base de données

  • Téléchargez les scripts d'initialisation :

    Database init

    Cette archive contient quatre scripts :

    • db/V1.0.0__travel_user.table.sql : Définit les tables Travel et User.
    • db/V1.0.1__travel_user.data.sql : Ajoute quelques entrées aux tables mentionnées ci-dessus
  • Placez les scripts dans un nouveau dossier appelé sql, à la racine de votre répertoire de travail.

Mise en place de la base de données

Nous allons utiliser une base de données PostgreSQL.

Pour des raisons de simplicité, la base de données tourne dans un conteneur Docker et nous vous fournissons tous les fichiers d'installation.

  • Installez Docker Engine.

    Vous pouvez ignorer cette étape si Docker est déjà installé sur votre système. Dans le cas contraire, voici comment procéder à son installation : installer Docker Engine

  • Téléchargez les fichiers docker-compose et placez-les à la racine de votre projet : Download docker-compose-db

    Cette archive contient trois fichiers :

    • docker-compose.yml : Définit un service db qui démarre une instance PostgreSQL, initialisée avec les scripts que nous avons placés précédemment dans le dossier sql.
    • docker-compose.override.yml : Définit les paramètres Docker pour le développement, expose la base de données au port 5432 et génère un Adminer pour gérer la base de données avec une GUI.
  • Exécutez la base de données avec la commande docker compose.

    docker compose up
    

  • Une fois l'exécution réussie, vous pouvez vous connecter à la base de données à l'aide de l'Adminer sur http://localhost:18080 en utilisant :

    • nom d'utilisateur=madmin
    • mot de passe=madmin
    • nom de la base de données=agencymanagement_db Ce sont les valeurs par défaut spécifiées dans le fichier docker-compose.

Assurez-vous d'avoir quatre tables : travel, user, reservation et wait_list.

adminer

JDBC

La connexion à une base de données avec Java est normalisée par la spécification JDBC pour "Java Database Connectivity".
C'est une API fondamentale pour la connectivité des bases de données dans l'écosystème Java. Elle offre les outils nécessaires pour établir des connexions, exécuter des requêtes SQL, récupérer et mettre à jour des données dans les bases de données, tout en gérant efficacement les connexions.

Info

  • Pour faire simple, JDBC offre un objet Connection, ainsi que le strict minimum pour exécuter des instructions SQL (Statement, PreparedStatement).
  • Dans des projets plus avancés, on utilise des bibliothèques avancées telles que Hibernate ou jOOQ au lieu du simple JDBC pour gérer nos bases de données SQL.

Installez le pilote JDBC / JDBC Driver

JDBC est compatible avec les SGBD (Systèmes de Gestion de Base de Données) les plus populaires.

  • Ajoutez la dernière dépendance PostgreSQL à votre pom.xml.
Affichez le pom.xml résultant
<dependency>
  <groupId>org.postgresql</groupId>
  <artifactId>postgresql</artifactId>
  <version>${postgres.version}</version>
  <scope>runtime</scope>
</dependency>

Le pilote JDBC est une bibliothèque qui fournit l'implémentation spécifique pour se connecter à un SGBD. En d'autres termes, il est nécessaire à l'exécution mais n'est jamais référencé directement dans le code. Par conséquent, la portée de Maven pour les pilotes JDBC doit être runtime.

JDBC Connection

JDBC définit un objet Connection qui représente une seule connexion à la base de données. Vous pouvez obtenir cet objet Connection avec l'instruction suivante :

public Connection getConnection() throws SQLException {
  return DriverManager.getConnection("jdbc:postgresql://localhost:5432/agencymanagement_db", "user", "password");
}

Nous allons utiliser l'objet Connection à plusieurs endroits, mais comme il est préférable de ne pas copier-coller cette méthode dans tout le code, nous allons définir une classe ConnectionManager pour s'en occuper.

Le singleton ConnectionManager :

Nous n'avons besoin que d'un seul ConnectionManager pour gérer l'objet Connection. Pour ce cas d'utilisation,
le design pattern Singleton convient parfaitement.

  • Créez une nouvelle classe appelée ConnectionManager. Cette classe utilise le modèle singleton.
  • Ajoutez une méthode appelée getConnection() permettant de récupérer la connexion.

Tip

Comme l'a popularisé Joshua Block dans son livre Effective Java, il est recommandé d'écrire des singletons à l'aide des Java enums.

package io.takima.agencymanagement

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

public enum ConnectionManager {
    INSTANCE;

    private final String JDBC_URL = "jdbc:postgresql://localhost:5432/agencymanagement_db";
    private final String JDBC_USER = "madmin";
    private final String JDBC_PASSWORD = "madmin";

    public Connection getConnection() throws SQLException {
        return DriverManager.getConnection(JDBC_URL,JDBC_USER,JDBC_PASSWORD);
    }
}

DAO

La récupération et le mapping des données de la base de données vers Java doivent être effectués par des DAO. Comme on l'a déjà mentionné, les DAO (Data Access Objects) servent d'interface intermédiaire entre la logique métier et la base de données. Leur rôle principal est de simplifier l'accès aux données en isolant la logique d'accès, formant ainsi une couche de persistance.

Étant donné que des DAO vous ont été fournis lors du jour 1, c'est maintenant à votre tour de créer vos propres DAO.

PreparedStatement

L'objet Connection offre plusieurs méthodes pour exécuter des requêtes SQL. Dans cette étape, nous allons utiliser Connection.prepareStatement.

Pourquoi privilégier les PreparedStatement plutôt que les Statement ?

Contrairement à la classe Statement, la classe PreparedStatement permet de définir des paramètres de manière dynamique et offre une protection contre les injections SQL. De plus, elle est mise en cache par le SGBD, ce qui la rend plus efficace lorsqu'elle est exécutée plusieurs fois.

Codons ensemble la fonction nous permettra d'insérer des données, on fera ça pour un travel.

public void save(Travel travel) throws SQLException {
    // Récupérer la connexion
    Connection connection = cm.getConnection();
    // Ecriture de la requête SQL
    PreparedStatement ps = connection.prepareStatement("INSERT INTO travel(id, name, departure_airport, " +
            " arrival_airport, departure_date, arrival_date, destination, capacity, price) " +
            " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)");

    // Attribution des paramêtres
    ps.setLong(1, travel.getId());
    ps.setString(2, travel.getName());
    ps.setString(3, travel.getDepartureAirport().getAcronym());
    ps.setString(4, travel.getArrivalAirport().getAcronym());
    // Il faut transformer le Instant en Timestamp
    ps.setTimestamp(5, java.sql.Timestamp.from(travel.getDepartureDate()));
    ps.setTimestamp(6, java.sql.Timestamp.from(travel.getArrivalDate()));
    ps.setString(7, travel.getDestination());
    ps.setInt(8, travel.getCapacity());
    ps.setDouble(9, travel.getPrice());

    // Exécution de la requête
    ps.executeQuery();
}

À vous de jouer

  • Créez une classe nommée JdbcTravelDao qui utilise JDBC et implémente les fonctions suivantes :

On veut commencer par lister tous les voyages proposés par notre agence.

  • Créez la méthode List<Travel> findAll();.
  • Créez une PreparedStatement pour sélectionner tous les voyages.
  • Créez la méthode List<Travel> findTravelsByDestination(String destination) pour lister tous les voyages ayant une destination précise.
  • Exécutez cette instruction, itérez sur le ResultSet et imprimez les noms des voyages.
Indice
private final ConnectionManager cm = ConnectionManager.INSTANCE;
// dao fonctions
...
public List<String> findAll() throws SQLException {
    Connection connection = cm.getConnection();
    PreparedStatement ps = connection.prepareStatement("SELECT t.name FROM travel t");

    ...
}
public List<String> findTravelsByDestination(String destination) {
    Connection connection = cm.getConnection();
    PreparedStatement ps = connection.prepareStatement( "SELECT * FROM travel t 
                                                        WHERE t.destination LIKE? ") ;

    ps.setString(1, "%" + destination + "%");
}
public List<String> findTravelsByDestination(String destination) throws SQLException {
    // ...

    ResultSet rs = ps.executeQuery();
    List<String> travels = new LinkedList<>();

    while (rs.next()) {
      travels.add(rs.getString("name"));
    }

    return travels;
}

Note

  • Envisagez de nommer une méthode findById() au lieu de geById() :

N'appelez pas la méthode getById() si elle risque de ne pas donner de résultat. Si la méthode renvoie un résultat null dans le cas d'une ressource non trouvée, il est préférable d'appeler la méthode findById().

  • Renvoyez un Optional plutôt que null :

Si une méthode renvoie null, vous devez vérifier la nullité à chaque fois que vous appelez la méthode. Vous prenez le risque d'oublier cette vérification et d'avoir une NullPointerException.

  • N'oubliez pas de fermer les ressources une fois qu'elles ne sont plus nécessaires :

Attention !

connection.close();

Comme indiqué dans la documentation, la fermeture de la Connection ferme également les PreparedStatement et ResultSet associés.

  • N'oubliez pas de fermer la Connection en cas d'exception également. Comme Connection implémente AutoClosable, la méthode try-with-resources est parfaitement adaptée à ce cas d'utilisation.
Connection connection = null;
try {
    connection =  cm.getConnection();
    PreparedStatement ps = connection.prepareStatement("...");

    ResultSet rs = ps.executeQuery();
} catch (SQLException e) {
    if (connection != null) {
        connection.close();
    }
    throw e;
}
try (Connection connection = cm.getConnection()) {

    PreparedStatement ps = connection.prepareStatement("...");

    ResultSet rs = ps.executeQuery();
}

À vous de jouer

  • À votre tour, implémentez List<User> findAllParticipantsByTravelId(long travelId); pour lister tous les voyageurs inscrits à un voyage précis.
  • Respectez la structure proposée lors du premier jour et créez votre service TravelService, utilisant les fonctions du DAO.
  • Testez votre code en appelant le service à partir de votre méthode main.
Exemple
public static void main(String[] args) throws Exception {
    List<String> travels = travelService.findAll();

    travels.forEach(travel -> {
    LOGGER.info("{}", travel);
    });
}

Connection pool

Dans les applications d'entreprise réelles, la base de données a souvent beaucoup de lecture/écriture à faire et peut devenir un "goulot d'étranglement".

Pour atténuer ce problème, nous utilisons souvent plusieurs objets Connection pour équilibrer la charge.

Avec votre code actuel, il serait facile d'appeler DriverManager.getConnection(jdbcUrl) à chaque fois que vous en avez besoin, mais cela pose un certain nombre de problèmes :

  • Il est difficile de contrôler le nombre de connexions ouvertes.
  • Un trop grand nombre de connexions ouvertes ralentit l'application.
  • L'ouverture et la fermeture d'une connexion est un processus lourd.

Idéalement, nous voulons ouvrir un nombre fixe de connexions et les réutiliser à l'infini.

C'est exactement ce à quoi sert un connexion pool.

HikariCP

HikariCP est en effet un pool de connexions pour Java, utilisé pour gérer et optimiser l'utilisation des connexions à une base de données.

À vous de jouer

Afficher le pom.xml résultant
<dependency>
  <groupId>com.zaxxer</groupId>
  <artifactId>HikariCP</artifactId>
  <version>${hikaricp.version}</version>
</dependency>
  • Modifiez le ConnectionManager pour utiliser HikariCP.
Indice
public enum ConnectionManager {
    INSTANCE;

    private static final String JDBC_URL = "jdbc:postgresql://localhost:5432/agency_management_db";
    private static final HikariConfig config = new HikariConfig();
    private static final HikariDataSource ds;

    ...
}

Service / DAO

Maintenant que nous avons compris comment les interactions avec notre base de données fonctionnent, revenons à notre diagramme. Vous remarquerez que les DAO sont appelés uniquement par leur service correspondant. D'autre part, les services peuvent être appelés entre eux.

Diagramme d'architecture

Exemple :

Nous avons une fonction findAll dans notre DAO. Pour pouvoir l'utiliser dans le service, il faut faire :

...
public List<String> findAll() ...
...
...
private final JdbcTravelDao jdbcTravelDao = new JdbcTravelDao();

public List<Travel> findAll() {
    return jdbcTravelDao.findAll();
}
...

À vous de jouer

  • Pour toutes les fonctions créées dans votre service (durant le jour 1), réalisez une fonction DAO que vous appellerez selon vos besoins :
...
public List<String> findAvailableTravels() {
    ...
}
...
...
public List<Travel> findAvailableTravels() {
    return jdbcTravelDao.findAvailableTravels();
}
...

Fichiers modifiés

docker-compose.yml
docker-compose.override.yml
pom.xml
src/main/java/io/takima/agencymanagement/Application.java

N'oubliez pas de Commit votre travail !