CodeGym /Kurse /Hibernate deep-dive /Lesen der Historie über AuditReader

Lesen der Historie über AuditReader

Hibernate deep-dive
Level 21, Lektion 3
Verfügbar

1. Rolle von AuditReader

Sobald die Audit-Grenze definiert ist, stellt sich schnell die nächste praktische Hürde: das manuelle Lesen von *_AUD ist unbequem.

Wenn Sie jemals eine Audit-Tabelle geöffnet und versucht haben, die Historie „auf Sicht“ wiederherzustellen, kennen Sie das Gefühl: Alles ist da, aber das Gehirn beginnt leise um Urlaub zu bitten. *_AUD – das sind Daten im „Maschinenformat“: bequem zu speichern, aber unbequem zu lesen, besonders wenn nicht nur eine Zeile, sondern eine Abfolge von Ereignissen benötigt wird. AuditReader macht dasselbe, aber in einer menschenfreundlichen API: er liefert eine Liste der Revisionen, ermöglicht es, einen Snapshot einer Entität zu einer Revision zu nehmen und hilft, eine verständliche Timeline der Änderungen zu erstellen, ohne dass Sie manuell REVINFO, REVTYPE und die Spalten von *_AUD zusammenfummeln müssen.

Der wichtigste Gedanke: Envers speichert die Historie als Daten, also wollen wir sie auch als Daten lesen – über eine API, in Java-Code, innerhalb eines Service-Szenarios, das getestet und weiterentwickelt werden kann.

Um das Modell visuell zu verankern, kann man sich folgenden Ablauf vorstellen:

flowchart TD
    A["Service-Methode
@Transactional(readOnly=true)"] --> B[EntityManager] B --> C["AuditReaderFactory.get(em)"] C --> D[AuditReader] D --> E["REVINFO + *_AUD"] E --> F["Entity-Snapshots bei Revisionen"]

Abrufen von AuditReader in einer Spring-Anwendung

AuditReader existiert nicht für sich allein. Er wird „über“ Ihren normalen EntityManager (genauer gesagt über die Hibernate Session darin) erstellt. Der wichtigste praktische Punkt: Um AuditReader zu erhalten, benötigen Sie einen lebenden EntityManager im Transaktionskontext. In unserem Kurs-Baseline ist spring.jpa.open-in-view=false, also kann man nicht darauf hoffen, dass „es sich irgendwie aus der Webschicht liest“ – und das ist auch nicht nötig: Lesen Sie die Historie dort, wo sie hingehört: im Service, innerhalb von @Transactional(readOnly = true).

Im Projekt Commerce Persistence Lab ist es praktisch, den Code zum Lesen der Historie im Paket com.example.commerce.audit.query zu halten: es handelt sich um Query-Logik, nicht um Zustandsänderungen.

Ein minimales Grundgerüst für einen Service zum Lesen der Envers-Historie könnte so aussehen:

import jakarta.persistence.EntityManager;
import org.hibernate.envers.AuditReader;
import org.hibernate.envers.AuditReaderFactory;
import org.springframework.stereotype.Service;

@Service
class ProductAuditQueryService {
    // EntityManager muss „lebendig“ sein, daher setzt der Service einen Aufruf aus @Transactional voraus
    private final EntityManager em;

    ProductAuditQueryService(EntityManager em) {
        // Injiziere normalen EntityManager; AuditReader wird darauf aufgebaut
        this.em = em;
    }

    AuditReader reader() {
        // Erhalte AuditReader, gebunden an den aktuellen EntityManager/Session
        return AuditReaderFactory.get(em);
    }
}

Beachten Sie die Ironie der Situation: Um durch die Vergangenheit zu reisen, brauchen wir … einen normalen EntityManager. Keine Portale, Blitze oder DeLorean – nur Spring und Hibernate. Langweilig, aber es funktioniert.

2. Revisionen und Snapshots einer Entität

Liste der Revisionen getRevisions(entityClass, id)

Der erste Schritt beim Lesen der Historie ist meist sehr bodenständig: Wir wollen wissen, bei welchen Revisionen die Entität überhaupt geändert wurde. Envers bietet dafür eine direkte Methode: AuditReader#getRevisions(Class, id). Sie gibt eine Liste von Revisionsnummern zurück (List<Number>). Dies sind nicht „Versionen der Entität“ und nicht @Version, sondern Envers-Revisionen – globale historische Punkte, die beim Commit von Transaktionen erstellt werden, in denen audited-Entitäten geändert wurden.

Es gibt zwei Nuancen, die sofort klar sein sollten, sonst gibt es später Verwirrung. Erstens: Die Liste der Revisionen muss nicht aufeinanderfolgend sein: 1, 2, 3 ist ein Traum, die Realität ist eher 12, 18, 27, weil die Revision für das gesamte System gilt, nicht nur für ein einzelnes Produkt. Zweitens: Eine Revision wird pro Transaktion erstellt: Wenn Sie in einer Transaktion sowohl Product als auch PurchaseOrder geändert haben, erhalten beide dieselbe Revisionsnummer.

Beispiel einer Methode, die die Revisionen eines Produkts zurückgibt:

import java.util.List;
import org.springframework.transaction.annotation.Transactional;

@Transactional(readOnly = true)
public List<Number> getProductRevisions(long productId) {
    // Gibt die Envers-Revisionsnummern zurück, nicht @Version
    // reader() ist der über AuditReaderFactory.get(EntityManager) erhaltene AuditReader
    return reader().getRevisions(
            com.example.commerce.catalog.entity.Product.class,
            productId
    );
}

Um den Unterschied zwischen „Revision“ und „Version“ zu verfestigen, hilft ein direkter Vergleich:

Begriff Wo gespeichert Wozu benötigt Ändert sich, wenn
@Version (Optimistic Locking) In der Haupttabelle der Entität Schützt vor Lost Update Bei jedem UPDATE der Entität (im Rahmen des ORM-Updates)
Envers-Revision (REVINFO.id) In REVINFO + *_AUD Speichert den Zustandsverlauf Wenn eine Transaktion mit Änderungen an audited-Daten committet wird

Wenn man dies im Kopf behält, wird es einfacher, den „Mechanismus der Korrektheit bei konkurrierendem Schreiben“ und den „Mechanismus der Historizität“ nicht zu verwechseln.

Snapshot zu einer Revision reader.find(entityClass, id, revision)

Wenn wir die Revisionsnummern haben, ist der nächste logische Schritt, einen Snapshot der Entität zur ausgewählten Revision zu nehmen. Dafür gibt es die Methode AuditReader#find(Class, id, revision). Sie liefert ein Entitätsobjekt, das mit Daten aus den Audit-Tabellen gefüllt ist – also den rekonstruierten Zustand „wie damals“. Ein solches Objekt sollte als Read-Only-Snapshot betrachtet werden: Man kann es lesen, vergleichen, in DTOs umwandeln, dem Benutzer anzeigen oder in einem Bericht ablegen. Aber die Idee „Ich ändere es und rufe dann save auf“ ist ein direktes Ticket in den Club der seltsamen Bugs (und das wollen wir vermeiden).

Und hier ist es wichtig, die Audit-Grenze zu beachten. Envers stellt nicht „eine vollständig wiederbelebte Entität aus der Vergangenheit“ wieder her, sondern den historischen Ausschnitt, den Sie tatsächlich gespeichert haben. Wenn bei Product nur price, status und deleted in die Historie aufgenommen wurden, und bei PurchaseOrder die items ausgeschlossen sind, dann lesen Sie genau diesen Ausschnitt – daher ist es so naheliegend, die Historie in schmale DTOs/Read-Modelle zu mappen.

Das Angenehmste an find(...) ist, dass es sich so liest, als würden Sie einen normalen entityManager.find(...) ausführen, nur mit einem zusätzlichen Zeitparameter in Form von revision. Geradlinig und ohne überflüssige Magie.

Beispiel für Product:

import org.springframework.transaction.annotation.Transactional;

@Transactional(readOnly = true)
public com.example.commerce.catalog.entity.Product getProductAtRevision(
        long productId, Number revision
) {
    // find(...) gibt einen „Snapshot“ aus *_AUD zur angegebenen Revision zurück
    // Dieses Objekt sollte besser als Read-Only-Daten für Anzeige/Vergleich betrachtet werden
    return reader().find(
            com.example.commerce.catalog.entity.Product.class,
            productId,
            revision
    );
}

Auf der Denkebene kann man es sich gut als „Fotoalbum öffnen“ vorstellen. Die Haupttabelle ist das „aktuelle Profilfoto“, Envers das Archiv der Schnappschüsse. find(..., revision) bedeutet „zeig mir das Foto auf Seite 17“.

3. Historie als Ereignisse: Typen und Abfragen

RevisionType und die Abfrage forRevisionsOfEntity(...)

Manchmal reicht ein einzelner Snapshot nicht. Wenn Sie beispielsweise einen Ereignisfeed aufbauen und nicht nur den Zustand zu einer Revision sehen, sondern auch den Änderungstyp: war es ein Hinzufügen (ADD), eine Änderung (MOD) oder ein Löschen (DEL). In Envers wird das durch RevisionType ausgedrückt (sinngemäß fast dasselbe wie REVTYPE, nur als Enum). Um RevisionType zu erhalten, verwendet man üblicherweise eine Audit-Abfrage, die nicht nur die Entität, sondern auch die Revisionsmetadaten zurückgibt.

Hier kommt eine wichtige Konstruktion ins Spiel: reader.createQuery().forRevisionsOfEntity(...). Sie hat zwei boolesche Flags, die oft verwechselt werden, daher besprechen wir sie in menschlicher Sprache. Wenn selectEntitiesOnly=true, erhalten Sie nur eine Liste von Entitäten (ohne Revision-Entity und Revision-Type). Wenn selectEntitiesOnly=false, gibt Envers Zeilen als Object[] zurück, die enthalten: 1) Entitäts-Snapshot, 2) Revision-Entity, 3) RevisionType. Das zweite Flag selectDeletedEntities bestimmt, ob Löschungen in die Ergebnisse aufgenommen werden.

Eine einfache Abfrage für die Historie eines einzelnen Product anhand der ID sieht so aus:

import java.util.List;
import org.hibernate.envers.query.AuditEntity;
import org.springframework.transaction.annotation.Transactional;

@Transactional(readOnly = true)
public List<Object[]> getProductRevisionRows(long productId) {
    // Struktur jeder Ergebniszeile (Object[]):
    // row[0] – Entitätssnapshot zur Revision (Product)
    // row[1] – Revision-Entity (normalerweise DefaultRevisionEntity)
    // row[2] – Änderungstyp (RevisionType: ADD/MOD/DEL)
    return reader().createQuery()
            .forRevisionsOfEntity(
                    com.example.commerce.catalog.entity.Product.class,
                    false,   // wir möchten nicht nur die Entität
                    true     // wir möchten auch DEL sehen, falls vorhanden
            )
            // Filtere die Historie auf die spezifische ID, sonst erhält man „die Geschichte von allem“
            .add(AuditEntity.id().eq(productId))
            .getResultList();
}

Ja, Object[] ist nicht das romantischste Datenformat der Welt. Aber es ist der ehrliche Preis dafür, dass wir noch keine komplexe Infrastruktur um die Historie herum aufbauen. In einem praktischen Anwendungsfall werden solche Zeilen normalerweise sofort in ein vernünftiges Timeline-Modell umgewandelt, aber hier ist es wichtiger, die Mechanik selbst zu verstehen.

AuditEntity: Filtern und Sortieren der Historie

AuditEntity ist der criterionbasierte DSL von Envers für Audit-Abfragen. Es ähnelt dem Criteria API im Geiste, ist aber in seiner Aufgabe viel einfacher: Wir beschreiben Bedingungen speziell für die Historie, nicht für Live-Tabellen. In der Praxis wird AuditEntity fast immer benötigt, denn ohne Filter erhalten Sie leicht die Historie aller Produkte auf einmal – das ähnelt dem „versehentlichen Ausdrucken des Internets auf dem Drucker“.

Der häufigste Filter ist nach ID (AuditEntity.id().eq(...)). Und die häufigste Sortierung ist nach Revisionsnummer, damit die Ereignisse zeitlich geordnet sind. Meistens möchte man asc, also von frühen zu späten, um die Historie wie ein normaler Mensch von links nach rechts zu lesen.

Fügen wir die Sortierung hinzu:

import java.util.List;
import org.hibernate.envers.query.AuditEntity;
import org.springframework.transaction.annotation.Transactional;

@Transactional(readOnly = true)
public List<Object[]> getProductRevisionRowsOrdered(long productId) {
    // Dasselbe wie in der vorherigen Methode, aber mit expliziter Sortierung nach Revisionsnummer
    return reader().createQuery()
            .forRevisionsOfEntity(
                    com.example.commerce.catalog.entity.Product.class,
                    false, // gebe Object[] zurück (Entität + Revision-Entity + Typ)
                    true   // schließe gelöschte Entitäten (DEL) ein, falls vorhanden
            )
            .add(AuditEntity.id().eq(productId))
            // Sortiere von frühen zu späten Revisionen, um eine „natürliche“ Timeline zu erhalten
            .addOrder(AuditEntity.revisionNumber().asc())
            .getResultList();
}

Jetzt erhalten wir die Historienzeilen in natürlicher Reihenfolge. Und ja – „Sortierung nach Revision“ ist in Envers in der Regel gleich „Sortierung nach Zeit“, da die Revision steigt. Das ist kein Ersatz für einen Timestamp (dazu später), aber eine sehr praktische Ordnung.

4. Metadaten von Revisionen und Modelle

Datum und Uhrzeit einer Revision

Die Revisionsnummer ist praktisch als „laufende Ereignisnummer“, aber Menschen fragen doch eher „wann ist das passiert?“. Envers speichert den Timestamp der Revision (in REVINFO), und AuditReader kann ihn ausgeben. Der direkteste Weg ist reader.getRevisionDate(revision), der ein java.util.Date zurückgibt (ja, etwas Retro, aber das ist in Ordnung: Hibernate muss nicht immer auf der Höhe der java.time Mode sein).

In der Praxis wandeln wir das fast immer sofort in Instant um, um dann in der Welt von java.time zu leben.

Minibeispiel:

import java.time.Instant;
import java.util.Date;
import org.hibernate.envers.AuditReader;

public Instant revisionInstant(AuditReader reader, Number revision) {
    // Hibernate/Envers gibt java.util.Date zurück, daher wird meist sofort in Instant konvertiert
    Date date = reader.getRevisionDate(revision);
    return date.toInstant();
}

Wenn Sie Zeilen als Object[] über forRevisionsOfEntity(..., false, ...) erhalten, liegt in der zweiten Zelle normalerweise DefaultRevisionEntity. Daraus können Sie ID und Timestamp entnehmen:

import java.time.Instant;
import org.hibernate.envers.DefaultRevisionEntity;

public Instant revisionInstant(DefaultRevisionEntity rev) {
    // Timestamp wird in Millisekunden seit der Epoche gespeichert
    return Instant.ofEpochMilli(rev.getTimestamp());
}

Das ist praktischer, wenn Sie eine Ereignisliste aufbauen und nicht für jede Revisionsnummer einen separaten Aufruf von getRevisionDate(...) machen möchten.

Einfaches Modell einer Verlaufszeile

Rohe Object[] sind gut als „Überprüfung, dass Envers funktioniert“. Aber in echtem Code (selbst in Lehrbeispielen) sollte man sie lieber schnell in eine verständliche Struktur umwandeln, sonst lebt die Historie in Ihrem Projekt als Index-Magie: row[0], row[1], row[2]. Das führt fast zwangsläufig zu Fehlern, besonders wenn Sie nach einer Woche auf den Code zurückblicken und sich fragen: „Was war row[1]? Und warum ist es kein Long?“

Führen wir ein minimales Modell für eine Verlaufszeile ein. In Java 25 ist es sehr praktisch, einen record zu verwenden:

import java.time.Instant;
import org.hibernate.envers.RevisionType;

public record AuditRevisionRow<T>(
        // Snapshot der Entität zu einer bestimmten Revision (aus *_AUD)
        T entitySnapshot,
        // Envers-Revisionsnummer (normalerweise REVINFO.id)
        int revision,
        // Änderungstyp: ADD / MOD / DEL
        RevisionType revisionType,
        // Zeit der Revision, umgewandelt in java.time
        Instant revisionAt
) {}

Und jetzt – ein kleiner Mapper von Object[] zu AuditRevisionRow<Product>. Wichtig: Hier gehen wir von der Standard-Envers-Konfiguration aus, bei der die Revision-Entity DefaultRevisionEntity ist.

import java.time.Instant;
import org.hibernate.envers.DefaultRevisionEntity;
import org.hibernate.envers.RevisionType;

public AuditRevisionRow<com.example.commerce.catalog.entity.Product> mapProductRow(Object[] row) {
    // row[0] – Snapshot der Entität zur Revision (Product)
    var snapshot = (com.example.commerce.catalog.entity.Product) row[0];

    // row[1] – Revision-Entity (Metadaten der Revision: id und timestamp)
    var revEntity = (DefaultRevisionEntity) row[1];

    // row[2] – Änderungstyp (ADD/MOD/DEL)
    var revType = (RevisionType) row[2];

    // Erstelle ein vernünftiges typisiertes Modell statt „Index-Magie“
    return new AuditRevisionRow<>(
            snapshot,
            revEntity.getId(),
            revType,
            Instant.ofEpochMilli(revEntity.getTimestamp())
    );
}

Technisch gesehen sieht das nach „unnötigen Bewegungen“ aus. Praktisch ist es eine Nervenersparnis: Im weiteren Code arbeiten Sie mit revisionAt und revisionType, nicht mit rätselhaften Array-Indizes.

Verhaltensnuancen von Revisionen

Envers zeichnet eine Revision pro Transaktion auf, nicht für jede „Bewegung“ einer Entität innerhalb einer Methode. Das bedeutet: Wenn Sie in einer @Transactional-Methode den Preis eines Produkts dreimal geändert haben (100 → 110 → 105), sehen Sie in der Historie nicht drei Ereignisse, sondern nur eines – den endgültigen Zustand zum Zeitpunkt des Transaktionscommits. Für die Historie ist das logisch: Von außen ist das Endergebnis wichtig, nicht wie Sie während der Codeausführung dorthin gelangt sind. Wenn Sie ein „Intra-Transaktionslog“ benötigen, ist das eine separate Aufgabenklasse.

Eine weitere wichtige Nuance: Die Revision ist global. Wenn Sie in einer Transaktion sowohl ein Produkt als auch eine Bestellung geändert haben, erhalten beide Audit-Tabellen Zeilen mit derselben Revisionsnummer. Das ist für die praktische Interpretation sehr nützlich: Sie können Änderungen in „einem Geschäftsereignis“ verknüpfen. Aber es ist auch der Grund, warum Revisionen einer bestimmten Entität „lückenhaft“ aussehen (z.B. 5, 9, 14): Dazwischen können Revisionen anderer Entitäten liegen.

Und schließlich: Denken Sie an die Verknüpfung mit Soft Delete aus dem vorherigen Tag. Wenn „Löschen“ als product.deleted=true implementiert ist, wird Envers dies als MOD sehen, weil es aus Sicht der Datenbank ein UPDATE ist. DEL erscheint eher nur bei physischem DELETE (falls Sie das bei audited-Entitäten überhaupt tun).

5. Typische Fehler beim Lesen der Historie über AuditReader

Fast alle Probleme beim Lesen der Envers-Historie entstehen nicht durch die „Komplexität von Envers“, sondern durch die Vermischung zweier Welten: des aktuellen ORM-Modells und des historischen Modells.

Fehler Nr. 1: Envers-Revision mit @Version verwechseln.
Die Revision ist eine historische Ereignisnummer, die Version ein Mechanismus zum Schutz vor Konkurrenz. Wenn ein Entwickler beginnt, sie zu vergleichen oder zu versuchen, „eine Revision anhand der Version zu finden“, klebt er im Wesentlichen zwei verschiedene Verantwortungsebenen zusammen und bekommt ein Durcheinander im Kopf und im Code.

Fehler Nr. 2: AuditReaderFactory.get(...) außerhalb einer Transaktion aufrufen und sich über seltsame Effekte wundern.
In unserem Baseline ist open-in-view=false, und das bedeutet, dass ein „lebendiger EntityManager“ kein Hintergrunddienst ist, sondern ein expliziter Teil des Designs. Das Lesen der Historie ist normales Lesen aus der Datenbank, nur über andere Tabellen, daher wird eine Transaktion zumindest für die Vorhersagbarkeit der Initialisierung und der Arbeit mit dem EntityManager benötigt.

Fehler Nr. 3: List<Object[]> als Ergebnis eines Service nach außen geben.
Das ist verlockend, weil „es ja funktioniert“. Aber damit bauen Sie eine Zeitbombe ein: Die nächste Schicht beginnt, row[2] als String zu lesen, dann stellt sich heraus, dass es ein RevisionType ist, dann verwechselt jemand die Reihenfolge – und Sie haben einen Bug, der sehr unangenehm zu debuggen ist. Besser, Sie mappen die Historienzeilen einmal in einen verständlichen record.

Fehler Nr. 4: Versuchen, „die Vergangenheit zu bearbeiten“.
Ein historischer Snapshot ist kein Objekt für eine spätere Speicherung. Man sollte ihn wie ein Foto betrachten: Man kann es betrachten, zwei Fotos vergleichen, das Datum notieren – aber wenn Sie anfangen, dem Foto einen Schnurrbart zu malen und versuchen, es als neues Profil zurückzuladen, wird es seltsam. Im Code sieht das aus wie der Versuch, den Snapshot zu ändern und dann repository.save(snapshot) aufzurufen – und das ist fast immer ein Designfehler.

Fehler Nr. 5: Erwarten, dass getRevisions(...) „aufeinanderfolgende Revisionen“ zurückgibt.
Revisionen sind global, daher werden sie für eine einzelne Entität selten 1..N sein. Wenn Sie das nicht berücksichtigen, schreiben Sie leicht Code, der annimmt, dass „nächste Revision = aktuelle + 1“ ist, und der bricht dann an der realen Historie. Die richtige Strategie ist, der Liste der Revisionen zu folgen, die Envers zurückgibt, und sich keine eigenen Nummern auszudenken.

1
Aufgabe
Hibernate deep-dive, Level 21, Lektion 3
Gesperrt
Liste der Produktrevisionen mittels `AuditReader`
Liste der Produktrevisionen mittels `AuditReader`
1
Aufgabe
Hibernate deep-dive, Level 21, Lektion 3
Gesperrt
Schnappschuss des Produkts in einer bestimmten Revision
Schnappschuss des Produkts in einer bestimmten Revision
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION