Handling von Datenbank-Updates

Logo von Liquibase

Die Verwaltung von Datenbank-Updates ist heute ein elementar Punkt innerhalb der Software-Entwicklung. In den vergangenen Jahren sind dazu eine Vielzahl von Frameworks entstanden. Wie baut man aber diese Frameworks nun richtig und flexibel in sein Projekt ein?

In diesem Artikel möchte ich kurz eine Möglichkeit beschreiben, wie man mit Liquibase (http://www.liquibase.org) und HSQLDB (http://hsqldb.org) ein einfaches Projektsetup aufsetzen kann, das automatisch Datenbank-Updates anwendet. HSQLDB dient dabei als „lokale“ Entwicklungsdatenbank, die später dann durch ein vollständiges DBMS ersetzt werden kann.

Die Idee hinter diesem Vorgehen ist jetzt einfach:

  • Jeder Entwickler soll seine eigene Datenbank zum Testen haben.
  • Die (Unit-)Testfälle sollen auf einem definierten und reproduzierbaren Datenbestand ausgeführt werden.
  • Die Datenbank-Updates sollen an einen zentralen und versionierten Ort gespeichert werden und jeder Entwickler kann diese erweitern.
  • Sie sollen automatisch bei den Entwicklern ausgeführt werden.
  • Angewendete Updates sollen nicht erneut ausgeführt werden.

Was ist also zu tun, um diese Anforderungen umzusetzen?

Zunächst werden in der pom.xml die erforderlichen Abhängigkeiten ergänzt. Zur Umsetzung brauchen wir folgende Abhängigkeiten:

<!-- HSQLDB -->
<dependency>
    <groupId>org.hsqldb</groupId>
    <artifactId>hsqldb</artifactId>
    <version>2.3.2</version>
</dependency>
<!-- Liquibase -->
<dependency>
    <groupId>org.liquibase</groupId>
    <artifactId>liquibase-core</artifactId>
    <version>3.2.2</version>
</dependency>
<dependency>
    <groupId>com.mattbertolini</groupId>
    <artifactId>liquibase-slf4j</artifactId>
    <version>1.2.1</version>
</dependency>

Als OR-Mapper setzen wir in diesem Beispiel Hibernate ein. Daher muss noch Hibernate als Maven Dependency ergänzt werden:

<!-- Hibernate -->
<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-core</artifactId>
    <version>4.3.6.Final</version>
</dependency>

<!-- JavaX -->
<dependency>
    <groupId>javax.persistence</groupId>
    <artifactId>persistence-api</artifactId>
    <version>1.0.2</version>
</dependency>

Für das Logging setzen wir auf log4j und slf4j:

<!-- Logging -->
<dependency>
    <groupId>log4j</groupId>
    <artifactId>log4j</artifactId>
    <version>1.2.17</version>
</dependency>

<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>1.7.7</version>
</dependency>
<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-log4j12</artifactId>
    <version>1.7.7</version>
</dependency>

Nun brauchen wir noch einen Update-Mechanismus, der die Datenbank-Änderungen in der Datenbank anwendet. In diesem Beispiel verwenden wir eine Hibernate-Konfiguration hibernate.cfg.xml, die über ein HibernateUtil geladen wird. Zunächst konfigurieren wir die Hibernate-Verbindung mit der HSQLDB:

<?xml version='1.0' encoding='utf-8'?>
<!DOCTYPE hibernate-configuration PUBLIC
        "-//Hibernate/Hibernate Configuration DTD 3.0//EN"
        "http://www.hibernate.org/dtd/hibernate-configuration-3.0.dtd">

<hibernate-configuration>

    <session-factory>

        <!-- Connection Einstellung -->
        <property name="connection.driver_class">org.hsqldb.jdbcDriver</property>
        <property name="connection.url">jdbc:hsqldb:file:./database/hibernate/db;shutdown=true</property>
        <property name="connection.username">sa</property>
        <property name="connection.password"/>

        <!-- JDBC Connection Pool -->
        <property name="connection.pool_size">5</property>

        <!-- SQL Dialect -->
        <property name="dialect">org.hibernate.dialect.HSQLDialect</property>

        <!-- Ausgabe der SQL Befehle -->
        <property name="show_sql">true</property>

        <!-- Erstellen oder aktualisieren der Datenbank-Tabellen -->
        <property name="hbm2ddl.auto">validate</property>

        <!-- Aktiviert Hibernate's automatic session context management -->
        <property name="current_session_context_class">thread</property>

        <!-- Mapping der Enity-Klassen -->
        <!--
        <mapping class="SomeEntities"/>
        -->

    </session-factory>

</hibernate-configuration>

Wir nutzen hier eine persistierte HSQLDB. Mit der URL jdbc:hsqldb:file:./database/hibernate/db wird eine lokale Datenbank-Datei im Verzeichnis ./database/hibernate mit dem Namen db erstellt. Alternativ kann auch eine reine In-Memory-Datenbank verwendet werden (siehe http://www.hsqldb.org/doc/guide/dbproperties-chapt.html).

Beim Starten der Anwendung wird nun der LiquibaseUpdater eingefügt. Dieser aktualisiert mittels der Methode update() über die Datenbank-Verbindung des HibernateUtils die bestehende Datenbank.

/**
 * Ausführung der Datenbank-Updates mittels Liquibase.
 *
 * @author Denis Wirries
 * @version 3.0
 * @since 14.02.14
 */
public class LiquibaseUpdater implements Updatable {

    private static final Logger LOGGER = LoggerFactory.getLogger(LiquibaseUpdater.class);

    private final String changelogFile;
    private final DatabaseConnectable connectable;

    /**
     * Erstellt einen neuen Liquibase-Updater. Die Updates werden über
     * das Properties-File <code>database/changelog.properties</code> geladen.
     * Die Properties-Datei liegt innerhalb des Classpath.
     *
     * @param connectable Stellt die Verbindung zur Datenbank her.
     */
    public LiquibaseUpdater(DatabaseConnectable connectable) {
        this(connectable, "database/changelog.properties");
    }

    /**
     * Erstellt einen neuen Liquibase-Updater. Die Updates werden über
     * das angegebene Properties-File geladen. Die Properties-Datei liegt
     * innerhalb des Classpath.
     *
     * @param connectable   Stellt die Verbindung zur Datenbank her.
     * @param changelogFile Properties-Datei für die Updates
     */
    public LiquibaseUpdater(DatabaseConnectable connectable, String changelogFile) {
        InputStream stream = ClassLoader.getSystemResourceAsStream(changelogFile);
        if (stream == null) throw new IllegalArgumentException("Properties " + changelogFile + " not exists");

        this.connectable = connectable;
        this.changelogFile = changelogFile;
    }

    /**
     * Führt die Aktualisierung der Datenbank durch.
     */
    public void update() throws UpdateException {
        try {
            final Connection connection = connectable.getConnection();
            final Database database = DatabaseFactory
                    .getInstance()
                    .findCorrectDatabaseImplementation(new JdbcConnection(connection));

            final List<String> updateResources = loadUpdateList();
            for (final String ur : updateResources)
                doUpdate(database, ur);

            LOGGER.info("Aktualisierung erfolgreich");
            connection.close();
        } catch (final Exception ex) {
            LOGGER.error("Datenbank konnte nicht aktualisiert werden", ex);
            throw new UpdateException("Datenbank konnte nicht aktualisiert werden", ex);
        }
    }


    /**
     * Führt die Aktualisierung der Datenbank mit der angegebenen Resource für das Update-File durch.
     *
     * @param database       Datenbank
     * @param updateResource Resource mit der Datenbank.
     * @throws LiquibaseException Fehler beim Aktualisieren
     */
    private void doUpdate(final Database database, final String updateResource) throws LiquibaseException {
        LOGGER.info("Aktualisiere Datenbank mit " + updateResource);
        final Liquibase liquibase = new Liquibase(updateResource, new ClassLoaderResourceAccessor(), database);
        liquibase.update("");
    }

    /**
     * Lädt die Liste der Updates.
     *
     * @return Liste mit Update-Ressourcen
     */
    protected List<String> loadUpdateList() throws UpdateException {
        final List<String> list = new ArrayList<String>();

        // Lade Properties Files
        final Properties props = new Properties();
        try {
            props.load(ClassLoader.getSystemResourceAsStream(changelogFile));
        } catch (final IOException ex) {
            LOGGER.error("Updates konnten nicht eingelesen werden", ex);
            throw new UpdateException("Updates konnten nicht eingelesen werden", ex);
        }

        // Da Keys als HashMap gespeichert werden, ist die Reihenfolge nicht gewährleistet
        final List<String> keys = new ArrayList<String>();
        for (final Object key : props.keySet()) {
            keys.add((String) key);
        }
        Collections.sort(keys);

        // Schlüssel in richtiger Reihenfolge auslesen
        for (final String key : keys) {
            list.add(props.getProperty(key));
        }

        return list;
    }

}

Die Updates befinden sich dabei im Verzeichnis database und werden in der Datei changelog.properties eintragen.

#
# Updates, die in der angegebenen Reihenfolge ausgeführt werden sollen.
# Die XML-Dateien sind gemäß dem Liquibase-Update-Mechanismus aufgebaut.
#
0001=database/changelog.0.1.0.xml
0002=database/changelog.0.1.1.xml
0003=database/changelog.0.1.2.xml

Es empfiehlt sich die Updates z.B. nach Releases aufzuteilen, damit die Update-Dateien übersichtlich bleiben. Jede Update-Datei ist dabei eine Changelog-Datei des Liquibase-Frameworks (Inhaltlich siehe http://www.liquibase.org/documentation/changeset.html).

<?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">

    <!-- More informations under: http://www.liquibase.org/documentation/databasechangelog.html -->

    <!-- Create-Table: Table A -->
    <changeSet id="0.1.0-CREATE-TABLEA" author="Denis Wirries">
        <createTable tableName="tableA">
            <column name="id" type="int" autoIncrement="true">
                <constraints primaryKey="true"/>
            </column>
            <column name="attributeA" type="varchar(64)">
                <constraints nullable="false"/>
            </column>
            <column name="attrubuteB" type="varchar(64)">
                <constraints nullable="true"/>
            </column>
            <column name="lastUpdated" type="date">
                <constraints nullable="false"/>
            </column>
        </createTable>
    </changeSet>

</databaseChangeLog>

In diesen ChangeLog-Dateien können verschiedene Aktionen ausgeführt, z.B. Tabellen erstellen, Daten einfügen oder andere SQL-Skripte ausführen. Dabei kann über einen zusätzlichen Parameter „dbms“ gesteuert werden, in welchen Datenbank der Befehl ausgeführt werden soll. Z.B. dbms=“mysql“ für MySQL-Datenbanken. Somit lassen sich auch „spezielle“ Konstrukte in den spezifischen Datenbank-Systemen umsetzen.

Über unterschiedliche Hibernate-Konfigurationen kann nun einfach zwischen unterschiedlichen Datenbanken gewechselt werden. Beim Starten prüft Liquibase nun welche Updates bereits vorhanden sind. Fehlende Updates werden automatisch angewendet und die Datenbank passt zum entsprechenden Source Code-Stand. Durch die Ablage der Änderungsdateien in der Versionsverwaltung hat man immer den aktuellen Änderungsstand.

Aber Achtung: wurde ein ChangeSet bereits in der Datenbank angewendet und wird dann nachträglich noch verändert führt dies zu einem Fehler beim Starten des Updates. In diesem Fall muss entweder die Datenbank zurückgesetzt werden oder das Update manuell rückgängig machen.

Das vollständige Beispiel findet sich in meinem GitHub-Repository unter https://github.com/denisw160/LiquibaseUpdate/releases/tag/0.1.

 

 

 

 

 

1 comment for “Handling von Datenbank-Updates

Schreibe einen Kommentar