Hibernate_logo_a

Heute schreibst Du Geschichte – Entity Auditing mit Hibernate Envers

Hallo zusammen,

JPA und Hibernate machen uns das Leben seit Jahres sehr einfach. Persistenz ist mittlerweile keine Schwierigkeit mehr, sondern man macht es einfach. Probleme treten maximal in Randbezirken auf, im Großen und Ganzen ist sowohl die Verwendung als auch die Performance der ORM Mapper (sei es nun Hibernate, EclipseLink oder ein beliebig anderer) mehr als gut.

Aber Persistenz ist nur ein Use-Case einer Enterprise Anwendung. Ein weiterer interessanter Use-Case ist das Auditing von Entities. Auditing beschreibt hierbei quasi eine Historisierung von Entities und Aktionen darauf.

Man könnte diese Funktionalität jetzt quasi selber implementieren. Das gestaltet sich aber gar nicht so einfach, da ein EntityManager zu jeder Zeit nur eine Version einer Entity laden kann. Eine manuelle Implementierung müsste mit Hilfe eines EntityListeners an eine alte Version der Entity kommen, berechnen, welche Daten verändert worden sind und eine weitere Revision speichern. Klingt kompliziert, ist aber tatsächlich noch schwieriger als es sich anhört.

Lassen wir das…

Das Erste was wir machen ist uns ein einfaches Projekt zu erzeugen mit Hibernate als Persistenzprovider. Hierfür definiere ich folgende POM, in der u.a. alle notwendigen Versionen für Spring, Spring-Data, Hibernate und JPA deklariert sind.

Für diesen Use-Case implementieren wir kein Frontend, sondern wir werden uns allein auf Unit-Tests verlassen (die ja letztendlich sowieso die Einzige Wahrheit liefern).

<?xml version="1.0" encoding="UTF-8"?>
<!--
   Licensed to the Apache Software Foundation (ASF) under one or more
   contributor license agreements.  See the NOTICE file distributed with
   this work for additional information regarding copyright ownership.
   The ASF licenses this file to You under the Apache License, Version 2.0
   (the "License"); you may not use this file except in compliance with
   the License.  You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

   Unless required by applicable law or agreed to in writing, software
   distributed under the License is distributed on an "AS IS" BASIS,
   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
   See the License for the specific language governing permissions and
   limitations under the License.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">

    <modelVersion>4.0.0</modelVersion>
    <groupId>de.effectivetrainings</groupId>
    <artifactId>hibernate-envers</artifactId>
    <packaging>jar</packaging>
    <version>1.0-SNAPSHOT</version>
    <name>Hibernate Envers</name>
    <description></description>

    <properties>
        <jetty.version>7.6.3.v20120416</jetty.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <spring.version>3.1.3.RELEASE</spring.version>
        <spring.security.version>3.1.3.RELEASE</spring.security.version>
        <hibernate.version>4.1.2.Final</hibernate.version>
    </properties>
    <dependencies>

        <!-- SPRING AND SPRING-DATA -->
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-core</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-aspects</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-aop</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-asm</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-beans</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-context-support</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-web</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-webmvc</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-orm</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-tx</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-test</artifactId>
            <version>${spring.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.data</groupId>
            <artifactId>spring-data-jpa</artifactId>
            <version>1.3.0.RELEASE</version>
        </dependency>

        <!-- HIBERNATE -->
        <dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-core</artifactId>
            <version>${hibernate.version}</version>
        </dependency>
        <dependency>
            <groupId>org.hibernate.javax.persistence</groupId>
            <artifactId>hibernate-jpa-2.0-api</artifactId>
            <version>1.0.1.Final</version>
        </dependency>
        <dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-entitymanager</artifactId>
            <version>${hibernate.version}</version>
        </dependency>

        <!-- LOGGING DEPENDENCIES - LOG4J -->
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-log4j12</artifactId>
            <version>1.6.4</version>
        </dependency>
        <dependency>
            <groupId>log4j</groupId>
            <artifactId>log4j</artifactId>
            <version>1.2.16</version>
        </dependency>

        <!--  JUNIT DEPENDENCY FOR TESTING -->
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.10</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>javax.inject</groupId>
            <artifactId>javax.inject</artifactId>
            <version>1</version>
        </dependency>

        <dependency>
            <groupId>org.hsqldb</groupId>
            <artifactId>hsqldb</artifactId>
            <version>2.2.9</version>
        </dependency>

    </dependencies>
    <build>
        <resources>
            <resource>
                <filtering>false</filtering>
                <directory>src/main/resources</directory>
            </resource>
            <resource>
                <filtering>false</filtering>
                <directory>src/main/java</directory>
                <includes>
                    <include>**</include>
                </includes>
                <excludes>
                    <exclude>**/*.java</exclude>
                </excludes>
            </resource>
        </resources>
        <testResources>
            <testResource>
                <filtering>false</filtering>
                <directory>src/test/resources</directory>
            </testResource>
            <testResource>
                <filtering>false</filtering>
                <directory>src/test/java</directory>
                <includes>
                    <include>**</include>
                </includes>
                <excludes>
                    <exclude>**/*.java</exclude>
                </excludes>
            </testResource>
        </testResources>
        <plugins>
            <plugin>
                <inherited>true</inherited>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>2.5.1</version>
                <configuration>
                    <source>1.6</source>
                    <target>1.6</target>
                    <encoding>UTF-8</encoding>
                    <showWarnings>true</showWarnings>
                    <showDeprecation>true</showDeprecation>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.mortbay.jetty</groupId>
                <artifactId>jetty-maven-plugin</artifactId>
                <version>${jetty.version}</version>
                <configuration>
                    <connectors>
                        <connector implementation="org.eclipse.jetty.server.nio.SelectChannelConnector">
                            <port>8080</port>
                            <maxIdleTime>3600000</maxIdleTime>
                        </connector>
                        <connector implementation="org.eclipse.jetty.server.ssl.SslSocketConnector">
                            <port>8443</port>
                            <maxIdleTime>3600000</maxIdleTime>
                            <keystore>${project.build.directory}/test-classes/keystore</keystore>
                            <password>wicket</password>
                            <keyPassword>wicket</keyPassword>
                        </connector>
                    </connectors>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-eclipse-plugin</artifactId>
                <version>2.9</version>
                <configuration>
                    <downloadSources>true</downloadSources>
                </configuration>
            </plugin>
        </plugins>
    </build>

    <repositories>
        <repository>
            <id>Apache Nexus</id>
            <url>https://repository.apache.org/content/repositories/snapshots/</url>
            <releases>
                <enabled>false</enabled>
            </releases>
            <snapshots>
                <enabled>true</enabled>
            </snapshots>
        </repository>
    </repositories>
</project>

 Spring-Konfiguration

Nachdem wir alle notwendigen Abhängigkeiten deklariert haben definieren wir uns einen einfachen Spring-Context (und ja, das machen wir in XML, weil gerade das Bootstrapping von JPA/Hibernate in XML eleganter ist als in Java – meine Meinung).

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:jdbc="http://www.springframework.org/schema/jdbc"
       xmlns:tx="http://www.springframework.org/schema/tx" xmlns:context="http://www.springframework.org/schema/context"
       xmlns:util="http://www.springframework.org/schema/util"
       xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/jdbc http://www.springframework.org/schema/jdbc/spring-jdbc.xsd http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx.xsd http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd http://www.springframework.org/schema/util http://www.springframework.org/schema/util/spring-util.xsd">

    <tx:jta-transaction-manager/>
    <tx:annotation-driven/>
    <context:annotation-config/>
    <jpa:repositories base-package="de.effectivetrainings"/>
    <bean name="emfactory" class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean">
        <property name="jpaDialect" ref="jpaDialect"/>
        <property name="packagesToScan" value="de.effectivetrainings"/>
        <property name="jpaVendorAdapter" ref="jpaVendorAdapter"/>
        <property name="dataSource" ref="db"/>
        <property name="jpaProperties" ref="jpaProperties"/>
    </bean>

    <bean id="jpaDialect" class="org.springframework.orm.jpa.vendor.HibernateJpaDialect"/>
    <bean id="jpaVendorAdapter" class="org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter"/>

    <bean name="transactionManager" class="org.springframework.orm.jpa.JpaTransactionManager">
        <property name="entityManagerFactory" ref="emfactory"/>
    </bean>

    <util:properties id="jpaProperties">
        <prop key="hibernate.hbm2ddl.auto">create</prop>
    </util:properties>

    <jdbc:embedded-database id="db" type="HSQL"/>

</beans>

Dieser Spring-Context sorgt dafür, dass

  • alle JPA-Entities automatisch gewired sind
  • das Tabellen automatisch generiert werden
  • das wir eine Embedded-HSQL Datenbank haben, gegen die wir uns verbinden können
  • das alle Spring-Data Repositories und auch alle Spring-Sevices automatisch gewired werden

Bootstrapping mit JUnit

Höchste Zeit, das wir den Spring-Container zum ersten mal hochfahren. Hierfür definieren wir den folgenden Test.

@ContextConfiguration(value = "classpath:spring-context.xml")
public class SpringBootstrapTest extends AbstractJUnit4SpringContextTests {

    @Test
    public void configure(){
        //nothing to do, just bootstrap
    }
}

Ein einfaches Entity-Modell

Wir definieren uns ein einfaches Entity-Modell.

@Entity
public class Customer implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String name;
    private String email;

    @OneToOne(cascade = CascadeType.ALL)
    private Address address;

    public Customer() {
    }

    public Customer(String name, String email, Address address) {
        this.name = name;
        this.email = email;
        this.address = address;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public String getEmail() {
        return email;
    }

    public Address getAddress() {
        return address;
    }
}
@Entity
public class Address {

    @Id
    @GeneratedValue
    private Long id;

    private String street;
    private String zip;
    private String city;

    public Address() {
    }

    public Address(String street, String zip, String city) {
        this.street = street;
        this.zip = zip;
        this.city = city;
    }

    public String getStreet() {
        return street;
    }

    public String getZip() {
        return zip;
    }

    public String getCity() {
        return city;
    }
}

Ein zugehöriges Repository um Daten zu laden und zu speichern.

public interface CustomerRepository extends JpaRepository<Customer, Long> {
}

Und folgenden Test, der das Speichern und Laden eines Kunden testet.

@ContextConfiguration(value = "classpath:spring-context.xml")
public class SpringBootstrapTest extends AbstractJUnit4SpringContextTests {

    @Inject
    private CustomerRepository customerRepository;

    @Test
    public void configure(){
        assertNotNull(customerRepository);
    }

    @Test
    public void saveCustomer(){

        assertTrue(customerRepository.findAll().isEmpty());

        Address address = new Address("test-street","80805","München");
        Customer customer = new Customer("Hans","test@test.de", address);
        Customer persistedCustomer = customerRepository.save(customer);

        Customer dbCustomer= customerRepository.findOne(persistedCustomer.getId());
        assertNotNull(dbCustomer);

        //some simple sanity checks
        assertEquals(dbCustomer.getName(),"Hans");
        assertEquals(dbCustomer.getAddress().getStreet(),"test-street");
    }
}

 Wir schreiben Geschichte – Introducing Hibernate Envers

Was wir jetzt gerne möchten ist die Historisierung unserer Änderungen in der Datenbank. Hierfür verwenden wir Hibernate Envers. Zunächst deklarieren wir die Abhängigkeit auf Envers in der POM.

<dependency>
            <groupId>org.hibernate</groupId>
            <artifactId>hibernate-envers</artifactId>
            <version>${hibernate.version}</version>
</dependency>

Nachdem Envers bereit steht müssen wir nur noch konfigurieren, dass beim Speichern einer Entity auch jeweils eine Revision gespeichert wird.

Seit Envers 4.x werden automatisch die entsprechenden EntityListener registriert, die notwendig sind (mit Envers 3.x musste dies noch manuell gemacht werden).

Wir deklarieren unsere Entities als “auditierbar” mit der Annotation @Audited.

@Entity
@Audited
public class Customer implements Serializable {}

@Entity
@Audited
public class Address {}

Das war es schon, Hibernate wird jedesmal, wenn wir eine Änderung vornehmen und speichern eine neue sogenannte Revision speichern, auf die zu einem späteren Zeitpunkt zugegriffen werden kann. Das SQL zum Speichern eines Customers ist folgendes.

Hibernate: insert into Address (id, city, street, zip) values (default, ?, ?, ?)
Hibernate: insert into Customer (id, address_id, email, name) values (default, ?, ?, ?)
Hibernate: insert into REVINFO (REV, REVTSTMP) values (default, ?)
Hibernate: insert into Address_AUD (REVTYPE, city, street, zip, id, REV) values (?, ?, ?, ?, ?, ?)
Hibernate: insert into Customer_AUD (REVTYPE, email, name, address_id, id, REV) values (?, ?, ?, ?, ?, ?)

Wir sehen, es wurden drei neue Tabellen angelegt

  • REVINFO – für jede Änderung wird eine neue Revision angelegt und in der Tabelle REVINFO gespeichert. Eine Revision bezieht sich immer auf eine Änderung an einer Entity zu einem bestimmten Zeitpunkt.
  • Address_AUD – Hier werden Änderungen für die Address-Entity gespeichert, aktuell landen alle Attribute in unserem Audit, das heisst für jede Änderung wird quasi eine Kopie der alten Address-Entity gespeichert.
  • Customer_AUD – analog Address_AUD werden hier die Audit-Informationen für die Customer-Entity gespeichert.

Die Namen der Audit-Tabellen lässt sich bequem über die Annotation @AuditTable festlegen.

@Entity
@Audited
@AuditTable(value = "Address_History")
public class Address {}

@Entity
@Audited
@AuditTable(value = "Customer_History")
public class Customer implements Serializable {}

Geschichtsstunde – Der Auditmanager

Ein einfacher Test zum Auslesen der Customer-Historie sieht so aus

@Test
public void checkAudit() throws Exception {

        assertTrue(customerRepository.findAll().isEmpty());

        Address address = new Address("test-street", "80805", "München");
        Customer customer = new Customer("Hans", "test@test.de", address);
        Customer persistedCustomer = customerRepository.save(customer);

        //make some changes
        persistedCustomer.setName("Georg");
        persistedCustomer = customerRepository.save(persistedCustomer);

        EntityManager entityManager = entityManagerFactory.createEntityManager();
        AuditReader auditReader = AuditReaderFactory.get(entityManager);

        List<Number> revisions = auditReader.getRevisions(Customer.class, persistedCustomer.getId());
        assertEquals(revisions.size(), 2);

        //delete customer
        customerRepository.delete(persistedCustomer);

        AuditQuery query=auditReader.createQuery().forRevisionsOfEntity(Customer.class, true, true);
        List<Customer> result = query.getResultList();
        assertEquals(result.size(),3);

}

Zunächst speichern wir einen neuen Kunden, ändern anschließend dessen Namen und löschen den Kunden anschließend.

Zum Auslesen der Kundenhistorie mit Envers kann der mächtige AuditReader verwendet werden. Der AuditReader bringt eine einfache Query-API mit.

Die einfachste Query, die uns alle Revisionen von ALLEN Kunden liefert ist diese

AuditQuery query=auditReader.createQuery().forRevisionsOfEntity(Customer.class, true, true);
List<Customer> result = query.getResultList();

Die Methode forRevisionOfEntity erwartet mehrere Parameter.

  1. Typ der Entität
  2. Sollen nur die Entitäten (Customer) zurückgeliefert werden oder Array[3]-Elemente mit Entität, Revisionsnummer und RevisionsTyp (DELETE, ADD, MODIFY)

Es ist natürlich wenig performant, sich ständig alle Revisionen für alle Entitäten ausgeben zu lassen.

AuditManager – Finetuning

Wir speichern uns einfach einen zweiten Kunden.

 Address anotherAddres = new Address("test-street", "80805", "München");
 Customer anotherCustomer = new Customer("Heinz","test@test.de", anotherAddres);
 customerRepository.save(anotherCustomer);

Um nur die Historie eines bestimmten Kunden zu laden erweitern wir die Query um eine Expression.

AuditQuery query=auditReader.createQuery().forRevisionsOfEntity(Customer.class, true, true);
query.add(new IdentifierEqAuditExpression(persistedCustomer.getId(),true));
List<Customer> result = query.getResultList();
assertEquals(result.size(),3);

Envers bringt eine ganze Reihe von Criterions mit.

Bildschirmfoto 2013-05-20 um 19.26.21

Verfügbare Criterions in Enver

Wann werden gleich nochmal die Revisions gespeichert?

Nehmen wir folgenden Testcase

 @Test
    public void saveRevisionIfEmbeddedEntityChanges(){
        Address address = new Address("test-street", "80805", "München");
        Customer customer = new Customer("Hans", "test@test.de", address);
        Customer persistedCustomer = customerRepository.save(customer);

        persistedCustomer.getAddress().setStreet("Teststraße 4");
        customerRepository.save(persistedCustomer);

        AuditReader auditReader = createAuditReader();
        assertEquals(2,auditReader.getRevisions(Customer.class,persistedCustomer.getId()).size());
    }

Dieser Test schlägt fehl. Wir ändern ein Attribut der eingebetteten Address-Instanz. Hierdurch wird zwar eine neue Revision der Address-Entity gespeichert, aber keine für die “Eltern”-Entität Customer. Normalerweise würde man aber erwarten, dass eine neue Revision gespeichert wird, wann immer sich etwas ändert und sei dies nur ein Attribut einer Instanzvariablen.

Leider ist dies nicht ohne weiteres möglich, wir behelfen uns aber mit einem Workaround. Wir erweitern den Kunden um ein ChangeDate – ein Datum, wann der Kunde zuletzt geändert wurde. Dieses Datum setzen wir jedesmal neu, wenn ein Kunde gespeichert oder geändert wird. Hierdurch erhalten wir jeweils eine neue Revision. Wir brauchen natürlich viel mehr Speicher aber das Handling wird einfacher.

@Column(name = "CHANGE_DATE")
@Temporal(value = TemporalType.TIME)
private Date changeDate;

...
@PrePersist
@PreUpdate
protected void updateChangeDate(){
   this.changeDate = new Date();
}

Nach dieser Änderung ist unser Test grün und es ist sichergestellt, dass wir für jede Änderung eine neue Revision bekommen.

Fazit

Ich glaube, es geht kaum einfacher als mit Envers Entitäten zu auditieren. Alleine die Vorstellung dies selbst zu implementieren lässt mich erschauern. Meine Empfehlung – ganz klar einfach verwenden.

Der Code findet sich natürlich wieder in einem GitHub Repository.

Links

Envers Dokumentation

GitHub-Repository

 

 

Effective Trainings & Consulting - Martin Dilger



Hat Ihnen dieser Blog-Eintrag gefallen? Ich stelle in diesem Blog Informationen über Tools, Frameworks und Werkzeuge zur Verfügung, die mich produktiver machen. Vielleicht kann ich auch Ihnen helfen, produktiver zu werden.


Ich unterstütze Sie als freier Mitarbeiter bei der Entwicklung von Software-Projekten, Agiler Arbeit sowie Schulungen / Fortbildungen.


Jeden Tag ein bisschen produktiver - ab heute