Liquibase : Contrôlez l’évolution de votre base de données

Liquibase est une librairie open-source permettant de tracer et gérer les modifications d'une base de données.
Elle peut servir à la fois pour gérer le versionning de sa BDD sur différents environnements et permettre la gestion des tests d'intégration utilisant un jeux de données.

Le principe est plutôt simple, liquibase ajoute à votre base 2 tables :

  • databasechangelog : trace toutes les modifications effectuées par liquibase sur votre BD.
  • databasechangeloglock : permet d'assurer à liquibase qu'il n'y a pas 2 exécutions simultanées de liquibase.

Il suffit ensuite de lui fournir la liste des modifications ordonnées à réaliser via un ensemble de fichiers textes pouvant accepter divers formats (xml,json,sql,yaml).
Les modifications sont séparées en lots appelés changeSet identifiés par un id, un auteur ainsi que le nom du fichier sans lequel il est déclaré.

Liquibase exécute les changeSet dans l'ordre et trace en cas de succès dans la table databasechangelog le passage du changeSet. Si la table databasechangelog a déjà tracé l’exécution d'un changeSet, Liquibase le saute tout simplement.

Définition des changements

Prenons l'exemple du format xml. On définit dans un fichier db-changelog.xml nos changeSet.
ex:

<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.5.xsd">
	<changeSet id="identifiant_unique" author="nomAuteur">
<!-- Ma modification -->
	</changeSet>
</databaseChangeLog>
db-changelog.xml

Par exemple dans db-changelog-0.xml je crée une table avec une séquence d'initialisation.

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
                      http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
	<changeSet id="seq_matable-create" author="dev">
		<createSequence sequenceName="seq_matable" />
	</changeSet>
	<changeSet id="matable-create" author="dev">
		<createTable tableName="matable" remarks="Ma table">
			<column name="id" type="BIGINT" defaultValueSequenceNext="seq_matable">
				<constraints primaryKey="true" primaryKeyName="pk_matable" />
			</column>
			<column name="monChamp1" type="VARCHAR(255)" />
		</createTable>
	</changeSet>
</databaseChangeLog>
db-changelog-0.xml

Si je souhaite maintenant l'exécuter sur ma base (par exemple postgresql) je peux réaliser la ligne de commande suivante :

java -jar /path_to/liquibase-core-3.5.3.jar --driver=org.postgresql.Driver --classpath=/path_to/postgresql.jar --url=jdbc:postgresql://localhost:5432/maDB --username=user --password=mdp --changeLogFile=/path_to/db-changelog-0.xml update
update

Et comme par magie ma table et ma séquence sont créées

Puis plus tard dans mon projet je souhaite créer une autre table et ajouter une colonne à ma première table. Je crée un autre fichier db-changelog-1.xml avec les changeSet :

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
                      http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
	<changeSet id="2017-03-20-matable-addColumn-1" author="dev">
		<addColumn tableName="matable">
			<column name="monChamp2" type="varchar(255)" />
		</addColumn>
	</changeSet>
	<changeSet id="2017-03-20-monautretable-create" author="dev">
		<createTable tableName="monautretable" remarks="Mon autre table">
			<column name="maDate" type="DATE" />
		</createTable>
	</changeSet>
</databaseChangeLog>
db-changelog-1.xml

Pour séquencer tout ça je crée un fichier maître (db-changelog-master.xml) qui va les déclarer dans l'ordre d'exécution :

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
                      http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
	<include relativeToChangelogFile="true" file="db-changelog-0.xml" />
	<include relativeToChangelogFile="true" file="db-changelog-1.xml" />
</databaseChangeLog>
db-changelog-master.xml

Je ré-exécute la commande cette fois en faisant pointer le '--changeLogFile' vers mon fichier db-changelog-master.xml. Et hop maTable est mise à jour et monAutreTable est créée.

Identification des changeSet

Liquibase identifie vos changements en combinant l'identifiant du changeSet mais aussi le nom du fichier changeLog qui le contenait. Ainsi si vous renommez votre fichier changeLog en cours de projet Liquibase retentera d'exécuter votre changeSet. De plus suivant la façon de lancer liquibase (cf. Parite Intégration) liquibase pourra selon le cas enregistré, comme filename du changelog, son chemin complet, son chemin relatif ou sa valeur.

Pour remédier à cela vous pouvez ajouter à la balise databaseChangeLog la propriété logicalFilePath="monIdentifiantChangeLog" qui sera la valeur enregistrée dans la colonne filename de la table databasechangelog. Bien évidemment cette valeur doit être différente pour chacun des fichiers.

Validation du changeSet

Enfin liquibase vérifie que le changeSet que vous lui fournissez n'a pas changé depuis son dernier passage. Pour cela lors de l'exécution du changeSet elle enregistre dans la table databasechangelog un  md5 du changement réalisé dans la colonne md5Sum. Si le md5 de votre changeSet ne corrrespond pas au md5 du changeSet déjà passé (même identifiant et même filename), alors liquibase termine en erreur.

Comment faire en cours de projet

* generateChangeLog : permet de générer le fichier changelog correspondant à une base déjà existante.
* diff : permet de générer le fichier changelog correspondant à l'écart entre 2 bases.

Bien d'autres fonctionnalités et paramétrages sont disponibles : http://www.liquibase.org/documentation/command_line.html

Intégration

Il existe plusieurs moyen d'intégrer Liquibase à son projet.

Intégration maven

Pour permettre à vos développeurs en une commande d'avoir leur base toujours à jour.
Le plugin liquibase-maven-plugin permet d’exécuter les différentes actions liquibase.

Avec la configuration suivante :
Dans le pom.xml

<build>
	<pluginManagement>
		<plugins>
			<plugin>
				<groupId>org.liquibase</groupId>
				<artifactId>liquibase-maven-plugin</artifactId>
				<configuration>
					<promptOnNonLocalDatabase>true</promptOnNonLocalDatabase>
					<propertyFile>${project.build.directory}/classes/liquibase.properties</propertyFile>
				</configuration>
			</plugin>
		</plugins>
	</pluginManagement>
</build>
pom.xml

Dans le fichier liquibase.properties

driver: ${filter.datasource.driver}
url: ${filter.datasource.url}
username: ${filter.datasource.username}
password: ${filter.datasource.password}
changeLogFile: src/main/resources/db/changelog/db-changelog-master.xml
liquibase.properties

Il suffit ensuite d'exécuter la commande suivante pour que votre base se mette à jour

mvn liquibase:update
update

Intégration spring

Pour cela je vous conseil de regarder la page http://www.liquibase.org/documentation/spring.html

Intégration spring-boot

L'objectif est de lancer liquibase au démarrage de votre appli pour s'assurer que votre base est toujours à jour.
Avec la configuration suivante :
Dans le pom.xml

<dependencies>
  <!-- Liquibase -->
  <dependency>
    <groupId>org.liquibase</groupId>
    <artifactId>liquibase-core</artifactId>
    <scope>runtime</scope>
  </dependency>
  <dependency>
    <groupId>com.mattbertolini</groupId>
    <artifactId>liquibase-slf4j</artifactId>
    <scope>runtime</scope>
  </dependency>
</dependencies>
pom.xml

Dans le application.properties

# DATASOURCE
...
# LIQUIBASE
liquibase.enabled=true
liquibase.change-log=classpath:/db/changelog/db-changelog-master.xml
application.properties

Au démarrage de l'application liquibase va automatique vérifier la base de données et appliquer les changements correspondants.

Intégration tests

A l'instar de dbunit, Liquibase permet aussi de peupler une BDD de test d'intégration.
Si vous avez déjà créé le changelog permettant l'initialisation de la BDD sur vos environnements de déploiement, il ne vous reste plus qu'à déclarer pour les tests un changelog contenant les données à peupler.

Si l'on continu dans l'exemple de l'intégration spring-boot. La déclaration dans le application.properties de test des propriétés liquibase vous garantie l'initialisation de la BDD. L'idéal étant que ce fichier ne charge que les modifications de structure pour laisser le test s'occuper de charger au setup du test les données qui lui sont spécifiques et de rollback ces modifications en fin de test.

Voici un exemple de classe gérant le chargement des fichiers liquibase unitairement :

package fr.netapsys.blog.liquibase;

import java.sql.Connection;
import java.sql.SQLException;
import java.util.Optional;

import javax.sql.DataSource;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import liquibase.Liquibase;
import liquibase.database.jvm.JdbcConnection;
import liquibase.exception.ChangeLogParseException;
import liquibase.exception.LiquibaseException;
import liquibase.resource.ClassLoaderResourceAccessor;

/**
 * Helper pour le chargement de données via liquibase.
 * 
 * Une utilisation type de cette classe pour les tests : <BR/>
 * Liquibase liquibase = LiquibaseHelper.loadData(dataSource, dataChangelogFile);
 * try{
 * .. code de test
 * } finally {
 * LiquibaseHelper.rollbackAndClose(liquibase);
 * }
 * 
 * @author Joan DAVID
 * @date 20 mars 2017
 * @version $Revision$ $Date$
 */
public class LiquibaseHelper
{
    /** Logger. */
    private static final Logger LOG = LoggerFactory.getLogger(LiquibaseHelper.class);

    /**
     * Import des données via un fichier liquibase.
     * @param dataSource
     * @param dataChangelogFile Le fichier de chargement
     * @return Une connection Liquibase optionnelle, si le fichier a été trouvé
     * @throws ExceptionMetier
     */
    public static Optional<Liquibase> loadData(final DataSource dataSource,
            final String dataChangelogFile)
    {
        try {
            final Connection connection = dataSource.getConnection();
            final Liquibase liquibase = new Liquibase(dataChangelogFile,
                    new ClassLoaderResourceAccessor(), new JdbcConnection(connection));
            liquibase.update("");
            return Optional.of(liquibase);
        } catch (final ChangeLogParseException e) {
            LOG.debug(e.getMessage(), e);
            return Optional.empty();
        } catch (LiquibaseException | SQLException e) {
            throw new RuntimeException(e.getMessage(), e);

        }
    }

    /**
     * Rollback les données liquibase et ferme la connection.
     * @param optLiquibase
     */
    public static void rollbackAndClose(final Optional<Liquibase> optLiquibase)
    {
        if (optLiquibase.isPresent()) {
            final Liquibase liquibase = optLiquibase.get();
            try {
                liquibase.rollback(1000, null);
                liquibase.getDatabase().close();
            } catch (final LiquibaseException e) {
                throw new RuntimeException(e.getMessage(), e);
            }
        }
    }

}
LiquibaseHelper.java

Il suffit alors d'ajouter à votre test la configuration suivante :

package fr.netapsys.blog.liquibase;

import java.util.List;
import java.util.Optional;

import javax.sql.DataSource;

import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.transaction.annotation.Transactional;

import liquibase.Liquibase;

/**
 * Ma classe de test
 * 
 * @author Joan DAVID
 * @date 20 mars 2017
 * @version $Revision$ $Date$
 */
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest
public class MyTest
{

    @Autowired
    private DataSource dataSource;
    @Autowired
    private JdbcTemplate jdbcTemplate;

    private Optional<Liquibase> liquibase;

    /**
     * Initialise le contexte de test.
     * @throws Exception
     */
    @Before
    public void _setUp() throws Exception
    {
        liquibase = LiquibaseHelper.loadData(dataSource,
                "db/changelog/MyTest/db-changelog-data.xml");
    }

    /**
     * Détruit le contexte de test.
     * @throws Exception
     */
    @After
    public void _tearDown() throws Exception
    {
        LiquibaseHelper.rollbackAndClose(liquibase);
    }

    /**
     * Mon test.
     */
    @Test
    @Transactional(readOnly = true)
    public void test()
    {
        // Test pouvant modifier les données transactionnellement
        final List<MaTable> query = jdbcTemplate.query("SELECT * FROM matable",
                new BeanPropertyRowMapper(MaTable.class));
        Assert.assertEquals(query.size(), 2);
    }
}
MyTest.java

Attention la Transactionnalité du test n'est pas ce qui permet la réinitialisation des données importées par liquibase entre deux tests. C'est bien la fonction de rollback qui doit être utilisée.
Pour cela il faut penser à rajouter dans le changelog le rollback à réaliser pour chaque changeSet.
Mon conseil et de déclarer un changeSet par table que l'on souhaite alimenter, contenant un rollback réalisant un deleteAll de la table.

<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
                      http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.1.xsd">
	<changeSet author="dev" id="matable-values">
		<insert tableName="matable">
			<column name="id" valueNumeric="123" />
			<column name="monChamp1" value="Valeur 1" />
		</insert>
		<insert tableName="matable">
			<column name="id" valueNumeric="456" />
			<column name="monChamp1" value="Valeur 2" />
			<column name="monChamp2" value="Valeur 3" />
		</insert>
		<rollback>
			<delete tableName="matable" />
		</rollback>
	</changeSet>
</databaseChangeLog>
db-changelog-data.xml

L'avantage de séparer le chargement de la structure de la base de données par spring-boot et le chargement spécifique pour chaque classe de test de son jeux de données et potentiellement de ses modifications temporaires de structure est double :

  1. Eviter les conflits de données lors de l’exécution d'une suite de test
  2. Garantir que nos tests sont toujours compatibles avec la structure de la base de données

 

Si ce blog vous a intéressé et que vous souhaitez approfondir vos connaissances n'hésitez pas à suivre ces liens :

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Captcha *